@tuongaz/seeflow 0.1.109 → 0.1.111

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-CvIAMnMC.js → architectureDiagram-3BPJPVTR-C-2fCGRP.js} +1 -1
  2. package/dist/web/assets/{blockDiagram-GPEHLZMM-C55itdvA.js → blockDiagram-GPEHLZMM-Br3qZBhv.js} +1 -1
  3. package/dist/web/assets/{c4Diagram-AAUBKEIU-BUJz6OcY.js → c4Diagram-AAUBKEIU-C4uX4ZH4.js} +1 -1
  4. package/dist/web/assets/channel-qhQeh_4Q.js +1 -0
  5. package/dist/web/assets/{chart-Ck4M6xxI.js → chart-9kjHxc0V.js} +1 -1
  6. package/dist/web/assets/{chunk-2J33WTMH-CYHRe8M7.js → chunk-2J33WTMH-2Fn5NN7i.js} +1 -1
  7. package/dist/web/assets/{chunk-4BX2VUAB-0G6VpCw_.js → chunk-4BX2VUAB-Df_7ccfg.js} +1 -1
  8. package/dist/web/assets/{chunk-55IACEB6-YeyA7Efg.js → chunk-55IACEB6-DdU4dw35.js} +1 -1
  9. package/dist/web/assets/{chunk-727SXJPM-irL9oAdE.js → chunk-727SXJPM-1at9fA5f.js} +1 -1
  10. package/dist/web/assets/{chunk-AQP2D5EJ-DB0ZGTqs.js → chunk-AQP2D5EJ-CPe9M1hv.js} +1 -1
  11. package/dist/web/assets/{chunk-FMBD7UC4-Z48rYWhG.js → chunk-FMBD7UC4-DCUEmPvJ.js} +1 -1
  12. package/dist/web/assets/{chunk-ND2GUHAM-BgYSDKdi.js → chunk-ND2GUHAM-tE2Ii-jJ.js} +1 -1
  13. package/dist/web/assets/{chunk-QZHKN3VN-CzcjFmi-.js → chunk-QZHKN3VN-Db5o8Kzv.js} +1 -1
  14. package/dist/web/assets/classDiagram-4FO5ZUOK-BCHg-KrI.js +1 -0
  15. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-BCHg-KrI.js +1 -0
  16. package/dist/web/assets/{code-block-BwE8Ip6V.js → code-block-CTINSk3z.js} +1 -1
  17. package/dist/web/assets/{cose-bilkent-S5V4N54A-DtufDmle.js → cose-bilkent-S5V4N54A-D7tNx5-t.js} +1 -1
  18. package/dist/web/assets/{dagre-BM42HDAG-DwYVzLme.js → dagre-BM42HDAG-Cdb53Asb.js} +1 -1
  19. package/dist/web/assets/{diagram-2AECGRRQ-DDw_qvjI.js → diagram-2AECGRRQ-HS6pnGrL.js} +1 -1
  20. package/dist/web/assets/{diagram-5GNKFQAL-BYG2VdwF.js → diagram-5GNKFQAL-c9k4swAt.js} +1 -1
  21. package/dist/web/assets/{diagram-KO2AKTUF-Drp2zu_G.js → diagram-KO2AKTUF-CB3k4xVS.js} +1 -1
  22. package/dist/web/assets/{diagram-LMA3HP47-CXmAihfn.js → diagram-LMA3HP47-XKAfR3DB.js} +1 -1
  23. package/dist/web/assets/{diagram-OG6HWLK6-i6-iKnAU.js → diagram-OG6HWLK6-18dGr0GC.js} +1 -1
  24. package/dist/web/assets/{erDiagram-TEJ5UH35-Bo96y9hO.js → erDiagram-TEJ5UH35-9DnFE7wo.js} +1 -1
  25. package/dist/web/assets/{flowDiagram-I6XJVG4X-D9fneMTu.js → flowDiagram-I6XJVG4X-CglLanUO.js} +1 -1
  26. package/dist/web/assets/{ganttDiagram-6RSMTGT7-Cbefa8NE.js → ganttDiagram-6RSMTGT7-_zKCXbXg.js} +1 -1
  27. package/dist/web/assets/{gitGraphDiagram-PVQCEYII-BHYd5nUs.js → gitGraphDiagram-PVQCEYII-D6axSpL3.js} +1 -1
  28. package/dist/web/assets/{iconify-BuhA_8An.js → iconify-CF3Xx8Oq.js} +1 -1
  29. package/dist/web/assets/index-BTL8cVgF.js +8629 -0
  30. package/dist/web/assets/{index.es-pImjWTGX.js → index.es-DyQqI4EJ.js} +1 -1
  31. package/dist/web/assets/{infoDiagram-5YYISTIA-Bw7MIIwj.js → infoDiagram-5YYISTIA-BfnEVJMR.js} +1 -1
  32. package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-CySt1i7t.js → ishikawaDiagram-YF4QCWOH-C-CbbOkc.js} +1 -1
  33. package/dist/web/assets/{journeyDiagram-JHISSGLW-q4YV61wv.js → journeyDiagram-JHISSGLW-CxcYZYhO.js} +1 -1
  34. package/dist/web/assets/{jspdf.es.min-B95ptK5L.js → jspdf.es.min-BoDPBXoT.js} +3 -3
  35. package/dist/web/assets/{kanban-definition-UN3LZRKU-C1oN-zoM.js → kanban-definition-UN3LZRKU-Dw9dGj5D.js} +1 -1
  36. package/dist/web/assets/{linear-DPSv7VcC.js → linear-DJLoiaYn.js} +1 -1
  37. package/dist/web/assets/{markdown-btlM2PIA.js → markdown-BXIhHJY7.js} +1 -1
  38. package/dist/web/assets/{mermaid.core-Cmch2GTm.js → mermaid.core-CTeg8SMj.js} +4 -4
  39. package/dist/web/assets/{mindmap-definition-RKZ34NQL--XOTWr6e.js → mindmap-definition-RKZ34NQL-DG4pYeYK.js} +1 -1
  40. package/dist/web/assets/{pieDiagram-4H26LBE5-DxZgtzul.js → pieDiagram-4H26LBE5-qPHgJ6_d.js} +1 -1
  41. package/dist/web/assets/{quadrantDiagram-W4KKPZXB-bbV2bAO3.js → quadrantDiagram-W4KKPZXB-C-yiyax0.js} +1 -1
  42. package/dist/web/assets/{requirementDiagram-4Y6WPE33-CiJM8hio.js → requirementDiagram-4Y6WPE33-vBgnScGl.js} +1 -1
  43. package/dist/web/assets/{sankeyDiagram-5OEKKPKP-DABBnuaB.js → sankeyDiagram-5OEKKPKP-H-Yoexss.js} +1 -1
  44. package/dist/web/assets/{sequenceDiagram-3UESZ5HK-BTQJk7bM.js → sequenceDiagram-3UESZ5HK-B0PUqbDC.js} +1 -1
  45. package/dist/web/assets/{stateDiagram-AJRCARHV-yL0tAd3x.js → stateDiagram-AJRCARHV-LHZJU6NT.js} +1 -1
  46. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-Jjcvv867.js +1 -0
  47. package/dist/web/assets/{time-hCZUKGxT.js → time-CytNQj8l.js} +1 -1
  48. package/dist/web/assets/{timeline-definition-PNZ67QCA-CG6k16Wj.js → timeline-definition-PNZ67QCA-CNYtzzkX.js} +1 -1
  49. package/dist/web/assets/{vennDiagram-CIIHVFJN-BVH530xf.js → vennDiagram-CIIHVFJN-CR3j3MfQ.js} +1 -1
  50. package/dist/web/assets/{wardley-L42UT6IY-BYVkFMrL.js → wardley-L42UT6IY-DsRtYkk3.js} +1 -1
  51. package/dist/web/assets/{wardleyDiagram-YWT4CUSO-BGMuHejP.js → wardleyDiagram-YWT4CUSO-Bkiae0No.js} +1 -1
  52. package/dist/web/assets/{xychartDiagram-2RQKCTM6-D6MSlnJD.js → xychartDiagram-2RQKCTM6-DFoBCrdz.js} +1 -1
  53. package/dist/web/index.html +1 -1
  54. package/package.json +1 -1
  55. package/src/api.ts +0 -291
  56. package/src/server.ts +0 -20
  57. package/dist/web/assets/channel-DtcQ9fhj.js +0 -1
  58. package/dist/web/assets/classDiagram-4FO5ZUOK-BjYXB41E.js +0 -1
  59. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-BjYXB41E.js +0 -1
  60. package/dist/web/assets/index-08hmlCqO.js +0 -8644
  61. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-BzWj6i1l.js +0 -1
  62. package/src/share/sse-frame.ts +0 -85
  63. package/src/share/sse-outbound-queue.ts +0 -173
  64. package/src/share/sse-rate-limit.ts +0 -205
  65. package/src/share/sse-tap.ts +0 -183
  66. package/src/share-audit.ts +0 -267
  67. package/src/share-envelope.ts +0 -155
  68. package/src/share-file-request.ts +0 -399
  69. package/src/share-file-resolver.ts +0 -68
  70. package/src/share-file-upload.ts +0 -595
  71. package/src/share-files-manifest.ts +0 -232
  72. package/src/share-ratelimit.ts +0 -69
  73. package/src/share-rpc-schema.ts +0 -249
  74. package/src/share-transport.ts +0 -205
  75. package/src/share.ts +0 -1663
