@tuongaz/seeflow 0.1.109 → 0.1.111
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/web/assets/{architectureDiagram-3BPJPVTR-CvIAMnMC.js → architectureDiagram-3BPJPVTR-C-2fCGRP.js} +1 -1
- package/dist/web/assets/{blockDiagram-GPEHLZMM-C55itdvA.js → blockDiagram-GPEHLZMM-Br3qZBhv.js} +1 -1
- package/dist/web/assets/{c4Diagram-AAUBKEIU-BUJz6OcY.js → c4Diagram-AAUBKEIU-C4uX4ZH4.js} +1 -1
- package/dist/web/assets/channel-qhQeh_4Q.js +1 -0
- package/dist/web/assets/{chart-Ck4M6xxI.js → chart-9kjHxc0V.js} +1 -1
- package/dist/web/assets/{chunk-2J33WTMH-CYHRe8M7.js → chunk-2J33WTMH-2Fn5NN7i.js} +1 -1
- package/dist/web/assets/{chunk-4BX2VUAB-0G6VpCw_.js → chunk-4BX2VUAB-Df_7ccfg.js} +1 -1
- package/dist/web/assets/{chunk-55IACEB6-YeyA7Efg.js → chunk-55IACEB6-DdU4dw35.js} +1 -1
- package/dist/web/assets/{chunk-727SXJPM-irL9oAdE.js → chunk-727SXJPM-1at9fA5f.js} +1 -1
- package/dist/web/assets/{chunk-AQP2D5EJ-DB0ZGTqs.js → chunk-AQP2D5EJ-CPe9M1hv.js} +1 -1
- package/dist/web/assets/{chunk-FMBD7UC4-Z48rYWhG.js → chunk-FMBD7UC4-DCUEmPvJ.js} +1 -1
- package/dist/web/assets/{chunk-ND2GUHAM-BgYSDKdi.js → chunk-ND2GUHAM-tE2Ii-jJ.js} +1 -1
- package/dist/web/assets/{chunk-QZHKN3VN-CzcjFmi-.js → chunk-QZHKN3VN-Db5o8Kzv.js} +1 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-BCHg-KrI.js +1 -0
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-BCHg-KrI.js +1 -0
- package/dist/web/assets/{code-block-BwE8Ip6V.js → code-block-CTINSk3z.js} +1 -1
- package/dist/web/assets/{cose-bilkent-S5V4N54A-DtufDmle.js → cose-bilkent-S5V4N54A-D7tNx5-t.js} +1 -1
- package/dist/web/assets/{dagre-BM42HDAG-DwYVzLme.js → dagre-BM42HDAG-Cdb53Asb.js} +1 -1
- package/dist/web/assets/{diagram-2AECGRRQ-DDw_qvjI.js → diagram-2AECGRRQ-HS6pnGrL.js} +1 -1
- package/dist/web/assets/{diagram-5GNKFQAL-BYG2VdwF.js → diagram-5GNKFQAL-c9k4swAt.js} +1 -1
- package/dist/web/assets/{diagram-KO2AKTUF-Drp2zu_G.js → diagram-KO2AKTUF-CB3k4xVS.js} +1 -1
- package/dist/web/assets/{diagram-LMA3HP47-CXmAihfn.js → diagram-LMA3HP47-XKAfR3DB.js} +1 -1
- package/dist/web/assets/{diagram-OG6HWLK6-i6-iKnAU.js → diagram-OG6HWLK6-18dGr0GC.js} +1 -1
- package/dist/web/assets/{erDiagram-TEJ5UH35-Bo96y9hO.js → erDiagram-TEJ5UH35-9DnFE7wo.js} +1 -1
- package/dist/web/assets/{flowDiagram-I6XJVG4X-D9fneMTu.js → flowDiagram-I6XJVG4X-CglLanUO.js} +1 -1
- package/dist/web/assets/{ganttDiagram-6RSMTGT7-Cbefa8NE.js → ganttDiagram-6RSMTGT7-_zKCXbXg.js} +1 -1
- package/dist/web/assets/{gitGraphDiagram-PVQCEYII-BHYd5nUs.js → gitGraphDiagram-PVQCEYII-D6axSpL3.js} +1 -1
- package/dist/web/assets/{iconify-BuhA_8An.js → iconify-CF3Xx8Oq.js} +1 -1
- package/dist/web/assets/index-BTL8cVgF.js +8629 -0
- package/dist/web/assets/{index.es-pImjWTGX.js → index.es-DyQqI4EJ.js} +1 -1
- package/dist/web/assets/{infoDiagram-5YYISTIA-Bw7MIIwj.js → infoDiagram-5YYISTIA-BfnEVJMR.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-CySt1i7t.js → ishikawaDiagram-YF4QCWOH-C-CbbOkc.js} +1 -1
- package/dist/web/assets/{journeyDiagram-JHISSGLW-q4YV61wv.js → journeyDiagram-JHISSGLW-CxcYZYhO.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-B95ptK5L.js → jspdf.es.min-BoDPBXoT.js} +3 -3
- package/dist/web/assets/{kanban-definition-UN3LZRKU-C1oN-zoM.js → kanban-definition-UN3LZRKU-Dw9dGj5D.js} +1 -1
- package/dist/web/assets/{linear-DPSv7VcC.js → linear-DJLoiaYn.js} +1 -1
- package/dist/web/assets/{markdown-btlM2PIA.js → markdown-BXIhHJY7.js} +1 -1
- package/dist/web/assets/{mermaid.core-Cmch2GTm.js → mermaid.core-CTeg8SMj.js} +4 -4
- package/dist/web/assets/{mindmap-definition-RKZ34NQL--XOTWr6e.js → mindmap-definition-RKZ34NQL-DG4pYeYK.js} +1 -1
- package/dist/web/assets/{pieDiagram-4H26LBE5-DxZgtzul.js → pieDiagram-4H26LBE5-qPHgJ6_d.js} +1 -1
- package/dist/web/assets/{quadrantDiagram-W4KKPZXB-bbV2bAO3.js → quadrantDiagram-W4KKPZXB-C-yiyax0.js} +1 -1
- package/dist/web/assets/{requirementDiagram-4Y6WPE33-CiJM8hio.js → requirementDiagram-4Y6WPE33-vBgnScGl.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-5OEKKPKP-DABBnuaB.js → sankeyDiagram-5OEKKPKP-H-Yoexss.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-3UESZ5HK-BTQJk7bM.js → sequenceDiagram-3UESZ5HK-B0PUqbDC.js} +1 -1
- package/dist/web/assets/{stateDiagram-AJRCARHV-yL0tAd3x.js → stateDiagram-AJRCARHV-LHZJU6NT.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU-Jjcvv867.js +1 -0
- package/dist/web/assets/{time-hCZUKGxT.js → time-CytNQj8l.js} +1 -1
- package/dist/web/assets/{timeline-definition-PNZ67QCA-CG6k16Wj.js → timeline-definition-PNZ67QCA-CNYtzzkX.js} +1 -1
- package/dist/web/assets/{vennDiagram-CIIHVFJN-BVH530xf.js → vennDiagram-CIIHVFJN-CR3j3MfQ.js} +1 -1
- package/dist/web/assets/{wardley-L42UT6IY-BYVkFMrL.js → wardley-L42UT6IY-DsRtYkk3.js} +1 -1
- package/dist/web/assets/{wardleyDiagram-YWT4CUSO-BGMuHejP.js → wardleyDiagram-YWT4CUSO-Bkiae0No.js} +1 -1
- package/dist/web/assets/{xychartDiagram-2RQKCTM6-D6MSlnJD.js → xychartDiagram-2RQKCTM6-DFoBCrdz.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/src/api.ts +0 -291
- package/src/server.ts +0 -20
- package/dist/web/assets/channel-DtcQ9fhj.js +0 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-BjYXB41E.js +0 -1
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-BjYXB41E.js +0 -1
- package/dist/web/assets/index-08hmlCqO.js +0 -8644
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU-BzWj6i1l.js +0 -1
- package/src/share/sse-frame.ts +0 -85
- package/src/share/sse-outbound-queue.ts +0 -173
- package/src/share/sse-rate-limit.ts +0 -205
- package/src/share/sse-tap.ts +0 -183
- package/src/share-audit.ts +0 -267
- package/src/share-envelope.ts +0 -155
- package/src/share-file-request.ts +0 -399
- package/src/share-file-resolver.ts +0 -68
- package/src/share-file-upload.ts +0 -595
- package/src/share-files-manifest.ts +0 -232
- package/src/share-ratelimit.ts +0 -69
- package/src/share-rpc-schema.ts +0 -249
- package/src/share-transport.ts +0 -205
- package/src/share.ts +0 -1663
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{b as r,a as e,s as a,S as s}from"./chunk-AQP2D5EJ-DB0ZGTqs.js";import{a as i}from"./mermaid.core-Cmch2GTm.js";import"./index-08hmlCqO.js";import"./chunk-55IACEB6-YeyA7Efg.js";import"./chunk-2J33WTMH-CYHRe8M7.js";import"./purify.es-CLGrRn1w.js";import"./step-CWvwoXpJ.js";var n={parser:a,get db(){return new s(2)},renderer:e,styles:r,init:i(t=>{t.state||(t.state={}),t.state.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")};export{n as diagram};
|
package/src/share/sse-frame.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SSE bridge envelope payload schema for live-share `sse` frames.
|
|
3
|
-
*
|
|
4
|
-
* Single source of truth for the wire shape of runtime events relayed from
|
|
5
|
-
* the host studio's local EventBus to peers over the share WebSocket. Both
|
|
6
|
-
* sides parse against the same Zod schema; drift between this file and the
|
|
7
|
-
* peer SPA's mirror (`seeflow-viewer/src/lib/share-sse-frame.ts`) is gated
|
|
8
|
-
* by `apps/studio/scripts/check-sse-frame-sync.ts`.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { z } from 'zod';
|
|
12
|
-
import type { StudioEvent } from '../events.ts';
|
|
13
|
-
|
|
14
|
-
// SYNC-WITH-PEER:BEGIN
|
|
15
|
-
export const SSE_EVENT_TYPES = [
|
|
16
|
-
'flow:reload',
|
|
17
|
-
'node:running',
|
|
18
|
-
'node:done',
|
|
19
|
-
'node:error',
|
|
20
|
-
'node:status',
|
|
21
|
-
] as const;
|
|
22
|
-
|
|
23
|
-
export const SseEventTypeSchema = z.enum(SSE_EVENT_TYPES);
|
|
24
|
-
export type SseEventType = z.infer<typeof SseEventTypeSchema>;
|
|
25
|
-
|
|
26
|
-
export const SsePayloadSchema = z.object({
|
|
27
|
-
t: SseEventTypeSchema,
|
|
28
|
-
flowId: z.string().min(1),
|
|
29
|
-
ts: z.number().int().nonnegative(),
|
|
30
|
-
data: z.unknown(),
|
|
31
|
-
seq: z.number().int().nonnegative(),
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
export type SsePayload = z.infer<typeof SsePayloadSchema>;
|
|
35
|
-
|
|
36
|
-
export interface SseEnvelope {
|
|
37
|
-
v: 1;
|
|
38
|
-
type: 'sse';
|
|
39
|
-
from: 'host';
|
|
40
|
-
to: 'all';
|
|
41
|
-
payload: SsePayload;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function isSseEventType(t: string): t is SseEventType {
|
|
45
|
-
return (SSE_EVENT_TYPES as readonly string[]).includes(t);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Snapshot replay payload sent to a freshly-joined peer so its canvas badges
|
|
50
|
-
* match the host without waiting for the next live tick. `flows` is a 2-level
|
|
51
|
-
* map of flowId -> nodeId -> latest SsePayload observed by the host's tap.
|
|
52
|
-
* When the serialized snapshot exceeds the 256 KB per-frame cap, the host
|
|
53
|
-
* splits per-flow and stamps each frame with `chunk` (zero-based) + `total`
|
|
54
|
-
* so the peer can reassemble before applying.
|
|
55
|
-
*/
|
|
56
|
-
export const SseSnapshotPayloadSchema = z.object({
|
|
57
|
-
flows: z.record(z.string(), z.record(z.string(), SsePayloadSchema)),
|
|
58
|
-
chunk: z.number().int().nonnegative().optional(),
|
|
59
|
-
total: z.number().int().positive().optional(),
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
export type SseSnapshotPayload = z.infer<typeof SseSnapshotPayloadSchema>;
|
|
63
|
-
// SYNC-WITH-PEER:END
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Wrap a local StudioEvent into a relay-shaped `sse` envelope. Returns null
|
|
67
|
-
* when the event type is not one of the SSE-bridged kinds (`file:changed`,
|
|
68
|
-
* `registry:reload`) so callers can filter without try/catch.
|
|
69
|
-
*/
|
|
70
|
-
export function wrapAsSseFrame(event: StudioEvent, seq: number): SseEnvelope | null {
|
|
71
|
-
if (!isSseEventType(event.type)) return null;
|
|
72
|
-
return {
|
|
73
|
-
v: 1,
|
|
74
|
-
type: 'sse',
|
|
75
|
-
from: 'host',
|
|
76
|
-
to: 'all',
|
|
77
|
-
payload: {
|
|
78
|
-
t: event.type,
|
|
79
|
-
flowId: event.flowId,
|
|
80
|
-
ts: event.ts,
|
|
81
|
-
data: event.payload,
|
|
82
|
-
seq,
|
|
83
|
-
},
|
|
84
|
-
};
|
|
85
|
-
}
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* US-072 — Per-peer outbound SSE queue with drop-on-slow-consumer.
|
|
3
|
-
*
|
|
4
|
-
* A bounded async queue, one instance per connected peer, that decouples the
|
|
5
|
-
* host's SSE bridge (synchronous EventBus -> tap -> per-peer enqueue) from the
|
|
6
|
-
* relay WebSocket's send path. If a peer's drain is slow, frames accumulate up
|
|
7
|
-
* to `maxFrames` (default 256). On overflow:
|
|
8
|
-
*
|
|
9
|
-
* - Non-terminal new frame: evict the OLDEST non-terminal entry. If no
|
|
10
|
-
* non-terminal entries exist (queue full of terminals), drop the new one.
|
|
11
|
-
* - Terminal new frame (`node:done` / `node:error`): evict the OLDEST entry
|
|
12
|
-
* regardless of type so the terminal is always queued.
|
|
13
|
-
*
|
|
14
|
-
* Per-peer metrics (`queueDepth`, `droppedFrames`, `lastSendMs`) are surfaced
|
|
15
|
-
* to the LiveShareDialog via the local `/api/share/state` SSE stream. When the
|
|
16
|
-
* rolling 60s drop count exceeds 100, the queue fires `onResyncTriggered` so
|
|
17
|
-
* the host can emit an `sse-snapshot` to recover the peer's canvas state.
|
|
18
|
-
*
|
|
19
|
-
* Enqueue is synchronous and non-blocking; the producer (the SSE tap) never
|
|
20
|
-
* awaits the WebSocket send. The drain runs as a single concurrent async loop
|
|
21
|
-
* — at most one in-flight send per peer keeps frame ordering stable while
|
|
22
|
-
* still letting different peers' queues drain in parallel.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import type { SsePayload } from './sse-frame.ts';
|
|
26
|
-
|
|
27
|
-
const TERMINAL_TYPES = new Set<SsePayload['t']>(['node:done', 'node:error']);
|
|
28
|
-
|
|
29
|
-
export const DEFAULT_MAX_FRAMES = 256;
|
|
30
|
-
export const DEFAULT_DROP_RESYNC_THRESHOLD = 100;
|
|
31
|
-
export const DEFAULT_DROP_RESYNC_WINDOW_MS = 60_000;
|
|
32
|
-
|
|
33
|
-
const isTerminalPayload = (p: SsePayload): boolean => TERMINAL_TYPES.has(p.t);
|
|
34
|
-
|
|
35
|
-
export interface PeerSseQueueMetrics {
|
|
36
|
-
/** Frames currently waiting to be sent (excludes the in-flight frame). */
|
|
37
|
-
queueDepth: number;
|
|
38
|
-
/** Lifetime count of frames evicted due to overflow. */
|
|
39
|
-
droppedFrames: number;
|
|
40
|
-
/** Duration in ms of the last awaited `send` call (null until first send). */
|
|
41
|
-
lastSendMs: number | null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface PeerSseQueueOpts {
|
|
45
|
-
peerConnId: string;
|
|
46
|
-
/**
|
|
47
|
-
* Underlying send for the queue. Awaited per-frame so slow consumers create
|
|
48
|
-
* backpressure here rather than in the producer. Resolves on success; any
|
|
49
|
-
* error is swallowed (logged) so a single bad frame doesn't stall the drain.
|
|
50
|
-
*/
|
|
51
|
-
send: (payload: SsePayload, peerConnId: string) => Promise<void> | void;
|
|
52
|
-
/** Defaults to 256. */
|
|
53
|
-
maxFrames?: number;
|
|
54
|
-
/**
|
|
55
|
-
* Called when `droppedFrames` within `dropResyncWindowMs` exceeds
|
|
56
|
-
* `dropResyncThreshold`. The window counter is reset after firing so the
|
|
57
|
-
* next trigger requires another full window of drops.
|
|
58
|
-
*/
|
|
59
|
-
onResyncTriggered?: () => void;
|
|
60
|
-
/** Defaults to 100. */
|
|
61
|
-
dropResyncThreshold?: number;
|
|
62
|
-
/** Defaults to 60_000. */
|
|
63
|
-
dropResyncWindowMs?: number;
|
|
64
|
-
/** Defaults to `Date.now`. */
|
|
65
|
-
now?: () => number;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export interface PeerSseQueue {
|
|
69
|
-
/** Synchronously enqueue. Returns void; never awaits the underlying send. */
|
|
70
|
-
enqueue(payload: SsePayload): void;
|
|
71
|
-
metrics(): PeerSseQueueMetrics;
|
|
72
|
-
/** Stops the drain loop and discards pending frames. Idempotent. */
|
|
73
|
-
dispose(): void;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
interface QueuedFrame {
|
|
77
|
-
payload: SsePayload;
|
|
78
|
-
terminal: boolean;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export function createPeerSseQueue(opts: PeerSseQueueOpts): PeerSseQueue {
|
|
82
|
-
const maxFrames = opts.maxFrames ?? DEFAULT_MAX_FRAMES;
|
|
83
|
-
const dropThreshold = opts.dropResyncThreshold ?? DEFAULT_DROP_RESYNC_THRESHOLD;
|
|
84
|
-
const dropWindowMs = opts.dropResyncWindowMs ?? DEFAULT_DROP_RESYNC_WINDOW_MS;
|
|
85
|
-
const now = opts.now ?? Date.now;
|
|
86
|
-
|
|
87
|
-
const queue: QueuedFrame[] = [];
|
|
88
|
-
const dropTimestamps: number[] = [];
|
|
89
|
-
let droppedFrames = 0;
|
|
90
|
-
let lastSendMs: number | null = null;
|
|
91
|
-
let disposed = false;
|
|
92
|
-
let draining = false;
|
|
93
|
-
|
|
94
|
-
const recordDrop = (): void => {
|
|
95
|
-
droppedFrames += 1;
|
|
96
|
-
const ts = now();
|
|
97
|
-
dropTimestamps.push(ts);
|
|
98
|
-
const cutoff = ts - dropWindowMs;
|
|
99
|
-
while (dropTimestamps.length > 0 && (dropTimestamps[0] ?? 0) < cutoff) {
|
|
100
|
-
dropTimestamps.shift();
|
|
101
|
-
}
|
|
102
|
-
if (dropTimestamps.length > dropThreshold) {
|
|
103
|
-
dropTimestamps.length = 0;
|
|
104
|
-
try {
|
|
105
|
-
opts.onResyncTriggered?.();
|
|
106
|
-
} catch (err) {
|
|
107
|
-
console.warn('[share] sse-outbound onResyncTriggered threw:', err);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
const drain = async (): Promise<void> => {
|
|
113
|
-
if (draining || disposed) return;
|
|
114
|
-
draining = true;
|
|
115
|
-
try {
|
|
116
|
-
while (queue.length > 0 && !disposed) {
|
|
117
|
-
const frame = queue.shift();
|
|
118
|
-
if (!frame) break;
|
|
119
|
-
const start = now();
|
|
120
|
-
try {
|
|
121
|
-
await opts.send(frame.payload, opts.peerConnId);
|
|
122
|
-
} catch (err) {
|
|
123
|
-
console.warn('[share] sse-outbound send failed:', err);
|
|
124
|
-
}
|
|
125
|
-
lastSendMs = now() - start;
|
|
126
|
-
}
|
|
127
|
-
} finally {
|
|
128
|
-
draining = false;
|
|
129
|
-
}
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const enqueue = (payload: SsePayload): void => {
|
|
133
|
-
if (disposed) return;
|
|
134
|
-
const terminal = isTerminalPayload(payload);
|
|
135
|
-
|
|
136
|
-
if (queue.length >= maxFrames) {
|
|
137
|
-
if (terminal) {
|
|
138
|
-
// Drop the oldest entry regardless of type so the terminal frame
|
|
139
|
-
// always finds a slot — terminal-wins semantics drive the peer's
|
|
140
|
-
// visual reconciliation back to a settled state.
|
|
141
|
-
queue.shift();
|
|
142
|
-
recordDrop();
|
|
143
|
-
} else {
|
|
144
|
-
const idx = queue.findIndex((f) => !f.terminal);
|
|
145
|
-
if (idx === -1) {
|
|
146
|
-
// Queue is full of terminals — drop the incoming non-terminal so
|
|
147
|
-
// we don't displace a settled state with a stale running tick.
|
|
148
|
-
recordDrop();
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
queue.splice(idx, 1);
|
|
152
|
-
recordDrop();
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
queue.push({ payload, terminal });
|
|
157
|
-
void drain();
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
return {
|
|
161
|
-
enqueue,
|
|
162
|
-
metrics: (): PeerSseQueueMetrics => ({
|
|
163
|
-
queueDepth: queue.length,
|
|
164
|
-
droppedFrames,
|
|
165
|
-
lastSendMs,
|
|
166
|
-
}),
|
|
167
|
-
dispose: (): void => {
|
|
168
|
-
disposed = true;
|
|
169
|
-
queue.length = 0;
|
|
170
|
-
dropTimestamps.length = 0;
|
|
171
|
-
},
|
|
172
|
-
};
|
|
173
|
-
}
|
|
@@ -1,205 +0,0 @@
|
|
|
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
|
-
}
|
package/src/share/sse-tap.ts
DELETED
|
@@ -1,183 +0,0 @@
|
|
|
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
|
-
}
|