@tuongaz/seeflow 0.1.109 → 0.1.111

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/web/assets/{architectureDiagram-3BPJPVTR-CvIAMnMC.js → architectureDiagram-3BPJPVTR-C-2fCGRP.js} +1 -1
  2. package/dist/web/assets/{blockDiagram-GPEHLZMM-C55itdvA.js → blockDiagram-GPEHLZMM-Br3qZBhv.js} +1 -1
  3. package/dist/web/assets/{c4Diagram-AAUBKEIU-BUJz6OcY.js → c4Diagram-AAUBKEIU-C4uX4ZH4.js} +1 -1
  4. package/dist/web/assets/channel-qhQeh_4Q.js +1 -0
  5. package/dist/web/assets/{chart-Ck4M6xxI.js → chart-9kjHxc0V.js} +1 -1
  6. package/dist/web/assets/{chunk-2J33WTMH-CYHRe8M7.js → chunk-2J33WTMH-2Fn5NN7i.js} +1 -1
  7. package/dist/web/assets/{chunk-4BX2VUAB-0G6VpCw_.js → chunk-4BX2VUAB-Df_7ccfg.js} +1 -1
  8. package/dist/web/assets/{chunk-55IACEB6-YeyA7Efg.js → chunk-55IACEB6-DdU4dw35.js} +1 -1
  9. package/dist/web/assets/{chunk-727SXJPM-irL9oAdE.js → chunk-727SXJPM-1at9fA5f.js} +1 -1
  10. package/dist/web/assets/{chunk-AQP2D5EJ-DB0ZGTqs.js → chunk-AQP2D5EJ-CPe9M1hv.js} +1 -1
  11. package/dist/web/assets/{chunk-FMBD7UC4-Z48rYWhG.js → chunk-FMBD7UC4-DCUEmPvJ.js} +1 -1
  12. package/dist/web/assets/{chunk-ND2GUHAM-BgYSDKdi.js → chunk-ND2GUHAM-tE2Ii-jJ.js} +1 -1
  13. package/dist/web/assets/{chunk-QZHKN3VN-CzcjFmi-.js → chunk-QZHKN3VN-Db5o8Kzv.js} +1 -1
  14. package/dist/web/assets/classDiagram-4FO5ZUOK-BCHg-KrI.js +1 -0
  15. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-BCHg-KrI.js +1 -0
  16. package/dist/web/assets/{code-block-BwE8Ip6V.js → code-block-CTINSk3z.js} +1 -1
  17. package/dist/web/assets/{cose-bilkent-S5V4N54A-DtufDmle.js → cose-bilkent-S5V4N54A-D7tNx5-t.js} +1 -1
  18. package/dist/web/assets/{dagre-BM42HDAG-DwYVzLme.js → dagre-BM42HDAG-Cdb53Asb.js} +1 -1
  19. package/dist/web/assets/{diagram-2AECGRRQ-DDw_qvjI.js → diagram-2AECGRRQ-HS6pnGrL.js} +1 -1
  20. package/dist/web/assets/{diagram-5GNKFQAL-BYG2VdwF.js → diagram-5GNKFQAL-c9k4swAt.js} +1 -1
  21. package/dist/web/assets/{diagram-KO2AKTUF-Drp2zu_G.js → diagram-KO2AKTUF-CB3k4xVS.js} +1 -1
  22. package/dist/web/assets/{diagram-LMA3HP47-CXmAihfn.js → diagram-LMA3HP47-XKAfR3DB.js} +1 -1
  23. package/dist/web/assets/{diagram-OG6HWLK6-i6-iKnAU.js → diagram-OG6HWLK6-18dGr0GC.js} +1 -1
  24. package/dist/web/assets/{erDiagram-TEJ5UH35-Bo96y9hO.js → erDiagram-TEJ5UH35-9DnFE7wo.js} +1 -1
  25. package/dist/web/assets/{flowDiagram-I6XJVG4X-D9fneMTu.js → flowDiagram-I6XJVG4X-CglLanUO.js} +1 -1
  26. package/dist/web/assets/{ganttDiagram-6RSMTGT7-Cbefa8NE.js → ganttDiagram-6RSMTGT7-_zKCXbXg.js} +1 -1
  27. package/dist/web/assets/{gitGraphDiagram-PVQCEYII-BHYd5nUs.js → gitGraphDiagram-PVQCEYII-D6axSpL3.js} +1 -1
  28. package/dist/web/assets/{iconify-BuhA_8An.js → iconify-CF3Xx8Oq.js} +1 -1
  29. package/dist/web/assets/index-BTL8cVgF.js +8629 -0
  30. package/dist/web/assets/{index.es-pImjWTGX.js → index.es-DyQqI4EJ.js} +1 -1
  31. package/dist/web/assets/{infoDiagram-5YYISTIA-Bw7MIIwj.js → infoDiagram-5YYISTIA-BfnEVJMR.js} +1 -1
  32. package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-CySt1i7t.js → ishikawaDiagram-YF4QCWOH-C-CbbOkc.js} +1 -1
  33. package/dist/web/assets/{journeyDiagram-JHISSGLW-q4YV61wv.js → journeyDiagram-JHISSGLW-CxcYZYhO.js} +1 -1
  34. package/dist/web/assets/{jspdf.es.min-B95ptK5L.js → jspdf.es.min-BoDPBXoT.js} +3 -3
  35. package/dist/web/assets/{kanban-definition-UN3LZRKU-C1oN-zoM.js → kanban-definition-UN3LZRKU-Dw9dGj5D.js} +1 -1
  36. package/dist/web/assets/{linear-DPSv7VcC.js → linear-DJLoiaYn.js} +1 -1
  37. package/dist/web/assets/{markdown-btlM2PIA.js → markdown-BXIhHJY7.js} +1 -1
  38. package/dist/web/assets/{mermaid.core-Cmch2GTm.js → mermaid.core-CTeg8SMj.js} +4 -4
  39. package/dist/web/assets/{mindmap-definition-RKZ34NQL--XOTWr6e.js → mindmap-definition-RKZ34NQL-DG4pYeYK.js} +1 -1
  40. package/dist/web/assets/{pieDiagram-4H26LBE5-DxZgtzul.js → pieDiagram-4H26LBE5-qPHgJ6_d.js} +1 -1
  41. package/dist/web/assets/{quadrantDiagram-W4KKPZXB-bbV2bAO3.js → quadrantDiagram-W4KKPZXB-C-yiyax0.js} +1 -1
  42. package/dist/web/assets/{requirementDiagram-4Y6WPE33-CiJM8hio.js → requirementDiagram-4Y6WPE33-vBgnScGl.js} +1 -1
  43. package/dist/web/assets/{sankeyDiagram-5OEKKPKP-DABBnuaB.js → sankeyDiagram-5OEKKPKP-H-Yoexss.js} +1 -1
  44. package/dist/web/assets/{sequenceDiagram-3UESZ5HK-BTQJk7bM.js → sequenceDiagram-3UESZ5HK-B0PUqbDC.js} +1 -1
  45. package/dist/web/assets/{stateDiagram-AJRCARHV-yL0tAd3x.js → stateDiagram-AJRCARHV-LHZJU6NT.js} +1 -1
  46. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-Jjcvv867.js +1 -0
  47. package/dist/web/assets/{time-hCZUKGxT.js → time-CytNQj8l.js} +1 -1
  48. package/dist/web/assets/{timeline-definition-PNZ67QCA-CG6k16Wj.js → timeline-definition-PNZ67QCA-CNYtzzkX.js} +1 -1
  49. package/dist/web/assets/{vennDiagram-CIIHVFJN-BVH530xf.js → vennDiagram-CIIHVFJN-CR3j3MfQ.js} +1 -1
  50. package/dist/web/assets/{wardley-L42UT6IY-BYVkFMrL.js → wardley-L42UT6IY-DsRtYkk3.js} +1 -1
  51. package/dist/web/assets/{wardleyDiagram-YWT4CUSO-BGMuHejP.js → wardleyDiagram-YWT4CUSO-Bkiae0No.js} +1 -1
  52. package/dist/web/assets/{xychartDiagram-2RQKCTM6-D6MSlnJD.js → xychartDiagram-2RQKCTM6-DFoBCrdz.js} +1 -1
  53. package/dist/web/index.html +1 -1
  54. package/package.json +1 -1
  55. package/src/api.ts +0 -291
  56. package/src/server.ts +0 -20
  57. package/dist/web/assets/channel-DtcQ9fhj.js +0 -1
  58. package/dist/web/assets/classDiagram-4FO5ZUOK-BjYXB41E.js +0 -1
  59. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-BjYXB41E.js +0 -1
  60. package/dist/web/assets/index-08hmlCqO.js +0 -8644
  61. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-BzWj6i1l.js +0 -1
  62. package/src/share/sse-frame.ts +0 -85
  63. package/src/share/sse-outbound-queue.ts +0 -173
  64. package/src/share/sse-rate-limit.ts +0 -205
  65. package/src/share/sse-tap.ts +0 -183
  66. package/src/share-audit.ts +0 -267
  67. package/src/share-envelope.ts +0 -155
  68. package/src/share-file-request.ts +0 -399
  69. package/src/share-file-resolver.ts +0 -68
  70. package/src/share-file-upload.ts +0 -595
  71. package/src/share-files-manifest.ts +0 -232
  72. package/src/share-ratelimit.ts +0 -69
  73. package/src/share-rpc-schema.ts +0 -249
  74. package/src/share-transport.ts +0 -205
  75. package/src/share.ts +0 -1663
@@ -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};
@@ -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
- }
@@ -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
- }