Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,84 @@
{
"name": "@suaveplan/events-transport",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Cross-context event bridges for @suaveplan/events: MessageChannel (at-least-once with ack) and BroadcastChannel (at-most-once) transports with nonce dedup, W3C trace context, and AbortSignal support.",
"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"
}
},
"./core/envelope": {
"import": {
"types": "./dist/core/envelope/envelope.d.ts",
"default": "./dist/core/envelope/envelope.js"
},
"require": {
"types": "./dist/core/envelope/envelope.d.ts",
"default": "./dist/core/envelope/envelope.js"
}
},
"./core/nonce-window": {
"import": {
"types": "./dist/core/nonce-window/nonce-window.d.ts",
"default": "./dist/core/nonce-window/nonce-window.js"
},
"require": {
"types": "./dist/core/nonce-window/nonce-window.d.ts",
"default": "./dist/core/nonce-window/nonce-window.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/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "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"
]
}

View File

@@ -0,0 +1,11 @@
/** Default ack timeout for MessageChannel transport in milliseconds. */
export const DEFAULT_ACK_TIMEOUT_MS = 5000;
/** Default maximum retries for MessageChannel transport. */
export const DEFAULT_MAX_RETRIES = 3;
/** Default nonce dedup window size (number of nonces to track). */
export const DEFAULT_NONCE_WINDOW_SIZE = 1000;
/** Transport envelope version. */
export const ENVELOPE_VERSION = 1 as const;

View File

@@ -0,0 +1,77 @@
# envelope
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `TransportEnvelope` wire format and provides `buildEnvelope` / `parseEnvelope` helpers for `@suaveplan/events-transport`. The envelope is the serialisable container that crosses context boundaries (worker threads, browser tabs). Every envelope carries a version tag, event name, structured-clone-safe payload, a dedup nonce, sender origin, timestamp, and optional W3C trace context headers.
The Zod schema validates all incoming envelopes at the receive boundary, throwing `EventsTransportError(EVENTS_TRANSPORT.ENVELOPE_INVALID)` for malformed data. This prevents processing corrupt or malicious messages from untrusted channels.
## Features
- `TransportEnvelope` — wire format type with v1 literal version tag
- `TransportEnvelopeSchema` — Zod schema for validation and coercion
- `buildEnvelope(event, payload, options)` — creates a typed envelope
- `parseEnvelope(raw)` — validates and returns `TransportEnvelope` or throws
## Usage: Basic Example
```ts
import { buildEnvelope, parseEnvelope } from "@suaveplan/events-transport";
const env = buildEnvelope("order.placed", { orderId: "o-1" }, {
nonce: crypto.randomUUID(),
origin: "tab-main",
ts: Date.now(),
});
const parsed = parseEnvelope(JSON.parse(JSON.stringify(env)));
console.log(parsed.event); // "order.placed"
```
## Usage: Advanced Example
```ts
import { buildEnvelope, parseEnvelope, TransportEnvelopeSchema } from "@suaveplan/events-transport";
// Build with trace context
const env = buildEnvelope("user.created", { id: "u-1" }, {
nonce: "abc-123",
origin: "worker-1",
ts: Date.now(),
traceContext: { traceparent: "00-abc...123-01" },
});
// Validate manually
const result = TransportEnvelopeSchema.safeParse(env);
if (!result.success) {
console.error("invalid envelope");
}
```
## API Reference
### `buildEnvelope(event, payload, options): TransportEnvelope`
| Param | Type | Description |
|---|---|---|
| `event` | `string` | Event name |
| `payload` | `unknown` | Structured-clone-safe payload |
| `options.nonce` | `string` | Unique dedup key |
| `options.origin` | `string` | Sender identifier |
| `options.ts` | `number` | Sender timestamp (ms) |
| `options.traceContext` | `Record<string,string>?` | W3C trace context headers |
### `parseEnvelope(raw): TransportEnvelope`
**Throws**: `EventsTransportError(EVENTS_TRANSPORT.ENVELOPE_INVALID)` if validation fails.
### `TransportEnvelopeSchema`
Zod schema for `TransportEnvelope`. Use directly when integrating with other Zod schemas.
## Implementation Notes
`payload: z.unknown()` in the schema means the payload is not validated structurally — that is left to the event schema validation layer (`@suaveplan/events-schema`). The envelope only validates the envelope _frame_ (version, nonce, origin, ts), not the payload content. This separation keeps the transport layer payload-agnostic.

View File

