-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathprogressEmitter.ts
More file actions
94 lines (82 loc) · 2.68 KB
/
Copy pathprogressEmitter.ts
File metadata and controls
94 lines (82 loc) · 2.68 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
import { PassThrough, Readable } from 'node:stream';
export interface ProgressEvent {
event: string;
data: unknown;
}
export type ProgressListener = (event: ProgressEvent) => void;
/**
* Lightweight pub-sub used to report phase/install/replicate events from a long-running
* operation back to the HTTP layer. We deliberately don't use Node's EventEmitter here:
* we only need broadcast semantics for a small set of event types, and we want the
* `emit(event, data)` shape that matches the SSE wire format directly.
*/
export class ProgressEmitter {
private listeners: ProgressListener[] = [];
emit(event: string, data: unknown): void {
// Snapshot before iteration so a listener that unsubscribes itself during dispatch
// doesn't shift indexes underneath us.
const snapshot = this.listeners.slice();
for (const listener of snapshot) {
try {
listener({ event, data });
} catch {
// A buggy listener must never break the operation. Swallow and continue.
}
}
}
subscribe(listener: ProgressListener): () => void {
this.listeners.push(listener);
return () => {
const i = this.listeners.indexOf(listener);
if (i !== -1) this.listeners.splice(i, 1);
};
}
}
/**
* Wrap a long-running operation so its progress events stream back as Server-Sent Events.
*
* The returned Readable emits one SSE message per `emitter.emit(...)` call, then a final
* `done` (or `error`) event with the operation's result, then ends. The caller is
* expected to set Content-Type: text/event-stream on the response.
*/
export function createSSEResponseStream(emitter: ProgressEmitter, operation: () => Promise<unknown>): Readable {
const stream = new PassThrough();
let active = true;
const unsubscribe = emitter.subscribe((event) => {
if (active) writeSSE(stream, event);
});
const cleanup = () => {
if (active) {
active = false;
unsubscribe();
}
};
// If the client disconnects (Ctrl-C, network drop) stop writing to the stream and
// release the emitter subscription so it doesn't accumulate for the operation lifetime.
stream.on('close', cleanup);
stream.on('end', cleanup);
operation()
.then((result) => {
if (active) writeSSE(stream, { event: 'done', data: { result } });
})
.catch((err) => {
if (active) {
writeSSE(stream, {
event: 'error',
data: {
message: err?.message ?? String(err),
code: err?.statusCode ?? err?.code,
},
});
}
})
.finally(() => {
cleanup();
stream.end();
});
return stream;
}
function writeSSE(stream: PassThrough, event: ProgressEvent): void {
const data = typeof event.data === 'string' ? event.data : JSON.stringify(event.data);
stream.write(`event: ${event.event}\ndata: ${data}\n\n`);
}