@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
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-bucket rate limiter + per-(flowId, nodeId) coalescer for SSE bridge
|
|
3
|
+
* frames. Sits between the SSE tap's event source and the relay broadcast so
|
|
4
|
+
* a noisy `node:status` storm cannot saturate the relay or peer browsers.
|
|
5
|
+
*
|
|
6
|
+
* Semantics:
|
|
7
|
+
* - Sustained rate `tokensPerSecond` (default 60) with bucket capacity
|
|
8
|
+
* `burst` (default 120). Each non-terminal frame consumes one token.
|
|
9
|
+
* - Non-terminal frames that arrive with no tokens are queued. When a
|
|
10
|
+
* `node:status` frame for the same `(flowId, nodeId)` is already pending,
|
|
11
|
+
* the latest payload replaces the queued one (last-wins coalescing) and
|
|
12
|
+
* `droppedFrames` is incremented for the displaced payload.
|
|
13
|
+
* - Terminal events (`node:done`, `node:error`) are NEVER coalesced or
|
|
14
|
+
* dropped — they bypass the bucket and emit immediately.
|
|
15
|
+
* - When the queue exceeds `maxQueueDepth` (default = burst), the oldest
|
|
16
|
+
* pending non-terminal frame is dropped and `droppedFrames` is incremented.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { SsePayload } from './sse-frame.ts';
|
|
20
|
+
|
|
21
|
+
const TERMINAL_TYPES: ReadonlySet<SsePayload['t']> = new Set(['node:done', 'node:error']);
|
|
22
|
+
|
|
23
|
+
export interface RateLimitOptions {
|
|
24
|
+
/** Tokens added per second. Default 60. */
|
|
25
|
+
tokensPerSecond?: number;
|
|
26
|
+
/** Bucket capacity (burst size). Default 120. */
|
|
27
|
+
burst?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Max pending non-terminal frames in the queue. Default = `burst`.
|
|
30
|
+
* Overflow drops the oldest pending frame.
|
|
31
|
+
*/
|
|
32
|
+
maxQueueDepth?: number;
|
|
33
|
+
/** Outbound emit callback (post-rate-limit). */
|
|
34
|
+
onEmit: (frame: SsePayload) => void;
|
|
35
|
+
/** Monotonic time source in milliseconds. Defaults to `performance.now()`. */
|
|
36
|
+
now?: () => number;
|
|
37
|
+
/**
|
|
38
|
+
* Schedule `fn` to run after `ms` milliseconds, returning a cancel function.
|
|
39
|
+
* Defaults to `setTimeout`. Injected in tests for deterministic draining.
|
|
40
|
+
*/
|
|
41
|
+
schedule?: (ms: number, fn: () => void) => () => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RateLimitMetrics {
|
|
45
|
+
droppedFrames: number;
|
|
46
|
+
queueDepth: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RateLimiter {
|
|
50
|
+
submit(frame: SsePayload): void;
|
|
51
|
+
metrics(): RateLimitMetrics;
|
|
52
|
+
/** Drain all queued frames immediately, ignoring tokens. */
|
|
53
|
+
flush(): void;
|
|
54
|
+
dispose(): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function defaultNow(): number {
|
|
58
|
+
return typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function defaultSchedule(ms: number, fn: () => void): () => void {
|
|
62
|
+
const handle = setTimeout(fn, ms);
|
|
63
|
+
return () => clearTimeout(handle);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function coalesceKey(frame: SsePayload): string | null {
|
|
67
|
+
if (frame.t !== 'node:status') return null;
|
|
68
|
+
const data = frame.data;
|
|
69
|
+
const nodeId =
|
|
70
|
+
data && typeof data === 'object' ? (data as { nodeId?: unknown }).nodeId : undefined;
|
|
71
|
+
if (typeof nodeId !== 'string' || nodeId.length === 0) return null;
|
|
72
|
+
return `${frame.flowId}\x00${nodeId}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createRateLimiter(opts: RateLimitOptions): RateLimiter {
|
|
76
|
+
const tokensPerSecond = Math.max(0.0001, opts.tokensPerSecond ?? 60);
|
|
77
|
+
const burst = Math.max(1, opts.burst ?? 120);
|
|
78
|
+
const maxQueueDepth = Math.max(1, opts.maxQueueDepth ?? burst);
|
|
79
|
+
const now = opts.now ?? defaultNow;
|
|
80
|
+
const schedule = opts.schedule ?? defaultSchedule;
|
|
81
|
+
|
|
82
|
+
let tokens = burst;
|
|
83
|
+
let lastRefill = now();
|
|
84
|
+
let droppedFrames = 0;
|
|
85
|
+
let drainCancel: (() => void) | null = null;
|
|
86
|
+
let disposed = false;
|
|
87
|
+
|
|
88
|
+
const queue: SsePayload[] = [];
|
|
89
|
+
/** coalesceKey -> queue index */
|
|
90
|
+
const queueIndex = new Map<string, number>();
|
|
91
|
+
|
|
92
|
+
function safeEmit(frame: SsePayload): void {
|
|
93
|
+
try {
|
|
94
|
+
opts.onEmit(frame);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error('[sse-rate-limit] onEmit listener threw:', err);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function refill(): void {
|
|
101
|
+
const t = now();
|
|
102
|
+
const dt = (t - lastRefill) / 1000;
|
|
103
|
+
if (dt <= 0) return;
|
|
104
|
+
tokens = Math.min(burst, tokens + dt * tokensPerSecond);
|
|
105
|
+
lastRefill = t;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function rebuildIndex(): void {
|
|
109
|
+
queueIndex.clear();
|
|
110
|
+
for (let i = 0; i < queue.length; i++) {
|
|
111
|
+
const frame = queue[i];
|
|
112
|
+
if (!frame) continue;
|
|
113
|
+
const k = coalesceKey(frame);
|
|
114
|
+
if (k !== null) queueIndex.set(k, i);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function clearDrainTimer(): void {
|
|
119
|
+
if (drainCancel) {
|
|
120
|
+
drainCancel();
|
|
121
|
+
drainCancel = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function scheduleDrain(): void {
|
|
126
|
+
if (disposed || queue.length === 0 || drainCancel) return;
|
|
127
|
+
refill();
|
|
128
|
+
const needed = Math.max(0, 1 - tokens);
|
|
129
|
+
const waitMs = Math.ceil((needed / tokensPerSecond) * 1000);
|
|
130
|
+
drainCancel = schedule(Math.max(1, waitMs), () => {
|
|
131
|
+
drainCancel = null;
|
|
132
|
+
drainQueue();
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function drainQueue(): void {
|
|
137
|
+
refill();
|
|
138
|
+
while (queue.length > 0 && tokens >= 1) {
|
|
139
|
+
const frame = queue.shift();
|
|
140
|
+
if (!frame) break;
|
|
141
|
+
tokens -= 1;
|
|
142
|
+
safeEmit(frame);
|
|
143
|
+
}
|
|
144
|
+
rebuildIndex();
|
|
145
|
+
scheduleDrain();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function enqueueNonTerminal(frame: SsePayload): void {
|
|
149
|
+
const key = coalesceKey(frame);
|
|
150
|
+
if (key !== null) {
|
|
151
|
+
const idx = queueIndex.get(key);
|
|
152
|
+
if (idx !== undefined && idx < queue.length && queue[idx] !== undefined) {
|
|
153
|
+
// Last-wins: replace the queued payload; older payload is "dropped".
|
|
154
|
+
queue[idx] = frame;
|
|
155
|
+
droppedFrames += 1;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (queue.length >= maxQueueDepth) {
|
|
160
|
+
queue.shift();
|
|
161
|
+
droppedFrames += 1;
|
|
162
|
+
rebuildIndex();
|
|
163
|
+
}
|
|
164
|
+
const newIdx = queue.length;
|
|
165
|
+
queue.push(frame);
|
|
166
|
+
if (key !== null) queueIndex.set(key, newIdx);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
submit(frame) {
|
|
171
|
+
if (disposed) return;
|
|
172
|
+
|
|
173
|
+
if (TERMINAL_TYPES.has(frame.t)) {
|
|
174
|
+
safeEmit(frame);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
refill();
|
|
179
|
+
if (queue.length === 0 && tokens >= 1) {
|
|
180
|
+
tokens -= 1;
|
|
181
|
+
safeEmit(frame);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
enqueueNonTerminal(frame);
|
|
186
|
+
scheduleDrain();
|
|
187
|
+
},
|
|
188
|
+
metrics() {
|
|
189
|
+
return { droppedFrames, queueDepth: queue.length };
|
|
190
|
+
},
|
|
191
|
+
flush() {
|
|
192
|
+
for (const frame of queue) safeEmit(frame);
|
|
193
|
+
queue.length = 0;
|
|
194
|
+
queueIndex.clear();
|
|
195
|
+
clearDrainTimer();
|
|
196
|
+
},
|
|
197
|
+
dispose() {
|
|
198
|
+
if (disposed) return;
|
|
199
|
+
disposed = true;
|
|
200
|
+
clearDrainTimer();
|
|
201
|
+
queue.length = 0;
|
|
202
|
+
queueIndex.clear();
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE tap: subscribes to the local EventBus for every flowId in the shared
|
|
3
|
+
* project, mirrors each bridged event into a per-flow ring buffer, and feeds
|
|
4
|
+
* the relay bridge via `onEvent`. A process-wide monotonic `seq` counter is
|
|
5
|
+
* stamped on every payload so peers can detect drops and out-of-order frames.
|
|
6
|
+
*
|
|
7
|
+
* Used by `apps/studio/src/share.ts` (US-067) to forward live runtime events
|
|
8
|
+
* to connected peers, and exposes a `snapshot()` of last-seen status per node
|
|
9
|
+
* so newly joined peers can be primed without replaying the entire buffer
|
|
10
|
+
* (US-069 / US-070).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { EventBus, StudioEvent } from '../events.ts';
|
|
14
|
+
import { type SsePayload, isSseEventType } from './sse-frame.ts';
|
|
15
|
+
import {
|
|
16
|
+
type RateLimitMetrics,
|
|
17
|
+
type RateLimitOptions,
|
|
18
|
+
type RateLimiter,
|
|
19
|
+
createRateLimiter,
|
|
20
|
+
} from './sse-rate-limit.ts';
|
|
21
|
+
|
|
22
|
+
export interface SseTapOptions {
|
|
23
|
+
/** Called on every refresh to compute the desired subscription set. */
|
|
24
|
+
flowIds: () => string[];
|
|
25
|
+
/** Invoked synchronously with each bridged event's payload (post-rate-limit). */
|
|
26
|
+
onEvent: (frame: SsePayload) => void;
|
|
27
|
+
/** Per-flow ring-buffer cap; defaults to 50. */
|
|
28
|
+
bufferSize?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Token-bucket rate limit + coalescing applied between the EventBus and
|
|
31
|
+
* `onEvent`. Pass `false` to disable (frames forwarded synchronously).
|
|
32
|
+
* Defaults to PRD-spec values: 60 frames/sec sustained, burst 120.
|
|
33
|
+
*/
|
|
34
|
+
rateLimit?: false | Omit<RateLimitOptions, 'onEmit'>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SseTap {
|
|
38
|
+
start(): void;
|
|
39
|
+
stop(): void;
|
|
40
|
+
/** Re-syncs the subscription set against `opts.flowIds()`. Idempotent. */
|
|
41
|
+
refreshFlows(): void;
|
|
42
|
+
/**
|
|
43
|
+
* Latest per-node status payload, grouped by flow. Only carries node-level
|
|
44
|
+
* event types (`node:running`/`node:done`/`node:error`/`node:status`).
|
|
45
|
+
*/
|
|
46
|
+
snapshot(): Record<string, Record<string, SsePayload>>;
|
|
47
|
+
/** Rate-limit metrics; zeros when rate limiting is disabled. */
|
|
48
|
+
metrics(): RateLimitMetrics;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DEFAULT_BUFFER_SIZE = 50;
|
|
52
|
+
|
|
53
|
+
const NODE_STATUS_TYPES: ReadonlySet<SsePayload['t']> = new Set([
|
|
54
|
+
'node:running',
|
|
55
|
+
'node:done',
|
|
56
|
+
'node:error',
|
|
57
|
+
'node:status',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
function extractNodeId(payload: unknown): string | null {
|
|
61
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
62
|
+
const candidate = (payload as { nodeId?: unknown }).nodeId;
|
|
63
|
+
return typeof candidate === 'string' && candidate.length > 0 ? candidate : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createSseTap(events: EventBus, opts: SseTapOptions): SseTap {
|
|
67
|
+
const bufferSize = Math.max(1, opts.bufferSize ?? DEFAULT_BUFFER_SIZE);
|
|
68
|
+
let seqCounter = 0;
|
|
69
|
+
let started = false;
|
|
70
|
+
|
|
71
|
+
const unsubs = new Map<string, () => void>();
|
|
72
|
+
const buffers = new Map<string, SsePayload[]>();
|
|
73
|
+
const latestByNode = new Map<string, Map<string, SsePayload>>();
|
|
74
|
+
|
|
75
|
+
const limiter: RateLimiter | null =
|
|
76
|
+
opts.rateLimit === false
|
|
77
|
+
? null
|
|
78
|
+
: createRateLimiter({ ...(opts.rateLimit ?? {}), onEmit: opts.onEvent });
|
|
79
|
+
|
|
80
|
+
function pushToBuffer(flowId: string, payload: SsePayload): void {
|
|
81
|
+
let buf = buffers.get(flowId);
|
|
82
|
+
if (!buf) {
|
|
83
|
+
buf = [];
|
|
84
|
+
buffers.set(flowId, buf);
|
|
85
|
+
}
|
|
86
|
+
buf.push(payload);
|
|
87
|
+
if (buf.length > bufferSize) buf.shift();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function recordLatestStatus(flowId: string, payload: SsePayload): void {
|
|
91
|
+
if (!NODE_STATUS_TYPES.has(payload.t)) return;
|
|
92
|
+
const nodeId = extractNodeId(payload.data);
|
|
93
|
+
if (!nodeId) return;
|
|
94
|
+
let nodeMap = latestByNode.get(flowId);
|
|
95
|
+
if (!nodeMap) {
|
|
96
|
+
nodeMap = new Map();
|
|
97
|
+
latestByNode.set(flowId, nodeMap);
|
|
98
|
+
}
|
|
99
|
+
nodeMap.set(nodeId, payload);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function makeSubscriber(flowId: string) {
|
|
103
|
+
return (event: StudioEvent): void => {
|
|
104
|
+
if (!isSseEventType(event.type)) return;
|
|
105
|
+
const payload: SsePayload = {
|
|
106
|
+
t: event.type,
|
|
107
|
+
flowId: event.flowId,
|
|
108
|
+
ts: event.ts,
|
|
109
|
+
data: event.payload,
|
|
110
|
+
seq: seqCounter++,
|
|
111
|
+
};
|
|
112
|
+
pushToBuffer(flowId, payload);
|
|
113
|
+
recordLatestStatus(flowId, payload);
|
|
114
|
+
if (limiter) {
|
|
115
|
+
limiter.submit(payload);
|
|
116
|
+
} else {
|
|
117
|
+
try {
|
|
118
|
+
opts.onEvent(payload);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error('[sse-tap] onEvent listener threw:', err);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function subscribeFlow(flowId: string): void {
|
|
127
|
+
if (unsubs.has(flowId)) return;
|
|
128
|
+
const off = events.subscribe(flowId, makeSubscriber(flowId));
|
|
129
|
+
unsubs.set(flowId, off);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function unsubscribeFlow(flowId: string): void {
|
|
133
|
+
const off = unsubs.get(flowId);
|
|
134
|
+
if (!off) return;
|
|
135
|
+
off();
|
|
136
|
+
unsubs.delete(flowId);
|
|
137
|
+
buffers.delete(flowId);
|
|
138
|
+
latestByNode.delete(flowId);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function syncSubscriptions(): void {
|
|
142
|
+
const desired = new Set(opts.flowIds());
|
|
143
|
+
for (const existing of [...unsubs.keys()]) {
|
|
144
|
+
if (!desired.has(existing)) unsubscribeFlow(existing);
|
|
145
|
+
}
|
|
146
|
+
for (const id of desired) subscribeFlow(id);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
start(): void {
|
|
151
|
+
if (started) return;
|
|
152
|
+
started = true;
|
|
153
|
+
syncSubscriptions();
|
|
154
|
+
},
|
|
155
|
+
stop(): void {
|
|
156
|
+
if (!started) return;
|
|
157
|
+
started = false;
|
|
158
|
+
for (const off of unsubs.values()) off();
|
|
159
|
+
unsubs.clear();
|
|
160
|
+
buffers.clear();
|
|
161
|
+
latestByNode.clear();
|
|
162
|
+
limiter?.dispose();
|
|
163
|
+
},
|
|
164
|
+
refreshFlows(): void {
|
|
165
|
+
if (!started) return;
|
|
166
|
+
syncSubscriptions();
|
|
167
|
+
},
|
|
168
|
+
snapshot(): Record<string, Record<string, SsePayload>> {
|
|
169
|
+
const out: Record<string, Record<string, SsePayload>> = {};
|
|
170
|
+
for (const [flowId, nodeMap] of latestByNode) {
|
|
171
|
+
const nodes: Record<string, SsePayload> = {};
|
|
172
|
+
for (const [nodeId, payload] of nodeMap) {
|
|
173
|
+
nodes[nodeId] = payload;
|
|
174
|
+
}
|
|
175
|
+
out[flowId] = nodes;
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
},
|
|
179
|
+
metrics(): RateLimitMetrics {
|
|
180
|
+
return limiter ? limiter.metrics() : { droppedFrames: 0, queueDepth: 0 };
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session JSONL audit log for inbound share frames.
|
|
3
|
+
*
|
|
4
|
+
* One file per session at `${opts.dir}/${opts.sessionId}.jsonl`. Each accepted
|
|
5
|
+
* or rejected envelope writes one JSON.stringify(entry) + '\n' line via
|
|
6
|
+
* `fs.appendFileSync` (or an injected appender for tests). The directory is
|
|
7
|
+
* created with mkdirSync recursive on first write.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { appendFile, mkdir, readFile } from 'node:fs/promises';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import type { Envelope } from './share-envelope.ts';
|
|
15
|
+
|
|
16
|
+
export type AuditVerdict = 'accept' | 'reject';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Per-frame audit entry written by the WS message dispatcher. Coexists with
|
|
20
|
+
* `RpcAuditEntry`, `FileUploadAuditEntry`, and `AuditEntry` (the US-078 shape)
|
|
21
|
+
* on the same JSONL file; readers should treat each line as the union.
|
|
22
|
+
*/
|
|
23
|
+
export interface FrameAuditEntry {
|
|
24
|
+
ts: number;
|
|
25
|
+
peerId: string;
|
|
26
|
+
displayName: string;
|
|
27
|
+
type: Envelope['type'];
|
|
28
|
+
verdict: AuditVerdict;
|
|
29
|
+
reason?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AuditLog {
|
|
33
|
+
append(entry: FrameAuditEntry): void;
|
|
34
|
+
close(): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AuditLogOpts {
|
|
38
|
+
dir: string;
|
|
39
|
+
sessionId: string;
|
|
40
|
+
// Injected for tests so we don't need a tmp dir. Default uses fs.appendFileSync.
|
|
41
|
+
appendFn?: (filePath: string, line: string) => void;
|
|
42
|
+
// Injected for tests so we don't need to mkdir on disk. Default uses fs.mkdirSync.
|
|
43
|
+
mkdirFn?: (dir: string) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const defaultAppend = (filePath: string, line: string): void => {
|
|
47
|
+
appendFileSync(filePath, line);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const defaultMkdir = (dir: string): void => {
|
|
51
|
+
mkdirSync(dir, { recursive: true });
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* RPC-specific audit entry shape. Used by US-038's `handleRpcFrame` after a
|
|
56
|
+
* dispatch attempt — written via `appendShareAudit` to the same per-session
|
|
57
|
+
* JSONL file `createAuditLog` writes to, so the two entry shapes coexist as a
|
|
58
|
+
* union on disk (callers reading the file should treat each line as
|
|
59
|
+
* `AuditEntry | RpcAuditEntry`).
|
|
60
|
+
*/
|
|
61
|
+
export interface RpcAuditEntry {
|
|
62
|
+
ts: number;
|
|
63
|
+
peerId: string;
|
|
64
|
+
op: string;
|
|
65
|
+
flowId: string;
|
|
66
|
+
ok: boolean;
|
|
67
|
+
reason?: string;
|
|
68
|
+
// Mirrors the `attributedTo` field on the outgoing `node-patched` broadcast
|
|
69
|
+
// so the audit trail records the same originator label peers will see. For
|
|
70
|
+
// peer-originated rpcs this is `{ peerId, displayName }` from the share
|
|
71
|
+
// controller's peer map; for host-local edits it is
|
|
72
|
+
// `{ peerId: 'host', displayName: hostDisplayName }`.
|
|
73
|
+
attributedTo?: { peerId: string; displayName: string };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface AppendShareAuditOpts {
|
|
77
|
+
// Override the default `~/.seeflow/share-history` root. Tests inject a
|
|
78
|
+
// tmpdir so they never touch the user's real audit dir.
|
|
79
|
+
dir?: string;
|
|
80
|
+
appendFn?: (filePath: string, line: string) => void;
|
|
81
|
+
mkdirFn?: (dir: string) => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const isSafeSessionId = (sessionId: string): boolean => {
|
|
85
|
+
if (sessionId.length === 0) return false;
|
|
86
|
+
if (sessionId === '.' || sessionId === '..') return false;
|
|
87
|
+
if (sessionId.includes('/') || sessionId.includes('\\')) return false;
|
|
88
|
+
if (sessionId.includes('\0')) return false;
|
|
89
|
+
return true;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* File-upload audit entry. Mirrors the design doc shape:
|
|
94
|
+
* `{ peerId, op:'file-upload', nodeId, filename, size, sha256, ts, accept }`.
|
|
95
|
+
* Coexists with `RpcAuditEntry` + `AuditEntry` on the same JSONL file; readers
|
|
96
|
+
* should treat each line as the union.
|
|
97
|
+
*/
|
|
98
|
+
export interface FileUploadAuditEntry {
|
|
99
|
+
ts: number;
|
|
100
|
+
peerId: string;
|
|
101
|
+
op: 'file-upload';
|
|
102
|
+
nodeId: string;
|
|
103
|
+
filename: string;
|
|
104
|
+
size: number;
|
|
105
|
+
sha256: string;
|
|
106
|
+
accept: boolean;
|
|
107
|
+
reason?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Append one JSON line to `<dir>/<sessionId>.jsonl`. The directory is created
|
|
112
|
+
* recursively on first write. Rejects sessionIds that contain path separators,
|
|
113
|
+
* NUL bytes, or `..` traversal attempts so a tampered peer can't drop frames
|
|
114
|
+
* outside the audit root.
|
|
115
|
+
*/
|
|
116
|
+
export function appendShareAudit(
|
|
117
|
+
sessionId: string,
|
|
118
|
+
entry: RpcAuditEntry | FileUploadAuditEntry,
|
|
119
|
+
opts: AppendShareAuditOpts = {},
|
|
120
|
+
): void {
|
|
121
|
+
if (!isSafeSessionId(sessionId)) {
|
|
122
|
+
throw new Error(`invalid sessionId: ${JSON.stringify(sessionId)}`);
|
|
123
|
+
}
|
|
124
|
+
const dir = opts.dir ?? join(homedir(), '.seeflow', 'share-history');
|
|
125
|
+
const appendFn = opts.appendFn ?? defaultAppend;
|
|
126
|
+
const mkdirFn = opts.mkdirFn ?? defaultMkdir;
|
|
127
|
+
mkdirFn(dir);
|
|
128
|
+
const filePath = join(dir, `${sessionId}.jsonl`);
|
|
129
|
+
appendFn(filePath, `${JSON.stringify(entry)}\n`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function createAuditLog(opts: AuditLogOpts): AuditLog {
|
|
133
|
+
const appendFn = opts.appendFn ?? defaultAppend;
|
|
134
|
+
const mkdirFn = opts.mkdirFn ?? defaultMkdir;
|
|
135
|
+
const filePath = join(opts.dir, `${opts.sessionId}.jsonl`);
|
|
136
|
+
let dirReady = false;
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
append(entry) {
|
|
140
|
+
if (!dirReady) {
|
|
141
|
+
mkdirFn(opts.dir);
|
|
142
|
+
dirReady = true;
|
|
143
|
+
}
|
|
144
|
+
appendFn(filePath, `${JSON.stringify(entry)}\n`);
|
|
145
|
+
},
|
|
146
|
+
async close() {
|
|
147
|
+
// No buffered writes — appendFileSync is synchronous. Hook here for any
|
|
148
|
+
// future flush logic.
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Phase-8 audit shape covering RPCs, kicks, rotations, the kill-switch, plus
|
|
155
|
+
* host start/stop and peer join/leave. Coexists on disk with `FrameAuditEntry`,
|
|
156
|
+
* `RpcAuditEntry`, and `FileUploadAuditEntry` — readers should tolerate the
|
|
157
|
+
* union per-line.
|
|
158
|
+
*/
|
|
159
|
+
export type AuditKind =
|
|
160
|
+
| 'rpc-accept'
|
|
161
|
+
| 'rpc-reject'
|
|
162
|
+
| 'kick'
|
|
163
|
+
| 'rotate'
|
|
164
|
+
| 'kill-switch'
|
|
165
|
+
| 'host-start'
|
|
166
|
+
| 'host-stop'
|
|
167
|
+
| 'peer-join'
|
|
168
|
+
| 'peer-leave';
|
|
169
|
+
|
|
170
|
+
export interface AuditEntry {
|
|
171
|
+
ts: number;
|
|
172
|
+
peerId: string | null;
|
|
173
|
+
displayName: string | null;
|
|
174
|
+
kind: AuditKind;
|
|
175
|
+
op?: string;
|
|
176
|
+
reason?: string;
|
|
177
|
+
details?: Record<string, unknown>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface AuditLogger {
|
|
181
|
+
append(entry: Omit<AuditEntry, 'ts'>): Promise<void>;
|
|
182
|
+
list(opts?: {
|
|
183
|
+
limit?: number;
|
|
184
|
+
cursor?: number;
|
|
185
|
+
}): Promise<{ entries: AuditEntry[]; nextCursor: number | null }>;
|
|
186
|
+
close(): Promise<void>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const auditLockChains = new Map<string, Promise<void>>();
|
|
190
|
+
|
|
191
|
+
const defaultRoot = (): string => join(homedir(), '.seeflow', 'share-history');
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Build a per-session audit logger backed by `${root}/${sessionId}.jsonl`.
|
|
195
|
+
* `append` serializes inside-process per file so 10+ concurrent callers can't
|
|
196
|
+
* interleave partial lines; the kernel-level O_APPEND on `fs.appendFile` keeps
|
|
197
|
+
* cross-process writes safe up to PIPE_BUF. `list` paginates by byte offset so
|
|
198
|
+
* callers can resume from `nextCursor` without re-parsing what they've seen.
|
|
199
|
+
*/
|
|
200
|
+
export function createAuditLogger(sessionId: string, root?: string): AuditLogger {
|
|
201
|
+
if (!isSafeSessionId(sessionId)) {
|
|
202
|
+
throw new Error(`invalid sessionId: ${JSON.stringify(sessionId)}`);
|
|
203
|
+
}
|
|
204
|
+
const dir = root ?? defaultRoot();
|
|
205
|
+
const filePath = join(dir, `${sessionId}.jsonl`);
|
|
206
|
+
let dirReady = false;
|
|
207
|
+
|
|
208
|
+
const ensureDir = async (): Promise<void> => {
|
|
209
|
+
if (dirReady) return;
|
|
210
|
+
await mkdir(dir, { recursive: true });
|
|
211
|
+
dirReady = true;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const append = async (entry: Omit<AuditEntry, 'ts'>): Promise<void> => {
|
|
215
|
+
await ensureDir();
|
|
216
|
+
const full: AuditEntry = { ...entry, ts: Date.now() };
|
|
217
|
+
const line = `${JSON.stringify(full)}\n`;
|
|
218
|
+
const prev = auditLockChains.get(filePath) ?? Promise.resolve();
|
|
219
|
+
const task = prev.then(() => appendFile(filePath, line));
|
|
220
|
+
auditLockChains.set(
|
|
221
|
+
filePath,
|
|
222
|
+
task.then(
|
|
223
|
+
() => undefined,
|
|
224
|
+
() => undefined,
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
await task;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const list = async (
|
|
231
|
+
opts: { limit?: number; cursor?: number } = {},
|
|
232
|
+
): Promise<{ entries: AuditEntry[]; nextCursor: number | null }> => {
|
|
233
|
+
const limit = opts.limit ?? 200;
|
|
234
|
+
const cursor = opts.cursor ?? 0;
|
|
235
|
+
let buf: Buffer;
|
|
236
|
+
try {
|
|
237
|
+
buf = await readFile(filePath);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
240
|
+
if (code === 'ENOENT') return { entries: [], nextCursor: null };
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
const entries: AuditEntry[] = [];
|
|
244
|
+
let offset = cursor < 0 ? 0 : cursor;
|
|
245
|
+
while (offset < buf.length && entries.length < limit) {
|
|
246
|
+
const nl = buf.indexOf(0x0a, offset);
|
|
247
|
+
if (nl === -1) break;
|
|
248
|
+
const line = buf.subarray(offset, nl).toString('utf8');
|
|
249
|
+
offset = nl + 1;
|
|
250
|
+
if (line.length === 0) continue;
|
|
251
|
+
try {
|
|
252
|
+
entries.push(JSON.parse(line) as AuditEntry);
|
|
253
|
+
} catch {
|
|
254
|
+
// Skip corrupted line; advance past it so list() stays monotonic.
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const nextCursor = offset >= buf.length ? null : offset;
|
|
258
|
+
return { entries, nextCursor };
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const close = async (): Promise<void> => {
|
|
262
|
+
const chain = auditLockChains.get(filePath);
|
|
263
|
+
if (chain) await chain;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return { append, list, close };
|
|
267
|
+
}
|