@@ -0,0 +1,146 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
EVENTS_TRANSPORT_CODES,
EventsTransportError,
} from "../../error-codes.js";
import {
buildEnvelope,
parseEnvelope,
TransportEnvelopeSchema,
} from "./envelope.js";
describe("buildEnvelope", () => {
it("builds a valid envelope", { timeout: 5000 }, () => {
const env = buildEnvelope(
"test.event",
{ x: 1 },
{
nonce: "n-1",
origin: "main",
ts: 12345,
}
);
expect(env.v).toBe(1);
expect(env.event).toBe("test.event");
expect(env.payload).toEqual({ x: 1 });
expect(env.nonce).toBe("n-1");
expect(env.origin).toBe("main");
expect(env.ts).toBe(12345);
expect(env.traceContext).toBeUndefined();
});
it("includes traceContext when provided", { timeout: 5000 }, () => {
const env = buildEnvelope(
"e",
{},
{
nonce: "n",
origin: "o",
ts: 0,
traceContext: { traceparent: "00-abc-01" },
}
);
expect(env.traceContext).toEqual({ traceparent: "00-abc-01" });
});
});
describe("parseEnvelope", () => {
it("parses a valid envelope", { timeout: 5000 }, () => {
const raw = {
v: 1,
event: "e",
payload: 42,
nonce: "n",
origin: "o",
ts: 0,
};
const env = parseEnvelope(raw);
expect(env.v).toBe(1);
expect(env.event).toBe("e");
expect(env.payload).toBe(42);
});
it("parses envelope with traceContext", { timeout: 5000 }, () => {
const raw = {
v: 1,
event: "e",
payload: {},
nonce: "n",
origin: "o",
ts: 0,
traceContext: { k: "v" },
};
const env = parseEnvelope(raw);
expect(env.traceContext).toEqual({ k: "v" });
});
it("throws for invalid envelope", { timeout: 5000 }, () => {
expect(() => parseEnvelope({ v: 2, event: "e" })).toThrow(
EventsTransportError
);
expect(() => parseEnvelope(null)).toThrow(EventsTransportError);
expect(() => parseEnvelope({ v: 1 })).toThrow(EventsTransportError);
});
it("throws with ENVELOPE_INVALID code", { timeout: 5000 }, () => {
const err = (() => {
try {
parseEnvelope({});
} catch (e) {
return e;
}
})();
expect(err).toBeInstanceOf(EventsTransportError);
expect((err as EventsTransportError).code).toBe(
EVENTS_TRANSPORT_CODES.ENVELOPE_INVALID
);
});
it("round-trips through JSON", { timeout: 5000 }, () => {
const env = buildEnvelope(
"e",
{ id: "x" },
{ nonce: "n", origin: "o", ts: 100 }
);
const parsed = parseEnvelope(JSON.parse(JSON.stringify(env)));
expect(parsed.event).toBe("e");
expect(parsed.payload).toEqual({ id: "x" });
});
});
describe("TransportEnvelopeSchema", () => {
it("accepts valid envelope", { timeout: 5000 }, () => {
const result = TransportEnvelopeSchema.safeParse({
v: 1,
event: "e",
payload: {},
nonce: "n",
origin: "o",
ts: 0,
});
expect(result.success).toBe(true);
});
it("rejects missing nonce", { timeout: 5000 }, () => {
const result = TransportEnvelopeSchema.safeParse({
v: 1,
event: "e",
payload: {},
origin: "o",
ts: 0,
});
expect(result.success).toBe(false);
});
it("rejects wrong version", { timeout: 5000 }, () => {
const result = TransportEnvelopeSchema.safeParse({
v: 2,
event: "e",
payload: {},
nonce: "n",
origin: "o",
ts: 0,
});
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,60 @@
import z from "zod";
import { ENVELOPE_VERSION } from "../../constants/index.js";
import {
EVENTS_TRANSPORT_CODES,
EventsTransportError,
} from "../../error-codes.js";
import type { TransportEnvelope } from "./envelope.types.js";
/** Zod schema for TransportEnvelope. */
export const TransportEnvelopeSchema = z.object({
v: z.literal(1),
event: z.string().min(1),
payload: z.unknown(),
nonce: z.string().min(1),
origin: z.string(),
ts: z.number().nonnegative(),
traceContext: z.record(z.string(), z.string()).optional(),
});
/**
* Build a transport envelope from an event name, payload, and options.
*/
export function buildEnvelope(
event: string,
payload: unknown,
options: {
nonce: string;
origin: string;
ts: number;
traceContext?: Record<string, string>;
}
): TransportEnvelope {
const env: TransportEnvelope = {
v: ENVELOPE_VERSION,
event,
payload,
nonce: options.nonce,
origin: options.origin,
ts: options.ts,
};
if (options.traceContext !== undefined) {
env.traceContext = options.traceContext;
}
return env;
}
/**
* Parse and validate a raw value as a TransportEnvelope.
* Throws EventsTransportError if the value is not a valid envelope.
*/
export function parseEnvelope(raw: unknown): TransportEnvelope {
const result = TransportEnvelopeSchema.safeParse(raw);
if (!result.success) {
throw new EventsTransportError(
EVENTS_TRANSPORT_CODES.ENVELOPE_INVALID,
`Invalid transport envelope: ${result.error.issues.map((e) => e.message).join(", ")}`
);
}
return result.data as TransportEnvelope;
}

View File

@@ -0,0 +1,17 @@
/** Wire format for cross-context event delivery. */
export interface TransportEnvelope {
/** Envelope version (always 1). */
v: 1;
/** Event name. */
event: string;
/** Structured-clone-safe payload. */
payload: unknown;
/** Unique per-send dedup nonce. */
nonce: string;
/** Sender identifier. */
origin: string;
/** Sender timestamp (ms since epoch). */
ts: number;
/** W3C trace context headers (optional). */
traceContext?: Record<string, string>;
}

View File

@@ -0,0 +1,6 @@
export {
buildEnvelope,
parseEnvelope,
TransportEnvelopeSchema,
} from "./envelope.js";
export type { TransportEnvelope } from "./envelope.types.js";

View File

@@ -0,0 +1 @@
export { NonceWindow } from "./nonce-window.js";

View File

@@ -0,0 +1,66 @@
# nonce-window
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `NonceWindow`, a sliding-window LRU set for deduplicating transport envelopes by nonce. Both `MessageChannelTransport` and `BroadcastChannelTransport` use it to silently drop duplicate envelopes that arrive within the dedup window, which prevents double-processing under at-least-once delivery (retries) or network-level message duplication.
The window size is configurable — larger windows catch duplicates over longer time horizons at the cost of memory; smaller windows are cheaper but may miss duplicates that arrive after the window has evicted them.
## Features
- `NonceWindow(windowSize?)` — LRU sliding window; default size 1000
- `check(nonce): boolean` — returns `true` (duplicate) or `false` (first-seen); records the nonce if not a duplicate
- `size` getter — current number of tracked nonces
- `clear()` — resets the window
## Usage: Basic Example
```ts
import { NonceWindow } from "@suaveplan/events-transport";
const window = new NonceWindow(100);
window.check("nonce-1"); // false (new)
window.check("nonce-1"); // true (duplicate)
window.check("nonce-2"); // false (new)
```
## Usage: Advanced Example
```ts
import { NonceWindow } from "@suaveplan/events-transport";
// Fill to capacity and verify LRU eviction
const window = new NonceWindow(2);
window.check("a"); // false
window.check("b"); // false
window.check("c"); // false — evicts "a"
window.check("a"); // false — "a" was evicted, treated as new
window.check("b"); // true — "b" still in window
```
## API Reference
### `new NonceWindow(windowSize?: number)`
| Param | Type | Description |
|---|---|---|
| `windowSize` | `number?` | Max nonces to track (default: 1000) |
### `check(nonce: string): boolean`
Records `nonce` if not seen. Returns `true` if already in the window (duplicate), `false` if new.
### `size: number`
Current count of tracked nonces.
### `clear(): void`
Empties the window; all subsequent nonces will be treated as new.
## Implementation Notes
The implementation uses a `string[]` ring-buffer and a `Set<string>` for O(1) lookup. On eviction, the oldest entry is `shift()`ed from the array and deleted from the set. This gives O(1) amortized `check` for the common case. A more memory-efficient LRU cache could use a doubly-linked list + Map for O(1) worst-case, but for window sizes of 1000 the array approach is faster in practice due to cache locality.

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NonceWindow } from "./nonce-window.js";
describe("NonceWindow", () => {
it("returns false for new nonces", { timeout: 5000 }, () => {
const w = new NonceWindow(10);
expect(w.check("a")).toBe(false);
expect(w.check("b")).toBe(false);
expect(w.check("c")).toBe(false);
});
it("returns true for duplicate nonces", { timeout: 5000 }, () => {
const w = new NonceWindow(10);
w.check("x");
expect(w.check("x")).toBe(true);
expect(w.check("x")).toBe(true);
});
it("evicts oldest nonce when at capacity", { timeout: 5000 }, () => {
const w = new NonceWindow(2);
w.check("a");
w.check("b");
w.check("c"); // evicts "a"; window: ["b", "c"]
expect(w.check("b")).toBe(true); // "b" still present
expect(w.check("c")).toBe(true); // "c" still present
expect(w.check("a")).toBe(false); // "a" was evicted, treated as new
});
it("size tracks tracked nonces", { timeout: 5000 }, () => {
const w = new NonceWindow(10);
expect(w.size).toBe(0);
w.check("a");
expect(w.size).toBe(1);
w.check("b");
expect(w.size).toBe(2);
w.check("a"); // duplicate, size unchanged
expect(w.size).toBe(2);
});
it("clear resets the window", { timeout: 5000 }, () => {
const w = new NonceWindow(10);
w.check("a");
w.check("b");
w.clear();
expect(w.size).toBe(0);
expect(w.check("a")).toBe(false); // treated as new after clear
});
it("handles large number of nonces", { timeout: 5000 }, () => {
const w = new NonceWindow(100);
for (let i = 0; i < 200; i++) {
w.check(`nonce-${i}`);
}
expect(w.size).toBe(100);
});
it("default window size is 1000", { timeout: 5000 }, () => {
const w = new NonceWindow();
for (let i = 0; i < 1000; i++) {
w.check(`n${i}`);
}
expect(w.size).toBe(1000);
w.check("n1001"); // evicts n0
expect(w.size).toBe(1000);
});
});

View File

@@ -0,0 +1,46 @@
import { DEFAULT_NONCE_WINDOW_SIZE } from "../../constants/index.js";
/**
* Sliding-window nonce deduplication tracker.
*
* Maintains an LRU set of received nonces up to `windowSize`. Any nonce
* seen within the window is considered a duplicate and should be dropped.
*/
export class NonceWindow {
protected readonly window: string[] = [];
protected readonly windowSize: number;
protected readonly seen = new Set<string>();
public constructor(windowSize: number = DEFAULT_NONCE_WINDOW_SIZE) {
this.windowSize = windowSize;
}
/**
* Returns true if the nonce has already been seen (duplicate).
* If not seen, records it and returns false.
*/
public check(nonce: string): boolean {
if (this.seen.has(nonce)) {
return true;
}
// Evict oldest if at capacity
if (this.window.length >= this.windowSize) {
const oldest = this.window.shift()!;
this.seen.delete(oldest);
}
this.window.push(nonce);
this.seen.add(nonce);
return false;
}
/** Current size of the dedup window. */
public get size(): number {
return this.window.length;
}
/** Clear the dedup window. */
public clear(): void {
this.window.length = 0;
this.seen.clear();
}
}

View File

@@ -0,0 +1,7 @@
export type {
ConnectOptions,
DeliveryGuarantee,
EventTransport,
TransportHandle,
TransportStats,
} from "./transport-interface.types.js";

View File

@@ -0,0 +1,79 @@
# transport-interface
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the core `EventTransport` interface and supporting types (`ConnectOptions`, `TransportHandle`, `TransportStats`, `DeliveryGuarantee`) that all transport implementations in `@suaveplan/events-transport` must satisfy. This interface layer is pure types — no runtime code — enabling consumers to depend on it for typing without pulling in any implementation.
The design follows a connect-and-disconnect pattern: `transport.connect(bus, options?)` returns a `TransportHandle` with `disconnect()` and a `stats` snapshot. The `deliveryGuarantee` property documents the transport's contract so callers can make informed decisions about idempotency and retry at the application layer.
## Features
- `EventTransport` interface with `name`, `deliveryGuarantee`, and `connect`
- `TransportHandle` with `disconnect()` and `stats` snapshot
- `TransportStats` with `sent`, `received`, `dropped`, `duplicatesFiltered` counters
- `DeliveryGuarantee` — `"at-most-once"` or `"at-least-once"`
- `ConnectOptions` — `eventFilter`, `signal`, `logger`, `clock`, `rng`, `idGenerator`
## Usage: Basic Example
```ts
import type { EventTransport, TransportHandle } from "@suaveplan/events-transport";
function withTransport(bus: EventBus, transport: EventTransport): () => void {
const handle: TransportHandle = transport.connect(bus);
console.log("guarantee:", transport.deliveryGuarantee);
return () => handle.disconnect();
}
```
## Usage: Advanced Example
```ts
import type { EventTransport, ConnectOptions } from "@suaveplan/events-transport";
import { EventBus } from "@suaveplan/events";
const bus = new EventBus();
const transport: EventTransport = createMessageChannelTransport({ channel: new MessageChannel() });
const handle = transport.connect(bus, {
eventFilter: (name) => name.startsWith("order."),
signal: AbortSignal.timeout(60_000),
});
console.log(handle.stats.sent); // 0 initially
console.log(handle.stats.received); // 0 initially
handle.disconnect();
```
## API Reference
### `EventTransport`
| Member | Type | Description |
|---|---|---|
| `name` | `string` | Transport identifier |
| `deliveryGuarantee` | `DeliveryGuarantee` | Delivery contract |
| `connect(bus, options?)` | `TransportHandle` | Attach transport to the bus |
### `TransportHandle`
| Member | Type | Description |
|---|---|---|
| `disconnect()` | `void` | Detach from the bus |
| `stats` | `TransportStats` | Live statistics |
### `TransportStats`
| Field | Type | Description |
|---|---|---|
| `sent` | `number` | Envelopes sent |
| `received` | `number` | Envelopes received |
| `dropped` | `number` | Envelopes dropped (non-cloneable, etc.) |
| `duplicatesFiltered` | `number` | Duplicate nonces filtered |
## Implementation Notes
The `EventTransport.connect` method accepts an `EventBus<EventMap>` (the widest type) rather than a specific generic type. This avoids TypeScript covariance issues between different `EventBus<TMap>` types at the cost of losing per-event-type safety at the transport boundary. Type safety is enforced by the event bus itself when re-dispatching received payloads.

View File

@@ -0,0 +1,8 @@
// Re-export types only — no runtime code at this layer.
export type {
ConnectOptions,
DeliveryGuarantee,
EventTransport,
TransportHandle,
TransportStats,
} from "./transport-interface.types.js";

View File

@@ -0,0 +1,57 @@
import type { EventBus, EventMap } from "@suaveplan/events";
import type { LoggerLike, Rng } from "@suaveplan/types";
/** Delivery guarantee contract for a transport. */
export type DeliveryGuarantee = "at-most-once" | "at-least-once";
/** Minimal clock interface for transport timestamp generation. */
export interface TransportClock {
now(): number;
}
/** Minimal ID generator interface for nonce generation. */
export interface TransportIdGenerator {
generate(): string;
}
/** Options passed to transport.connect(). */
export interface ConnectOptions {
/** Filter which events cross the transport boundary. */
eventFilter?: (name: string) => boolean;
/** Cancellation signal. */
signal?: AbortSignal;
/** Diagnostic logger. */
logger?: LoggerLike;
/** Injectable clock. */
clock?: TransportClock;
/** Injectable RNG. */
rng?: Rng;
/** Injectable ID generator. */
idGenerator?: TransportIdGenerator;
}
/** Statistics for a connected transport. */
export interface TransportStats {
sent: number;
received: number;
dropped: number;
duplicatesFiltered: number;
}
/** Handle returned by transport.connect(). */
export interface TransportHandle {
/** Disconnect the transport from the bus. */
disconnect(): void;
/** Current transport statistics. */
readonly stats: TransportStats;
}
/** Core interface for all transport implementations. */
export interface EventTransport {
/** Human-readable transport name. */
readonly name: string;
/** Delivery guarantee of this transport. */
readonly deliveryGuarantee: DeliveryGuarantee;
/** Connect the transport to an event bus. */
connect(bus: EventBus<EventMap>, options?: ConnectOptions): TransportHandle;
}

View File

@@ -0,0 +1,35 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
export const EVENTS_TRANSPORT_CODES = defineCodes("EVENTS_TRANSPORT", [
"NOT_CLONEABLE",
"ACK_TIMEOUT",
"CONNECT_FAILED",
"DISCONNECTED",
"ENVELOPE_INVALID",
] as const);
export type EventsTransportErrorCode =
(typeof EVENTS_TRANSPORT_CODES)[keyof typeof EVENTS_TRANSPORT_CODES];
/**
* Structured error for events-transport operations.
*/
export class EventsTransportError extends SuaveplanError<EventsTransportErrorCode> {
// biome-ignore lint/complexity/noUselessConstructor: exposes protected parent constructor as public
public constructor(
code: EventsTransportErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
}
}
/** Register events-transport error codes (idempotent). */
export function registerEventsTransportErrorCodes(): void {
// defineCodes handles registration; this is a no-op hook.
}

View File

@@ -0,0 +1,40 @@
// Core transport interface
// Constants
export {
DEFAULT_ACK_TIMEOUT_MS,
DEFAULT_MAX_RETRIES,
DEFAULT_NONCE_WINDOW_SIZE,
ENVELOPE_VERSION,
} from "./constants/index.js";
// Envelope
export { buildEnvelope, parseEnvelope } from "./core/envelope/envelope.js";
export type { TransportEnvelope } from "./core/envelope/envelope.types.js";
// Nonce window
export { NonceWindow } from "./core/nonce-window/nonce-window.js";
export type {
ConnectOptions,
DeliveryGuarantee,
EventTransport,
TransportHandle,
TransportStats,
} from "./core/transport-interface/transport-interface.types.js";
export type { EventsTransportErrorCode } from "./error-codes.js";
// Error codes
export {
EVENTS_TRANSPORT_CODES,
EventsTransportError,
registerEventsTransportErrorCodes,
} from "./error-codes.js";
export { createBroadcastChannelTransport } from "./transports/broadcast-channel/broadcast-channel-transport.js";
export type { BroadcastChannelTransportOptions } from "./transports/broadcast-channel/broadcast-channel-transport.types.js";
// Transports
export { createMessageChannelTransport } from "./transports/message-channel/message-channel-transport.js";
export type { MessageChannelTransportOptions } from "./transports/message-channel/message-channel-transport.types.js";
// Utilities
export {
assertStructuredCloneable,
isStructuredCloneable,
} from "./utils/structured-clone-check.js";

View File

@@ -0,0 +1,124 @@
# broadcast-channel-transport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements an `EventTransport` over the browser `BroadcastChannel` API, enabling
**at-most-once** event fan-out to every browser context (tab, worker, iframe) that has opened
a `BroadcastChannel` with the same channel name. Unlike the `MessageChannel` transport, no
acknowledgement is exchanged — delivery is fire-and-forget. This makes it ideal for cache
invalidation, UI refresh signals, and other scenarios where occasional loss is acceptable and
simplicity is preferred over reliability.
The transport filters out messages it sent itself (`origin === "local"`) to prevent event
loops, and uses a `NonceWindow` ring buffer to deduplicate retransmissions from other
transports bridged to the same bus.
## Features
- At-most-once delivery — no ack/retry overhead
- Same-origin loop prevention via `origin` tag in envelope
- Nonce-based duplicate filtering (configurable sliding-window)
- Payload safety check — non-structured-cloneable values are dropped rather than throwing
- AbortSignal support for clean shutdown
- Injectable clock, ID generator, and event filter via `ConnectOptions`
- Per-transport statistics: `sent`, `received`, `dropped`, `duplicatesFiltered`
## Usage: Basic Example
```ts
import { createBroadcastChannelTransport } from "@suaveplan/events-transport/transports/broadcast-channel/broadcast-channel-transport";
import { EventBus } from "@suaveplan/events";
const channel = new BroadcastChannel("app-events");
const bus = new EventBus<{ "cache.invalidated": { key: string } }>();
const transport = createBroadcastChannelTransport({ channel });
const handle = transport.connect(bus);
bus.on("cache.invalidated", (p) => console.log("invalidate", p.key));
await bus.emitAsync("cache.invalidated", { key: "user:42" });
// Event is broadcast to all other contexts on the same channel name
handle.disconnect();
channel.close();
```
## Usage: Advanced Example
```ts
import { createBroadcastChannelTransport } from "@suaveplan/events-transport/transports/broadcast-channel/broadcast-channel-transport";
import { EventBus } from "@suaveplan/events";
const channel = new BroadcastChannel("notifications");
const bus = new EventBus<{ "notification.show": { msg: string } }>();
const ac = new AbortController();
const transport = createBroadcastChannelTransport({
channel,
nonceWindowSize: 200,
});
const handle = transport.connect(bus, {
signal: ac.signal,
eventFilter: (name) => name.startsWith("notification."),
clock: { now: () => performance.now() },
idGenerator: { generate: () => crypto.randomUUID() },
});
bus.on("notification.show", (p) => console.log(p.msg));
await bus.emitAsync("notification.show", { msg: "Hello from another tab!" });
console.log(handle.stats.sent); // 1
ac.abort();
handle.disconnect();
channel.close();
```
## API Reference
### `createBroadcastChannelTransport(options): EventTransport`
| Param | Type | Description |
|---|---|---|
| `options.channel` | `BroadcastChannel` | The broadcast channel to use |
| `options.nonceWindowSize` | `number?` | Dedup window size. Default: `1000` |
**Returns**: `EventTransport` with `deliveryGuarantee: "at-most-once"`
### `EventTransport.connect(bus, options?): TransportHandle`
Connects the transport to an `EventBus`. Installs a `bus.use()` interceptor to capture
outbound dispatches and a `channel.onmessage` listener for inbound envelopes.
| Param | Type | Description |
|---|---|---|
| `bus` | `EventBus` | The bus to bridge |
| `options.eventFilter` | `(name: string) => boolean` | Filter events. Default: all events |
| `options.signal` | `AbortSignal?` | Cancellation |
| `options.clock` | `Clock?` | Injectable clock |
| `options.idGenerator` | `IdGenerator?` | Injectable nonce generator |
### `TransportHandle`
| Member | Type | Description |
|---|---|---|
| `disconnect()` | `() => void` | Uninstall interceptor and listener |
| `stats` | `TransportStats` | Snapshot of sent/received/dropped/duplicatesFiltered counts |
## Implementation Notes
Because `BroadcastChannel` delivers messages to every context **except** the sender, this
transport does not need to track pending acks. However, the `origin: "local"` tag is still
set on outbound envelopes so that if two tabs have the same logical bus connected via the
same `BroadcastChannel`, a message sent from tab A will not be re-dispatched by tab A's
own `onMessage` listener (which would cause a local loop if the same channel name were
somehow looped back).
The dedup window guards against scenarios where multiple transports are bridged together —
for example, a `MessageChannel` transport forwarding into a `BroadcastChannel` transport —
where the same nonce could arrive from multiple paths.

View File

@@ -0,0 +1,418 @@
import { EventBus } from "@suaveplan/events";
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from "@suaveplan/testing/runner";
import { createBroadcastChannelTransport } from "./broadcast-channel-transport.js";
import type { BroadcastChannelTransportOptions } from "./broadcast-channel-transport.types.js";
// Minimal BroadcastChannel mock for Node.js / Vitest forks environment
class MockBroadcastChannel {
public name: string;
protected listeners: ((ev: MessageEvent) => void)[] = [];
// Static registry to simulate cross-context delivery
protected static registry = new Map<string, MockBroadcastChannel[]>();
public constructor(name: string) {
this.name = name;
const existing = MockBroadcastChannel.registry.get(name) ?? [];
existing.push(this);
MockBroadcastChannel.registry.set(name, existing);
}
public addEventListener(
_type: string,
handler: (ev: MessageEvent) => void
): void {
this.listeners.push(handler);
}
public removeEventListener(
_type: string,
handler: (ev: MessageEvent) => void
): void {
this.listeners = this.listeners.filter((l) => l !== handler);
}
public postMessage(data: unknown): void {
// Deliver to ALL other instances on same channel name (not self)
const peers = MockBroadcastChannel.registry.get(this.name) ?? [];
for (const peer of peers) {
if (peer !== this) {
for (const listener of peer.listeners) {
listener({ data } as MessageEvent);
}
}
}
}
public close(): void {
const existing = MockBroadcastChannel.registry.get(this.name) ?? [];
MockBroadcastChannel.registry.set(
this.name,
existing.filter((c) => c !== this)
);
this.listeners = [];
}
public static reset(): void {
MockBroadcastChannel.registry.clear();
}
}
function makeBroadcastChannelTransportOptions(
name = "test-channel",
opts?: Partial<BroadcastChannelTransportOptions>
): {
channel: MockBroadcastChannel;
options: BroadcastChannelTransportOptions;
} {
const channel = new MockBroadcastChannel(name);
return {
channel,
options: { channel: channel as unknown as BroadcastChannel, ...opts },
};
}
describe("createBroadcastChannelTransport", () => {
beforeEach(() => {
MockBroadcastChannel.reset();
});
afterEach(() => {
MockBroadcastChannel.reset();
});
it("returns transport with correct name and guarantee", {
timeout: 5000,
}, () => {
const { options } = makeBroadcastChannelTransportOptions();
const transport = createBroadcastChannelTransport(options);
expect(transport.name).toBe("broadcast-channel");
expect(transport.deliveryGuarantee).toBe("at-most-once");
});
it("connect returns handle with initial zero stats", {
timeout: 5000,
}, () => {
const { options } = makeBroadcastChannelTransportOptions();
const bus = new EventBus<Record<string, unknown>>();
const transport = createBroadcastChannelTransport(options);
const handle = transport.connect(bus);
expect(handle.stats).toMatchObject({
sent: 0,
received: 0,
dropped: 0,
duplicatesFiltered: 0,
});
handle.disconnect();
});
it("broadcasts event to peer channel", { timeout: 10000 }, async () => {
const channelName = "broadcast-test";
const busA = new EventBus<{ hello: { msg: string } }>();
const busB = new EventBus<{ hello: { msg: string } }>();
const { options: optA } =
makeBroadcastChannelTransportOptions(channelName);
const { options: optB } =
makeBroadcastChannelTransportOptions(channelName);
const transportA = createBroadcastChannelTransport(optA);
const transportB = createBroadcastChannelTransport(optB);
const handleA = transportA.connect(busA);
const handleB = transportB.connect(busB);
const received: { msg: string }[] = [];
busB.on("hello", (p) => received.push(p));
await busA.emitAsync("hello", { msg: "world" });
await new Promise((resolve) => setTimeout(resolve, 100));
expect(received).toHaveLength(1);
expect(received[0]).toEqual({ msg: "world" });
expect(handleA.stats.sent).toBe(1);
expect(handleB.stats.received).toBe(1);
handleA.disconnect();
handleB.disconnect();
});
it("does not loop back to self", { timeout: 10000 }, async () => {
const { options } = makeBroadcastChannelTransportOptions();
const bus = new EventBus<{ self: { v: number } }>();
const transport = createBroadcastChannelTransport(options);
const handle = transport.connect(bus);
const selfReceived: { v: number }[] = [];
bus.on("self", (p) => selfReceived.push(p));
await bus.emitAsync("self", { v: 1 });
await new Promise((resolve) => setTimeout(resolve, 100));
// The local listener fires via local bus.emit, but the transport
// should NOT re-dispatch the broadcast back to itself
// (MockBroadcastChannel doesn't deliver to self)
expect(handle.stats.received).toBe(0);
handle.disconnect();
});
it("deduplicates messages with same nonce", {
timeout: 10000,
}, async () => {
const channelName = "dedup-test";
const { options: optA } =
makeBroadcastChannelTransportOptions(channelName);
const { options: optB } =
makeBroadcastChannelTransportOptions(channelName);
const busA = new EventBus<Record<string, unknown>>();
const busB = new EventBus<Record<string, unknown>>();
const transportA = createBroadcastChannelTransport(optA);
const transportB = createBroadcastChannelTransport(optB);
const handleA = transportA.connect(busA);
const handleB = transportB.connect(busB);
// Manually post the same envelope twice to busB's channel
const envelope = {
v: 1,
event: "dup-event",
payload: { n: 1 },
nonce: "dup-nonce-xyz",
origin: "remote",
ts: Date.now(),
};
// Simulate external post directly to busB's listener
const channelB = optB.channel as unknown as MockBroadcastChannel;
for (const listener of (
channelB as { listeners: ((e: MessageEvent) => void)[] }
).listeners) {
listener({ data: envelope } as MessageEvent);
listener({ data: envelope } as MessageEvent); // duplicate
}
await new Promise((resolve) => setTimeout(resolve, 100));
expect(handleB.stats.received).toBe(1);
expect(handleB.stats.duplicatesFiltered).toBe(1);
handleA.disconnect();
handleB.disconnect();
});
it("drops malformed inbound messages", { timeout: 10000 }, async () => {
const { options } = makeBroadcastChannelTransportOptions();
const bus = new EventBus<Record<string, unknown>>();
const transport = createBroadcastChannelTransport(options);
const handle = transport.connect(bus);
const channel = options.channel as unknown as MockBroadcastChannel;
for (const listener of (
channel as { listeners: ((e: MessageEvent) => void)[] }
).listeners) {
listener({ data: { garbage: true } } as MessageEvent);
}
await new Promise((resolve) => setTimeout(resolve, 100));
expect(handle.stats.dropped).toBe(1);
handle.disconnect();
});
it("drops non-structured-cloneable payloads", {
timeout: 10000,
}, async () => {
const { options } = makeBroadcastChannelTransportOptions();
const bus = new EventBus<Record<string, unknown>>();
const transport = createBroadcastChannelTransport(options);
const handle = transport.connect(bus);
await bus.emitAsync("fn-event" as never, (() => {}) as never);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(handle.stats.dropped).toBe(1);
expect(handle.stats.sent).toBe(0);
handle.disconnect();
});
it("respects eventFilter — blocked events are not broadcast", {
timeout: 10000,
}, async () => {
const channelName = "filter-test";
const { options: optA } =
makeBroadcastChannelTransportOptions(channelName);
const { options: optB } =
makeBroadcastChannelTransportOptions(channelName);
const busA = new EventBus<{
allowed: { v: number };
blocked: { v: number };
}>();
const busB = new EventBus<{
allowed: { v: number };
blocked: { v: number };
}>();
const transportA = createBroadcastChannelTransport(optA);
const transportB = createBroadcastChannelTransport(optB);
const handleA = transportA.connect(busA, {
eventFilter: (name) => name === "allowed",
});
const handleB = transportB.connect(busB);
const receivedB: string[] = [];
busB.on("allowed", () => receivedB.push("allowed"));
busB.on("blocked", () => receivedB.push("blocked"));
await busA.emitAsync("allowed", { v: 1 });
await busA.emitAsync("blocked", { v: 2 });
await new Promise((resolve) => setTimeout(resolve, 100));
expect(receivedB).toEqual(["allowed"]);
expect(handleA.stats.sent).toBe(1);
handleA.disconnect();
handleB.disconnect();
});
it("respects AbortSignal", { timeout: 10000 }, async () => {
const channelName = "abort-test";
const { options: optA } =
makeBroadcastChannelTransportOptions(channelName);
const { options: optB } =
makeBroadcastChannelTransportOptions(channelName);
const busA = new EventBus<{ ev: { v: number } }>();
const busB = new EventBus<{ ev: { v: number } }>();
const ac = new AbortController();
const transportA = createBroadcastChannelTransport(optA);
const transportB = createBroadcastChannelTransport(optB);
const handleA = transportA.connect(busA, { signal: ac.signal });
const handleB = transportB.connect(busB);
ac.abort();
await busA.emitAsync("ev", { v: 1 });
await new Promise((resolve) => setTimeout(resolve, 100));
expect(handleA.stats.sent).toBe(0);
expect(handleB.stats.received).toBe(0);
handleA.disconnect();
handleB.disconnect();
});
it("disconnect removes listener and unsubscribes interceptor", {
timeout: 5000,
}, () => {
const { options } = makeBroadcastChannelTransportOptions();
const bus = new EventBus<Record<string, unknown>>();
const transport = createBroadcastChannelTransport(options);
const handle = transport.connect(bus);
const channel = options.channel as unknown as MockBroadcastChannel;
const listenersBefore = (channel as { listeners: unknown[] }).listeners
.length;
handle.disconnect();
const listenersAfter = (channel as { listeners: unknown[] }).listeners
.length;
expect(listenersAfter).toBeLessThan(listenersBefore);
});
it("uses custom clock and idGenerator", { timeout: 10000 }, async () => {
const channelName = "custom-clock-test";
const { options: optA } =
makeBroadcastChannelTransportOptions(channelName);
const { options: optB } =
makeBroadcastChannelTransportOptions(channelName);
const busA = new EventBus<{ ts: { v: number } }>();
const busB = new EventBus<{ ts: { v: number } }>();
const clock = { now: vi.fn(() => 99999) };
const idGenerator = { generate: vi.fn(() => "my-nonce") };
const transportA = createBroadcastChannelTransport(optA);
const transportB = createBroadcastChannelTransport(optB);
const sentEnvelopes: unknown[] = [];
const origPost = (optB.channel as unknown as MockBroadcastChannel)
.postMessage;
// Capture envelopes received by busB's listener
const channelB = optB.channel as unknown as MockBroadcastChannel;
const captureListener = (ev: MessageEvent) =>
sentEnvelopes.push(ev.data);
channelB.addEventListener("message", captureListener);
const handleA = transportA.connect(busA, { clock, idGenerator });
const handleB = transportB.connect(busB);
void origPost;
await busA.emitAsync("ts", { v: 1 });
await new Promise((resolve) => setTimeout(resolve, 100));
// busB received the envelope; check that the sent envelope has our custom ts and nonce
const _channelA = optA.channel as unknown as MockBroadcastChannel;
// Post listener on A side won't see its own messages (self-exclusion in mock)
// but B received it
expect(handleB.stats.received).toBe(1);
// Verify clock and idGenerator were called
expect(clock.now).toHaveBeenCalled();
expect(idGenerator.generate).toHaveBeenCalled();
channelB.removeEventListener("message", captureListener);
handleA.disconnect();
handleB.disconnect();
});
it("ignores inbound messages after signal abort", {
timeout: 10000,
}, async () => {
const { options } = makeBroadcastChannelTransportOptions();
const bus = new EventBus<Record<string, unknown>>();
const ac = new AbortController();
const transport = createBroadcastChannelTransport(options);
const handle = transport.connect(bus, { signal: ac.signal });
const received: unknown[] = [];
bus.on("ev" as never, (p) => received.push(p));
// Abort before delivering message
ac.abort();
const channel = options.channel as unknown as MockBroadcastChannel;
const envelope = {
v: 1,
event: "ev",
payload: { v: 1 },
nonce: "abort-nonce-bc",
origin: "remote",
ts: Date.now(),
};
for (const listener of (
channel as { listeners: ((e: MessageEvent) => void)[] }
).listeners) {
listener({ data: envelope } as MessageEvent);
}
await new Promise((resolve) => setTimeout(resolve, 100));
expect(received).toHaveLength(0);
expect(handle.stats.received).toBe(0);
handle.disconnect();
});
});

View File

@@ -0,0 +1,118 @@
import type {
EventBus,
EventEnvelope,
EventMap,
Next,
} from "@suaveplan/events";
import { DEFAULT_NONCE_WINDOW_SIZE } from "../../constants/index.js";
import { buildEnvelope, parseEnvelope } from "../../core/envelope/envelope.js";
import { NonceWindow } from "../../core/nonce-window/nonce-window.js";
import type {
ConnectOptions,
EventTransport,
TransportHandle,
TransportStats,
} from "../../core/transport-interface/transport-interface.types.js";
import { isStructuredCloneable } from "../../utils/structured-clone-check.js";
import type { BroadcastChannelTransportOptions } from "./broadcast-channel-transport.types.js";
/**
* Creates a BroadcastChannel-based EventTransport with at-most-once delivery.
*
* All events are broadcast to every listener on the same channel name. No
* acknowledgement is sent or expected — delivery is fire-and-forget. Duplicate
* nonces from the same origin are filtered via a sliding NonceWindow.
*/
export function createBroadcastChannelTransport(
options: BroadcastChannelTransportOptions
): EventTransport {
const { channel } = options;
const nonceWindowSize =
options.nonceWindowSize ?? DEFAULT_NONCE_WINDOW_SIZE;
return {
name: "broadcast-channel",
deliveryGuarantee: "at-most-once",
connect(
bus: EventBus<EventMap>,
connectOptions?: ConnectOptions
): TransportHandle {
const filter = connectOptions?.eventFilter ?? (() => true);
const signal = connectOptions?.signal;
const clock = connectOptions?.clock ?? { now: () => Date.now() };
const idGen = connectOptions?.idGenerator ?? {
generate: () => Math.random().toString(36).slice(2),
};
const nonceWindow = new NonceWindow(nonceWindowSize);
const stats: TransportStats = {
sent: 0,
received: 0,
dropped: 0,
duplicatesFiltered: 0,
};
// Receive inbound broadcasts
const onMessage = (event: MessageEvent): void => {
if (signal?.aborted) return;
const data: unknown = event.data;
let envelope: ReturnType<typeof parseEnvelope>;
try {
envelope = parseEnvelope(data);
} catch {
stats.dropped++;
return;
}
// Dedup check
if (nonceWindow.check(envelope.nonce)) {
stats.duplicatesFiltered++;
return;
}
stats.received++;
bus.emit(
envelope.event as keyof EventMap,
envelope.payload as EventMap[keyof EventMap]
);
};
channel.addEventListener("message", onMessage);
// Outbound: intercept bus dispatches and broadcast
const unsubscribe = bus.use(
async (envRaw: EventEnvelope, next: Next) => {
const result = await next();
const eventName = envRaw.type;
if (!filter(eventName)) return result;
if (!isStructuredCloneable(envRaw.payload)) {
stats.dropped++;
return result;
}
if (signal?.aborted) return result;
const nonce = idGen.generate();
const env = buildEnvelope(eventName, envRaw.payload, {
nonce,
origin: "local",
ts: clock.now(),
});
channel.postMessage(env);
stats.sent++;
return result;
}
);
return {
disconnect(): void {
unsubscribe();
channel.removeEventListener("message", onMessage);
},
get stats(): TransportStats {
return { ...stats };
},
};
},
};
}

View File

@@ -0,0 +1,7 @@
/** Options for creating a BroadcastChannel transport. */
export interface BroadcastChannelTransportOptions {
/** The BroadcastChannel instance to communicate over. */
channel: BroadcastChannel;
/** Dedup window size. Default: 1000. */
nonceWindowSize?: number;
}

View File

@@ -0,0 +1,2 @@
export { createBroadcastChannelTransport } from "./broadcast-channel-transport.js";
export type { BroadcastChannelTransportOptions } from "./broadcast-channel-transport.types.js";

View File

@@ -0,0 +1,2 @@
export { createMessageChannelTransport } from "./message-channel-transport.js";
export type { MessageChannelTransportOptions } from "./message-channel-transport.types.js";

View File

@@ -0,0 +1,127 @@
# message-channel-transport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements an `EventTransport` over the browser and Node.js `MessageChannel` API, providing
bidirectional, structured-cloneable event forwarding between two `MessagePort` endpoints with
**at-least-once delivery** semantics. Every outbound event envelope is posted to `port1` and
the sender waits for an explicit acknowledgement from the receiver on `port2` before recording
the send as successful. If the ack does not arrive within the configured timeout, the message
is retried up to `maxRetries` times; unacked messages are tracked as dropped in the transport
statistics.
## Features
- At-least-once delivery via ack/retry protocol
- Nonce-based duplicate filtering on the receive side (configurable sliding-window size)
- Payload safety check — non-structured-cloneable values are dropped rather than throwing
- Per-transport statistics: `sent`, `received`, `dropped`, `duplicatesFiltered`
- AbortSignal support: stops outbound sends and inbound processing after abort
- Injectable clock, ID generator, and event filter via `ConnectOptions`
- Clean disconnect with pending ack cleanup
## Usage: Basic Example
```ts
import { createMessageChannelTransport } from "@suaveplan/events-transport/transports/message-channel/message-channel-transport";
import { EventBus } from "@suaveplan/events";
const channel = new MessageChannel();
const bus = new EventBus<{ "user.created": { id: string } }>();
const transport = createMessageChannelTransport({ channel });
const handle = transport.connect(bus);
bus.on("user.created", (p) => console.log("received", p.id));
await bus.emitAsync("user.created", { id: "u-1" });
console.log(handle.stats.sent); // 1
handle.disconnect();
```
## Usage: Advanced Example
```ts
import { createMessageChannelTransport } from "@suaveplan/events-transport/transports/message-channel/message-channel-transport";
import { EventBus } from "@suaveplan/events";
const channel = new MessageChannel();
const busA = new EventBus<{ ping: { seq: number } }>();
const busB = new EventBus<{ ping: { seq: number } }>();
const ac = new AbortController();
// Wire busA's port1 to busB's port2 via a shared channel
const transportA = createMessageChannelTransport({
channel,
ackTimeoutMs: 2000,
maxRetries: 5,
nonceWindowSize: 500,
});
const handleA = transportA.connect(busA, {
signal: ac.signal,
eventFilter: (name) => name.startsWith("ping"),
});
// busB receives events
busB.on("ping", (p) => console.log("busB got ping", p.seq));
// busA emits, arrives on busB
await busA.emitAsync("ping", { seq: 1 });
console.log(handleA.stats); // { sent: 1, received: 0, dropped: 0, duplicatesFiltered: 0 }
ac.abort();
handleA.disconnect();
```
## API Reference
### `createMessageChannelTransport(options): EventTransport`
| Param | Type | Description |
|---|---|---|
| `options.channel` | `MessageChannel` | The channel to communicate over |
| `options.ackTimeoutMs` | `number?` | Ack wait timeout in ms. Default: `5000` |
| `options.maxRetries` | `number?` | Max retries before drop. Default: `3` |
| `options.nonceWindowSize` | `number?` | Dedup window size. Default: `1000` |
**Returns**: `EventTransport` with `deliveryGuarantee: "at-least-once"`
### `EventTransport.connect(bus, options?): TransportHandle`
Connects the transport to an `EventBus`. Installs a `bus.use()` interceptor to capture outbound
dispatches and a `port2` message listener to receive inbound envelopes.
| Param | Type | Description |
|---|---|---|
| `bus` | `EventBus` | The bus to bridge |
| `options.eventFilter` | `(name: string) => boolean` | Filter events. Default: all events |
| `options.signal` | `AbortSignal?` | Cancellation |
| `options.clock` | `Clock?` | Injectable clock |
| `options.idGenerator` | `IdGenerator?` | Injectable nonce generator |
### `TransportHandle`
| Member | Type | Description |
|---|---|---|
| `disconnect()` | `() => void` | Uninstall interceptor, stop listener, clear pending acks |
| `stats` | `TransportStats` | Snapshot of sent/received/dropped/duplicatesFiltered counts |
## Implementation Notes
The ack protocol uses a `Map<string, () => void>` of pending ack resolvers keyed by nonce.
When `port1.postMessage(envelope)` is sent, a `Promise<boolean>` is created that resolves
`true` when the matching `{ ack: nonce }` reply arrives on `port2`, or `false` when the
timeout fires. The retry loop retries the full postMessage on each `false` result.
Non-structured-cloneable payloads are silently dropped (incrementing `stats.dropped`) rather
than throwing because the interceptor runs after `next()` — the local dispatch has already
succeeded. Throwing here would be misleading since the event was processed locally.
Duplicate filtering on receive uses a `NonceWindow` ring buffer so the sender can safely retry
without the receiver processing the same event twice.

View File

@@ -0,0 +1,426 @@
import { EventBus } from "@suaveplan/events";
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from "@suaveplan/testing/runner";
import { createMessageChannelTransport } from "./message-channel-transport.js";
describe("createMessageChannelTransport", () => {
let channel: MessageChannel;
beforeEach(() => {
channel = new MessageChannel();
});
afterEach(() => {
channel.port1.close();
channel.port2.close();
});
it("returns a transport with correct name and guarantee", {
timeout: 5000,
}, () => {
const transport = createMessageChannelTransport({ channel });
expect(transport.name).toBe("message-channel");
expect(transport.deliveryGuarantee).toBe("at-least-once");
});
it("connect returns a handle with stats and disconnect", {
timeout: 5000,
}, () => {
const transport = createMessageChannelTransport({ channel });
const bus = new EventBus<Record<string, unknown>>();
const handle = transport.connect(bus);
expect(handle.stats).toMatchObject({
sent: 0,
received: 0,
dropped: 0,
duplicatesFiltered: 0,
});
handle.disconnect();
});
it("sends an event across the channel and receives ack", {
timeout: 15000,
}, async () => {
const busA = new EventBus<{ ping: { v: number } }>();
const transport = createMessageChannelTransport({
channel,
ackTimeoutMs: 2000,
maxRetries: 3,
});
// Connect busA — transport owns port1, remote (test) uses port2.
// Transport sends outbound via port1.postMessage → remote receives on port2.
// Remote sends ack via port2.postMessage → transport receives on port1.
const handleA = transport.connect(busA);
const received: { v: number }[] = [];
channel.port2.addEventListener("message", (ev) => {
const env = ev.data as {
nonce: string;
event: string;
payload: unknown;
};
if ("ack" in env) return; // skip acks
received.push(env.payload as { v: number });
channel.port2.postMessage({ ack: env.nonce });
});
channel.port2.start();
await busA.emitAsync("ping", { v: 42 });
expect(received).toHaveLength(1);
expect(received[0]).toEqual({ v: 42 });
expect(handleA.stats.sent).toBe(1);
expect(handleA.stats.dropped).toBe(0);
handleA.disconnect();
});
it("increments received and emits on inbound message", {
timeout: 10000,
}, async () => {
const bus = new EventBus<{ foo: { x: number } }>();
const transport = createMessageChannelTransport({
channel,
ackTimeoutMs: 1000,
});
const handle = transport.connect(bus);
const events: { x: number }[] = [];
bus.on("foo", (p) => events.push(p));
// Simulate the remote sending an envelope from port2 → received by port1
const envelope = {
v: 1,
event: "foo",
payload: { x: 99 },
nonce: "test-nonce-1",
origin: "remote",
ts: Date.now(),
};
channel.port2.postMessage(envelope);
// Wait for re-dispatch
await new Promise((resolve) => setTimeout(resolve, 200));
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ x: 99 });
expect(handle.stats.received).toBe(1);
handle.disconnect();
});
it("filters duplicates via nonce window", { timeout: 10000 }, async () => {
const bus = new EventBus<{ dup: { n: number } }>();
const transport = createMessageChannelTransport({
channel,
ackTimeoutMs: 1000,
});
const handle = transport.connect(bus);
const events: { n: number }[] = [];
bus.on("dup", (p) => events.push(p));
const envelope = {
v: 1,
event: "dup",
payload: { n: 1 },
nonce: "dedup-nonce",
origin: "remote",
ts: Date.now(),
};
// Send twice from remote (port2) — transport receives both on port1
channel.port2.postMessage(envelope);
channel.port2.postMessage(envelope); // duplicate
await new Promise((resolve) => setTimeout(resolve, 300));
expect(events).toHaveLength(1);
expect(handle.stats.received).toBe(1);
expect(handle.stats.duplicatesFiltered).toBe(1);
handle.disconnect();
});
it("drops malformed inbound messages", { timeout: 10000 }, async () => {
const bus = new EventBus<Record<string, unknown>>();
const transport = createMessageChannelTransport({
channel,
ackTimeoutMs: 1000,
});
const handle = transport.connect(bus);
// Send a malformed message from remote side
channel.port2.postMessage({ totally: "invalid" });
await new Promise((resolve) => setTimeout(resolve, 200));
expect(handle.stats.dropped).toBe(1);
expect(handle.stats.received).toBe(0);
handle.disconnect();
});
it("retries on ack timeout and drops after maxRetries", {
timeout: 20000,
}, async () => {
const bus = new EventBus<{ retry: { n: number } }>();
const transport = createMessageChannelTransport({
channel,
ackTimeoutMs: 100,
maxRetries: 2,
});
const handle = transport.connect(bus);
// Do NOT respond with ack — so it should timeout and retry then drop.
// Remote listens on port2 to see what the transport sends.
const sentMessages: unknown[] = [];
channel.port2.addEventListener("message", (ev) => {
sentMessages.push(ev.data);
// Intentionally NOT sending ack
});
channel.port2.start();
bus.emitAsync("retry", { n: 1 }).catch(() => {
// intentionally ignored
});
// Wait for retries + drop
await new Promise((resolve) => setTimeout(resolve, 1000));
// Should have tried maxRetries times
expect(sentMessages.length).toBeGreaterThanOrEqual(2);
expect(handle.stats.dropped).toBe(1);
expect(handle.stats.sent).toBe(0);
handle.disconnect();
});
it("respects eventFilter — filtered events are not sent", {
timeout: 10000,
}, async () => {
const bus = new EventBus<{
allowed: { v: number };
blocked: { v: number };
}>();
const transport = createMessageChannelTransport({
channel,
ackTimeoutMs: 500,
});
const sentMessages: unknown[] = [];
channel.port2.addEventListener("message", (ev) => {
const env = ev.data as { nonce?: string };
if ("ack" in env) return;
sentMessages.push(ev.data);
channel.port2.postMessage({ ack: env.nonce });
});
channel.port2.start();
const handle = transport.connect(bus, {
eventFilter: (name) => name === "allowed",
});
await bus.emitAsync("allowed", { v: 1 });
await bus.emitAsync("blocked", { v: 2 });
await new Promise((resolve) => setTimeout(resolve, 200));
expect(sentMessages).toHaveLength(1);
expect((sentMessages[0] as { event: string }).event).toBe("allowed");
expect(handle.stats.sent).toBe(1);
handle.disconnect();
});
it("respects AbortSignal", { timeout: 10000 }, async () => {
const bus = new EventBus<{ ev: { v: number } }>();
const ac = new AbortController();
const transport = createMessageChannelTransport({
channel,
ackTimeoutMs: 500,
});
const handle = transport.connect(bus, { signal: ac.signal });
ac.abort();
// Try to send — should be skipped because signal is aborted
const sentMessages: unknown[] = [];
channel.port2.addEventListener("message", (ev) =>
sentMessages.push(ev.data)
);
channel.port2.start();
await bus.emitAsync("ev", { v: 1 });
await new Promise((resolve) => setTimeout(resolve, 200));
// aborted before send — no messages should be sent
expect(sentMessages).toHaveLength(0);
handle.disconnect();
});
it("disconnect clears pending acks without error", {
timeout: 10000,
}, async () => {
const bus = new EventBus<{ x: { n: number } }>();
const transport = createMessageChannelTransport({
channel,
ackTimeoutMs: 500,
});
const handle = transport.connect(bus);
// Start a send that won't be acked (no listener on port2)
const sendPromise = bus.emitAsync("x", { n: 1 });
// Disconnect immediately while ack is pending
handle.disconnect();
// emitAsync should resolve (not throw) even though disconnect cleared pending acks
await expect(sendPromise).resolves.not.toThrow();
});
it("drops non-structured-cloneable payloads", {
timeout: 10000,
}, async () => {
const bus = new EventBus<Record<string, unknown>>();
const transport = createMessageChannelTransport({
channel,
ackTimeoutMs: 500,
});
const handle = transport.connect(bus);
// Emit a payload with a function (not cloneable)
await bus.emitAsync("fn-event" as never, (() => {}) as never);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(handle.stats.dropped).toBe(1);
expect(handle.stats.sent).toBe(0);
handle.disconnect();
});
it("handles ack message for unknown nonce without crashing", {
timeout: 5000,
}, async () => {
const bus = new EventBus<Record<string, unknown>>();
const transport = createMessageChannelTransport({
channel,
ackTimeoutMs: 500,
});
const handle = transport.connect(bus);
// Send an ack for an unknown nonce from remote (port2) — should be silently ignored
channel.port2.postMessage({ ack: "unknown-nonce-xyz" });
await new Promise((resolve) => setTimeout(resolve, 100));
expect(handle.stats.dropped).toBe(0);
handle.disconnect();
});
it("uses custom clock and idGenerator", {
timeout: 10000,
}, async () => {
const bus = new EventBus<{ ts: { v: number } }>();
const clock = { now: vi.fn(() => 12345) };
const idGenerator = { generate: vi.fn(() => "custom-nonce") };
const transport = createMessageChannelTransport({
channel,
ackTimeoutMs: 500,
});
const sentEnvelopes: unknown[] = [];
channel.port2.addEventListener("message", (ev) => {
const env = ev.data as { nonce?: string };
if ("ack" in env) return;
sentEnvelopes.push(ev.data);
channel.port2.postMessage({ ack: env.nonce });
});
channel.port2.start();
const handle = transport.connect(bus, { clock, idGenerator });
await bus.emitAsync("ts", { v: 1 });
await new Promise((resolve) => setTimeout(resolve, 200));
expect(sentEnvelopes).toHaveLength(1);
expect((sentEnvelopes[0] as { ts: number }).ts).toBe(12345);
expect((sentEnvelopes[0] as { nonce: string }).nonce).toBe(
"custom-nonce"
);
handle.disconnect();
});
it("ignores inbound messages after signal abort", {
timeout: 10000,
}, async () => {
const bus = new EventBus<{ ev: { v: number } }>();
const ac = new AbortController();
const transport = createMessageChannelTransport({
channel,
ackTimeoutMs: 500,
});
const handle = transport.connect(bus, { signal: ac.signal });
const received: { v: number }[] = [];
bus.on("ev", (p) => received.push(p));
// Connect remote side
channel.port2.start();
ac.abort();
// Send an inbound message after abort — onMessage should return early
const envelope = {
v: 1,
event: "ev",
payload: { v: 1 },
nonce: "abort-nonce",
origin: "remote",
ts: Date.now(),
};
channel.port2.postMessage(envelope);
await new Promise((resolve) => setTimeout(resolve, 200));
expect(received).toHaveLength(0);
expect(handle.stats.received).toBe(0);
handle.disconnect();
});
it("aborts mid-retry in sendWithRetry when signal fires", {
timeout: 10000,
}, async () => {
const bus = new EventBus<{ ev: { v: number } }>();
const ac = new AbortController();
const transport = createMessageChannelTransport({
channel,
ackTimeoutMs: 200,
maxRetries: 5,
});
const handle = transport.connect(bus, { signal: ac.signal });
// Start listening on port2 but don't ack, so retry loop runs
channel.port2.addEventListener("message", () => {
// No ack — let it timeout
});
channel.port2.start();
// Start the emit, then abort partway through
const emitPromise = bus.emitAsync("ev", { v: 1 });
// Abort after first timeout (200ms) but before all retries complete
setTimeout(() => ac.abort(), 250);
await emitPromise;
// Should have dropped since signal aborted before all retries
// (exact behavior: dropped since ack never received AND signal aborted)
expect(handle.stats.sent).toBe(0);
handle.disconnect();
});
});

View File

@@ -0,0 +1,178 @@
import type {
EventBus,
EventEnvelope,
EventMap,
Next,
} from "@suaveplan/events";
import {
DEFAULT_ACK_TIMEOUT_MS,
DEFAULT_MAX_RETRIES,
DEFAULT_NONCE_WINDOW_SIZE,
} from "../../constants/index.js";
import { buildEnvelope, parseEnvelope } from "../../core/envelope/envelope.js";
import { NonceWindow } from "../../core/nonce-window/nonce-window.js";
import type {
ConnectOptions,
EventTransport,
TransportHandle,
TransportStats,
} from "../../core/transport-interface/transport-interface.types.js";
import { isStructuredCloneable } from "../../utils/structured-clone-check.js";
import type { MessageChannelTransportOptions } from "./message-channel-transport.types.js";
/**
* Creates a MessageChannel-based EventTransport with at-least-once delivery.
*
* This transport owns `port1`. The remote peer holds `port2`. All communication
* (outbound envelopes, inbound envelopes, and acks) flows through `port1`.
*
* The sender waits for an ack reply before considering delivery successful.
* If the ack does not arrive within `ackTimeoutMs`, the message is retried
* up to `maxRetries` times.
*/
export function createMessageChannelTransport(
options: MessageChannelTransportOptions
): EventTransport {
const { channel } = options;
const ackTimeoutMs = options.ackTimeoutMs ?? DEFAULT_ACK_TIMEOUT_MS;
const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
const nonceWindowSize =
options.nonceWindowSize ?? DEFAULT_NONCE_WINDOW_SIZE;
return {
name: "message-channel",
deliveryGuarantee: "at-least-once",
connect(
bus: EventBus<EventMap>,
connectOptions?: ConnectOptions
): TransportHandle {
const filter = connectOptions?.eventFilter ?? (() => true);
const signal = connectOptions?.signal;
const clock = connectOptions?.clock ?? { now: () => Date.now() };
const idGen = connectOptions?.idGenerator ?? {
generate: () => Math.random().toString(36).slice(2),
};
const nonceWindow = new NonceWindow(nonceWindowSize);
const stats: TransportStats = {
sent: 0,
received: 0,
dropped: 0,
duplicatesFiltered: 0,
};
// Pending acks: nonce -> resolve function
const pendingAcks = new Map<string, () => void>();
// This transport owns port1.
// All inbound messages (envelopes from remote + acks) arrive on port1
// because the remote posts via port2.postMessage → received by port1.
const onMessage = (event: MessageEvent): void => {
if (signal?.aborted) return;
const data: unknown = event.data;
// Handle ack messages (sent by remote via port2.postMessage)
if (
data !== null &&
typeof data === "object" &&
"ack" in data &&
typeof (data as Record<string, unknown>)["ack"] === "string"
) {
const ackNonce = (data as { ack: string }).ack;
pendingAcks.get(ackNonce)?.();
pendingAcks.delete(ackNonce);
return;
}
// Parse as envelope (sent by remote via port2.postMessage)
let envelope: ReturnType<typeof parseEnvelope>;
try {
envelope = parseEnvelope(data);
} catch {
stats.dropped++;
return;
}
// Dedup check
if (nonceWindow.check(envelope.nonce)) {
stats.duplicatesFiltered++;
return;
}
stats.received++;
// Re-dispatch on local bus and send ack back to remote (via port1 → received on port2)
bus.emit(
envelope.event as keyof EventMap,
envelope.payload as EventMap[keyof EventMap]
);
channel.port1.postMessage({ ack: envelope.nonce });
};
channel.port1.addEventListener("message", onMessage);
channel.port1.start();
// Outbound: intercept bus dispatches and send to remote via port1.postMessage
// (port1.postMessage → received by port2 listener on remote side)
const unsubscribe = bus.use(
async (envRaw: EventEnvelope, next: Next) => {
const result = await next();
const eventName = envRaw.type;
if (!filter(eventName)) return result;
if (!isStructuredCloneable(envRaw.payload)) {
stats.dropped++;
return result;
}
if (signal?.aborted) return result;
const nonce = idGen.generate();
const env = buildEnvelope(eventName, envRaw.payload, {
nonce,
origin: "local",
ts: clock.now(),
});
let attempts = 0;
const sendWithRetry = async (): Promise<void> => {
while (attempts < maxRetries) {
if (signal?.aborted) return;
// Send to remote (remote listens on port2)
channel.port1.postMessage(env);
attempts++;
const acked = await new Promise<boolean>(
(resolve) => {
const timer = setTimeout(() => {
pendingAcks.delete(nonce);
resolve(false);
}, ackTimeoutMs);
pendingAcks.set(nonce, () => {
clearTimeout(timer);
resolve(true);
});
}
);
if (acked) {
stats.sent++;
return;
}
}
stats.dropped++;
};
await sendWithRetry();
return result;
}
);
return {
disconnect(): void {
unsubscribe();
channel.port1.removeEventListener("message", onMessage);
pendingAcks.clear();
},
get stats(): TransportStats {
return { ...stats };
},
};
},
};
}

View File

@@ -0,0 +1,11 @@
/** Options for creating a MessageChannel transport. */
export interface MessageChannelTransportOptions {
/** The MessageChannel to use for bidirectional communication. */
channel: MessageChannel;
/** Ack timeout in milliseconds. Default: 5000. */
ackTimeoutMs?: number;
/** Maximum retry count before dropping a message. Default: 3. */
maxRetries?: number;
/** Dedup window size. Default: 1000. */
nonceWindowSize?: number;
}

View File

@@ -0,0 +1,64 @@
# structured-clone-check
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `isStructuredCloneable` and `assertStructuredCloneable` utilities for `@suaveplan/events-transport`. These functions validate that an event payload can be safely transmitted across context boundaries via the structured clone algorithm used by `MessageChannel` and `BroadcastChannel`. Payloads that fail the check are dropped before transmission to prevent runtime `DataCloneError` exceptions.
The check is conservative — it allows all primitives, plain objects, arrays, and well-known cloneable builtins (Date, RegExp, Map, Set, ArrayBuffer, TypedArrays, Error), and rejects functions, symbols, WeakMap, WeakSet, and class instances with custom prototypes.
## Features
- `isStructuredCloneable(value)` — recursive check for clone-safety
- `assertStructuredCloneable(value)` — throws `EventsTransportError(NOT_CLONEABLE)` on failure
- Handles nested objects and arrays
- Allows Date, RegExp, Map, Set, ArrayBuffer, TypedArray, Error
## Usage: Basic Example
```ts
import { isStructuredCloneable } from "@suaveplan/events-transport";
isStructuredCloneable({ id: "x", count: 1 }); // true
isStructuredCloneable(() => console.log("fn")); // false
isStructuredCloneable(Symbol("sym")); // false
isStructuredCloneable(new Date()); // true
isStructuredCloneable(new Map([["k", "v"]])); // true
```
## Usage: Advanced Example
```ts
import { assertStructuredCloneable, isStructuredCloneable } from "@suaveplan/events-transport";
import { EventsTransportError } from "@suaveplan/events-transport";
// Assert before transmission
try {
assertStructuredCloneable({ fn: () => {} });
} catch (err) {
if (err instanceof EventsTransportError) {
console.log(err.code); // "EVENTS_TRANSPORT.NOT_CLONEABLE"
}
}
// Safe payloads
isStructuredCloneable(new Uint8Array([1, 2, 3])); // true (TypedArray)
isStructuredCloneable(new Set([1, 2, 3])); // true (Set)
isStructuredCloneable(new WeakMap()); // false
```
## API Reference
### `isStructuredCloneable(value: unknown): boolean`
Returns `true` if the value can be transmitted via structured clone.
### `assertStructuredCloneable(value: unknown): void`
**Throws**: `EventsTransportError(EVENTS_TRANSPORT.NOT_CLONEABLE)` if value is not cloneable.
## Implementation Notes
The check uses `instanceof` for known builtins and `Object.getPrototypeOf` to detect class instances. It is recursive for arrays and plain objects. Custom class instances (those with a non-Object, non-null prototype that are not whitelisted) are rejected as non-cloneable. This is a conservative heuristic — some custom classes may be cloneable if they implement the `[Symbol.for("nodejs.util.inspect.custom")]` or structuredClone algorithm, but the safe default is to reject them. The actual `structuredClone()` call is not used for the check to avoid the cost of deep-cloning on every dispatch.

View File

@@ -0,0 +1,117 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
EVENTS_TRANSPORT_CODES,
EventsTransportError,
} from "../error-codes.js";
import {
assertStructuredCloneable,
isStructuredCloneable,
} from "./structured-clone-check.js";
describe("isStructuredCloneable", () => {
it("accepts primitives", { timeout: 5000 }, () => {
expect(isStructuredCloneable(null)).toBe(true);
expect(isStructuredCloneable(undefined)).toBe(true);
expect(isStructuredCloneable(42)).toBe(true);
expect(isStructuredCloneable("str")).toBe(true);
expect(isStructuredCloneable(true)).toBe(true);
expect(isStructuredCloneable(BigInt(1))).toBe(true);
});
it("rejects functions", { timeout: 5000 }, () => {
expect(isStructuredCloneable(() => void 0)).toBe(false);
expect(
isStructuredCloneable(function named() {
return 1;
})
).toBe(false);
});
it("rejects symbols", { timeout: 5000 }, () => {
expect(isStructuredCloneable(Symbol("s"))).toBe(false);
});
it("accepts plain objects", { timeout: 5000 }, () => {
expect(isStructuredCloneable({ a: 1, b: "x" })).toBe(true);
expect(isStructuredCloneable({})).toBe(true);
});
it("rejects objects with function values", { timeout: 5000 }, () => {
expect(isStructuredCloneable({ fn: () => void 0 })).toBe(false);
});
it("accepts arrays", { timeout: 5000 }, () => {
expect(isStructuredCloneable([1, 2, 3])).toBe(true);
expect(isStructuredCloneable([{ id: "x" }])).toBe(true);
});
it("rejects arrays with functions", { timeout: 5000 }, () => {
expect(isStructuredCloneable([1, () => void 0])).toBe(false);
});
it("accepts Date, RegExp, Map, Set, ArrayBuffer, TypedArray, Error", {
timeout: 5000,
}, () => {
expect(isStructuredCloneable(new Date())).toBe(true);
expect(isStructuredCloneable(/regex/)).toBe(true);
expect(isStructuredCloneable(new Map([["k", "v"]]))).toBe(true);
expect(isStructuredCloneable(new Set([1, 2]))).toBe(true);
expect(isStructuredCloneable(new ArrayBuffer(4))).toBe(true);
expect(isStructuredCloneable(new Uint8Array([1, 2]))).toBe(true);
expect(isStructuredCloneable(new Error("e"))).toBe(true);
});
it("rejects WeakMap and WeakSet", { timeout: 5000 }, () => {
expect(isStructuredCloneable(new WeakMap())).toBe(false);
expect(isStructuredCloneable(new WeakSet())).toBe(false);
});
it("rejects class instances with custom prototypes", {
timeout: 5000,
}, () => {
class MyClass {
x = 1;
}
expect(isStructuredCloneable(new MyClass())).toBe(false);
});
it("handles nested cloneable objects", { timeout: 5000 }, () => {
expect(isStructuredCloneable({ a: { b: { c: 42 } } })).toBe(true);
expect(isStructuredCloneable({ a: { b: { c: () => void 0 } } })).toBe(
false
);
});
});
describe("assertStructuredCloneable", () => {
it("does not throw for cloneable values", { timeout: 5000 }, () => {
expect(() => assertStructuredCloneable({ id: "x" })).not.toThrow();
expect(() => assertStructuredCloneable([1, 2, 3])).not.toThrow();
expect(() => assertStructuredCloneable(null)).not.toThrow();
});
it("throws EventsTransportError for non-cloneable values", {
timeout: 5000,
}, () => {
expect(() => assertStructuredCloneable(() => void 0)).toThrow(
EventsTransportError
);
expect(() => assertStructuredCloneable(Symbol("s"))).toThrow(
EventsTransportError
);
});
it("throws with NOT_CLONEABLE code", { timeout: 5000 }, () => {
const err = (() => {
try {
assertStructuredCloneable(() => void 0);
} catch (e) {
return e;
}
})();
expect(err).toBeInstanceOf(EventsTransportError);
expect((err as EventsTransportError).code).toBe(
EVENTS_TRANSPORT_CODES.NOT_CLONEABLE
);
});
});