package/src/share.ts DELETED
@@ -1,1663 +0,0 @@
1
- /**
2
- * Live Share controller. Owns the state machine for a host-side share session:
3
- * idle -> starting -> active -> stopping -> idle.
4
- *
5
- * This module is the local-API surface that the studio HTTP routes and toolbar
6
- * UI delegate to. start() drives the relay handshake (POST /api/share/sessions)
7
- * and boots a WebSocket transport; transport state events drive the controller
8
- * state machine. stop() tears the session down cleanly (and aborts a mid-boot
9
- * start), kick() sends a kick envelope to a peer, rotateUrl() stops + restarts
10
- * so an abused share link can be invalidated.
11
- *
12
- * `node-patched` broadcasts include the originator — peer SPAs reconcile their
13
- * optimistic state against the canonical diff; suppress reconcile-only
14
- * re-renders via the rpc-result `id` correlation in US-041.
15
- */
16
-
17
- import { existsSync, mkdirSync, readFileSync } from 'node:fs';
18
- import { homedir, userInfo } from 'node:os';
19
- import { dirname, join } from 'node:path';
20
- import { z } from 'zod';
21
- import { writeFileAtomic } from './atomic-write.ts';
22
- import type { EventBus } from './events.ts';
23
- import {
24
- ConnectorPatchBodySchema,
25
- FLOW_BULK_NON_EMPTY_MESSAGE,
26
- NodePatchBodySchema,
27
- type OperationsDeps,
28
- addConnectorImpl,
29
- addFlowBulkImpl,
30
- addNodeImpl,
31
- deleteConnectorImpl,
32
- deleteNodeImpl,
33
- flowBulkNonEmpty,
34
- moveNodeImpl,
35
- patchConnectorImpl,
36
- patchNodeImpl,
37
- reorderNodeImpl,
38
- } from './operations.ts';
39
- import {
40
- type AuditEntry,
41
- type AuditLog,
42
- type AuditLogOpts,
43
- type AuditLogger,
44
- type FrameAuditEntry,
45
- type RpcAuditEntry,
46
- appendShareAudit,
47
- createAuditLog,
48
- createAuditLogger,
49
- } from './share-audit.ts';
50
- import { type Envelope, makeEnvelope } from './share-envelope.ts';
51
- import {
52
- type FileRequestHandler,
53
- type PutToS3,
54
- type RequestUploadIntent,
55
- createFileRequestHandler,
56
- } from './share-file-request.ts';
57
- import {
58
- type AppendFileUploadAudit,
59
- type FileUploadHandler,
60
- createFileUploadHandler,
61
- } from './share-file-upload.ts';
62
- import { type FilesManifestBuilder, createFilesManifestBuilder } from './share-files-manifest.ts';
63
- import { type RateLimiter, createRateLimiter } from './share-ratelimit.ts';
64
- import { RpcFrameSchema, type RpcOp, type RpcResultFrame } from './share-rpc-schema.ts';
65
- import {
66
- type ShareTransport,
67
- type ShareTransportOpts,
68
- type ShareTransportState,
69
- createShareTransport,
70
- } from './share-transport.ts';
71
- import type { SsePayload, SseSnapshotPayload } from './share/sse-frame.ts';
72
- import {
73
- type PeerSseQueue,
74
- type PeerSseQueueMetrics,
75
- createPeerSseQueue,
76
- } from './share/sse-outbound-queue.ts';
77
- import type { RateLimitOptions } from './share/sse-rate-limit.ts';
78
- import { type SseTap, createSseTap } from './share/sse-tap.ts';
79
-
80
- // Presence frame payloads. We validate `kind` against the base shape, then
81
- // re-validate join/leave with their strict required fields so malformed
82
- // join/leave frames are dropped rather than treated as sideband. Cursor,
83
- // viewport, and any other future `kind` no-op in v1 per the design doc.
84
- // Presence payload shapes accepted on the wire. The cloud relay uses
85
- // `state: 'joined' | 'left' | 'active' | 'idle' | 'host-offline'`; older host
86
- // builds emitted `kind: 'join' | 'leave'`. We accept either to stay
87
- // forward/backward compatible.
88
- const PresenceBaseSchema = z
89
- .object({
90
- kind: z.string().optional(),
91
- state: z.string().optional(),
92
- })
93
- .passthrough();
94
- const PresenceJoinSchema = z
95
- .object({
96
- peerId: z.string(),
97
- displayName: z.string(),
98
- kind: z.literal('join').optional(),
99
- state: z.literal('joined').optional(),
100
- })
101
- .passthrough();
102
- const PresenceLeaveSchema = z
103
- .object({
104
- peerId: z.string(),
105
- kind: z.literal('leave').optional(),
106
- state: z.literal('left').optional(),
107
- })
108
- .passthrough();
109
-
110
- export interface PeerSummary {
111
- peerId: string;
112
- displayName: string;
113
- joinedAt: number;
114
- /**
115
- * Per-peer SSE outbound queue metrics surfaced for the LiveShareDialog's
116
- * "slow peer" warning (US-072). Absent when the peer has no SSE queue
117
- * (e.g. before the bridge has wired up, or when the session was started
118
- * without an `eventBus`).
119
- */
120
- outboundSse?: PeerSseQueueMetrics;
121
- }
122
-
123
- export type ShareState =
124
- | { status: 'idle' }
125
- | { status: 'starting' }
126
- | {
127
- status: 'active';
128
- sessionId: string;
129
- token: string;
130
- url: string;
131
- peers: PeerSummary[];
132
- startedAt: number;
133
- // Display label used as `attributedTo.displayName` for host-originated
134
- // node-patched broadcasts. Derived from `ShareDeps.hostDisplayName`
135
- // (defaults to 'Host') and exposed on state so the SSE bridge and local
136
- // studio UI can render the host's own suppressed self-attribution.
137
- hostDisplayName: string;
138
- // US-082: count of sessions currently tracked in active.json. Surfaced
139
- // on state so the apps/web LiveShareDialog can disable the kill-switch
140
- // button (and render "Active sessions: N") without a side-channel fetch.
141
- recentSessionCount: number;
142
- }
143
- | { status: 'stopping' }
144
- | { status: 'error'; reason: string };
145
-
146
- export interface ShareController {
147
- start(): Promise<{ url: string; sessionId: string }>;
148
- stop(): Promise<void>;
149
- kick(peerId: string): Promise<void>;
150
- rotateUrl(): Promise<{ url: string }>;
151
- /**
152
- * Host kill-switch (US-081). Revokes every session this studio has ever
153
- * opened — not just the currently active one. Tracked sessions live in
154
- * `<auditDir>/active.json`; killAll POSTs `/api/share/end` to the relay
155
- * for each entry, appends a `kill-switch` audit entry to each affected
156
- * session's JSONL log, then truncates `active.json`. Returns counts so
157
- * the UI toast can show "Ended N live sessions".
158
- */
159
- killAll(): Promise<{ revoked: number; failed: number }>;
160
- state(): ShareState;
161
- subscribe(fn: (s: ShareState) => void): () => void;
162
- /**
163
- * Dispatch an inbound `rpc` envelope as if it came from `fromPeerId`. Tests
164
- * call this directly without driving the transport. When the originator is
165
- * a known peer (registered via presence/join), pass `displayName` so the
166
- * outgoing `node-patched` broadcast's `attributedTo` field carries the
167
- * human-readable label; if omitted, `fromPeerId` is used as the fallback.
168
- */
169
- handleRpcFrame(frame: unknown, fromPeerId: string, displayName?: string): Promise<RpcResultFrame>;
170
- /**
171
- * Broadcast a `node-patched` frame for an edit applied by the host's own UI
172
- * (no peer rpc). `attributedTo.peerId` is the literal `'host'` and
173
- * `displayName` is the active state's `hostDisplayName`. No-ops when the
174
- * controller is not active or the outcome was not `kind: 'ok'`. Returns the
175
- * monotonic per-flow version assigned (or `null` when no broadcast fired).
176
- */
177
- broadcastHostEdit(op: RpcOp, outcome: RpcDispatchOutcome): number | null;
178
- /**
179
- * Subscribe to attribution events fired for every accepted op the host
180
- * broadcasts (peer-originated AND host-originated). Used by the host
181
- * studio's apps/web UI to render attribution toasts (US-053); the SSE
182
- * bridge in `apps/studio/src/api.ts` fans these out to /api/share/attributions.
183
- */
184
- subscribeAttributions(fn: (event: AttributionEvent) => void): () => void;
185
- /**
186
- * Read access to the per-session `AuditEntry` JSONL stream (US-079). The
187
- * /api/share/audit endpoint delegates to this; when no session is active
188
- * `list()` returns an empty page so the endpoint can still answer 200
189
- * (the endpoint itself gates on state before calling).
190
- */
191
- audit: {
192
- list(opts?: {
193
- limit?: number;
194
- cursor?: number;
195
- }): Promise<{ entries: AuditEntry[]; nextCursor: number | null }>;
196
- };
197
- }
198
-
199
- export interface AttributionEvent {
200
- flowId: string;
201
- op: string;
202
- diff: unknown;
203
- version: number;
204
- attributedTo: { peerId: string; displayName: string };
205
- ts: number;
206
- }
207
-
208
- // Generic outcome shape returned by a dispatcher entry. Each operations.ts
209
- // impl already conforms (`{ kind: 'ok'; data?: ... } | { kind: ...; message?: ... }`),
210
- // so the default dispatcher passes them through. Tests inject custom
211
- // dispatchers that return whatever they want for the per-op happy paths.
212
- export interface RpcDispatchOutcome {
213
- kind: string;
214
- data?: unknown;
215
- message?: string;
216
- }
217
-
218
- export type RpcDispatcher = (op: RpcOp) => Promise<RpcDispatchOutcome>;
219
-
220
- // Op allowlist — duplicated from `RpcOpSchema`'s discriminator literals as a
221
- // runtime defense-in-depth. The Zod schema rejects unknown ops up front; this
222
- // secondary gate catches a tampered runtime injection (e.g. a future code path
223
- // that skips the schema parse) before it reaches the impl.
224
- const ALLOWED_RPC_OPS = new Set<RpcOp['op']>([
225
- 'addNode',
226
- 'patchNode',
227
- 'moveNode',
228
- 'reorderNode',
229
- 'deleteNode',
230
- 'addConnector',
231
- 'patchConnector',
232
- 'deleteConnector',
233
- 'addBulk',
234
- ]);
235
-
236
- export function createDefaultRpcDispatcher(deps: OperationsDeps): RpcDispatcher {
237
- return async (op) => {
238
- switch (op.op) {
239
- case 'addNode':
240
- return addNodeImpl(deps, op.flowId, op.node);
241
- case 'patchNode': {
242
- const parsed = NodePatchBodySchema.safeParse(op.patch);
243
- if (!parsed.success) return { kind: 'badSchema', message: parsed.error.message };
244
- return patchNodeImpl(deps, op.flowId, op.nodeId, parsed.data);
245
- }
246
- case 'moveNode':
247
- return moveNodeImpl(deps, op.flowId, op.nodeId, op.position);
248
- case 'reorderNode':
249
- return reorderNodeImpl(deps, op.flowId, op.nodeId, op.reorder);
250
- case 'deleteNode':
251
- return deleteNodeImpl(deps, op.flowId, op.nodeId);
252
- case 'addConnector':
253
- return addConnectorImpl(deps, op.flowId, op.connector);
254
- case 'patchConnector': {
255
- const parsed = ConnectorPatchBodySchema.safeParse(op.patch);
256
- if (!parsed.success) return { kind: 'badSchema', message: parsed.error.message };
257
- return patchConnectorImpl(deps, op.flowId, op.connectorId, parsed.data);
258
- }
259
- case 'deleteConnector':
260
- return deleteConnectorImpl(deps, op.flowId, op.connectorId);
261
- case 'addBulk': {
262
- const body = { nodes: op.nodes, connectors: op.connectors };
263
- // The non-empty refine couldn't be expressed in the wire schema (a
264
- // ZodEffects member can't participate in a discriminatedUnion). It's
265
- // enforced here before dispatch so the wire stays loose.
266
- if (!flowBulkNonEmpty(body)) {
267
- return { kind: 'invalid', message: FLOW_BULK_NON_EMPTY_MESSAGE };
268
- }
269
- return addFlowBulkImpl(deps, op.flowId, body);
270
- }
271
- }
272
- };
273
- }
274
-
275
- export interface ShareDeps {
276
- relayHttpUrl: string;
277
- shareUrlBase: string;
278
- fetch?: typeof fetch;
279
- transportFactory?: (opts: ShareTransportOpts) => ShareTransport;
280
- // Rate-limiter is shared across the controller's lifetime so per-peer
281
- // buckets survive across kick/rejoin within a single session. Defaults to
282
- // 30 ops/sec / burst 30 per the design doc.
283
- rateLimiter?: RateLimiter;
284
- // Audit log lives one-per-session: created on transition to active, closed
285
- // by stop(). Path defaults to ~/.seeflow/share-history.
286
- auditDir?: string;
287
- /**
288
- * Path to the active-sessions tracking file (US-081 kill-switch). Defaults
289
- * to `<auditDir>/active.json`. The host appends `{sessionId, hostKey}` on
290
- * each start() and removes it on stop(); `killAll()` reads the file,
291
- * revokes every entry via POST /api/share/end, then truncates.
292
- */
293
- activeSessionsPath?: string;
294
- auditLogFactory?: (opts: AuditLogOpts) => AuditLog;
295
- // Phase-8 audit logger (US-078 shape: AuditEntry with kind). Created on
296
- // idle -> active alongside the legacy per-frame `auditLog`. Tests inject a
297
- // capturing factory; production callers leave it undefined and the real
298
- // `createAuditLogger` writes to `auditDir`.
299
- auditLoggerFactory?: (sessionId: string, root?: string) => AuditLogger;
300
- // Local EventBus to bridge onto outbound 'sse' envelopes so peers see live
301
- // runtime events (node:running, node:done, etc.) the same way the studio's
302
- // own SSE listeners do. On transition idle -> active we subscribe to each
303
- // flowId returned by flowIdsForBroadcast() and forward every StudioEvent as
304
- // makeEnvelope('sse', event, { to: 'all', from: 'host' }). Subscriptions are
305
- // torn down on teardown(). If either dep is absent the bridge is a no-op.
306
- eventBus?: EventBus;
307
- flowIdsForBroadcast?: () => string[];
308
- // OperationsDeps used by the default RPC dispatcher. Required when
309
- // `rpcDispatcher` is omitted; tests inject `rpcDispatcher` directly and can
310
- // skip this.
311
- operationsDeps?: OperationsDeps;
312
- // Per-op dispatcher invoked by `handleRpcFrame` after envelope validation.
313
- // Defaults to `createDefaultRpcDispatcher(operationsDeps)`. Tests inject
314
- // stubs to assert dispatch contract without touching the real impls.
315
- rpcDispatcher?: RpcDispatcher;
316
- // Injected for tests so they can capture audit writes without hitting disk.
317
- // Defaults to the real `appendShareAudit` writing to `auditDir`.
318
- appendShareAuditFn?: (sessionId: string, entry: RpcAuditEntry) => void;
319
- // Outbound broadcast seam. Defaults to forwarding the envelope through the
320
- // active transport when open. Tests inject a spy to assert the
321
- // node-patched fan-out without standing up a relay.
322
- broadcast?: (envelope: Envelope) => void;
323
- // Display label used as `attributedTo.displayName` for host-originated
324
- // node-patched broadcasts (and surfaced on `state().hostDisplayName` while
325
- // active). Defaults to `'Host'`. US-054 supplies the real OS username.
326
- hostDisplayName?: string;
327
- // Optional S3 staging deps for the oversize file-request path (US-060).
328
- // Both must be present to enable >256 KB file serving; otherwise the
329
- // handler replies `too-large` for big files. Tests can wire stubs.
330
- requestUploadIntent?: RequestUploadIntent;
331
- putToS3?: PutToS3;
332
- // Test seam: swap the entire file-request handler. Production callers
333
- // leave this undefined and the controller builds one from registry +
334
- // broadcast + the S3 deps above.
335
- fileRequestHandler?: FileRequestHandler;
336
- // Test seam: swap the entire file-upload handler. Production callers leave
337
- // this undefined and the controller builds one from registry + broadcast +
338
- // the active session id.
339
- fileUploadHandler?: FileUploadHandler;
340
- // Injected for tests so they can capture the upload audit entry without
341
- // hitting disk. Defaults to `appendShareAudit` writing to `auditDir`.
342
- appendFileUploadAuditFn?: AppendFileUploadAudit;
343
- // Test seam: swap the entire files-manifest builder (US-062). Production
344
- // callers leave this undefined and the controller builds one from
345
- // `operationsDeps.registry`. Without a registry the manifest is empty.
346
- filesManifestBuilder?: FilesManifestBuilder;
347
- /**
348
- * Per-peer outbound SSE send seam (US-072). Awaited per-frame inside the
349
- * per-peer queue so a slow consumer creates backpressure HERE rather than
350
- * in the producer. Defaults to a sync wrapper around `broadcast` that
351
- * sends an `sse` envelope addressed to the peer's connId. Tests inject a
352
- * delayed stub to exercise queue overflow + drop policy without standing
353
- * up a transport.
354
- */
355
- outboundSseSend?: (payload: SsePayload, peerConnId: string) => Promise<void> | void;
356
- /** Max in-queue frames per peer before eviction kicks in. Defaults to 256. */
357
- outboundSseMaxFrames?: number;
358
- /** Lifetime drops within this rolling window trigger a resync. Default 60000. */
359
- outboundSseDropResyncWindowMs?: number;
360
- /** Default 100. */
361
- outboundSseDropResyncThreshold?: number;
362
- /**
363
- * Tap-level rate-limit override (US-068). Passed through to `createSseTap`
364
- * as its `rateLimit` option. Defaults to the tap's own defaults (60/sec,
365
- * burst 120). Pass `false` to disable when testing per-peer backpressure
366
- * (US-072) so frames reach the queues without the tap's coalescer
367
- * absorbing the storm first.
368
- */
369
- sseTapRateLimit?: false | Omit<RateLimitOptions, 'onEmit'>;
370
- }
371
-
372
- interface RelaySessionResponse {
373
- sessionId: string;
374
- token: string;
375
- hostKey: string;
376
- wsUrl: string;
377
- }
378
-
379
- interface BootHandle {
380
- settled: boolean;
381
- cancelTimer: () => void;
382
- rejectStart: (err: Error) => void;
383
- }
384
-
385
- const BOOT_TIMEOUT_MS = 10_000;
386
-
387
- // Per-frame snapshot payload size cap. The host splits the snapshot into
388
- // per-flow chunks so no single `sse-snapshot` frame's serialized JSON exceeds
389
- // this size. A flow whose own snapshot is larger than the cap is still
390
- // emitted as one chunk — per-flow is the indivisible unit per the PRD.
391
- export const SSE_SNAPSHOT_CHUNK_BYTES = 256 * 1024;
392
-
393
- /**
394
- * Split a per-flow snapshot map into chunks so each chunk's serialized JSON
395
- * fits within `SSE_SNAPSHOT_CHUNK_BYTES`. Returns at least one chunk even
396
- * when the input is empty (callers gate on that separately). Always preserves
397
- * full flows — a single flow that overflows the cap occupies its own chunk
398
- * regardless of size.
399
- */
400
- export function chunkSnapshotByFlow<T>(
401
- snap: Record<string, Record<string, T>>,
402
- ): Record<string, Record<string, T>>[] {
403
- const entries = Object.entries(snap);
404
- if (entries.length === 0) return [];
405
-
406
- const chunks: Record<string, Record<string, T>>[] = [];
407
- let current: Record<string, Record<string, T>> = {};
408
-
409
- for (const [flowId, nodes] of entries) {
410
- const candidate: Record<string, Record<string, T>> = { ...current, [flowId]: nodes };
411
- const candidateSize = JSON.stringify({ flows: candidate }).length;
412
- const isCurrentEmpty = Object.keys(current).length === 0;
413
- if (!isCurrentEmpty && candidateSize > SSE_SNAPSHOT_CHUNK_BYTES) {
414
- chunks.push(current);
415
- current = { [flowId]: nodes };
416
- continue;
417
- }
418
- current = candidate;
419
- }
420
- if (Object.keys(current).length > 0) chunks.push(current);
421
- return chunks;
422
- }
423
-
424
- /**
425
- * Resolve the host's display label for `attributedTo.displayName` on
426
- * host-originated `node-patched` broadcasts (US-054). Tries the running OS
427
- * user's `username` first; falls back to literal `'Host'` if the syscall
428
- * throws (sandboxed envs) or returns a blank value. Trimmed so a username
429
- * with whitespace doesn't propagate through to UI.
430
- */
431
- export function resolveHostDisplayName(): string {
432
- try {
433
- const name = userInfo().username;
434
- if (typeof name === 'string') {
435
- const trimmed = name.trim();
436
- if (trimmed.length > 0) return trimmed;
437
- }
438
- } catch {
439
- // userInfo() throws on some sandboxed runtimes (e.g. Bun in a sealed
440
- // container with no /etc/passwd). Fall through to the literal default.
441
- }
442
- return 'Host';
443
- }
444
-
445
- // Fallback frame id surfaced when the inbound envelope failed validation
446
- // hard enough that we can't recover `frame.id`. Anything is fine as long as
447
- // it satisfies the wire schema's `min(1)` constraint on the `id` field.
448
- const INVALID_FRAME_ID = 'invalid';
449
-
450
- const extractFrameId = (frame: unknown): string => {
451
- if (frame && typeof frame === 'object' && 'id' in frame) {
452
- const raw = (frame as { id?: unknown }).id;
453
- if (typeof raw === 'string' && raw.length > 0) return raw;
454
- }
455
- return INVALID_FRAME_ID;
456
- };
457
-
458
- const makeRpcResultFrame = (id: string, payload: RpcResultFrame['payload']): RpcResultFrame => ({
459
- v: 1,
460
- type: 'rpc-result',
461
- id,
462
- payload,
463
- });
464
-
465
- // Build the diff payload broadcast as `node-patched` after a successful op.
466
- // Each branch follows the contract documented on US-039:
467
- // moveNode -> { kind: 'move', nodeId, position }
468
- // patchNode -> { kind: 'patch', nodeId, patch }
469
- // addNode -> { kind: 'add', node } (full new node from outcome)
470
- // deleteNode -> { kind: 'delete', nodeId }
471
- // reorderNode -> { kind: 'reorder', nodeId, op }
472
- // Connector variants mirror the same kinds with `connectorId` instead of
473
- // `nodeId`. `addBulk` reports `{ kind: 'bulk', result }` so peers can decide
474
- // whether to refetch or apply incrementally.
475
- function computeNodePatchedDiff(op: RpcOp, outcome: RpcDispatchOutcome): unknown {
476
- switch (op.op) {
477
- case 'moveNode':
478
- return { kind: 'move', nodeId: op.nodeId, position: op.position };
479
- case 'patchNode':
480
- return { kind: 'patch', nodeId: op.nodeId, patch: op.patch };
481
- case 'addNode': {
482
- const data = outcome.data as { node?: unknown } | undefined;
483
- return { kind: 'add', node: data?.node };
484
- }
485
- case 'deleteNode':
486
- return { kind: 'delete', nodeId: op.nodeId };
487
- case 'reorderNode':
488
- return { kind: 'reorder', nodeId: op.nodeId, op: op.reorder };
489
- case 'addConnector': {
490
- const data = outcome.data as { id?: string } | undefined;
491
- const connector =
492
- data?.id !== undefined ? { ...op.connector, id: data.id } : { ...op.connector };
493
- return { kind: 'add', connector };
494
- }
495
- case 'patchConnector':
496
- return { kind: 'patch', connectorId: op.connectorId, patch: op.patch };
497
- case 'deleteConnector':
498
- return { kind: 'delete', connectorId: op.connectorId };
499
- case 'addBulk':
500
- return { kind: 'bulk', result: outcome.data };
501
- }
502
- }
503
-
504
- export function createShareController(deps: ShareDeps): ShareController {
505
- // current is mutated through setState() so subscribers fan-out on every
506
- // transition. hostKey + transport live in closure scope — hostKey is never
507
- // returned by state() or logged. bootHandle is non-null only while start()
508
- // is in flight; stop() consults it to abort a mid-boot start.
509
- let current: ShareState = { status: 'idle' };
510
- const subscribers = new Set<(s: ShareState) => void>();
511
- const attributionSubscribers = new Set<(event: AttributionEvent) => void>();
512
- const fetchFn = deps.fetch ?? fetch;
513
- const transportFactory = deps.transportFactory ?? createShareTransport;
514
- const rateLimiter = deps.rateLimiter ?? createRateLimiter({ ratePerSec: 30, burst: 30 });
515
- const auditDir = deps.auditDir ?? join(homedir(), '.seeflow', 'share-history');
516
- const activeSessionsPath = deps.activeSessionsPath ?? join(auditDir, 'active.json');
517
- const auditLogFactory = deps.auditLogFactory ?? createAuditLog;
518
- const auditLoggerFactory = deps.auditLoggerFactory ?? createAuditLogger;
519
- const hostDisplayName = deps.hostDisplayName ?? resolveHostDisplayName();
520
- const rpcDispatcher: RpcDispatcher | null =
521
- deps.rpcDispatcher ??
522
- (deps.operationsDeps ? createDefaultRpcDispatcher(deps.operationsDeps) : null);
523
- const appendShareAuditFn =
524
- deps.appendShareAuditFn ??
525
- ((sessionId, entry) => appendShareAudit(sessionId, entry, { dir: auditDir }));
526
- // Per-flow monotonic counter bumped just before every accepted node-patched
527
- // broadcast. Peers use this only for tie-breaking out-of-order frames in a
528
- // future story; for now we only assert monotonic-increasing in tests.
529
- const flowVersions = new Map<string, number>();
530
-
531
- let hostKey: string | null = null;
532
- let transport: ShareTransport | null = null;
533
- let bootHandle: BootHandle | null = null;
534
- let auditLog: AuditLog | null = null;
535
- let auditLogger: AuditLogger | null = null;
536
- // SSE tap (US-066/US-067): single per-controller instance owning the
537
- // EventBus -> outbound `sse` envelope bridge. Created on idle -> active,
538
- // torn down on stop()/teardown(). Null when inactive or when no eventBus
539
- // dep was provided.
540
- let sseTap: SseTap | null = null;
541
- // Unsubscribe handle for the `__registry__` listener that drives
542
- // `sseTap.refreshFlows()` on registry:reload events. Captured at active
543
- // time so teardown can drop the subscription cleanly.
544
- let registryUnsubscribe: (() => void) | null = null;
545
- // peerId -> connId. Populated by presence/join frames; consulted by kick()
546
- // so a host can address a peer by stable peerId while the relay routes on
547
- // the per-connection connId. Cleared on teardown.
548
- const peerConnIds = new Map<string, string>();
549
- // connId -> peer info. Inverse of peerConnIds, kept in lockstep. Used by
550
- // handleFrame to resolve env.from -> peerId before rate-limiting/auditing.
551
- const connPeers = new Map<string, { peerId: string; displayName: string }>();
552
- // Per-peer outbound SSE queues (US-072), keyed by connId. Created on
553
- // presence/join, disposed on presence/leave + teardown. Absent before the
554
- // first peer joins; the SSE bridge falls back to a `to: 'all'` broadcast
555
- // when this map is empty so the relay's own fan-out still reaches
556
- // unattributed peers (and so the legacy 0-peer tests keep passing).
557
- const peerSseQueues = new Map<string, PeerSseQueue>();
558
- const outboundSseMaxFrames = deps.outboundSseMaxFrames;
559
- const outboundSseDropResyncThreshold = deps.outboundSseDropResyncThreshold;
560
- const outboundSseDropResyncWindowMs = deps.outboundSseDropResyncWindowMs;
561
-
562
- const broadcast =
563
- deps.broadcast ??
564
- ((envelope: Envelope) => {
565
- if (!transport || !transport.isOpen()) return;
566
- try {
567
- transport.send(envelope);
568
- } catch (err) {
569
- console.warn('[share] broadcast send failed:', err);
570
- }
571
- });
572
-
573
- // Default per-peer SSE send: emit an addressed `sse` envelope through the
574
- // broadcast seam. Tests override with a stub that returns a delayed Promise
575
- // to exercise queue backpressure without touching the transport.
576
- const outboundSseSend: (payload: SsePayload, peerConnId: string) => Promise<void> | void =
577
- deps.outboundSseSend ??
578
- ((payload, peerConnId) => {
579
- broadcast(makeEnvelope('sse', payload, { to: peerConnId, from: 'host' }));
580
- });
581
-
582
- // File-request handler (US-060). Instantiated lazily so test deps that pass
583
- // an explicit `fileRequestHandler` win; otherwise we build one from the
584
- // registry on `operationsDeps`. Without a registry there is no resolver
585
- // surface and file-request envelopes are dropped with a warn.
586
- const fileRequestHandler: FileRequestHandler | null =
587
- deps.fileRequestHandler ??
588
- (deps.operationsDeps
589
- ? createFileRequestHandler({
590
- registry: deps.operationsDeps.registry,
591
- broadcast: (env) => broadcast(env),
592
- requestUploadIntent: deps.requestUploadIntent,
593
- putToS3: deps.putToS3,
594
- })
595
- : null);
596
-
597
- // File-upload handler (US-061). Mirrors the file-request lazy-instantiation
598
- // contract: tests supply an explicit `fileUploadHandler` to short-circuit;
599
- // production callers thread `operationsDeps.registry`. Without a registry
600
- // upload frames are dropped with a warn.
601
- const appendFileUploadAuditFn: AppendFileUploadAudit =
602
- deps.appendFileUploadAuditFn ??
603
- ((sessionId, entry) => appendShareAudit(sessionId, entry, { dir: auditDir }));
604
- const fileUploadHandler: FileUploadHandler | null =
605
- deps.fileUploadHandler ??
606
- (deps.operationsDeps
607
- ? createFileUploadHandler({
608
- registry: deps.operationsDeps.registry,
609
- broadcast: (env) => broadcast(env),
610
- getSessionId: () => (current.status === 'active' ? current.sessionId : null),
611
- appendAudit: appendFileUploadAuditFn,
612
- })
613
- : null);
614
-
615
- // Files-manifest builder (US-062). Lazy-instantiated from the registry, with
616
- // the same test-seam pattern as file-request / file-upload handlers. The
617
- // controller drives `init()` on transition idle -> active and emits one
618
- // `files-manifest` frame per accepted presence/join.
619
- const filesManifestBuilder: FilesManifestBuilder | null =
620
- deps.filesManifestBuilder ??
621
- (deps.operationsDeps
622
- ? createFilesManifestBuilder({ registry: deps.operationsDeps.registry })
623
- : null);
624
- // Resolves when `filesManifestBuilder.init()` has finished for the current
625
- // session. Set on idle -> active; cleared on teardown. A presence/join that
626
- // races with init awaits this before broadcasting.
627
- let filesManifestReady: Promise<void> | null = null;
628
-
629
- // Enrich an active ShareState with current per-peer outbound SSE metrics
630
- // (US-072) and the live tracked-session count (US-082). Idempotent —
631
- // re-reading state() at any time picks up the latest queue depths/drops AND
632
- // the latest `active.json` count without requiring a setState transition.
633
- const enrichWithSseMetrics = (s: ShareState): ShareState => {
634
- if (s.status !== 'active') return s;
635
- const enrichedPeers = s.peers.map((p) => {
636
- const connId = peerConnIds.get(p.peerId);
637
- const queue = connId ? peerSseQueues.get(connId) : undefined;
638
- if (!queue) return p;
639
- return { ...p, outboundSse: queue.metrics() };
640
- });
641
- return {
642
- ...s,
643
- peers: enrichedPeers,
644
- recentSessionCount: readTrackedSessions().length,
645
- };
646
- };
647
-
648
- const setState = (next: ShareState) => {
649
- current = next;
650
- const enriched = enrichWithSseMetrics(next);
651
- for (const fn of subscribers) {
652
- try {
653
- fn(enriched);
654
- } catch (err) {
655
- console.error('[share] subscriber threw on transition:', err);
656
- }
657
- }
658
- };
659
-
660
- const subscribeToEventBus = () => {
661
- if (!deps.eventBus || !deps.flowIdsForBroadcast) return;
662
- const flowIdsForBroadcast = deps.flowIdsForBroadcast;
663
- // Build the SSE tap with a fresh per-session monotonic seq counter (owned
664
- // by createSseTap). onEvent forwards the validated SsePayload as the
665
- // `payload` of an outbound `sse` envelope; the buffer + snapshot are
666
- // private to the tap and used by US-069 / US-070 for join replay.
667
- const tap = createSseTap(deps.eventBus, {
668
- flowIds: () => flowIdsForBroadcast(),
669
- ...(deps.sseTapRateLimit !== undefined ? { rateLimit: deps.sseTapRateLimit } : {}),
670
- onEvent: (payload) => {
671
- // With at least one connected peer, fan out per-peer through bounded
672
- // queues so a slow consumer can't stall the host event loop (US-072).
673
- // With zero known peers, fall back to the legacy `to: 'all'` broadcast
674
- // so the relay still fans out to any peer the host hasn't seen
675
- // presence/join for yet (e.g. first frame after auth-peer races with
676
- // the bridge).
677
- if (peerSseQueues.size === 0) {
678
- try {
679
- broadcast(makeEnvelope('sse', payload, { to: 'all', from: 'host' }));
680
- } catch (err) {
681
- console.warn('[share] sse fan-out broadcast failed:', err);
682
- }
683
- return;
684
- }
685
- for (const queue of peerSseQueues.values()) {
686
- try {
687
- queue.enqueue(payload);
688
- } catch (err) {
689
- console.warn('[share] sse enqueue failed:', err);
690
- }
691
- }
692
- },
693
- });
694
- sseTap = tap;
695
- tap.start();
696
- // Watch the registry channel so adds/removes flow through to the tap's
697
- // subscription set without restarting the share session. Listens on the
698
- // sentinel flowId used by `apps/studio/src/api.ts` registry mutators.
699
- registryUnsubscribe = deps.eventBus.subscribe('__registry__', (event) => {
700
- if (event.type !== 'registry:reload') return;
701
- try {
702
- tap.refreshFlows();
703
- } catch (err) {
704
- console.warn('[share] sse tap refreshFlows failed:', err);
705
- }
706
- });
707
- };
708
-
709
- const unsubscribeFromEventBus = () => {
710
- const off = registryUnsubscribe;
711
- registryUnsubscribe = null;
712
- if (off) {
713
- try {
714
- off();
715
- } catch (err) {
716
- console.warn('[share] registry unsubscribe failed:', err);
717
- }
718
- }
719
- const tap = sseTap;
720
- sseTap = null;
721
- if (tap) {
722
- try {
723
- tap.stop();
724
- } catch (err) {
725
- console.warn('[share] sse tap stop failed:', err);
726
- }
727
- }
728
- };
729
-
730
- // Build a per-peer SSE outbound queue (US-072). On threshold drops the queue
731
- // fires our onResyncTriggered, which we use to emit a one-shot `sse-snapshot`
732
- // addressed to the peer so its canvas catches back up after the host stops
733
- // sending live frames (or, more typically, after the peer's network gets
734
- // back enough headroom to drain).
735
- const buildPeerSseQueue = (peerConnId: string): PeerSseQueue =>
736
- createPeerSseQueue({
737
- peerConnId,
738
- send: outboundSseSend,
739
- maxFrames: outboundSseMaxFrames,
740
- dropResyncThreshold: outboundSseDropResyncThreshold,
741
- dropResyncWindowMs: outboundSseDropResyncWindowMs,
742
- onResyncTriggered: () => {
743
- try {
744
- emitSseSnapshotForPeer(peerConnId);
745
- } catch (err) {
746
- console.warn('[share] sse resync emit failed:', err);
747
- }
748
- },
749
- });
750
-
751
- const disposePeerSseQueue = (peerConnId: string): void => {
752
- const q = peerSseQueues.get(peerConnId);
753
- if (!q) return;
754
- peerSseQueues.delete(peerConnId);
755
- try {
756
- q.dispose();
757
- } catch (err) {
758
- console.warn('[share] sse queue dispose failed:', err);
759
- }
760
- };
761
-
762
- const teardown = () => {
763
- const t = transport;
764
- const log = auditLog;
765
- const klogger = auditLogger;
766
- transport = null;
767
- hostKey = null;
768
- auditLog = null;
769
- auditLogger = null;
770
- filesManifestReady = null;
771
- unsubscribeFromEventBus();
772
- for (const q of peerSseQueues.values()) {
773
- try {
774
- q.dispose();
775
- } catch (err) {
776
- console.warn('[share] sse queue dispose failed:', err);
777
- }
778
- }
779
- peerSseQueues.clear();
780
- peerConnIds.clear();
781
- connPeers.clear();
782
- if (t) {
783
- try {
784
- t.close('user');
785
- } catch (err) {
786
- console.warn('[share] close failed during teardown:', err);
787
- }
788
- }
789
- if (log) {
790
- log.close().catch((err) => {
791
- console.warn('[share] audit log close failed:', err);
792
- });
793
- }
794
- if (klogger) {
795
- klogger.close().catch((err) => {
796
- console.warn('[share] audit kind log close failed:', err);
797
- });
798
- }
799
- };
800
-
801
- const audit = (entry: FrameAuditEntry) => {
802
- if (!auditLog) return;
803
- try {
804
- auditLog.append(entry);
805
- } catch (err) {
806
- console.warn('[share] audit append failed:', err);
807
- }
808
- };
809
-
810
- // Fire-and-forget append of a Phase-8 `AuditEntry` to the per-session JSONL
811
- // log. Errors only warn — auditing is best-effort; never block hot paths
812
- // (RPC dispatch, kick, rotate) on disk I/O.
813
- const auditKind = (entry: Omit<AuditEntry, 'ts'>): void => {
814
- const logger = auditLogger;
815
- if (!logger) return;
816
- logger.append(entry).catch((err) => {
817
- console.warn('[share] audit kind append failed:', err);
818
- });
819
- };
820
-
821
- // The studio API receives a flat node-patch body (`name`, `description`,
822
- // color tokens, `width` / `height`, …) which `mergeNodeUpdates` dispatches
823
- // into `node.data.*`. The peer SPA applies the wire diff as a shallow merge
824
- // against the node root, so a flat patch would land at the root and leave
825
- // `data.*` stale — the renderer reads `data.name` and would silently keep
826
- // the old label. Normalize the wire shape by reading the post-mutation node
827
- // from the watcher's snapshot and forwarding the full merged `data` (plus
828
- // `position` / `type` if they moved). Keeps the peer's shallow-merge
829
- // semantics correct without changing the wire schema.
830
- const NODE_ROOT_PATCH_KEYS = new Set(['position', 'type']);
831
- const canonicalizePatchNode = (op: RpcOp): RpcOp => {
832
- if (op.op !== 'patchNode') return op;
833
- const watcher = deps.operationsDeps?.watcher;
834
- const snap = watcher?.snapshot(op.flowId);
835
- const node = snap?.flow?.nodes.find((n) => n.id === op.nodeId);
836
- if (!node) return op;
837
- const newPatch: Record<string, unknown> = {};
838
- for (const key of Object.keys(op.patch)) {
839
- if (NODE_ROOT_PATCH_KEYS.has(key)) {
840
- newPatch[key] = (node as unknown as Record<string, unknown>)[key];
841
- } else {
842
- newPatch.data = node.data;
843
- }
844
- }
845
- return { ...op, patch: newPatch };
846
- };
847
-
848
- // Build + emit a `node-patched` envelope for an accepted op. Centralized so
849
- // peer-rpc and host-local edits assemble the wire payload identically (only
850
- // the `attributedTo` value differs). Returns the version assigned so callers
851
- // can echo it back into rpc-result if needed.
852
- const broadcastNodePatched = (
853
- rawOp: RpcOp,
854
- outcome: RpcDispatchOutcome,
855
- attributedTo: { peerId: string; displayName: string },
856
- ): number => {
857
- const op = canonicalizePatchNode(rawOp);
858
- const nextVersion = (flowVersions.get(op.flowId) ?? 0) + 1;
859
- flowVersions.set(op.flowId, nextVersion);
860
- const diff = computeNodePatchedDiff(op, outcome);
861
- try {
862
- broadcast(
863
- makeEnvelope(
864
- 'node-patched',
865
- { flowId: op.flowId, op: op.op, diff, version: nextVersion, attributedTo },
866
- { to: 'all' },
867
- ),
868
- );
869
- } catch (err) {
870
- console.warn('[share] node-patched broadcast failed:', err);
871
- }
872
- const event: AttributionEvent = {
873
- flowId: op.flowId,
874
- op: op.op,
875
- diff,
876
- version: nextVersion,
877
- attributedTo,
878
- ts: Date.now(),
879
- };
880
- for (const fn of attributionSubscribers) {
881
- try {
882
- fn(event);
883
- } catch (err) {
884
- console.error('[share] attribution subscriber threw:', err);
885
- }
886
- }
887
- return nextVersion;
888
- };
889
-
890
- const dispatchRpcFrame = async (
891
- frame: unknown,
892
- actor: { peerId: string; displayName: string },
893
- ): Promise<RpcResultFrame> => {
894
- const fallbackId = extractFrameId(frame);
895
- const parsed = RpcFrameSchema.safeParse(frame);
896
- if (!parsed.success) {
897
- // Never include payload in the log: a tampered envelope is hostile by
898
- // assumption.
899
- console.warn('[share] rpc frame rejected:', {
900
- type: 'rpc',
901
- from: actor.peerId,
902
- reason: 'invalid_envelope',
903
- });
904
- return makeRpcResultFrame(fallbackId, { ok: false, reason: 'invalid_envelope' });
905
- }
906
- const op = parsed.data.payload;
907
- if (!ALLOWED_RPC_OPS.has(op.op)) {
908
- // Defense-in-depth: unreachable past the schema, but guards a future
909
- // path that bypasses validation.
910
- console.warn('[share] rpc frame rejected:', {
911
- type: 'rpc',
912
- from: actor.peerId,
913
- reason: 'op_not_allowed',
914
- });
915
- return makeRpcResultFrame(parsed.data.id, { ok: false, reason: 'op_not_allowed' });
916
- }
917
- if (current.status !== 'active') {
918
- return makeRpcResultFrame(parsed.data.id, { ok: false, reason: 'not_active' });
919
- }
920
- if (!rpcDispatcher) {
921
- return makeRpcResultFrame(parsed.data.id, { ok: false, reason: 'no_dispatcher' });
922
- }
923
- const sessionId = current.sessionId;
924
- let outcome: RpcDispatchOutcome;
925
- try {
926
- outcome = await rpcDispatcher(op);
927
- } catch (err) {
928
- const message = err instanceof Error ? err.message : String(err);
929
- outcome = { kind: 'dispatcherThrew', message };
930
- }
931
- const ok = outcome.kind === 'ok';
932
- const reason = ok ? undefined : outcome.kind + (outcome.message ? `: ${outcome.message}` : '');
933
- const attributedTo = { peerId: actor.peerId, displayName: actor.displayName };
934
- try {
935
- const entry: RpcAuditEntry = {
936
- ts: Date.now(),
937
- peerId: actor.peerId,
938
- op: op.op,
939
- flowId: op.flowId,
940
- ok,
941
- ...(reason ? { reason } : {}),
942
- attributedTo,
943
- };
944
- appendShareAuditFn(sessionId, entry);
945
- } catch (err) {
946
- console.warn('[share] rpc audit append failed:', err);
947
- }
948
- if (ok) {
949
- // Phase-8 kind-shaped audit (US-079). Coexists with the RpcAuditEntry
950
- // line written above so consumers can filter by either schema.
951
- // `details` carries flowId + (for node-targeted ops) the nodeId so the
952
- // audit drawer can render the target without re-reading the op union.
953
- const rpcDetails: Record<string, unknown> = { flowId: op.flowId };
954
- if ('nodeId' in op && typeof op.nodeId === 'string') {
955
- rpcDetails.nodeId = op.nodeId;
956
- }
957
- auditKind({
958
- kind: 'rpc-accept',
959
- peerId: actor.peerId,
960
- displayName: actor.displayName,
961
- op: op.op,
962
- details: rpcDetails,
963
- });
964
- // Broadcast the canonical diff BEFORE resolving rpc-result so peers
965
- // (including the originator) see the patch first; the originator's
966
- // optimistic reconcile then folds into a no-op.
967
- broadcastNodePatched(op, outcome, attributedTo);
968
- return makeRpcResultFrame(parsed.data.id, {
969
- ok: true,
970
- ...(outcome.data !== undefined ? { result: outcome.data } : {}),
971
- attributedTo,
972
- });
973
- }
974
- auditKind({
975
- kind: 'rpc-reject',
976
- peerId: actor.peerId,
977
- displayName: actor.displayName,
978
- op: op.op,
979
- reason: reason ?? outcome.kind,
980
- });
981
- return makeRpcResultFrame(parsed.data.id, {
982
- ok: false,
983
- reason: reason ?? outcome.kind,
984
- });
985
- };
986
-
987
- // Emit one or more `sse-snapshot` frames to a freshly-joined peer so its
988
- // canvas badges / play-button rings match the host's live state without
989
- // waiting for the next tick. Caps each frame's serialized payload at 256 KB
990
- // by splitting per-flow into chunks (chunk + total stamped on each frame).
991
- // A single flow whose own snapshot exceeds the cap is still emitted as one
992
- // chunk — per the PRD, per-flow is the indivisible unit.
993
- const emitSseSnapshotForPeer = (peerConnId: string): void => {
994
- if (!sseTap) return;
995
- let snap: Record<string, Record<string, SsePayload>>;
996
- try {
997
- snap = sseTap.snapshot();
998
- } catch (err) {
999
- console.warn('[share] sse-snapshot read failed:', err);
1000
- return;
1001
- }
1002
- const entries = Object.entries(snap);
1003
- if (entries.length === 0) return;
1004
-
1005
- const chunks = chunkSnapshotByFlow(snap);
1006
- const total = chunks.length;
1007
- for (let i = 0; i < chunks.length; i += 1) {
1008
- const chunkFlows = chunks[i];
1009
- if (!chunkFlows) continue;
1010
- const payload: SseSnapshotPayload =
1011
- total === 1 ? { flows: chunkFlows } : { flows: chunkFlows, chunk: i, total };
1012
- try {
1013
- broadcast(makeEnvelope('sse-snapshot', payload, { to: peerConnId }));
1014
- } catch (err) {
1015
- console.warn('[share] sse-snapshot broadcast failed:', err);
1016
- }
1017
- }
1018
- };
1019
-
1020
- // Emit the files-manifest frame to a freshly-joined peer. Awaits init() so a
1021
- // racing join (e.g. peer auth-peer'd before the walk completed) still gets a
1022
- // populated manifest. Errors only warn — the manifest is a hint, not a
1023
- // gating signal; the peer can fall back to file-request on cache miss.
1024
- const emitFilesManifestForPeer = async (peerConnId: string): Promise<void> => {
1025
- if (!filesManifestBuilder) return;
1026
- try {
1027
- if (filesManifestReady) await filesManifestReady;
1028
- const payload = filesManifestBuilder.build();
1029
- broadcast(makeEnvelope('files-manifest', payload, { to: peerConnId }));
1030
- } catch (err) {
1031
- console.warn('[share] files-manifest emit failed:', err);
1032
- }
1033
- };
1034
-
1035
- // Emit a `flow-snapshot` envelope to a freshly-joined peer so its canvas can
1036
- // render the host's flow graph. Prefers the watcher's resolved snapshot
1037
- // (file refs inlined, style merged, schema-validated) so component / icon
1038
- // / image nodes render without the peer needing to walk the host's
1039
- // filesystem. Falls back to raw on-disk JSON for setups that boot without
1040
- // a watcher (CLI smoke tests, integration). Without this the peer is stuck
1041
- // on "CONNECTING…" forever (viewer's share-session.tsx blocks until
1042
- // `snapshot` is populated). Errors only warn — the peer can refresh /
1043
- // rotate to retry.
1044
- const emitFlowSnapshotForPeer = (peerConnId: string): void => {
1045
- const registry = deps.operationsDeps?.registry;
1046
- const watcher = deps.operationsDeps?.watcher;
1047
- if (!registry) return;
1048
- const entries = registry.list();
1049
- if (entries.length === 0) return;
1050
- const flows: Record<string, unknown> = {};
1051
- let activeFlowId: string | null = null;
1052
- for (const entry of entries) {
1053
- let flow: unknown = null;
1054
- const snap = watcher?.snapshot(entry.id) ?? watcher?.reparse(entry.id) ?? null;
1055
- if (snap?.valid && snap.flow) {
1056
- flow = snap.flow;
1057
- } else {
1058
- try {
1059
- flow = JSON.parse(readFileSync(join(entry.repoPath, entry.flowPath), 'utf8'));
1060
- } catch (err) {
1061
- console.warn(`[share] flow-snapshot read failed for ${entry.id}:`, err);
1062
- continue;
1063
- }
1064
- }
1065
- flows[entry.id] = flow;
1066
- if (activeFlowId === null) activeFlowId = entry.id;
1067
- if (entry.isDefault) activeFlowId = entry.id;
1068
- }
1069
- if (!activeFlowId) return;
1070
- try {
1071
- broadcast(
1072
- makeEnvelope('flow-snapshot', { flows, activeFlowId }, { to: peerConnId, from: 'host' }),
1073
- );
1074
- } catch (err) {
1075
- console.warn('[share] flow-snapshot broadcast failed:', err);
1076
- }
1077
- };
1078
-
1079
- const handleFrame = (env: Envelope) => {
1080
- if (env.type === 'presence') {
1081
- const base = PresenceBaseSchema.safeParse(env.payload);
1082
- if (!base.success) {
1083
- console.warn('[share] dropped presence frame: invalid payload');
1084
- return;
1085
- }
1086
- // Relay uses `state: 'joined'/'left'/...`; older host builds emitted
1087
- // `kind: 'join'/'leave'`. Map either onto our internal verbs.
1088
- const kind =
1089
- base.data.kind ??
1090
- (base.data.state === 'joined'
1091
- ? 'join'
1092
- : base.data.state === 'left'
1093
- ? 'leave'
1094
- : base.data.state);
1095
- if (kind === 'join') {
1096
- const parsed = PresenceJoinSchema.safeParse(env.payload);
1097
- if (!parsed.success) {
1098
- console.warn('[share] dropped presence/join: invalid fields');
1099
- return;
1100
- }
1101
- const { peerId, displayName } = parsed.data;
1102
- peerConnIds.set(peerId, env.from);
1103
- connPeers.set(env.from, { peerId, displayName });
1104
- // Spin up the per-peer outbound SSE queue alongside the connId
1105
- // bookkeeping so any live frame that fires after presence/join lands
1106
- // on a bounded queue rather than hitting the legacy `to: 'all'` path.
1107
- if (!peerSseQueues.has(env.from)) {
1108
- peerSseQueues.set(env.from, buildPeerSseQueue(env.from));
1109
- }
1110
- // Join itself is always accepted — the peer becomes "known" only as
1111
- // a result of this frame, so rate-limiting it would be a chicken-and-
1112
- // egg problem. Audit it as accept so the trail shows who joined when.
1113
- audit({ ts: Date.now(), peerId, displayName, type: 'presence', verdict: 'accept' });
1114
- auditKind({ kind: 'peer-join', peerId, displayName });
1115
- if (current.status !== 'active') return;
1116
- if (current.peers.some((peer) => peer.peerId === peerId)) return;
1117
- setState({
1118
- ...current,
1119
- peers: [...current.peers, { peerId, displayName, joinedAt: Date.now() }],
1120
- });
1121
- // Prime the new peer with the host's flow graph first — without this
1122
- // the peer is stuck on "CONNECTING…" forever (viewer waits on
1123
- // snapshot before rendering the canvas).
1124
- emitFlowSnapshotForPeer(env.from);
1125
- // Prime the new peer with a one-shot files-manifest snapshot so its
1126
- // canvas can render placeholder sizing before any file-request fires.
1127
- // Fire-and-forget; emit failures are warned, never propagated.
1128
- void emitFilesManifestForPeer(env.from);
1129
- // Replay the SSE tap's last-seen per-node status so the joiner's
1130
- // canvas badges + play-button rings match the host within one render.
1131
- // Addressed to the joiner's connId only (not broadcast). Chunked
1132
- // per-flow when the serialized payload exceeds 256 KB.
1133
- emitSseSnapshotForPeer(env.from);
1134
- return;
1135
- }
1136
- if (kind === 'leave') {
1137
- const parsed = PresenceLeaveSchema.safeParse(env.payload);
1138
- if (!parsed.success) {
1139
- console.warn('[share] dropped presence/leave: invalid fields');
1140
- return;
1141
- }
1142
- const { peerId } = parsed.data;
1143
- const known = connPeers.get(env.from);
1144
- peerConnIds.delete(peerId);
1145
- connPeers.delete(env.from);
1146
- disposePeerSseQueue(env.from);
1147
- if (known) {
1148
- audit({
1149
- ts: Date.now(),
1150
- peerId: known.peerId,
1151
- displayName: known.displayName,
1152
- type: 'presence',
1153
- verdict: 'accept',
1154
- });
1155
- auditKind({
1156
- kind: 'peer-leave',
1157
- peerId: known.peerId,
1158
- displayName: known.displayName,
1159
- });
1160
- }
1161
- if (current.status !== 'active') return;
1162
- if (!current.peers.some((peer) => peer.peerId === peerId)) return;
1163
- setState({
1164
- ...current,
1165
- peers: current.peers.filter((peer) => peer.peerId !== peerId),
1166
- });
1167
- return;
1168
- }
1169
- // Other presence kinds (cursor, viewport, etc.) are sideband-only in v1.
1170
- return;
1171
- }
1172
-
1173
- // Non-presence frames must come from a known peer (introduced earlier via
1174
- // presence/join). Frames from an unknown connId are dropped silently — we
1175
- // have no peerId/displayName to attribute the audit entry to.
1176
- const peer = connPeers.get(env.from);
1177
- if (!peer) {
1178
- console.debug('[share] dropped frame from unknown peer:', {
1179
- type: env.type,
1180
- from: env.from,
1181
- });
1182
- return;
1183
- }
1184
-
1185
- const verdict = rateLimiter.check(peer.peerId);
1186
- if (!verdict.ok) {
1187
- audit({
1188
- ts: Date.now(),
1189
- peerId: peer.peerId,
1190
- displayName: peer.displayName,
1191
- type: env.type,
1192
- verdict: 'reject',
1193
- reason: 'rate-limited',
1194
- });
1195
- return;
1196
- }
1197
-
1198
- audit({
1199
- ts: Date.now(),
1200
- peerId: peer.peerId,
1201
- displayName: peer.displayName,
1202
- type: env.type,
1203
- verdict: 'accept',
1204
- });
1205
-
1206
- if (env.type === 'file-request') {
1207
- if (!fileRequestHandler) {
1208
- console.warn('[share] dropped file-request: no handler configured');
1209
- return;
1210
- }
1211
- fileRequestHandler.handle(env).catch((err) => {
1212
- console.warn('[share] file-request handler threw:', err);
1213
- });
1214
- return;
1215
- }
1216
-
1217
- if (
1218
- env.type === 'file-upload-intent' ||
1219
- env.type === 'file-bytes' ||
1220
- env.type === 'file-upload-done'
1221
- ) {
1222
- if (!fileUploadHandler) {
1223
- console.warn(`[share] dropped ${env.type}: no upload handler configured`);
1224
- return;
1225
- }
1226
- const peerCtx = { peerId: peer.peerId, displayName: peer.displayName };
1227
- const dispatch =
1228
- env.type === 'file-upload-intent'
1229
- ? fileUploadHandler.handleIntent(env, peerCtx)
1230
- : env.type === 'file-bytes'
1231
- ? fileUploadHandler.handleBytes(env, peerCtx)
1232
- : fileUploadHandler.handleDone(env, peerCtx);
1233
- dispatch.catch((err) => {
1234
- console.warn(`[share] ${env.type} handler threw:`, err);
1235
- });
1236
- return;
1237
- }
1238
-
1239
- if (env.type === 'rpc') {
1240
- // The wire-side `RpcFrameSchema` is strict, so strip envelope-only fields
1241
- // (`from`, `to`) before dispatch. Missing fields surface as
1242
- // 'invalid_envelope' below.
1243
- const wireFrame = {
1244
- v: env.v,
1245
- type: env.type,
1246
- id: env.id,
1247
- payload: env.payload,
1248
- };
1249
- const replyTo = env.from;
1250
- dispatchRpcFrame(wireFrame, { peerId: peer.peerId, displayName: peer.displayName })
1251
- .then((result) => {
1252
- if (!transport || !transport.isOpen()) return;
1253
- try {
1254
- transport.send(
1255
- makeEnvelope('rpc-result', result.payload, { to: replyTo, id: result.id }),
1256
- );
1257
- } catch (err) {
1258
- console.warn('[share] rpc-result send failed:', err);
1259
- }
1260
- })
1261
- .catch((err) => {
1262
- console.warn('[share] dispatchRpcFrame threw:', err);
1263
- });
1264
- return;
1265
- }
1266
-
1267
- // All other envelope types are accepted-and-dropped in v1; real handling
1268
- // (file streaming, etc.) lands in phase 6+. Log type+from only — never
1269
- // the payload.
1270
- console.debug('[share] dropped frame:', { type: env.type, from: env.from });
1271
- };
1272
-
1273
- // Active-sessions tracker (US-081). Persists `{sessionId, hostKey}` per
1274
- // session in `<auditDir>/active.json` so a single `killAll()` call can
1275
- // revoke every share link this studio has ever opened — not just the
1276
- // currently active one. Writes are atomic; reads tolerate a missing or
1277
- // corrupted file by returning an empty array.
1278
- interface TrackedSession {
1279
- sessionId: string;
1280
- hostKey: string;
1281
- }
1282
- const readTrackedSessions = (): TrackedSession[] => {
1283
- try {
1284
- if (!existsSync(activeSessionsPath)) return [];
1285
- const raw = readFileSync(activeSessionsPath, 'utf8');
1286
- const parsed = JSON.parse(raw) as unknown;
1287
- if (!Array.isArray(parsed)) return [];
1288
- return parsed.filter(
1289
- (s): s is TrackedSession =>
1290
- typeof s === 'object' &&
1291
- s !== null &&
1292
- typeof (s as { sessionId?: unknown }).sessionId === 'string' &&
1293
- typeof (s as { hostKey?: unknown }).hostKey === 'string',
1294
- );
1295
- } catch {
1296
- return [];
1297
- }
1298
- };
1299
- const writeTrackedSessions = (sessions: TrackedSession[]): void => {
1300
- try {
1301
- mkdirSync(dirname(activeSessionsPath), { recursive: true });
1302
- writeFileAtomic(activeSessionsPath, JSON.stringify(sessions));
1303
- } catch (err) {
1304
- console.warn('[share] active sessions write failed:', err);
1305
- }
1306
- };
1307
- const addTrackedSession = (sessionId: string, hk: string): void => {
1308
- const tracked = readTrackedSessions();
1309
- if (tracked.some((s) => s.sessionId === sessionId)) return;
1310
- writeTrackedSessions([...tracked, { sessionId, hostKey: hk }]);
1311
- };
1312
- const removeTrackedSession = (sessionId: string): void => {
1313
- const tracked = readTrackedSessions();
1314
- const next = tracked.filter((s) => s.sessionId !== sessionId);
1315
- if (next.length === tracked.length) return;
1316
- writeTrackedSessions(next);
1317
- };
1318
-
1319
- const controller: ShareController = {
1320
- async start() {
1321
- if (current.status !== 'idle') {
1322
- throw new Error('share-already-active');
1323
- }
1324
- const res = await fetchFn(`${deps.relayHttpUrl}/api/share/sessions`, { method: 'POST' });
1325
- if (!res.ok) {
1326
- throw new Error(`share-relay-http-${res.status}`);
1327
- }
1328
- const body = (await res.json()) as RelaySessionResponse;
1329
-
1330
- hostKey = body.hostKey;
1331
-
1332
- return await new Promise<{ url: string; sessionId: string }>((resolve, reject) => {
1333
- let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
1334
-
1335
- const handle: BootHandle = {
1336
- settled: false,
1337
- cancelTimer: () => {
1338
- if (timeoutHandle !== null) {
1339
- clearTimeout(timeoutHandle);
1340
- timeoutHandle = null;
1341
- }
1342
- },
1343
- rejectStart: (err) => {
1344
- handle.settled = true;
1345
- reject(err);
1346
- },
1347
- };
1348
- bootHandle = handle;
1349
-
1350
- timeoutHandle = setTimeout(() => {
1351
- if (handle.settled) return;
1352
- handle.settled = true;
1353
- timeoutHandle = null;
1354
- bootHandle = null;
1355
- teardown();
1356
- setState({ status: 'idle' });
1357
- reject(new Error('share-boot-timeout'));
1358
- }, BOOT_TIMEOUT_MS);
1359
-
1360
- const onTransportState = (s: ShareTransportState) => {
1361
- if (handle.settled) return;
1362
- if (s === 'connecting' || s === 'reconnecting') {
1363
- if (current.status !== 'starting') setState({ status: 'starting' });
1364
- return;
1365
- }
1366
- if (s === 'open') {
1367
- handle.settled = true;
1368
- handle.cancelTimer();
1369
- bootHandle = null;
1370
- const url = `${deps.shareUrlBase}/${body.token}`;
1371
- // Open the per-session audit log before transitioning state so
1372
- // any frame that races in (e.g. immediate presence/join from a
1373
- // pre-connected peer) is captured.
1374
- try {
1375
- auditLog = auditLogFactory({ dir: auditDir, sessionId: body.sessionId });
1376
- } catch (err) {
1377
- console.warn('[share] audit log open failed:', err);
1378
- auditLog = null;
1379
- }
1380
- try {
1381
- auditLogger = auditLoggerFactory(body.sessionId, auditDir);
1382
- } catch (err) {
1383
- console.warn('[share] audit kind log open failed:', err);
1384
- auditLogger = null;
1385
- }
1386
- // host-start is the first kind-shaped entry written to the file
1387
- // so a consumer reading from cursor:0 sees the session boundary.
1388
- auditKind({ kind: 'host-start', peerId: null, displayName: null });
1389
- // Track the session in active.json so killAll() can revoke it
1390
- // later — even after the controller goes idle.
1391
- if (hostKey) addTrackedSession(body.sessionId, hostKey);
1392
- // Subscribe to local runtime events BEFORE transitioning state so
1393
- // any event that fires synchronously from a state subscriber finds
1394
- // the bridge wired up.
1395
- subscribeToEventBus();
1396
- // Kick off the files-manifest disk walk. presence/join handlers
1397
- // await this before emitting so the first joiner gets a populated
1398
- // payload even if it races init.
1399
- if (filesManifestBuilder) {
1400
- filesManifestReady = filesManifestBuilder.init().catch((err) => {
1401
- console.warn('[share] files-manifest init failed:', err);
1402
- });
1403
- }
1404
- setState({
1405
- status: 'active',
1406
- sessionId: body.sessionId,
1407
- token: body.token,
1408
- url,
1409
- peers: [],
1410
- startedAt: Date.now(),
1411
- hostDisplayName,
1412
- recentSessionCount: 0,
1413
- });
1414
- resolve({ url, sessionId: body.sessionId });
1415
- return;
1416
- }
1417
- // s === 'closed' before reaching 'open' => boot failed.
1418
- handle.settled = true;
1419
- handle.cancelTimer();
1420
- bootHandle = null;
1421
- transport = null;
1422
- hostKey = null;
1423
- setState({ status: 'idle' });
1424
- reject(new Error('share-transport-closed-during-boot'));
1425
- };
1426
-
1427
- transport = transportFactory({
1428
- wsUrl: body.wsUrl,
1429
- sessionId: body.sessionId,
1430
- hostKey: body.hostKey,
1431
- onFrame: handleFrame,
1432
- onStateChange: onTransportState,
1433
- });
1434
- });
1435
- },
1436
- async stop() {
1437
- if (current.status === 'idle') return;
1438
-
1439
- if (current.status === 'starting') {
1440
- // Abort the in-flight start: mark its boot settled BEFORE closing the
1441
- // transport so a synchronous 'closed' emit can't double-reject with
1442
- // 'share-transport-closed-during-boot'.
1443
- const handle = bootHandle;
1444
- bootHandle = null;
1445
- if (handle) {
1446
- handle.settled = true;
1447
- handle.cancelTimer();
1448
- }
1449
- setState({ status: 'stopping' });
1450
- teardown();
1451
- setState({ status: 'idle' });
1452
- if (handle) handle.rejectStart(new Error('share-stopped-during-start'));
1453
- return;
1454
- }
1455
-
1456
- if (current.status === 'active') {
1457
- // host-stop fires BEFORE teardown so the audit append lands on the
1458
- // still-open logger; teardown closes it after.
1459
- auditKind({ kind: 'host-stop', peerId: null, displayName: null });
1460
- removeTrackedSession(current.sessionId);
1461
- setState({ status: 'stopping' });
1462
- teardown();
1463
- setState({ status: 'idle' });
1464
- return;
1465
- }
1466
-
1467
- // status === 'stopping' or 'error' — treat as no-op; the in-flight
1468
- // stop will complete on its own and a fresh start() can follow.
1469
- },
1470
- handleRpcFrame: (frame, fromPeerId, displayName) =>
1471
- dispatchRpcFrame(frame, {
1472
- peerId: fromPeerId,
1473
- // Prefer the controller's known peer record so test/UI callers that
1474
- // only pass a peerId still attribute correctly when the peer is
1475
- // already known via presence/join. Falls back to the passed
1476
- // displayName, then the peerId itself, so attribution is always
1477
- // non-empty (AttributionSchema requires `displayName.min(1)`).
1478
- displayName:
1479
- [...connPeers.values()].find((p) => p.peerId === fromPeerId)?.displayName ??
1480
- displayName ??
1481
- fromPeerId,
1482
- }),
1483
- broadcastHostEdit(op, outcome) {
1484
- if (current.status !== 'active') return null;
1485
- if (outcome.kind !== 'ok') return null;
1486
- const attributedTo = { peerId: 'host', displayName: current.hostDisplayName };
1487
- // Audit the host-local edit so the JSONL file reflects every accepted
1488
- // mutation regardless of origin. peerId mirrors `attributedTo.peerId`
1489
- // ('host') so consumers can filter by attribution without a join.
1490
- try {
1491
- const entry: RpcAuditEntry = {
1492
- ts: Date.now(),
1493
- peerId: 'host',
1494
- op: op.op,
1495
- flowId: op.flowId,
1496
- ok: true,
1497
- attributedTo,
1498
- };
1499
- appendShareAuditFn(current.sessionId, entry);
1500
- } catch (err) {
1501
- console.warn('[share] host-edit audit append failed:', err);
1502
- }
1503
- return broadcastNodePatched(op, outcome, attributedTo);
1504
- },
1505
- async kick(peerId: string) {
1506
- if (current.status !== 'active' || !hostKey) {
1507
- throw new Error('share-not-active');
1508
- }
1509
- // Look up displayName from connPeers so the audit entry records the
1510
- // human-readable label even though the relay endpoint only takes a
1511
- // peerId. Falls back to null when the peer is unknown — but in that
1512
- // case we throw share-peer-not-found before issuing the RPC.
1513
- const known = [...connPeers.values()].find((p) => p.peerId === peerId);
1514
- if (!known) {
1515
- const reason = 'share-peer-not-found';
1516
- auditKind({
1517
- kind: 'rpc-reject',
1518
- peerId,
1519
- displayName: null,
1520
- op: 'kick',
1521
- reason,
1522
- });
1523
- throw new Error(reason);
1524
- }
1525
- const sessionId = current.sessionId;
1526
- try {
1527
- const res = await fetchFn(`${deps.relayHttpUrl}/api/share/kick`, {
1528
- method: 'POST',
1529
- headers: { 'content-type': 'application/json' },
1530
- body: JSON.stringify({ sessionId, hostKey, peerId }),
1531
- });
1532
- if (!res.ok) {
1533
- throw new Error(`share-relay-http-${res.status}`);
1534
- }
1535
- } catch (err) {
1536
- const reason = err instanceof Error ? err.message : String(err);
1537
- auditKind({
1538
- kind: 'rpc-reject',
1539
- peerId,
1540
- displayName: known.displayName,
1541
- op: 'kick',
1542
- reason,
1543
- });
1544
- throw err;
1545
- }
1546
- auditKind({
1547
- kind: 'kick',
1548
- peerId,
1549
- displayName: known.displayName,
1550
- });
1551
- },
1552
- async rotateUrl() {
1553
- if (current.status !== 'active' || !hostKey) {
1554
- throw new Error('share-not-active');
1555
- }
1556
- const sessionId = current.sessionId;
1557
- const res = await fetchFn(`${deps.relayHttpUrl}/api/share/rotate`, {
1558
- method: 'POST',
1559
- headers: { 'content-type': 'application/json' },
1560
- body: JSON.stringify({ sessionId, hostKey }),
1561
- });
1562
- if (!res.ok) {
1563
- throw new Error(`share-relay-http-${res.status}`);
1564
- }
1565
- const body = (await res.json()) as { token: string };
1566
- const newUrl = `${deps.shareUrlBase}/${body.token}`;
1567
- // The relay's rotate endpoint kicks every peer connection as part of
1568
- // the rotation — none of them will reconnect with the old token. Clear
1569
- // our local peer book-keeping so state() reflects the empty roster the
1570
- // next subscriber tick will read.
1571
- for (const q of peerSseQueues.values()) {
1572
- try {
1573
- q.dispose();
1574
- } catch (err) {
1575
- console.warn('[share] sse queue dispose failed during rotate:', err);
1576
- }
1577
- }
1578
- peerSseQueues.clear();
1579
- peerConnIds.clear();
1580
- connPeers.clear();
1581
- if (current.status === 'active') {
1582
- setState({ ...current, token: body.token, url: newUrl, peers: [] });
1583
- }
1584
- auditKind({ kind: 'rotate', peerId: null, displayName: null });
1585
- return { url: newUrl };
1586
- },
1587
- async killAll() {
1588
- const tracked = readTrackedSessions();
1589
- let revoked = 0;
1590
- let failed = 0;
1591
- for (const { sessionId, hostKey: hk } of tracked) {
1592
- try {
1593
- const res = await fetchFn(`${deps.relayHttpUrl}/api/share/end`, {
1594
- method: 'POST',
1595
- headers: { 'content-type': 'application/json' },
1596
- body: JSON.stringify({ sessionId, hostKey: hk }),
1597
- });
1598
- if (!res.ok) {
1599
- failed += 1;
1600
- continue;
1601
- }
1602
- revoked += 1;
1603
- } catch {
1604
- failed += 1;
1605
- }
1606
- }
1607
- // Per-session audit append. Each session gets a `kill-switch` entry so
1608
- // a reader paging through that file sees the boundary. Use the factory
1609
- // (not the active controller's `auditLogger`) so we can target any
1610
- // tracked session — including ones whose controller is long since
1611
- // torn down.
1612
- for (const { sessionId } of tracked) {
1613
- try {
1614
- const logger = auditLoggerFactory(sessionId, auditDir);
1615
- await logger.append({
1616
- kind: 'kill-switch',
1617
- peerId: null,
1618
- displayName: null,
1619
- details: { revoked, failed },
1620
- });
1621
- await logger.close();
1622
- } catch (err) {
1623
- console.warn('[share] kill-switch audit append failed:', err);
1624
- }
1625
- }
1626
- writeTrackedSessions([]);
1627
- return { revoked, failed };
1628
- },
1629
- state() {
1630
- return enrichWithSseMetrics(current);
1631
- },
1632
- subscribe(fn) {
1633
- subscribers.add(fn);
1634
- try {
1635
- fn(enrichWithSseMetrics(current));
1636
- } catch (err) {
1637
- console.error('[share] subscriber threw on initial deliver, dropping:', err);
1638
- }
1639
- return () => {
1640
- subscribers.delete(fn);
1641
- };
1642
- },
1643
- subscribeAttributions(fn) {
1644
- attributionSubscribers.add(fn);
1645
- return () => {
1646
- attributionSubscribers.delete(fn);
1647
- };
1648
- },
1649
- audit: {
1650
- // Delegates to the per-session AuditLogger. Returns an empty page
1651
- // when no session is active so the endpoint can answer without a
1652
- // file-read; the endpoint itself returns 400 in that case before
1653
- // calling in, but stay safe-by-default here too.
1654
- async list(opts) {
1655
- const logger = auditLogger;
1656
- if (!logger) return { entries: [], nextCursor: null };
1657
- return logger.list(opts);
1658
- },
1659
- },
1660
- };
1661
-
1662
- return controller;
1663
- }