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,67 @@
# `SseBroker`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SseBroker` is the engine-side publish / subscribe surface for Server-Sent Events. It mirrors the shape of `@suaveplan/api-ws`'s `WsBroker` (publish / subscribe / dispose) but is a distinct type so engines can supply different transports for SSE vs WebSocket without their type-level slots leaking into each other's `CapabilityMap` entries.
Concrete engines plug behind this contract: an in-process broker holds an event-emitter, a Redis-backed broker holds a `XADD` connection, a Cloudflare-Pipeline broker holds a `Queue` binding. The host never knows which one is wired in — every code path uses the same `publish(envelope)` / `subscribe(topic, handler)` calls.
## Features
- Envelope carries `topic`, `tenantId`, `event` so cross-tenant transports cannot fan out to the wrong audience.
- `subscribe` returns an unsubscribe function — symmetrical with `addEventListener`.
- `dispose` is async so transports can flush in-flight writes.
- Handler signature accepts sync or async return — engines normalise.
## Usage: Basic
```ts
import type { SseBroker } from "@suaveplan/api-sse/broker/broker";
declare const broker: SseBroker;
const unsubscribe = broker.subscribe("orders", async (env) => {
console.log(env.event.data);
});
```
## Usage: Advanced
```ts
import type {
SseBroker,
SseBrokerEnvelope,
} from "@suaveplan/api-sse/broker/broker";
declare const broker: SseBroker;
declare const tenantId: import("@suaveplan/api-types/tenant").TenantId;
async function notifyOrder(orderId: string) {
const envelope: SseBrokerEnvelope = {
topic: "orders",
tenantId,
event: { id: orderId, event: "order.created", data: orderId },
};
await broker.publish(envelope);
}
// Tear down on plugin stop:
await broker.dispose();
```
## API Reference
- `interface SseBroker`
- `publish(envelope: SseBrokerEnvelope): Promise<void>`
- `subscribe(topic: string, handler: SseBrokerHandler): () => void`
- `dispose(): Promise<void>`
- `interface SseBrokerEnvelope { topic: string; tenantId: TenantId; event: SseEvent }`
- `type SseBrokerHandler = (envelope: SseBrokerEnvelope) => void | Promise<void>`
## Implementation Notes
Handlers MUST be idempotent because upstream transports (Redis Streams with `XADD`, Kafka, etc.) may replay an envelope across consumer-group rebalances. The host's `ReplayBuffer` is the canonical de-dupe surface — handlers SHOULD use it for any side-effecting fan-out.

View File

@@ -0,0 +1,47 @@
# `SseCapabilityId` + `SSE_CAPABILITY_ID`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `"sse"` `CapabilityId` slot connects this package to the host's `CapabilityMap`. Plugin authors declare `requires: ["sse"]` in their manifest; the host narrows their `PluginContext` so `ctx.capabilities.sse` is typed as the concrete SSE broker / registry pair.
`@suaveplan/api-types/capabilities` already lists `"sse"` in its closed `CapabilityId` union — this module pins that contract with a type-test so an accidental removal upstream surfaces at this package's gates rather than landing silently and breaking every downstream consumer.
## Features
- `SseCapabilityId` is `Extract<CapabilityId, "sse">` — narrows to the literal at type-test time so a renamed/removed entry fails compile here.
- `SSE_CAPABILITY_ID` is a runtime `const` (`"sse"` `as const satisfies CapabilityId`) — usable in `requires: [SSE_CAPABILITY_ID] as const` declarations.
- No module augmentation needed; api-types owns the canonical union.
## Usage: Basic
```ts
import { SSE_CAPABILITY_ID } from "@suaveplan/api-sse/capability-id/capability-id";
const requires = [SSE_CAPABILITY_ID] as const;
```
## Usage: Advanced
```ts
import type { SseCapabilityId } from "@suaveplan/api-sse/capability-id/capability-id";
import type { CapabilityMap } from "@suaveplan/api-types/capabilities";
type SseSlot = CapabilityMap[SseCapabilityId];
function consume(slot: SseSlot) {
// slot is the concrete shape the host vends behind the SSE capability.
}
```
## API Reference
- `SSE_CAPABILITY_ID: "sse"` — runtime constant.
- `type SseCapabilityId = Extract<CapabilityId, "sse">` — type-level proof that `"sse"` is a member.
## Implementation Notes
The spec for this package describes the contribution as "augments the `CapabilityId` union to include `"sse"`". In practice api-types already declares `"sse"` directly, so the augmentation is degenerate: this module's job is to PIN the contract via `expectTypeOf<"sse">().toMatchTypeOf<CapabilityId>()` so the slot cannot be removed without breaking the api-sse build. This guards a real failure mode — a previous wave's WS capability was silently removed from api-types and the breakage surfaced only at app-tier integration.

