first commit
This commit is contained in:
66
docs/api-stack/api/api-plugin-sse/src/buffer/ring-buffer.md
Normal file
66
docs/api-stack/api/api-plugin-sse/src/buffer/ring-buffer.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# ring-buffer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Bounded per-channel ring buffer that powers `Last-Event-ID` resume. The SSE service appends every broadcast to the buffer keyed by channel; when a subscriber reconnects with a `Last-Event-ID` header, the service queries the buffer for events newer than that cursor and replays them before forwarding live broadcasts. The buffer caps total memory by evicting oldest events once `retentionPerChannel` is exceeded.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-channel storage so retention applies independently across topics.
|
||||
- O(n) `since()` lookup — adequate for retention values in the hundreds, which is the production target.
|
||||
- Stale-cursor protection: an unknown `lastEventId` returns an empty slice so over-aged clients start fresh rather than receiving the entire buffer.
|
||||
- `clear(channel?)` for explicit eviction in tests and during plugin shutdown.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createRingBuffer } from "@suaveplan/api-plugin-sse/buffer/ring-buffer";
|
||||
|
||||
const buffer = createRingBuffer(100);
|
||||
buffer.append("notifications", { id: "1", data: { kind: "ping" } });
|
||||
buffer.append("notifications", { id: "2", data: { kind: "ack" } });
|
||||
|
||||
const missed = buffer.since("notifications", "1");
|
||||
console.log(missed.map((e) => e.id)); // ["2"]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createRingBuffer } from "@suaveplan/api-plugin-sse/buffer/ring-buffer";
|
||||
|
||||
const buffer = createRingBuffer(50);
|
||||
|
||||
// Persistent worker keeps the latest 50 events for each channel.
|
||||
for await (const evt of source) {
|
||||
buffer.append(evt.channel, evt);
|
||||
}
|
||||
|
||||
// Reconnect: resume from the cursor the client supplied.
|
||||
function replay(channel: string, lastEventId: string | undefined) {
|
||||
if (lastEventId === undefined) return [];
|
||||
return buffer.since(channel, lastEventId);
|
||||
}
|
||||
|
||||
// Periodic eviction of dead channels — call from a sweeper.
|
||||
function drop(channel: string) {
|
||||
buffer.clear(channel);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createRingBuffer(retentionPerChannel: number): RingBuffer` — constructs a buffer with the supplied per-channel cap. A retention of `0` is permitted (no replay) but renders `since()` always empty.
|
||||
- `RingBuffer.append(channel: string, event: SseEvent): void` — appends an event to the channel's ring; evicts the oldest event when over capacity.
|
||||
- `RingBuffer.since(channel: string, lastEventId: string): readonly SseEvent[]` — returns the slice of retained events whose `id` strictly follows `lastEventId`. Pass `""` to retrieve every retained event. Returns an empty array when the channel is unknown or the cursor is stale.
|
||||
- `RingBuffer.clear(channel?: string): void` — evicts a single channel (when supplied) or every channel.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The implementation uses a `Map<string, SseEvent[]>`. Insertion is O(1) amortised; overflow eviction splices the head in O(retention).
|
||||
- The buffer is in-memory only — a process restart drops every retained event. A future `RedisRingBuffer` (separate package) implements the same interface against Redis Streams for cross-node retention.
|
||||
- Event ids are not assumed to be monotonic; `since` searches by exact match. The plugin's default id generator emits UUIDv7 strings via `crypto.randomUUID()` which sort lexicographically by creation time, but the buffer does not depend on that property.
|
||||
@@ -0,0 +1,73 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable error-code registry and `SseError` class for `@suaveplan/api-plugin-sse`. Every failure path in the plugin raises an `SseError` carrying one of eight `SSE_*` codes so consumers can branch deterministically on the string value rather than parsing error messages. Codes are declared via `defineCodes("SSE", [...] as const)` so the type system narrows `error.code` to a literal-string union — handlers cannot accidentally branch on an unregistered code.
|
||||
|
||||
## Features
|
||||
|
||||
- 8 stable codes covering authentication, capacity, lifecycle, validation, and backpressure failures.
|
||||
- `SseError extends SuaveplanError<SseErrorCode>` so `instanceof SseError` narrows the `code` field.
|
||||
- Public constructor for direct instantiation from callers.
|
||||
- `cause` chain preserved for upstream `ZodError` and runtime failures.
|
||||
- `context` carries diagnostic payload (`channel`, `principalId`, `max`, `statusCode`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SSE_CODES, SseError } from "@suaveplan/api-plugin-sse/error-codes/error-codes";
|
||||
|
||||
try {
|
||||
service.subscribe({ channel: "" });
|
||||
} catch (err) {
|
||||
if (err instanceof SseError && err.code === SSE_CODES.CHANNEL_REQUIRED) {
|
||||
return Response.json({ error: "channel required" }, { status: 400 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SSE_CODES, SseError } from "@suaveplan/api-plugin-sse/error-codes/error-codes";
|
||||
|
||||
// Map every SSE_* code to an HTTP status for a unified handler.
|
||||
const STATUS_BY_CODE: Record<string, number> = {
|
||||
[SSE_CODES.UNAUTHENTICATED]: 401,
|
||||
[SSE_CODES.PER_PRINCIPAL_LIMIT_EXCEEDED]: 429,
|
||||
[SSE_CODES.TOO_MANY_CONNECTIONS]: 429,
|
||||
[SSE_CODES.CHANNEL_REQUIRED]: 400,
|
||||
[SSE_CODES.INVALID_INPUT]: 400,
|
||||
[SSE_CODES.SERVICE_NOT_AVAILABLE]: 500,
|
||||
[SSE_CODES.CLOSED]: 503,
|
||||
[SSE_CODES.QUEUE_OVERFLOW]: 503,
|
||||
};
|
||||
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof SseError) {
|
||||
return c.json(
|
||||
{ code: err.code, message: err.message, context: err.context },
|
||||
STATUS_BY_CODE[err.code] ?? 500
|
||||
);
|
||||
}
|
||||
return c.text("internal", 500);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SSE_CODES` — frozen record produced by `defineCodes("SSE", [...] as const)`. Keys: `UNAUTHENTICATED`, `TOO_MANY_CONNECTIONS`, `PER_PRINCIPAL_LIMIT_EXCEEDED`, `CHANNEL_REQUIRED`, `SERVICE_NOT_AVAILABLE`, `INVALID_INPUT`, `QUEUE_OVERFLOW`, `CLOSED`. Values are `SSE.<KEY>` strings.
|
||||
- `type SseErrorCode` — union of every value in `SSE_CODES`.
|
||||
- `class SseError extends SuaveplanError<SseErrorCode>` — public constructor `(code, message, options?)`. `options` accepts `{ cause?, context? }` per `SuaveplanErrorOptions`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `QUEUE_OVERFLOW` is not thrown — it is attached to the `sse.queue_overflow_total` OTel counter as the `code` attribute so dashboards can correlate drops with the registry entry without inventing a parallel taxonomy.
|
||||
- `UNAUTHENTICATED` is thrown only when the handler is configured with `requirePrincipal: true` AND `resolvePrincipalId` returned `undefined`. Anonymous routes never raise it.
|
||||
- `SERVICE_NOT_AVAILABLE` carries `context.missing: string[]` so the orchestrator can list every missing slot in one failure message.
|
||||
- All codes are stable across versions — adding new codes is additive; removing a code is a breaking change.
|
||||
71
docs/api-stack/api/api-plugin-sse/src/fanout/in-process.md
Normal file
71
docs/api-stack/api/api-plugin-sse/src/fanout/in-process.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# in-process
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Single-process publish/subscribe bus consumed by the SSE service to deliver every broadcast to every active subscriber on the matching channel. The `Fanout` interface is intentionally minimal so a future cross-node implementation (Redis pub/sub, NATS) can drop in without changing the service surface. The in-process implementation is the default — sufficient for any deployment that runs on a single process or behind a sticky load balancer.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-channel listener sets with O(1) subscribe / unsubscribe.
|
||||
- Synchronous publish — listeners run in registration order during the `publish` call.
|
||||
- Unsubscribe is idempotent; calling the returned function twice is a no-op.
|
||||
- Empty channels are pruned automatically so `Map` memory does not grow unbounded across short-lived topics.
|
||||
- `clear()` for explicit eviction in test teardown.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createInProcessFanout } from "@suaveplan/api-plugin-sse/fanout/in-process";
|
||||
|
||||
const fanout = createInProcessFanout();
|
||||
const unsubscribe = fanout.subscribe("notifications", (event) => {
|
||||
console.log(event.id, event.data);
|
||||
});
|
||||
|
||||
fanout.publish("notifications", { id: "1", data: { kind: "ping" } });
|
||||
unsubscribe();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createInProcessFanout, type Fanout } from "@suaveplan/api-plugin-sse/fanout/in-process";
|
||||
|
||||
// Wrap the in-process bus with a metering layer.
|
||||
function meter(inner: Fanout): Fanout {
|
||||
let published = 0;
|
||||
return {
|
||||
publish(channel, event) {
|
||||
published++;
|
||||
inner.publish(channel, event);
|
||||
},
|
||||
subscribe(channel, listener) {
|
||||
return inner.subscribe(channel, listener);
|
||||
},
|
||||
clear() {
|
||||
inner.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fanout = meter(createInProcessFanout());
|
||||
fanout.publish("a", { id: "1", data: 1 });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createInProcessFanout(): Fanout` — constructs a single-process bus.
|
||||
- `Fanout.publish(channel: string, event: SseEvent): void` — invokes every listener registered against the channel.
|
||||
- `Fanout.subscribe(channel: string, listener: (event: SseEvent) => void): () => void` — registers a listener; returns an unsubscribe function. Calling the unsubscribe function more than once is safe.
|
||||
- `Fanout.clear(): void` — removes every listener from every channel.
|
||||
- `type FanoutListener = (event: SseEvent) => void` — listener signature.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Listeners run synchronously on the publishing call stack. A long-running listener will block other listeners and the broadcaster — keep listeners O(microseconds) or hand off to a queue.
|
||||
- Subscribers added during a `publish` invocation are NOT invoked for that publish (set iteration semantics in V8 / JavaScriptCore guarantee the snapshot is taken at iteration start).
|
||||
- Errors thrown by a listener propagate to the publisher — wrap in `try/catch` per listener if isolated error handling is needed. (The SSE service's listener is a closure over a per-connection queue and never throws.)
|
||||
57
docs/api-stack/api/api-plugin-sse/src/formatter/format.md
Normal file
57
docs/api-stack/api/api-plugin-sse/src/formatter/format.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# format
|
||||
|
||||
## Purpose
|
||||
|
||||
Serialises `SseEvent` objects into the `text/event-stream` wire format defined by the WHATWG HTML Standard so the SSE handler can write framed bytes directly to the response body. Also exposes a small helper for the heartbeat comment frame that keeps idle connections alive through proxies. Pure functions — no I/O, no state, suitable for use anywhere in the runtime.
|
||||
|
||||
## Features
|
||||
|
||||
- Canonical SSE framing: `id:`, `event:`, `retry:`, `data:` lines followed by the blank-line frame terminator.
|
||||
- Multi-line string payloads expanded to one `data:` line per source line per the spec, so browser-side parsers join them with `\n` automatically.
|
||||
- Non-string payloads passed through `JSON.stringify` so the wire format is always plain text.
|
||||
- `formatHeartbeatFrame()` emits a `:`-prefixed comment frame that EventSource ignores but proxies see as activity.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { formatSseFrame } from "@suaveplan/api-plugin-sse/formatter/format";
|
||||
|
||||
const frame = formatSseFrame({ id: "42", event: "ping", data: { n: 1 } });
|
||||
// "id: 42\nevent: ping\ndata: {\"n\":1}\n\n"
|
||||
controller.enqueue(new TextEncoder().encode(frame));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
formatHeartbeatFrame,
|
||||
formatSseFrame,
|
||||
} from "@suaveplan/api-plugin-sse/formatter/format";
|
||||
|
||||
// Manual stream construction that bypasses the bundled handler.
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
const interval = setInterval(() => {
|
||||
controller.enqueue(encoder.encode(formatHeartbeatFrame()));
|
||||
}, 30_000);
|
||||
// ... wire up your own subscriber loop
|
||||
return () => clearInterval(interval);
|
||||
},
|
||||
});
|
||||
|
||||
// retry: hint for client reconnect-after value
|
||||
const reconnectHint = formatSseFrame({ id: "0", retry: 5_000, data: "" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `formatSseFrame(event: SseEvent): string` — returns the canonical wire-format string. `event.data` is JSON-stringified when not already a string; multi-line strings are split into one `data:` line per source line.
|
||||
- `formatHeartbeatFrame(): string` — returns `": heartbeat\n\n"`. EventSource ignores comment frames; HTTP intermediaries observe activity and keep the connection open.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The serialiser inserts a single space after the colon (`id: 42`, not `id:42`). The space is per spec recommendation and is stripped by the browser parser.
|
||||
- Binary payloads are not supported — the wire format is line-oriented text. Base64-encode binary data into a string field if you need to transmit blobs.
|
||||
- Field ordering (`id`, `event`, `retry`, `data`) is consistent across every call so dashboards relying on byte-level matching see stable output. The spec does not mandate ordering, but consistency simplifies snapshot tests downstream.
|
||||
61
docs/api-stack/api/api-plugin-sse/src/formatter/parse.md
Normal file
61
docs/api-stack/api/api-plugin-sse/src/formatter/parse.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# parse
|
||||
|
||||
## Purpose
|
||||
|
||||
Async streaming parser for `text/event-stream`. Decodes a `ReadableStreamDefaultReader<Uint8Array>` into a sequence of `SseEvent` objects, yielding one event per frame. The parser is used by the integration test client (which subscribes via `fetch` and reads the body manually) and is available to any consumer that needs to consume SSE without depending on the browser's `EventSource`.
|
||||
|
||||
## Features
|
||||
|
||||
- WHATWG-conformant frame parsing: handles `\n`, `\r\n`, and `\r` line endings; recognises `id`, `event`, `retry`, `data` fields; ignores comment lines (`:`) and unknown field names.
|
||||
- Multi-line `data:` lines reassembled with `\n` (canonical browser behavior).
|
||||
- Streaming: frame boundaries that span chunk reads are handled transparently via a buffered append-and-split.
|
||||
- Zero dependencies — relies only on `TextDecoder` from the runtime.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseSseStream } from "@suaveplan/api-plugin-sse/formatter/parse";
|
||||
|
||||
const res = await fetch("/sse/notifications");
|
||||
const reader = res.body!.getReader();
|
||||
for await (const event of parseSseStream(reader)) {
|
||||
console.log(event.id, JSON.parse(event.data as string));
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { parseSseStream } from "@suaveplan/api-plugin-sse/formatter/parse";
|
||||
|
||||
// Reconnect loop with Last-Event-ID resume.
|
||||
let lastId: string | undefined;
|
||||
while (true) {
|
||||
const headers: HeadersInit = {};
|
||||
if (lastId !== undefined) headers["Last-Event-ID"] = lastId;
|
||||
const res = await fetch("/sse/c", { headers });
|
||||
if (!res.ok || res.body === null) {
|
||||
await new Promise((r) => setTimeout(r, 1_000));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
for await (const event of parseSseStream(res.body.getReader())) {
|
||||
if (event.id !== "") lastId = event.id;
|
||||
handle(event);
|
||||
}
|
||||
} catch {
|
||||
// network blip — retry from lastId
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseSseStream(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncIterableIterator<SseEvent>` — async iterator yielding one `SseEvent` per frame. The iterator ends when the underlying reader signals `done`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The parser preserves the empty-string `data` field on retry-only frames so callers can observe `retry:` updates without inventing a sentinel for "no data".
|
||||
- Comment-only frames (e.g. heartbeats) are skipped silently; if your application needs to observe heartbeats, switch to a custom reader or use the `sse.heartbeats_total` OTel counter the server emits.
|
||||
- The parser does NOT validate UTF-8 sequences strictly — `TextDecoder` is invoked with `stream: true` and silently substitutes replacement characters for invalid bytes. This matches the browser's behavior.
|
||||
- Each frame is parsed independently; there is no global cursor state between frames except the residual byte buffer, so the iterator is safe to abandon at any point (caller is responsible for cancelling the underlying reader).
|
||||
@@ -0,0 +1,72 @@
|
||||
# sse-handler
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Hono handler factory that turns a subscription on `SseService` into a streaming HTTP response framed in the `text/event-stream` wire format. Handles per-connection heartbeats, abort-on-disconnect via the request's `AbortSignal`, `Last-Event-ID` resume from the ring buffer, and backpressure honoring the configured `queueHighWaterMark` and `overflowPolicy`. Returns a standard `Response` whose body is a `ReadableStream<Uint8Array>` so any modern runtime (Bun, Node 18+, Cloudflare Workers, Deno) streams without an adapter shim.
|
||||
|
||||
## Features
|
||||
|
||||
- Reads `:channel` from the route path; missing throws `SseError(SSE_CHANNEL_REQUIRED)` for the host's error handler to translate to 400.
|
||||
- Reads `Last-Event-ID` from request headers and forwards to `service.subscribe` for replay.
|
||||
- Optional `resolvePrincipalId(c)` lets consumers wire any auth stack (e.g. `c.get("user")?.id`) without this package depending on `@suaveplan/api-plugin-auth`.
|
||||
- Optional `requirePrincipal: true` raises `SseError(SSE_UNAUTHENTICATED)` (HTTP 401) when the resolver returns `undefined`.
|
||||
- Heartbeat scheduler writes a `: heartbeat\n\n` comment frame every `heartbeatIntervalMs` to keep idle connections alive through proxies; the timer is injectable (`setIntervalImpl`/`clearIntervalImpl`) for deterministic tests.
|
||||
- Backpressure handling: when the writable controller's `desiredSize` falls to zero or the in-memory backlog exceeds `queueHighWaterMark`, applies `overflowPolicy` and increments `sse.queue_overflow_total{policy, code}`.
|
||||
- Sets canonical response headers: `Content-Type: text/event-stream; charset=utf-8`, `Cache-Control: no-cache, no-transform`, `X-Accel-Buffering: no`, `Connection: keep-alive`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import { createSseRoute } from "@suaveplan/api-plugin-sse/middleware/sse-handler";
|
||||
|
||||
const app = new Hono();
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(sseService, {
|
||||
heartbeatIntervalMs: 30_000,
|
||||
queueHighWaterMark: 256,
|
||||
overflowPolicy: "drop-oldest",
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import { createSseRoute } from "@suaveplan/api-plugin-sse/middleware/sse-handler";
|
||||
import { requireAuth } from "@suaveplan/api-plugin-auth/middleware/require-auth";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Compose with the auth plugin: require an authenticated principal,
|
||||
// scope the principalId to the subscription cap.
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
requireAuth(),
|
||||
createSseRoute(sseService, {
|
||||
heartbeatIntervalMs: 15_000,
|
||||
queueHighWaterMark: 128,
|
||||
overflowPolicy: "drop-newest",
|
||||
resolvePrincipalId: (c) => c.get("user")?.id,
|
||||
requirePrincipal: true,
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface CreateSseRouteOptions` — `{ heartbeatIntervalMs, queueHighWaterMark, overflowPolicy, resolvePrincipalId?, requirePrincipal?, setIntervalImpl?, clearIntervalImpl? }`.
|
||||
- `createSseRoute(service: SseService, options: CreateSseRouteOptions): Handler` — returns a Hono handler.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The response body is constructed as `new ReadableStream<Uint8Array>` with an async producer running inside `start(controller)`. The producer drains `handle.events` via `for await`, frames each event with `formatSseFrame`, and pushes the encoded bytes through the controller.
|
||||
- Cleanup is centralised in a single async closure that clears the heartbeat interval, calls `handle.close()`, and records the `sse.connection_duration_ms` histogram. It is invoked from both the iterator's `finally` branch and the stream's `cancel` callback so abort and natural end converge on the same code path.
|
||||
- Backpressure is detected by checking `controller.desiredSize`; values of `null` (no high-water mark) and positive integers are treated as "not full". Zero or negative trigger the overflow policy.
|
||||
- Heartbeat interval uses native `setInterval` by default; tests inject a fake to drive the timer deterministically (matching the FakeClock pattern used elsewhere ).
|
||||
70
docs/api-stack/api/api-plugin-sse/src/otel/instruments.md
Normal file
70
docs/api-stack/api/api-plugin-sse/src/otel/instruments.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the canonical OpenTelemetry instrument bundle emitted by the SSE plugin. Counters and a histogram cover the four observability questions operators ask: "how many connections are live?", "how busy is each channel?", "are heartbeats firing?", and "are we shedding load under backpressure?". Instruments are lazily resolved against the global `MeterProvider` and cached so every production call site shares the same identity (a cold-start operator can install a fresh provider in tests via `resetSseInstruments()`).
|
||||
|
||||
## Features
|
||||
|
||||
- `sse.active_connections` — UpDownCounter; +1 on subscribe, −1 on close. Carries `channel` attribute so dashboards can show per-channel concurrency.
|
||||
- `sse.broadcasts_total` — counter incremented on every `service.broadcast` call, partitioned by `channel`.
|
||||
- `sse.heartbeats_total` — counter incremented every time the handler writes a `: heartbeat\n\n` comment frame.
|
||||
- `sse.message_bytes_total` — total bytes written across every framed event (helps capacity planning).
|
||||
- `sse.queue_overflow_total` — counter incremented when the handler drops events under backpressure; tags `policy` and the originating `code: SSE.QUEUE_OVERFLOW`.
|
||||
- `sse.connection_duration_ms` — histogram of per-connection lifetime in milliseconds, recorded on close.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getSseInstruments } from "@suaveplan/api-plugin-sse/otel/instruments";
|
||||
|
||||
const instruments = getSseInstruments();
|
||||
instruments.broadcastsTotal.add(1, { channel: "notifications" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
InMemoryMetricExporter,
|
||||
AggregationTemporality,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
getSseInstruments,
|
||||
resetSseInstruments,
|
||||
} from "@suaveplan/api-plugin-sse/otel/instruments";
|
||||
|
||||
// Install a fresh provider in a test, then rebind the cache.
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetSseInstruments();
|
||||
|
||||
const i = getSseInstruments();
|
||||
i.activeConnections.add(1, { channel: "c" });
|
||||
await provider.forceFlush();
|
||||
console.log(exporter.getMetrics());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SseInstruments` — the bundle: `activeConnections: UpDownCounter`, `broadcastsTotal: Counter`, `heartbeatsTotal: Counter`, `messageBytesTotal: Counter`, `queueOverflowTotal: Counter`, `connectionDurationMs: Histogram`.
|
||||
- `getSseInstruments(): SseInstruments` — lazily resolve the bundle against the current global `MeterProvider`; cached so subsequent calls are O(1).
|
||||
- `resetSseInstruments(): void` — drop the cache so the next `getSseInstruments` rebinds. Tests call this after installing a fresh provider.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Instrument names use the canonical OpenTelemetry semantic conventions style (`sse.snake_case_name`). They are public — renaming is a breaking change.
|
||||
- The bundle is module-level cached. A second `MeterProvider.setGlobalMeterProvider` call after `getSseInstruments` has been invoked will NOT take effect until `resetSseInstruments` runs.
|
||||
- `connection_duration_ms` is recorded with the `channel` attribute so per-channel SLO dashboards work without a separate aggregator.
|
||||
73
docs/api-stack/api/api-plugin-sse/src/plugin/plugin.md
Normal file
73
docs/api-stack/api/api-plugin-sse/src/plugin/plugin.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# plugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Factory producing a typed `PluginDescriptor` for the SSE plugin. Wraps the `@suaveplan/api-plugin` `definePlugin` foundation with Zod-validated options at the boundary and a 6-step `onInit` lifecycle that resolves the host's Hono `app`, constructs the ring buffer / fanout / service triple, registers `sseService` in the host `ServiceRegistry`, and mounts the default streaming route. Mirrors the 3-way plug-and-play contract pioneered by `@suaveplan/api-plugin-auth` so consumers compose any combination of `@suaveplan/api-plugin-*` packages with a single `definePlugin` array in their app boot.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated options at the factory boundary — invalid input throws `SseError(SSE_INVALID_INPUT)` carrying the `ZodError` as `cause`.
|
||||
- 6-step `onInit`: (1) resolve `app` from registry (or throw `SSE_SERVICE_NOT_AVAILABLE`), (2) build ring buffer + in-process fanout, (3) build `SseService`, (4) register `sseService`, (5) optionally mount `GET <routePrefix>/:channel`, (6) defer to descriptor's `onClose` for teardown.
|
||||
- `onClose` terminates every open connection by calling `service.close()`; idempotent on second call.
|
||||
- `attachDefaultRoute: false` skips route registration so consumers can mount their own handler under any path / middleware stack.
|
||||
- `routePrefix: "/"` mounts at `/:channel` so callers can use SSE as their entire app surface when desired.
|
||||
- Defaults reflected in `descriptor.defaults` so a downstream introspection tool sees every resolved value without re-parsing.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createSsePlugin } from "@suaveplan/api-plugin-sse/plugin/plugin";
|
||||
|
||||
const plugin = createSsePlugin({
|
||||
heartbeatIntervalMs: 15_000,
|
||||
maxConnectionsPerPrincipal: 8,
|
||||
});
|
||||
// Pass to your @suaveplan/api registry along with other plugins.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createSsePlugin } from "@suaveplan/api-plugin-sse/plugin/plugin";
|
||||
import { createPluginRegistry } from "@suaveplan/api-plugin";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const app = new Hono();
|
||||
const registry = createPluginRegistry({
|
||||
seedServices: { app },
|
||||
});
|
||||
|
||||
await registry.register(
|
||||
createSsePlugin({
|
||||
heartbeatIntervalMs: 15_000,
|
||||
retentionPerChannel: 500,
|
||||
maxConnectionsPerPrincipal: 4,
|
||||
maxTotalConnections: 5_000,
|
||||
queueHighWaterMark: 512,
|
||||
overflowPolicy: "drop-newest",
|
||||
attachDefaultRoute: false, // mount manually under /v1/events/:channel
|
||||
})
|
||||
);
|
||||
await registry.init();
|
||||
|
||||
const sse = registry.getService("sseService");
|
||||
sse?.broadcast("welcome", { data: { message: "boot complete" } });
|
||||
|
||||
// Graceful shutdown closes every open SSE connection.
|
||||
process.on("SIGTERM", () => registry.shutdown());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createSsePlugin(options?: SsePluginOptions): PluginDescriptor<SsePluginConfig>` — factory. `options` is optional; defaults are applied by `SsePluginOptionsSchema`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The descriptor's `onInit` captures its `service` instance in a closure so `onClose` can call `service.close()` without re-resolving the `ServiceRegistry` (the registry instance is the same across phases, but the closure is more robust to lifecycle edge cases).
|
||||
- Default `idGenerator` uses `globalThis.crypto.randomUUID()` when available; falls back to a timestamp + random suffix on legacy runtimes. The plugin layer is the canonical place to apply this default — `SseService` itself takes the generator verbatim.
|
||||
- `routePrefix === "/"` is special-cased to avoid a double-slash in the mount path; the plugin uses `app.get("/:channel", handler)` directly in that case.
|
||||
- `onClose` checks `state.service` for `undefined` so it is safe to call before `onInit` has completed (e.g. when `onInit` throws and the registry sweeps up).
|
||||
@@ -0,0 +1,60 @@
|
||||
# service-registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript module augmentation declaring the `sseService` slot on the `@suaveplan/api-plugin/service-registry` `ServiceRegistry` interface. Importing this module for its side effects makes `ctx.getService("sseService")` return `SseService | undefined` with no cast at the call site — the package root barrel imports it automatically so every consumer of `@suaveplan/api-plugin-sse` gets the typed slot.
|
||||
|
||||
## Features
|
||||
|
||||
- Side-effect-only augmentation — exports an empty `type` so the file participates in the module graph without contributing runtime code.
|
||||
- Mirrors the host's `app` slot declaration so the plugin can run in isolation against a stand-alone Hono instance (TypeScript merges duplicate declarations safely).
|
||||
- Co-located with the plugin's types so a single import path establishes the augmentation.
|
||||
- Excluded from coverage / docs gates because it carries no executable code.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
// Importing the package barrel surfaces the augmentation automatically.
|
||||
import "@suaveplan/api-plugin-sse";
|
||||
|
||||
// Now every ctx.getService("sseService") call is typed.
|
||||
function handle(ctx: PluginContext) {
|
||||
const sse = ctx.getService("sseService");
|
||||
sse?.broadcast("c", { data: 1 });
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// Explicit augmentation import — useful when consuming a single subpath
|
||||
// without pulling the whole barrel.
|
||||
import "@suaveplan/api-plugin-sse/registry/service-registry";
|
||||
import type { SseService } from "@suaveplan/api-plugin-sse/services/service";
|
||||
|
||||
declare const ctx: PluginContext;
|
||||
const sse: SseService | undefined = ctx.getService("sseService");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- No exported values. The module augments:
|
||||
|
||||
```ts
|
||||
declare module "@suaveplan/api-plugin/service-registry" {
|
||||
interface ServiceRegistry {
|
||||
sseService: SseService;
|
||||
app: Hono;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- This file does NOT export `type` aliases or values — it exists solely for module-augmentation side effects. The `export type {}` at the end keeps TypeScript happy with isolated modules.
|
||||
- The `app` re-declaration is intentional and harmless. `@suaveplan/api` declares the same slot; TypeScript merges duplicate interface declarations and the two contribute identical types.
|
||||
- Excluded from coverage in `vitest.config.ts` (`src/registry/service-registry.ts`) because there is no runtime to cover; type-level conformance is asserted in the `api-plugin-sse.spec-conformance.test.ts` suite via `expectTypeOf`.
|
||||
57
docs/api-stack/api/api-plugin-sse/src/schemas/event.md
Normal file
57
docs/api-stack/api/api-plugin-sse/src/schemas/event.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# event
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schema validating the `SseEvent` shape accepted by `SseService.broadcast`. Ensures application payloads cannot bypass the boundary with malformed ids, negative `retry` values, or empty event names. Validation failure raises `SseError(SSE_INVALID_INPUT)` with the `ZodError` chained as `cause` so consumers can surface field-level diagnostics to upstream handlers.
|
||||
|
||||
## Features
|
||||
|
||||
- `id` is optional at the boundary — the service generates one via the configured `idGenerator` when absent. When supplied it must be a non-empty string.
|
||||
- `event` (the SSE `event:` field) optional but non-empty when supplied.
|
||||
- `data` accepts any JSON-serialisable value (`z.unknown()`); the handler is responsible for JSON serialisation on the wire.
|
||||
- `retry` constrained to positive integers so a `retry: 0` or `retry: -1` cannot be sent (both are confusing in browsers).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SseEventSchema } from "@suaveplan/api-plugin-sse/schemas/event";
|
||||
|
||||
const parsed = SseEventSchema.parse({ data: { kind: "ping" } });
|
||||
// parsed.id is undefined; the service fills it from idGenerator
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SseEventSchema } from "@suaveplan/api-plugin-sse/schemas/event";
|
||||
import { SSE_CODES, SseError } from "@suaveplan/api-plugin-sse/error-codes/error-codes";
|
||||
|
||||
function safeBroadcast(service: { broadcast: (c: string, e: unknown) => void }, channel: string, raw: unknown) {
|
||||
const parsed = SseEventSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new SseError(SSE_CODES.INVALID_INPUT, "invalid SSE event", {
|
||||
cause: parsed.error,
|
||||
context: { channel, raw },
|
||||
});
|
||||
}
|
||||
service.broadcast(channel, parsed.data);
|
||||
}
|
||||
|
||||
safeBroadcast(service, "notifications", { event: "ping", data: { ts: Date.now() } });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SseEventSchema` — Zod object schema. Fields:
|
||||
- `id` — optional non-empty string.
|
||||
- `event` — optional non-empty string.
|
||||
- `data` — `z.unknown()` (any JSON-serialisable value).
|
||||
- `retry` — optional positive integer (milliseconds).
|
||||
- `type SseEventInputParsed` — `z.input<typeof SseEventSchema>` (caller-facing).
|
||||
- `type SseEventResolved` — `z.output<typeof SseEventSchema>` (post-validation).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The schema is intentionally NOT `.strict()` because applications may pass through additional metadata as part of the broadcast call (`tags`, `severity`, etc.). The service does not propagate unknown fields to the wire — only `id`, `event`, `retry`, `data` are framed.
|
||||
- `data: undefined` is rejected — `undefined` cannot be JSON-stringified and would silently drop the payload at framing time.
|
||||
- A stricter `data` schema (e.g. `z.object({ kind: z.string() })`) belongs in application code that composes its own broadcast wrappers. The plugin level is intentionally permissive to support arbitrary payloads.
|
||||
75
docs/api-stack/api/api-plugin-sse/src/schemas/options.md
Normal file
75
docs/api-stack/api/api-plugin-sse/src/schemas/options.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# options
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schema validating `SsePluginOptions` at the factory boundary. `createSsePlugin` calls `SsePluginOptionsSchema.safeParse(options)` and raises `SseError(SSE_INVALID_INPUT)` with the `ZodError` as `cause` on failure. The schema also encodes the canonical defaults so the plugin descriptor's resolved config reflects every documented default value without the implementation hand-coding them.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict object validation rejects unknown keys to catch typos like `heartBeatInterval`.
|
||||
- Defaults applied via Zod's `.default()` so the resolved type has every field non-optional.
|
||||
- Numeric fields constrained: positive integers for time/cap fields; non-negative integer for retention (zero disables replay).
|
||||
- `routePrefix` regex matches `/`, `/sse`, `/v1/sse`, rejecting trailing-slash and missing-leading-slash forms.
|
||||
- Runtime objects (`idGenerator`, `clock`, `logger`) validated structurally via `z.custom` so duck-typed implementations are accepted.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SsePluginOptionsSchema } from "@suaveplan/api-plugin-sse/schemas/options";
|
||||
|
||||
const parsed = SsePluginOptionsSchema.parse({
|
||||
heartbeatIntervalMs: 15_000,
|
||||
maxConnectionsPerPrincipal: 4,
|
||||
});
|
||||
console.log(parsed.routePrefix); // "/sse" (defaulted)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SsePluginOptionsSchema } from "@suaveplan/api-plugin-sse/schemas/options";
|
||||
import { SSE_CODES, SseError } from "@suaveplan/api-plugin-sse/error-codes/error-codes";
|
||||
|
||||
function validateOrThrow(input: unknown) {
|
||||
const result = SsePluginOptionsSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
throw new SseError(SSE_CODES.INVALID_INPUT, "invalid SSE config", {
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// User-supplied logger duck-typed against LoggerLike — accepted.
|
||||
validateOrThrow({
|
||||
logger: {
|
||||
debug: console.debug,
|
||||
info: console.info,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SsePluginOptionsSchema` — Zod object schema. Fields and defaults:
|
||||
- `heartbeatIntervalMs` — positive integer (default `30_000`).
|
||||
- `routePrefix` — string matching `^/[A-Za-z0-9\-_/]*[A-Za-z0-9\-_]$|^/$` (default `"/sse"`).
|
||||
- `attachDefaultRoute` — boolean (default `true`).
|
||||
- `retentionPerChannel` — non-negative integer (default `100`).
|
||||
- `maxConnectionsPerPrincipal` — positive integer (default `16`).
|
||||
- `maxTotalConnections` — positive integer (default `10_000`).
|
||||
- `queueHighWaterMark` — positive integer (default `256`).
|
||||
- `overflowPolicy` — `"drop-oldest" | "drop-newest"` (default `"drop-oldest"`).
|
||||
- `idGenerator` — optional `() => string`.
|
||||
- `clock` — optional `Clock` from `@suaveplan/types`.
|
||||
- `logger` — optional `LoggerLike` from `@suaveplan/types`.
|
||||
- `type SsePluginOptionsInput` — `z.input<typeof SsePluginOptionsSchema>` (caller-facing shape).
|
||||
- `type SsePluginOptionsResolved` — `z.output<typeof SsePluginOptionsSchema>` (post-default shape).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The schema is `.strict()` — unknown keys produce a `ZodIssueCode.unrecognized_keys` issue. Intentionally strict so a typo doesn't silently fall back to a default.
|
||||
- The runtime-object validators do not require structural typing of every method — they sniff the presence of one canonical method (`logger.info`, `clock.now`) so callers can pass extended objects without rewriting them.
|
||||
- Defaults are intentionally generous for production use; tighten them in your `createSsePlugin` call if you run behind aggressive proxies or with constrained memory budgets.
|
||||
93
docs/api-stack/api/api-plugin-sse/src/services/service.md
Normal file
93
docs/api-stack/api/api-plugin-sse/src/services/service.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# service
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Core SSE service registered under the `sseService` slot in the host `ServiceRegistry`. Owns subscription lifecycle (id assignment, per-principal and global caps, abort-signal threading), ring-buffer append on broadcast, fanout delivery to live subscribers, and clean termination of every active iterator on `close`. Route handlers consume this service via `ctx.getService("sseService")` and never touch the underlying buffer/fanout directly.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-subscription `AsyncIterableIterator<SseEvent>` that resolves the next event from a backlog queue or a pending promise — so callers can `for await` without polling.
|
||||
- `Last-Event-ID` replay: on subscribe with `lastEventId`, the service pre-fills the backlog from the ring buffer before forwarding live events.
|
||||
- Global `maxTotalConnections` and per-principal `maxConnectionsPerPrincipal` caps with typed errors.
|
||||
- `AbortSignal` threading: client disconnect closes the subscription deterministically; pre-aborted signals are handled via `queueMicrotask`.
|
||||
- `close(channel?)` terminates every iterator (optionally restricted to a single channel) and rejects further `subscribe`/`broadcast` calls with `SseError(SSE_CLOSED)`.
|
||||
- `stats()` returns a live snapshot of active counts globally and per channel.
|
||||
- Every public failure path raises a typed `SseError`; `cause` chains preserve the underlying `ZodError` when relevant.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createSseService } from "@suaveplan/api-plugin-sse/services/service";
|
||||
import { createRingBuffer } from "@suaveplan/api-plugin-sse/buffer/ring-buffer";
|
||||
import { createInProcessFanout } from "@suaveplan/api-plugin-sse/fanout/in-process";
|
||||
|
||||
const service = createSseService({
|
||||
buffer: createRingBuffer(100),
|
||||
fanout: createInProcessFanout(),
|
||||
idGenerator: () => crypto.randomUUID(),
|
||||
maxConnectionsPerPrincipal: 16,
|
||||
maxTotalConnections: 10_000,
|
||||
});
|
||||
|
||||
const handle = service.subscribe({ channel: "notifications" });
|
||||
service.broadcast("notifications", { data: { kind: "ping" } });
|
||||
for await (const event of handle.events) {
|
||||
console.log(event);
|
||||
break;
|
||||
}
|
||||
await handle.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createSseService } from "@suaveplan/api-plugin-sse/services/service";
|
||||
|
||||
const service = createSseService({ /* ... */ });
|
||||
|
||||
// Subscribe with Last-Event-ID resume and an abort signal.
|
||||
const controller = new AbortController();
|
||||
const handle = service.subscribe({
|
||||
channel: "notifications",
|
||||
lastEventId: "evt-42",
|
||||
principalId: "user-123",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
// Disconnect on user navigation.
|
||||
window.addEventListener("beforeunload", () => controller.abort());
|
||||
|
||||
try {
|
||||
for await (const event of handle.events) {
|
||||
renderEvent(event);
|
||||
}
|
||||
} catch (err) {
|
||||
// Abort signal will resolve the iterator with done:true, not throw.
|
||||
// Errors here come from custom listener implementations.
|
||||
}
|
||||
|
||||
console.log(service.stats());
|
||||
// { activeConnections: 1, perChannel: { notifications: 1 } }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface CreateSseServiceOptions` — `{ buffer, fanout, idGenerator, maxConnectionsPerPrincipal, maxTotalConnections }`.
|
||||
- `createSseService(options: CreateSseServiceOptions): SseService` — constructs the service.
|
||||
- `interface SseService`:
|
||||
- `broadcast(channel: string, event: SseEventInput): SseEvent` — validates the event, appends to buffer, publishes via fanout. Returns the resolved event (with generated id when not supplied).
|
||||
- `subscribe(args: SubscribeArgs): SubscriptionHandle` — enforces caps, replays from `lastEventId` if supplied, threads `signal`. Returns a handle exposing the iterator and a `close()`.
|
||||
- `close(channel?: string): Promise<void>` — terminates every active subscription (optionally restricted to a single channel). Subsequent `broadcast` / `subscribe` calls raise `SSE_CLOSED` when called without a channel.
|
||||
- `stats(): SseStats` — snapshot `{ activeConnections, perChannel }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The iterator's `return()` method is honored so `for await (const ev of handle.events) { break }` cleans up the pending promise rather than leaking it.
|
||||
- Per-principal counts are tracked in a separate `Map<string, number>` for O(1) cap enforcement. The map is pruned when a principal's count returns to zero.
|
||||
- The fanout listener captures `sub.closed` inside the closure so deliveries after `detach` are silent no-ops — no race between unsubscribe and the next `publish`.
|
||||
- `close()` without a channel sets a `closedAll` latch so subsequent operations fail fast rather than racing the pending teardown.
|
||||
- The `idGenerator` is intentionally caller-provided so tests can use deterministic ids; the plugin layer supplies `crypto.randomUUID()` as the default.
|
||||
Reference in New Issue
Block a user