Initial Commit
This commit is contained in:
53
packages/networking/events/events-transport/README.md
Normal file
53
packages/networking/events/events-transport/README.md
Normal file
File diff suppressed because one or more lines are too long
84
packages/networking/events/events-transport/package.json
Normal file
84
packages/networking/events/events-transport/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
buildEnvelope,
|
||||
parseEnvelope,
|
||||
TransportEnvelopeSchema,
|
||||
} from "./envelope.js";
|
||||
export type { TransportEnvelope } from "./envelope.types.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { NonceWindow } from "./nonce-window.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
ConnectOptions,
|
||||
DeliveryGuarantee,
|
||||
EventTransport,
|
||||
TransportHandle,
|
||||
TransportStats,
|
||||
} from "./transport-interface.types.js";
|
||||
@@ -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.
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
40
packages/networking/events/events-transport/src/index.ts
Normal file
40
packages/networking/events/events-transport/src/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createBroadcastChannelTransport } from "./broadcast-channel-transport.js";
|
||||
export type { BroadcastChannelTransportOptions } from "./broadcast-channel-transport.types.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createMessageChannelTransport } from "./message-channel-transport.js";
|
||||
export type { MessageChannelTransportOptions } from "./message-channel-transport.types.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
38
packages/networking/events/events-transport/vite.config.ts
Normal file
38
packages/networking/events/events-transport/vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
26
packages/networking/events/events-transport/vitest.config.ts
Normal file
26
packages/networking/events/events-transport/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user