Files
suaveplan/packages/api-stack/api/api-plugin-sse/src/services/service.ts
2026-05-27 23:29:53 +01:00

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;
}