first commit
This commit is contained in:
74
docs/api-stack/api/api-ws/src/backpressure/backpressure.md
Normal file
74
docs/api-stack/api/api-ws/src/backpressure/backpressure.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# `backpressure` — bounded send-queue policy
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Per design doc §11.4, every WebSocket connection MUST have a bounded outbound
|
||||
queue. This module owns the abstract policy + the evaluator helper that
|
||||
concrete engines (Bun, future browser shims) call when a connection's queue
|
||||
depth changes. Centralising the rule prevents engines from drifting from
|
||||
the documented 256-message default.
|
||||
|
||||
The eviction action is a hard close with the documented
|
||||
`BACKPRESSURE_EVICTED` reason — slow consumers do not get to keep their
|
||||
slot.
|
||||
|
||||
## Features
|
||||
|
||||
- `BackpressurePolicy` interface — one knob: `maxQueuedMessages`.
|
||||
- `DEFAULT_BACKPRESSURE_POLICY` — frozen `{ maxQueuedMessages: 256 }`.
|
||||
- `evaluateBackpressure(depth, policy?)` returns `"accept"` or `"evict"`.
|
||||
- `raiseBackpressureEvicted(connectionId)` throws the canonical `WsError`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
DEFAULT_BACKPRESSURE_POLICY,
|
||||
evaluateBackpressure,
|
||||
} from "@suaveplan/api-ws/backpressure";
|
||||
|
||||
if (evaluateBackpressure(queue.length) === "evict") {
|
||||
await connection.close(1013, "backpressure");
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
evaluateBackpressure,
|
||||
raiseBackpressureEvicted,
|
||||
type BackpressurePolicy,
|
||||
} from "@suaveplan/api-ws/backpressure";
|
||||
|
||||
const tightPolicy: BackpressurePolicy = { maxQueuedMessages: 64 };
|
||||
|
||||
function enqueue(conn: { id: string; queue: unknown[] }, msg: unknown): void {
|
||||
conn.queue.push(msg);
|
||||
if (evaluateBackpressure(conn.queue.length, tightPolicy) === "evict") {
|
||||
raiseBackpressureEvicted(conn.id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface BackpressurePolicy { maxQueuedMessages: number }`.
|
||||
- `const DEFAULT_BACKPRESSURE_POLICY: BackpressurePolicy` (frozen).
|
||||
- `type BackpressureAction = "accept" | "evict"`.
|
||||
- `evaluateBackpressure(queuedMessages, policy?): BackpressureAction`.
|
||||
- `raiseBackpressureEvicted(connectionId): never` — throws.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The evaluator uses `>` so a depth equal to the cap still accepts; the
|
||||
cap is the maximum permitted, not the boundary.
|
||||
- The error message embeds the connection id so operators triaging a
|
||||
flood of evictions can correlate to a specific slow consumer without
|
||||
parsing the per-error context.
|
||||
- The thrown error is unconditional `never` so engines that call it
|
||||
inside a synchronous `enqueue` path don't need a noreturn shim.
|
||||
81
docs/api-stack/api/api-ws/src/broker/broker.md
Normal file
81
docs/api-stack/api/api-ws/src/broker/broker.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# `broker` — multi-host fan-out contract
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
A single-host deployment never needs a broker — the `SubscriptionBus`
|
||||
inside that process already sees every connection. Production deployments
|
||||
that run multiple host replicas behind a load balancer DO need one: a
|
||||
broadcast issued on host A has to reach the connections attached to host
|
||||
B without migrating connections between processes. The `WsBroker` contract
|
||||
defined here is the seam those broker adapters implement.
|
||||
|
||||
Concrete brokers (`api-ws-broker-in-memory` for single-host tests,
|
||||
`api-ws-broker-redis` for production) plug behind this interface and stay
|
||||
out of plugin code paths.
|
||||
|
||||
## Features
|
||||
|
||||
- `BrokerEnvelope` — wire payload (`topic`, `tenant`, `payload`, optional
|
||||
`originHost`).
|
||||
- `BrokerHandler` — subscriber callback signature.
|
||||
- `BrokerSubscription` — disposal handle returned from `subscribe`.
|
||||
- `WsBroker<Payload>` interface — `publish`, `subscribe`, `dispose`. Each
|
||||
method may return synchronously or asynchronously so in-memory brokers
|
||||
stay cheap and Redis-backed brokers can `await` round trips.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { WsBroker } from "@suaveplan/api-ws/broker";
|
||||
|
||||
async function fanOut(broker: WsBroker<{ orderId: string }>): Promise<void> {
|
||||
await broker.publish("orders.created", {
|
||||
topic: "orders.created",
|
||||
tenant: tenantId,
|
||||
payload: { orderId: "o1" },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { WsBroker, BrokerSubscription } from "@suaveplan/api-ws/broker";
|
||||
|
||||
async function bridge(
|
||||
broker: WsBroker<unknown>,
|
||||
local: { broadcast: (...args: unknown[]) => void },
|
||||
): Promise<BrokerSubscription> {
|
||||
const subscription = await broker.subscribe("orders.created", (envelope) => {
|
||||
local.broadcast(envelope.topic, {
|
||||
tenant: envelope.tenant,
|
||||
payload: envelope.payload,
|
||||
});
|
||||
});
|
||||
return subscription;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface BrokerEnvelope<Payload> { topic, tenant, payload, originHost? }`.
|
||||
- `type BrokerHandler<Payload> = (env: BrokerEnvelope<Payload>) => void | Promise<void>`.
|
||||
- `interface BrokerSubscription { topic; unsubscribe(): void | Promise<void> }`.
|
||||
- `interface WsBroker<Payload> { publish, subscribe, dispose }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The contract intentionally does not specify reliability semantics. Each
|
||||
adapter declares its own delivery guarantees (`in-memory` is at-most-
|
||||
once with FIFO inside the process; `redis` is at-most-once with Redis
|
||||
pub/sub semantics). Plugins that need stronger guarantees use the
|
||||
saga / 2PC capabilities elsewhere in the foundation.
|
||||
- `originHost` is optional so adapters that don't need loop-back
|
||||
suppression can omit it. Adapters that DO suppress loop-back compare
|
||||
against the local host's identity at the receive side.
|
||||
- `dispose` MUST close every subscription cleanly; the conformance
|
||||
harness pins this invariant against every concrete adapter.
|
||||
73
docs/api-stack/api/api-ws/src/capability-id/capability-id.md
Normal file
73
docs/api-stack/api/api-ws/src/capability-id/capability-id.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# `capability-id` — `ws` + `ws.broadcast-cross-tenant`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/api-types/capabilities` owns the closed `CapabilityId` union for
|
||||
the whole API foundation. `@suaveplan/api-ws/capability-id` re-exports the
|
||||
two literals this package contributes (`"ws"` and
|
||||
`"ws.broadcast-cross-tenant"`) plus a frozen `WS_CAPABILITY_IDS` map so
|
||||
plugins authoring manifests have a single import to reach for. Type-level
|
||||
extracts pin the literals so adding a new capability upstream cannot
|
||||
silently drift this package's view.
|
||||
|
||||
The frozen object plus the `expectTypeOf` assertions in the conformance
|
||||
test together guarantee both compile-time AND run-time identity of the two
|
||||
capability ids.
|
||||
|
||||
## Features
|
||||
|
||||
- `WsCapabilityId` literal type extract.
|
||||
- `WsBroadcastCrossTenantCapabilityId` literal type extract.
|
||||
- `WS_CAPABILITY_IDS` frozen record (`base`, `crossTenantBroadcast`).
|
||||
- Re-exports the upstream `CapabilityId` union for convenience.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { WS_CAPABILITY_IDS } from "@suaveplan/api-ws/capability-id";
|
||||
|
||||
definePlugin({
|
||||
requires: [WS_CAPABILITY_IDS.base] as const,
|
||||
websockets: { /* ... */ },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
WS_CAPABILITY_IDS,
|
||||
type WsBroadcastCrossTenantCapabilityId,
|
||||
} from "@suaveplan/api-ws/capability-id";
|
||||
|
||||
function assertCrossTenantOk(caps: ReadonlySet<string>): void {
|
||||
if (!caps.has(WS_CAPABILITY_IDS.crossTenantBroadcast)) {
|
||||
throw new Error("missing capability");
|
||||
}
|
||||
}
|
||||
|
||||
type Cap = WsBroadcastCrossTenantCapabilityId;
|
||||
const value: Cap = "ws.broadcast-cross-tenant";
|
||||
void value;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type WsCapabilityId = "ws"`.
|
||||
- `type WsBroadcastCrossTenantCapabilityId = "ws.broadcast-cross-tenant"`.
|
||||
- `const WS_CAPABILITY_IDS: { base: "ws"; crossTenantBroadcast: "ws.broadcast-cross-tenant" }` — frozen.
|
||||
- Re-exported: `type CapabilityId from "@suaveplan/api-types/capabilities"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `satisfies` clause on `WS_CAPABILITY_IDS` is the compile-time gate
|
||||
that fails if either literal is removed from the upstream union, so a
|
||||
future deprecation in api-types will fail to typecheck here and the
|
||||
build stops at the contract boundary rather than at an integration site.
|
||||
- `Extract` keeps the dependency direction one-way — api-ws depends on
|
||||
api-types, never the reverse. Engines downstream of api-ws inherit the
|
||||
same extracts.
|
||||
@@ -0,0 +1,86 @@
|
||||
# `connection-registry` — multi-index lookup for active connections
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Every plugin endpoint that opens a WebSocket connection registers it with a
|
||||
host-owned `ConnectionRegistry`. The registry is the lookup surface every
|
||||
other piece of the engine consumes: the subscription bus iterates
|
||||
connections by `(topic, tenant)`, the per-tenant cap counts by `tenant`,
|
||||
the heartbeat loop walks the registry to find stale connections. Keeping
|
||||
the contract here lets future broker-backed registries (Redis, SurrealDB)
|
||||
swap in without touching plugin code.
|
||||
|
||||
This module ships the abstract `ConnectionRegistry` interface AND a
|
||||
single-host in-memory implementation. The in-memory registry doubles as
|
||||
the default for the conformance harness and is what every test in the
|
||||
Stage-1 contract depends on.
|
||||
|
||||
## Features
|
||||
|
||||
- `ConnectionRegistry` interface — `add`, `remove`, `lookupByTenant`,
|
||||
`lookupByTopic`, `countByTenant`.
|
||||
- `InMemoryConnectionRegistry` — O(1) `add` / `remove` / lookups via three
|
||||
internal maps; idempotent `add` / `remove`; topic bindings dropped
|
||||
automatically when a connection is removed.
|
||||
- `recordSubscription(conn, topic, tenant)` and `dropSubscription(...)`
|
||||
hooks the subscription bus uses to keep the `(topic, tenant)` index in
|
||||
sync.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { InMemoryConnectionRegistry } from "@suaveplan/api-ws/connection-registry";
|
||||
|
||||
const registry = new InMemoryConnectionRegistry();
|
||||
registry.add(connection);
|
||||
const peersForTenant = registry.lookupByTenant(connection.tenantId);
|
||||
registry.remove(connection);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
InMemoryConnectionRegistry,
|
||||
type ConnectionRegistry,
|
||||
} from "@suaveplan/api-ws/connection-registry";
|
||||
|
||||
class TenantCapGate {
|
||||
constructor(
|
||||
private readonly registry: ConnectionRegistry,
|
||||
private readonly maxPerTenant: number,
|
||||
) {}
|
||||
|
||||
accept(conn: { tenantId: TenantId }): boolean {
|
||||
return this.registry.countByTenant(conn.tenantId) < this.maxPerTenant;
|
||||
}
|
||||
}
|
||||
|
||||
const registry: ConnectionRegistry = new InMemoryConnectionRegistry();
|
||||
const gate = new TenantCapGate(registry, 1000);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface ConnectionRegistry { add, remove, lookupByTenant, lookupByTopic, countByTenant }`.
|
||||
- `class InMemoryConnectionRegistry implements ConnectionRegistry`.
|
||||
- `recordSubscription(connection, topic, tenant)` — wired by
|
||||
`SubscriptionBus.subscribe`.
|
||||
- `dropSubscription(connection, topic, tenant)` — wired by `unsubscribe`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The three indices (by id, by tenant, by `(topic, tenant)`) stay in sync
|
||||
through the `add` / `remove` / `recordSubscription` / `dropSubscription`
|
||||
quartet. `remove` walks the topic index because in practice a
|
||||
connection's full subscription set isn't carried on the `WsConnection`
|
||||
contract — engines may keep that state themselves.
|
||||
- `recordSubscription` silently ignores unknown connections so the
|
||||
subscription bus can call it without re-checking membership.
|
||||
- The `(topic, tenant)` key uses `::` as a delimiter; tenant ids that
|
||||
contain `::` are still distinguishable because the encoding is
|
||||
prefix-based, not lossy.
|
||||
77
docs/api-stack/api/api-ws/src/connection/connection.md
Normal file
77
docs/api-stack/api/api-ws/src/connection/connection.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# `connection` — canonical `WsConnection` contract
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/api-ws/connection` declares the abstract `WsConnection`
|
||||
interface every concrete engine (`api-ws-bun`, future browser shims)
|
||||
implements. Plugins author against this contract; the host wires whichever
|
||||
engine the deployment requires. This is The type-only foundation —
|
||||
no runtime, no state, just the interface and its supporting helper types.
|
||||
|
||||
The handshake principal is frozen for the connection's lifetime; the
|
||||
contract intentionally exposes `principal` as a `readonly Principal` so a
|
||||
plugin cannot mutate it mid-session, and the host emits
|
||||
`connection.principal_expired` (an api-host concern) when the underlying
|
||||
session lapses.
|
||||
|
||||
## Features
|
||||
|
||||
- `WsConnection` interface — id, principal, tenantId, lastEventId, state,
|
||||
plus `subscribe / unsubscribe / send / close / ack`.
|
||||
- `WsConnectionState` — `"open" | "closing" | "closed"`.
|
||||
- `SubscribeOptions` — tenant override and optional `filter` predicate.
|
||||
- `SubscriptionFilter<Payload>` — `(payload: Payload) => boolean`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { WsConnection } from "@suaveplan/api-ws/connection";
|
||||
|
||||
async function onConnect(conn: WsConnection): Promise<void> {
|
||||
await conn.subscribe("orders.created", { tenant: conn.tenantId });
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SubscribeOptions,
|
||||
WsConnection,
|
||||
} from "@suaveplan/api-ws/connection";
|
||||
|
||||
interface OrderEvent { priority: number }
|
||||
|
||||
const opts: SubscribeOptions<OrderEvent> = {
|
||||
tenant: tenantId,
|
||||
filter: (payload): boolean => payload.priority >= 5,
|
||||
};
|
||||
|
||||
await conn.subscribe("orders.created", opts);
|
||||
await conn.send({ id: "1", type: "ack", topic: "orders.created" });
|
||||
conn.ack("e-42");
|
||||
if (conn.state !== "open") {
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface WsConnection { id, principal, tenantId, lastEventId, state, subscribe, unsubscribe, send, close, ack }`.
|
||||
- `type WsConnectionState = "open" | "closing" | "closed"`.
|
||||
- `type SubscriptionFilter<Payload> = (payload: Payload) => boolean`.
|
||||
- `interface SubscribeOptions<Payload> { tenant?: TenantId; filter?: SubscriptionFilter<Payload> }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `send` accepts a `WsEnvelope` (the wire schema) so engines can apply
|
||||
the same boundary validation on outbound frames they apply on inbound.
|
||||
- `subscribe` / `unsubscribe` / `send` / `close` are typed as
|
||||
`void | Promise<void>` so engines that need to coordinate with a
|
||||
broker can be async, while in-memory engines can stay synchronous.
|
||||
- `ack` is synchronous because it only updates the connection's
|
||||
`lastEventId` cursor; it never blocks on I/O.
|
||||
78
docs/api-stack/api/api-ws/src/envelope/envelope.md
Normal file
78
docs/api-stack/api/api-ws/src/envelope/envelope.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# `envelope` — wire-frame Zod schema
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/api-ws/envelope` owns the canonical Zod schema for every
|
||||
WebSocket frame that flows between the host and a plugin. Engines parse
|
||||
incoming frames against this schema before the message reaches plugin code,
|
||||
so unknown top-level keys never leak past the host boundary and every frame
|
||||
is guaranteed to carry the correlation id and discriminator the
|
||||
subscription bus, ack pipeline, and reconnect-resume path require.
|
||||
|
||||
The shape is deliberately minimal — `id`, `type`, optional `topic`,
|
||||
optional `payload`, optional `ts` — so plugins can compose their own Zod
|
||||
schemas on top of `payload` without fighting the wire format.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict object schema — extra keys are a parse failure, not a silent drop.
|
||||
- `isWsEnvelope(value): value is WsEnvelope` predicate for ergonomic
|
||||
narrowing inside engine adapters.
|
||||
- `parseWsEnvelope(value)` throws the underlying `ZodError` for callers
|
||||
that want to convert to a `WsError` at the boundary.
|
||||
- Always uses the default-import form `import z from "zod"` per the
|
||||
project Zod policy.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { isWsEnvelope, parseWsEnvelope } from "@suaveplan/api-ws/envelope";
|
||||
|
||||
const frame = parseWsEnvelope(JSON.parse(rawText));
|
||||
console.log(frame.id, frame.type);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { isWsEnvelope, type WsEnvelope } from "@suaveplan/api-ws/envelope";
|
||||
import { WS_CODES, WsError } from "@suaveplan/api-ws/error-codes";
|
||||
import z from "zod";
|
||||
|
||||
const orderPayload = z.object({ orderId: z.string() });
|
||||
|
||||
function handle(raw: unknown): void {
|
||||
if (!isWsEnvelope(raw)) {
|
||||
throw new WsError(WS_CODES.HANDSHAKE_FAILED, "invalid envelope");
|
||||
}
|
||||
const envelope: WsEnvelope = raw;
|
||||
if (envelope.type === "orders.ack") {
|
||||
const payload = orderPayload.parse(envelope.payload);
|
||||
// narrow + branch...
|
||||
void payload;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `wsEnvelopeSchema: z.ZodSchema<WsEnvelope>` — strict object schema.
|
||||
- `type WsEnvelope = z.infer<typeof wsEnvelopeSchema>`.
|
||||
- `isWsEnvelope(value: unknown): value is WsEnvelope` — type predicate.
|
||||
- `parseWsEnvelope(value: unknown): WsEnvelope` — throwing parser.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `strict()` mode is non-negotiable. Engines that need to allow custom
|
||||
extensions MUST do so by defining their own discriminated union of
|
||||
`wsEnvelopeSchema.extend({ ... })` shapes per `type`, not by relaxing the
|
||||
base schema.
|
||||
- `payload` is `z.unknown()` rather than `z.any()` so downstream code is
|
||||
forced to narrow before use.
|
||||
- The Zod default-import form (`import z from "zod"`) is the only
|
||||
Bun-safe form per the project policy and is enforced by a repo-wide
|
||||
grep test.
|
||||
69
docs/api-stack/api/api-ws/src/error-codes/error-codes.md
Normal file
69
docs/api-stack/api/api-ws/src/error-codes/error-codes.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# `error-codes` — WS namespace registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/api-ws` owns the abstract WebSocket contract. Every public failure
|
||||
path raises a `WsError` carrying a stable, namespaced `WS.*` code so consumers
|
||||
can branch on the value without parsing messages. This module declares the
|
||||
eight Stage-1 codes through `defineCodes("WS", [...])` and exposes the
|
||||
matching `WsError` subclass.
|
||||
|
||||
The registry is frozen by `defineCodes` so consumers can rely on the literal
|
||||
string identity of every code at compile time and at run time.
|
||||
|
||||
## Features
|
||||
|
||||
- `WS_CODES` frozen registry with one literal per documented failure mode.
|
||||
- `WsErrorCode` union derived from the registry.
|
||||
- `WsError` class extending `SuaveplanError<WsErrorCode>` with a public
|
||||
constructor and a `wsCode` field that narrows correctly inside
|
||||
`instanceof` branches.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { WS_CODES, WsError } from "@suaveplan/api-ws/error-codes";
|
||||
|
||||
throw new WsError(WS_CODES.HEARTBEAT_FAILED, "two missed pongs");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { WS_CODES, WsError, type WsErrorCode } from "@suaveplan/api-ws/error-codes";
|
||||
|
||||
function describe(error: unknown): string {
|
||||
if (error instanceof WsError) {
|
||||
const code: WsErrorCode = error.wsCode;
|
||||
switch (code) {
|
||||
case WS_CODES.BACKPRESSURE_EVICTED:
|
||||
return "send queue overflow";
|
||||
case WS_CODES.CROSS_TENANT_BROADCAST_DENIED:
|
||||
return "missing ws.broadcast-cross-tenant";
|
||||
default:
|
||||
return code;
|
||||
}
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `WS_CODES: { readonly HANDSHAKE_FAILED: "WS.HANDSHAKE_FAILED", ... }` — frozen record.
|
||||
- `type WsErrorCode = (typeof WS_CODES)[keyof typeof WS_CODES]`.
|
||||
- `class WsError extends SuaveplanError<WsErrorCode>` with
|
||||
`constructor(code, message, options?)` and a `wsCode: WsErrorCode` field.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The eight codes mirror the rows of design-doc §11.4 / §11.5 plus the cross-
|
||||
tenant gate of §11.2 and the broker-unavailability path of §11.3. Adding a
|
||||
new code requires an OpenSpec delta — the registry is intentionally closed.
|
||||
- `WsError` widens the base constructor from `protected` to `public` so
|
||||
callers may construct instances directly. The base class still provides the
|
||||
redaction, OTel adapter, and cause-chain traversal.
|
||||
85
docs/api-stack/api/api-ws/src/heartbeat/heartbeat.md
Normal file
85
docs/api-stack/api/api-ws/src/heartbeat/heartbeat.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# `heartbeat` — ping/pong policy + evaluator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Every WebSocket connection that the Suaveplan API host accepts is policed
|
||||
by a heartbeat: the engine pings at a fixed cadence and forcibly closes the
|
||||
connection once a documented number of consecutive pongs are missed. This
|
||||
module owns the abstract policy and the stateless evaluator that concrete
|
||||
engines call from their heartbeat tick. The defaults — 30000ms cadence,
|
||||
two missed pongs to abort — track design doc §11.4.
|
||||
|
||||
The evaluator is intentionally stateless: it consumes scalar measurements
|
||||
the engine already tracks and returns the next action. State lives with
|
||||
the engine; the contract lives here.
|
||||
|
||||
## Features
|
||||
|
||||
- `HeartbeatPolicy` interface (`heartbeatMs`, `maxMissedPongs`).
|
||||
- `DEFAULT_HEARTBEAT_POLICY` — frozen design-doc defaults.
|
||||
- `evaluateHeartbeat(msSinceLastPong, consecutiveMissedPongs, policy?)` →
|
||||
`"ok" | "ping" | "abort"`.
|
||||
- `raiseHeartbeatFailed(connectionId)` throws the canonical `WsError`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { evaluateHeartbeat } from "@suaveplan/api-ws/heartbeat";
|
||||
|
||||
const action = evaluateHeartbeat(msSinceLastPong, missedPongs);
|
||||
if (action === "ping") sendPing();
|
||||
if (action === "abort") closeAbnormal();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
evaluateHeartbeat,
|
||||
raiseHeartbeatFailed,
|
||||
type HeartbeatPolicy,
|
||||
} from "@suaveplan/api-ws/heartbeat";
|
||||
|
||||
class HeartbeatTicker {
|
||||
private missed = 0;
|
||||
private lastPong = Date.now();
|
||||
|
||||
constructor(private readonly policy: HeartbeatPolicy) {}
|
||||
|
||||
tick(now: number, connectionId: string): void {
|
||||
const action = evaluateHeartbeat(now - this.lastPong, this.missed, this.policy);
|
||||
if (action === "ping") {
|
||||
this.missed += 1;
|
||||
} else if (action === "abort") {
|
||||
raiseHeartbeatFailed(connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
onPong(now: number): void {
|
||||
this.missed = 0;
|
||||
this.lastPong = now;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface HeartbeatPolicy { heartbeatMs: number; maxMissedPongs: number }`.
|
||||
- `const DEFAULT_HEARTBEAT_POLICY: HeartbeatPolicy` (frozen).
|
||||
- `type HeartbeatAction = "ok" | "ping" | "abort"`.
|
||||
- `evaluateHeartbeat(msSinceLastPong, consecutiveMissedPongs, policy?): HeartbeatAction`.
|
||||
- `raiseHeartbeatFailed(connectionId): never`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The evaluator checks `consecutiveMissedPongs` before `msSinceLastPong`
|
||||
so an already-failed connection aborts immediately on the next tick
|
||||
even if the engine's clock briefly stalls.
|
||||
- `consecutiveMissedPongs` is the engine's counter — every ping increments
|
||||
it; every pong resets it. The evaluator does not own that state.
|
||||
- `raiseHeartbeatFailed` is `never` so callers can use it as a terminal
|
||||
branch without an explicit unreachable assertion.
|
||||
@@ -0,0 +1,86 @@
|
||||
# `reconnect-token` — HMAC envelope for resumable WebSocket sessions
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
When the host closes a WebSocket connection cleanly (heartbeat failure,
|
||||
deploy, transient network issue), it issues a reconnect token the client can
|
||||
present on a follow-up upgrade request. The host validates the HMAC and
|
||||
re-attaches the previous subscription set with no plugin code change. This
|
||||
module owns the abstract payload shape and a Web-Crypto-based sign/verify
|
||||
pair. State is carried inside the token; the host stores nothing.
|
||||
|
||||
The TTL default is 5 minutes, matching design doc §11.5. Anything older is
|
||||
rejected with `WS.RECONNECT_TOKEN_INVALID` so a stolen token has a hard
|
||||
upper bound on its usefulness.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict Zod schema for the payload — `tenant`, `principalId`,
|
||||
`lastEventId` (nullable), `exp`.
|
||||
- `signReconnectToken(input, { secret, clock?, ttlMs? })`.
|
||||
- `verifyReconnectToken(token, { secret, clock? })` — throws on bad
|
||||
encoding, bad MAC, bad shape, or expired payload.
|
||||
- Uses Web Crypto (`globalThis.crypto.subtle`) — runs unchanged in Bun,
|
||||
Node 20+, browser, and worker tiers.
|
||||
- Constant-time MAC comparison.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
signReconnectToken,
|
||||
verifyReconnectToken,
|
||||
} from "@suaveplan/api-ws/reconnect-token";
|
||||
|
||||
const secret = crypto.getRandomValues(new Uint8Array(32));
|
||||
const token = await signReconnectToken(
|
||||
{ tenant: "t1", principalId: "p1", lastEventId: "e-9" },
|
||||
{ secret }
|
||||
);
|
||||
const payload = await verifyReconnectToken(token, { secret });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
RECONNECT_TOKEN_TTL_MS,
|
||||
signReconnectToken,
|
||||
verifyReconnectToken,
|
||||
} from "@suaveplan/api-ws/reconnect-token";
|
||||
|
||||
const clock = (): number => mockNow;
|
||||
let mockNow = 1_000_000;
|
||||
|
||||
const token = await signReconnectToken(
|
||||
{ tenant: "t1", principalId: "p1", lastEventId: null },
|
||||
{ secret, clock, ttlMs: RECONNECT_TOKEN_TTL_MS }
|
||||
);
|
||||
mockNow += RECONNECT_TOKEN_TTL_MS;
|
||||
await verifyReconnectToken(token, { secret, clock }); // throws WS.RECONNECT_TOKEN_INVALID
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `const RECONNECT_TOKEN_TTL_MS = 300_000`.
|
||||
- `interface ReconnectTokenPayload { tenant, principalId, lastEventId, exp }`.
|
||||
- `reconnectTokenPayloadSchema: z.ZodSchema<ReconnectTokenPayload>`.
|
||||
- `signReconnectToken(input, options): Promise<string>`.
|
||||
- `verifyReconnectToken(token, options): Promise<ReconnectTokenPayload>` —
|
||||
throws `WsError(WS.RECONNECT_TOKEN_INVALID)` on every failure mode.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- HMAC-SHA-256 over the canonical JSON of the payload (the exact bytes
|
||||
signed are the bytes verified — no re-serialisation).
|
||||
- `base64url` encoding (no padding, `-`/`_` instead of `+`/`/`) keeps the
|
||||
token URL-safe for the `?reconnectToken=…` query parameter.
|
||||
- The verifier uses constant-time comparison so a malicious client cannot
|
||||
recover the MAC byte-by-byte through timing.
|
||||
- All four failure modes (malformed wire form, bad encoding, bad MAC,
|
||||
expired) collapse to a single `WS.RECONNECT_TOKEN_INVALID` code so
|
||||
clients cannot probe for which guard rejected them.
|
||||
@@ -0,0 +1,103 @@
|
||||
# `subscription-bus` — typed fan-out with the cross-tenant capability gate
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SubscriptionBus` is the broadcast surface every plugin uses to fan a
|
||||
payload out to every subscribed connection on the host. It owns the
|
||||
`(topic, tenant)` lookup against the registry, the per-connection filter
|
||||
predicate, and the cross-tenant capability gate documented in design doc
|
||||
§11.2. This module exposes the abstract `SubscriptionBus<Payload>` contract
|
||||
and ships `InMemorySubscriptionBus` — the single-host default used by the
|
||||
conformance harness.
|
||||
|
||||
The bus encodes each broadcast into a `WsEnvelope` exactly once and pushes
|
||||
the same encoded frame to every matching connection; per-connection
|
||||
filters can drop the delivery without re-encoding the payload.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic `SubscriptionBus<Payload>` interface with `subscribe`,
|
||||
`unsubscribe`, `broadcast`, and `asyncIterator`.
|
||||
- `BusCaller` — identity (tenant + capabilities) used to enforce the
|
||||
`ws.broadcast-cross-tenant` capability gate.
|
||||
- `BusSubscribeOptions` and `BusBroadcastOptions` carry the optional
|
||||
tenant override and `filter` predicate.
|
||||
- `InMemorySubscriptionBus` — single-host default; keeps subscriptions
|
||||
indexed by `(topic, tenant)` and integrates with the registry's
|
||||
`recordSubscription` / `dropSubscription` hooks.
|
||||
- `asyncIterator(topic, { tenant? })` — `for await (const event of …)`
|
||||
consumption for plugins that prefer pull-style.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { InMemorySubscriptionBus } from "@suaveplan/api-ws/subscription-bus";
|
||||
|
||||
const bus = new InMemorySubscriptionBus<{ orderId: string }>();
|
||||
bus.subscribe(connection, "orders.created");
|
||||
bus.broadcast(
|
||||
{ tenant: connection.tenantId, capabilities: new Set() },
|
||||
"orders.created",
|
||||
{ tenant: connection.tenantId, payload: { orderId: "o1" } },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
InMemorySubscriptionBus,
|
||||
type BusCaller,
|
||||
} from "@suaveplan/api-ws/subscription-bus";
|
||||
|
||||
interface Alert { priority: number }
|
||||
const bus = new InMemorySubscriptionBus<Alert>();
|
||||
|
||||
bus.subscribe(highPriorityConn, "alerts", {
|
||||
filter: (alert) => alert.priority >= 5,
|
||||
});
|
||||
|
||||
const caller: BusCaller = {
|
||||
tenant: orgA,
|
||||
capabilities: new Set(["ws.broadcast-cross-tenant"]),
|
||||
};
|
||||
|
||||
bus.broadcast(caller, "alerts", {
|
||||
tenant: orgB,
|
||||
payload: { priority: 9 },
|
||||
});
|
||||
|
||||
const iterator = bus.asyncIterator("alerts", { tenant: orgA });
|
||||
for await (const evt of iterator) {
|
||||
console.log(evt.payload.priority);
|
||||
break;
|
||||
}
|
||||
await iterator.return?.();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SubscriptionBus<Payload> { subscribe, unsubscribe, broadcast, asyncIterator }`.
|
||||
- `interface BusCaller { tenant: TenantId; capabilities: ReadonlySet<CapabilityId> }`.
|
||||
- `interface BusBroadcastOptions<Payload> { tenant, payload, filter? }`.
|
||||
- `interface BusSubscribeOptions<Payload> { tenant?, filter? }`.
|
||||
- `class InMemorySubscriptionBus<Payload> implements SubscriptionBus<Payload>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The cross-tenant gate fires when `opts.tenant !== caller.tenant` AND
|
||||
the caller is missing `ws.broadcast-cross-tenant`. Same-tenant
|
||||
broadcasts never need the capability.
|
||||
- The bus and the registry share the `(topic, tenant)` indexing key
|
||||
format so a future audit pass can cross-check the two without a
|
||||
schema gymnastic.
|
||||
- The `asyncIterator` is unbounded — callers MUST `await
|
||||
iterator.return?.()` (or rely on `for await` to do so when the loop
|
||||
exits) to release the bus's listener slot.
|
||||
- Internal envelope ids are produced from the topic + a timestamp + a
|
||||
short random suffix; engines that need stronger guarantees can wrap
|
||||
the bus and override `send` in their own adapter.
|
||||
88
docs/api-stack/api/api-ws/src/testing/conformance.md
Normal file
88
docs/api-stack/api/api-ws/src/testing/conformance.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# `testing` — `runWsEngineConformance` harness
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Every concrete WebSocket engine and broker the foundation publishes
|
||||
(`api-ws-bun`, `api-ws-broker-in-memory`, `api-ws-broker-redis`, future
|
||||
adapters) MUST pass an identical battery of behavioural tests. This module
|
||||
ships that battery as a reusable harness so engine authors get the same
|
||||
verification surface without re-deriving it. The harness covers handshake,
|
||||
subscribe / broadcast fan-out, backpressure policy, heartbeat policy,
|
||||
reconnect-token round trip, broker disposal, and the cross-tenant
|
||||
broadcast capability gate.
|
||||
|
||||
It also ships `inMemoryFactory` — a self-contained `(bus, registry,
|
||||
broker, makeConnection)` quadruple wired against the in-memory primitives
|
||||
shipped in this package — so the harness self-tests cleanly and engine
|
||||
authors have an executable reference.
|
||||
|
||||
## Features
|
||||
|
||||
- `runWsEngineConformance({ label, factory })` — Vitest `describe`/`it`
|
||||
block driving the seven category tests.
|
||||
- `WsEngineFactoryResult` — strongly typed quadruple every adapter
|
||||
returns: `{ bus, registry, broker, makeConnection, dispose }`.
|
||||
- `inMemoryFactory()` — reference factory used by the package's own
|
||||
conformance test.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runWsEngineConformance } from "@suaveplan/api-ws/testing";
|
||||
import { createBunEngine } from "@suaveplan/api-ws-bun";
|
||||
|
||||
runWsEngineConformance({
|
||||
label: "Bun engine",
|
||||
factory: async () => createBunEngine(),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
inMemoryFactory,
|
||||
runWsEngineConformance,
|
||||
type WsEngineFactoryResult,
|
||||
} from "@suaveplan/api-ws/testing";
|
||||
|
||||
async function instrumentedFactory(): Promise<WsEngineFactoryResult> {
|
||||
const base = inMemoryFactory();
|
||||
return {
|
||||
...base,
|
||||
async dispose() {
|
||||
await base.dispose();
|
||||
console.log("engine disposed");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
runWsEngineConformance({
|
||||
label: "instrumented",
|
||||
factory: instrumentedFactory,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface WsEngineFactoryResult<Payload> { bus, registry, broker, makeConnection, dispose }`.
|
||||
- `interface WsEngineConformanceOptions<Payload> { label, factory }`.
|
||||
- `runWsEngineConformance<Payload>(options): void`.
|
||||
- `inMemoryFactory<Payload>(): WsEngineFactoryResult<Payload>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The harness uses `@suaveplan/testing/runner` (NOT a direct `vitest`
|
||||
import) so it stays portable between Vitest and Bun-test under the
|
||||
project's runner conditional-export wrapper.
|
||||
- `dispose()` runs inside a `finally` block so a failing assertion still
|
||||
releases the underlying engine resources.
|
||||
- The cross-tenant test uses a deliberately under-capability `caller`
|
||||
to assert that engines properly delegate to the bus's capability gate
|
||||
rather than re-implementing it incorrectly.
|
||||
- The harness is intentionally additive — engines may layer their own
|
||||
`describe(...)` blocks alongside it for engine-specific behaviour.
|
||||
Reference in New Issue
Block a user