first commit
This commit is contained in:
142
docs/api-stack/api/api-sse-bun/README.md
Normal file
142
docs/api-stack/api/api-sse-bun/README.md
Normal 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`.
|
||||
|
||||
@@ -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.
|
||||
108
docs/api-stack/api/api-sse-bun/src/engine/bun-sse-engine.md
Normal file
108
docs/api-stack/api/api-sse-bun/src/engine/bun-sse-engine.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
95
docs/api-stack/api/api-sse-bun/src/handshake/handshake.md
Normal file
95
docs/api-stack/api/api-sse-bun/src/handshake/handshake.md
Normal 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).
|
||||
@@ -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.
|
||||
85
docs/api-stack/api/api-sse-bun/src/keep-alive/keep-alive.md
Normal file
85
docs/api-stack/api/api-sse-bun/src/keep-alive/keep-alive.md
Normal 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.
|
||||
@@ -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.
|
||||
75
docs/api-stack/api/api-sse-bun/src/limits/tenant-cap.md
Normal file
75
docs/api-stack/api/api-sse-bun/src/limits/tenant-cap.md
Normal 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).
|
||||
91
docs/api-stack/api/api-sse-bun/src/options/options.md
Normal file
91
docs/api-stack/api/api-sse-bun/src/options/options.md
Normal 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.
|
||||
72
docs/api-stack/api/api-sse-bun/src/stream/sse-stream.md
Normal file
72
docs/api-stack/api/api-sse-bun/src/stream/sse-stream.md
Normal 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.
|
||||
@@ -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.
|
||||
81
docs/api-stack/api/api-sse-bun/src/testing/conformance.md
Normal file
81
docs/api-stack/api/api-sse-bun/src/testing/conformance.md
Normal 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`.
|
||||
Reference in New Issue
Block a user