fix: serialize session lifecycle transitions

This commit is contained in:
User
2026-07-15 01:37:42 +02:00
parent 08b1f4909e
commit 6747f6f8a0
10 changed files with 1402 additions and 101 deletions
+21 -4
View File
@@ -1,5 +1,11 @@
type Send = (event: string, data: object, id: number) => void;
interface Subscriber {
send: Send;
close: () => void;
closed: boolean;
}
/**
* Per-session ring buffer of recent events.
*
@@ -17,6 +23,7 @@ interface BufferedEvent {
interface SubscribeOptions {
afterId?: number;
pending?: object | null;
close?: () => void;
}
function descriptorId(value: unknown): string | null {
@@ -32,7 +39,7 @@ function bufferedGateId(item: BufferedEvent): string | null {
}
export class SseHub {
private subs = new Map<string, Set<Send>>();
private subs = new Map<string, Set<Subscriber>>();
private buffers = new Map<string, BufferedEvent[]>();
private lastIds = new Map<string, number>();
@@ -42,7 +49,8 @@ export class SseHub {
options: SubscribeOptions = {},
): () => void {
if (!this.subs.has(sessionId)) this.subs.set(sessionId, new Set());
this.subs.get(sessionId)!.add(send);
const subscriber = { send, close: options.close ?? (() => undefined), closed: false };
this.subs.get(sessionId)!.add(subscriber);
const afterId = Number.isSafeInteger(options.afterId) && (options.afterId ?? 0) >= 0
? options.afterId ?? 0
@@ -63,12 +71,14 @@ export class SseHub {
send(item.event, item.data, item.id);
}
return () => this.subs.get(sessionId)?.delete(send);
return () => this.subs.get(sessionId)?.delete(subscriber);
}
publish(sessionId: string, event: string, data: object): number {
const item = this.buffer(sessionId, event, data);
for (const send of this.subs.get(sessionId) ?? []) send(event, data, item.id);
for (const subscriber of this.subs.get(sessionId) ?? []) {
subscriber.send(event, data, item.id);
}
return item.id;
}
@@ -88,6 +98,13 @@ export class SseHub {
/** Clear buffered/runtime bindings while retaining session event-id monotonicity. */
clear(sessionId: string): void {
// Snapshot because ending an HTTP response can synchronously/asynchronously unsubscribe it.
// Mark before invoking callbacks so even a re-entrant clear cannot close a response twice.
for (const subscriber of [...(this.subs.get(sessionId) ?? [])]) {
if (subscriber.closed) continue;
subscriber.closed = true;
try { subscriber.close(); } catch { /* disconnect every remaining subscriber */ }
}
this.buffers.delete(sessionId);
this.subs.delete(sessionId);
}