@tuongaz/seeflow 0.1.101 → 0.1.103
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/web/assets/{architectureDiagram-3BPJPVTR-CHxyYMbj.js → architectureDiagram-3BPJPVTR-Bw9x8Qs3.js} +1 -1
- package/dist/web/assets/{blockDiagram-GPEHLZMM-BEFRoCwf.js → blockDiagram-GPEHLZMM-BnwpSAen.js} +1 -1
- package/dist/web/assets/{c4Diagram-AAUBKEIU-Br8d90eB.js → c4Diagram-AAUBKEIU-BVDyhOJ3.js} +1 -1
- package/dist/web/assets/channel-DkLku8oP.js +1 -0
- package/dist/web/assets/{chart-BIsuELng.js → chart-BcqCvSuE.js} +1 -1
- package/dist/web/assets/{chunk-2J33WTMH-kp1HzlkG.js → chunk-2J33WTMH-nZ6teWx5.js} +1 -1
- package/dist/web/assets/{chunk-4BX2VUAB-BO5lRpcJ.js → chunk-4BX2VUAB-ldyAyOdq.js} +1 -1
- package/dist/web/assets/{chunk-55IACEB6-Brh-Cxxv.js → chunk-55IACEB6-D5jFWCVV.js} +1 -1
- package/dist/web/assets/{chunk-727SXJPM-Rng9Q1Qi.js → chunk-727SXJPM-CnsykKyK.js} +1 -1
- package/dist/web/assets/{chunk-AQP2D5EJ-64NIU-6E.js → chunk-AQP2D5EJ-Bd02cZe2.js} +1 -1
- package/dist/web/assets/{chunk-FMBD7UC4-oWQHD8HT.js → chunk-FMBD7UC4-7QaX2cZq.js} +1 -1
- package/dist/web/assets/{chunk-ND2GUHAM-CZuU1Pvz.js → chunk-ND2GUHAM-BdU63yQ1.js} +1 -1
- package/dist/web/assets/{chunk-QZHKN3VN-Dtxpr6Lp.js → chunk-QZHKN3VN-BxL7agDu.js} +1 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-BVTmCHkJ.js +1 -0
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-BVTmCHkJ.js +1 -0
- package/dist/web/assets/{code-block-CXyix7T4.js → code-block-BoWNyy3K.js} +1 -1
- package/dist/web/assets/{cose-bilkent-S5V4N54A-Dy3n6Pzg.js → cose-bilkent-S5V4N54A-Do7Ahxc1.js} +1 -1
- package/dist/web/assets/{dagre-BM42HDAG--dGIcj7p.js → dagre-BM42HDAG-Da7WeKs9.js} +1 -1
- package/dist/web/assets/{diagram-2AECGRRQ-Dt21gwWu.js → diagram-2AECGRRQ-vzafP_ZS.js} +1 -1
- package/dist/web/assets/{diagram-5GNKFQAL-Dopd3jNv.js → diagram-5GNKFQAL-n-IXhudG.js} +1 -1
- package/dist/web/assets/{diagram-KO2AKTUF-CJEAQsDF.js → diagram-KO2AKTUF-NGDeB_YZ.js} +1 -1
- package/dist/web/assets/{diagram-LMA3HP47-CfKPAvzE.js → diagram-LMA3HP47-CF8wirl0.js} +1 -1
- package/dist/web/assets/{diagram-OG6HWLK6-C9HTQiVv.js → diagram-OG6HWLK6-BnRo_bhI.js} +1 -1
- package/dist/web/assets/{erDiagram-TEJ5UH35-w6pYFBfQ.js → erDiagram-TEJ5UH35-C6izH2L9.js} +1 -1
- package/dist/web/assets/{flowDiagram-I6XJVG4X-Cfb85Fie.js → flowDiagram-I6XJVG4X-D7QJkdLn.js} +1 -1
- package/dist/web/assets/{ganttDiagram-6RSMTGT7-Dyps7xvy.js → ganttDiagram-6RSMTGT7-CHMbzxxN.js} +1 -1
- package/dist/web/assets/{gitGraphDiagram-PVQCEYII-f4JBeh_-.js → gitGraphDiagram-PVQCEYII-DLLTMloz.js} +1 -1
- package/dist/web/assets/{iconify-DP0e3Q9S.js → iconify-B6i1sS6t.js} +1 -1
- package/dist/web/assets/{index-DZK24AXs.js → index-D0y4g-ET.js} +1743 -1743
- package/dist/web/assets/{index.es-DFitEGBR.js → index.es-DpqOAO37.js} +1 -1
- package/dist/web/assets/{infoDiagram-5YYISTIA-DjJb_lcW.js → infoDiagram-5YYISTIA-DFC0_4VT.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-D-E7Iit6.js → ishikawaDiagram-YF4QCWOH-BUNJEEXj.js} +1 -1
- package/dist/web/assets/{journeyDiagram-JHISSGLW-CtwLAgCl.js → journeyDiagram-JHISSGLW-WpYiKdO4.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-CP3g_qxA.js → jspdf.es.min-BUpN05U5.js} +3 -3
- package/dist/web/assets/{kanban-definition-UN3LZRKU-bx6zf7oK.js → kanban-definition-UN3LZRKU-C3YKo72r.js} +1 -1
- package/dist/web/assets/{linear-BotE_b7Y.js → linear-CNjCwNlO.js} +1 -1
- package/dist/web/assets/{markdown-Dw6v_KEB.js → markdown-OxD7kcm2.js} +1 -1
- package/dist/web/assets/{mermaid.core-DWCabHOC.js → mermaid.core-BMY_XaVJ.js} +4 -4
- package/dist/web/assets/{mindmap-definition-RKZ34NQL-HStLvyzJ.js → mindmap-definition-RKZ34NQL-L231FEYw.js} +1 -1
- package/dist/web/assets/{pieDiagram-4H26LBE5-BulJN0Sj.js → pieDiagram-4H26LBE5-XQJ6ZE8g.js} +1 -1
- package/dist/web/assets/{quadrantDiagram-W4KKPZXB-Bb4MvZWK.js → quadrantDiagram-W4KKPZXB-D2YS3TIj.js} +1 -1
- package/dist/web/assets/{requirementDiagram-4Y6WPE33-CQYEBtzI.js → requirementDiagram-4Y6WPE33-DlP1wS6w.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-5OEKKPKP-D8YtIq7m.js → sankeyDiagram-5OEKKPKP-CZKS5wN2.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-3UESZ5HK-KrSL_EE_.js → sequenceDiagram-3UESZ5HK-Cqzp79DP.js} +1 -1
- package/dist/web/assets/{stateDiagram-AJRCARHV-kYBSHT6F.js → stateDiagram-AJRCARHV-kHUSgVSA.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU-DumcoRrq.js +1 -0
- package/dist/web/assets/{time-DmpATWGU.js → time-CN9gB7W3.js} +1 -1
- package/dist/web/assets/{timeline-definition-PNZ67QCA-BAMgt9mr.js → timeline-definition-PNZ67QCA-DNgz91eH.js} +1 -1
- package/dist/web/assets/{vennDiagram-CIIHVFJN-QVTz6kMx.js → vennDiagram-CIIHVFJN-DKcizV3B.js} +1 -1
- package/dist/web/assets/{wardley-L42UT6IY-CVpJdoa4.js → wardley-L42UT6IY-raPmt_do.js} +1 -1
- package/dist/web/assets/{wardleyDiagram-YWT4CUSO-DThuC68t.js → wardleyDiagram-YWT4CUSO-B065t95m.js} +1 -1
- package/dist/web/assets/{xychartDiagram-2RQKCTM6-CURdkufb.js → xychartDiagram-2RQKCTM6-jufOp8SY.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/src/api.ts +241 -0
- package/src/atomic-write.ts +1 -1
- package/src/server.ts +19 -0
- package/src/share/sse-frame.ts +85 -0
- package/src/share/sse-outbound-queue.ts +173 -0
- package/src/share/sse-rate-limit.ts +205 -0
- package/src/share/sse-tap.ts +183 -0
- package/src/share-audit.ts +267 -0
- package/src/share-envelope.ts +152 -0
- package/src/share-file-request.ts +353 -0
- package/src/share-file-resolver.ts +68 -0
- package/src/share-file-upload.ts +595 -0
- package/src/share-files-manifest.ts +232 -0
- package/src/share-ratelimit.ts +69 -0
- package/src/share-rpc-schema.ts +249 -0
- package/src/share-transport.ts +205 -0
- package/src/share.ts +1561 -0
- package/dist/web/assets/channel-Brt3AMYa.js +0 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-DzRSPB2q.js +0 -1
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-DzRSPB2q.js +0 -1
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU-CdRvxYCb.js +0 -1
package/src/api.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
} from './schema-catalog.ts';
|
|
48
48
|
import type { ComponentAction, SeeflowManifest } from './schema.ts';
|
|
49
49
|
import { FlowIdPattern, FlowSchema, ResolvedFlowSchema } from './schema.ts';
|
|
50
|
+
import type { AttributionEvent, ShareController, ShareState } from './share.ts';
|
|
50
51
|
import { type Spawner, defaultSpawner } from './shellout.ts';
|
|
51
52
|
import { ID_TYPES, MAX_ID_COUNT, generateIds, isIdType } from './short-id.ts';
|
|
52
53
|
import type { StatusRunner } from './status-runner.ts';
|
|
@@ -236,8 +237,14 @@ export interface ApiOptions {
|
|
|
236
237
|
/** Override the icon installer fetcher. Production uses fetchWithProgress;
|
|
237
238
|
* integration tests inject a fixture-returning closure. */
|
|
238
239
|
iconFetcher?: IconFetcher;
|
|
240
|
+
/** Live Share controller. The studio bootstrap instantiates one per process
|
|
241
|
+
* (see server.ts); tests inject a fake to drive the /api/share/* routes
|
|
242
|
+
* without touching the relay or a real WebSocket. */
|
|
243
|
+
share?: ShareController;
|
|
239
244
|
}
|
|
240
245
|
|
|
246
|
+
const KickBodySchema = z.object({ peerId: z.string().min(1) });
|
|
247
|
+
|
|
241
248
|
/**
|
|
242
249
|
* Thin call-through wrapper around the proxy.ts module exports. Lets tests
|
|
243
250
|
* inject a recording fake to observe runPlay invocations — the play-run map
|
|
@@ -2090,6 +2097,240 @@ export function createApi(options: ApiOptions): Hono {
|
|
|
2090
2097
|
});
|
|
2091
2098
|
});
|
|
2092
2099
|
|
|
2100
|
+
// Live Share local API. Five routes that delegate to the injected
|
|
2101
|
+
// ShareController. The controller is optional — when absent (e.g. tests
|
|
2102
|
+
// that don't exercise share) every endpoint returns 503 so misconfigured
|
|
2103
|
+
// deployments fail visibly instead of silently 404ing.
|
|
2104
|
+
const share = options.share;
|
|
2105
|
+
|
|
2106
|
+
// Map a controller rejection to an HTTP status + error body. share-not-active
|
|
2107
|
+
// and share-peer-not-found are the two domain-specific reasons; everything
|
|
2108
|
+
// else surfaces as 500 with the raw message so misconfigurations (bad relay
|
|
2109
|
+
// URL, network failures, etc.) are debuggable from the response.
|
|
2110
|
+
const shareErrorStatus = (err: unknown): { status: 400 | 404 | 409 | 500; error: string } => {
|
|
2111
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2112
|
+
if (msg === 'share-not-active') return { status: 409, error: msg };
|
|
2113
|
+
if (msg === 'share-peer-not-found') return { status: 404, error: msg };
|
|
2114
|
+
if (msg === 'share-already-active') return { status: 409, error: msg };
|
|
2115
|
+
return { status: 500, error: msg };
|
|
2116
|
+
};
|
|
2117
|
+
|
|
2118
|
+
api.post('/share/start', async (c) => {
|
|
2119
|
+
if (!share) return c.json({ error: 'share controller not configured' }, 503);
|
|
2120
|
+
try {
|
|
2121
|
+
const result = await share.start();
|
|
2122
|
+
return c.json(result);
|
|
2123
|
+
} catch (err) {
|
|
2124
|
+
const mapped = shareErrorStatus(err);
|
|
2125
|
+
return c.json({ error: mapped.error }, mapped.status);
|
|
2126
|
+
}
|
|
2127
|
+
});
|
|
2128
|
+
|
|
2129
|
+
api.post('/share/stop', async (c) => {
|
|
2130
|
+
if (!share) return c.json({ error: 'share controller not configured' }, 503);
|
|
2131
|
+
try {
|
|
2132
|
+
await share.stop();
|
|
2133
|
+
return c.body(null, 204);
|
|
2134
|
+
} catch (err) {
|
|
2135
|
+
const mapped = shareErrorStatus(err);
|
|
2136
|
+
return c.json({ error: mapped.error }, mapped.status);
|
|
2137
|
+
}
|
|
2138
|
+
});
|
|
2139
|
+
|
|
2140
|
+
api.post('/share/kick', async (c) => {
|
|
2141
|
+
if (!share) return c.json({ error: 'share controller not configured' }, 503);
|
|
2142
|
+
let body: unknown;
|
|
2143
|
+
try {
|
|
2144
|
+
body = await c.req.json();
|
|
2145
|
+
} catch {
|
|
2146
|
+
return c.json({ error: 'Body must be valid JSON' }, 400);
|
|
2147
|
+
}
|
|
2148
|
+
const parsed = KickBodySchema.safeParse(body);
|
|
2149
|
+
if (!parsed.success) {
|
|
2150
|
+
return c.json({ error: 'Invalid kick body', issues: parsed.error.issues }, 400);
|
|
2151
|
+
}
|
|
2152
|
+
try {
|
|
2153
|
+
await share.kick(parsed.data.peerId);
|
|
2154
|
+
return c.body(null, 204);
|
|
2155
|
+
} catch (err) {
|
|
2156
|
+
const mapped = shareErrorStatus(err);
|
|
2157
|
+
return c.json({ error: mapped.error }, mapped.status);
|
|
2158
|
+
}
|
|
2159
|
+
});
|
|
2160
|
+
|
|
2161
|
+
api.post('/share/rotate', async (c) => {
|
|
2162
|
+
if (!share) return c.json({ error: 'share controller not configured' }, 503);
|
|
2163
|
+
try {
|
|
2164
|
+
const result = await share.rotateUrl();
|
|
2165
|
+
return c.json(result);
|
|
2166
|
+
} catch (err) {
|
|
2167
|
+
const mapped = shareErrorStatus(err);
|
|
2168
|
+
return c.json({ error: mapped.error }, mapped.status);
|
|
2169
|
+
}
|
|
2170
|
+
});
|
|
2171
|
+
|
|
2172
|
+
// POST /api/share/kill-all — host kill-switch (US-081). Revokes every
|
|
2173
|
+
// session this studio has tracked in `active.json`, not just the active
|
|
2174
|
+
// one. Local-only by the cors.ts middleware. Returns the counts surfaced
|
|
2175
|
+
// by the controller so the UI toast can show "Ended N live sessions".
|
|
2176
|
+
api.post('/share/kill-all', async (c) => {
|
|
2177
|
+
if (!share) return c.json({ error: 'share controller not configured' }, 503);
|
|
2178
|
+
try {
|
|
2179
|
+
const result = await share.killAll();
|
|
2180
|
+
return c.json(result);
|
|
2181
|
+
} catch (err) {
|
|
2182
|
+
const mapped = shareErrorStatus(err);
|
|
2183
|
+
return c.json({ error: mapped.error }, mapped.status);
|
|
2184
|
+
}
|
|
2185
|
+
});
|
|
2186
|
+
|
|
2187
|
+
// GET /api/share/audit — page through the per-session AuditEntry JSONL log.
|
|
2188
|
+
// Local-only by virtue of the cors.ts middleware. Requires an active session
|
|
2189
|
+
// — when the controller is idle there is no logger to read from, so respond
|
|
2190
|
+
// 400 rather than silently returning an empty page (consumers can use the
|
|
2191
|
+
// status code to distinguish "no entries yet" from "no session").
|
|
2192
|
+
api.get('/share/audit', async (c) => {
|
|
2193
|
+
if (!share) return c.json({ error: 'share controller not configured' }, 503);
|
|
2194
|
+
if (share.state().status !== 'active') {
|
|
2195
|
+
return c.json({ error: 'share-not-active' }, 400);
|
|
2196
|
+
}
|
|
2197
|
+
const limitRaw = c.req.query('limit');
|
|
2198
|
+
const cursorRaw = c.req.query('cursor');
|
|
2199
|
+
const limit = limitRaw !== undefined ? Number.parseInt(limitRaw, 10) : undefined;
|
|
2200
|
+
const cursor = cursorRaw !== undefined ? Number.parseInt(cursorRaw, 10) : undefined;
|
|
2201
|
+
const opts: { limit?: number; cursor?: number } = {};
|
|
2202
|
+
if (typeof limit === 'number' && Number.isFinite(limit)) opts.limit = limit;
|
|
2203
|
+
if (typeof cursor === 'number' && Number.isFinite(cursor)) opts.cursor = cursor;
|
|
2204
|
+
try {
|
|
2205
|
+
const page = await share.audit.list(opts);
|
|
2206
|
+
return c.json(page);
|
|
2207
|
+
} catch (err) {
|
|
2208
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2209
|
+
return c.json({ error: message }, 500);
|
|
2210
|
+
}
|
|
2211
|
+
});
|
|
2212
|
+
|
|
2213
|
+
// GET /api/share/state — SSE stream of ShareState transitions. Initial frame
|
|
2214
|
+
// is the current state; subsequent frames are pushed on each controller
|
|
2215
|
+
// subscribe callback. Mirrors the /api/events shape (queue + wake + heartbeat)
|
|
2216
|
+
// so the UI can use the same EventSource handling.
|
|
2217
|
+
api.get('/share/state', (c) => {
|
|
2218
|
+
if (!share) return c.json({ error: 'share controller not configured' }, 503);
|
|
2219
|
+
const controller = share;
|
|
2220
|
+
|
|
2221
|
+
return streamSSE(c, async (stream) => {
|
|
2222
|
+
let active = true;
|
|
2223
|
+
const queue: Array<{ event: string; data: string }> = [];
|
|
2224
|
+
let resume: (() => void) | null = null;
|
|
2225
|
+
|
|
2226
|
+
const wake = () => {
|
|
2227
|
+
if (resume) {
|
|
2228
|
+
const r = resume;
|
|
2229
|
+
resume = null;
|
|
2230
|
+
r();
|
|
2231
|
+
}
|
|
2232
|
+
};
|
|
2233
|
+
|
|
2234
|
+
const enqueue = (s: ShareState) => {
|
|
2235
|
+
queue.push({ event: 'state', data: JSON.stringify(s) });
|
|
2236
|
+
wake();
|
|
2237
|
+
};
|
|
2238
|
+
|
|
2239
|
+
// subscribe() invokes fn synchronously with the current state, so the
|
|
2240
|
+
// initial frame lands in the queue automatically — no explicit prologue.
|
|
2241
|
+
const unsubscribe = controller.subscribe(enqueue);
|
|
2242
|
+
|
|
2243
|
+
stream.onAbort(() => {
|
|
2244
|
+
active = false;
|
|
2245
|
+
unsubscribe();
|
|
2246
|
+
wake();
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
try {
|
|
2250
|
+
while (active) {
|
|
2251
|
+
while (queue.length > 0) {
|
|
2252
|
+
const next = queue.shift();
|
|
2253
|
+
if (!next) break;
|
|
2254
|
+
await stream.writeSSE(next);
|
|
2255
|
+
}
|
|
2256
|
+
if (!active) break;
|
|
2257
|
+
let heartbeat: ReturnType<typeof setTimeout> | null = null;
|
|
2258
|
+
const reason = await new Promise<'event' | 'heartbeat'>((r) => {
|
|
2259
|
+
resume = () => r('event');
|
|
2260
|
+
heartbeat = setTimeout(() => r('heartbeat'), SSE_HEARTBEAT_MS);
|
|
2261
|
+
});
|
|
2262
|
+
if (heartbeat) clearTimeout(heartbeat);
|
|
2263
|
+
resume = null;
|
|
2264
|
+
if (reason === 'heartbeat' && active) {
|
|
2265
|
+
await stream.write(': ping\n\n');
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
} finally {
|
|
2269
|
+
unsubscribe();
|
|
2270
|
+
}
|
|
2271
|
+
});
|
|
2272
|
+
});
|
|
2273
|
+
|
|
2274
|
+
// GET /api/share/attributions — SSE stream of `node-patched` attribution
|
|
2275
|
+
// events for the host studio's apps/web UI (US-053). Fires once per accepted
|
|
2276
|
+
// op (peer-originated AND host-originated), carrying `{flowId, op, diff,
|
|
2277
|
+
// version, attributedTo, ts}`. No initial replay — the toast stack is for
|
|
2278
|
+
// live activity only.
|
|
2279
|
+
api.get('/share/attributions', (c) => {
|
|
2280
|
+
if (!share) return c.json({ error: 'share controller not configured' }, 503);
|
|
2281
|
+
const controller = share;
|
|
2282
|
+
|
|
2283
|
+
return streamSSE(c, async (stream) => {
|
|
2284
|
+
let active = true;
|
|
2285
|
+
const queue: Array<{ event: string; data: string }> = [];
|
|
2286
|
+
let resume: (() => void) | null = null;
|
|
2287
|
+
|
|
2288
|
+
const wake = () => {
|
|
2289
|
+
if (resume) {
|
|
2290
|
+
const r = resume;
|
|
2291
|
+
resume = null;
|
|
2292
|
+
r();
|
|
2293
|
+
}
|
|
2294
|
+
};
|
|
2295
|
+
|
|
2296
|
+
const enqueue = (event: AttributionEvent) => {
|
|
2297
|
+
queue.push({ event: 'attribution', data: JSON.stringify(event) });
|
|
2298
|
+
wake();
|
|
2299
|
+
};
|
|
2300
|
+
|
|
2301
|
+
const unsubscribe = controller.subscribeAttributions(enqueue);
|
|
2302
|
+
|
|
2303
|
+
stream.onAbort(() => {
|
|
2304
|
+
active = false;
|
|
2305
|
+
unsubscribe();
|
|
2306
|
+
wake();
|
|
2307
|
+
});
|
|
2308
|
+
|
|
2309
|
+
try {
|
|
2310
|
+
while (active) {
|
|
2311
|
+
while (queue.length > 0) {
|
|
2312
|
+
const next = queue.shift();
|
|
2313
|
+
if (!next) break;
|
|
2314
|
+
await stream.writeSSE(next);
|
|
2315
|
+
}
|
|
2316
|
+
if (!active) break;
|
|
2317
|
+
let heartbeat: ReturnType<typeof setTimeout> | null = null;
|
|
2318
|
+
const reason = await new Promise<'event' | 'heartbeat'>((r) => {
|
|
2319
|
+
resume = () => r('event');
|
|
2320
|
+
heartbeat = setTimeout(() => r('heartbeat'), SSE_HEARTBEAT_MS);
|
|
2321
|
+
});
|
|
2322
|
+
if (heartbeat) clearTimeout(heartbeat);
|
|
2323
|
+
resume = null;
|
|
2324
|
+
if (reason === 'heartbeat' && active) {
|
|
2325
|
+
await stream.write(': ping\n\n');
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
} finally {
|
|
2329
|
+
unsubscribe();
|
|
2330
|
+
}
|
|
2331
|
+
});
|
|
2332
|
+
});
|
|
2333
|
+
|
|
2093
2334
|
// Global registry channel — broadcasts `registry:reload` when an external
|
|
2094
2335
|
// process (e.g. the CLI) writes to ~/.seeflow/registry.json. Subscribers
|
|
2095
2336
|
// re-fetch the flow list. The channel id is the internal sentinel from
|
package/src/atomic-write.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
2
|
|
|
3
|
-
export const writeFileAtomic = (filePath: string, content: string): void => {
|
|
3
|
+
export const writeFileAtomic = (filePath: string, content: string | Uint8Array): void => {
|
|
4
4
|
const tempPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
|
|
5
5
|
try {
|
|
6
6
|
writeFileSync(tempPath, content);
|
package/src/server.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { seeflowHome } from './paths.ts';
|
|
|
14
14
|
import { type ProcessSpawner, defaultProcessSpawner } from './process-spawner.ts';
|
|
15
15
|
import { type RegistryWatcher, createRegistryWatcher } from './registry-watcher.ts';
|
|
16
16
|
import { type Registry, createRegistry, manifestOnlyEntryFilter } from './registry.ts';
|
|
17
|
+
import { type ShareController, createShareController, resolveHostDisplayName } from './share.ts';
|
|
17
18
|
import type { Spawner } from './shellout.ts';
|
|
18
19
|
import { type StatusRunner, createStatusRunner } from './status-runner.ts';
|
|
19
20
|
import { type FlowWatcher, createWatcher } from './watcher.ts';
|
|
@@ -79,8 +80,16 @@ export interface CreateAppOptions {
|
|
|
79
80
|
/** Override the icon installer's fetcher. Production uses fetchWithProgress
|
|
80
81
|
* (real network); integration tests inject a fixture-returning closure. */
|
|
81
82
|
iconFetcher?: IconFetcher;
|
|
83
|
+
/** Inject a Live Share controller. Defaults to one pointed at
|
|
84
|
+
* https://seeflow.dev (SEEFLOW_SHARE_RELAY_URL) with share URLs rooted at
|
|
85
|
+
* https://share.seeflow.dev (SEEFLOW_SHARE_URL_BASE). Tests inject a fake
|
|
86
|
+
* to exercise the /api/share/* routes without a real relay. */
|
|
87
|
+
share?: ShareController;
|
|
82
88
|
}
|
|
83
89
|
|
|
90
|
+
const DEFAULT_SHARE_RELAY_URL = 'https://seeflow.dev';
|
|
91
|
+
const DEFAULT_SHARE_URL_BASE = 'https://share.seeflow.dev';
|
|
92
|
+
|
|
84
93
|
const DEFAULT_VITE_DEV_URL = 'http://localhost:5173';
|
|
85
94
|
const DEFAULT_STATIC_ROOT = resolvePath(import.meta.dir, '../dist/web');
|
|
86
95
|
|
|
@@ -108,6 +117,15 @@ export function createApp(options: CreateAppOptions = {}): Hono {
|
|
|
108
117
|
options.statusRunner ??
|
|
109
118
|
createStatusRunner({ registry, events, spawner: defaultProcessSpawner });
|
|
110
119
|
const iconJobs = options.iconJobs ?? createJobRegistry();
|
|
120
|
+
const share =
|
|
121
|
+
options.share ??
|
|
122
|
+
createShareController({
|
|
123
|
+
relayHttpUrl: process.env.SEEFLOW_SHARE_RELAY_URL ?? DEFAULT_SHARE_RELAY_URL,
|
|
124
|
+
shareUrlBase: process.env.SEEFLOW_SHARE_URL_BASE ?? DEFAULT_SHARE_URL_BASE,
|
|
125
|
+
eventBus: events,
|
|
126
|
+
flowIdsForBroadcast: () => registry.list().map((e) => e.id),
|
|
127
|
+
hostDisplayName: resolveHostDisplayName(),
|
|
128
|
+
});
|
|
111
129
|
|
|
112
130
|
if (watcher && (options.watchAllOnBoot ?? true)) {
|
|
113
131
|
watcher.watchAll();
|
|
@@ -171,6 +189,7 @@ export function createApp(options: CreateAppOptions = {}): Hono {
|
|
|
171
189
|
iconJobs,
|
|
172
190
|
iconCacheRoot: options.iconCacheRoot,
|
|
173
191
|
iconFetcher: options.iconFetcher,
|
|
192
|
+
share,
|
|
174
193
|
}),
|
|
175
194
|
);
|
|
176
195
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE bridge envelope payload schema for live-share `sse` frames.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for the wire shape of runtime events relayed from
|
|
5
|
+
* the host studio's local EventBus to peers over the share WebSocket. Both
|
|
6
|
+
* sides parse against the same Zod schema; drift between this file and the
|
|
7
|
+
* peer SPA's mirror (`seeflow-viewer/src/lib/share-sse-frame.ts`) is gated
|
|
8
|
+
* by `apps/studio/scripts/check-sse-frame-sync.ts`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import type { StudioEvent } from '../events.ts';
|
|
13
|
+
|
|
14
|
+
// SYNC-WITH-PEER:BEGIN
|
|
15
|
+
export const SSE_EVENT_TYPES = [
|
|
16
|
+
'flow:reload',
|
|
17
|
+
'node:running',
|
|
18
|
+
'node:done',
|
|
19
|
+
'node:error',
|
|
20
|
+
'node:status',
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
export const SseEventTypeSchema = z.enum(SSE_EVENT_TYPES);
|
|
24
|
+
export type SseEventType = z.infer<typeof SseEventTypeSchema>;
|
|
25
|
+
|
|
26
|
+
export const SsePayloadSchema = z.object({
|
|
27
|
+
t: SseEventTypeSchema,
|
|
28
|
+
flowId: z.string().min(1),
|
|
29
|
+
ts: z.number().int().nonnegative(),
|
|
30
|
+
data: z.unknown(),
|
|
31
|
+
seq: z.number().int().nonnegative(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export type SsePayload = z.infer<typeof SsePayloadSchema>;
|
|
35
|
+
|
|
36
|
+
export interface SseEnvelope {
|
|
37
|
+
v: 1;
|
|
38
|
+
type: 'sse';
|
|
39
|
+
from: 'host';
|
|
40
|
+
to: 'all';
|
|
41
|
+
payload: SsePayload;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isSseEventType(t: string): t is SseEventType {
|
|
45
|
+
return (SSE_EVENT_TYPES as readonly string[]).includes(t);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Snapshot replay payload sent to a freshly-joined peer so its canvas badges
|
|
50
|
+
* match the host without waiting for the next live tick. `flows` is a 2-level
|
|
51
|
+
* map of flowId -> nodeId -> latest SsePayload observed by the host's tap.
|
|
52
|
+
* When the serialized snapshot exceeds the 256 KB per-frame cap, the host
|
|
53
|
+
* splits per-flow and stamps each frame with `chunk` (zero-based) + `total`
|
|
54
|
+
* so the peer can reassemble before applying.
|
|
55
|
+
*/
|
|
56
|
+
export const SseSnapshotPayloadSchema = z.object({
|
|
57
|
+
flows: z.record(z.string(), z.record(z.string(), SsePayloadSchema)),
|
|
58
|
+
chunk: z.number().int().nonnegative().optional(),
|
|
59
|
+
total: z.number().int().positive().optional(),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export type SseSnapshotPayload = z.infer<typeof SseSnapshotPayloadSchema>;
|
|
63
|
+
// SYNC-WITH-PEER:END
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Wrap a local StudioEvent into a relay-shaped `sse` envelope. Returns null
|
|
67
|
+
* when the event type is not one of the SSE-bridged kinds (`file:changed`,
|
|
68
|
+
* `registry:reload`) so callers can filter without try/catch.
|
|
69
|
+
*/
|
|
70
|
+
export function wrapAsSseFrame(event: StudioEvent, seq: number): SseEnvelope | null {
|
|
71
|
+
if (!isSseEventType(event.type)) return null;
|
|
72
|
+
return {
|
|
73
|
+
v: 1,
|
|
74
|
+
type: 'sse',
|
|
75
|
+
from: 'host',
|
|
76
|
+
to: 'all',
|
|
77
|
+
payload: {
|
|
78
|
+
t: event.type,
|
|
79
|
+
flowId: event.flowId,
|
|
80
|
+
ts: event.ts,
|
|
81
|
+
data: event.payload,
|
|
82
|
+
seq,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* US-072 — Per-peer outbound SSE queue with drop-on-slow-consumer.
|
|
3
|
+
*
|
|
4
|
+
* A bounded async queue, one instance per connected peer, that decouples the
|
|
5
|
+
* host's SSE bridge (synchronous EventBus -> tap -> per-peer enqueue) from the
|
|
6
|
+
* relay WebSocket's send path. If a peer's drain is slow, frames accumulate up
|
|
7
|
+
* to `maxFrames` (default 256). On overflow:
|
|
8
|
+
*
|
|
9
|
+
* - Non-terminal new frame: evict the OLDEST non-terminal entry. If no
|
|
10
|
+
* non-terminal entries exist (queue full of terminals), drop the new one.
|
|
11
|
+
* - Terminal new frame (`node:done` / `node:error`): evict the OLDEST entry
|
|
12
|
+
* regardless of type so the terminal is always queued.
|
|
13
|
+
*
|
|
14
|
+
* Per-peer metrics (`queueDepth`, `droppedFrames`, `lastSendMs`) are surfaced
|
|
15
|
+
* to the LiveShareDialog via the local `/api/share/state` SSE stream. When the
|
|
16
|
+
* rolling 60s drop count exceeds 100, the queue fires `onResyncTriggered` so
|
|
17
|
+
* the host can emit an `sse-snapshot` to recover the peer's canvas state.
|
|
18
|
+
*
|
|
19
|
+
* Enqueue is synchronous and non-blocking; the producer (the SSE tap) never
|
|
20
|
+
* awaits the WebSocket send. The drain runs as a single concurrent async loop
|
|
21
|
+
* — at most one in-flight send per peer keeps frame ordering stable while
|
|
22
|
+
* still letting different peers' queues drain in parallel.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { SsePayload } from './sse-frame.ts';
|
|
26
|
+
|
|
27
|
+
const TERMINAL_TYPES = new Set<SsePayload['t']>(['node:done', 'node:error']);
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_MAX_FRAMES = 256;
|
|
30
|
+
export const DEFAULT_DROP_RESYNC_THRESHOLD = 100;
|
|
31
|
+
export const DEFAULT_DROP_RESYNC_WINDOW_MS = 60_000;
|
|
32
|
+
|
|
33
|
+
const isTerminalPayload = (p: SsePayload): boolean => TERMINAL_TYPES.has(p.t);
|
|
34
|
+
|
|
35
|
+
export interface PeerSseQueueMetrics {
|
|
36
|
+
/** Frames currently waiting to be sent (excludes the in-flight frame). */
|
|
37
|
+
queueDepth: number;
|
|
38
|
+
/** Lifetime count of frames evicted due to overflow. */
|
|
39
|
+
droppedFrames: number;
|
|
40
|
+
/** Duration in ms of the last awaited `send` call (null until first send). */
|
|
41
|
+
lastSendMs: number | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PeerSseQueueOpts {
|
|
45
|
+
peerConnId: string;
|
|
46
|
+
/**
|
|
47
|
+
* Underlying send for the queue. Awaited per-frame so slow consumers create
|
|
48
|
+
* backpressure here rather than in the producer. Resolves on success; any
|
|
49
|
+
* error is swallowed (logged) so a single bad frame doesn't stall the drain.
|
|
50
|
+
*/
|
|
51
|
+
send: (payload: SsePayload, peerConnId: string) => Promise<void> | void;
|
|
52
|
+
/** Defaults to 256. */
|
|
53
|
+
maxFrames?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Called when `droppedFrames` within `dropResyncWindowMs` exceeds
|
|
56
|
+
* `dropResyncThreshold`. The window counter is reset after firing so the
|
|
57
|
+
* next trigger requires another full window of drops.
|
|
58
|
+
*/
|
|
59
|
+
onResyncTriggered?: () => void;
|
|
60
|
+
/** Defaults to 100. */
|
|
61
|
+
dropResyncThreshold?: number;
|
|
62
|
+
/** Defaults to 60_000. */
|
|
63
|
+
dropResyncWindowMs?: number;
|
|
64
|
+
/** Defaults to `Date.now`. */
|
|
65
|
+
now?: () => number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface PeerSseQueue {
|
|
69
|
+
/** Synchronously enqueue. Returns void; never awaits the underlying send. */
|
|
70
|
+
enqueue(payload: SsePayload): void;
|
|
71
|
+
metrics(): PeerSseQueueMetrics;
|
|
72
|
+
/** Stops the drain loop and discards pending frames. Idempotent. */
|
|
73
|
+
dispose(): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface QueuedFrame {
|
|
77
|
+
payload: SsePayload;
|
|
78
|
+
terminal: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createPeerSseQueue(opts: PeerSseQueueOpts): PeerSseQueue {
|
|
82
|
+
const maxFrames = opts.maxFrames ?? DEFAULT_MAX_FRAMES;
|
|
83
|
+
const dropThreshold = opts.dropResyncThreshold ?? DEFAULT_DROP_RESYNC_THRESHOLD;
|
|
84
|
+
const dropWindowMs = opts.dropResyncWindowMs ?? DEFAULT_DROP_RESYNC_WINDOW_MS;
|
|
85
|
+
const now = opts.now ?? Date.now;
|
|
86
|
+
|
|
87
|
+
const queue: QueuedFrame[] = [];
|
|
88
|
+
const dropTimestamps: number[] = [];
|
|
89
|
+
let droppedFrames = 0;
|
|
90
|
+
let lastSendMs: number | null = null;
|
|
91
|
+
let disposed = false;
|
|
92
|
+
let draining = false;
|
|
93
|
+
|
|
94
|
+
const recordDrop = (): void => {
|
|
95
|
+
droppedFrames += 1;
|
|
96
|
+
const ts = now();
|
|
97
|
+
dropTimestamps.push(ts);
|
|
98
|
+
const cutoff = ts - dropWindowMs;
|
|
99
|
+
while (dropTimestamps.length > 0 && (dropTimestamps[0] ?? 0) < cutoff) {
|
|
100
|
+
dropTimestamps.shift();
|
|
101
|
+
}
|
|
102
|
+
if (dropTimestamps.length > dropThreshold) {
|
|
103
|
+
dropTimestamps.length = 0;
|
|
104
|
+
try {
|
|
105
|
+
opts.onResyncTriggered?.();
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.warn('[share] sse-outbound onResyncTriggered threw:', err);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const drain = async (): Promise<void> => {
|
|
113
|
+
if (draining || disposed) return;
|
|
114
|
+
draining = true;
|
|
115
|
+
try {
|
|
116
|
+
while (queue.length > 0 && !disposed) {
|
|
117
|
+
const frame = queue.shift();
|
|
118
|
+
if (!frame) break;
|
|
119
|
+
const start = now();
|
|
120
|
+
try {
|
|
121
|
+
await opts.send(frame.payload, opts.peerConnId);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.warn('[share] sse-outbound send failed:', err);
|
|
124
|
+
}
|
|
125
|
+
lastSendMs = now() - start;
|
|
126
|
+
}
|
|
127
|
+
} finally {
|
|
128
|
+
draining = false;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const enqueue = (payload: SsePayload): void => {
|
|
133
|
+
if (disposed) return;
|
|
134
|
+
const terminal = isTerminalPayload(payload);
|
|
135
|
+
|
|
136
|
+
if (queue.length >= maxFrames) {
|
|
137
|
+
if (terminal) {
|
|
138
|
+
// Drop the oldest entry regardless of type so the terminal frame
|
|
139
|
+
// always finds a slot — terminal-wins semantics drive the peer's
|
|
140
|
+
// visual reconciliation back to a settled state.
|
|
141
|
+
queue.shift();
|
|
142
|
+
recordDrop();
|
|
143
|
+
} else {
|
|
144
|
+
const idx = queue.findIndex((f) => !f.terminal);
|
|
145
|
+
if (idx === -1) {
|
|
146
|
+
// Queue is full of terminals — drop the incoming non-terminal so
|
|
147
|
+
// we don't displace a settled state with a stale running tick.
|
|
148
|
+
recordDrop();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
queue.splice(idx, 1);
|
|
152
|
+
recordDrop();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
queue.push({ payload, terminal });
|
|
157
|
+
void drain();
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
enqueue,
|
|
162
|
+
metrics: (): PeerSseQueueMetrics => ({
|
|
163
|
+
queueDepth: queue.length,
|
|
164
|
+
droppedFrames,
|
|
165
|
+
lastSendMs,
|
|
166
|
+
}),
|
|
167
|
+
dispose: (): void => {
|
|
168
|
+
disposed = true;
|
|
169
|
+
queue.length = 0;
|
|
170
|
+
dropTimestamps.length = 0;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|