View File

@@ -0,0 +1,67 @@
# `SseConnectionRegistry` + `createConnectionRegistry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SseConnectionRegistry` is the multi-index registry for active SSE connections. Engines call `add` on handshake, `remove` on disconnect, and the host fans out broadcasts via `lookupByTenant` / `lookupByTopic`. Lookups are O(1) by design — the registry keeps a parallel `tenantId → Set<conn>` and `(topic, tenantId) → Set<conn>` index so broadcast paths avoid scanning the full connection set.
`remove` is idempotent: engines can call it from both the graceful-close path and a finaliser without risk of throwing or double-decrementing tenant counts. This matches the shape of `WsConnectionRegistry` in `@suaveplan/api-types/ws` so engines that bridge both transports can share lifecycle code.
## Features
- O(1) lookup by `tenantId` via `lookupByTenant`.
- O(1) lookup by `(topic, tenantId)` via `lookupByTopic` — cross-tenant fan-out is impossible by construction.
- `countByTenant` powers the per-principal cap check at handshake.
- `add(conn, topics)` is mutation-only; topics MUST be supplied at `add` time (no `subscribe(conn, topic)` here — topic subscriptions live with the SSE manifest).
## Usage: Basic
```ts
import { createConnectionRegistry } from "@suaveplan/api-sse/connection-registry/connection-registry";
const reg = createConnectionRegistry();
declare const connection: import("@suaveplan/api-sse/connection/connection").SseConnection;
reg.add(connection, ["orders", "notifications"]);
const audience = reg.lookupByTopic("orders", connection.tenantId);
```
## Usage: Advanced
```ts
import { createConnectionRegistry } from "@suaveplan/api-sse/connection-registry/connection-registry";
const reg = createConnectionRegistry();
function broadcast(
tenantId: import("@suaveplan/api-types/tenant").TenantId,
topic: string,
event: import("@suaveplan/api-sse/event/event").SseEvent
) {
return Promise.all(
reg.lookupByTopic(topic, tenantId).map((conn) => conn.send(event))
);
}
// idempotent close
reg.remove(conn);
reg.remove(conn); // does not throw
```
## API Reference
- `createConnectionRegistry(): SseConnectionRegistry`
- `interface SseConnectionRegistry`
- `add(conn: SseConnection, topics?: readonly string[]): void`
- `remove(conn: SseConnection): void` (idempotent)
- `lookupByTenant(tenantId: TenantId): readonly SseConnection[]`
- `lookupByTopic(topic: string, tenantId: TenantId): readonly SseConnection[]`
- `countByTenant(tenantId: TenantId): number`
- `size: number`
## Implementation Notes
The implementation uses a `Set` per tenant and per `(topic, tenantId)` key for O(1) `add` / `delete`. Topics-per-connection are stashed in a `WeakMap` so the registry never holds a strong reference past `remove` — the connection becomes eligible for GC as soon as the engine drops its own handle. The `(topic, tenantId)` composite key uses a space separator (`${tenantId} ${topic}`) because branded `TenantId` cannot contain spaces by construction (resolved at the trust boundary).

View File

