@tuongaz/seeflow 0.1.102 → 0.1.104

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.
Files changed (75) hide show
  1. package/dist/web/assets/{architectureDiagram-3BPJPVTR-Cy5G99GE.js → architectureDiagram-3BPJPVTR-S06hgR1x.js} +1 -1
  2. package/dist/web/assets/{blockDiagram-GPEHLZMM-B1gN1ugF.js → blockDiagram-GPEHLZMM-ZdAkZGrj.js} +1 -1
  3. package/dist/web/assets/{c4Diagram-AAUBKEIU-CYuuPtx8.js → c4Diagram-AAUBKEIU-C5MfRz02.js} +1 -1
  4. package/dist/web/assets/channel-D-VTcFiH.js +1 -0
  5. package/dist/web/assets/{chart-YfoAGRGq.js → chart-BxTNHoKY.js} +1 -1
  6. package/dist/web/assets/{chunk-2J33WTMH-B4sFED7K.js → chunk-2J33WTMH-Bn46MReV.js} +1 -1
  7. package/dist/web/assets/{chunk-4BX2VUAB-D2Pr1i6l.js → chunk-4BX2VUAB-CXHVHaLs.js} +1 -1
  8. package/dist/web/assets/{chunk-55IACEB6-B0QJVegf.js → chunk-55IACEB6-CRgBKrhO.js} +1 -1
  9. package/dist/web/assets/{chunk-727SXJPM-Dc2HoDqb.js → chunk-727SXJPM-BH62wjtB.js} +1 -1
  10. package/dist/web/assets/{chunk-AQP2D5EJ-D30cXxxk.js → chunk-AQP2D5EJ-RxGAqK2A.js} +1 -1
  11. package/dist/web/assets/{chunk-FMBD7UC4-DsgHMGgF.js → chunk-FMBD7UC4-Cb-wgtwe.js} +1 -1
  12. package/dist/web/assets/{chunk-ND2GUHAM-n6Sr5izJ.js → chunk-ND2GUHAM-CJlFQBdh.js} +1 -1
  13. package/dist/web/assets/{chunk-QZHKN3VN-BA1s2pgD.js → chunk-QZHKN3VN-DjoTSKYv.js} +1 -1
  14. package/dist/web/assets/classDiagram-4FO5ZUOK-DoPcjHFc.js +1 -0
  15. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-DoPcjHFc.js +1 -0
  16. package/dist/web/assets/{code-block-CeM4DS5T.js → code-block-DwqzBBWk.js} +1 -1
  17. package/dist/web/assets/{cose-bilkent-S5V4N54A-gJFCrqp6.js → cose-bilkent-S5V4N54A-DpGzn2ro.js} +1 -1
  18. package/dist/web/assets/{dagre-BM42HDAG-DJgtzuMT.js → dagre-BM42HDAG-CUapj-lK.js} +1 -1
  19. package/dist/web/assets/{diagram-2AECGRRQ-DfqMYNjs.js → diagram-2AECGRRQ-7ISjO-i-.js} +1 -1
  20. package/dist/web/assets/{diagram-5GNKFQAL-Bqivakd0.js → diagram-5GNKFQAL-DCPWI1Ok.js} +1 -1
  21. package/dist/web/assets/{diagram-KO2AKTUF-Cm0eKgu4.js → diagram-KO2AKTUF-zSsOIiq1.js} +1 -1
  22. package/dist/web/assets/{diagram-LMA3HP47-DCzPDQwE.js → diagram-LMA3HP47-BcqSq_nr.js} +1 -1
  23. package/dist/web/assets/{diagram-OG6HWLK6-L5YX65FQ.js → diagram-OG6HWLK6-Dw6nG7MD.js} +1 -1
  24. package/dist/web/assets/{erDiagram-TEJ5UH35-Ds6LiAGH.js → erDiagram-TEJ5UH35-B3QPOZAY.js} +1 -1
  25. package/dist/web/assets/{flowDiagram-I6XJVG4X-D50Ubn70.js → flowDiagram-I6XJVG4X-Ceu3sQuX.js} +1 -1
  26. package/dist/web/assets/{ganttDiagram-6RSMTGT7-Dkaswtst.js → ganttDiagram-6RSMTGT7-B26peYjY.js} +1 -1
  27. package/dist/web/assets/{gitGraphDiagram-PVQCEYII-OaI-_pfD.js → gitGraphDiagram-PVQCEYII-CX8DOFLm.js} +1 -1
  28. package/dist/web/assets/{iconify-MlxYu4AY.js → iconify-DAxofKes.js} +1 -1
  29. package/dist/web/assets/{index-B6U4bGj0.js → index-B7r8KKap.js} +1758 -1743
  30. package/dist/web/assets/{index.es-BNr31JLP.js → index.es-wzlW-y-I.js} +1 -1
  31. package/dist/web/assets/{infoDiagram-5YYISTIA-dIvLrSFw.js → infoDiagram-5YYISTIA-DDVBGLt8.js} +1 -1
  32. package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-LveMmZUw.js → ishikawaDiagram-YF4QCWOH-C6qCp-Zn.js} +1 -1
  33. package/dist/web/assets/{journeyDiagram-JHISSGLW-B0NT14UG.js → journeyDiagram-JHISSGLW-DX8AxLaA.js} +1 -1
  34. package/dist/web/assets/{jspdf.es.min-DIDY-jNM.js → jspdf.es.min-ykdbTFYz.js} +3 -3
  35. package/dist/web/assets/{kanban-definition-UN3LZRKU-6Ze2WLaL.js → kanban-definition-UN3LZRKU-B8-itdXZ.js} +1 -1
  36. package/dist/web/assets/{linear-lr2_gH5v.js → linear-Dqxq5R6C.js} +1 -1
  37. package/dist/web/assets/{markdown-71DgXg9K.js → markdown-D9XDrg2s.js} +1 -1
  38. package/dist/web/assets/{mermaid.core-CSskwtFd.js → mermaid.core-DujK2Rzn.js} +4 -4
  39. package/dist/web/assets/{mindmap-definition-RKZ34NQL-55BhA-Da.js → mindmap-definition-RKZ34NQL-CXa-5n8a.js} +1 -1
  40. package/dist/web/assets/{pieDiagram-4H26LBE5-CMysRCVC.js → pieDiagram-4H26LBE5-CGlnQV5E.js} +1 -1
  41. package/dist/web/assets/{quadrantDiagram-W4KKPZXB-BDfPYcaj.js → quadrantDiagram-W4KKPZXB-L_Ap8kcw.js} +1 -1
  42. package/dist/web/assets/{requirementDiagram-4Y6WPE33-CNfdldEP.js → requirementDiagram-4Y6WPE33-B2Io1TQi.js} +1 -1
  43. package/dist/web/assets/{sankeyDiagram-5OEKKPKP-C_wMGAZ1.js → sankeyDiagram-5OEKKPKP-DeARBi7s.js} +1 -1
  44. package/dist/web/assets/{sequenceDiagram-3UESZ5HK-CbGM_Yv-.js → sequenceDiagram-3UESZ5HK-BR0jm0Tk.js} +1 -1
  45. package/dist/web/assets/{stateDiagram-AJRCARHV-BCSHXjtA.js → stateDiagram-AJRCARHV-Ck_33KQ3.js} +1 -1
  46. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-D122sbeB.js +1 -0
  47. package/dist/web/assets/{time-Dm45mmVJ.js → time-DY4vPLRk.js} +1 -1
  48. package/dist/web/assets/{timeline-definition-PNZ67QCA-Cv6Hh2hS.js → timeline-definition-PNZ67QCA-DMm8bfvY.js} +1 -1
  49. package/dist/web/assets/{vennDiagram-CIIHVFJN-CNaR42oY.js → vennDiagram-CIIHVFJN-BEfqdSFT.js} +1 -1
  50. package/dist/web/assets/{wardley-L42UT6IY-BDpknWMg.js → wardley-L42UT6IY-Bmxjf0CJ.js} +1 -1
  51. package/dist/web/assets/{wardleyDiagram-YWT4CUSO-BxIDc-nV.js → wardleyDiagram-YWT4CUSO-D4ialTz3.js} +1 -1
  52. package/dist/web/assets/{xychartDiagram-2RQKCTM6-8Ir0d3kF.js → xychartDiagram-2RQKCTM6-nm_TLxlx.js} +1 -1
  53. package/dist/web/index.html +1 -1
  54. package/package.json +1 -1
  55. package/src/api.ts +241 -0
  56. package/src/atomic-write.ts +1 -1
  57. package/src/server.ts +19 -0
  58. package/src/share/sse-frame.ts +85 -0
  59. package/src/share/sse-outbound-queue.ts +173 -0
  60. package/src/share/sse-rate-limit.ts +205 -0
  61. package/src/share/sse-tap.ts +183 -0
  62. package/src/share-audit.ts +267 -0
  63. package/src/share-envelope.ts +152 -0
  64. package/src/share-file-request.ts +353 -0
  65. package/src/share-file-resolver.ts +68 -0
  66. package/src/share-file-upload.ts +595 -0
  67. package/src/share-files-manifest.ts +232 -0
  68. package/src/share-ratelimit.ts +69 -0
  69. package/src/share-rpc-schema.ts +249 -0
  70. package/src/share-transport.ts +205 -0
  71. package/src/share.ts +1561 -0
  72. package/dist/web/assets/channel-VQJmKMDU.js +0 -1
  73. package/dist/web/assets/classDiagram-4FO5ZUOK-Biiw_jnD.js +0 -1
  74. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-Biiw_jnD.js +0 -1
  75. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-Bbabof8W.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
@@ -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
+ }