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