@@ -0,0 +1,64 @@
# `SseConnection`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SseConnection` is the engine-side contract for a single Server-Sent-Events stream. Concrete engines (`api-sse-bun`, `api-sse-cloudflare`, ...) hand the host one of these per accepted client. The host fans out broadcasts, replays buffered events on `Last-Event-Id` reconnect, and tracks lifecycle state through this interface — without ever knowing which transport implements it.
Unlike `@suaveplan/api-types/sse`'s minimal plugin-facing slot, this richer engine-facing shape adds `lastEventId`, `state`, and a typed `send(event: SseEvent)` so the host can drive the replay-buffer / connection-registry pair without re-deriving the resume contract per engine.
## Features
- Branded `tenantId` lifts tenant scoping into the type system.
- `principal` is `Principal | null` so engines accept routes listed under `publicRoutes`.
- `state` tracks `"open" | "closing" | "closed"` so callers can short-circuit sends during graceful close.
- `lastEventId` is the client-supplied `Last-Event-Id` header on (re)connect; `null` on first connect.
- `send` accepts a fully-formed `SseEvent` — wire formatting is the engine's responsibility.
## Usage: Basic
```ts
import type { SseConnection } from "@suaveplan/api-sse/connection/connection";
declare const connection: SseConnection;
if (connection.state === "open") {
await connection.send({ id: "1", data: "hello" });
}
```
## Usage: Advanced
```ts
import type { SseConnection } from "@suaveplan/api-sse/connection/connection";
import { createReplayBuffer } from "@suaveplan/api-sse/replay-buffer/replay-buffer";
const buffer = createReplayBuffer({ eventBufferSize: 512 });
async function attach(connection: SseConnection) {
if (connection.lastEventId !== null) {
for (const event of buffer.since(connection.lastEventId)) {
await connection.send(event);
}
}
}
```
## API Reference
- `id: string` — ULID assigned at handshake.
- `principal: Principal | null` — caller identity; `null` for public routes.
- `tenantId: TenantId` — branded tenant scope.
- `lastEventId: string | null` — client-supplied resume marker.
- `state: SseConnectionState` — `"open" | "closing" | "closed"`.
- `send(event: SseEvent): Promise<void>` — write a fully-formed event to the wire.
- `close(): Promise<void>` — initiate graceful close (idempotent).
`SseConnectionState` exports the literal union so engines can pattern-match.
## Implementation Notes
This is a contract-only interface. No implementation lives in `@suaveplan/api-sse` — the package ships the contract and the conformance harness, and concrete engines plug behind it at . The `state` field is read-only on the interface so engines retain the freedom to drive transitions through whatever internal mechanism (Bun's `WritableStream` close callback, Cloudflare's `WebSocketServer.dispatch`, etc.) suits their transport.

View File

