first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View 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.