265 lines
9.6 KiB
TypeScript
265 lines
9.6 KiB
TypeScript
import type { RingBuffer } from "../buffer/ring-buffer.js";
|
|
import { SSE_CODES, SseError } from "../error-codes/error-codes.js";
|
|
import type { Fanout } from "../fanout/in-process.js";
|
|
import { getSseInstruments } from "../otel/instruments.js";
|
|
import { SseEventSchema } from "../schemas/event.js";
|
|
import type {
|
|
SseEvent,
|
|
SseEventInput,
|
|
SseStats,
|
|
SubscribeArgs,
|
|
SubscriptionHandle,
|
|
} from "../types/types.js";
|
|
|
|
/**
|
|
* Constructor options for `createSseService`. The plugin lifecycle
|
|
* supplies all four — callers using the service outside the plugin
|
|
* (rare) can build them by hand.
|
|
*/
|
|
export interface CreateSseServiceOptions {
|
|
readonly buffer: RingBuffer;
|
|
readonly fanout: Fanout;
|
|
readonly idGenerator: () => string;
|
|
readonly maxConnectionsPerPrincipal: number;
|
|
readonly maxTotalConnections: number;
|
|
}
|
|
|
|
/**
|
|
* Public surface registered into the host `ServiceRegistry` under the
|
|
* `sseService` slot. Route handlers and downstream plugins consume
|
|
* this — never the underlying `Fanout` or `RingBuffer` directly — so
|
|
* the plugin can swap implementations without breaking consumers.
|
|
*/
|
|
export interface SseService {
|
|
broadcast(channel: string, event: SseEventInput): SseEvent;
|
|
subscribe(args: SubscribeArgs): SubscriptionHandle;
|
|
close(channel?: string): Promise<void>;
|
|
stats(): SseStats;
|
|
}
|
|
|
|
type Pending = { resolve: (v: IteratorResult<SseEvent>) => void };
|
|
|
|
interface Subscription {
|
|
readonly id: string;
|
|
readonly channel: string;
|
|
readonly principalId: string | undefined;
|
|
readonly queue: SseEvent[];
|
|
closed: boolean;
|
|
pending: Pending | undefined;
|
|
unsubscribeFanout: (() => void) | undefined;
|
|
signal: AbortSignal | undefined;
|
|
abortHandler: (() => void) | undefined;
|
|
}
|
|
|
|
/**
|
|
* Construct an `SseService`. The plugin's `onInit` is the only
|
|
* caller in production; tests may call it directly for unit
|
|
* coverage of the service in isolation.
|
|
*/
|
|
export function createSseService(options: CreateSseServiceOptions): SseService {
|
|
const {
|
|
buffer,
|
|
fanout,
|
|
idGenerator,
|
|
maxConnectionsPerPrincipal,
|
|
maxTotalConnections,
|
|
} = options;
|
|
const subscriptions = new Map<string, Subscription>();
|
|
const perPrincipal = new Map<string, number>();
|
|
let closedAll = false;
|
|
|
|
function takeStats(): SseStats {
|
|
const perChannel: Record<string, number> = {};
|
|
for (const sub of subscriptions.values()) {
|
|
perChannel[sub.channel] = (perChannel[sub.channel] ?? 0) + 1;
|
|
}
|
|
return { activeConnections: subscriptions.size, perChannel };
|
|
}
|
|
|
|
function detach(sub: Subscription): void {
|
|
if (sub.closed) return;
|
|
sub.closed = true;
|
|
sub.unsubscribeFanout?.();
|
|
if (sub.signal !== undefined && sub.abortHandler !== undefined) {
|
|
sub.signal.removeEventListener("abort", sub.abortHandler);
|
|
}
|
|
subscriptions.delete(sub.id);
|
|
if (sub.principalId !== undefined) {
|
|
// subscribe() always sets perPrincipal[principalId] >= 1 before
|
|
// creating the subscription, so the map entry is guaranteed.
|
|
const next = (perPrincipal.get(sub.principalId) as number) - 1;
|
|
if (next <= 0) perPrincipal.delete(sub.principalId);
|
|
else perPrincipal.set(sub.principalId, next);
|
|
}
|
|
getSseInstruments().activeConnections.add(-1, { channel: sub.channel });
|
|
if (sub.pending !== undefined) {
|
|
const p = sub.pending;
|
|
sub.pending = undefined;
|
|
p.resolve({ value: undefined, done: true });
|
|
}
|
|
}
|
|
|
|
return {
|
|
broadcast(channel, eventInput) {
|
|
if (closedAll) {
|
|
throw new SseError(
|
|
SSE_CODES.CLOSED,
|
|
"broadcast called after service.close()",
|
|
{ context: { channel } }
|
|
);
|
|
}
|
|
const parsed = SseEventSchema.safeParse(eventInput);
|
|
if (!parsed.success) {
|
|
throw new SseError(
|
|
SSE_CODES.INVALID_INPUT,
|
|
"broadcast received invalid SseEvent",
|
|
{ cause: parsed.error, context: { channel } }
|
|
);
|
|
}
|
|
const event: SseEvent = {
|
|
id: parsed.data.id ?? idGenerator(),
|
|
...(parsed.data.event !== undefined
|
|
? { event: parsed.data.event }
|
|
: {}),
|
|
...(parsed.data.retry !== undefined
|
|
? { retry: parsed.data.retry }
|
|
: {}),
|
|
data: parsed.data.data,
|
|
};
|
|
buffer.append(channel, event);
|
|
getSseInstruments().broadcastsTotal.add(1, { channel });
|
|
fanout.publish(channel, event);
|
|
return event;
|
|
},
|
|
subscribe(args) {
|
|
if (closedAll) {
|
|
throw new SseError(
|
|
SSE_CODES.CLOSED,
|
|
"subscribe called after service.close()",
|
|
{ context: { channel: args.channel } }
|
|
);
|
|
}
|
|
if (args.channel === "") {
|
|
throw new SseError(
|
|
SSE_CODES.CHANNEL_REQUIRED,
|
|
"subscribe requires a non-empty channel"
|
|
);
|
|
}
|
|
if (subscriptions.size >= maxTotalConnections) {
|
|
throw new SseError(
|
|
SSE_CODES.TOO_MANY_CONNECTIONS,
|
|
`Global active-connection cap reached (${maxTotalConnections})`,
|
|
{ context: { max: maxTotalConnections } }
|
|
);
|
|
}
|
|
if (args.principalId !== undefined) {
|
|
const current = perPrincipal.get(args.principalId) ?? 0;
|
|
if (current >= maxConnectionsPerPrincipal) {
|
|
throw new SseError(
|
|
SSE_CODES.PER_PRINCIPAL_LIMIT_EXCEEDED,
|
|
`Principal '${args.principalId}' already holds ${current} connections`,
|
|
{
|
|
context: {
|
|
principalId: args.principalId,
|
|
max: maxConnectionsPerPrincipal,
|
|
},
|
|
}
|
|
);
|
|
}
|
|
perPrincipal.set(args.principalId, current + 1);
|
|
}
|
|
const id = idGenerator();
|
|
const sub: Subscription = {
|
|
id,
|
|
channel: args.channel,
|
|
principalId: args.principalId,
|
|
queue: [],
|
|
closed: false,
|
|
pending: undefined,
|
|
unsubscribeFanout: undefined,
|
|
signal: args.signal,
|
|
abortHandler: undefined,
|
|
};
|
|
if (args.lastEventId !== undefined) {
|
|
for (const ev of buffer.since(args.channel, args.lastEventId)) {
|
|
sub.queue.push(ev);
|
|
}
|
|
}
|
|
sub.unsubscribeFanout = fanout.subscribe(args.channel, (event) => {
|
|
if (sub.pending !== undefined) {
|
|
const p = sub.pending;
|
|
sub.pending = undefined;
|
|
p.resolve({ value: event, done: false });
|
|
} else {
|
|
sub.queue.push(event);
|
|
}
|
|
});
|
|
if (args.signal !== undefined) {
|
|
const handler = () => detach(sub);
|
|
sub.abortHandler = handler;
|
|
if (args.signal.aborted) {
|
|
queueMicrotask(handler);
|
|
} else {
|
|
args.signal.addEventListener("abort", handler, {
|
|
once: true,
|
|
});
|
|
}
|
|
}
|
|
subscriptions.set(id, sub);
|
|
getSseInstruments().activeConnections.add(1, {
|
|
channel: args.channel,
|
|
});
|
|
const handle: SubscriptionHandle = {
|
|
id,
|
|
channel: args.channel,
|
|
events: createIterator(sub),
|
|
close: async () => {
|
|
detach(sub);
|
|
},
|
|
};
|
|
return handle;
|
|
},
|
|
async close(channel) {
|
|
if (channel === undefined) closedAll = true;
|
|
const victims: Subscription[] = [];
|
|
for (const sub of subscriptions.values()) {
|
|
if (channel === undefined || sub.channel === channel) {
|
|
victims.push(sub);
|
|
}
|
|
}
|
|
for (const v of victims) detach(v);
|
|
},
|
|
stats: takeStats,
|
|
};
|
|
}
|
|
|
|
function createIterator(sub: Subscription): AsyncIterableIterator<SseEvent> {
|
|
const iterator: AsyncIterableIterator<SseEvent> = {
|
|
next() {
|
|
if (sub.queue.length > 0) {
|
|
const value = sub.queue.shift() as SseEvent;
|
|
return Promise.resolve({ value, done: false });
|
|
}
|
|
if (sub.closed) {
|
|
return Promise.resolve({ value: undefined, done: true });
|
|
}
|
|
return new Promise<IteratorResult<SseEvent>>((resolve) => {
|
|
sub.pending = { resolve };
|
|
});
|
|
},
|
|
return() {
|
|
if (!sub.closed) sub.closed = true;
|
|
if (sub.pending !== undefined) {
|
|
const p = sub.pending;
|
|
sub.pending = undefined;
|
|
p.resolve({ value: undefined, done: true });
|
|
}
|
|
return Promise.resolve({ value: undefined, done: true });
|
|
},
|
|
[Symbol.asyncIterator]() {
|
|
return iterator;
|
|
},
|
|
};
|
|
return iterator;
|
|
}
|