@@ -0,0 +1,65 @@
# `SSE_CODES` + `SseError`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SSE_CODES` is the stable error-code registry every contract-level failure path in the SSE family raises. Concrete engines (`api-sse-bun`, `api-sse-cloudflare`, ...) re-throw using this same registry so consumers can switch on `err.code === SSE_CODES.LAST_EVENT_ID_UNKNOWN` regardless of which engine produced the error.
The registry is frozen at module-init time (via `defineCodes` from `@suaveplan/error`) and the six entries are pinned by the package's `error-codes.spec-conformance.test.ts` — every entry has a production throw site reachable from `production-throws.ts`. New codes go through an OpenSpec change; this file is the canonical list.
## Features
- Six stable codes: `HANDSHAKE_FAILED`, `PRINCIPAL_EXPIRED`, `BUFFER_OVERFLOW`, `LAST_EVENT_ID_UNKNOWN`, `CONNECTION_CAP_EXCEEDED`, `BROKER_UNAVAILABLE`.
- Every code is namespaced under `SSE.*`.
- `SseError extends SuaveplanError<SseCode>` — `instanceof SseError` narrows the union.
- `defineCodes` returns a frozen record; mutation throws in strict mode.
## Usage: Basic
```ts
import { SSE_CODES, SseError } from "@suaveplan/api-sse/error-codes/error-codes";
throw new SseError(
SSE_CODES.LAST_EVENT_ID_UNKNOWN,
"error.sse.last_event_id_unknown",
{ context: { lastEventId: "evicted-99" } }
);
```
## Usage: Advanced
```ts
import { SseError } from "@suaveplan/api-sse/error-codes/error-codes";
function handle(err: unknown) {
if (err instanceof SseError) {
switch (err.code) {
case "SSE.LAST_EVENT_ID_UNKNOWN":
// fall back to live subscription
return;
case "SSE.PRINCIPAL_EXPIRED":
// close + force client re-auth
return;
case "SSE.BUFFER_OVERFLOW":
// emit OTel counter `sse.dropped_events_total`
return;
default:
throw err;
}
}
throw err;
}
```
## API Reference
- `SSE_CODES` — frozen record `{ HANDSHAKE_FAILED: "SSE.HANDSHAKE_FAILED", ... }`.
- `SseCode` — union of every value in `SSE_CODES`.
- `class SseError extends SuaveplanError<SseCode>` — ctor `(code, messageKey, options?)`.
## Implementation Notes
The six codes were chosen to match the SSE contract surface exposed by this package — every other observable failure (transport down, rate limit, etc.) is the engine's concern and lives in the engine's own code registry. Plugin authors who need a new contract-level code raise an OpenSpec change rather than reaching for a generic one; the registry is intentionally small to keep switch-statements exhaustive.

View File

@@ -0,0 +1,60 @@
# `production-throws`
## Purpose
`production-throws` exports the canonical factory functions that the host (and concrete SSE engines) invoke at every documented contract-level failure point. Each function constructs and throws a fully-formed `SseError` with the appropriate context payload — engines call these directly rather than re-implementing the throw shape, so the wire-visible error envelope stays identical across implementations.
The factories also let `api-sse` (a contract-only package) satisfy the "every `SSE_CODES` entry has a production throw site" invariant without inventing fake engines. Each factory's signature is the minimum information the host needs to surface a useful error to the client.
## Features
- One factory per `SSE_CODES` entry.
- Every factory returns `never` so `try / catch` flow analysis tightens.
- `context` payloads carry the structured diagnostic data (`detail`, `principalId`, `connectionId + queued`, `lastEventId`, `scope + limit`, `operation`).
- `brokerUnavailable` accepts an optional `cause` so engines can chain transport-level errors.
## Usage: Basic
```ts
import { lastEventIdUnknown } from "@suaveplan/api-sse/error-codes/production-throws";
function replay(id: string) {
if (!buffer.has(id)) {
lastEventIdUnknown(id);
}
return buffer.since(id);
}
```
## Usage: Advanced
```ts
import {
brokerUnavailable,
connectionCapExceeded,
} from "@suaveplan/api-sse/error-codes/production-throws";
async function handshake() {
if (activeConnections >= globalCap) {
connectionCapExceeded("global", globalCap);
}
try {
await transport.connect();
} catch (cause) {
brokerUnavailable("connect", cause as Error);
}
}
```
## API Reference
- `handshakeFailed(detail?: string): never`
- `principalExpired(principalId?: string): never`
- `bufferOverflow(connectionId: string, queued: number): never`
- `lastEventIdUnknown(lastEventId: string): never`
- `connectionCapExceeded(scope: "global" | "principal", limit: number): never`
- `brokerUnavailable(operation: string, cause?: Error): never`
## Implementation Notes
The factories live in a separate module from the `SSE_CODES` registry so the registry stays a pure data object and tree-shaking can drop the throw factories from a bundle that only needs to switch on codes. The conformance test imports the factory list and exercises every one, which is how the package guarantees "every code reachable" without coupling the registry definition to the throw sites.

View File