View File

@@ -0,0 +1,70 @@
import {
EVENTS_TRANSPORT_CODES,
EventsTransportError,
} from "../error-codes.js";
/**
* Returns true if the value can be transmitted via structured clone.
*
* Checks for known non-cloneable types: functions, symbols, WeakMap,
* WeakSet, WeakRef, and class instances with custom prototypes that
* are not plain objects.
*/
export function isStructuredCloneable(value: unknown): boolean {
if (value === null || value === undefined) return true;
const type = typeof value;
if (type === "function" || type === "symbol") return false;
if (type !== "object") return true; // primitives are cloneable
// Known non-cloneable builtins
if (
value instanceof WeakMap ||
value instanceof WeakSet ||
value instanceof Function
) {
return false;
}
// Arrays: check elements recursively
if (Array.isArray(value)) {
return value.every(isStructuredCloneable);
}
// Plain objects: check values recursively
const proto = Object.getPrototypeOf(value);
if (proto !== null && proto !== Object.prototype) {
// Non-plain object: allow known safe builtins
if (
value instanceof Date ||
value instanceof RegExp ||
value instanceof Map ||
value instanceof Set ||
value instanceof ArrayBuffer ||
ArrayBuffer.isView(value) ||
value instanceof Error
) {
return true;
}
// Unknown class instance — not safe
return false;
}
// Plain object: check all values
return Object.values(value as Record<string, unknown>).every(
isStructuredCloneable
);
}
/**
* Asserts that a value is structured-clone-safe.
*
* @throws EventsTransportError with code EVENTS_TRANSPORT.NOT_CLONEABLE
*/
export function assertStructuredCloneable(value: unknown): void {
if (!isStructuredCloneable(value)) {
throw new EventsTransportError(
EVENTS_TRANSPORT_CODES.NOT_CLONEABLE,
"Payload is not structured-clone-safe: contains functions, symbols, or non-plain objects"
);
}
}

