first commit
This commit is contained in:
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