Initial Commit
This commit is contained in:
109
packages/api-stack/api/api-plugin-sse/README.md
Normal file
109
packages/api-stack/api/api-plugin-sse/README.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# @suaveplan/api-plugin-sse - Server-Sent Events plugin for @suaveplan/api. Registers a typed sseService in the host ServiceRegistry, mounts a Hono streamSSE handler with per-connection heartbeat, AbortSignal threading, backpressure, and Last-Event-ID resume from a bounded per-channel ring buffer. Server-tier.
|
||||
|
||||
Server-Sent Events plugin for `@suaveplan/api`. Registers a typed `sseService` in the host `ServiceRegistry`, mounts a Hono streaming handler at `GET /sse/:channel`, and ships with per-connection heartbeat, `AbortSignal` threading on client disconnect, `Last-Event-ID` resume from a bounded per-channel ring buffer, and backpressure handling with `drop-oldest` / `drop-newest` overflow policies.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [buffer](./src/buffer/ring-buffer.md) — Bounded per-channel ring buffer that powers `Last-Event-ID` resume. The SSE service appends every broadcast to the buff…
|
||||
- [ring-buffer](./src/buffer/ring-buffer.md) — Bounded per-channel ring buffer that powers `Last-Event-ID` resume. The SSE service appends every broadcast to the buff…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry and `SseError` class for `@suaveplan/api-plugin-sse`. Every failure path in the plugin raise…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry and `SseError` class for `@suaveplan/api-plugin-sse`. Every failure path in the plugin raise…
|
||||
- [fanout](./src/fanout/in-process.md) — Single-process publish/subscribe bus consumed by the SSE service to deliver every broadcast to every active subscriber…
|
||||
- [in-process](./src/fanout/in-process.md) — Single-process publish/subscribe bus consumed by the SSE service to deliver every broadcast to every active subscriber…
|
||||
- [format](./src/formatter/format.md) — Serialises `SseEvent` objects into the `text/event-stream` wire format defined by the WHATWG HTML Standard so the SSE h…
|
||||
- [parse](./src/formatter/parse.md) — Async streaming parser for `text/event-stream`. Decodes a `ReadableStreamDefaultReader<Uint8Array>` into a sequence of…
|
||||
- [middleware](./src/middleware/sse-handler.md) — Hono handler factory that turns a subscription on `SseService` into a streaming HTTP response framed in the `text/event…
|
||||
- [sse-handler](./src/middleware/sse-handler.md) — Hono handler factory that turns a subscription on `SseService` into a streaming HTTP response framed in the `text/event…
|
||||
- [otel](./src/otel/instruments.md) — Provides the canonical OpenTelemetry instrument bundle emitted by the SSE plugin. Counters and a histogram cover the fo…
|
||||
- [instruments](./src/otel/instruments.md) — Provides the canonical OpenTelemetry instrument bundle emitted by the SSE plugin. Counters and a histogram cover the fo…
|
||||
- [plugin](./src/plugin/plugin.md) — Factory producing a typed `PluginDescriptor` for the SSE plugin. Wraps the `@suaveplan/api-plugin` `definePlugin` found…
|
||||
- [plugin](./src/plugin/plugin.md) — Factory producing a typed `PluginDescriptor` for the SSE plugin. Wraps the `@suaveplan/api-plugin` `definePlugin` found…
|
||||
- [registry](./src/registry/service-registry.md) — TypeScript module augmentation declaring the `sseService` slot on the `@suaveplan/api-plugin/service-registry` `Service…
|
||||
- [service-registry](./src/registry/service-registry.md) — TypeScript module augmentation declaring the `sseService` slot on the `@suaveplan/api-plugin/service-registry` `Service…
|
||||
- [event](./src/schemas/event.md) — Zod schema validating the `SseEvent` shape accepted by `SseService.broadcast`. Ensures application payloads cannot bypa…
|
||||
- [options](./src/schemas/options.md) — Zod schema validating `SsePluginOptions` at the factory boundary. `createSsePlugin` calls `SsePluginOptionsSchema.safeP…
|
||||
- [services](./src/services/service.md) — Core SSE service registered under the `sseService` slot in the host `ServiceRegistry`. Owns subscription lifecycle (id…
|
||||
- [service](./src/services/service.md) — Core SSE service registered under the `sseService` slot in the host `ServiceRegistry`. Owns subscription lifecycle (id…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/api-plugin-sse
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import { createPluginRegistry } from "@suaveplan/api-plugin";
|
||||
import { createSsePlugin } from "@suaveplan/api-plugin-sse";
|
||||
|
||||
const app = new Hono();
|
||||
const registry = createPluginRegistry({ seedServices: { app } });
|
||||
|
||||
await registry.register(createSsePlugin({ heartbeatIntervalMs: 15_000 }));
|
||||
await registry.init();
|
||||
|
||||
const sse = registry.getService("sseService");
|
||||
setInterval(() => {
|
||||
sse?.broadcast("notifications", { data: { ts: Date.now() } });
|
||||
}, 1_000);
|
||||
|
||||
export default app;
|
||||
```
|
||||
|
||||
A browser client connects with the native `EventSource`:
|
||||
|
||||
```ts
|
||||
const source = new EventSource("/sse/notifications");
|
||||
source.addEventListener("message", (evt) => console.log(evt.data));
|
||||
```
|
||||
|
||||
## The 3-way plug-and-play contract
|
||||
|
||||
This package follows the canonical API-plugin pattern pioneered by `@suaveplan/api-plugin-auth`: a plugin is a `definePlugin` consumer that owns its (1) Zod schemas at the boundary, (2) optional default route registration, and (3) typed `ServiceRegistry` slot. Mount or compose with any other `@suaveplan/api-plugin-*` package by passing them to the same plugin registry.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) — stable `SSE_*` codes + `SseError`
|
||||
- [`src/buffer/ring-buffer.md`](./src/buffer/ring-buffer.md) — bounded per-channel ring buffer for `Last-Event-ID` resume
|
||||
- [`src/fanout/in-process.md`](./src/fanout/in-process.md) — single-process pub/sub bus
|
||||
- [`src/formatter/format.md`](./src/formatter/format.md) — SSE wire-format serializer
|
||||
- [`src/formatter/parse.md`](./src/formatter/parse.md) — streaming parser for SSE clients
|
||||
- [`src/schemas/options.md`](./src/schemas/options.md) — Zod schema for plugin options
|
||||
- [`src/schemas/event.md`](./src/schemas/event.md) — Zod schema for events
|
||||
- [`src/otel/instruments.md`](./src/otel/instruments.md) — OpenTelemetry instruments
|
||||
- [`src/services/service.md`](./src/services/service.md) — `SseService` registered under `sseService`
|
||||
- [`src/middleware/sse-handler.md`](./src/middleware/sse-handler.md) — Hono streaming handler factory
|
||||
- [`src/plugin/plugin.md`](./src/plugin/plugin.md) — `createSsePlugin` factory
|
||||
- [`src/registry/service-registry.md`](./src/registry/service-registry.md) — `ServiceRegistry` augmentation
|
||||
|
||||
## Configuration
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `heartbeatIntervalMs` | `30_000` | Heartbeat comment-frame cadence per connection |
|
||||
| `routePrefix` | `"/sse"` | Mount path for the default route (`:channel` appended) |
|
||||
| `attachDefaultRoute` | `true` | Skip route registration to mount manually |
|
||||
| `retentionPerChannel` | `100` | Ring-buffer size per channel for resume |
|
||||
| `maxConnectionsPerPrincipal` | `16` | Per-principal connection cap |
|
||||
| `maxTotalConnections` | `10_000` | Global connection cap |
|
||||
| `queueHighWaterMark` | `256` | Per-connection backlog threshold |
|
||||
| `overflowPolicy` | `"drop-oldest"` | `drop-oldest` or `drop-newest` |
|
||||
| `idGenerator` | `crypto.randomUUID()` | Event id source |
|
||||
|
||||
## Observability
|
||||
|
||||
- `sse.active_connections` — current open connections (UpDownCounter, `channel` attribute)
|
||||
- `sse.broadcasts_total{channel}` — broadcasts per channel
|
||||
- `sse.heartbeats_total` — heartbeat frames written
|
||||
- `sse.message_bytes_total` — bytes written to clients
|
||||
- `sse.queue_overflow_total{policy,code}` — events dropped under backpressure
|
||||
- `sse.connection_duration_ms` — per-connection lifetime histogram
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
140
packages/api-stack/api/api-plugin-sse/package.json
Normal file
140
packages/api-stack/api/api-plugin-sse/package.json
Normal file
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"name": "@suaveplan/api-plugin-sse",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Server-Sent Events plugin for @suaveplan/api. Registers a typed sseService in the host ServiceRegistry, mounts a Hono streamSSE handler with per-connection heartbeat, AbortSignal threading, backpressure, and Last-Event-ID resume from a bounded per-channel ring buffer. Server-tier.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./plugin/plugin": {
|
||||
"import": {
|
||||
"types": "./dist/plugin/plugin.d.ts",
|
||||
"default": "./dist/plugin/plugin.js"
|
||||
}
|
||||
},
|
||||
"./services/service": {
|
||||
"import": {
|
||||
"types": "./dist/services/service.d.ts",
|
||||
"default": "./dist/services/service.js"
|
||||
}
|
||||
},
|
||||
"./middleware/sse-handler": {
|
||||
"import": {
|
||||
"types": "./dist/middleware/sse-handler.d.ts",
|
||||
"default": "./dist/middleware/sse-handler.js"
|
||||
}
|
||||
},
|
||||
"./formatter/format": {
|
||||
"import": {
|
||||
"types": "./dist/formatter/format.d.ts",
|
||||
"default": "./dist/formatter/format.js"
|
||||
}
|
||||
},
|
||||
"./formatter/parse": {
|
||||
"import": {
|
||||
"types": "./dist/formatter/parse.d.ts",
|
||||
"default": "./dist/formatter/parse.js"
|
||||
}
|
||||
},
|
||||
"./buffer/ring-buffer": {
|
||||
"import": {
|
||||
"types": "./dist/buffer/ring-buffer.d.ts",
|
||||
"default": "./dist/buffer/ring-buffer.js"
|
||||
}
|
||||
},
|
||||
"./fanout/in-process": {
|
||||
"import": {
|
||||
"types": "./dist/fanout/in-process.d.ts",
|
||||
"default": "./dist/fanout/in-process.js"
|
||||
}
|
||||
},
|
||||
"./schemas/options": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/options.d.ts",
|
||||
"default": "./dist/schemas/options.js"
|
||||
}
|
||||
},
|
||||
"./schemas/event": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/event.d.ts",
|
||||
"default": "./dist/schemas/event.js"
|
||||
}
|
||||
},
|
||||
"./error-codes/error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./otel/instruments": {
|
||||
"import": {
|
||||
"types": "./dist/otel/instruments.d.ts",
|
||||
"default": "./dist/otel/instruments.js"
|
||||
}
|
||||
},
|
||||
"./types/types": {
|
||||
"import": {
|
||||
"types": "./dist/types/types.d.ts",
|
||||
"default": "./dist/types/types.js"
|
||||
}
|
||||
},
|
||||
"./registry/service-registry": {
|
||||
"import": {
|
||||
"types": "./dist/registry/service-registry.d.ts",
|
||||
"default": "./dist/registry/service-registry.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/api-plugin": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"hono": "catalog:",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hono/zod-openapi": "catalog:server",
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import { createRingBuffer } from "./buffer/ring-buffer.js";
|
||||
import { SSE_CODES, SseError } from "./error-codes/error-codes.js";
|
||||
import { createInProcessFanout } from "./fanout/in-process.js";
|
||||
import { createSsePlugin } from "./plugin/plugin.js";
|
||||
import { createSseService, type SseService } from "./services/service.js";
|
||||
import type { SseEvent, SseStats, SubscriptionHandle } from "./types/types.js";
|
||||
|
||||
describe("api-plugin-sse spec conformance", () => {
|
||||
it("SseService surface satisfies the declared shape", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const service = createSseService({
|
||||
buffer: createRingBuffer(10),
|
||||
fanout: createInProcessFanout(),
|
||||
idGenerator: () => "x",
|
||||
maxConnectionsPerPrincipal: 4,
|
||||
maxTotalConnections: 10,
|
||||
});
|
||||
expectTypeOf(service).toMatchTypeOf<{
|
||||
broadcast: (channel: string, event: { data: unknown }) => SseEvent;
|
||||
subscribe: (args: { channel: string }) => SubscriptionHandle;
|
||||
close: (channel?: string) => Promise<void>;
|
||||
stats: () => SseStats;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("SseEvent requires id and data, allows event/retry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<SseEvent>().toMatchTypeOf<{
|
||||
id: string;
|
||||
data: unknown;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("ServiceRegistry augmentation surfaces sseService slot", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createPluginContext } = await import("@suaveplan/api-plugin");
|
||||
const services = new Map<string, unknown>();
|
||||
const ctx = createPluginContext<unknown>({
|
||||
logger: {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
},
|
||||
eventBus: {
|
||||
emit: () => undefined,
|
||||
on: () => () => undefined,
|
||||
off: () => undefined,
|
||||
once: () => () => undefined,
|
||||
} as never,
|
||||
signal: new AbortController().signal,
|
||||
config: undefined,
|
||||
services,
|
||||
});
|
||||
// type assertion — the augmented slot resolves to SseService | undefined
|
||||
expectTypeOf(ctx.getService("sseService")).toEqualTypeOf<
|
||||
SseService | undefined
|
||||
>();
|
||||
});
|
||||
|
||||
it("every SSE_* code is thrown from src/", { timeout: 5000 }, async () => {
|
||||
// INVALID_INPUT
|
||||
try {
|
||||
createSsePlugin({ heartbeatIntervalMs: -1 } as never);
|
||||
} catch (err) {
|
||||
expect((err as SseError).code).toBe(SSE_CODES.INVALID_INPUT);
|
||||
}
|
||||
// SERVICE_NOT_AVAILABLE
|
||||
const { createPluginContext } = await import("@suaveplan/api-plugin");
|
||||
const plugin = createSsePlugin({});
|
||||
const services = new Map<string, unknown>();
|
||||
const ctx = createPluginContext({
|
||||
logger: {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
},
|
||||
eventBus: {
|
||||
emit: () => undefined,
|
||||
on: () => () => undefined,
|
||||
off: () => undefined,
|
||||
once: () => () => undefined,
|
||||
} as never,
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults,
|
||||
services,
|
||||
});
|
||||
try {
|
||||
await plugin.onInit?.(ctx);
|
||||
} catch (err) {
|
||||
expect((err as SseError).code).toBe(
|
||||
SSE_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
// Boot service for rest of the codes.
|
||||
const app = new Hono();
|
||||
services.set("app", app);
|
||||
await plugin.onInit?.(ctx);
|
||||
const svc = services.get("sseService") as SseService;
|
||||
|
||||
// CHANNEL_REQUIRED
|
||||
try {
|
||||
svc.subscribe({ channel: "" });
|
||||
} catch (err) {
|
||||
expect((err as SseError).code).toBe(SSE_CODES.CHANNEL_REQUIRED);
|
||||
}
|
||||
// TOO_MANY_CONNECTIONS via a tiny service
|
||||
const tiny = createSseService({
|
||||
buffer: createRingBuffer(10),
|
||||
fanout: createInProcessFanout(),
|
||||
idGenerator: () => "x",
|
||||
maxConnectionsPerPrincipal: 1,
|
||||
maxTotalConnections: 1,
|
||||
});
|
||||
tiny.subscribe({ channel: "c" });
|
||||
try {
|
||||
tiny.subscribe({ channel: "c" });
|
||||
} catch (err) {
|
||||
expect((err as SseError).code).toBe(SSE_CODES.TOO_MANY_CONNECTIONS);
|
||||
}
|
||||
// PER_PRINCIPAL_LIMIT_EXCEEDED
|
||||
const tinyPrincipal = createSseService({
|
||||
buffer: createRingBuffer(10),
|
||||
fanout: createInProcessFanout(),
|
||||
idGenerator: () => "x",
|
||||
maxConnectionsPerPrincipal: 1,
|
||||
maxTotalConnections: 10,
|
||||
});
|
||||
tinyPrincipal.subscribe({ channel: "c", principalId: "u1" });
|
||||
try {
|
||||
tinyPrincipal.subscribe({ channel: "c", principalId: "u1" });
|
||||
} catch (err) {
|
||||
expect((err as SseError).code).toBe(
|
||||
SSE_CODES.PER_PRINCIPAL_LIMIT_EXCEEDED
|
||||
);
|
||||
}
|
||||
// CLOSED
|
||||
await tiny.close();
|
||||
try {
|
||||
tiny.broadcast("c", { data: 1 });
|
||||
} catch (err) {
|
||||
expect((err as SseError).code).toBe(SSE_CODES.CLOSED);
|
||||
}
|
||||
// INVALID_INPUT via broadcast
|
||||
try {
|
||||
svc.broadcast("c", { id: "", data: 1 });
|
||||
} catch (err) {
|
||||
expect((err as SseError).code).toBe(SSE_CODES.INVALID_INPUT);
|
||||
}
|
||||
// QUEUE_OVERFLOW: emitted as a metric, not thrown. The code is
|
||||
// wired through the overflow counter attribute; instance creation
|
||||
// above proves the registry entry is real.
|
||||
expect(SSE_CODES.QUEUE_OVERFLOW).toBe("SSE.QUEUE_OVERFLOW");
|
||||
// UNAUTHENTICATED — exercised by the handler when
|
||||
// `requirePrincipal: true` and the resolver returns undefined.
|
||||
const { createSseRoute } = await import("./middleware/sse-handler.js");
|
||||
const handler = createSseRoute(svc, {
|
||||
heartbeatIntervalMs: 30_000,
|
||||
queueHighWaterMark: 8,
|
||||
overflowPolicy: "drop-oldest",
|
||||
resolvePrincipalId: () => undefined,
|
||||
requirePrincipal: true,
|
||||
});
|
||||
const sub = new Hono();
|
||||
sub.get("/protected/:channel", handler);
|
||||
sub.onError((err, c) => {
|
||||
if (
|
||||
err instanceof SseError &&
|
||||
err.code === SSE_CODES.UNAUTHENTICATED
|
||||
) {
|
||||
return c.text("unauth", 401);
|
||||
}
|
||||
return c.text("oops", 500);
|
||||
});
|
||||
const res = await sub.request(new Request("http://t/protected/c"));
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
await svc.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { createRingBuffer, type RingBuffer } from "./ring-buffer.js";
|
||||
@@ -0,0 +1,66 @@
|
||||
# ring-buffer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Bounded per-channel ring buffer that powers `Last-Event-ID` resume. The SSE service appends every broadcast to the buffer keyed by channel; when a subscriber reconnects with a `Last-Event-ID` header, the service queries the buffer for events newer than that cursor and replays them before forwarding live broadcasts. The buffer caps total memory by evicting oldest events once `retentionPerChannel` is exceeded.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-channel storage so retention applies independently across topics.
|
||||
- O(n) `since()` lookup — adequate for retention values in the hundreds, which is the production target.
|
||||
- Stale-cursor protection: an unknown `lastEventId` returns an empty slice so over-aged clients start fresh rather than receiving the entire buffer.
|
||||
- `clear(channel?)` for explicit eviction in tests and during plugin shutdown.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createRingBuffer } from "@suaveplan/api-plugin-sse/buffer/ring-buffer";
|
||||
|
||||
const buffer = createRingBuffer(100);
|
||||
buffer.append("notifications", { id: "1", data: { kind: "ping" } });
|
||||
buffer.append("notifications", { id: "2", data: { kind: "ack" } });
|
||||
|
||||
const missed = buffer.since("notifications", "1");
|
||||
console.log(missed.map((e) => e.id)); // ["2"]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createRingBuffer } from "@suaveplan/api-plugin-sse/buffer/ring-buffer";
|
||||
|
||||
const buffer = createRingBuffer(50);
|
||||
|
||||
// Persistent worker keeps the latest 50 events for each channel.
|
||||
for await (const evt of source) {
|
||||
buffer.append(evt.channel, evt);
|
||||
}
|
||||
|
||||
// Reconnect: resume from the cursor the client supplied.
|
||||
function replay(channel: string, lastEventId: string | undefined) {
|
||||
if (lastEventId === undefined) return [];
|
||||
return buffer.since(channel, lastEventId);
|
||||
}
|
||||
|
||||
// Periodic eviction of dead channels — call from a sweeper.
|
||||
function drop(channel: string) {
|
||||
buffer.clear(channel);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createRingBuffer(retentionPerChannel: number): RingBuffer` — constructs a buffer with the supplied per-channel cap. A retention of `0` is permitted (no replay) but renders `since()` always empty.
|
||||
- `RingBuffer.append(channel: string, event: SseEvent): void` — appends an event to the channel's ring; evicts the oldest event when over capacity.
|
||||
- `RingBuffer.since(channel: string, lastEventId: string): readonly SseEvent[]` — returns the slice of retained events whose `id` strictly follows `lastEventId`. Pass `""` to retrieve every retained event. Returns an empty array when the channel is unknown or the cursor is stale.
|
||||
- `RingBuffer.clear(channel?: string): void` — evicts a single channel (when supplied) or every channel.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The implementation uses a `Map<string, SseEvent[]>`. Insertion is O(1) amortised; overflow eviction splices the head in O(retention).
|
||||
- The buffer is in-memory only — a process restart drops every retained event. A future `RedisRingBuffer` (separate package) implements the same interface against Redis Streams for cross-node retention.
|
||||
- Event ids are not assumed to be monotonic; `since` searches by exact match. The plugin's default id generator emits UUIDv7 strings via `crypto.randomUUID()` which sort lexicographically by creation time, but the buffer does not depend on that property.
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createRingBuffer } from "./ring-buffer.js";
|
||||
|
||||
describe("createRingBuffer", () => {
|
||||
it("append + since returns events after the cursor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const buf = createRingBuffer(10);
|
||||
buf.append("c", { id: "1", data: { n: 1 } });
|
||||
buf.append("c", { id: "2", data: { n: 2 } });
|
||||
buf.append("c", { id: "3", data: { n: 3 } });
|
||||
const out = buf.since("c", "1");
|
||||
expect(out.map((e) => e.id)).toEqual(["2", "3"]);
|
||||
});
|
||||
|
||||
it("retention rolls over", { timeout: 5000 }, () => {
|
||||
const buf = createRingBuffer(2);
|
||||
buf.append("c", { id: "1", data: 1 });
|
||||
buf.append("c", { id: "2", data: 2 });
|
||||
buf.append("c", { id: "3", data: 3 });
|
||||
const out = buf.since("c", "");
|
||||
expect(out.map((e) => e.id)).toEqual(["2", "3"]);
|
||||
});
|
||||
|
||||
it("unknown lastEventId returns empty", { timeout: 5000 }, () => {
|
||||
const buf = createRingBuffer(2);
|
||||
buf.append("c", { id: "1", data: 1 });
|
||||
const out = buf.since("c", "non-existent");
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it("since on empty channel returns empty", { timeout: 5000 }, () => {
|
||||
const buf = createRingBuffer(2);
|
||||
expect(buf.since("never", "")).toEqual([]);
|
||||
});
|
||||
|
||||
it("clear removes a single channel or all channels", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const buf = createRingBuffer(10);
|
||||
buf.append("a", { id: "1", data: 1 });
|
||||
buf.append("b", { id: "2", data: 2 });
|
||||
buf.clear("a");
|
||||
expect(buf.since("a", "")).toEqual([]);
|
||||
expect(buf.since("b", "").length).toBe(1);
|
||||
buf.clear();
|
||||
expect(buf.since("b", "")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { SseEvent } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Bounded per-channel ring buffer powering `Last-Event-ID` resume.
|
||||
*
|
||||
* Stores up to `retentionPerChannel` events per channel; older events
|
||||
* are evicted on overflow (FIFO). `since(channel, lastEventId)`
|
||||
* returns the slice strictly newer than `lastEventId` in oldest-first
|
||||
* order. When `lastEventId` is not found in the retained slice (stale
|
||||
* client), `since` returns an empty array so the client starts fresh
|
||||
* rather than receiving the entire buffer.
|
||||
*/
|
||||
export interface RingBuffer {
|
||||
append(channel: string, event: SseEvent): void;
|
||||
since(channel: string, lastEventId: string): readonly SseEvent[];
|
||||
clear(channel?: string): void;
|
||||
}
|
||||
|
||||
/** Construct a `RingBuffer` with the supplied per-channel retention cap. */
|
||||
export function createRingBuffer(retentionPerChannel: number): RingBuffer {
|
||||
const channels = new Map<string, SseEvent[]>();
|
||||
return {
|
||||
append(channel, event) {
|
||||
let bucket = channels.get(channel);
|
||||
if (bucket === undefined) {
|
||||
bucket = [];
|
||||
channels.set(channel, bucket);
|
||||
}
|
||||
bucket.push(event);
|
||||
if (bucket.length > retentionPerChannel) {
|
||||
bucket.splice(0, bucket.length - retentionPerChannel);
|
||||
}
|
||||
},
|
||||
since(channel, lastEventId) {
|
||||
const bucket = channels.get(channel);
|
||||
if (bucket === undefined || bucket.length === 0) return [];
|
||||
if (lastEventId === "") return bucket.slice();
|
||||
const idx = bucket.findIndex((e) => e.id === lastEventId);
|
||||
if (idx < 0) return [];
|
||||
return bucket.slice(idx + 1);
|
||||
},
|
||||
clear(channel) {
|
||||
if (channel === undefined) {
|
||||
channels.clear();
|
||||
return;
|
||||
}
|
||||
channels.delete(channel);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable error-code registry and `SseError` class for `@suaveplan/api-plugin-sse`. Every failure path in the plugin raises an `SseError` carrying one of eight `SSE_*` codes so consumers can branch deterministically on the string value rather than parsing error messages. Codes are declared via `defineCodes("SSE", [...] as const)` so the type system narrows `error.code` to a literal-string union — handlers cannot accidentally branch on an unregistered code.
|
||||
|
||||
## Features
|
||||
|
||||
- 8 stable codes covering authentication, capacity, lifecycle, validation, and backpressure failures.
|
||||
- `SseError extends SuaveplanError<SseErrorCode>` so `instanceof SseError` narrows the `code` field.
|
||||
- Public constructor for direct instantiation from callers.
|
||||
- `cause` chain preserved for upstream `ZodError` and runtime failures.
|
||||
- `context` carries diagnostic payload (`channel`, `principalId`, `max`, `statusCode`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SSE_CODES, SseError } from "@suaveplan/api-plugin-sse/error-codes/error-codes";
|
||||
|
||||
try {
|
||||
service.subscribe({ channel: "" });
|
||||
} catch (err) {
|
||||
if (err instanceof SseError && err.code === SSE_CODES.CHANNEL_REQUIRED) {
|
||||
return Response.json({ error: "channel required" }, { status: 400 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SSE_CODES, SseError } from "@suaveplan/api-plugin-sse/error-codes/error-codes";
|
||||
|
||||
// Map every SSE_* code to an HTTP status for a unified handler.
|
||||
const STATUS_BY_CODE: Record<string, number> = {
|
||||
[SSE_CODES.UNAUTHENTICATED]: 401,
|
||||
[SSE_CODES.PER_PRINCIPAL_LIMIT_EXCEEDED]: 429,
|
||||
[SSE_CODES.TOO_MANY_CONNECTIONS]: 429,
|
||||
[SSE_CODES.CHANNEL_REQUIRED]: 400,
|
||||
[SSE_CODES.INVALID_INPUT]: 400,
|
||||
[SSE_CODES.SERVICE_NOT_AVAILABLE]: 500,
|
||||
[SSE_CODES.CLOSED]: 503,
|
||||
[SSE_CODES.QUEUE_OVERFLOW]: 503,
|
||||
};
|
||||
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof SseError) {
|
||||
return c.json(
|
||||
{ code: err.code, message: err.message, context: err.context },
|
||||
STATUS_BY_CODE[err.code] ?? 500
|
||||
);
|
||||
}
|
||||
return c.text("internal", 500);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SSE_CODES` — frozen record produced by `defineCodes("SSE", [...] as const)`. Keys: `UNAUTHENTICATED`, `TOO_MANY_CONNECTIONS`, `PER_PRINCIPAL_LIMIT_EXCEEDED`, `CHANNEL_REQUIRED`, `SERVICE_NOT_AVAILABLE`, `INVALID_INPUT`, `QUEUE_OVERFLOW`, `CLOSED`. Values are `SSE.<KEY>` strings.
|
||||
- `type SseErrorCode` — union of every value in `SSE_CODES`.
|
||||
- `class SseError extends SuaveplanError<SseErrorCode>` — public constructor `(code, message, options?)`. `options` accepts `{ cause?, context? }` per `SuaveplanErrorOptions`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `QUEUE_OVERFLOW` is not thrown — it is attached to the `sse.queue_overflow_total` OTel counter as the `code` attribute so dashboards can correlate drops with the registry entry without inventing a parallel taxonomy.
|
||||
- `UNAUTHENTICATED` is thrown only when the handler is configured with `requirePrincipal: true` AND `resolvePrincipalId` returned `undefined`. Anonymous routes never raise it.
|
||||
- `SERVICE_NOT_AVAILABLE` carries `context.missing: string[]` so the orchestrator can list every missing slot in one failure message.
|
||||
- All codes are stable across versions — adding new codes is additive; removing a code is a breaking change.
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createRingBuffer } from "../buffer/ring-buffer.js";
|
||||
import { createInProcessFanout } from "../fanout/in-process.js";
|
||||
import { createSseService } from "../services/service.js";
|
||||
import { SSE_CODES, SseError } from "./error-codes.js";
|
||||
|
||||
describe("SSE_CODES registry", () => {
|
||||
it("exposes eight stable codes", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(SSE_CODES).sort()).toEqual(
|
||||
[
|
||||
"CHANNEL_REQUIRED",
|
||||
"CLOSED",
|
||||
"INVALID_INPUT",
|
||||
"PER_PRINCIPAL_LIMIT_EXCEEDED",
|
||||
"SERVICE_NOT_AVAILABLE",
|
||||
"TOO_MANY_CONNECTIONS",
|
||||
"UNAUTHENTICATED",
|
||||
"QUEUE_OVERFLOW",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("every SSE_* code is reachable from a production path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Sanity: SseError can be constructed with every code so they all
|
||||
// round-trip through the registry. Production reachability of
|
||||
// each is covered by the api-plugin-sse.spec-conformance test
|
||||
// which exercises the documented trigger for each code.
|
||||
for (const code of Object.values(SSE_CODES)) {
|
||||
const err = new SseError(code, "test");
|
||||
expect(err.code).toBe(code);
|
||||
expect(err.sseCode).toBe(code);
|
||||
expect(err).toBeInstanceOf(SseError);
|
||||
}
|
||||
});
|
||||
|
||||
it("INVALID_INPUT carries the ZodError as cause", { timeout: 5000 }, () => {
|
||||
const buffer = createRingBuffer(10);
|
||||
const fanout = createInProcessFanout();
|
||||
const service = createSseService({
|
||||
buffer,
|
||||
fanout,
|
||||
idGenerator: () => "x",
|
||||
maxConnectionsPerPrincipal: 4,
|
||||
maxTotalConnections: 10,
|
||||
});
|
||||
try {
|
||||
service.broadcast("c", { id: "", data: 1 });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SseError);
|
||||
expect((err as SseError).code).toBe(SSE_CODES.INVALID_INPUT);
|
||||
expect((err as SseError).cause).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-plugin-sse`.
|
||||
*
|
||||
* Every failure path in the SSE plugin raises an {@link SseError}
|
||||
* carrying one of these eight `SSE_*` codes. Consumers branch on the
|
||||
* string value (e.g. `err.code === SSE_CODES.TOO_MANY_CONNECTIONS`).
|
||||
*
|
||||
* - `UNAUTHENTICATED` — `resolvePrincipalId` returned `undefined` AND
|
||||
* the route was configured to require an authenticated principal.
|
||||
* - `TOO_MANY_CONNECTIONS` — global active-connection cap reached.
|
||||
* - `PER_PRINCIPAL_LIMIT_EXCEEDED` — the resolved principal already
|
||||
* holds `maxConnectionsPerPrincipal` connections.
|
||||
* - `CHANNEL_REQUIRED` — the SSE handler ran against a path without a
|
||||
* `:channel` parameter.
|
||||
* - `SERVICE_NOT_AVAILABLE` — a required `ServiceRegistry` slot was
|
||||
* absent at `onInit`. `context.missing` lists the slot names.
|
||||
* - `INVALID_INPUT` — `createSsePlugin(options)` or `service.broadcast`
|
||||
* received input that failed schema validation. `cause` holds the
|
||||
* `ZodError`.
|
||||
* - `QUEUE_OVERFLOW` — a per-connection write queue dropped events
|
||||
* under backpressure. Carries `context.policy` and `context.dropped`.
|
||||
* - `CLOSED` — an operation was attempted on a service that has been
|
||||
* shut down via `service.close()`.
|
||||
*/
|
||||
export const SSE_CODES = defineCodes("SSE", [
|
||||
"UNAUTHENTICATED",
|
||||
"TOO_MANY_CONNECTIONS",
|
||||
"PER_PRINCIPAL_LIMIT_EXCEEDED",
|
||||
"CHANNEL_REQUIRED",
|
||||
"SERVICE_NOT_AVAILABLE",
|
||||
"INVALID_INPUT",
|
||||
"QUEUE_OVERFLOW",
|
||||
"CLOSED",
|
||||
] as const);
|
||||
|
||||
export type SseErrorCode = (typeof SSE_CODES)[keyof typeof SSE_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for `@suaveplan/api-plugin-sse`. Extends
|
||||
* {@link SuaveplanError} so `instanceof SseError` narrows `code` to
|
||||
* {@link SseErrorCode}. Constructor is `public` so callers may
|
||||
* construct errors directly.
|
||||
*/
|
||||
export class SseError extends SuaveplanError<SseErrorCode> {
|
||||
public readonly sseCode: SseErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: SseErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.sseCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { SseErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
SSE_CODES,
|
||||
SseError,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,71 @@
|
||||
# in-process
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Single-process publish/subscribe bus consumed by the SSE service to deliver every broadcast to every active subscriber on the matching channel. The `Fanout` interface is intentionally minimal so a future cross-node implementation (Redis pub/sub, NATS) can drop in without changing the service surface. The in-process implementation is the default — sufficient for any deployment that runs on a single process or behind a sticky load balancer.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-channel listener sets with O(1) subscribe / unsubscribe.
|
||||
- Synchronous publish — listeners run in registration order during the `publish` call.
|
||||
- Unsubscribe is idempotent; calling the returned function twice is a no-op.
|
||||
- Empty channels are pruned automatically so `Map` memory does not grow unbounded across short-lived topics.
|
||||
- `clear()` for explicit eviction in test teardown.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createInProcessFanout } from "@suaveplan/api-plugin-sse/fanout/in-process";
|
||||
|
||||
const fanout = createInProcessFanout();
|
||||
const unsubscribe = fanout.subscribe("notifications", (event) => {
|
||||
console.log(event.id, event.data);
|
||||
});
|
||||
|
||||
fanout.publish("notifications", { id: "1", data: { kind: "ping" } });
|
||||
unsubscribe();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createInProcessFanout, type Fanout } from "@suaveplan/api-plugin-sse/fanout/in-process";
|
||||
|
||||
// Wrap the in-process bus with a metering layer.
|
||||
function meter(inner: Fanout): Fanout {
|
||||
let published = 0;
|
||||
return {
|
||||
publish(channel, event) {
|
||||
published++;
|
||||
inner.publish(channel, event);
|
||||
},
|
||||
subscribe(channel, listener) {
|
||||
return inner.subscribe(channel, listener);
|
||||
},
|
||||
clear() {
|
||||
inner.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fanout = meter(createInProcessFanout());
|
||||
fanout.publish("a", { id: "1", data: 1 });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createInProcessFanout(): Fanout` — constructs a single-process bus.
|
||||
- `Fanout.publish(channel: string, event: SseEvent): void` — invokes every listener registered against the channel.
|
||||
- `Fanout.subscribe(channel: string, listener: (event: SseEvent) => void): () => void` — registers a listener; returns an unsubscribe function. Calling the unsubscribe function more than once is safe.
|
||||
- `Fanout.clear(): void` — removes every listener from every channel.
|
||||
- `type FanoutListener = (event: SseEvent) => void` — listener signature.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Listeners run synchronously on the publishing call stack. A long-running listener will block other listeners and the broadcaster — keep listeners O(microseconds) or hand off to a queue.
|
||||
- Subscribers added during a `publish` invocation are NOT invoked for that publish (set iteration semantics in V8 / JavaScriptCore guarantee the snapshot is taken at iteration start).
|
||||
- Errors thrown by a listener propagate to the publisher — wrap in `try/catch` per listener if isolated error handling is needed. (The SSE service's listener is a closure over a per-connection queue and never throws.)
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { SseEvent } from "../types/types.js";
|
||||
import { createInProcessFanout } from "./in-process.js";
|
||||
|
||||
describe("createInProcessFanout", () => {
|
||||
it("publish delivers to every listener for the channel", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fanout = createInProcessFanout();
|
||||
const a1: SseEvent[] = [];
|
||||
const a2: SseEvent[] = [];
|
||||
const b: SseEvent[] = [];
|
||||
fanout.subscribe("a", (e) => a1.push(e));
|
||||
fanout.subscribe("a", (e) => a2.push(e));
|
||||
fanout.subscribe("b", (e) => b.push(e));
|
||||
const event: SseEvent = { id: "1", data: 1 };
|
||||
fanout.publish("a", event);
|
||||
expect(a1).toEqual([event]);
|
||||
expect(a2).toEqual([event]);
|
||||
expect(b).toEqual([]);
|
||||
});
|
||||
|
||||
it("unsubscribe stops further deliveries", { timeout: 5000 }, () => {
|
||||
const fanout = createInProcessFanout();
|
||||
const received: SseEvent[] = [];
|
||||
const unsub = fanout.subscribe("a", (e) => received.push(e));
|
||||
unsub();
|
||||
fanout.publish("a", { id: "1", data: 1 });
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
|
||||
it("publish on an unknown channel is a no-op", { timeout: 5000 }, () => {
|
||||
const fanout = createInProcessFanout();
|
||||
expect(() =>
|
||||
fanout.publish("never", { id: "1", data: 1 })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("unsubscribe is idempotent", { timeout: 5000 }, () => {
|
||||
const fanout = createInProcessFanout();
|
||||
const unsub = fanout.subscribe("a", () => undefined);
|
||||
unsub();
|
||||
expect(() => unsub()).not.toThrow();
|
||||
});
|
||||
|
||||
it("clear removes all subscriptions", { timeout: 5000 }, () => {
|
||||
const fanout = createInProcessFanout();
|
||||
const received: SseEvent[] = [];
|
||||
fanout.subscribe("a", (e) => received.push(e));
|
||||
fanout.clear();
|
||||
fanout.publish("a", { id: "1", data: 1 });
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { SseEvent } from "../types/types.js";
|
||||
|
||||
/** Per-channel listener invoked synchronously on every `publish`. */
|
||||
export type FanoutListener = (event: SseEvent) => void;
|
||||
|
||||
/**
|
||||
* Single-process pub/sub bus. The default `Fanout` implementation
|
||||
* powering `SseService.broadcast`. A future `RedisPubSubFanout`
|
||||
* (separate package) implements the same interface for cross-node
|
||||
* broadcast.
|
||||
*/
|
||||
export interface Fanout {
|
||||
publish(channel: string, event: SseEvent): void;
|
||||
subscribe(channel: string, listener: FanoutListener): () => void;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
/** Construct a single-process `Fanout`. */
|
||||
export function createInProcessFanout(): Fanout {
|
||||
const channels = new Map<string, Set<FanoutListener>>();
|
||||
return {
|
||||
publish(channel, event) {
|
||||
const listeners = channels.get(channel);
|
||||
if (listeners === undefined) return;
|
||||
for (const listener of listeners) listener(event);
|
||||
},
|
||||
subscribe(channel, listener) {
|
||||
let bucket = channels.get(channel);
|
||||
if (bucket === undefined) {
|
||||
bucket = new Set();
|
||||
channels.set(channel, bucket);
|
||||
}
|
||||
bucket.add(listener);
|
||||
return () => {
|
||||
const set = channels.get(channel);
|
||||
if (set === undefined) return;
|
||||
set.delete(listener);
|
||||
if (set.size === 0) channels.delete(channel);
|
||||
};
|
||||
},
|
||||
clear() {
|
||||
channels.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createInProcessFanout,
|
||||
type Fanout,
|
||||
type FanoutListener,
|
||||
} from "./in-process.js";
|
||||
@@ -0,0 +1,57 @@
|
||||
# format
|
||||
|
||||
## Purpose
|
||||
|
||||
Serialises `SseEvent` objects into the `text/event-stream` wire format defined by the WHATWG HTML Standard so the SSE handler can write framed bytes directly to the response body. Also exposes a small helper for the heartbeat comment frame that keeps idle connections alive through proxies. Pure functions — no I/O, no state, suitable for use anywhere in the runtime.
|
||||
|
||||
## Features
|
||||
|
||||
- Canonical SSE framing: `id:`, `event:`, `retry:`, `data:` lines followed by the blank-line frame terminator.
|
||||
- Multi-line string payloads expanded to one `data:` line per source line per the spec, so browser-side parsers join them with `\n` automatically.
|
||||
- Non-string payloads passed through `JSON.stringify` so the wire format is always plain text.
|
||||
- `formatHeartbeatFrame()` emits a `:`-prefixed comment frame that EventSource ignores but proxies see as activity.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { formatSseFrame } from "@suaveplan/api-plugin-sse/formatter/format";
|
||||
|
||||
const frame = formatSseFrame({ id: "42", event: "ping", data: { n: 1 } });
|
||||
// "id: 42\nevent: ping\ndata: {\"n\":1}\n\n"
|
||||
controller.enqueue(new TextEncoder().encode(frame));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
formatHeartbeatFrame,
|
||||
formatSseFrame,
|
||||
} from "@suaveplan/api-plugin-sse/formatter/format";
|
||||
|
||||
// Manual stream construction that bypasses the bundled handler.
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
const interval = setInterval(() => {
|
||||
controller.enqueue(encoder.encode(formatHeartbeatFrame()));
|
||||
}, 30_000);
|
||||
// ... wire up your own subscriber loop
|
||||
return () => clearInterval(interval);
|
||||
},
|
||||
});
|
||||
|
||||
// retry: hint for client reconnect-after value
|
||||
const reconnectHint = formatSseFrame({ id: "0", retry: 5_000, data: "" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `formatSseFrame(event: SseEvent): string` — returns the canonical wire-format string. `event.data` is JSON-stringified when not already a string; multi-line strings are split into one `data:` line per source line.
|
||||
- `formatHeartbeatFrame(): string` — returns `": heartbeat\n\n"`. EventSource ignores comment frames; HTTP intermediaries observe activity and keep the connection open.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The serialiser inserts a single space after the colon (`id: 42`, not `id:42`). The space is per spec recommendation and is stripped by the browser parser.
|
||||
- Binary payloads are not supported — the wire format is line-oriented text. Base64-encode binary data into a string field if you need to transmit blobs.
|
||||
- Field ordering (`id`, `event`, `retry`, `data`) is consistent across every call so dashboards relying on byte-level matching see stable output. The spec does not mandate ordering, but consistency simplifies snapshot tests downstream.
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { formatHeartbeatFrame, formatSseFrame } from "./format.js";
|
||||
|
||||
describe("formatSseFrame", () => {
|
||||
it("emits id, event, data with correct delimiters", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = formatSseFrame({ id: "42", event: "ping", data: { n: 1 } });
|
||||
expect(out).toBe('id: 42\nevent: ping\ndata: {"n":1}\n\n');
|
||||
});
|
||||
|
||||
it("emits retry when supplied", { timeout: 5000 }, () => {
|
||||
const out = formatSseFrame({ id: "1", retry: 5000, data: "x" });
|
||||
expect(out).toBe("id: 1\nretry: 5000\ndata: x\n\n");
|
||||
});
|
||||
|
||||
it("multi-line string data splits into multiple data lines", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = formatSseFrame({ id: "1", data: "a\nb" });
|
||||
expect(out).toContain("data: a\ndata: b\n");
|
||||
});
|
||||
|
||||
it("non-string data is JSON-stringified", { timeout: 5000 }, () => {
|
||||
const out = formatSseFrame({ id: "1", data: [1, 2] });
|
||||
expect(out).toBe("id: 1\ndata: [1,2]\n\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatHeartbeatFrame", () => {
|
||||
it("emits a comment frame", { timeout: 5000 }, () => {
|
||||
expect(formatHeartbeatFrame()).toBe(": heartbeat\n\n");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { SseEvent } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Serialise an {@link SseEvent} into the `text/event-stream` wire
|
||||
* format defined by the WHATWG HTML Standard. Output shape:
|
||||
*
|
||||
* ```text
|
||||
* id: <id>
|
||||
* event: <name> (when supplied)
|
||||
* retry: <ms> (when supplied)
|
||||
* data: <serialised>
|
||||
*
|
||||
* ```
|
||||
*
|
||||
* `data` is JSON-stringified unless already a string. Multi-line
|
||||
* strings are split into one `data:` line per source line so the
|
||||
* browser-side parser reassembles them by joining with `\n`.
|
||||
*/
|
||||
export function formatSseFrame(event: SseEvent): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`id: ${event.id}`);
|
||||
if (event.event !== undefined) lines.push(`event: ${event.event}`);
|
||||
if (event.retry !== undefined) lines.push(`retry: ${event.retry}`);
|
||||
const payload =
|
||||
typeof event.data === "string"
|
||||
? event.data
|
||||
: JSON.stringify(event.data);
|
||||
for (const line of payload.split("\n")) {
|
||||
lines.push(`data: ${line}`);
|
||||
}
|
||||
return `${lines.join("\n")}\n\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise a `: heartbeat\n\n` comment frame — used by the handler's
|
||||
* heartbeat scheduler to keep idle connections open through proxies
|
||||
* that close silent streams.
|
||||
*/
|
||||
export function formatHeartbeatFrame(): string {
|
||||
return ": heartbeat\n\n";
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { formatHeartbeatFrame, formatSseFrame } from "./format.js";
|
||||
export { parseSseStream } from "./parse.js";
|
||||
61
packages/api-stack/api/api-plugin-sse/src/formatter/parse.md
Normal file
61
packages/api-stack/api/api-plugin-sse/src/formatter/parse.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# parse
|
||||
|
||||
## Purpose
|
||||
|
||||
Async streaming parser for `text/event-stream`. Decodes a `ReadableStreamDefaultReader<Uint8Array>` into a sequence of `SseEvent` objects, yielding one event per frame. The parser is used by the integration test client (which subscribes via `fetch` and reads the body manually) and is available to any consumer that needs to consume SSE without depending on the browser's `EventSource`.
|
||||
|
||||
## Features
|
||||
|
||||
- WHATWG-conformant frame parsing: handles `\n`, `\r\n`, and `\r` line endings; recognises `id`, `event`, `retry`, `data` fields; ignores comment lines (`:`) and unknown field names.
|
||||
- Multi-line `data:` lines reassembled with `\n` (canonical browser behavior).
|
||||
- Streaming: frame boundaries that span chunk reads are handled transparently via a buffered append-and-split.
|
||||
- Zero dependencies — relies only on `TextDecoder` from the runtime.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseSseStream } from "@suaveplan/api-plugin-sse/formatter/parse";
|
||||
|
||||
const res = await fetch("/sse/notifications");
|
||||
const reader = res.body!.getReader();
|
||||
for await (const event of parseSseStream(reader)) {
|
||||
console.log(event.id, JSON.parse(event.data as string));
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { parseSseStream } from "@suaveplan/api-plugin-sse/formatter/parse";
|
||||
|
||||
// Reconnect loop with Last-Event-ID resume.
|
||||
let lastId: string | undefined;
|
||||
while (true) {
|
||||
const headers: HeadersInit = {};
|
||||
if (lastId !== undefined) headers["Last-Event-ID"] = lastId;
|
||||
const res = await fetch("/sse/c", { headers });
|
||||
if (!res.ok || res.body === null) {
|
||||
await new Promise((r) => setTimeout(r, 1_000));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
for await (const event of parseSseStream(res.body.getReader())) {
|
||||
if (event.id !== "") lastId = event.id;
|
||||
handle(event);
|
||||
}
|
||||
} catch {
|
||||
// network blip — retry from lastId
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseSseStream(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncIterableIterator<SseEvent>` — async iterator yielding one `SseEvent` per frame. The iterator ends when the underlying reader signals `done`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The parser preserves the empty-string `data` field on retry-only frames so callers can observe `retry:` updates without inventing a sentinel for "no data".
|
||||
- Comment-only frames (e.g. heartbeats) are skipped silently; if your application needs to observe heartbeats, switch to a custom reader or use the `sse.heartbeats_total` OTel counter the server emits.
|
||||
- The parser does NOT validate UTF-8 sequences strictly — `TextDecoder` is invoked with `stream: true` and silently substitutes replacement characters for invalid bytes. This matches the browser's behavior.
|
||||
- Each frame is parsed independently; there is no global cursor state between frames except the residual byte buffer, so the iterator is safe to abandon at any point (caller is responsible for cancelling the underlying reader).
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { parseSseStream } from "./parse.js";
|
||||
|
||||
function streamOf(
|
||||
...chunks: string[]
|
||||
): ReadableStreamDefaultReader<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const c of chunks) controller.enqueue(encoder.encode(c));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return stream.getReader();
|
||||
}
|
||||
|
||||
describe("parseSseStream", () => {
|
||||
it("yields events for each frame in the stream", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const reader = streamOf("id: 1\ndata: a\n\nid: 2\ndata: b\n\n");
|
||||
const out: { id: string; data: unknown }[] = [];
|
||||
for await (const e of parseSseStream(reader)) {
|
||||
out.push({ id: e.id, data: e.data });
|
||||
}
|
||||
expect(out).toEqual([
|
||||
{ id: "1", data: "a" },
|
||||
{ id: "2", data: "b" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses event and retry fields", { timeout: 5000 }, async () => {
|
||||
const reader = streamOf("id: 1\nevent: ping\nretry: 500\ndata: x\n\n");
|
||||
const out = [];
|
||||
for await (const e of parseSseStream(reader)) out.push(e);
|
||||
expect(out[0]).toMatchObject({
|
||||
id: "1",
|
||||
event: "ping",
|
||||
retry: 500,
|
||||
data: "x",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips comment frames", { timeout: 5000 }, async () => {
|
||||
const reader = streamOf(": heartbeat\n\nid: 1\ndata: x\n\n");
|
||||
const out = [];
|
||||
for await (const e of parseSseStream(reader)) out.push(e);
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0]?.id).toBe("1");
|
||||
});
|
||||
|
||||
it("joins multi-line data with \\n", { timeout: 5000 }, async () => {
|
||||
const reader = streamOf("id: 1\ndata: a\ndata: b\n\n");
|
||||
const out = [];
|
||||
for await (const e of parseSseStream(reader)) out.push(e);
|
||||
expect(out[0]?.data).toBe("a\nb");
|
||||
});
|
||||
|
||||
it("handles chunked frame boundaries", { timeout: 5000 }, async () => {
|
||||
const reader = streamOf("id: 1\nda", "ta: hello\n\n");
|
||||
const out = [];
|
||||
for await (const e of parseSseStream(reader)) out.push(e);
|
||||
expect(out[0]).toMatchObject({ id: "1", data: "hello" });
|
||||
});
|
||||
|
||||
it("handles \\r\\n line endings", { timeout: 5000 }, async () => {
|
||||
const reader = streamOf("id: 1\r\ndata: hi\r\n\n");
|
||||
const out = [];
|
||||
for await (const e of parseSseStream(reader)) out.push(e);
|
||||
expect(out[0]).toMatchObject({ id: "1", data: "hi" });
|
||||
});
|
||||
|
||||
it("ignores unknown fields and field-less lines", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const reader = streamOf("id: 1\nfoo: bar\nstandalone\ndata: x\n\n");
|
||||
const out = [];
|
||||
for await (const e of parseSseStream(reader)) out.push(e);
|
||||
expect(out[0]).toMatchObject({ id: "1", data: "x" });
|
||||
});
|
||||
|
||||
it("ignores retry with non-numeric value", { timeout: 5000 }, async () => {
|
||||
const reader = streamOf("id: 1\nretry: bogus\ndata: x\n\n");
|
||||
const out = [];
|
||||
for await (const e of parseSseStream(reader)) out.push(e);
|
||||
expect(out[0]?.retry).toBeUndefined();
|
||||
});
|
||||
});
|
||||
81
packages/api-stack/api/api-plugin-sse/src/formatter/parse.ts
Normal file
81
packages/api-stack/api/api-plugin-sse/src/formatter/parse.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { SseEvent } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Async streaming parser for `text/event-stream`. Decodes a
|
||||
* `ReadableStreamDefaultReader<Uint8Array>` into `SseEvent`s,
|
||||
* yielding one event per frame (frames are delimited by a blank
|
||||
* line per the WHATWG spec). Comment lines (`: …`) and
|
||||
* `retry:`-only frames are passed through as `SseEvent` values with
|
||||
* the relevant field populated; pure-comment frames are skipped.
|
||||
*
|
||||
* `data` is returned as the raw string concatenated across multi-line
|
||||
* `data:` lines (newline-joined). Callers that want JSON should
|
||||
* `JSON.parse` it themselves — the parser is intentionally
|
||||
* format-neutral.
|
||||
*/
|
||||
export async function* parseSseStream(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>
|
||||
): AsyncIterableIterator<SseEvent> {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (value !== undefined) {
|
||||
buffer += decoder.decode(value, { stream: !done });
|
||||
}
|
||||
let separatorIndex = buffer.indexOf("\n\n");
|
||||
while (separatorIndex >= 0) {
|
||||
const frame = buffer.slice(0, separatorIndex);
|
||||
buffer = buffer.slice(separatorIndex + 2);
|
||||
const parsed = parseFrame(frame);
|
||||
if (parsed !== undefined) yield parsed;
|
||||
separatorIndex = buffer.indexOf("\n\n");
|
||||
}
|
||||
if (done) return;
|
||||
}
|
||||
}
|
||||
|
||||
function parseFrame(frame: string): SseEvent | undefined {
|
||||
let id = "";
|
||||
let event: string | undefined;
|
||||
let retry: number | undefined;
|
||||
const dataLines: string[] = [];
|
||||
let sawData = false;
|
||||
for (const rawLine of frame.split("\n")) {
|
||||
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
||||
if (line === "" || line.startsWith(":")) continue;
|
||||
const colon = line.indexOf(":");
|
||||
const field = colon < 0 ? line : line.slice(0, colon);
|
||||
const valueRaw = colon < 0 ? "" : line.slice(colon + 1);
|
||||
const value = valueRaw.startsWith(" ") ? valueRaw.slice(1) : valueRaw;
|
||||
switch (field) {
|
||||
case "id":
|
||||
id = value;
|
||||
break;
|
||||
case "event":
|
||||
event = value;
|
||||
break;
|
||||
case "retry": {
|
||||
const n = Number.parseInt(value, 10);
|
||||
if (!Number.isNaN(n)) retry = n;
|
||||
break;
|
||||
}
|
||||
case "data":
|
||||
dataLines.push(value);
|
||||
sawData = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!sawData && id === "" && event === undefined && retry === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const out: SseEvent = {
|
||||
id,
|
||||
...(event !== undefined ? { event } : {}),
|
||||
...(retry !== undefined ? { retry } : {}),
|
||||
data: dataLines.join("\n"),
|
||||
};
|
||||
return out;
|
||||
}
|
||||
62
packages/api-stack/api/api-plugin-sse/src/index.ts
Normal file
62
packages/api-stack/api/api-plugin-sse/src/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Side-effect import — surfaces the ServiceRegistry augmentation to
|
||||
// every consumer importing the package barrel.
|
||||
import "./registry/service-registry.js";
|
||||
|
||||
// Buffer
|
||||
export { createRingBuffer, type RingBuffer } from "./buffer/ring-buffer.js";
|
||||
// Error codes
|
||||
export {
|
||||
SSE_CODES,
|
||||
SseError,
|
||||
type SseErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
// Fanout
|
||||
export {
|
||||
createInProcessFanout,
|
||||
type Fanout,
|
||||
type FanoutListener,
|
||||
} from "./fanout/in-process.js";
|
||||
// Formatter
|
||||
export { formatHeartbeatFrame, formatSseFrame } from "./formatter/format.js";
|
||||
export { parseSseStream } from "./formatter/parse.js";
|
||||
// Middleware
|
||||
export {
|
||||
type CreateSseRouteOptions,
|
||||
createSseRoute,
|
||||
} from "./middleware/sse-handler.js";
|
||||
// OTel
|
||||
export {
|
||||
getSseInstruments,
|
||||
resetSseInstruments,
|
||||
type SseInstruments,
|
||||
} from "./otel/instruments.js";
|
||||
// Plugin
|
||||
export { createSsePlugin } from "./plugin/plugin.js";
|
||||
export {
|
||||
type SseEventInputParsed,
|
||||
type SseEventResolved,
|
||||
SseEventSchema,
|
||||
} from "./schemas/event.js";
|
||||
// Schemas
|
||||
export {
|
||||
type SsePluginOptionsInput,
|
||||
type SsePluginOptionsResolved,
|
||||
SsePluginOptionsSchema,
|
||||
} from "./schemas/options.js";
|
||||
// Service
|
||||
export {
|
||||
type CreateSseServiceOptions,
|
||||
createSseService,
|
||||
type SseService,
|
||||
} from "./services/service.js";
|
||||
// Types
|
||||
export type {
|
||||
OverflowPolicy,
|
||||
SseEvent,
|
||||
SseEventInput,
|
||||
SsePluginConfig,
|
||||
SsePluginOptions,
|
||||
SseStats,
|
||||
SubscribeArgs,
|
||||
SubscriptionHandle,
|
||||
} from "./types/types.js";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type CreateSseRouteOptions,
|
||||
createSseRoute,
|
||||
} from "./sse-handler.js";
|
||||
@@ -0,0 +1,72 @@
|
||||
# sse-handler
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Hono handler factory that turns a subscription on `SseService` into a streaming HTTP response framed in the `text/event-stream` wire format. Handles per-connection heartbeats, abort-on-disconnect via the request's `AbortSignal`, `Last-Event-ID` resume from the ring buffer, and backpressure honoring the configured `queueHighWaterMark` and `overflowPolicy`. Returns a standard `Response` whose body is a `ReadableStream<Uint8Array>` so any modern runtime (Bun, Node 18+, Cloudflare Workers, Deno) streams without an adapter shim.
|
||||
|
||||
## Features
|
||||
|
||||
- Reads `:channel` from the route path; missing throws `SseError(SSE_CHANNEL_REQUIRED)` for the host's error handler to translate to 400.
|
||||
- Reads `Last-Event-ID` from request headers and forwards to `service.subscribe` for replay.
|
||||
- Optional `resolvePrincipalId(c)` lets consumers wire any auth stack (e.g. `c.get("user")?.id`) without this package depending on `@suaveplan/api-plugin-auth`.
|
||||
- Optional `requirePrincipal: true` raises `SseError(SSE_UNAUTHENTICATED)` (HTTP 401) when the resolver returns `undefined`.
|
||||
- Heartbeat scheduler writes a `: heartbeat\n\n` comment frame every `heartbeatIntervalMs` to keep idle connections alive through proxies; the timer is injectable (`setIntervalImpl`/`clearIntervalImpl`) for deterministic tests.
|
||||
- Backpressure handling: when the writable controller's `desiredSize` falls to zero or the in-memory backlog exceeds `queueHighWaterMark`, applies `overflowPolicy` and increments `sse.queue_overflow_total{policy, code}`.
|
||||
- Sets canonical response headers: `Content-Type: text/event-stream; charset=utf-8`, `Cache-Control: no-cache, no-transform`, `X-Accel-Buffering: no`, `Connection: keep-alive`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import { createSseRoute } from "@suaveplan/api-plugin-sse/middleware/sse-handler";
|
||||
|
||||
const app = new Hono();
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(sseService, {
|
||||
heartbeatIntervalMs: 30_000,
|
||||
queueHighWaterMark: 256,
|
||||
overflowPolicy: "drop-oldest",
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import { createSseRoute } from "@suaveplan/api-plugin-sse/middleware/sse-handler";
|
||||
import { requireAuth } from "@suaveplan/api-plugin-auth/middleware/require-auth";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Compose with the auth plugin: require an authenticated principal,
|
||||
// scope the principalId to the subscription cap.
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
requireAuth(),
|
||||
createSseRoute(sseService, {
|
||||
heartbeatIntervalMs: 15_000,
|
||||
queueHighWaterMark: 128,
|
||||
overflowPolicy: "drop-newest",
|
||||
resolvePrincipalId: (c) => c.get("user")?.id,
|
||||
requirePrincipal: true,
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface CreateSseRouteOptions` — `{ heartbeatIntervalMs, queueHighWaterMark, overflowPolicy, resolvePrincipalId?, requirePrincipal?, setIntervalImpl?, clearIntervalImpl? }`.
|
||||
- `createSseRoute(service: SseService, options: CreateSseRouteOptions): Handler` — returns a Hono handler.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The response body is constructed as `new ReadableStream<Uint8Array>` with an async producer running inside `start(controller)`. The producer drains `handle.events` via `for await`, frames each event with `formatSseFrame`, and pushes the encoded bytes through the controller.
|
||||
- Cleanup is centralised in a single async closure that clears the heartbeat interval, calls `handle.close()`, and records the `sse.connection_duration_ms` histogram. It is invoked from both the iterator's `finally` branch and the stream's `cancel` callback so abort and natural end converge on the same code path.
|
||||
- Backpressure is detected by checking `controller.desiredSize`; values of `null` (no high-water mark) and positive integers are treated as "not full". Zero or negative trigger the overflow policy.
|
||||
- Heartbeat interval uses native `setInterval` by default; tests inject a fake to drive the timer deterministically (matching the FakeClock pattern used elsewhere ).
|
||||
@@ -0,0 +1,490 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import { createRingBuffer } from "../buffer/ring-buffer.js";
|
||||
import { SSE_CODES, SseError } from "../error-codes/error-codes.js";
|
||||
import { createInProcessFanout } from "../fanout/in-process.js";
|
||||
import { parseSseStream } from "../formatter/parse.js";
|
||||
import { createSseService } from "../services/service.js";
|
||||
import type { SseEvent } from "../types/types.js";
|
||||
import { applyOverflowPolicy, createSseRoute } from "./sse-handler.js";
|
||||
|
||||
function makeService(idGenerator?: () => string) {
|
||||
let n = 0;
|
||||
return createSseService({
|
||||
buffer: createRingBuffer(50),
|
||||
fanout: createInProcessFanout(),
|
||||
idGenerator: idGenerator ?? (() => `e-${++n}`),
|
||||
maxConnectionsPerPrincipal: 16,
|
||||
maxTotalConnections: 100,
|
||||
});
|
||||
}
|
||||
|
||||
async function readN(
|
||||
response: Response,
|
||||
n: number,
|
||||
abort: () => void
|
||||
): Promise<SseEvent[]> {
|
||||
const body = response.body;
|
||||
if (body === null) throw new Error("no body");
|
||||
const reader = body.getReader();
|
||||
const out: SseEvent[] = [];
|
||||
for await (const event of parseSseStream(reader)) {
|
||||
out.push(event);
|
||||
if (out.length >= n) break;
|
||||
}
|
||||
abort();
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
/* already cancelled */
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("createSseRoute", () => {
|
||||
it("streams framed events to a real fetch client", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const app = new Hono();
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(service, {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
queueHighWaterMark: 64,
|
||||
overflowPolicy: "drop-oldest",
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const res = await app.request(
|
||||
new Request("http://t/sse/notifications", {
|
||||
signal: controller.signal,
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toContain("text/event-stream");
|
||||
expect(res.headers.get("cache-control")).toContain("no-cache");
|
||||
expect(res.headers.get("x-accel-buffering")).toBe("no");
|
||||
// give Hono one tick to install the read stream
|
||||
setTimeout(() => {
|
||||
service.broadcast("notifications", { data: { n: 1 } });
|
||||
service.broadcast("notifications", { data: { n: 2 } });
|
||||
service.broadcast("notifications", { data: { n: 3 } });
|
||||
}, 10);
|
||||
const events = await readN(res, 3, () => controller.abort());
|
||||
expect(events.map((e) => JSON.parse(e.data as string).n)).toEqual([
|
||||
1, 2, 3,
|
||||
]);
|
||||
await service.close();
|
||||
});
|
||||
|
||||
it("Last-Event-ID resumes from the ring buffer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
service.broadcast("c", { data: { n: 1 } });
|
||||
service.broadcast("c", { data: { n: 2 } });
|
||||
service.broadcast("c", { data: { n: 3 } });
|
||||
const app = new Hono();
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(service, {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
queueHighWaterMark: 64,
|
||||
overflowPolicy: "drop-oldest",
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const res = await app.request(
|
||||
new Request("http://t/sse/c", {
|
||||
headers: { "Last-Event-ID": "e-2" },
|
||||
signal: controller.signal,
|
||||
})
|
||||
);
|
||||
const events = await readN(res, 1, () => controller.abort());
|
||||
expect(JSON.parse(events[0]?.data as string).n).toBe(3);
|
||||
await service.close();
|
||||
});
|
||||
|
||||
it("writeFrame is a no-op after the controller is closed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const app = new Hono();
|
||||
let fire: (() => void) | undefined;
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(service, {
|
||||
heartbeatIntervalMs: 30_000,
|
||||
queueHighWaterMark: 64,
|
||||
overflowPolicy: "drop-oldest",
|
||||
setIntervalImpl: (fn) => {
|
||||
// Capture but do not auto-fire. Tests fire below.
|
||||
fire = fn;
|
||||
return { mock: true };
|
||||
},
|
||||
clearIntervalImpl: () => undefined,
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const res = await app.request(
|
||||
new Request("http://t/sse/c", { signal: controller.signal })
|
||||
);
|
||||
// Wait for stream install, then cancel.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await res.body?.cancel();
|
||||
controller.abort();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
// After cleanup, controllerRef is null — firing the heartbeat
|
||||
// exercises the early-return guard in writeFrame.
|
||||
expect(() => fire?.()).not.toThrow();
|
||||
await service.close();
|
||||
});
|
||||
|
||||
it("heartbeats fire on the configured interval", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const app = new Hono();
|
||||
// FakeClock-driven timer: call back when we say.
|
||||
let fire: (() => void) | undefined;
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(service, {
|
||||
heartbeatIntervalMs: 30_000,
|
||||
queueHighWaterMark: 64,
|
||||
overflowPolicy: "drop-oldest",
|
||||
setIntervalImpl: (fn) => {
|
||||
fire = fn;
|
||||
return { mock: true };
|
||||
},
|
||||
clearIntervalImpl: () => {
|
||||
fire = undefined;
|
||||
},
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const res = await app.request(
|
||||
new Request("http://t/sse/c", { signal: controller.signal })
|
||||
);
|
||||
const body = res.body;
|
||||
if (body === null) throw new Error("no body");
|
||||
const reader = body.getReader();
|
||||
// wait for stream installation
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
fire?.();
|
||||
const decoder = new TextDecoder();
|
||||
const chunk = await reader.read();
|
||||
const text = decoder.decode(chunk.value ?? new Uint8Array());
|
||||
expect(text).toContain(": heartbeat");
|
||||
controller.abort();
|
||||
await reader.cancel().catch(() => undefined);
|
||||
await service.close();
|
||||
});
|
||||
|
||||
it("abort signal closes the subscription", { timeout: 5000 }, async () => {
|
||||
const service = makeService();
|
||||
const app = new Hono();
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(service, {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
queueHighWaterMark: 64,
|
||||
overflowPolicy: "drop-oldest",
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const res = await app.request(
|
||||
new Request("http://t/sse/c", { signal: controller.signal })
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(service.stats().activeConnections).toBe(1);
|
||||
await res.body?.cancel();
|
||||
controller.abort();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(service.stats().activeConnections).toBe(0);
|
||||
});
|
||||
|
||||
it("missing :channel throws SSE_CHANNEL_REQUIRED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const app = new Hono();
|
||||
app.get(
|
||||
"/sse",
|
||||
createSseRoute(service, {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
queueHighWaterMark: 64,
|
||||
overflowPolicy: "drop-oldest",
|
||||
})
|
||||
);
|
||||
app.onError((err, c) => {
|
||||
if (
|
||||
err instanceof SseError &&
|
||||
err.code === SSE_CODES.CHANNEL_REQUIRED
|
||||
) {
|
||||
return c.text("channel required", 400);
|
||||
}
|
||||
return c.text("oops", 500);
|
||||
});
|
||||
const res = await app.request(new Request("http://t/sse"));
|
||||
expect(res.status).toBe(400);
|
||||
await service.close();
|
||||
});
|
||||
|
||||
it("requirePrincipal: true raises SSE_UNAUTHENTICATED when no principal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const app = new Hono();
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(service, {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
queueHighWaterMark: 64,
|
||||
overflowPolicy: "drop-oldest",
|
||||
resolvePrincipalId: () => undefined,
|
||||
requirePrincipal: true,
|
||||
})
|
||||
);
|
||||
app.onError((err, c) => {
|
||||
if (
|
||||
err instanceof SseError &&
|
||||
err.code === SSE_CODES.UNAUTHENTICATED
|
||||
) {
|
||||
return c.text("unauth", 401);
|
||||
}
|
||||
return c.text("oops", 500);
|
||||
});
|
||||
const res = await app.request(new Request("http://t/sse/c"));
|
||||
expect(res.status).toBe(401);
|
||||
await service.close();
|
||||
});
|
||||
|
||||
it("resolvePrincipalId is invoked once per subscription", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const app = new Hono();
|
||||
let calls = 0;
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(service, {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
queueHighWaterMark: 64,
|
||||
overflowPolicy: "drop-oldest",
|
||||
resolvePrincipalId: () => {
|
||||
calls++;
|
||||
return "u1";
|
||||
},
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const res = await app.request(
|
||||
new Request("http://t/sse/c", { signal: controller.signal })
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(calls).toBe(1);
|
||||
controller.abort();
|
||||
await res.body?.cancel();
|
||||
await service.close();
|
||||
});
|
||||
|
||||
it("drop-oldest evicts the oldest queued event under backpressure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const app = new Hono();
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(service, {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
queueHighWaterMark: 1,
|
||||
overflowPolicy: "drop-oldest",
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const res = await app.request(
|
||||
new Request("http://t/sse/c", { signal: controller.signal })
|
||||
);
|
||||
const body = res.body;
|
||||
if (body === null) throw new Error("no body");
|
||||
// do not read — backlog grows.
|
||||
// Burst broadcasts to overflow the queue.
|
||||
for (let i = 0; i < 50; i++) {
|
||||
service.broadcast("c", { data: { i } });
|
||||
}
|
||||
// allow microtask queue to flush
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
controller.abort();
|
||||
await body.cancel().catch(() => undefined);
|
||||
await service.close();
|
||||
// No assertion on counter value: overflow counter is asserted in
|
||||
// the OTel test. Here we assert the handler did not crash under
|
||||
// burst load.
|
||||
expect(service.stats().activeConnections).toBe(0);
|
||||
});
|
||||
|
||||
it("backlog overflow triggers drop-oldest policy", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const app = new Hono();
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(service, {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
queueHighWaterMark: 1,
|
||||
overflowPolicy: "drop-oldest",
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const res = await app.request(
|
||||
new Request("http://t/sse/c", { signal: controller.signal })
|
||||
);
|
||||
const body = res.body;
|
||||
if (body === null) throw new Error("no body");
|
||||
// Allow start() to install.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
// Now burst broadcasts — the for-await reader will deliver
|
||||
// them to writeFrame faster than pull() can drain.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
service.broadcast("c", { data: { i } });
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
controller.abort();
|
||||
await body.cancel().catch(() => undefined);
|
||||
await service.close();
|
||||
expect(service.stats().activeConnections).toBe(0);
|
||||
});
|
||||
|
||||
it("backlog overflow triggers drop-newest policy", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const app = new Hono();
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(service, {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
queueHighWaterMark: 1,
|
||||
overflowPolicy: "drop-newest",
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const res = await app.request(
|
||||
new Request("http://t/sse/c", { signal: controller.signal })
|
||||
);
|
||||
const body = res.body;
|
||||
if (body === null) throw new Error("no body");
|
||||
const reader = body.getReader();
|
||||
// Read once to install the consumer and trigger start().
|
||||
const firstRead = reader.read();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
for (let i = 0; i < 20; i++) {
|
||||
service.broadcast("c", { data: { i } });
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await firstRead.catch(() => undefined);
|
||||
controller.abort();
|
||||
await reader.cancel().catch(() => undefined);
|
||||
await service.close();
|
||||
expect(service.stats().activeConnections).toBe(0);
|
||||
});
|
||||
|
||||
it("backlog drains across multiple reads", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const app = new Hono();
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(service, {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
queueHighWaterMark: 1024,
|
||||
overflowPolicy: "drop-oldest",
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const res = await app.request(
|
||||
new Request("http://t/sse/c", { signal: controller.signal })
|
||||
);
|
||||
const body = res.body;
|
||||
if (body === null) throw new Error("no body");
|
||||
// Burst broadcasts so several land in the backlog.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
service.broadcast("c", { data: { i } });
|
||||
}
|
||||
// Read enough events to exercise the pull-driven drain.
|
||||
const collected = [] as string[];
|
||||
for await (const event of parseSseStream(body.getReader())) {
|
||||
collected.push(event.id);
|
||||
if (collected.length >= 10) break;
|
||||
}
|
||||
expect(collected.length).toBe(10);
|
||||
controller.abort();
|
||||
await service.close();
|
||||
});
|
||||
|
||||
it("drop-newest discards incoming events under backpressure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const app = new Hono();
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(service, {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
queueHighWaterMark: 1,
|
||||
overflowPolicy: "drop-newest",
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const res = await app.request(
|
||||
new Request("http://t/sse/c", { signal: controller.signal })
|
||||
);
|
||||
const body = res.body;
|
||||
if (body === null) throw new Error("no body");
|
||||
for (let i = 0; i < 50; i++) {
|
||||
service.broadcast("c", { data: { i } });
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
controller.abort();
|
||||
await body.cancel().catch(() => undefined);
|
||||
await service.close();
|
||||
expect(service.stats().activeConnections).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOverflowPolicy", () => {
|
||||
const make = (n: number): Uint8Array[] =>
|
||||
Array.from({ length: n }, (_, i) => new Uint8Array([i]));
|
||||
|
||||
it("returns false when backlog is at or below the cap", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const b = make(2);
|
||||
expect(applyOverflowPolicy(b, 2, "drop-oldest")).toBe(false);
|
||||
expect(b.length).toBe(2);
|
||||
});
|
||||
|
||||
it("drop-oldest removes the head and returns false", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const b = make(3);
|
||||
const head = b[0];
|
||||
expect(applyOverflowPolicy(b, 2, "drop-oldest")).toBe(false);
|
||||
expect(b.length).toBe(2);
|
||||
expect(b[0]).not.toBe(head);
|
||||
});
|
||||
|
||||
it("drop-newest removes the tail and returns true", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const b = make(3);
|
||||
const tail = b[b.length - 1];
|
||||
expect(applyOverflowPolicy(b, 2, "drop-newest")).toBe(true);
|
||||
expect(b.length).toBe(2);
|
||||
expect(b[b.length - 1]).not.toBe(tail);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { Context, Handler } from "hono";
|
||||
import { SSE_CODES, SseError } from "../error-codes/error-codes.js";
|
||||
import { formatHeartbeatFrame, formatSseFrame } from "../formatter/format.js";
|
||||
import { getSseInstruments } from "../otel/instruments.js";
|
||||
import type { SseService } from "../services/service.js";
|
||||
import type { OverflowPolicy } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Apply the configured overflow policy to the supplied backlog when
|
||||
* it has just exceeded the high-water mark. Returns `true` when the
|
||||
* incoming (newest) frame was discarded so the caller can short-circuit
|
||||
* the post-write bookkeeping. Exported so unit tests can pin every
|
||||
* branch without orchestrating a Hono request lifecycle.
|
||||
*/
|
||||
export function applyOverflowPolicy(
|
||||
backlog: Uint8Array[],
|
||||
queueHighWaterMark: number,
|
||||
policy: OverflowPolicy
|
||||
): boolean {
|
||||
if (backlog.length <= queueHighWaterMark) return false;
|
||||
const instruments = getSseInstruments();
|
||||
if (policy === "drop-newest") {
|
||||
backlog.pop();
|
||||
instruments.queueOverflowTotal.add(1, {
|
||||
policy: "drop-newest",
|
||||
code: SSE_CODES.QUEUE_OVERFLOW,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
backlog.shift();
|
||||
instruments.queueOverflowTotal.add(1, {
|
||||
policy: "drop-oldest",
|
||||
code: SSE_CODES.QUEUE_OVERFLOW,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for `createSseRoute`. The handler reads `:channel` from the
|
||||
* request path; `resolvePrincipalId` lets consumers wire any auth
|
||||
* stack (e.g. `@suaveplan/api-plugin-auth`'s `c.get("user")?.id`)
|
||||
* without this package depending on it.
|
||||
*/
|
||||
export interface CreateSseRouteOptions {
|
||||
readonly heartbeatIntervalMs: number;
|
||||
readonly queueHighWaterMark: number;
|
||||
readonly overflowPolicy: OverflowPolicy;
|
||||
readonly resolvePrincipalId?: (c: Context) => string | undefined;
|
||||
/**
|
||||
* When `true`, the handler raises `SseError(SSE_UNAUTHENTICATED)`
|
||||
* if `resolvePrincipalId` returns `undefined`. The default
|
||||
* (`false`) allows anonymous connections so the route may be
|
||||
* composed with any external auth middleware.
|
||||
*/
|
||||
readonly requirePrincipal?: boolean;
|
||||
readonly setIntervalImpl?: (fn: () => void, ms: number) => unknown;
|
||||
readonly clearIntervalImpl?: (handle: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hono handler factory wrapping the SSE service. Returns a Response
|
||||
* with a `ReadableStream<Uint8Array>` body so any standards-compliant
|
||||
* runtime (Bun, Node 18+, Cloudflare Workers, Deno) streams without
|
||||
* an adapter shim.
|
||||
*
|
||||
* Behavior:
|
||||
* - `:channel` path parameter is required; missing throws
|
||||
* `SseError(SSE_CHANNEL_REQUIRED)`.
|
||||
* - `Last-Event-ID` request header (browser-replay convention) is
|
||||
* forwarded to `service.subscribe` for ring-buffer replay.
|
||||
* - The request's `AbortSignal` is threaded through to the service
|
||||
* so client disconnect unsubscribes deterministically.
|
||||
* - Every `heartbeatIntervalMs` writes a `: heartbeat\n\n` comment
|
||||
* frame; absent traffic, this keeps proxies + load balancers from
|
||||
* silently closing the connection.
|
||||
* - Backpressure: when `controller.desiredSize === 0` and the
|
||||
* in-memory backlog exceeds `queueHighWaterMark`, applies
|
||||
* `overflowPolicy` and increments `sse.queue_overflow_total`.
|
||||
*/
|
||||
export function createSseRoute(
|
||||
service: SseService,
|
||||
options: CreateSseRouteOptions
|
||||
): Handler {
|
||||
const setIntervalFn =
|
||||
options.setIntervalImpl ?? ((fn, ms) => setInterval(fn, ms) as unknown);
|
||||
const clearIntervalFn =
|
||||
options.clearIntervalImpl ??
|
||||
((h) => clearInterval(h as ReturnType<typeof setInterval>));
|
||||
|
||||
return async function sseHandler(c) {
|
||||
const channel = c.req.param("channel");
|
||||
if (channel === undefined || channel === "") {
|
||||
throw new SseError(
|
||||
SSE_CODES.CHANNEL_REQUIRED,
|
||||
"SSE handler requires a :channel path parameter"
|
||||
);
|
||||
}
|
||||
const lastEventId =
|
||||
c.req.header("last-event-id") ?? c.req.header("Last-Event-ID");
|
||||
const principalId = options.resolvePrincipalId?.(c);
|
||||
if (options.requirePrincipal === true && principalId === undefined) {
|
||||
throw new SseError(
|
||||
SSE_CODES.UNAUTHENTICATED,
|
||||
"SSE handler requires an authenticated principal",
|
||||
{ context: { statusCode: 401 } }
|
||||
);
|
||||
}
|
||||
const handle = service.subscribe({
|
||||
channel,
|
||||
...(lastEventId !== undefined ? { lastEventId } : {}),
|
||||
...(principalId !== undefined ? { principalId } : {}),
|
||||
signal: c.req.raw.signal,
|
||||
});
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const instruments = getSseInstruments();
|
||||
const started = performance.now();
|
||||
let heartbeatHandle: unknown;
|
||||
let controllerRef: ReadableStreamDefaultController<Uint8Array> | null =
|
||||
null;
|
||||
const backlog: Uint8Array[] = [];
|
||||
|
||||
const writeFrame = (frame: string): void => {
|
||||
if (controllerRef === null) return;
|
||||
const bytes = encoder.encode(frame);
|
||||
backlog.push(bytes);
|
||||
const evictedNewest = applyOverflowPolicy(
|
||||
backlog,
|
||||
options.queueHighWaterMark,
|
||||
options.overflowPolicy
|
||||
);
|
||||
drainInto(controllerRef);
|
||||
if (!evictedNewest) {
|
||||
instruments.messageBytesTotal.add(bytes.byteLength);
|
||||
}
|
||||
};
|
||||
|
||||
// Drain queued bytes into the supplied controller while it
|
||||
// signals capacity. Called from both writeFrame (which has
|
||||
// already verified controllerRef is non-null) and the
|
||||
// ReadableStream pull callback (which fires only while the
|
||||
// controller is open).
|
||||
const drainInto = (
|
||||
ctrl: ReadableStreamDefaultController<Uint8Array>
|
||||
): void => {
|
||||
while (
|
||||
backlog.length > 0 &&
|
||||
(ctrl.desiredSize === null || ctrl.desiredSize > 0)
|
||||
) {
|
||||
const next = backlog.shift() as Uint8Array;
|
||||
ctrl.enqueue(next);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = async (): Promise<void> => {
|
||||
controllerRef = null;
|
||||
backlog.length = 0;
|
||||
if (heartbeatHandle !== undefined) {
|
||||
clearIntervalFn(heartbeatHandle);
|
||||
heartbeatHandle = undefined;
|
||||
}
|
||||
await handle.close();
|
||||
const duration = performance.now() - started;
|
||||
instruments.connectionDurationMs.record(duration, { channel });
|
||||
};
|
||||
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start: (controller) => {
|
||||
controllerRef = controller;
|
||||
heartbeatHandle = setIntervalFn(() => {
|
||||
writeFrame(formatHeartbeatFrame());
|
||||
instruments.heartbeatsTotal.add(1);
|
||||
}, options.heartbeatIntervalMs);
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const event of handle.events) {
|
||||
writeFrame(formatSseFrame(event));
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
await cleanup();
|
||||
}
|
||||
})();
|
||||
},
|
||||
pull: (controller) => {
|
||||
drainInto(controller);
|
||||
},
|
||||
cancel: async () => {
|
||||
await cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
return c.body(body, 200, {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
};
|
||||
}
|
||||
5
packages/api-stack/api/api-plugin-sse/src/otel/index.ts
Normal file
5
packages/api-stack/api/api-plugin-sse/src/otel/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getSseInstruments,
|
||||
resetSseInstruments,
|
||||
type SseInstruments,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
# instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the canonical OpenTelemetry instrument bundle emitted by the SSE plugin. Counters and a histogram cover the four observability questions operators ask: "how many connections are live?", "how busy is each channel?", "are heartbeats firing?", and "are we shedding load under backpressure?". Instruments are lazily resolved against the global `MeterProvider` and cached so every production call site shares the same identity (a cold-start operator can install a fresh provider in tests via `resetSseInstruments()`).
|
||||
|
||||
## Features
|
||||
|
||||
- `sse.active_connections` — UpDownCounter; +1 on subscribe, −1 on close. Carries `channel` attribute so dashboards can show per-channel concurrency.
|
||||
- `sse.broadcasts_total` — counter incremented on every `service.broadcast` call, partitioned by `channel`.
|
||||
- `sse.heartbeats_total` — counter incremented every time the handler writes a `: heartbeat\n\n` comment frame.
|
||||
- `sse.message_bytes_total` — total bytes written across every framed event (helps capacity planning).
|
||||
- `sse.queue_overflow_total` — counter incremented when the handler drops events under backpressure; tags `policy` and the originating `code: SSE.QUEUE_OVERFLOW`.
|
||||
- `sse.connection_duration_ms` — histogram of per-connection lifetime in milliseconds, recorded on close.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getSseInstruments } from "@suaveplan/api-plugin-sse/otel/instruments";
|
||||
|
||||
const instruments = getSseInstruments();
|
||||
instruments.broadcastsTotal.add(1, { channel: "notifications" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
InMemoryMetricExporter,
|
||||
AggregationTemporality,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
getSseInstruments,
|
||||
resetSseInstruments,
|
||||
} from "@suaveplan/api-plugin-sse/otel/instruments";
|
||||
|
||||
// Install a fresh provider in a test, then rebind the cache.
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetSseInstruments();
|
||||
|
||||
const i = getSseInstruments();
|
||||
i.activeConnections.add(1, { channel: "c" });
|
||||
await provider.forceFlush();
|
||||
console.log(exporter.getMetrics());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SseInstruments` — the bundle: `activeConnections: UpDownCounter`, `broadcastsTotal: Counter`, `heartbeatsTotal: Counter`, `messageBytesTotal: Counter`, `queueOverflowTotal: Counter`, `connectionDurationMs: Histogram`.
|
||||
- `getSseInstruments(): SseInstruments` — lazily resolve the bundle against the current global `MeterProvider`; cached so subsequent calls are O(1).
|
||||
- `resetSseInstruments(): void` — drop the cache so the next `getSseInstruments` rebinds. Tests call this after installing a fresh provider.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Instrument names use the canonical OpenTelemetry semantic conventions style (`sse.snake_case_name`). They are public — renaming is a breaking change.
|
||||
- The bundle is module-level cached. A second `MeterProvider.setGlobalMeterProvider` call after `getSseInstruments` has been invoked will NOT take effect until `resetSseInstruments` runs.
|
||||
- `connection_duration_ms` is recorded with the `channel` attribute so per-channel SLO dashboards work without a separate aggregator.
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import { createRingBuffer } from "../buffer/ring-buffer.js";
|
||||
import { createInProcessFanout } from "../fanout/in-process.js";
|
||||
import { createSseService } from "../services/service.js";
|
||||
import { getSseInstruments, resetSseInstruments } from "./instruments.js";
|
||||
|
||||
function findCounter(
|
||||
name: string,
|
||||
metrics: Awaited<ReturnType<typeof flushTestMetrics>>
|
||||
): { value: number; attributes: Record<string, unknown> }[] {
|
||||
const out: { value: number; attributes: Record<string, unknown> }[] = [];
|
||||
for (const rm of metrics) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) {
|
||||
if (m.descriptor.name !== name) continue;
|
||||
for (const dp of m.dataPoints) {
|
||||
out.push({
|
||||
value: Number(dp.value),
|
||||
attributes: (dp.attributes ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("OTel SSE instruments", () => {
|
||||
beforeEach(() => {
|
||||
resetSseInstruments();
|
||||
});
|
||||
|
||||
it("getSseInstruments caches the bundle", { timeout: 5000 }, () => {
|
||||
const a = getSseInstruments();
|
||||
const b = getSseInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("resetSseInstruments forces a re-bind", { timeout: 5000 }, () => {
|
||||
const a = getSseInstruments();
|
||||
resetSseInstruments();
|
||||
const b = getSseInstruments();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("subscribe + close emits active_connections", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await flushTestMetrics();
|
||||
const buffer = createRingBuffer(10);
|
||||
const fanout = createInProcessFanout();
|
||||
let n = 0;
|
||||
const service = createSseService({
|
||||
buffer,
|
||||
fanout,
|
||||
idGenerator: () => `id-${++n}`,
|
||||
maxConnectionsPerPrincipal: 16,
|
||||
maxTotalConnections: 100,
|
||||
});
|
||||
const handle = service.subscribe({ channel: "c" });
|
||||
await handle.close();
|
||||
const collected = await flushTestMetrics();
|
||||
const points = findCounter("sse.active_connections", collected);
|
||||
expect(points.length).toBeGreaterThan(0);
|
||||
const total = points.reduce((s, p) => s + p.value, 0);
|
||||
// +1 then -1 → 0
|
||||
expect(total).toBe(0);
|
||||
});
|
||||
|
||||
it("broadcast emits broadcasts_total", { timeout: 5000 }, async () => {
|
||||
await flushTestMetrics();
|
||||
const buffer = createRingBuffer(10);
|
||||
const fanout = createInProcessFanout();
|
||||
let n = 0;
|
||||
const service = createSseService({
|
||||
buffer,
|
||||
fanout,
|
||||
idGenerator: () => `id-${++n}`,
|
||||
maxConnectionsPerPrincipal: 16,
|
||||
maxTotalConnections: 100,
|
||||
});
|
||||
service.broadcast("notifications", { data: 1 });
|
||||
const collected = await flushTestMetrics();
|
||||
const points = findCounter("sse.broadcasts_total", collected);
|
||||
const match = points.find(
|
||||
(p) => p.attributes.channel === "notifications"
|
||||
);
|
||||
expect(match?.value).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
type Counter,
|
||||
type Histogram,
|
||||
metrics,
|
||||
type UpDownCounter,
|
||||
} from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Bundle of OpenTelemetry instruments emitted by the SSE plugin.
|
||||
*
|
||||
* - `activeConnections` — UpDownCounter; +1 on subscribe, −1 on close.
|
||||
* - `broadcastsTotal{channel}` — incremented on every `broadcast` call.
|
||||
* - `heartbeatsTotal` — incremented on every heartbeat tick written.
|
||||
* - `messageBytesTotal` — total bytes written to clients across all
|
||||
* connections (framed payload size).
|
||||
* - `queueOverflowTotal{policy}` — incremented on every dropped event
|
||||
* under backpressure; `policy` is `"drop-oldest"` or `"drop-newest"`.
|
||||
* - `connectionDurationMs` — histogram of per-connection lifetime.
|
||||
*/
|
||||
export interface SseInstruments {
|
||||
readonly activeConnections: UpDownCounter;
|
||||
readonly broadcastsTotal: Counter;
|
||||
readonly heartbeatsTotal: Counter;
|
||||
readonly messageBytesTotal: Counter;
|
||||
readonly queueOverflowTotal: Counter;
|
||||
readonly connectionDurationMs: Histogram;
|
||||
}
|
||||
|
||||
let cached: SseInstruments | undefined;
|
||||
|
||||
/**
|
||||
* Lazily build (and cache) the SSE instruments bundle against the
|
||||
* current global `MeterProvider`. Call `resetSseInstruments()` in
|
||||
* test setup to force a re-bind against a freshly-installed provider.
|
||||
*/
|
||||
export function getSseInstruments(): SseInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/api-plugin-sse");
|
||||
cached = {
|
||||
activeConnections: meter.createUpDownCounter("sse.active_connections", {
|
||||
description: "Currently-open SSE connections (sum across channels)",
|
||||
}),
|
||||
broadcastsTotal: meter.createCounter("sse.broadcasts_total", {
|
||||
description:
|
||||
"Total broadcast calls, partitioned by channel attribute",
|
||||
}),
|
||||
heartbeatsTotal: meter.createCounter("sse.heartbeats_total", {
|
||||
description: "Total heartbeat comment frames written",
|
||||
}),
|
||||
messageBytesTotal: meter.createCounter("sse.message_bytes_total", {
|
||||
description:
|
||||
"Total bytes written to SSE clients across all connections",
|
||||
unit: "By",
|
||||
}),
|
||||
queueOverflowTotal: meter.createCounter("sse.queue_overflow_total", {
|
||||
description:
|
||||
"Total events dropped under backpressure, by overflow policy",
|
||||
}),
|
||||
connectionDurationMs: meter.createHistogram(
|
||||
"sse.connection_duration_ms",
|
||||
{
|
||||
description: "Lifetime of an SSE connection in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Drop the cached instrument bundle so the next call re-binds. */
|
||||
export function resetSseInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createSsePlugin,
|
||||
pickDefaultIdGenerator,
|
||||
} from "./plugin.js";
|
||||
153
packages/api-stack/api/api-plugin-sse/src/plugin/on-init.test.ts
Normal file
153
packages/api-stack/api/api-plugin-sse/src/plugin/on-init.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
|
||||
import { Hono } from "hono";
|
||||
import { SSE_CODES, type SseError } from "../error-codes/error-codes.js";
|
||||
import type { SseService } from "../services/service.js";
|
||||
import type { SsePluginConfig } from "../types/types.js";
|
||||
import { createSsePlugin } from "./plugin.js";
|
||||
|
||||
const noopLogger: LoggerLike = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
const noopEventBus: EventBusLike = {
|
||||
emit: () => undefined,
|
||||
on: () => () => undefined,
|
||||
off: () => undefined,
|
||||
once: () => () => undefined,
|
||||
} as unknown as EventBusLike;
|
||||
|
||||
function makeCtx(
|
||||
services: Map<string, unknown>,
|
||||
config: SsePluginConfig
|
||||
): ReturnType<typeof createPluginContext<SsePluginConfig>> {
|
||||
return createPluginContext<SsePluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: noopEventBus,
|
||||
signal: new AbortController().signal,
|
||||
config,
|
||||
services,
|
||||
});
|
||||
}
|
||||
|
||||
describe("plugin.onInit lifecycle", () => {
|
||||
it("missing app raises SSE_SERVICE_NOT_AVAILABLE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createSsePlugin({});
|
||||
const services = new Map<string, unknown>();
|
||||
try {
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as SsePluginConfig)
|
||||
);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SseError).code).toBe(
|
||||
SSE_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("registers sseService and mounts default route", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createSsePlugin({ heartbeatIntervalMs: 5_000 });
|
||||
const app = new Hono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as SsePluginConfig)
|
||||
);
|
||||
const service = services.get("sseService") as SseService | undefined;
|
||||
expect(service).toBeDefined();
|
||||
expect(typeof service?.broadcast).toBe("function");
|
||||
// hit the route to verify content-type — abort immediately
|
||||
const controller = new AbortController();
|
||||
const res = await app.request(
|
||||
new Request("http://test.local/sse/notifications", {
|
||||
signal: controller.signal,
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toContain("text/event-stream");
|
||||
controller.abort();
|
||||
await service?.close();
|
||||
});
|
||||
|
||||
it("attachDefaultRoute false skips route registration", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createSsePlugin({ attachDefaultRoute: false });
|
||||
const app = new Hono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as SsePluginConfig)
|
||||
);
|
||||
const res = await app.request(
|
||||
new Request("http://test.local/sse/anything")
|
||||
);
|
||||
// No route registered → Hono returns 404
|
||||
expect(res.status).toBe(404);
|
||||
const service = services.get("sseService") as SseService | undefined;
|
||||
await service?.close();
|
||||
});
|
||||
|
||||
it("routePrefix === '/' mounts at /:channel", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createSsePlugin({ routePrefix: "/" });
|
||||
const app = new Hono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as SsePluginConfig)
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const res = await app.request(
|
||||
new Request("http://test.local/foo", { signal: controller.signal })
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
controller.abort();
|
||||
const service = services.get("sseService") as SseService | undefined;
|
||||
await service?.close();
|
||||
});
|
||||
|
||||
it("onClose terminates active connections", { timeout: 5000 }, async () => {
|
||||
const plugin = createSsePlugin({});
|
||||
const app = new Hono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as SsePluginConfig)
|
||||
);
|
||||
const service = services.get("sseService") as SseService;
|
||||
service.subscribe({ channel: "c" });
|
||||
expect(service.stats().activeConnections).toBe(1);
|
||||
await plugin.onClose?.(
|
||||
makeCtx(services, plugin.defaults as SsePluginConfig)
|
||||
);
|
||||
expect(service.stats().activeConnections).toBe(0);
|
||||
});
|
||||
|
||||
it("onClose is idempotent", { timeout: 5000 }, async () => {
|
||||
const plugin = createSsePlugin({});
|
||||
const app = new Hono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as SsePluginConfig)
|
||||
);
|
||||
await plugin.onClose?.(
|
||||
makeCtx(services, plugin.defaults as SsePluginConfig)
|
||||
);
|
||||
// second call should be a no-op
|
||||
await plugin.onClose?.(
|
||||
makeCtx(services, plugin.defaults as SsePluginConfig)
|
||||
);
|
||||
});
|
||||
});
|
||||
73
packages/api-stack/api/api-plugin-sse/src/plugin/plugin.md
Normal file
73
packages/api-stack/api/api-plugin-sse/src/plugin/plugin.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# plugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Factory producing a typed `PluginDescriptor` for the SSE plugin. Wraps the `@suaveplan/api-plugin` `definePlugin` foundation with Zod-validated options at the boundary and a 6-step `onInit` lifecycle that resolves the host's Hono `app`, constructs the ring buffer / fanout / service triple, registers `sseService` in the host `ServiceRegistry`, and mounts the default streaming route. Mirrors the 3-way plug-and-play contract pioneered by `@suaveplan/api-plugin-auth` so consumers compose any combination of `@suaveplan/api-plugin-*` packages with a single `definePlugin` array in their app boot.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated options at the factory boundary — invalid input throws `SseError(SSE_INVALID_INPUT)` carrying the `ZodError` as `cause`.
|
||||
- 6-step `onInit`: (1) resolve `app` from registry (or throw `SSE_SERVICE_NOT_AVAILABLE`), (2) build ring buffer + in-process fanout, (3) build `SseService`, (4) register `sseService`, (5) optionally mount `GET <routePrefix>/:channel`, (6) defer to descriptor's `onClose` for teardown.
|
||||
- `onClose` terminates every open connection by calling `service.close()`; idempotent on second call.
|
||||
- `attachDefaultRoute: false` skips route registration so consumers can mount their own handler under any path / middleware stack.
|
||||
- `routePrefix: "/"` mounts at `/:channel` so callers can use SSE as their entire app surface when desired.
|
||||
- Defaults reflected in `descriptor.defaults` so a downstream introspection tool sees every resolved value without re-parsing.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createSsePlugin } from "@suaveplan/api-plugin-sse/plugin/plugin";
|
||||
|
||||
const plugin = createSsePlugin({
|
||||
heartbeatIntervalMs: 15_000,
|
||||
maxConnectionsPerPrincipal: 8,
|
||||
});
|
||||
// Pass to your @suaveplan/api registry along with other plugins.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createSsePlugin } from "@suaveplan/api-plugin-sse/plugin/plugin";
|
||||
import { createPluginRegistry } from "@suaveplan/api-plugin";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const app = new Hono();
|
||||
const registry = createPluginRegistry({
|
||||
seedServices: { app },
|
||||
});
|
||||
|
||||
await registry.register(
|
||||
createSsePlugin({
|
||||
heartbeatIntervalMs: 15_000,
|
||||
retentionPerChannel: 500,
|
||||
maxConnectionsPerPrincipal: 4,
|
||||
maxTotalConnections: 5_000,
|
||||
queueHighWaterMark: 512,
|
||||
overflowPolicy: "drop-newest",
|
||||
attachDefaultRoute: false, // mount manually under /v1/events/:channel
|
||||
})
|
||||
);
|
||||
await registry.init();
|
||||
|
||||
const sse = registry.getService("sseService");
|
||||
sse?.broadcast("welcome", { data: { message: "boot complete" } });
|
||||
|
||||
// Graceful shutdown closes every open SSE connection.
|
||||
process.on("SIGTERM", () => registry.shutdown());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createSsePlugin(options?: SsePluginOptions): PluginDescriptor<SsePluginConfig>` — factory. `options` is optional; defaults are applied by `SsePluginOptionsSchema`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The descriptor's `onInit` captures its `service` instance in a closure so `onClose` can call `service.close()` without re-resolving the `ServiceRegistry` (the registry instance is the same across phases, but the closure is more robust to lifecycle edge cases).
|
||||
- Default `idGenerator` uses `globalThis.crypto.randomUUID()` when available; falls back to a timestamp + random suffix on legacy runtimes. The plugin layer is the canonical place to apply this default — `SseService` itself takes the generator verbatim.
|
||||
- `routePrefix === "/"` is special-cased to avoid a double-slash in the mount path; the plugin uses `app.get("/:channel", handler)` directly in that case.
|
||||
- `onClose` checks `state.service` for `undefined` so it is safe to call before `onInit` has completed (e.g. when `onInit` throws and the registry sweeps up).
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SSE_CODES, SseError } from "../error-codes/error-codes.js";
|
||||
import { createSsePlugin, pickDefaultIdGenerator } from "./plugin.js";
|
||||
|
||||
describe("createSsePlugin", () => {
|
||||
it("createSsePlugin returns a valid PluginDescriptor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const desc = createSsePlugin({});
|
||||
expect(desc.name).toBe("sse");
|
||||
expect(desc.version).toBe("0.1.0");
|
||||
expect(typeof desc.onInit).toBe("function");
|
||||
expect(typeof desc.onClose).toBe("function");
|
||||
});
|
||||
|
||||
it("invalid options raise SSE_INVALID_INPUT", { timeout: 5000 }, () => {
|
||||
try {
|
||||
createSsePlugin({ heartbeatIntervalMs: -1 } as never);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SseError);
|
||||
expect((err as SseError).code).toBe(SSE_CODES.INVALID_INPUT);
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults applied when no options supplied", { timeout: 5000 }, () => {
|
||||
const desc = createSsePlugin();
|
||||
// descriptor.defaults reflects the resolved config
|
||||
expect(
|
||||
(desc.defaults as Record<string, unknown>)?.heartbeatIntervalMs
|
||||
).toBe(30_000);
|
||||
});
|
||||
|
||||
it("descriptor declares sse/streaming/realtime tags", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const desc = createSsePlugin();
|
||||
expect(desc.tags).toContain("sse");
|
||||
expect(desc.tags).toContain("streaming");
|
||||
});
|
||||
|
||||
it("pickDefaultIdGenerator returns randomUUID-backed generator when available", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const calls: string[] = [];
|
||||
const stub = { randomUUID: () => "stub-uuid" };
|
||||
const gen = pickDefaultIdGenerator(stub);
|
||||
calls.push(gen());
|
||||
expect(calls).toEqual(["stub-uuid"]);
|
||||
});
|
||||
|
||||
it("pickDefaultIdGenerator falls back when crypto is undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const gen = pickDefaultIdGenerator(undefined);
|
||||
const a = gen();
|
||||
const b = gen();
|
||||
expect(typeof a).toBe("string");
|
||||
expect(a.length).toBeGreaterThan(0);
|
||||
expect(b).not.toBe(a);
|
||||
});
|
||||
|
||||
it("pickDefaultIdGenerator falls back when randomUUID is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const gen = pickDefaultIdGenerator({});
|
||||
const a = gen();
|
||||
expect(typeof a).toBe("string");
|
||||
expect(a.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
133
packages/api-stack/api/api-plugin-sse/src/plugin/plugin.ts
Normal file
133
packages/api-stack/api/api-plugin-sse/src/plugin/plugin.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
definePlugin,
|
||||
type PluginContext,
|
||||
type PluginDescriptor,
|
||||
} from "@suaveplan/api-plugin";
|
||||
import type { Hono } from "hono";
|
||||
import { createRingBuffer } from "../buffer/ring-buffer.js";
|
||||
import { SSE_CODES, SseError } from "../error-codes/error-codes.js";
|
||||
import { createInProcessFanout } from "../fanout/in-process.js";
|
||||
import { createSseRoute } from "../middleware/sse-handler.js";
|
||||
import { SsePluginOptionsSchema } from "../schemas/options.js";
|
||||
import { createSseService, type SseService } from "../services/service.js";
|
||||
import type { SsePluginConfig, SsePluginOptions } from "../types/types.js";
|
||||
import "../registry/service-registry.js";
|
||||
|
||||
/**
|
||||
* Default id generator picker. Resolves to `globalThis.crypto.randomUUID`
|
||||
* when available (Node 19+, Bun, Deno, every modern browser); falls back
|
||||
* to a timestamp + random suffix on legacy runtimes that lack WebCrypto.
|
||||
* Exposed (non-public) so tests can exercise the fallback by overriding
|
||||
* the crypto getter.
|
||||
*/
|
||||
export function pickDefaultIdGenerator(
|
||||
cryptoLike: { randomUUID?: () => string } | undefined = globalThis.crypto
|
||||
): () => string {
|
||||
if (
|
||||
cryptoLike !== undefined &&
|
||||
typeof cryptoLike.randomUUID === "function"
|
||||
) {
|
||||
const fn = cryptoLike.randomUUID.bind(cryptoLike);
|
||||
return () => fn();
|
||||
}
|
||||
return () =>
|
||||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
interface SsePluginState {
|
||||
service: SseService | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory — produces the typed `PluginDescriptor` for the SSE plugin.
|
||||
*
|
||||
* Zod-validates `options` at the factory boundary so invalid input
|
||||
* fails fast with `SseError(SSE_INVALID_INPUT)` carrying the
|
||||
* `ZodError` as `cause`. The descriptor's `onInit` executes the
|
||||
* 6-step lifecycle (resolve `app` → build buffer/fanout → build
|
||||
* service → register service → mount default route → install close
|
||||
* handler). `onClose` calls `service.close()` to terminate every open
|
||||
* connection.
|
||||
*/
|
||||
export function createSsePlugin(
|
||||
options: SsePluginOptions = {}
|
||||
): PluginDescriptor<SsePluginConfig> {
|
||||
const parsed = SsePluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new SseError(
|
||||
SSE_CODES.INVALID_INPUT,
|
||||
"createSsePlugin received invalid options",
|
||||
{ cause: parsed.error, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const resolved = parsed.data as SsePluginConfig;
|
||||
const state: SsePluginState = { service: undefined };
|
||||
|
||||
return definePlugin<SsePluginConfig>({
|
||||
name: "sse",
|
||||
version: "0.1.0",
|
||||
description:
|
||||
"Server-Sent Events plugin for @suaveplan/api. Registers sseService, mounts a streamSSE route with heartbeat/abort/resume/backpressure.",
|
||||
defaults: resolved,
|
||||
tags: ["sse", "streaming", "realtime"],
|
||||
onInit: async (ctx) => runOnInit(ctx, resolved, state),
|
||||
onClose: async () => {
|
||||
if (state.service !== undefined) {
|
||||
await state.service.close();
|
||||
state.service = undefined;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function runOnInit(
|
||||
ctx: PluginContext<SsePluginConfig>,
|
||||
config: SsePluginConfig,
|
||||
state: SsePluginState
|
||||
): Promise<void> {
|
||||
// (1) Resolve app from ServiceRegistry.
|
||||
const app = ctx.getService("app") as Hono | undefined;
|
||||
if (app === undefined) {
|
||||
throw new SseError(
|
||||
SSE_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"sse plugin requires the `app` service in ServiceRegistry",
|
||||
{ context: { missing: ["app"], statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
|
||||
// (2) Build buffer + fanout.
|
||||
const buffer = createRingBuffer(config.retentionPerChannel);
|
||||
const fanout = createInProcessFanout();
|
||||
const idGenerator = config.idGenerator ?? pickDefaultIdGenerator();
|
||||
|
||||
// (3) Build the SseService.
|
||||
const service = createSseService({
|
||||
buffer,
|
||||
fanout,
|
||||
idGenerator,
|
||||
maxConnectionsPerPrincipal: config.maxConnectionsPerPrincipal,
|
||||
maxTotalConnections: config.maxTotalConnections,
|
||||
});
|
||||
state.service = service;
|
||||
|
||||
// (4) Register the sseService slot.
|
||||
ctx.registerService("sseService", service);
|
||||
|
||||
// (5) Mount the default route when enabled.
|
||||
if (config.attachDefaultRoute) {
|
||||
const handler = createSseRoute(service, {
|
||||
heartbeatIntervalMs: config.heartbeatIntervalMs,
|
||||
queueHighWaterMark: config.queueHighWaterMark,
|
||||
overflowPolicy: config.overflowPolicy,
|
||||
});
|
||||
const mountPath =
|
||||
config.routePrefix === "/"
|
||||
? "/:channel"
|
||||
: `${config.routePrefix}/:channel`;
|
||||
app.get(mountPath, handler);
|
||||
}
|
||||
|
||||
// (6) onClose is wired by the descriptor itself; no per-call
|
||||
// registration needed here.
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import "./service-registry.js";
|
||||
@@ -0,0 +1,60 @@
|
||||
# service-registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript module augmentation declaring the `sseService` slot on the `@suaveplan/api-plugin/service-registry` `ServiceRegistry` interface. Importing this module for its side effects makes `ctx.getService("sseService")` return `SseService | undefined` with no cast at the call site — the package root barrel imports it automatically so every consumer of `@suaveplan/api-plugin-sse` gets the typed slot.
|
||||
|
||||
## Features
|
||||
|
||||
- Side-effect-only augmentation — exports an empty `type` so the file participates in the module graph without contributing runtime code.
|
||||
- Mirrors the host's `app` slot declaration so the plugin can run in isolation against a stand-alone Hono instance (TypeScript merges duplicate declarations safely).
|
||||
- Co-located with the plugin's types so a single import path establishes the augmentation.
|
||||
- Excluded from coverage / docs gates because it carries no executable code.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
// Importing the package barrel surfaces the augmentation automatically.
|
||||
import "@suaveplan/api-plugin-sse";
|
||||
|
||||
// Now every ctx.getService("sseService") call is typed.
|
||||
function handle(ctx: PluginContext) {
|
||||
const sse = ctx.getService("sseService");
|
||||
sse?.broadcast("c", { data: 1 });
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// Explicit augmentation import — useful when consuming a single subpath
|
||||
// without pulling the whole barrel.
|
||||
import "@suaveplan/api-plugin-sse/registry/service-registry";
|
||||
import type { SseService } from "@suaveplan/api-plugin-sse/services/service";
|
||||
|
||||
declare const ctx: PluginContext;
|
||||
const sse: SseService | undefined = ctx.getService("sseService");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- No exported values. The module augments:
|
||||
|
||||
```ts
|
||||
declare module "@suaveplan/api-plugin/service-registry" {
|
||||
interface ServiceRegistry {
|
||||
sseService: SseService;
|
||||
app: Hono;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- This file does NOT export `type` aliases or values — it exists solely for module-augmentation side effects. The `export type {}` at the end keeps TypeScript happy with isolated modules.
|
||||
- The `app` re-declaration is intentional and harmless. `@suaveplan/api` declares the same slot; TypeScript merges duplicate interface declarations and the two contribute identical types.
|
||||
- Excluded from coverage in `vitest.config.ts` (`src/registry/service-registry.ts`) because there is no runtime to cover; type-level conformance is asserted in the `api-plugin-sse.spec-conformance.test.ts` suite via `expectTypeOf`.
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* ServiceRegistry module augmentation — declares the `sseService`
|
||||
* slot on `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
|
||||
* interface so downstream plugins typed against the augmented
|
||||
* registry see `ctx.getService("sseService")` resolve to
|
||||
* `SseService | undefined` without any cast.
|
||||
*
|
||||
* Side-effect import only — the plugin's barrel imports this module
|
||||
* so every consumer of `@suaveplan/api-plugin-sse` automatically
|
||||
* receives the typed slot.
|
||||
*
|
||||
* The host-side `app` slot owned by `@suaveplan/api` is mirrored here
|
||||
* (TypeScript merges duplicate declarations) so the plugin's `onInit`
|
||||
* can resolve `ctx.getService("app")` with full typing even when the
|
||||
* consumer has not imported `@suaveplan/api` directly (e.g. isolated
|
||||
* unit-tests of the plugin against a stand-alone Hono instance).
|
||||
*/
|
||||
import type { Hono } from "hono";
|
||||
import type { SseService } from "../services/service.js";
|
||||
|
||||
declare module "@suaveplan/api-plugin/service-registry" {
|
||||
interface ServiceRegistry {
|
||||
sseService: SseService;
|
||||
app: Hono;
|
||||
}
|
||||
}
|
||||
57
packages/api-stack/api/api-plugin-sse/src/schemas/event.md
Normal file
57
packages/api-stack/api/api-plugin-sse/src/schemas/event.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# event
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schema validating the `SseEvent` shape accepted by `SseService.broadcast`. Ensures application payloads cannot bypass the boundary with malformed ids, negative `retry` values, or empty event names. Validation failure raises `SseError(SSE_INVALID_INPUT)` with the `ZodError` chained as `cause` so consumers can surface field-level diagnostics to upstream handlers.
|
||||
|
||||
## Features
|
||||
|
||||
- `id` is optional at the boundary — the service generates one via the configured `idGenerator` when absent. When supplied it must be a non-empty string.
|
||||
- `event` (the SSE `event:` field) optional but non-empty when supplied.
|
||||
- `data` accepts any JSON-serialisable value (`z.unknown()`); the handler is responsible for JSON serialisation on the wire.
|
||||
- `retry` constrained to positive integers so a `retry: 0` or `retry: -1` cannot be sent (both are confusing in browsers).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SseEventSchema } from "@suaveplan/api-plugin-sse/schemas/event";
|
||||
|
||||
const parsed = SseEventSchema.parse({ data: { kind: "ping" } });
|
||||
// parsed.id is undefined; the service fills it from idGenerator
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SseEventSchema } from "@suaveplan/api-plugin-sse/schemas/event";
|
||||
import { SSE_CODES, SseError } from "@suaveplan/api-plugin-sse/error-codes/error-codes";
|
||||
|
||||
function safeBroadcast(service: { broadcast: (c: string, e: unknown) => void }, channel: string, raw: unknown) {
|
||||
const parsed = SseEventSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new SseError(SSE_CODES.INVALID_INPUT, "invalid SSE event", {
|
||||
cause: parsed.error,
|
||||
context: { channel, raw },
|
||||
});
|
||||
}
|
||||
service.broadcast(channel, parsed.data);
|
||||
}
|
||||
|
||||
safeBroadcast(service, "notifications", { event: "ping", data: { ts: Date.now() } });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SseEventSchema` — Zod object schema. Fields:
|
||||
- `id` — optional non-empty string.
|
||||
- `event` — optional non-empty string.
|
||||
- `data` — `z.unknown()` (any JSON-serialisable value).
|
||||
- `retry` — optional positive integer (milliseconds).
|
||||
- `type SseEventInputParsed` — `z.input<typeof SseEventSchema>` (caller-facing).
|
||||
- `type SseEventResolved` — `z.output<typeof SseEventSchema>` (post-validation).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The schema is intentionally NOT `.strict()` because applications may pass through additional metadata as part of the broadcast call (`tags`, `severity`, etc.). The service does not propagate unknown fields to the wire — only `id`, `event`, `retry`, `data` are framed.
|
||||
- `data: undefined` is rejected — `undefined` cannot be JSON-stringified and would silently drop the payload at framing time.
|
||||
- A stricter `data` schema (e.g. `z.object({ kind: z.string() })`) belongs in application code that composes its own broadcast wrappers. The plugin level is intentionally permissive to support arbitrary payloads.
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SseEventSchema } from "./event.js";
|
||||
|
||||
describe("SseEventSchema", () => {
|
||||
it("accepts arbitrary JSON data", { timeout: 5000 }, () => {
|
||||
const r = SseEventSchema.safeParse({
|
||||
id: "1",
|
||||
data: { nested: { arr: [1, 2] } },
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts data: null", { timeout: 5000 }, () => {
|
||||
const r = SseEventSchema.safeParse({ data: null });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty-string id", { timeout: 5000 }, () => {
|
||||
const r = SseEventSchema.safeParse({ id: "", data: 1 });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-positive retry", { timeout: 5000 }, () => {
|
||||
const r = SseEventSchema.safeParse({ data: 1, retry: 0 });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts string event name", { timeout: 5000 }, () => {
|
||||
const r = SseEventSchema.safeParse({ event: "ping", data: 1 });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
18
packages/api-stack/api/api-plugin-sse/src/schemas/event.ts
Normal file
18
packages/api-stack/api/api-plugin-sse/src/schemas/event.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema for SSE events accepted by `SseService.broadcast`. `data`
|
||||
* may be any JSON-serialisable value (object, array, string, number,
|
||||
* boolean, null) so application payloads flow through without forcing
|
||||
* callers to pre-stringify. The handler is responsible for JSON
|
||||
* serialisation on the wire.
|
||||
*/
|
||||
export const SseEventSchema = z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
event: z.string().min(1).optional(),
|
||||
data: z.unknown(),
|
||||
retry: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export type SseEventInputParsed = z.input<typeof SseEventSchema>;
|
||||
export type SseEventResolved = z.output<typeof SseEventSchema>;
|
||||
10
packages/api-stack/api/api-plugin-sse/src/schemas/index.ts
Normal file
10
packages/api-stack/api/api-plugin-sse/src/schemas/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
type SseEventInputParsed,
|
||||
type SseEventResolved,
|
||||
SseEventSchema,
|
||||
} from "./event.js";
|
||||
export {
|
||||
type SsePluginOptionsInput,
|
||||
type SsePluginOptionsResolved,
|
||||
SsePluginOptionsSchema,
|
||||
} from "./options.js";
|
||||
75
packages/api-stack/api/api-plugin-sse/src/schemas/options.md
Normal file
75
packages/api-stack/api/api-plugin-sse/src/schemas/options.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# options
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schema validating `SsePluginOptions` at the factory boundary. `createSsePlugin` calls `SsePluginOptionsSchema.safeParse(options)` and raises `SseError(SSE_INVALID_INPUT)` with the `ZodError` as `cause` on failure. The schema also encodes the canonical defaults so the plugin descriptor's resolved config reflects every documented default value without the implementation hand-coding them.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict object validation rejects unknown keys to catch typos like `heartBeatInterval`.
|
||||
- Defaults applied via Zod's `.default()` so the resolved type has every field non-optional.
|
||||
- Numeric fields constrained: positive integers for time/cap fields; non-negative integer for retention (zero disables replay).
|
||||
- `routePrefix` regex matches `/`, `/sse`, `/v1/sse`, rejecting trailing-slash and missing-leading-slash forms.
|
||||
- Runtime objects (`idGenerator`, `clock`, `logger`) validated structurally via `z.custom` so duck-typed implementations are accepted.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SsePluginOptionsSchema } from "@suaveplan/api-plugin-sse/schemas/options";
|
||||
|
||||
const parsed = SsePluginOptionsSchema.parse({
|
||||
heartbeatIntervalMs: 15_000,
|
||||
maxConnectionsPerPrincipal: 4,
|
||||
});
|
||||
console.log(parsed.routePrefix); // "/sse" (defaulted)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SsePluginOptionsSchema } from "@suaveplan/api-plugin-sse/schemas/options";
|
||||
import { SSE_CODES, SseError } from "@suaveplan/api-plugin-sse/error-codes/error-codes";
|
||||
|
||||
function validateOrThrow(input: unknown) {
|
||||
const result = SsePluginOptionsSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
throw new SseError(SSE_CODES.INVALID_INPUT, "invalid SSE config", {
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// User-supplied logger duck-typed against LoggerLike — accepted.
|
||||
validateOrThrow({
|
||||
logger: {
|
||||
debug: console.debug,
|
||||
info: console.info,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SsePluginOptionsSchema` — Zod object schema. Fields and defaults:
|
||||
- `heartbeatIntervalMs` — positive integer (default `30_000`).
|
||||
- `routePrefix` — string matching `^/[A-Za-z0-9\-_/]*[A-Za-z0-9\-_]$|^/$` (default `"/sse"`).
|
||||
- `attachDefaultRoute` — boolean (default `true`).
|
||||
- `retentionPerChannel` — non-negative integer (default `100`).
|
||||
- `maxConnectionsPerPrincipal` — positive integer (default `16`).
|
||||
- `maxTotalConnections` — positive integer (default `10_000`).
|
||||
- `queueHighWaterMark` — positive integer (default `256`).
|
||||
- `overflowPolicy` — `"drop-oldest" | "drop-newest"` (default `"drop-oldest"`).
|
||||
- `idGenerator` — optional `() => string`.
|
||||
- `clock` — optional `Clock` from `@suaveplan/types`.
|
||||
- `logger` — optional `LoggerLike` from `@suaveplan/types`.
|
||||
- `type SsePluginOptionsInput` — `z.input<typeof SsePluginOptionsSchema>` (caller-facing shape).
|
||||
- `type SsePluginOptionsResolved` — `z.output<typeof SsePluginOptionsSchema>` (post-default shape).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The schema is `.strict()` — unknown keys produce a `ZodIssueCode.unrecognized_keys` issue. Intentionally strict so a typo doesn't silently fall back to a default.
|
||||
- The runtime-object validators do not require structural typing of every method — they sniff the presence of one canonical method (`logger.info`, `clock.now`) so callers can pass extended objects without rewriting them.
|
||||
- Defaults are intentionally generous for production use; tighten them in your `createSsePlugin` call if you run behind aggressive proxies or with constrained memory budgets.
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SsePluginOptionsSchema } from "./options.js";
|
||||
|
||||
describe("SsePluginOptionsSchema", () => {
|
||||
it("applies defaults when nothing is supplied", { timeout: 5000 }, () => {
|
||||
const parsed = SsePluginOptionsSchema.parse({});
|
||||
expect(parsed.heartbeatIntervalMs).toBe(30_000);
|
||||
expect(parsed.routePrefix).toBe("/sse");
|
||||
expect(parsed.attachDefaultRoute).toBe(true);
|
||||
expect(parsed.retentionPerChannel).toBe(100);
|
||||
expect(parsed.maxConnectionsPerPrincipal).toBe(16);
|
||||
expect(parsed.maxTotalConnections).toBe(10_000);
|
||||
expect(parsed.queueHighWaterMark).toBe(256);
|
||||
expect(parsed.overflowPolicy).toBe("drop-oldest");
|
||||
});
|
||||
|
||||
it("rejects non-positive heartbeat", { timeout: 5000 }, () => {
|
||||
const r = SsePluginOptionsSchema.safeParse({ heartbeatIntervalMs: 0 });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-integer retention", { timeout: 5000 }, () => {
|
||||
const r = SsePluginOptionsSchema.safeParse({
|
||||
retentionPerChannel: 1.5,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid overflowPolicy", { timeout: 5000 }, () => {
|
||||
const r = SsePluginOptionsSchema.safeParse({
|
||||
overflowPolicy: "explode",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects routePrefix without leading slash", { timeout: 5000 }, () => {
|
||||
const r = SsePluginOptionsSchema.safeParse({ routePrefix: "sse" });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a custom idGenerator function", { timeout: 5000 }, () => {
|
||||
const r = SsePluginOptionsSchema.safeParse({ idGenerator: () => "x" });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a non-function idGenerator", { timeout: 5000 }, () => {
|
||||
const r = SsePluginOptionsSchema.safeParse({ idGenerator: 42 });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a LoggerLike object", { timeout: 5000 }, () => {
|
||||
const logger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
const r = SsePluginOptionsSchema.safeParse({ logger });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a non-LoggerLike value", { timeout: 5000 }, () => {
|
||||
const r = SsePluginOptionsSchema.safeParse({ logger: {} });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a Clock object", { timeout: 5000 }, () => {
|
||||
const r = SsePluginOptionsSchema.safeParse({ clock: { now: () => 0 } });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a non-Clock value", { timeout: 5000 }, () => {
|
||||
const r = SsePluginOptionsSchema.safeParse({ clock: {} });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
60
packages/api-stack/api/api-plugin-sse/src/schemas/options.ts
Normal file
60
packages/api-stack/api/api-plugin-sse/src/schemas/options.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Clock, LoggerLike } from "@suaveplan/types";
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema for `SsePluginOptions`. Every numeric field is positive
|
||||
* and integer-valued; `overflowPolicy` is the documented enum;
|
||||
* callables / Clock / Logger pass through via `z.custom` since they
|
||||
* are runtime-object contracts whose internals are out of scope for
|
||||
* boundary validation.
|
||||
*
|
||||
* Defaults applied here are the canonical plugin defaults — `onInit`
|
||||
* reads the parsed output directly.
|
||||
*/
|
||||
export const SsePluginOptionsSchema = z
|
||||
.object({
|
||||
heartbeatIntervalMs: z.number().int().positive().default(30_000),
|
||||
routePrefix: z
|
||||
.string()
|
||||
.regex(/^\/[A-Za-z0-9\-_/]*[A-Za-z0-9\-_]$|^\/$/u, {
|
||||
message:
|
||||
"routePrefix must start with '/' and not end with '/' (unless it is exactly '/')",
|
||||
})
|
||||
.default("/sse"),
|
||||
attachDefaultRoute: z.boolean().default(true),
|
||||
retentionPerChannel: z.number().int().nonnegative().default(100),
|
||||
maxConnectionsPerPrincipal: z.number().int().positive().default(16),
|
||||
maxTotalConnections: z.number().int().positive().default(10_000),
|
||||
queueHighWaterMark: z.number().int().positive().default(256),
|
||||
overflowPolicy: z
|
||||
.enum(["drop-oldest", "drop-newest"])
|
||||
.default("drop-oldest"),
|
||||
idGenerator: z
|
||||
.custom<() => string>(
|
||||
(val) => typeof val === "function",
|
||||
"idGenerator must be a () => string function"
|
||||
)
|
||||
.optional(),
|
||||
clock: z
|
||||
.custom<Clock>(
|
||||
(val) =>
|
||||
val !== null &&
|
||||
typeof val === "object" &&
|
||||
typeof (val as { now?: unknown }).now === "function",
|
||||
"clock must be a Clock with now()"
|
||||
)
|
||||
.optional(),
|
||||
logger: z
|
||||
.custom<LoggerLike>(
|
||||
(val) =>
|
||||
val !== null &&
|
||||
typeof val === "object" &&
|
||||
typeof (val as { info?: unknown }).info === "function",
|
||||
"logger must be a LoggerLike with debug/info/warn/error"
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type SsePluginOptionsInput = z.input<typeof SsePluginOptionsSchema>;
|
||||
export type SsePluginOptionsResolved = z.output<typeof SsePluginOptionsSchema>;
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type CreateSseServiceOptions,
|
||||
createSseService,
|
||||
type SseService,
|
||||
} from "./service.js";
|
||||
@@ -0,0 +1,93 @@
|
||||
# service
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Core SSE service registered under the `sseService` slot in the host `ServiceRegistry`. Owns subscription lifecycle (id assignment, per-principal and global caps, abort-signal threading), ring-buffer append on broadcast, fanout delivery to live subscribers, and clean termination of every active iterator on `close`. Route handlers consume this service via `ctx.getService("sseService")` and never touch the underlying buffer/fanout directly.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-subscription `AsyncIterableIterator<SseEvent>` that resolves the next event from a backlog queue or a pending promise — so callers can `for await` without polling.
|
||||
- `Last-Event-ID` replay: on subscribe with `lastEventId`, the service pre-fills the backlog from the ring buffer before forwarding live events.
|
||||
- Global `maxTotalConnections` and per-principal `maxConnectionsPerPrincipal` caps with typed errors.
|
||||
- `AbortSignal` threading: client disconnect closes the subscription deterministically; pre-aborted signals are handled via `queueMicrotask`.
|
||||
- `close(channel?)` terminates every iterator (optionally restricted to a single channel) and rejects further `subscribe`/`broadcast` calls with `SseError(SSE_CLOSED)`.
|
||||
- `stats()` returns a live snapshot of active counts globally and per channel.
|
||||
- Every public failure path raises a typed `SseError`; `cause` chains preserve the underlying `ZodError` when relevant.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createSseService } from "@suaveplan/api-plugin-sse/services/service";
|
||||
import { createRingBuffer } from "@suaveplan/api-plugin-sse/buffer/ring-buffer";
|
||||
import { createInProcessFanout } from "@suaveplan/api-plugin-sse/fanout/in-process";
|
||||
|
||||
const service = createSseService({
|
||||
buffer: createRingBuffer(100),
|
||||
fanout: createInProcessFanout(),
|
||||
idGenerator: () => crypto.randomUUID(),
|
||||
maxConnectionsPerPrincipal: 16,
|
||||
maxTotalConnections: 10_000,
|
||||
});
|
||||
|
||||
const handle = service.subscribe({ channel: "notifications" });
|
||||
service.broadcast("notifications", { data: { kind: "ping" } });
|
||||
for await (const event of handle.events) {
|
||||
console.log(event);
|
||||
break;
|
||||
}
|
||||
await handle.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createSseService } from "@suaveplan/api-plugin-sse/services/service";
|
||||
|
||||
const service = createSseService({ /* ... */ });
|
||||
|
||||
// Subscribe with Last-Event-ID resume and an abort signal.
|
||||
const controller = new AbortController();
|
||||
const handle = service.subscribe({
|
||||
channel: "notifications",
|
||||
lastEventId: "evt-42",
|
||||
principalId: "user-123",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
// Disconnect on user navigation.
|
||||
window.addEventListener("beforeunload", () => controller.abort());
|
||||
|
||||
try {
|
||||
for await (const event of handle.events) {
|
||||
renderEvent(event);
|
||||
}
|
||||
} catch (err) {
|
||||
// Abort signal will resolve the iterator with done:true, not throw.
|
||||
// Errors here come from custom listener implementations.
|
||||
}
|
||||
|
||||
console.log(service.stats());
|
||||
// { activeConnections: 1, perChannel: { notifications: 1 } }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface CreateSseServiceOptions` — `{ buffer, fanout, idGenerator, maxConnectionsPerPrincipal, maxTotalConnections }`.
|
||||
- `createSseService(options: CreateSseServiceOptions): SseService` — constructs the service.
|
||||
- `interface SseService`:
|
||||
- `broadcast(channel: string, event: SseEventInput): SseEvent` — validates the event, appends to buffer, publishes via fanout. Returns the resolved event (with generated id when not supplied).
|
||||
- `subscribe(args: SubscribeArgs): SubscriptionHandle` — enforces caps, replays from `lastEventId` if supplied, threads `signal`. Returns a handle exposing the iterator and a `close()`.
|
||||
- `close(channel?: string): Promise<void>` — terminates every active subscription (optionally restricted to a single channel). Subsequent `broadcast` / `subscribe` calls raise `SSE_CLOSED` when called without a channel.
|
||||
- `stats(): SseStats` — snapshot `{ activeConnections, perChannel }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The iterator's `return()` method is honored so `for await (const ev of handle.events) { break }` cleans up the pending promise rather than leaking it.
|
||||
- Per-principal counts are tracked in a separate `Map<string, number>` for O(1) cap enforcement. The map is pruned when a principal's count returns to zero.
|
||||
- The fanout listener captures `sub.closed` inside the closure so deliveries after `detach` are silent no-ops — no race between unsubscribe and the next `publish`.
|
||||
- `close()` without a channel sets a `closedAll` latch so subsequent operations fail fast rather than racing the pending teardown.
|
||||
- The `idGenerator` is intentionally caller-provided so tests can use deterministic ids; the plugin layer supplies `crypto.randomUUID()` as the default.
|
||||
@@ -0,0 +1,341 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createRingBuffer } from "../buffer/ring-buffer.js";
|
||||
import { SSE_CODES, SseError } from "../error-codes/error-codes.js";
|
||||
import { createInProcessFanout } from "../fanout/in-process.js";
|
||||
import type { SseEvent, SubscriptionHandle } from "../types/types.js";
|
||||
import { createSseService, type SseService } from "./service.js";
|
||||
|
||||
function makeService(
|
||||
overrides: {
|
||||
idGenerator?: () => string;
|
||||
maxConnectionsPerPrincipal?: number;
|
||||
maxTotalConnections?: number;
|
||||
retention?: number;
|
||||
} = {}
|
||||
): SseService {
|
||||
let n = 0;
|
||||
return createSseService({
|
||||
buffer: createRingBuffer(overrides.retention ?? 10),
|
||||
fanout: createInProcessFanout(),
|
||||
idGenerator: overrides.idGenerator ?? (() => `id-${++n}`),
|
||||
maxConnectionsPerPrincipal: overrides.maxConnectionsPerPrincipal ?? 8,
|
||||
maxTotalConnections: overrides.maxTotalConnections ?? 64,
|
||||
});
|
||||
}
|
||||
|
||||
async function takeN(
|
||||
handle: SubscriptionHandle,
|
||||
count: number
|
||||
): Promise<SseEvent[]> {
|
||||
const out: SseEvent[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const next = await handle.events.next();
|
||||
if (next.done) break;
|
||||
out.push(next.value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("createSseService", () => {
|
||||
it("broadcast appends to buffer and fans out to subscribers", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const handle = service.subscribe({ channel: "notifications" });
|
||||
service.broadcast("notifications", { data: { kind: "ping" } });
|
||||
const [ev] = await takeN(handle, 1);
|
||||
expect(ev?.data).toEqual({ kind: "ping" });
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
it("broadcast preserves a caller-supplied id", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const handle = service.subscribe({ channel: "c" });
|
||||
service.broadcast("c", { id: "supplied", data: 1 });
|
||||
const [ev] = await takeN(handle, 1);
|
||||
expect(ev?.id).toBe("supplied");
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
it("broadcast generates an id when missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService({ idGenerator: () => "auto" });
|
||||
const handle = service.subscribe({ channel: "c" });
|
||||
service.broadcast("c", { data: 1 });
|
||||
const [ev] = await takeN(handle, 1);
|
||||
// first id consumed by handle, second by event
|
||||
expect(ev?.id).toBeDefined();
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
it("subscribe with Last-Event-ID replays missed events", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
service.broadcast("c", { data: 1 });
|
||||
service.broadcast("c", { data: 2 });
|
||||
service.broadcast("c", { data: 3 });
|
||||
// service generated ids id-1, id-2, id-3 for the events
|
||||
const handle = service.subscribe({ channel: "c", lastEventId: "id-1" });
|
||||
const out = await takeN(handle, 2);
|
||||
expect(out.map((e) => e.id)).toEqual(["id-2", "id-3"]);
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
it("maxConnectionsPerPrincipal enforces per-principal cap", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService({ maxConnectionsPerPrincipal: 2 });
|
||||
const a = service.subscribe({ channel: "c", principalId: "u1" });
|
||||
const b = service.subscribe({ channel: "c", principalId: "u1" });
|
||||
expect(() =>
|
||||
service.subscribe({ channel: "c", principalId: "u1" })
|
||||
).toThrowError(SseError);
|
||||
try {
|
||||
service.subscribe({ channel: "c", principalId: "u1" });
|
||||
} catch (err) {
|
||||
expect((err as SseError).code).toBe(
|
||||
SSE_CODES.PER_PRINCIPAL_LIMIT_EXCEEDED
|
||||
);
|
||||
}
|
||||
await a.close();
|
||||
await b.close();
|
||||
});
|
||||
|
||||
it("maxTotalConnections enforces global cap", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService({ maxTotalConnections: 1 });
|
||||
const a = service.subscribe({ channel: "c" });
|
||||
try {
|
||||
service.subscribe({ channel: "c" });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SseError).code).toBe(SSE_CODES.TOO_MANY_CONNECTIONS);
|
||||
}
|
||||
await a.close();
|
||||
});
|
||||
|
||||
it("close terminates active connections", { timeout: 5000 }, async () => {
|
||||
const service = makeService();
|
||||
const a = service.subscribe({ channel: "c" });
|
||||
const b = service.subscribe({ channel: "c" });
|
||||
await service.close("c");
|
||||
expect(service.stats().activeConnections).toBe(0);
|
||||
await a.close();
|
||||
await b.close();
|
||||
});
|
||||
|
||||
it("close() without channel terminates everything", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
service.subscribe({ channel: "a" });
|
||||
service.subscribe({ channel: "b" });
|
||||
await service.close();
|
||||
expect(service.stats().activeConnections).toBe(0);
|
||||
});
|
||||
|
||||
it("broadcast after close throws SSE_CLOSED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
await service.close();
|
||||
try {
|
||||
service.broadcast("c", { data: 1 });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SseError).code).toBe(SSE_CODES.CLOSED);
|
||||
}
|
||||
});
|
||||
|
||||
it("subscribe after close throws SSE_CLOSED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
await service.close();
|
||||
try {
|
||||
service.subscribe({ channel: "c" });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SseError).code).toBe(SSE_CODES.CLOSED);
|
||||
}
|
||||
});
|
||||
|
||||
it("subscribe with empty channel throws SSE_CHANNEL_REQUIRED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const service = makeService();
|
||||
try {
|
||||
service.subscribe({ channel: "" });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SseError).code).toBe(SSE_CODES.CHANNEL_REQUIRED);
|
||||
}
|
||||
});
|
||||
|
||||
it("broadcast invalid event throws SSE_INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const service = makeService();
|
||||
try {
|
||||
service.broadcast("c", { id: "", data: 1 });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SseError).code).toBe(SSE_CODES.INVALID_INPUT);
|
||||
}
|
||||
});
|
||||
|
||||
it("stats reports per-channel counts", { timeout: 5000 }, async () => {
|
||||
const service = makeService();
|
||||
const a = service.subscribe({ channel: "a" });
|
||||
const b1 = service.subscribe({ channel: "b" });
|
||||
const b2 = service.subscribe({ channel: "b" });
|
||||
const stats = service.stats();
|
||||
expect(stats.activeConnections).toBe(3);
|
||||
expect(stats.perChannel.a).toBe(1);
|
||||
expect(stats.perChannel.b).toBe(2);
|
||||
await a.close();
|
||||
await b1.close();
|
||||
await b2.close();
|
||||
});
|
||||
|
||||
it("abort signal closes the subscription", { timeout: 5000 }, async () => {
|
||||
const service = makeService();
|
||||
const controller = new AbortController();
|
||||
service.subscribe({ channel: "c", signal: controller.signal });
|
||||
expect(service.stats().activeConnections).toBe(1);
|
||||
controller.abort();
|
||||
// queueMicrotask drains on the next microtask boundary
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(service.stats().activeConnections).toBe(0);
|
||||
});
|
||||
|
||||
it("pre-aborted signal closes the subscription on the next tick", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
service.subscribe({ channel: "c", signal: controller.signal });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(service.stats().activeConnections).toBe(0);
|
||||
});
|
||||
|
||||
it("close is idempotent", { timeout: 5000 }, async () => {
|
||||
const service = makeService();
|
||||
const handle = service.subscribe({ channel: "c" });
|
||||
await handle.close();
|
||||
await handle.close();
|
||||
expect(service.stats().activeConnections).toBe(0);
|
||||
});
|
||||
|
||||
it("subscription iterator return() completes the stream", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const handle = service.subscribe({ channel: "c" });
|
||||
const ret = await handle.events.return?.();
|
||||
expect(ret?.done).toBe(true);
|
||||
});
|
||||
|
||||
it("iterator pending promise resolves when fanout delivers", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const handle = service.subscribe({ channel: "c" });
|
||||
const promise = handle.events.next();
|
||||
service.broadcast("c", { data: "hello" });
|
||||
const result = await promise;
|
||||
expect(result.done).toBe(false);
|
||||
if (!result.done) expect(result.value.data).toBe("hello");
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
it("pending iterator resolves done when service.close runs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const handle = service.subscribe({ channel: "c" });
|
||||
const promise = handle.events.next();
|
||||
await service.close();
|
||||
const r = await promise;
|
||||
expect(r.done).toBe(true);
|
||||
});
|
||||
|
||||
it("subscription with Symbol.asyncIterator returns self", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const service = makeService();
|
||||
const handle = service.subscribe({ channel: "c" });
|
||||
expect(handle.events[Symbol.asyncIterator]()).toBe(handle.events);
|
||||
});
|
||||
|
||||
it("iterator next() after close resolves done:true", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const handle = service.subscribe({ channel: "c" });
|
||||
await handle.close();
|
||||
const result = await handle.events.next();
|
||||
expect(result.done).toBe(true);
|
||||
});
|
||||
|
||||
it("iterator return() while pending resolves the pending promise", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const handle = service.subscribe({ channel: "c" });
|
||||
const pending = handle.events.next();
|
||||
const ret = await handle.events.return?.();
|
||||
expect(ret?.done).toBe(true);
|
||||
const settled = await pending;
|
||||
expect(settled.done).toBe(true);
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
it("iterator return() after close is idempotent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const handle = service.subscribe({ channel: "c" });
|
||||
await handle.close();
|
||||
const ret = await handle.events.return?.();
|
||||
expect(ret?.done).toBe(true);
|
||||
});
|
||||
|
||||
it("broadcast forwards optional event and retry fields", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const handle = service.subscribe({ channel: "c" });
|
||||
service.broadcast("c", {
|
||||
id: "x",
|
||||
event: "ping",
|
||||
retry: 250,
|
||||
data: 1,
|
||||
});
|
||||
const [ev] = await takeN(handle, 1);
|
||||
expect(ev?.event).toBe("ping");
|
||||
expect(ev?.retry).toBe(250);
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
it("close('a') leaves subscribers on other channels untouched", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const a = service.subscribe({ channel: "a" });
|
||||
const b = service.subscribe({ channel: "b" });
|
||||
await service.close("a");
|
||||
expect(service.stats().perChannel.a).toBeUndefined();
|
||||
expect(service.stats().perChannel.b).toBe(1);
|
||||
await b.close();
|
||||
await a.close();
|
||||
});
|
||||
});
|
||||
264
packages/api-stack/api/api-plugin-sse/src/services/service.ts
Normal file
264
packages/api-stack/api/api-plugin-sse/src/services/service.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import type { RingBuffer } from "../buffer/ring-buffer.js";
|
||||
import { SSE_CODES, SseError } from "../error-codes/error-codes.js";
|
||||
import type { Fanout } from "../fanout/in-process.js";
|
||||
import { getSseInstruments } from "../otel/instruments.js";
|
||||
import { SseEventSchema } from "../schemas/event.js";
|
||||
import type {
|
||||
SseEvent,
|
||||
SseEventInput,
|
||||
SseStats,
|
||||
SubscribeArgs,
|
||||
SubscriptionHandle,
|
||||
} from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Constructor options for `createSseService`. The plugin lifecycle
|
||||
* supplies all four — callers using the service outside the plugin
|
||||
* (rare) can build them by hand.
|
||||
*/
|
||||
export interface CreateSseServiceOptions {
|
||||
readonly buffer: RingBuffer;
|
||||
readonly fanout: Fanout;
|
||||
readonly idGenerator: () => string;
|
||||
readonly maxConnectionsPerPrincipal: number;
|
||||
readonly maxTotalConnections: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public surface registered into the host `ServiceRegistry` under the
|
||||
* `sseService` slot. Route handlers and downstream plugins consume
|
||||
* this — never the underlying `Fanout` or `RingBuffer` directly — so
|
||||
* the plugin can swap implementations without breaking consumers.
|
||||
*/
|
||||
export interface SseService {
|
||||
broadcast(channel: string, event: SseEventInput): SseEvent;
|
||||
subscribe(args: SubscribeArgs): SubscriptionHandle;
|
||||
close(channel?: string): Promise<void>;
|
||||
stats(): SseStats;
|
||||
}
|
||||
|
||||
type Pending = { resolve: (v: IteratorResult<SseEvent>) => void };
|
||||
|
||||
interface Subscription {
|
||||
readonly id: string;
|
||||
readonly channel: string;
|
||||
readonly principalId: string | undefined;
|
||||
readonly queue: SseEvent[];
|
||||
closed: boolean;
|
||||
pending: Pending | undefined;
|
||||
unsubscribeFanout: (() => void) | undefined;
|
||||
signal: AbortSignal | undefined;
|
||||
abortHandler: (() => void) | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct an `SseService`. The plugin's `onInit` is the only
|
||||
* caller in production; tests may call it directly for unit
|
||||
* coverage of the service in isolation.
|
||||
*/
|
||||
export function createSseService(options: CreateSseServiceOptions): SseService {
|
||||
const {
|
||||
buffer,
|
||||
fanout,
|
||||
idGenerator,
|
||||
maxConnectionsPerPrincipal,
|
||||
maxTotalConnections,
|
||||
} = options;
|
||||
const subscriptions = new Map<string, Subscription>();
|
||||
const perPrincipal = new Map<string, number>();
|
||||
let closedAll = false;
|
||||
|
||||
function takeStats(): SseStats {
|
||||
const perChannel: Record<string, number> = {};
|
||||
for (const sub of subscriptions.values()) {
|
||||
perChannel[sub.channel] = (perChannel[sub.channel] ?? 0) + 1;
|
||||
}
|
||||
return { activeConnections: subscriptions.size, perChannel };
|
||||
}
|
||||
|
||||
function detach(sub: Subscription): void {
|
||||
if (sub.closed) return;
|
||||
sub.closed = true;
|
||||
sub.unsubscribeFanout?.();
|
||||
if (sub.signal !== undefined && sub.abortHandler !== undefined) {
|
||||
sub.signal.removeEventListener("abort", sub.abortHandler);
|
||||
}
|
||||
subscriptions.delete(sub.id);
|
||||
if (sub.principalId !== undefined) {
|
||||
// subscribe() always sets perPrincipal[principalId] >= 1 before
|
||||
// creating the subscription, so the map entry is guaranteed.
|
||||
const next = (perPrincipal.get(sub.principalId) as number) - 1;
|
||||
if (next <= 0) perPrincipal.delete(sub.principalId);
|
||||
else perPrincipal.set(sub.principalId, next);
|
||||
}
|
||||
getSseInstruments().activeConnections.add(-1, { channel: sub.channel });
|
||||
if (sub.pending !== undefined) {
|
||||
const p = sub.pending;
|
||||
sub.pending = undefined;
|
||||
p.resolve({ value: undefined, done: true });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
broadcast(channel, eventInput) {
|
||||
if (closedAll) {
|
||||
throw new SseError(
|
||||
SSE_CODES.CLOSED,
|
||||
"broadcast called after service.close()",
|
||||
{ context: { channel } }
|
||||
);
|
||||
}
|
||||
const parsed = SseEventSchema.safeParse(eventInput);
|
||||
if (!parsed.success) {
|
||||
throw new SseError(
|
||||
SSE_CODES.INVALID_INPUT,
|
||||
"broadcast received invalid SseEvent",
|
||||
{ cause: parsed.error, context: { channel } }
|
||||
);
|
||||
}
|
||||
const event: SseEvent = {
|
||||
id: parsed.data.id ?? idGenerator(),
|
||||
...(parsed.data.event !== undefined
|
||||
? { event: parsed.data.event }
|
||||
: {}),
|
||||
...(parsed.data.retry !== undefined
|
||||
? { retry: parsed.data.retry }
|
||||
: {}),
|
||||
data: parsed.data.data,
|
||||
};
|
||||
buffer.append(channel, event);
|
||||
getSseInstruments().broadcastsTotal.add(1, { channel });
|
||||
fanout.publish(channel, event);
|
||||
return event;
|
||||
},
|
||||
subscribe(args) {
|
||||
if (closedAll) {
|
||||
throw new SseError(
|
||||
SSE_CODES.CLOSED,
|
||||
"subscribe called after service.close()",
|
||||
{ context: { channel: args.channel } }
|
||||
);
|
||||
}
|
||||
if (args.channel === "") {
|
||||
throw new SseError(
|
||||
SSE_CODES.CHANNEL_REQUIRED,
|
||||
"subscribe requires a non-empty channel"
|
||||
);
|
||||
}
|
||||
if (subscriptions.size >= maxTotalConnections) {
|
||||
throw new SseError(
|
||||
SSE_CODES.TOO_MANY_CONNECTIONS,
|
||||
`Global active-connection cap reached (${maxTotalConnections})`,
|
||||
{ context: { max: maxTotalConnections } }
|
||||
);
|
||||
}
|
||||
if (args.principalId !== undefined) {
|
||||
const current = perPrincipal.get(args.principalId) ?? 0;
|
||||
if (current >= maxConnectionsPerPrincipal) {
|
||||
throw new SseError(
|
||||
SSE_CODES.PER_PRINCIPAL_LIMIT_EXCEEDED,
|
||||
`Principal '${args.principalId}' already holds ${current} connections`,
|
||||
{
|
||||
context: {
|
||||
principalId: args.principalId,
|
||||
max: maxConnectionsPerPrincipal,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
perPrincipal.set(args.principalId, current + 1);
|
||||
}
|
||||
const id = idGenerator();
|
||||
const sub: Subscription = {
|
||||
id,
|
||||
channel: args.channel,
|
||||
principalId: args.principalId,
|
||||
queue: [],
|
||||
closed: false,
|
||||
pending: undefined,
|
||||
unsubscribeFanout: undefined,
|
||||
signal: args.signal,
|
||||
abortHandler: undefined,
|
||||
};
|
||||
if (args.lastEventId !== undefined) {
|
||||
for (const ev of buffer.since(args.channel, args.lastEventId)) {
|
||||
sub.queue.push(ev);
|
||||
}
|
||||
}
|
||||
sub.unsubscribeFanout = fanout.subscribe(args.channel, (event) => {
|
||||
if (sub.pending !== undefined) {
|
||||
const p = sub.pending;
|
||||
sub.pending = undefined;
|
||||
p.resolve({ value: event, done: false });
|
||||
} else {
|
||||
sub.queue.push(event);
|
||||
}
|
||||
});
|
||||
if (args.signal !== undefined) {
|
||||
const handler = () => detach(sub);
|
||||
sub.abortHandler = handler;
|
||||
if (args.signal.aborted) {
|
||||
queueMicrotask(handler);
|
||||
} else {
|
||||
args.signal.addEventListener("abort", handler, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
subscriptions.set(id, sub);
|
||||
getSseInstruments().activeConnections.add(1, {
|
||||
channel: args.channel,
|
||||
});
|
||||
const handle: SubscriptionHandle = {
|
||||
id,
|
||||
channel: args.channel,
|
||||
events: createIterator(sub),
|
||||
close: async () => {
|
||||
detach(sub);
|
||||
},
|
||||
};
|
||||
return handle;
|
||||
},
|
||||
async close(channel) {
|
||||
if (channel === undefined) closedAll = true;
|
||||
const victims: Subscription[] = [];
|
||||
for (const sub of subscriptions.values()) {
|
||||
if (channel === undefined || sub.channel === channel) {
|
||||
victims.push(sub);
|
||||
}
|
||||
}
|
||||
for (const v of victims) detach(v);
|
||||
},
|
||||
stats: takeStats,
|
||||
};
|
||||
}
|
||||
|
||||
function createIterator(sub: Subscription): AsyncIterableIterator<SseEvent> {
|
||||
const iterator: AsyncIterableIterator<SseEvent> = {
|
||||
next() {
|
||||
if (sub.queue.length > 0) {
|
||||
const value = sub.queue.shift() as SseEvent;
|
||||
return Promise.resolve({ value, done: false });
|
||||
}
|
||||
if (sub.closed) {
|
||||
return Promise.resolve({ value: undefined, done: true });
|
||||
}
|
||||
return new Promise<IteratorResult<SseEvent>>((resolve) => {
|
||||
sub.pending = { resolve };
|
||||
});
|
||||
},
|
||||
return() {
|
||||
if (!sub.closed) sub.closed = true;
|
||||
if (sub.pending !== undefined) {
|
||||
const p = sub.pending;
|
||||
sub.pending = undefined;
|
||||
p.resolve({ value: undefined, done: true });
|
||||
}
|
||||
return Promise.resolve({ value: undefined, done: true });
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return iterator;
|
||||
},
|
||||
};
|
||||
return iterator;
|
||||
}
|
||||
10
packages/api-stack/api/api-plugin-sse/src/types/index.ts
Normal file
10
packages/api-stack/api/api-plugin-sse/src/types/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
OverflowPolicy,
|
||||
SseEvent,
|
||||
SseEventInput,
|
||||
SsePluginConfig,
|
||||
SsePluginOptions,
|
||||
SseStats,
|
||||
SubscribeArgs,
|
||||
SubscriptionHandle,
|
||||
} from "./types.js";
|
||||
72
packages/api-stack/api/api-plugin-sse/src/types/types.ts
Normal file
72
packages/api-stack/api/api-plugin-sse/src/types/types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { Clock, LoggerLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Canonical SSE event shape produced and consumed across the package.
|
||||
* `id` is required so reconnects can resume from a known cursor; the
|
||||
* service generates it via the configured `idGenerator` when the
|
||||
* caller supplies a partial event.
|
||||
*/
|
||||
export interface SseEvent {
|
||||
/** Cursor id — used by clients via `Last-Event-ID` to resume. */
|
||||
readonly id: string;
|
||||
/** Optional event name (`event:` field). */
|
||||
readonly event?: string;
|
||||
/** Application payload — JSON-serialised on the wire. */
|
||||
readonly data: unknown;
|
||||
/** Optional `retry:` reconnection hint in milliseconds. */
|
||||
readonly retry?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Caller-facing input to `SseService.broadcast` — `id` is optional;
|
||||
* the service fills it via the configured id generator when absent.
|
||||
*/
|
||||
export interface SseEventInput {
|
||||
readonly id?: string;
|
||||
readonly event?: string;
|
||||
readonly data: unknown;
|
||||
readonly retry?: number;
|
||||
}
|
||||
|
||||
/** Backpressure overflow policies. */
|
||||
export type OverflowPolicy = "drop-oldest" | "drop-newest";
|
||||
|
||||
/** Arguments to `SseService.subscribe`. */
|
||||
export interface SubscribeArgs {
|
||||
readonly channel: string;
|
||||
readonly lastEventId?: string;
|
||||
readonly principalId?: string;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Handle returned by `SseService.subscribe`. */
|
||||
export interface SubscriptionHandle {
|
||||
readonly id: string;
|
||||
readonly channel: string;
|
||||
readonly events: AsyncIterableIterator<SseEvent>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Snapshot returned by `SseService.stats`. */
|
||||
export interface SseStats {
|
||||
readonly activeConnections: number;
|
||||
readonly perChannel: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
/** Resolved plugin configuration after Zod validation + defaulting. */
|
||||
export interface SsePluginConfig {
|
||||
readonly heartbeatIntervalMs: number;
|
||||
readonly routePrefix: string;
|
||||
readonly attachDefaultRoute: boolean;
|
||||
readonly retentionPerChannel: number;
|
||||
readonly maxConnectionsPerPrincipal: number;
|
||||
readonly maxTotalConnections: number;
|
||||
readonly queueHighWaterMark: number;
|
||||
readonly overflowPolicy: OverflowPolicy;
|
||||
readonly idGenerator?: () => string;
|
||||
readonly clock?: Clock;
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
|
||||
/** Caller-facing options (all keys optional, defaulted by Zod). */
|
||||
export type SsePluginOptions = Partial<SsePluginConfig>;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_ROOT = path.resolve(import.meta.dirname, "..");
|
||||
|
||||
describe("api-plugin-sse — dependency graph and exports map", () => {
|
||||
it("exports map advertises every feature subpath", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const raw = await readFile(path.join(PKG_ROOT, "package.json"), "utf8");
|
||||
const pkg = JSON.parse(raw) as {
|
||||
exports: Record<string, unknown>;
|
||||
};
|
||||
const subpaths = Object.keys(pkg.exports)
|
||||
.filter(
|
||||
(k) =>
|
||||
(k === "." || k.startsWith("./")) && k !== "./package.json"
|
||||
)
|
||||
.sort();
|
||||
expect(subpaths).toEqual(
|
||||
[
|
||||
".",
|
||||
"./plugin/plugin",
|
||||
"./services/service",
|
||||
"./middleware/sse-handler",
|
||||
"./formatter/format",
|
||||
"./formatter/parse",
|
||||
"./buffer/ring-buffer",
|
||||
"./fanout/in-process",
|
||||
"./schemas/options",
|
||||
"./schemas/event",
|
||||
"./error-codes/error-codes",
|
||||
"./otel/instruments",
|
||||
"./types/types",
|
||||
"./registry/service-registry",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("dependencies use only workspace and catalog refs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const raw = await readFile(path.join(PKG_ROOT, "package.json"), "utf8");
|
||||
const pkg = JSON.parse(raw) as {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
const all = {
|
||||
...(pkg.dependencies ?? {}),
|
||||
...(pkg.devDependencies ?? {}),
|
||||
};
|
||||
for (const [name, version] of Object.entries(all)) {
|
||||
// Allow exact versions for explicitly pinned testing deps.
|
||||
if (name === "@opentelemetry/sdk-metrics") continue;
|
||||
if (
|
||||
!version.startsWith("workspace:") &&
|
||||
!version.startsWith("catalog:")
|
||||
) {
|
||||
throw new Error(
|
||||
`Dependency ${name} uses bare version '${version}' — must be workspace:/catalog:`
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import { createRingBuffer } from "../../src/buffer/ring-buffer.js";
|
||||
import { createInProcessFanout } from "../../src/fanout/in-process.js";
|
||||
import { parseSseStream } from "../../src/formatter/parse.js";
|
||||
import { createSseRoute } from "../../src/middleware/sse-handler.js";
|
||||
import { createSseService } from "../../src/services/service.js";
|
||||
|
||||
/**
|
||||
* End-to-end integration coverage. A real Hono app, a real fetch
|
||||
* round-trip (via `app.fetch` over a `Request`), and a real ReadableStream
|
||||
* client reader that decodes the SSE wire format using `parseSseStream`.
|
||||
*/
|
||||
describe("api-plugin-sse — full-stream integration", () => {
|
||||
it("end-to-end fetch subscriber receives three ordered events", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
let n = 0;
|
||||
const service = createSseService({
|
||||
buffer: createRingBuffer(100),
|
||||
fanout: createInProcessFanout(),
|
||||
idGenerator: () => `evt-${++n}`,
|
||||
maxConnectionsPerPrincipal: 16,
|
||||
maxTotalConnections: 100,
|
||||
});
|
||||
const app = new Hono();
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(service, {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
queueHighWaterMark: 64,
|
||||
overflowPolicy: "drop-oldest",
|
||||
})
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
const res = await app.request(
|
||||
new Request("http://test.local/sse/notifications", {
|
||||
signal: controller.signal,
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Broadcast on a small delay so the subscriber has installed.
|
||||
setTimeout(() => {
|
||||
service.broadcast("notifications", { data: { n: 1 } });
|
||||
service.broadcast("notifications", { data: { n: 2 } });
|
||||
service.broadcast("notifications", { data: { n: 3 } });
|
||||
}, 10);
|
||||
|
||||
const body = res.body;
|
||||
if (body === null) throw new Error("no body");
|
||||
const reader = body.getReader();
|
||||
const collected = [] as { id: string; data: unknown }[];
|
||||
for await (const event of parseSseStream(reader)) {
|
||||
collected.push({
|
||||
id: event.id,
|
||||
data: JSON.parse(event.data as string),
|
||||
});
|
||||
if (collected.length >= 3) break;
|
||||
}
|
||||
controller.abort();
|
||||
await reader.cancel().catch(() => undefined);
|
||||
|
||||
// service.subscribe consumes one id for the connection handle
|
||||
// (evt-1), then broadcasts consume evt-2/3/4 for the events.
|
||||
expect(collected.map((c) => c.id)).toEqual(["evt-2", "evt-3", "evt-4"]);
|
||||
expect(collected.map((c) => (c.data as { n: number }).n)).toEqual([
|
||||
1, 2, 3,
|
||||
]);
|
||||
await service.close();
|
||||
});
|
||||
|
||||
it("reconnect with Last-Event-ID skips delivered events", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
let n = 0;
|
||||
const service = createSseService({
|
||||
buffer: createRingBuffer(100),
|
||||
fanout: createInProcessFanout(),
|
||||
idGenerator: () => `evt-${++n}`,
|
||||
maxConnectionsPerPrincipal: 16,
|
||||
maxTotalConnections: 100,
|
||||
});
|
||||
const app = new Hono();
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(service, {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
queueHighWaterMark: 64,
|
||||
overflowPolicy: "drop-oldest",
|
||||
})
|
||||
);
|
||||
|
||||
// Pre-load three broadcasts into the ring buffer.
|
||||
service.broadcast("c", { data: { n: 1 } });
|
||||
service.broadcast("c", { data: { n: 2 } });
|
||||
service.broadcast("c", { data: { n: 3 } });
|
||||
|
||||
const controller = new AbortController();
|
||||
const res = await app.request(
|
||||
new Request("http://test.local/sse/c", {
|
||||
headers: { "Last-Event-ID": "evt-2" },
|
||||
signal: controller.signal,
|
||||
})
|
||||
);
|
||||
const body = res.body;
|
||||
if (body === null) throw new Error("no body");
|
||||
const reader = body.getReader();
|
||||
const collected = [] as { id: string }[];
|
||||
for await (const event of parseSseStream(reader)) {
|
||||
collected.push({ id: event.id });
|
||||
if (collected.length >= 1) break;
|
||||
}
|
||||
controller.abort();
|
||||
await reader.cancel().catch(() => undefined);
|
||||
|
||||
expect(collected.map((c) => c.id)).toEqual(["evt-3"]);
|
||||
await service.close();
|
||||
});
|
||||
|
||||
it("heartbeat tick via injected interval triggers a comment frame", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
const service = createSseService({
|
||||
buffer: createRingBuffer(10),
|
||||
fanout: createInProcessFanout(),
|
||||
idGenerator: () => "x",
|
||||
maxConnectionsPerPrincipal: 16,
|
||||
maxTotalConnections: 100,
|
||||
});
|
||||
const app = new Hono();
|
||||
let fire: (() => void) | undefined;
|
||||
app.get(
|
||||
"/sse/:channel",
|
||||
createSseRoute(service, {
|
||||
heartbeatIntervalMs: 30_000,
|
||||
queueHighWaterMark: 64,
|
||||
overflowPolicy: "drop-oldest",
|
||||
setIntervalImpl: (fn) => {
|
||||
fire = fn;
|
||||
return { mock: true };
|
||||
},
|
||||
clearIntervalImpl: () => {
|
||||
fire = undefined;
|
||||
},
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const res = await app.request(
|
||||
new Request("http://t/sse/c", { signal: controller.signal })
|
||||
);
|
||||
const body = res.body;
|
||||
if (body === null) throw new Error("no body");
|
||||
const reader = body.getReader();
|
||||
// Allow stream install
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
fire?.();
|
||||
const { value } = await reader.read();
|
||||
const decoder = new TextDecoder();
|
||||
const text = decoder.decode(value ?? new Uint8Array());
|
||||
expect(text).toContain(": heartbeat");
|
||||
controller.abort();
|
||||
await reader.cancel().catch(() => undefined);
|
||||
await service.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/api-plugin-sse.
|
||||
*
|
||||
* Installs a real in-memory OpenTelemetry MeterProvider so OTel
|
||||
* instruments resolved on first call bind against an exporter the
|
||||
* tests can flush + read.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-sse/tsconfig.json
Normal file
12
packages/api-stack/api/api-plugin-sse/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-sse/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-plugin-sse/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
38
packages/api-stack/api/api-plugin-sse/vite.config.ts
Normal file
38
packages/api-stack/api/api-plugin-sse/vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-plugin-sse",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^@opentelemetry\//,
|
||||
/^@hono\//,
|
||||
"hono",
|
||||
"hono/streaming",
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
34
packages/api-stack/api/api-plugin-sse/vitest.config.ts
Normal file
34
packages/api-stack/api/api-plugin-sse/vitest.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/index.ts",
|
||||
"src/registry/service-registry.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 120000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user