Initial Commit
This commit is contained in:
264
packages/api-stack/api/api-plugin-sse/src/services/service.ts
Normal file
264
packages/api-stack/api/api-plugin-sse/src/services/service.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user