@@ -0,0 +1,52 @@
# `SseEvent` + `sseEventSchema`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SseEvent` is the wire-shape contract for a single Server-Sent-Events field block: `id`, optional `event`, `data`, optional `retry`. The Zod schema `sseEventSchema` validates the shape at every host → engine boundary so plugin authors catch typos (`eventName` vs `event`) at parse time instead of silently dropping fields on the wire.
The fields follow WHATWG and W3C SSE: every concrete engine serialises the parsed shape onto the wire as `id: <id>\nevent: <event>\ndata: <data>\nretry: <ms>\n\n`. Multi-line `data` is the engine's responsibility — splitting newlines into multiple `data:` lines is a wire-format concern, not part of this contract.
## Features
- `strict()` schema: unknown keys fail parse.
- `id` is non-empty (powers the replay buffer's `since(lastEventId)` lookup).
- `event` is non-empty when supplied; absent stays absent (no default).
- `retry` is a non-negative integer; floats and negatives are rejected at parse.
## Usage: Basic
```ts
import { sseEventSchema } from "@suaveplan/api-sse/event/event";
const event = sseEventSchema.parse({ id: "1", data: "hello" });
```
## Usage: Advanced
```ts
import { sseEventSchema, type SseEvent } from "@suaveplan/api-sse/event/event";
function validatedSend(input: unknown): SseEvent {
return sseEventSchema.parse(input);
}
const ping: SseEvent = validatedSend({
id: "evt-42",
event: "ping",
data: JSON.stringify({ ts: Date.now() }),
retry: 15_000,
});
```
## API Reference
- `sseEventSchema: z.ZodObject<...>` — strict Zod schema; `parse(input)` returns a typed `SseEvent`.
- `interface SseEvent` — compile-time view: `{ id: string; event?: string; data: string; retry?: number }`.
## Implementation Notes
The interface is declared separately from the inferred Zod type so consumers can annotate handler arguments without paying for Zod inference cost in their public surface. The runtime schema is the only validator; engines MUST call `sseEventSchema.parse(input)` before queueing for the wire so the replay buffer can rely on a valid `id` field.

View File

@@ -0,0 +1,74 @@
# `ReplayBuffer` + `createReplayBuffer`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ReplayBuffer` is the per-`(tenant, topic)` ring buffer that powers the `Last-Event-Id` resume contract. Concrete engines call `append(event)` for every published event, `since(lastEventId)` when a client reconnects with a `Last-Event-Id` header, and `evictExpired(now)` from a periodic timer so the buffer stays within its `eventBufferMs` window.
The buffer is bounded by both an entry count (`eventBufferSize`, default 256) and an age (`eventBufferMs`, default 30 000 ms). When the count cap is reached, the oldest entry is dropped on the next `append`. When the age cap is reached, every expired entry is dropped on the next `evictExpired`. Unknown event ids — either never observed or already evicted — surface `SSE.LAST_EVENT_ID_UNKNOWN` so the engine can fall back to a live subscription.
## Features
- Size cap and age cap evict independently — either can trip first.
- Injectable `clock` so tests can fast-forward without `Date.now` mocking.
- `since(id)` returns every event AFTER the marker in append order; the marker itself is excluded.
- Throws `SseError` with `SSE.LAST_EVENT_ID_UNKNOWN` for evicted or never-seen ids.
## Usage: Basic
```ts
import { createReplayBuffer } from "@suaveplan/api-sse/replay-buffer/replay-buffer";
const buf = createReplayBuffer();
buf.append({ id: "1", data: "hello" });
buf.append({ id: "2", data: "world" });
const replayed = buf.since("1"); // [{ id: "2", data: "world" }]
```
## Usage: Advanced
```ts
import { createReplayBuffer } from "@suaveplan/api-sse/replay-buffer/replay-buffer";
import { SseError } from "@suaveplan/api-sse/error-codes/error-codes";
let t = 0;
const buf = createReplayBuffer({
eventBufferSize: 1024,
eventBufferMs: 60_000,
clock: () => t,
});
t = 0;
buf.append({ id: "evt-1", data: "x" });
t = 70_000;
buf.evictExpired(t); // drops evt-1
try {
buf.since("evt-1");
} catch (err) {
if (err instanceof SseError && err.code === "SSE.LAST_EVENT_ID_UNKNOWN") {
// fall back to live subscription
}
}
```
## API Reference
- `createReplayBuffer(options?: ReplayBufferOptions): ReplayBuffer`
- `interface ReplayBuffer`
- `append(event: SseEvent): void`
- `since(lastEventId: string): readonly SseEvent[]`
- `evictExpired(now: number): void`
- `size: number`
- `interface ReplayBufferOptions`
- `eventBufferSize?: number` (default 256, min 1)
- `eventBufferMs?: number` (default 30 000, min 1)
- `clock?: () => number` (default `Date.now`)
## Implementation Notes
The buffer is FIFO — `append` pushes onto the tail, `evictExpired` and the size cap shift off the head. `since(id)` performs an O(n) `findIndex`; for typical buffer sizes (≤ 1024) this is faster than maintaining a `Map<id, index>` parallel index because the constant factor on `findIndex` is roughly 1 ns / entry on V8. If a deployment shows hot-path latency at scale, swap the linear scan for a `Map<id, sequence-number>` and the public API stays the same.

View File

@@ -0,0 +1,72 @@
# `runSseEngineConformance` + `createStubConformanceEngine`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`runSseEngineConformance` is the reusable conformance harness every concrete `@suaveplan/api-sse` engine MUST pass. Engine packages (`api-sse-bun`, future `api-sse-cloudflare`, ...) import the harness, supply a factory that builds their engine, and the harness runs four categories of tests: handshake, send, last-event-id replay, and time-based eviction.
`createStubConformanceEngine` is the built-in stub the contract package uses to self-test the harness — it lets api-sse run the harness against a known-good fake so a refactor of the harness itself fails this package's gates before any concrete engine sees the change. Engines never depend on the stub; it lives in `src/testing/` purely so api-sse's own tests can exercise the four categories without a circular dependency on a concrete engine.
## Features
- Four test categories: `handshake`, `send`, `replay`, `eviction`.
- `skip: ["replay"]` option for local-development override (forbidden in CI gates).
- `label` is interpolated into the `describe(...)` title so multi-engine test runs are readable.
- Stub engine ships a `ConformanceEngine` factory that satisfies every category.
## Usage: Basic
```ts
import {
createStubConformanceEngine,
runSseEngineConformance,
} from "@suaveplan/api-sse/testing/conformance";
runSseEngineConformance({
factory: () => createStubConformanceEngine(),
label: "my-engine",
});
```
## Usage: Advanced
```ts
import { runSseEngineConformance } from "@suaveplan/api-sse/testing/conformance";
import { createBunEngine } from "./engine.js";
runSseEngineConformance({
factory: async () => {
const engine = await createBunEngine({ port: 0 });
return {
get sentEvents() {
return engine.sentEvents;
},
accept: () => engine.accept(),
send: (event) => engine.send(event),
buildReplayBuffer: (opts) => engine.buildReplayBuffer(opts),
};
},
label: "api-sse-bun",
});
```
## API Reference
- `runSseEngineConformance(options: RunSseEngineConformanceOptions): void`
- `interface RunSseEngineConformanceOptions`
- `factory: ConformanceEngineFactory`
- `label: string`
- `skip?: ReadonlyArray<"handshake" | "send" | "replay" | "eviction">`
- `interface ConformanceEngine`
- `sentEvents: readonly SseEvent[]`
- `accept(): Promise<{ state: "open" | "closing" | "closed" }>`
- `send(event: SseEvent): Promise<void>`
- `buildReplayBuffer(options?): ReplayBuffer`
- `createStubConformanceEngine(): ConformanceEngine`
## Implementation Notes
The harness uses `describe / it` from `@suaveplan/testing/runner` so it runs cleanly under both vitest and `bun:test` (which is how this contract package supports rule 11). The stub engine deliberately keeps `sentEvents` as a getter over an internal array so the conformance harness exercises array-mutation paths the way a real engine would — replacing it with an immutable snapshot would hide a real failure mode (mutation after `close()`).