View File

@@ -0,0 +1,120 @@
/**
* Dependency graph smoke test — verifies the package entry point exports
* all expected symbols and that no unexpected runtime imports are present.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as Transport from "../src/index.js";
describe("@suaveplan/events-transport — public API surface", () => {
it("exports createMessageChannelTransport", { timeout: 5000 }, () => {
expect(typeof Transport.createMessageChannelTransport).toBe("function");
});
it("exports createBroadcastChannelTransport", { timeout: 5000 }, () => {
expect(typeof Transport.createBroadcastChannelTransport).toBe(
"function"
);
});
it("exports buildEnvelope and parseEnvelope", { timeout: 5000 }, () => {
expect(typeof Transport.buildEnvelope).toBe("function");
expect(typeof Transport.parseEnvelope).toBe("function");
});
it("exports NonceWindow", { timeout: 5000 }, () => {
expect(typeof Transport.NonceWindow).toBe("function");
});
it("exports isStructuredCloneable and assertStructuredCloneable", {
timeout: 5000,
}, () => {
expect(typeof Transport.isStructuredCloneable).toBe("function");
expect(typeof Transport.assertStructuredCloneable).toBe("function");
});
it("exports EventsTransportError", { timeout: 5000 }, () => {
expect(typeof Transport.EventsTransportError).toBe("function");
});
it("exports EVENTS_TRANSPORT_CODES", { timeout: 5000 }, () => {
expect(typeof Transport.EVENTS_TRANSPORT_CODES).toBe("object");
});
it("registerEventsTransportErrorCodes is a no-op function", {
timeout: 5000,
}, () => {
expect(() =>
Transport.registerEventsTransportErrorCodes()
).not.toThrow();
});
it("exports constants", { timeout: 5000 }, () => {
expect(typeof Transport.DEFAULT_ACK_TIMEOUT_MS).toBe("number");
expect(typeof Transport.DEFAULT_MAX_RETRIES).toBe("number");
expect(typeof Transport.DEFAULT_NONCE_WINDOW_SIZE).toBe("number");
expect(typeof Transport.ENVELOPE_VERSION).toBe("number");
});
it("createMessageChannelTransport returns correct transport shape", {
timeout: 5000,
}, () => {
const channel = new MessageChannel();
const transport = Transport.createMessageChannelTransport({ channel });
expect(transport.name).toBe("message-channel");
expect(transport.deliveryGuarantee).toBe("at-least-once");
expect(typeof transport.connect).toBe("function");
channel.port1.close();
channel.port2.close();
});
it("createBroadcastChannelTransport with mock channel shape", {
timeout: 5000,
}, () => {
// Minimal mock since BroadcastChannel is not available in Node test env
const mockChannel = {
name: "test",
addEventListener: () => {},
removeEventListener: () => {},
postMessage: () => {},
close: () => {},
} as unknown as BroadcastChannel;
const transport = Transport.createBroadcastChannelTransport({
channel: mockChannel,
});
expect(transport.name).toBe("broadcast-channel");
expect(transport.deliveryGuarantee).toBe("at-most-once");
});
it("NonceWindow tracks and deduplicates nonces", { timeout: 5000 }, () => {
const nw = new Transport.NonceWindow(10);
expect(nw.check("a")).toBe(false); // new
expect(nw.check("a")).toBe(true); // duplicate
expect(nw.check("b")).toBe(false); // new
});
it("buildEnvelope and parseEnvelope round-trips correctly", {
timeout: 5000,
}, () => {
const env = Transport.buildEnvelope(
"test.event",
{ id: "1" },
{
nonce: "n1",
origin: "local",
ts: 1000,
}
);
const parsed = Transport.parseEnvelope(env);
expect(parsed.event).toBe("test.event");
expect(parsed.payload).toEqual({ id: "1" });
expect(parsed.nonce).toBe("n1");
});
it("isStructuredCloneable returns correct results", {
timeout: 5000,
}, () => {
expect(Transport.isStructuredCloneable({ a: 1 })).toBe(true);
expect(Transport.isStructuredCloneable(() => {})).toBe(false);
});
});

View File

@@ -0,0 +1,10 @@
import { webcrypto } from "node:crypto";
// Polyfill Web Crypto for Node.js environments (Vitest forks pool)
if (typeof globalThis.crypto === "undefined") {
Object.defineProperty(globalThis, "crypto", {
value: webcrypto,
writable: false,
configurable: true,
});
}

View 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"]
}

View 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/events-transport",
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/error",
"@suaveplan/events",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,26 @@
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",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});