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,142 @@
# @suaveplan/api-sse-bun - Bun.serve concrete engine for @suaveplan/api-sse: text/event-stream framing, Last-Event-Id replay, per-(tenant,topic) ring buffer with broker delegation, gzip streaming, keep-alive comments, per-tenant connection cap, shared subscription bus with WS engine, and the runSseEngineConformance harness bound to createBunSseEngine.
`Bun.serve`-backed Server-Sent Events engine for the `@suaveplan/api`
plugin system. Implements the abstract `@suaveplan/api-sse`
contract and passes `runSseEngineConformance` end-to-end. Lands at
api-plugin-foundation umbrella.
SSE complements `api-ws-bun`: where WebSocket carries bi-directional
duplex traffic, SSE is the simpler server→client stream useful for
activity feeds, dashboards, notifications, and any payload pattern
that does not need client→server acks. SSE also falls back gracefully
on networks that block WebSocket (corporate proxies, restrictive
firewalls) so a single broadcast can reach clients via either
transport.
<!-- begin:auto-docs -->
## Documentation tree
- [compression](./src/compression/compression.md) — The `compression` module owns the engine's optional gzip path. SSE streams compress well — UTF-8 text framed with repea…
- [compression](./src/compression/compression.md) — The `compression` module owns the engine's optional gzip path. SSE streams compress well — UTF-8 text framed with repea…
- [engine](./src/engine/bun-sse-engine.md) — The `bun-sse-engine` module exports `createBunSseEngine`, the factory the host calls to mount Server-Sent Events on top…
- [bun-sse-engine](./src/engine/bun-sse-engine.md) — The `bun-sse-engine` module exports `createBunSseEngine`, the factory the host calls to mount Server-Sent Events on top…
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module defines the stable `SSE_BUN_*` error registry used across the Bun SSE engine. Every failure mo…
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module defines the stable `SSE_BUN_*` error registry used across the Bun SSE engine. Every failure mo…
- [event-buffer](./src/event-buffer/event-buffer.md) — The `event-buffer` module owns the per-`(tenant, topic)` replay storage the engine consults on `Last-Event-Id` reconnec…
- [event-buffer](./src/event-buffer/event-buffer.md) — The `event-buffer` module owns the per-`(tenant, topic)` replay storage the engine consults on `Last-Event-Id` reconnec…
- [handshake](./src/handshake/handshake.md) — The `handshake` module implements the 7-step SSE handshake pipeline: topic validation, `Last-Event-Id` parsing, authent…
- [handshake](./src/handshake/handshake.md) — The `handshake` module implements the 7-step SSE handshake pipeline: topic validation, `Last-Event-Id` parsing, authent…
- [instruments](./src/instruments/instruments.md) — The `instruments` module wires the engine's OpenTelemetry surface. Every metric mandated by design doc §11.7 — connecti…
- [instruments](./src/instruments/instruments.md) — The `instruments` module wires the engine's OpenTelemetry surface. Every metric mandated by design doc §11.7 — connecti…
- [keep-alive](./src/keep-alive/keep-alive.md) — The `keep-alive` module emits invisible SSE comment lines on a fixed cadence so intermediary proxies (corporate gateway…
- [keep-alive](./src/keep-alive/keep-alive.md) — The `keep-alive` module emits invisible SSE comment lines on a fixed cadence so intermediary proxies (corporate gateway…
- [last-event-id](./src/last-event-id/last-event-id.md) — The `last-event-id` module parses and validates the `Last-Event-Id` header browsers send automatically on `EventSource`…
- [last-event-id](./src/last-event-id/last-event-id.md) — The `last-event-id` module parses and validates the `Last-Event-Id` header browsers send automatically on `EventSource`…
- [limits](./src/limits/tenant-cap.md) — The `tenant-cap` module owns the per-tenant SSE connection counter the engine consults during handshake. A tenant that…
- [tenant-cap](./src/limits/tenant-cap.md) — The `tenant-cap` module owns the per-tenant SSE connection counter the engine consults during handshake. A tenant that…
- [options](./src/options/options.md) — The `options` module declares the public configuration surface accepted by `createBunSseEngine` and exposes `resolveOpt…
- [options](./src/options/options.md) — The `options` module declares the public configuration surface accepted by `createBunSseEngine` and exposes `resolveOpt…
- [stream](./src/stream/sse-stream.md) — The `sse-stream` module owns the byte-level framing for the SSE wire protocol. Every event the engine pushes to a clien…
- [sse-stream](./src/stream/sse-stream.md) — The `sse-stream` module owns the byte-level framing for the SSE wire protocol. Every event the engine pushes to a clien…
- [subscription](./src/subscription/subscription-bus.md) — The `subscription-bus` module owns the shared fan-out surface both the WS and SSE engines plug into. The host construct…
- [subscription-bus](./src/subscription/subscription-bus.md) — The `subscription-bus` module owns the shared fan-out surface both the WS and SSE engines plug into. The host construct…
- [testing](./src/testing/conformance.md) — The `testing` module exposes `runBunSseEngineConformance` — a thin binding that hands `createBunSseEngine` to the abstr…
- [conformance](./src/testing/conformance.md) — The `testing` module exposes `runBunSseEngineConformance` — a thin binding that hands `createBunSseEngine` to the abstr…
<!-- end:auto-docs -->
## Modules
- **`engine/bun-sse-engine`** — `createBunSseEngine(options)` factory
returning the public engine value; wires options + buffer + bus +
cap + instruments + handshake into one cohesive instance.
- **`handshake/handshake`** — 7-step pipeline (topic validation,
Last-Event-Id parse, auth, cap acquire, compression negotiate,
prologue + replay, subscribe + keep-alive).
- **`stream/sse-stream`** — WHATWG-compliant byte-level framing for
`id`/`event`/`data`/`retry`/`:comment` lines.
- **`event-buffer/event-buffer`** — in-memory ring buffer + broker
delegate behind one `EventBuffer` surface.
- **`subscription/subscription-bus`** — shared topic + tenant bus the
WS engine reuses.
- **`limits/tenant-cap`** — per-tenant connection counter enforced at
handshake time.
- **`compression/compression`** — `Accept-Encoding` negotiation +
`node:zlib` streaming with sync-flush after every chunk.
- **`keep-alive/keep-alive`** — periodic `:keepalive` comment
scheduler with injectable timer for `FakeClock` tests.
- **`last-event-id/last-event-id`** — header parser enforcing decimal
format.
- **`instruments/instruments`** — OTel meter bundle: connections
gauge, events-sent counter, handshake-duration histogram,
replay-hit/miss counter, compression-ratio histogram.
- **`error-codes/error-codes`** — 10 stable `SSE_BUN_*` codes +
`SseBunError` subclass.
- **`testing/conformance`** — `runBunSseEngineConformance()` binding
to the abstract `runSseEngineConformance` harness.
## Defaults
| Knob | Default | Notes |
|------|---------|-------|
| `eventBufferSize` | 256 | Per-`(tenant, topic)` ring capacity. |
| `eventBufferMs` | 300_000 | 5 min — pinned to WS reconnect window. |
| `keepAliveCommentMs` | 15_000 | 15 s — inside the 30 s proxy idle window. |
| `maxConnectionsPerTenant` | 1_000 | Per-tenant cap. |
| `maxEventBytes` | 65_536 | 64 KiB per encoded frame. |
| `compressionEnabled` | `true` | Honours `Accept-Encoding: gzip`. |
| `retryMs` | 3_000 | Initial `retry:` directive. |
## Boundary vs `api-ws-bun`
SSE is **server→client only**. It does **not** carry client→server
messages, acks, or close codes — the client opens a long-lived GET,
the server streams text/event-stream until the client disconnects or
the server tears down. Use SSE when you need a uni-directional
broadcast stream that defaults to working through restrictive
intermediaries; use WS when you need duplex semantics, binary frames,
or per-message acks. The two engines share the same subscription bus
and event-buffer surface so the choice is per-route, not per-deploy.
## Install + use
```bash
bun add @suaveplan/api-sse-bun
```
```ts
import { createBunSseEngine } from "@suaveplan/api-sse-bun";
const engine = createBunSseEngine({
authenticate: async (req) => ({
tenantId: (req.headers.get("x-tenant") ?? "anon") as never,
principal: null,
}),
});
Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/sse/audit") {
return engine.acceptRequest({
request: {
headers: request.headers,
url: request.url,
method: request.method,
},
path: url.pathname,
topic: "audit",
});
}
return new Response("not found", { status: 404 });
},
});
```
## Status
Stage-7 keystone. Conformance is pinned by the abstract
`runSseEngineConformance` harness from `@suaveplan/api-sse`; this
package's `runBunSseEngineConformance()` re-runs it bound to
`createBunSseEngine`.

View File

@@ -0,0 +1,85 @@
# `compression`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `compression` module owns the engine's optional gzip path. SSE
streams compress well — UTF-8 text framed with repeating field labels —
so honouring `Accept-Encoding: gzip` shrinks bandwidth meaningfully
without paying any encoding cost when the client did not opt in. The
module exposes the negotiation decision separately from the encoder so
the engine can short-circuit before allocating a `node:zlib` stream when
`compressionEnabled: false` or the client did not ask for gzip.
## Features
- `negotiateCompression(acceptEncoding, compressionEnabled)` returns a
declarative plan; no side effects.
- `Z_SYNC_FLUSH` after every `write(chunk)` so events hit the wire
immediately — gzip's default block boundary would delay them.
- Counters expose `uncompressed`/`compressed` byte totals so the OTel
histogram `sse.compression_ratio` can record per-stream values.
- Encoder errors throw `SseBunError(COMPRESSION_FAILED)` with the
underlying `cause` attached for log forensics.
## Usage: Basic
```ts
import {
createCompressionStream,
negotiateCompression,
} from "@suaveplan/api-sse-bun";
const plan = negotiateCompression(request.headers.get("accept-encoding"), true);
if (plan.useGzip) {
const stream = createCompressionStream();
const chunk = await stream.write(new TextEncoder().encode("data: hi\n\n"));
// ...write chunk to socket...
const tail = await stream.close();
}
```
## Usage: Advanced
```ts
import {
createCompressionStream,
SSE_BUN_CODES,
SseBunError,
} from "@suaveplan/api-sse-bun";
const stream = createCompressionStream();
try {
await stream.write(new TextEncoder().encode("data: hello\n\n"));
} catch (err) {
if (
err instanceof SseBunError &&
err.code === SSE_BUN_CODES.COMPRESSION_FAILED
) {
// fall back to plain delivery; emit log + telemetry
} else {
throw err;
}
}
const ratio = stream.uncompressed / stream.compressed;
```
## API Reference
- `interface CompressionPlan` — `{ useGzip: boolean }`.
- `function negotiateCompression(acceptEncoding, compressionEnabled): CompressionPlan`.
- `interface CompressionStream` — `write(bytes)`, `close()`,
`uncompressed`, `compressed`.
- `function createCompressionStream(): CompressionStream`.
## Implementation Notes
The encoder buffers no more than one in-flight chunk at a time —
`write(chunk)` resolves only after `Z_SYNC_FLUSH` drains the encoder,
so back-pressure surfaces to the engine immediately. Closing the
stream emits any final bytes (the gzip footer). Error context wraps
the underlying `node:zlib` error via `cause` so consumers retain the
stack across the throw boundary.

View File

@@ -0,0 +1,108 @@
# `bun-sse-engine`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `bun-sse-engine` module exports `createBunSseEngine`, the factory
the host calls to mount Server-Sent Events on top of `Bun.serve`. The
factory wires the seven collaborators (options, subscription bus,
tenant cap, event buffer, instruments, handshake pipeline, active
connection registry) into one cohesive engine value. Plugin authors
never touch the internals — they hand the engine to the host's router
which calls `engine.acceptRequest(...)` on every matching GET request.
## Features
- Single factory: validated options, broker- or in-memory event buffer
selected automatically, shared subscription bus exposed for the WS
engine to plug into.
- `acceptRequest({ request, path, topic })` runs the full handshake
pipeline and returns the 200 streaming `Response`.
- `publish(envelope)` shortcut: append to the buffer + broadcast on the
bus in one call — used by plugins that publish in-process.
- `activeConnections` map for observability dashboards (size, tenant
distribution, oldest open).
- `buildReplayBuffer` exposed for the conformance harness so the
engine's ring-buffer semantics are pinned by the same test path
every other engine uses.
## Usage: Basic
```ts
import { createBunSseEngine } from "@suaveplan/api-sse-bun";
const engine = createBunSseEngine({
authenticate: async () => ({
tenantId: "acme" as never,
principal: null,
}),
});
// ...wire engine.acceptRequest into a Bun.serve handler...
engine.dispose();
```
## Usage: Advanced
```ts
import { createBunSseEngine } from "@suaveplan/api-sse-bun";
import Bun from "bun";
const engine = createBunSseEngine({
eventBufferSize: 512,
eventBufferMs: 600_000,
keepAliveCommentMs: 30_000,
maxConnectionsPerTenant: 5_000,
compressionEnabled: true,
retryMs: 4_000,
authenticate: async (req) => {
const tenant = req.headers.get("x-tenant");
if (tenant === null) return { tenantId: null, principal: null };
return { tenantId: tenant as never, principal: null };
},
});
Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/sse/audit") {
return engine.acceptRequest({
request: {
headers: request.headers,
url: request.url,
method: request.method,
},
path: url.pathname,
topic: "audit",
});
}
return new Response("not found", { status: 404 });
},
});
```
## API Reference
- `interface SseAcceptInputs` — `{ request, path, topic }`.
- `interface BunSseEngine` — `bus`, `cap`, `buffer`, `instruments`,
`options`, `activeConnections`, `acceptRequest(inputs)`,
`publish(envelope)`, `dispose()`, `buildReplayBuffer(opts?)`.
- `function createBunSseEngine(options: BunSseEngineOptions): BunSseEngine`.
- `function aggregateConnectionSnapshot(active, plugin): readonly { plugin,
path, tenant, value }[]` — exported so OTel snapshots can be exercised
in tests independent of the global meter provider.
## Implementation Notes
The factory does not start any background timers — every long-lived
resource (keep-alive scheduler, gzip stream) is owned by the
handshake pipeline and bound to a single connection. `dispose()` is a
synchronous best-effort: it cancels the bus and clears the active map
but does not eagerly close every open stream (the HTTP transport
handles that when the underlying socket closes). The
`connectionSnapshot` for the OTel gauge aggregates active connections
per `(path, tenantId)` so dashboards can plot capacity per route per
tenant without re-walking the connection set inside the SDK callback.

View File

@@ -0,0 +1,78 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `error-codes` module defines the stable `SSE_BUN_*` error registry used
across the Bun SSE engine. Every failure mode the engine exposes — handshake
rejection, tenant-cap breach, replay-buffer miss, compression fallback,
malformed `Last-Event-Id`, broker outage, oversize event, principal expiry,
topic validation — is represented by exactly one code so callers can branch on
`err.code === SSE_BUN_CODES.TENANT_CAP_EXCEEDED` without parsing message
strings. The module also exports `SseBunError`, the typed `SuaveplanError`
subclass every engine throw site instantiates.
## Features
- Ten stable codes registered via `defineCodes("SSE_BUN", …)`.
- Typed `SseBunCode` union derived from the registry constant.
- `SseBunError` subclass with a public constructor (passes the
`check-error-subclass-constructors` lint).
- Compatible with the `@suaveplan/error` redaction + wire format.
## Usage: Basic
```ts
import { SSE_BUN_CODES, SseBunError } from "@suaveplan/api-sse-bun/error-codes";
throw new SseBunError(
SSE_BUN_CODES.TENANT_CAP_EXCEEDED,
"error.sse_bun.tenant_cap_exceeded",
{ context: { tenantId: "acme", limit: 1000 } }
);
```
## Usage: Advanced
```ts
import { SSE_BUN_CODES, SseBunError } from "@suaveplan/api-sse-bun/error-codes";
try {
await engine.acceptRequest(request);
} catch (err) {
if (err instanceof SseBunError) {
switch (err.code) {
case SSE_BUN_CODES.LAST_EVENT_ID_INVALID:
return new Response("bad Last-Event-Id", { status: 400 });
case SSE_BUN_CODES.TENANT_CAP_EXCEEDED:
return new Response("rate limited", { status: 429 });
default:
throw err;
}
}
throw err;
}
```
## API Reference
- `SSE_BUN_CODES` — frozen record mapping symbolic names to the prefixed
string codes (e.g. `SSE_BUN.TENANT_CAP_EXCEEDED`).
- `type SseBunCode` — union of every value in `SSE_BUN_CODES`.
- `class SseBunError extends SuaveplanError<SseBunCode>` — public
constructor `(code, messageKey, options?)`; mirrors every other typed
error in the foundation.
## Implementation Notes
The module side-effect-imports nothing — `defineCodes` registers the codes
exactly once at module load. The class declares `static readonly errorName`
so cross-bundle `instanceof` checks have a backup discriminator when source
maps strip the prototype chain. The constructor signature accepts an
optional `SuaveplanErrorOptions` value (cause + context); concrete throw
sites in the engine always pass a context payload so operators can route
on `tenantId`, `connectionId`, or `lastEventId` without re-parsing the
message.

View File

@@ -0,0 +1,86 @@
# `event-buffer`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `event-buffer` module owns the per-`(tenant, topic)` replay storage
the engine consults on `Last-Event-Id` reconnects. Two implementations
are exported: an in-memory ring buffer for single-host deployments and a
broker-backed adapter for clusters that share replay state with the WS
engine. Both expose the same `EventBuffer` surface so the engine code
path is identical regardless of deployment topology.
## Features
- Multi-key buffer (`tenantId`, `topic`) on top of the abstract
`ReplayBuffer` exported by `@suaveplan/api-sse`.
- In-memory mode: lazy slot allocation, size + ttl bounded ring buffer,
one `ReplayBuffer` per `(tenantId, topic)`.
- Broker mode: delegates `publish` to the wired `SseBroker`, keeps a
shadow in-memory mirror so reads stay synchronous.
- `evictExpired(now)` sweeps every per-key buffer in one call.
- Synchronous `size(key)` probe used by the `BUFFER_MISS` decision.
## Usage: Basic
```ts
import { createInMemoryEventBuffer } from "@suaveplan/api-sse-bun";
const buffer = createInMemoryEventBuffer({
eventBufferSize: 256,
eventBufferMs: 300_000,
});
const key = { tenantId: "acme" as never, topic: "audit" };
buffer.append(key, { id: "1", data: "first" });
const replay = await buffer.since(key, "non-existent");
console.log(replay); // []
```
## Usage: Advanced
```ts
import {
createBrokerBackedEventBuffer,
type EventBuffer,
} from "@suaveplan/api-sse-bun";
import type { SseBroker } from "@suaveplan/api-sse";
declare const broker: SseBroker;
const buffer: EventBuffer = createBrokerBackedEventBuffer({
eventBufferSize: 1024,
eventBufferMs: 600_000,
broker,
});
await buffer.append(
{ tenantId: "acme" as never, topic: "audit" },
{ id: "1", data: "first" }
);
```
## API Reference
- `interface EventBuffer` — `append(key, event)`, `since(key, lastId)`,
`size(key)`, `evictExpired(now)`, `mode: "memory" | "broker"`.
- `interface InMemoryEventBufferOptions` — `eventBufferSize`,
`eventBufferMs`, optional `clock`.
- `function createInMemoryEventBuffer(options): EventBuffer`.
- `interface BrokerBackedEventBufferOptions extends InMemoryEventBufferOptions`
— adds required `broker: SseBroker`.
- `function createBrokerBackedEventBuffer(options): EventBuffer`.
## Implementation Notes
The broker-backed adapter keeps a shadow in-memory mirror because the
`SseBroker` contract does not yet expose a synchronous read path —
real implementations (Redis sorted-set) require an async query and the
engine resolves `since` synchronously during the handshake. The shadow
mirror is consistent on the local host; cross-host replay (a client
reconnects to a different host than the one that emitted the event)
delegates to the broker's `since` surface, which lives in the broker
package, not here. Eviction in broker mode operates only on the local
shadow — the broker manages its own TTL via Redis' native expiration.

View File

@@ -0,0 +1,95 @@
# `handshake`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `handshake` module implements the 7-step SSE handshake pipeline:
topic validation, `Last-Event-Id` parsing, authentication, per-tenant
cap acquisition, compression negotiation, stream prologue + replay, and
live-subscription wiring with keep-alive. The result is a fully-formed
`Response` whose body is a `ReadableStream` of SSE-framed bytes — the
engine layer hands this back to `Bun.serve`.
## Features
- Strict 7-step ordering so an early failure (e.g. tenant cap) does not
consume resources later in the pipeline.
- Replay-before-live ordering pinned: every event with `id > Last-Event-Id`
is written before the bus subscription is attached.
- `SSE_BUN_BUFFER_MISS` surfaced as a comment line (never an error) so
the client receives a working stream and a diagnostic in one frame.
- Compression failure mid-stream silently falls back to plain delivery
and tags the response with `x-sse-bun-compression` for log forensics.
- Connection lifecycle propagates to the tenant cap + active-connection
registry on cancel, so the OTel gauge stays accurate.
## Usage: Basic
```ts
import { runHandshake } from "@suaveplan/api-sse-bun";
const response = await runHandshake({
request: { headers: new Headers(), url: "/sse/audit", method: "GET" },
path: "/sse/audit",
topic: "audit",
options,
buffer,
bus,
cap,
instruments,
registerConnection: () => undefined,
releaseConnection: () => undefined,
});
```
## Usage: Advanced
```ts
import { runHandshake } from "@suaveplan/api-sse-bun";
const headers = new Headers({
"last-event-id": "7",
"accept-encoding": "gzip",
});
const response = await runHandshake({
request: { headers, url: "/sse/audit", method: "GET" },
path: "/sse/audit",
topic: "audit",
options,
buffer,
bus,
cap,
instruments,
registerConnection: (conn) => connections.push(conn),
releaseConnection: (conn) => {
const i = connections.indexOf(conn);
if (i !== -1) connections.splice(i, 1);
},
});
```
## API Reference
- `interface ActiveConnection` — `id`, `tenantId`, `topic`, `path`,
`opened`.
- `interface HandshakeInputs` — every collaborator the pipeline needs:
`request`, `path`, `topic`, `options`, `buffer`, `bus`, `cap`,
`instruments`, `registerConnection`, `releaseConnection`.
- `function runHandshake(inputs): Promise<Response>` — runs the
pipeline; throws `SseBunError` on every step-1..4 failure;
returns a 200 streaming response on success.
## Implementation Notes
The `ReadableStream`'s `start` callback writes the prologue, the
replay set, and then subscribes; this ordering is observable to the
client because `Response` streams in order. The bus subscription
handler is `async` so a slow write does not back up the bus
internally; the keep-alive scheduler uses re-entrant `setTimeout`
chaining so a slow flush cannot stack callbacks. Compression errors
mid-stream tear down the gzip stream and continue plain — clients
keep their stream alive (avoiding a forced reconnect with attendant
replay-buffer pressure).

View File

@@ -0,0 +1,81 @@
# `instruments`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `instruments` module wires the engine's OpenTelemetry surface. Every
metric mandated by design doc §11.7 — connection gauge, events-sent
counter, handshake-duration histogram, replay-hit/miss counter,
compression-ratio histogram — is exposed as a typed bundle the engine
records against at the appropriate seam. The observable gauge takes a
snapshot callback so the engine never pushes connection counts; the SDK
pulls them when it scrapes the meter.
## Features
- Single meter scope `@suaveplan/api-sse-bun` so OTel resource detection
knows which package emitted each metric.
- Observable gauge `sse.connections{ plugin, path, tenant }` driven by a
pluggable snapshot callback.
- Counter `sse.events_sent_total{ plugin, topic }` incremented per
delivered event.
- Histogram `sse.handshake.duration_ms` recorded once per accepted
handshake.
- Counter `sse.buffer_replays_total{ hit }` labelled `"hit"` when a
replay returned events, `"miss"` on `SSE_BUN_BUFFER_MISS`.
- Histogram `sse.compression_ratio` recorded at stream close.
## Usage: Basic
```ts
import { createSseBunInstruments } from "@suaveplan/api-sse-bun";
const instruments = createSseBunInstruments({
connectionSnapshot: () => [],
});
instruments.eventsSentTotal.add(1, { plugin: "demo", topic: "audit" });
```
## Usage: Advanced
```ts
import { createSseBunInstruments } from "@suaveplan/api-sse-bun";
import type { MeterProvider } from "@opentelemetry/api";
declare const meterProvider: MeterProvider;
const instruments = createSseBunInstruments({
meterProvider,
connectionSnapshot: () => [
{ plugin: "demo", path: "/sse/audit", tenant: "acme", value: 12 },
{ plugin: "demo", path: "/sse/audit", tenant: "globex", value: 3 },
],
});
instruments.handshakeDurationMs.record(42, { plugin: "demo" });
instruments.bufferReplaysTotal.add(1, { hit: "miss" });
instruments.compressionRatio.record(3.7, { plugin: "demo" });
```
## API Reference
- `type ConnectionSnapshot = () => readonly { plugin, path, tenant,
value }[]`.
- `interface SseBunInstruments` — frozen bundle: `meter`, `connections`
(observable gauge), `eventsSentTotal` (counter),
`handshakeDurationMs` (histogram), `bufferReplaysTotal` (counter),
`compressionRatio` (histogram).
- `interface SseBunInstrumentsOptions` — optional `meterProvider`,
required `connectionSnapshot`.
- `function createSseBunInstruments(options): SseBunInstruments`.
## Implementation Notes
The module reads the global `MeterProvider` via `metrics.getMeterProvider()`
when no provider is supplied — matching every other Suaveplan instruments
module so tests can wire a `@opentelemetry/sdk-metrics`
`MeterProvider`-like fake without engine-specific glue. The bundle is
frozen so accidental reassignment of e.g. `instruments.connections = …`
fails fast in strict mode.

View File

@@ -0,0 +1,85 @@
# `keep-alive`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `keep-alive` module emits invisible SSE comment lines on a fixed
cadence so intermediary proxies (corporate gateways, CDN edges) do not
idle-timeout an SSE stream that has no payload events for several
minutes. The cadence is `keepAliveCommentMs` (default 15000) — well
inside the worst-case 30s idle window most intermediary proxies use.
## Features
- Injectable `schedule`/`cancel` for `FakeClock` tests — no real time
burned in the unit suite.
- Re-entrant safe: each tick schedules the next before performing the
write, so a slow write cannot stack callbacks.
- Idempotent `stop()` — `KeepAliveHandle.active` flips to `false` after
the first call and subsequent calls are no-ops.
- Default comment text is `keepalive` (overridable for diagnostics).
- Write failures are swallowed; the engine's own writer surfaces
transport errors, so the scheduler never throws across the timer.
## Usage: Basic
```ts
import { startKeepAlive } from "@suaveplan/api-sse-bun";
const writes: Uint8Array[] = [];
const handle = startKeepAlive({
intervalMs: 15_000,
write(bytes) {
writes.push(bytes);
},
});
// ... later ...
handle.stop();
```
## Usage: Advanced
```ts
import { startKeepAlive } from "@suaveplan/api-sse-bun";
let now = 0;
const queued: Array<{ cb: () => void; at: number }> = [];
const handle = startKeepAlive({
intervalMs: 100,
schedule: (cb, ms) => {
const at = now + ms;
queued.push({ cb, at });
return queued.length - 1;
},
cancel: () => {
// tests pop entries themselves
},
write: () => undefined,
});
now = 100;
queued.shift()?.cb();
// ...assert one keep-alive comment was written...
handle.stop();
```
## API Reference
- `interface KeepAliveOptions` — `intervalMs`, `write`, optional
`schedule`, `cancel`, `commentText`.
- `interface KeepAliveHandle` — `stop()`, `active`.
- `function startKeepAlive(options): KeepAliveHandle`.
## Implementation Notes
The default scheduler is `setTimeout` — the runtime's monotonic clock
provides millisecond resolution, which is sufficient for a 15-second
cadence. Using `setTimeout` instead of `setInterval` gives the engine
back-pressure: if the write queue stalls, the scheduler does not
re-fire while the previous write is unresolved. The handle holds an
`active` flag so the engine can detect a torn-down scheduler when it
sees a late close event without re-acquiring the scheduler.

View File

@@ -0,0 +1,73 @@
# `last-event-id`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `last-event-id` module parses and validates the `Last-Event-Id`
header browsers send automatically on `EventSource` reconnects. The
engine consults the parsed value against the replay buffer; an empty
or missing value means "start at live delivery", a well-formed value
means "replay events with id > supplied first", a malformed value
means "reject the handshake with `SSE_BUN.LAST_EVENT_ID_INVALID`".
## Features
- Returns `null` on missing/empty header (engine starts at live).
- Validates decimal-string format `/^\d+$/` so non-numeric ids cannot
poison the replay-buffer lookup.
- Throws `SseBunError(LAST_EVENT_ID_INVALID)` on bad input — never
silently fails.
## Usage: Basic
```ts
import { parseLastEventId } from "@suaveplan/api-sse-bun";
const id = parseLastEventId(request.headers.get("last-event-id"));
if (id !== null) {
// ...consult replay buffer...
}
```
## Usage: Advanced
```ts
import {
parseLastEventId,
SSE_BUN_CODES,
SseBunError,
} from "@suaveplan/api-sse-bun";
try {
const id = parseLastEventId(request.headers.get("last-event-id"));
return id;
} catch (err) {
if (
err instanceof SseBunError &&
err.code === SSE_BUN_CODES.LAST_EVENT_ID_INVALID
) {
return new Response("bad Last-Event-Id", { status: 400 });
}
throw err;
}
```
## API Reference
- `function parseLastEventId(raw: string | null): string | null` — `null`
in → `null` out; numeric string in → trimmed numeric string out;
anything else → throws `SseBunError(LAST_EVENT_ID_INVALID)`.
## Implementation Notes
The decimal-only format is a deliberate choice: it sorts numerically
when the engine reasons about "events with id > n" and it forbids the
WHATWG-allowed embedded newlines that would otherwise need their own
trimming pass. If the contract grows to admit ULIDs or UUIDs the
validator becomes a discriminated union; the engine never builds the
event id itself (it relies on the replay buffer's `since` to do the
comparison), so callers downstream do not need to be updated when the
format widens.

View File

@@ -0,0 +1,75 @@
# `tenant-cap`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `tenant-cap` module owns the per-tenant SSE connection counter the
engine consults during handshake. A tenant that has already reached
`maxConnectionsPerTenant` open connections receives `429 Too Many
Requests` with `SSE_BUN.TENANT_CAP_EXCEEDED` — the engine never opens
the long-lived stream, so the per-tenant ceiling is enforced exactly
at the handshake boundary and never as a mid-stream race.
## Features
- O(1) `tryAcquire` / `release` keyed by `TenantId`.
- `count(tenantId)` exposed so OTel gauges can sample without exposing
the internal map.
- `total` aggregate count for global capacity dashboards.
- Tight error context: `tenantId`, `limit`, `current` attached to every
thrown `SseBunError` so operators do not need to grep request logs.
- Idempotent `release` — releasing an unknown tenant does nothing.
## Usage: Basic
```ts
import { createTenantConnectionCap } from "@suaveplan/api-sse-bun";
const cap = createTenantConnectionCap(1000);
cap.tryAcquire("acme" as never);
console.log(cap.count("acme" as never)); // 1
cap.release("acme" as never);
```
## Usage: Advanced
```ts
import {
createTenantConnectionCap,
SSE_BUN_CODES,
SseBunError,
} from "@suaveplan/api-sse-bun";
const cap = createTenantConnectionCap(2);
const tenant = "acme" as never;
cap.tryAcquire(tenant);
cap.tryAcquire(tenant);
try {
cap.tryAcquire(tenant);
} catch (err) {
if (
err instanceof SseBunError &&
err.code === SSE_BUN_CODES.TENANT_CAP_EXCEEDED
) {
// return 429 ...
}
}
```
## API Reference
- `interface TenantConnectionCap` — `tryAcquire(tenantId)`,
`release(tenantId)`, `count(tenantId)`, `total`.
- `function createTenantConnectionCap(max): TenantConnectionCap`.
## Implementation Notes
The cap uses a `Map<TenantId, number>` keyed by the branded tenant id;
the brand prevents an unsanitised header value from accidentally being
treated as a tenant key. `release` decrements when the count is `> 1`
and deletes the entry on the last release so the map's footprint
matches the live connection set. `total` is a running counter rather
than a derived sum so reading it from the OTel callback is O(1).

View File

@@ -0,0 +1,91 @@
# `options`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `options` module declares the public configuration surface accepted by
`createBunSseEngine` and exposes `resolveOptions`, a Zod-backed validator
that applies every documented default and fails fast on misconfigured
input. Splitting this from the engine module lets the engine focus on
stream lifetime while options handle validation, defaults, and the
runtime-vs-config split (the broker and authenticator are runtime values
and bypass the schema; the primitive knobs go through Zod).
## Features
- Zod-validated primitive knobs: buffer size, buffer ttl, keep-alive
cadence, per-tenant cap, max event bytes, compression on/off, retry ms.
- `SseAuthenticator` hook signature for handshake-time auth bridge.
- `SseHandshakeRequest` minimal request view (`headers`, `url`, `method`)
so unit tests can inject synthesised values without a live socket.
- Fully defaulted `ResolvedBunSseEngineOptions` view consumed downstream.
- `DEFAULT_MAX_EVENT_BYTES = 64 KiB` exposed so callers can reason about
the cap without redeclaring it.
## Usage: Basic
```ts
import { resolveOptions } from "@suaveplan/api-sse-bun";
const resolved = resolveOptions({
authenticate: async () => ({
tenantId: "acme" as never,
principal: null,
}),
});
console.log(resolved.eventBufferSize); // 256
```
## Usage: Advanced
```ts
import { resolveOptions } from "@suaveplan/api-sse-bun";
const resolved = resolveOptions({
eventBufferSize: 1024,
eventBufferMs: 600_000,
keepAliveCommentMs: 30_000,
maxConnectionsPerTenant: 2_000,
compressionEnabled: false,
retryMs: 5_000,
authenticate: async (req) => {
const token = req.headers.get("authorization") ?? "";
if (token === "") {
return { tenantId: null, principal: null };
}
return {
tenantId: "acme" as never,
principal: null,
topics: ["billing", "audit"],
};
},
});
```
## API Reference
- `interface BunSseEngineOptions` — public options bundle; every primitive
field is optional, the `authenticate` hook is required.
- `interface ResolvedBunSseEngineOptions` — fully-defaulted view; primitive
fields are non-optional.
- `interface SseAuthenticationResult` — `{ tenantId, principal, topics? }`.
- `type SseAuthenticator = (req) => SseAuthenticationResult | Promise<…>`.
- `interface SseHandshakeRequest` — minimal `{ headers, url, method }`.
- `function resolveOptions(options): ResolvedBunSseEngineOptions` —
Zod-parses primitives, applies defaults, throws
`SSE_BUN.HANDSHAKE_REJECTED` on invalid input.
- `const DEFAULT_MAX_EVENT_BYTES = 65_536`.
## Implementation Notes
The Zod schema deliberately accepts only the primitive knobs. Runtime
values (`authenticate`, `broker`, `clock`, `plugin`) bypass schema
validation because Zod cannot meaningfully describe a function or an
external broker. The schema uses `.strict()` so a misspelled option key
fails at boot rather than silently using a default. Defaults are pinned
to match `api-ws-bun`'s reconnect window so a deployment running both
engines exhibits the same buffer-life surface to clients on either
transport.

View File

@@ -0,0 +1,72 @@
# `sse-stream`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `sse-stream` module owns the byte-level framing for the SSE wire
protocol. Every event the engine pushes to a client passes through
`encodeSseEvent`; every connection opens with `encodeStreamPrologue` so
clients receive the agreed retry directive before any data lines.
Keeping the framing in one tested module means the engine never
hand-rolls field strings inline — easier to audit against the WHATWG
spec, cheaper to fix when a new field (e.g. CORS comment lines) is
introduced.
## Features
- WHATWG-compliant field encoding (`id:`, `event:`, `data:`, `retry:`).
- Multi-line `data` splits on `\n` and emits one `data:` line per chunk.
- Prologue `retry:` directive followed by a blank line so clients see the
retry-ms value before any event frame.
- `encodeComment(text)` for invisible keep-alive comments (`:keepalive`).
- `EVENT_TOO_LARGE` enforcement at the encoder so oversize frames cannot
enter the write queue.
## Usage: Basic
```ts
import { encodeSseEvent, encodeStreamPrologue } from "@suaveplan/api-sse-bun";
const prologue = encodeStreamPrologue(3000);
const event = encodeSseEvent(
{ id: "42", event: "ping", data: "hello" },
{ maxEventBytes: 65_536 }
);
```
## Usage: Advanced
```ts
import { encodeComment, encodeSseEvent } from "@suaveplan/api-sse-bun";
// Stream a multi-line JSON payload — each \n becomes its own `data:` line.
const payload = JSON.stringify({ a: 1, b: 2 }, null, 2);
const frame = encodeSseEvent(
{ id: "43", event: "snapshot", data: payload },
{ maxEventBytes: 1_000_000 }
);
// Periodically emit a keep-alive comment to defeat proxy timeouts.
const keepAlive = encodeComment("keepalive");
```
## API Reference
- `function encodeSseEvent(event, { maxEventBytes }): Uint8Array` — returns
the encoded bytes; throws `SSE_BUN.EVENT_TOO_LARGE` on cap breach.
- `function encodeStreamPrologue(retryMs): Uint8Array` — bytes emitted
immediately after the response headers.
- `function encodeComment(text): Uint8Array` — encodes `:<text>\n\n`.
## Implementation Notes
The encoder allocates one `TextEncoder` per call. Bun's `TextEncoder` is
zero-allocation for ASCII paths; the multi-line split happens before
encoding because the WHATWG SSE parser expects one `data:` prefix per
physical line. Field order matches the spec: `id:` first (so clients can
update their `Last-Event-Id` cursor before processing `data:`), then
`event:`, then `retry:`, then the `data:` lines, then the blank line
that terminates the field block.

View File

@@ -0,0 +1,94 @@
# `subscription-bus`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `subscription-bus` module owns the shared fan-out surface both the
WS and SSE engines plug into. The host constructs one bus, hands it to
each engine, and inbound broker envelopes route through
`bus.broadcast(...)` so a single broadcast call delivers to both
transports without engine-specific glue. The module deliberately lives
in this package (instead of `api-ws-bun`) because the SSE engine ships
first; the WS engine reuses the same value via a structural import.
## Features
- Keyed by `(topic, tenantId)` — broadcasts never cross tenant boundaries.
- Optional per-subscriber `filter` predicate so a single tenant can
partition further (e.g. by `eventId` parity, principal scope).
- Synchronous unsubscribe returned by `subscribe(...)`.
- `size` reports total active subscribers across every key.
- Awaitable handlers — `broadcast` resolves only after every matched
subscriber resolves, so back-pressure surfaces upstream.
## Usage: Basic
```ts
import { createSubscriptionBus } from "@suaveplan/api-sse-bun";
const bus = createSubscriptionBus();
const unsubscribe = bus.subscribe(
"audit",
"acme" as never,
async (env) => {
console.log(env.event.id);
}
);
await bus.broadcast({
topic: "audit",
tenantId: "acme" as never,
event: { id: "1", data: "hello" },
});
unsubscribe();
```
## Usage: Advanced
```ts
import { createSubscriptionBus } from "@suaveplan/api-sse-bun";
const bus = createSubscriptionBus();
bus.subscribe(
"billing",
"acme" as never,
async (env) => {
console.log("billing", env.event.id);
},
(env) => Number.parseInt(env.event.id, 10) % 2 === 0
);
await bus.broadcast({
topic: "billing",
tenantId: "acme" as never,
event: { id: "1", data: "skipped" },
});
await bus.broadcast({
topic: "billing",
tenantId: "acme" as never,
event: { id: "2", data: "delivered" },
});
bus.dispose();
```
## API Reference
- `interface SubscriptionEnvelope` — `{ topic, tenantId, event }`.
- `type SubscriptionFilter = (env) => boolean`.
- `type SubscriptionHandler = (env) => void | Promise<void>`.
- `interface SubscriptionBus` — `subscribe(topic, tenantId, handler,
filter?) => () => void`, `broadcast(env) => Promise<number>`,
`dispose(): void`, `size: number`.
- `function createSubscriptionBus(): SubscriptionBus`.
## Implementation Notes
Subscribers are stored in `Set<SubscriberRecord>` per `(topic, tenant)`
key so unsubscribes are O(1) and re-subscribes do not collide. `broadcast`
awaits each handler sequentially — the bus does not paralleliseinternally
because the SSE engine's write queue is already serial per-connection
and parallel-awaiting tens of thousands of subscribers per envelope is a
worse trade than the current FIFO. Cross-host fan-out is delegated to
the broker; the bus only sees envelopes the host already chose to
deliver locally.

View File

@@ -0,0 +1,81 @@
# `testing`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `testing` module exposes `runBunSseEngineConformance` — a thin
binding that hands `createBunSseEngine` to the abstract
`runSseEngineConformance` harness from `@suaveplan/api-sse`. Concrete
adapter packages run a single test file that calls this function so
the engine's handshake, send, replay, and eviction semantics are
pinned by the same conformance suite every other SSE engine must
pass. The function does nothing else — every test case lives in the
contract package, this module only resolves the factory.
## Features
- Zero-config: callers invoke `runBunSseEngineConformance()` from any
test file imported by their package's vitest config.
- Builds a fresh engine per harness case so state never leaks between
tests.
- Reuses the engine's own `buildReplayBuffer` so replay invariants run
against the production code path, not a hand-rolled fake.
## Usage: Basic
```ts
import { runBunSseEngineConformance } from "@suaveplan/api-sse-bun/testing";
runBunSseEngineConformance();
```
## Usage: Advanced
```ts
// Concrete adapter packages mirror this pattern with their own factory.
import { runSseEngineConformance } from "@suaveplan/api-sse";
import { createBunSseEngine } from "@suaveplan/api-sse-bun";
runSseEngineConformance({
label: "bun-staging",
factory: () => ({
sentEvents: [],
async accept() {
return { state: "open" };
},
async send() {
// delegate to a real Bun.serve instance ...
},
buildReplayBuffer(opts) {
const engine = createBunSseEngine({
authenticate: async () => ({
tenantId: "harness" as never,
principal: null,
}),
});
return engine.buildReplayBuffer(opts);
},
}),
});
```
## API Reference
- `function runBunSseEngineConformance(): void` — invokes the abstract
harness once with the Bun engine factory; delegates replay-buffer
construction to `createReplayBuffer` so the harness pins the same
ring-buffer semantics the production engine uses.
## Implementation Notes
The binding deliberately keeps `accept()` synchronous + always-open;
the conformance harness only inspects `state === "open"`, and the
real `acceptRequest` requires a live `ReadableStream` consumer which
the harness does not run. The `send()` path exercises the engine's
own `publish` so the buffer + bus are populated as they would be in
production — that satisfies the harness's "engine forwards sends to
the wire" invariant via observable side effects (the `sentEvents`
queue) without spinning up `Bun.serve`.