Initial Commit

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

View File

@@ -0,0 +1,26 @@
# @suaveplan/network-plugin-relay - Cross-mesh relay: relay router, host-backbone transport, cross-mesh envelope schema, mesh directory, loop-prevention guard, manager
## Summary
Cross-mesh relay — the federation layer that lets Tier 0 hosts of different meshes route messages to each other. Composes `RelayRouter`, `HostBackboneTransport` (Tier 0 ↔ Tier 0), `CrossMeshEnvelope` (Zod-validated), `MeshDirectory` (host lookup), `LoopGuard` (hopCount/ttl/visitedNodes), and `RelayManager` (plugin entry point).
<!-- begin:auto-docs -->
## Documentation tree
- [backbone](./src/backbone/host-backbone-transport.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [host-backbone-transport](./src/backbone/host-backbone-transport.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [directory](./src/directory/mesh-directory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [mesh-directory](./src/directory/mesh-directory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [envelope](./src/envelope/cross-mesh-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cross-mesh-envelope](./src/envelope/cross-mesh-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [loop-prevention](./src/loop-prevention/loop-guard.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [loop-guard](./src/loop-prevention/loop-guard.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [manager](./src/manager/relay-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [relay-manager](./src/manager/relay-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [router](./src/router/relay-router.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [relay-router](./src/router/relay-router.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-relay plugin. Five codes cover the full surface: `ROUTE_NOT_FOUND…
<!-- end:auto-docs --> - [host-backbone-transport](./src/backbone/host-backbone-transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [directory](./src/directory/mesh-directory.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh-directory](./src/directory/mesh-directory.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope](./src/envelope/cross-mesh-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [cross-mesh-envelope](./src/envelope/cross-mesh-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [loop-prevention](./src/loop-prevention/loop-guard.md) — <!-- begin:auto-docs --> _(no submodules)_- [loop-guard](./src/loop-prevention/loop-guard.md) — <!-- begin:auto-docs --> _(no submodules)_- [manager](./src/manager/relay-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-manager](./src/manager/relay-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [router](./src/router/relay-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-router](./src/router/relay-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-relay plugin. Five codes cover the full surface: `ROUTE_NOT_FOUND…
- [host-backbone-transport](./src/backbone/host-backbone-transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [directory](./src/directory/mesh-directory.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh-directory](./src/directory/mesh-directory.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope](./src/envelope/cross-mesh-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [cross-mesh-envelope](./src/envelope/cross-mesh-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [loop-prevention](./src/loop-prevention/loop-guard.md) — <!-- begin:auto-docs --> _(no submodules)_- [loop-guard](./src/loop-prevention/loop-guard.md) — <!-- begin:auto-docs --> _(no submodules)_- [manager](./src/manager/relay-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-manager](./src/manager/relay-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [router](./src/router/relay-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-router](./src/router/relay-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-relay plugin. Five codes cover the full surface: `ROUTE_NOT_FOUND…
- [host-backbone-transport](./src/backbone/host-backbone-transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [directory](./src/directory/mesh-directory.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh-directory](./src/directory/mesh-directory.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope](./src/envelope/cross-mesh-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [cross-mesh-envelope](./src/envelope/cross-mesh-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [loop-prevention](./src/loop-prevention/loop-guard.md) — <!-- begin:auto-docs --> _(no submodules)_- [loop-guard](./src/loop-prevention/loop-guard.md) — <!-- begin:auto-docs --> _(no submodules)_- [manager](./src/manager/relay-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-manager](./src/manager/relay-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [router](./src/router/relay-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [relay-router](./src/router/relay-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-relay plugin. Five codes cover the full surface: `ROUTE_NOT_FOUND…
- [host-backbone-transport](./src/backbone/host-backbone-transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [directory](./src/directory/mesh-directory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [mesh-directory](./src/directory/mesh-directory.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope](./src/envelope/cross-mesh-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cross-mesh-envelope](./src/envelope/cross-mesh-envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [loop-prevention](./src/loop-prevention/loop-guard.md) — <!-- begin:auto-docs --> _(no submodules)_ - [loop-guard](./src/loop-prevention/loop-guard.md) — <!-- begin:auto-docs --> _(no submodules)_- [manager](./src/manager/relay-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [relay-manager](./src/manager/relay-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [router](./src/router/relay-router.md) — <!-- begin:auto-docs --> _(no submodules)_ - [relay-router](./src/router/relay-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-relay plugin. Five codes cover the full surface: `ROUTE_NOT_FOUND…

View File

@@ -0,0 +1,49 @@
{
"name": "@suaveplan/network-plugin-relay",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Cross-mesh relay: relay router, host-backbone transport, cross-mesh envelope schema, mesh directory, loop-prevention guard, manager",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"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" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check 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/network": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createCrossMeshEnvelope } from "../envelope/cross-mesh-envelope.js";
import {
type BackboneWire,
HostBackboneTransport,
} from "./host-backbone-transport.js";
function makeWire(): BackboneWire & {
fireInbound(hostId: string, payload: Uint8Array): void;
sent: Array<{ hostId: string; payload: Uint8Array }>;
} {
const sent: Array<{ hostId: string; payload: Uint8Array }> = [];
const connections = new Set<string>();
let inboundHandler: ((hostId: string, payload: Uint8Array) => void) | null =
null;
return {
sent,
async open(hostId: string) {
connections.add(hostId);
},
async close(hostId: string) {
connections.delete(hostId);
},
async write(hostId: string, payload: Uint8Array) {
sent.push({ hostId, payload });
},
onPayload(handler) {
inboundHandler = handler;
return () => {
inboundHandler = null;
};
},
isConnected(hostId: string): boolean {
return connections.has(hostId);
},
fireInbound(hostId: string, payload: Uint8Array): void {
inboundHandler?.(hostId, payload);
},
};
}
const envelope = createCrossMeshEnvelope({
id: "msg-1",
type: "chat",
senderId: "alice",
senderMeshId: "mesh-A",
targetId: "bob",
targetMeshId: "mesh-B",
payload: { text: "hi" },
timestamp: 1,
});
describe("HostBackboneTransport", () => {
it("connects, sends, and disconnects", async () => {
const wire = makeWire();
const backbone = new HostBackboneTransport({ wire });
await backbone.connect("host-X", "wss://x");
expect(backbone.isConnected("host-X")).toBe(true);
await backbone.send("host-X", envelope);
expect(wire.sent).toHaveLength(1);
await backbone.disconnect("host-X");
expect(backbone.isConnected("host-X")).toBe(false);
});
it("rejects send when not connected", async () => {
const wire = makeWire();
const backbone = new HostBackboneTransport({ wire });
await expect(backbone.send("ghost", envelope)).rejects.toThrow(
"backbone.disconnected"
);
});
it("delivers inbound payloads to subscribers", async () => {
const wire = makeWire();
const backbone = new HostBackboneTransport({ wire });
backbone.start();
const seen: string[] = [];
backbone.onMessage((env, fromHostId) => {
seen.push(`${fromHostId}::${env.id}`);
});
const payload = new TextEncoder().encode(JSON.stringify(envelope));
wire.fireInbound("host-Y", payload);
await Promise.resolve();
expect(seen).toEqual(["host-Y::msg-1"]);
});
it("respects unsubscribe handles from onMessage", async () => {
const wire = makeWire();
const backbone = new HostBackboneTransport({ wire });
backbone.start();
const seen: string[] = [];
const off = backbone.onMessage((env) => seen.push(env.id));
off();
wire.fireInbound(
"host-Y",
new TextEncoder().encode(JSON.stringify(envelope))
);
expect(seen).toHaveLength(0);
});
it("start is idempotent and stop unwires", () => {
const wire = makeWire();
const backbone = new HostBackboneTransport({ wire });
backbone.start();
backbone.start();
backbone.stop();
backbone.stop();
});
it("uses a custom serializer", async () => {
const wire = makeWire();
const encoded = new Uint8Array([1, 2, 3]);
const decoded = envelope;
const backbone = new HostBackboneTransport({
wire,
serializer: {
encode: () => encoded,
decode: () => decoded,
},
});
await backbone.connect("host-X", "wss://x");
await backbone.send("host-X", envelope);
expect(wire.sent[0]?.payload).toBe(encoded);
});
});

View File

@@ -0,0 +1,85 @@
# HostBackboneTransport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Cross-mesh transport for Tier 0 ↔ Tier 0 traffic. Runs over an injected `BackboneWire` (WebSocket, RTCDataChannel, libp2p, anything) and adds the `CrossMeshEnvelope` encode/decode plus a single-handler `onMessage` subscriber that the relay manager hooks into. Decoupling the wire from the envelope shape means a swap from WebSocket to QUIC doesn't touch the manager.
## Features
- Pluggable `BackboneWire` interface (`open` / `close` / `write` / `onPayload` / `isConnected`).
- Pluggable serializer; default uses JSON over UTF-8 bytes.
- `start()` wires the wire's `onPayload` into the local handler set; `stop()` unwires it.
- `send` throws `BACKBONE_DISCONNECTED` when the wire reports the host isn't connected.
- `onMessage(handler)` returns an unsubscribe function so subscribers don't leak.
- `isConnected(hostId)` exposed for observability.
## Usage: Basic Example
```ts
import {
type BackboneWire,
HostBackboneTransport,
} from "@suaveplan/network-plugin-relay";
declare const wire: BackboneWire;
const backbone = new HostBackboneTransport({ wire });
backbone.start();
await backbone.connect("host-X", "wss://x");
await backbone.send("host-X", envelope);
```
## Usage: Advanced Example
```ts
import {
HostBackboneTransport,
RELAY_CODES,
RelayError,
} from "@suaveplan/network-plugin-relay";
const backbone = new HostBackboneTransport({
wire,
serializer: {
encode: (env) => msgpack.encode(env),
decode: (b) => msgpack.decode(b) as never,
},
});
backbone.start();
backbone.onMessage(async (env, fromHostId) => {
await manager.deliverInbound(env, fromHostId);
});
try {
await backbone.send("host-X", envelope);
} catch (err) {
if (
err instanceof RelayError &&
err.code === RELAY_CODES.BACKBONE_DISCONNECTED
) {
await backbone.connect("host-X", "wss://x");
await backbone.send("host-X", envelope);
} else {
throw err;
}
}
```
## API Reference
- `interface BackboneWire { open, close, write, onPayload, isConnected }`.
- `interface HostBackboneTransportConfig { wire, serializer? }`.
- `start()` / `stop()` — bind / unbind the inbound payload subscriber.
- `connect(hostId, address)` / `disconnect(hostId)`.
- `send(hostId, envelope): Promise<void>` — throws `BACKBONE_DISCONNECTED` if the wire is not open.
- `onMessage(handler) → () => void`.
- `isConnected(hostId): boolean`.
## Implementation Notes
Inbound handlers are dispatched eagerly with `void h(...)` so an async handler that throws does not block subsequent handlers — this matches the observer-not-subscriber semantics other transports in the network suite use. Encoding via JSON is the default because it's debuggable and small enough for federation traffic; consumers that need binary framing should pass a custom `serializer`. The `onPayload` callback is captured and the unsubscribe stored in `offWire` so `stop()` can reverse the wiring cleanly without leaking listeners on the underlying wire.

View File

@@ -0,0 +1,110 @@
import type { CrossMeshEnvelope } from "../envelope/cross-mesh-envelope.js";
import { RELAY_CODES, RelayError } from "../error-codes.js";
/**
* Inbound message handler for the backbone.
*/
export type BackboneMessageHandler = (
envelope: CrossMeshEnvelope,
fromHostId: string
) => void | Promise<void>;
/**
* Pluggable wire transport for Tier 0 ↔ Tier 0 traffic. Lets consumers
* use WebSocket, RTCDataChannel, libp2p, etc. without coupling to one.
*/
export interface BackboneWire {
open(hostId: string, address: string): Promise<void>;
close(hostId: string): Promise<void>;
write(hostId: string, payload: Uint8Array): Promise<void>;
onPayload(
handler: (hostId: string, payload: Uint8Array) => void
): () => void;
isConnected(hostId: string): boolean;
}
/**
* Configuration for `HostBackboneTransport`.
*/
export interface HostBackboneTransportConfig {
readonly wire: BackboneWire;
readonly serializer?: {
encode(envelope: CrossMeshEnvelope): Uint8Array;
decode(payload: Uint8Array): CrossMeshEnvelope;
};
}
const DEFAULT_SERIALIZER = {
encode: (env: CrossMeshEnvelope): Uint8Array =>
new TextEncoder().encode(JSON.stringify(env)),
decode: (payload: Uint8Array): CrossMeshEnvelope =>
JSON.parse(new TextDecoder().decode(payload)) as CrossMeshEnvelope,
};
/**
* Cross-mesh transport that runs over an injected `BackboneWire`.
* Encodes / decodes `CrossMeshEnvelope` and surfaces a single-handler
* onMessage subscriber to the relay manager.
*/
export class HostBackboneTransport {
private readonly wire: BackboneWire;
private readonly serializer: NonNullable<
HostBackboneTransportConfig["serializer"]
>;
private readonly handlers = new Set<BackboneMessageHandler>();
private offWire: (() => void) | null = null;
public constructor(config: HostBackboneTransportConfig) {
this.wire = config.wire;
this.serializer = config.serializer ?? DEFAULT_SERIALIZER;
}
public start(): void {
if (this.offWire !== null) return;
this.offWire = this.wire.onPayload((hostId, payload) => {
const decoded = this.serializer.decode(payload);
for (const h of this.handlers) {
void h(decoded, hostId);
}
});
}
public stop(): void {
this.offWire?.();
this.offWire = null;
this.handlers.clear();
}
public async connect(hostId: string, address: string): Promise<void> {
await this.wire.open(hostId, address);
}
public async disconnect(hostId: string): Promise<void> {
await this.wire.close(hostId);
}
public async send(
hostId: string,
envelope: CrossMeshEnvelope
): Promise<void> {
if (!this.wire.isConnected(hostId)) {
throw new RelayError(
RELAY_CODES.BACKBONE_DISCONNECTED,
"backbone.disconnected",
{ context: { hostId, envelopeId: envelope.id } }
);
}
await this.wire.write(hostId, this.serializer.encode(envelope));
}
public onMessage(handler: BackboneMessageHandler): () => void {
this.handlers.add(handler);
return () => {
this.handlers.delete(handler);
};
}
public isConnected(hostId: string): boolean {
return this.wire.isConnected(hostId);
}
}

View File

@@ -0,0 +1,122 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MeshDirectory } from "./mesh-directory.js";
describe("MeshDirectory", () => {
it("registers and looks up by mesh id", () => {
const d = new MeshDirectory();
d.register({
meshId: "mesh-B",
hostId: "host-X",
address: "wss://x",
registeredAt: 0,
});
const hosts = d.lookup("mesh-B");
expect(hosts).toHaveLength(1);
expect(hosts[0]?.hostId).toBe("host-X");
});
it("filters out expired entries", () => {
const now = 1_000;
const d = new MeshDirectory({ clock: () => now });
d.register({
meshId: "mesh-B",
hostId: "host-X",
address: "wss://x",
registeredAt: 0,
expiresAt: 500,
});
expect(d.lookup("mesh-B")).toHaveLength(0);
});
it("upserts on duplicate (meshId, hostId)", () => {
const d = new MeshDirectory();
d.register({
meshId: "mesh-B",
hostId: "host-X",
address: "wss://old",
registeredAt: 0,
});
d.register({
meshId: "mesh-B",
hostId: "host-X",
address: "wss://new",
registeredAt: 1,
});
expect(d.lookup("mesh-B")[0]?.address).toBe("wss://new");
});
it("deregister removes and reports success", () => {
const d = new MeshDirectory();
d.register({
meshId: "mesh-B",
hostId: "host-X",
address: "wss://x",
registeredAt: 0,
});
expect(d.deregister("mesh-B", "host-X")).toBe(true);
expect(d.deregister("mesh-B", "host-X")).toBe(false);
});
it("list filters expired entries", () => {
const now = 1_000;
const d = new MeshDirectory({ clock: () => now });
d.register({
meshId: "mesh-B",
hostId: "host-X",
address: "wss://x",
registeredAt: 0,
expiresAt: 500,
});
d.register({
meshId: "mesh-B",
hostId: "host-Y",
address: "wss://y",
registeredAt: 0,
});
const live = d.list();
expect(live.length).toBe(1);
expect(live[0]?.hostId).toBe("host-Y");
});
it("size and clear behave as expected", () => {
const d = new MeshDirectory();
d.register({
meshId: "mesh-B",
hostId: "host-X",
address: "wss://x",
registeredAt: 0,
});
expect(d.size()).toBe(1);
d.clear();
expect(d.size()).toBe(0);
});
it("uses Date.now-based clock when none injected", () => {
const d = new MeshDirectory();
d.register({
meshId: "mesh-B",
hostId: "host-X",
address: "wss://x",
registeredAt: Date.now(),
expiresAt: Date.now() + 100_000,
});
expect(d.lookup("mesh-B")).toHaveLength(1);
});
it("lookup excludes other-mesh entries", () => {
const d = new MeshDirectory();
d.register({
meshId: "mesh-B",
hostId: "host-X",
address: "wss://x",
registeredAt: 0,
});
d.register({
meshId: "mesh-C",
hostId: "host-Y",
address: "wss://y",
registeredAt: 0,
});
expect(d.lookup("mesh-B")).toHaveLength(1);
});
});

View File

@@ -0,0 +1,73 @@
# MeshDirectory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Tracks which Tier 0 host advertises which mesh. Hosts query this when they need "who is the gateway for mesh-B?". A separate registry from `RelayRouter` because the directory is the _source_ of routing information — the router is the per-peer cache derived from it. Decoupling lets directory sync run on its own cadence without churning the router.
## Features
- `register(reg)` — insert or update a `MeshRegistration` (mesh id, host id, address, optional expiry).
- `deregister(meshId, hostId)` — explicit removal.
- `lookup(meshId): readonly MeshRegistration[]` — every non-expired host advertising this mesh.
- `list()` — every non-expired registration.
- Pluggable `clock` for deterministic tests.
- Expired entries are filtered lazily on every lookup, never stored after expiry.
## Usage: Basic Example
```ts
import { MeshDirectory } from "@suaveplan/network-plugin-relay";
const directory = new MeshDirectory();
directory.register({
meshId: "mesh-B",
hostId: "host-X",
address: "wss://x.example.com",
registeredAt: Date.now(),
});
const hosts = directory.lookup("mesh-B");
```
## Usage: Advanced Example
```ts
import { MeshDirectory } from "@suaveplan/network-plugin-relay";
let now = 1_000;
const directory = new MeshDirectory({ clock: () => now });
directory.register({
meshId: "mesh-B",
hostId: "host-X",
address: "wss://x",
registeredAt: 1_000,
expiresAt: 2_000,
});
directory.register({
meshId: "mesh-B",
hostId: "host-Y",
address: "wss://y",
registeredAt: 1_000,
});
now = 5_000;
const live = directory.lookup("mesh-B");
// live === [{ hostId: "host-Y", ... }] — host-X expired
```
## API Reference
- `interface MeshRegistration { meshId, hostId, address, registeredAt, expiresAt? }`.
- `interface MeshDirectoryConfig { clock? }`.
- `register(reg)` / `deregister(meshId, hostId): boolean`.
- `lookup(meshId): readonly MeshRegistration[]`.
- `list(): readonly MeshRegistration[]`.
- `size(): number` / `clear()`.
## Implementation Notes
Expiry is enforced at read time rather than via a background sweep timer, which keeps the directory free of leaked timers across test/server boundaries. The cost is one extra `Date.now()` (or injected clock) call per lookup; the benefit is that the directory has no lifecycle of its own — it can be created and discarded on demand. `register` of the same `(meshId, hostId)` pair is an upsert by design so directory sync can refresh expiry without explicit deregister-then-register.

View File

@@ -0,0 +1,69 @@
/**
* One mesh registration entry.
*/
export interface MeshRegistration {
readonly meshId: string;
readonly hostId: string;
readonly address: string;
readonly registeredAt: number;
readonly expiresAt?: number;
}
/**
* Configuration for `MeshDirectory`.
*/
export interface MeshDirectoryConfig {
readonly clock?: () => number;
}
/**
* Tracks which Tier 0 host advertises which mesh. Hosts query this when
* they need to know "who is the gateway for mesh-B?".
*/
export class MeshDirectory {
private readonly registrations = new Map<string, MeshRegistration>();
private readonly clock: () => number;
public constructor(config: MeshDirectoryConfig = {}) {
this.clock = config.clock ?? (() => Date.now());
}
public register(reg: MeshRegistration): void {
this.registrations.set(this.key(reg.meshId, reg.hostId), reg);
}
public deregister(meshId: string, hostId: string): boolean {
return this.registrations.delete(this.key(meshId, hostId));
}
public lookup(meshId: string): readonly MeshRegistration[] {
const out: MeshRegistration[] = [];
const now = this.clock();
const prefix = `${meshId}::`;
for (const [key, reg] of this.registrations) {
if (!key.startsWith(prefix)) continue;
if (reg.expiresAt !== undefined && reg.expiresAt <= now) continue;
out.push(reg);
}
return out;
}
public list(): readonly MeshRegistration[] {
const now = this.clock();
return [...this.registrations.values()].filter(
(r) => r.expiresAt === undefined || r.expiresAt > now
);
}
public size(): number {
return this.list().length;
}
public clear(): void {
this.registrations.clear();
}
private key(meshId: string, hostId: string): string {
return `${meshId}::${hostId}`;
}
}

View File

@@ -0,0 +1,68 @@
# CrossMeshEnvelope
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod-validated envelope shape for cross-mesh relay traffic. Extends the intra-mesh `MessageEnvelope` with `senderMeshId` / `targetMeshId` for federation routing, plus `hopCount` / `maxHops` / `visitedNodes` for loop prevention. Centralising the schema means every relay component (router, manager, backbone transport) parses the same shape and rejects malformed wire frames at the boundary.
## Features
- Zod schema with bounded `maxHops` (`1..255`) and non-negative `hopCount`.
- Boundary validators: `validateCrossMeshEnvelope(value)` (throws `ENVELOPE_INVALID` on failure) and `createCrossMeshEnvelope(partial)` (defaults headers / hop count / visited nodes).
- Headers are string-only (cross-mesh framing is intentionally simpler than intra-mesh).
- `visitedNodes` is an ordered array, not a set, so loop detection can preserve traversal history for diagnostics.
## Usage: Basic Example
```ts
import { createCrossMeshEnvelope } from "@suaveplan/network-plugin-relay";
const envelope = createCrossMeshEnvelope({
id: "msg-1",
type: "chat",
senderId: "alice",
senderMeshId: "mesh-A",
targetId: "bob",
targetMeshId: "mesh-B",
payload: { text: "hi" },
timestamp: Date.now(),
});
```
## Usage: Advanced Example
```ts
import {
createCrossMeshEnvelope,
RELAY_CODES,
RelayError,
validateCrossMeshEnvelope,
} from "@suaveplan/network-plugin-relay";
try {
const decoded = validateCrossMeshEnvelope(JSON.parse(wireBytes));
await router.route(decoded);
} catch (err) {
if (
err instanceof RelayError &&
err.code === RELAY_CODES.ENVELOPE_INVALID
) {
metrics.counter("relay.malformed").increment();
return;
}
throw err;
}
```
## API Reference
- `interface CrossMeshEnvelope` — Zod-inferred shape with id, type, senderId, senderMeshId, targetId, targetMeshId, payload, timestamp, headers, hopCount, maxHops, visitedNodes.
- `CrossMeshEnvelopeSchema` — the Zod object schema.
- `validateCrossMeshEnvelope(value): CrossMeshEnvelope` — throws `ENVELOPE_INVALID`.
- `createCrossMeshEnvelope(partial): CrossMeshEnvelope` — default-injecting constructor.
## Implementation Notes
`payload` is typed `unknown` because the federation layer doesn't constrain payload shape — that's the recipient's domain. Consumers that want a typed payload must layer their own Zod schema on top. We deliberately reject `maxHops > 255` to keep the on-wire counter byte-aligned for any future binary frame variant. The `visitedNodes` cap is enforced by the loop guard, not the schema, so callers that want to inspect a longer trace can extend the schema without coupling to the guard's policy.

View File

@@ -0,0 +1,68 @@
import z from "zod";
import { RELAY_CODES, RelayError } from "../error-codes.js";
/**
* Zod schema for a cross-mesh envelope.
*/
export const CrossMeshEnvelopeSchema = z.object({
id: z.string().min(1),
type: z.string().min(1),
senderId: z.string().min(1),
senderMeshId: z.string().min(1),
targetId: z.string().min(1),
targetMeshId: z.string().min(1),
payload: z.unknown(),
timestamp: z.number().int().nonnegative(),
headers: z.record(z.string(), z.string()).default({}),
hopCount: z.number().int().min(0).default(0),
maxHops: z.number().int().min(1).max(255).default(16),
visitedNodes: z.array(z.string()).default([]),
});
export type CrossMeshEnvelope = z.infer<typeof CrossMeshEnvelopeSchema>;
/**
* Validate a candidate cross-mesh envelope.
*/
export function validateCrossMeshEnvelope(value: unknown): CrossMeshEnvelope {
const result = CrossMeshEnvelopeSchema.safeParse(value);
if (!result.success) {
throw new RelayError(
RELAY_CODES.ENVELOPE_INVALID,
"relay.envelope-invalid",
{ cause: result.error }
);
}
return result.data;
}
/**
* Construct a cross-mesh envelope with sensible defaults.
*/
export function createCrossMeshEnvelope(
partial: Pick<
CrossMeshEnvelope,
| "id"
| "type"
| "senderId"
| "senderMeshId"
| "targetId"
| "targetMeshId"
| "payload"
| "timestamp"
> &
Partial<
Pick<
CrossMeshEnvelope,
"headers" | "hopCount" | "maxHops" | "visitedNodes"
>
>
): CrossMeshEnvelope {
return validateCrossMeshEnvelope({
...partial,
headers: partial.headers ?? {},
hopCount: partial.hopCount ?? 0,
maxHops: partial.maxHops ?? 16,
visitedNodes: partial.visitedNodes ?? [],
});
}

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CrossMeshEnvelopeSchema,
createCrossMeshEnvelope,
validateCrossMeshEnvelope,
} from "./cross-mesh-envelope.js";
const validInput = {
id: "msg-1",
type: "chat",
senderId: "alice",
senderMeshId: "mesh-A",
targetId: "bob",
targetMeshId: "mesh-B",
payload: { text: "hi" },
timestamp: 1,
};
describe("CrossMeshEnvelope", () => {
it("validates a complete payload", () => {
const env = validateCrossMeshEnvelope(validInput);
expect(env.id).toBe("msg-1");
expect(env.maxHops).toBe(16);
expect(env.hopCount).toBe(0);
expect(env.visitedNodes).toEqual([]);
});
it("rejects missing required fields", () => {
expect(() => validateCrossMeshEnvelope({})).toThrow(
"relay.envelope-invalid"
);
});
it("rejects invalid maxHops", () => {
expect(() =>
validateCrossMeshEnvelope({ ...validInput, maxHops: 256 })
).toThrow("relay.envelope-invalid");
expect(() =>
validateCrossMeshEnvelope({ ...validInput, maxHops: 0 })
).toThrow("relay.envelope-invalid");
});
it("rejects negative hopCount", () => {
expect(() =>
validateCrossMeshEnvelope({ ...validInput, hopCount: -1 })
).toThrow("relay.envelope-invalid");
});
it("createCrossMeshEnvelope fills defaults", () => {
const env = createCrossMeshEnvelope(validInput);
expect(env.headers).toEqual({});
expect(env.hopCount).toBe(0);
expect(env.visitedNodes).toEqual([]);
});
it("createCrossMeshEnvelope respects partial overrides", () => {
const env = createCrossMeshEnvelope({
...validInput,
headers: { "x-trace": "abc" },
maxHops: 8,
visitedNodes: ["host-Y"],
});
expect(env.headers["x-trace"]).toBe("abc");
expect(env.maxHops).toBe(8);
expect(env.visitedNodes).toEqual(["host-Y"]);
});
it("schema accepts string-typed payloads", () => {
const env = CrossMeshEnvelopeSchema.parse({
...validInput,
payload: "hello",
});
expect(env.payload).toBe("hello");
});
});

View File

@@ -0,0 +1,61 @@
# RELAY_CODES
## Purpose
Frozen registry of error codes for the network-plugin-relay plugin. Five codes cover the full surface: `ROUTE_NOT_FOUND` when the router cannot resolve a `(meshId, peerId)` pair, `LOOP_DETECTED` when the loop-guard sees the local host in `visitedNodes`, `TTL_EXHAUSTED` when an envelope's hop budget is spent, `BACKBONE_DISCONNECTED` when the host-to-host backbone has lost the next-hop link, and `ENVELOPE_INVALID` when a Zod check on the wire frame fails. The companion `RelayError` extends `SuaveplanError<RelayErrorCode>` so consumers pattern-match on `code` rather than parse messages.
## Features
- Five frozen codes registered under the `NET_RELAY` namespace.
- `RelayError extends SuaveplanError<RelayErrorCode>`.
- `name: "RelayError"` on every instance — useful for log filters and dashboard alerts.
- `cause` and `context` propagation via `SuaveplanErrorOptions`.
- Codes are stable across versions; the registry only ever grows, never shrinks.
## Usage: Basic Example
```ts
import {
RELAY_CODES,
RelayError,
} from "@suaveplan/network-plugin-relay";
throw new RelayError(RELAY_CODES.ROUTE_NOT_FOUND, "relay.route-not-found", {
context: { targetMeshId: "mesh-B", targetPeerId: "bob" },
});
```
## Usage: Advanced Example
```ts
import {
RELAY_CODES,
RelayError,
} from "@suaveplan/network-plugin-relay";
try {
await manager.route(envelope);
} catch (err) {
if (err instanceof RelayError) {
if (err.code === RELAY_CODES.LOOP_DETECTED) {
metrics.counter("relay.loops").increment();
return;
}
if (err.code === RELAY_CODES.TTL_EXHAUSTED) {
metrics.counter("relay.ttl-exhausted").increment();
return;
}
}
throw err;
}
```
## API Reference
- `RELAY_CODES` — frozen `{ ROUTE_NOT_FOUND, LOOP_DETECTED, TTL_EXHAUSTED, BACKBONE_DISCONNECTED, ENVELOPE_INVALID }`.
- `type RelayErrorCode` — union of registered code values.
- `class RelayError extends SuaveplanError<RelayErrorCode>` — typed throwable.
## Implementation Notes
`LOOP_DETECTED` and `TTL_EXHAUSTED` are intentionally distinct codes even though they both indicate "stop relaying this envelope." Loops mean the topology is misconfigured (or the visitedNodes list lost an entry); TTL exhaustion is a normal end-of-life signal for over-aged envelopes. Conflating them would hide topology bugs behind expected drops. The package barrel side-effect-imports `./error-codes.js` so subpath consumers register the codes too.

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RELAY_CODES, RelayError } from "./error-codes.js";
describe("RELAY_CODES", () => {
it("registers under NET_RELAY", () => {
for (const value of Object.values(RELAY_CODES)) {
expect(value).toMatch(/^NET_RELAY\./);
}
});
it("is frozen", () => {
expect(Object.isFrozen(RELAY_CODES)).toBe(true);
});
it("contains the documented codes", () => {
for (const key of [
"ROUTE_NOT_FOUND",
"LOOP_DETECTED",
"TTL_EXHAUSTED",
"BACKBONE_DISCONNECTED",
"ENVELOPE_INVALID",
] as const) {
expect(RELAY_CODES).toHaveProperty(key);
}
});
});
describe("RelayError", () => {
it("extends SuaveplanError", () => {
const err = new RelayError(
RELAY_CODES.ROUTE_NOT_FOUND,
"route-not-found"
);
expect(err.name).toBe("RelayError");
expect(err.code).toBe(RELAY_CODES.ROUTE_NOT_FOUND);
});
it("preserves cause", () => {
const cause = new Error("inner");
const err = new RelayError(RELAY_CODES.ENVELOPE_INVALID, "with-cause", {
cause,
});
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,22 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const RELAY_CODES = defineCodes("NET_RELAY", [
"ROUTE_NOT_FOUND",
"LOOP_DETECTED",
"TTL_EXHAUSTED",
"BACKBONE_DISCONNECTED",
"ENVELOPE_INVALID",
] as const);
export type RelayErrorCode = (typeof RELAY_CODES)[keyof typeof RELAY_CODES];
export class RelayError extends SuaveplanError<RelayErrorCode> {
public constructor(
code: RelayErrorCode,
messageKey: string,
options?: import("@suaveplan/error").SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "RelayError";
}
}

View File

@@ -0,0 +1,42 @@
// Error codes (side-effect registration)
import "./error-codes.js";
// Backbone
export {
type BackboneMessageHandler,
type BackboneWire,
HostBackboneTransport,
type HostBackboneTransportConfig,
} from "./backbone/host-backbone-transport.js";
// Directory
export {
MeshDirectory,
type MeshDirectoryConfig,
type MeshRegistration,
} from "./directory/mesh-directory.js";
// Envelope
export {
type CrossMeshEnvelope,
CrossMeshEnvelopeSchema,
createCrossMeshEnvelope,
validateCrossMeshEnvelope,
} from "./envelope/cross-mesh-envelope.js";
// Errors
export {
RELAY_CODES,
RelayError,
type RelayErrorCode,
} from "./error-codes.js";
// Loop guard
export {
LoopGuard,
type LoopGuardConfig,
} from "./loop-prevention/loop-guard.js";
// Manager
export {
type LocalDeliveryHandler,
RelayManager,
type RelayManagerConfig,
} from "./manager/relay-manager.js";
// Router
export { RelayRouter } from "./router/relay-router.js";

View File

@@ -0,0 +1,67 @@
# LoopGuard
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Verifies a cross-mesh envelope can safely be forwarded. Two checks: TTL (hopCount + 1 ≤ maxHops) and loop detection (local host id not in visitedNodes). On success, returns a new envelope with `localHostId` appended to `visitedNodes` and `hopCount` incremented. Centralising this in one class means every relay component (manager, backbone, integration tests) enforces the same invariant.
## Features
- Two failure modes: `TTL_EXHAUSTED` and `LOOP_DETECTED` (kept distinct on purpose).
- `assertCanForward(envelope)` — pure check, no mutation.
- `stamp(envelope): CrossMeshEnvelope` — checks then returns a stamped copy.
- Configurable `maxVisitedHistory` (default 64) — caps memory on long routes.
- Truncates `visitedNodes` from the head when history exceeds the cap, preserving the most recent hops.
- Read-only `localHostId` — guard instance is per-host and does not mutate over time.
## Usage: Basic Example
```ts
import { LoopGuard } from "@suaveplan/network-plugin-relay";
const guard = new LoopGuard({ localHostId: "host-X" });
const stamped = guard.stamp(envelope);
await backbone.send(nextHopId, stamped);
```
## Usage: Advanced Example
```ts
import {
LoopGuard,
RELAY_CODES,
RelayError,
} from "@suaveplan/network-plugin-relay";
const guard = new LoopGuard({
localHostId: "host-X",
maxVisitedHistory: 16,
});
try {
const stamped = guard.stamp(envelope);
await forward(stamped);
} catch (err) {
if (err instanceof RelayError) {
if (err.code === RELAY_CODES.LOOP_DETECTED) {
metrics.counter("relay.loops").increment();
} else if (err.code === RELAY_CODES.TTL_EXHAUSTED) {
metrics.counter("relay.ttl-exhausted").increment();
}
return;
}
throw err;
}
```
## API Reference
- `interface LoopGuardConfig { localHostId, maxVisitedHistory? }`.
- `assertCanForward(envelope)` — throws `TTL_EXHAUSTED` / `LOOP_DETECTED`.
- `stamp(envelope): CrossMeshEnvelope` — returns a fresh stamped envelope.
## Implementation Notes
The check ordering (TTL first, loop second) is deliberate: an over-aged envelope is dropped before we even consider whether it has visited the local host, since TTL exhaustion is a routine end-of-life signal whereas a loop is a topology bug. `stamp` returns a new object rather than mutating the input so callers can safely reuse the original envelope (e.g. for diagnostic logging) without worrying about iteration-order surprises.

View File

@@ -0,0 +1,73 @@
import type { CrossMeshEnvelope } from "../envelope/cross-mesh-envelope.js";
import { RELAY_CODES, RelayError } from "../error-codes.js";
/**
* Configuration for `LoopGuard`.
*/
export interface LoopGuardConfig {
readonly localHostId: string;
readonly maxVisitedHistory?: number;
}
/**
* Verifies an envelope can be forwarded:
*
* 1. `hopCount + 1` must be ≤ `maxHops` (else `TTL_EXHAUSTED`).
* 2. `localHostId` must not appear in `visitedNodes` (else `LOOP_DETECTED`).
*
* Returns the new envelope with `localHostId` appended to `visitedNodes`
* and `hopCount` incremented. Truncates the history at
* `maxVisitedHistory` (default 64) so a long-lived envelope does not
* grow without bound.
*/
export class LoopGuard {
private readonly localHostId: string;
private readonly maxVisitedHistory: number;
public constructor(config: LoopGuardConfig) {
this.localHostId = config.localHostId;
this.maxVisitedHistory = config.maxVisitedHistory ?? 64;
}
public assertCanForward(envelope: CrossMeshEnvelope): void {
if (envelope.hopCount + 1 > envelope.maxHops) {
throw new RelayError(
RELAY_CODES.TTL_EXHAUSTED,
"loop-guard.ttl-exhausted",
{
context: {
envelopeId: envelope.id,
hopCount: envelope.hopCount,
maxHops: envelope.maxHops,
},
}
);
}
if (envelope.visitedNodes.includes(this.localHostId)) {
throw new RelayError(
RELAY_CODES.LOOP_DETECTED,
"loop-guard.loop-detected",
{
context: {
envelopeId: envelope.id,
localHostId: this.localHostId,
},
}
);
}
}
public stamp(envelope: CrossMeshEnvelope): CrossMeshEnvelope {
this.assertCanForward(envelope);
const visited = [...envelope.visitedNodes, this.localHostId];
const truncated =
visited.length > this.maxVisitedHistory
? visited.slice(-this.maxVisitedHistory)
: visited;
return {
...envelope,
hopCount: envelope.hopCount + 1,
visitedNodes: truncated,
};
}
}

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createCrossMeshEnvelope } from "../envelope/cross-mesh-envelope.js";
import { LoopGuard } from "./loop-guard.js";
const baseEnvelope = createCrossMeshEnvelope({
id: "msg-1",
type: "chat",
senderId: "alice",
senderMeshId: "mesh-A",
targetId: "bob",
targetMeshId: "mesh-B",
payload: null,
timestamp: 0,
});
describe("LoopGuard", () => {
it("stamps with localHostId and increments hopCount", () => {
const guard = new LoopGuard({ localHostId: "host-X" });
const stamped = guard.stamp(baseEnvelope);
expect(stamped.hopCount).toBe(1);
expect(stamped.visitedNodes).toEqual(["host-X"]);
});
it("rejects when localHostId is already in visitedNodes", () => {
const guard = new LoopGuard({ localHostId: "host-X" });
expect(() =>
guard.stamp({
...baseEnvelope,
visitedNodes: ["host-X"],
})
).toThrow("loop-guard.loop-detected");
});
it("rejects when hopCount + 1 exceeds maxHops", () => {
const guard = new LoopGuard({ localHostId: "host-X" });
expect(() =>
guard.stamp({
...baseEnvelope,
hopCount: 16,
maxHops: 16,
})
).toThrow("loop-guard.ttl-exhausted");
});
it("assertCanForward does not mutate the envelope", () => {
const guard = new LoopGuard({ localHostId: "host-X" });
guard.assertCanForward(baseEnvelope);
expect(baseEnvelope.hopCount).toBe(0);
expect(baseEnvelope.visitedNodes).toEqual([]);
});
it("truncates visitedNodes to maxVisitedHistory", () => {
const guard = new LoopGuard({
localHostId: "host-X",
maxVisitedHistory: 3,
});
const stamped = guard.stamp({
...baseEnvelope,
visitedNodes: ["a", "b", "c"],
});
expect(stamped.visitedNodes).toEqual(["b", "c", "host-X"]);
});
it("uses default maxVisitedHistory when not configured", () => {
const guard = new LoopGuard({ localHostId: "host-X" });
const fifty = Array.from({ length: 50 }, (_, i) => `h${i}`);
const stamped = guard.stamp({
...baseEnvelope,
visitedNodes: fifty,
});
expect(stamped.visitedNodes.length).toBe(51);
});
it("checks TTL before loop", () => {
const guard = new LoopGuard({ localHostId: "host-X" });
// both conditions could fire — TTL should win
expect(() =>
guard.stamp({
...baseEnvelope,
hopCount: 16,
maxHops: 16,
visitedNodes: ["host-X"],
})
).toThrow("loop-guard.ttl-exhausted");
});
});

View File

@@ -0,0 +1,158 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type BackboneWire,
HostBackboneTransport,
} from "../backbone/host-backbone-transport.js";
import { MeshDirectory } from "../directory/mesh-directory.js";
import { createCrossMeshEnvelope } from "../envelope/cross-mesh-envelope.js";
import { LoopGuard } from "../loop-prevention/loop-guard.js";
import { RelayRouter } from "../router/relay-router.js";
import { RelayManager } from "./relay-manager.js";
function makeWire(): BackboneWire & {
sent: Array<{ hostId: string; payload: Uint8Array }>;
fire(hostId: string, payload: Uint8Array): void;
} {
const sent: Array<{ hostId: string; payload: Uint8Array }> = [];
const open = new Set<string>();
let onIn: ((hostId: string, payload: Uint8Array) => void) | null = null;
return {
sent,
async open(h) {
open.add(h);
},
async close(h) {
open.delete(h);
},
async write(hostId, payload) {
sent.push({ hostId, payload });
},
onPayload(handler) {
onIn = handler;
return () => {
onIn = null;
};
},
isConnected(h) {
return open.has(h);
},
fire(hostId, payload): void {
onIn?.(hostId, payload);
},
};
}
function makeManager(targetMeshId = "mesh-B") {
const wire = makeWire();
const backbone = new HostBackboneTransport({ wire });
backbone.start();
const router = new RelayRouter();
const directory = new MeshDirectory();
const loopGuard = new LoopGuard({ localHostId: "host-A" });
const delivered: unknown[] = [];
const manager = new RelayManager({
localMeshId: "mesh-A",
localHostId: "host-A",
router,
backbone,
directory,
loopGuard,
deliverLocally: async (env) => {
delivered.push(env);
},
});
return {
manager,
router,
directory,
wire,
backbone,
delivered,
targetMeshId,
};
}
const remoteEnvelope = createCrossMeshEnvelope({
id: "msg-1",
type: "chat",
senderId: "alice",
senderMeshId: "mesh-A",
targetId: "bob",
targetMeshId: "mesh-B",
payload: null,
timestamp: 0,
});
const localEnvelope = createCrossMeshEnvelope({
id: "msg-2",
type: "chat",
senderId: "carol",
senderMeshId: "mesh-Z",
targetId: "alice",
targetMeshId: "mesh-A",
payload: null,
timestamp: 0,
});
describe("RelayManager", () => {
it("delivers locally when targetMeshId matches", async () => {
const { manager, delivered } = makeManager();
await manager.route(localEnvelope);
expect(delivered).toHaveLength(1);
});
it("forwards to next-hop host via the backbone", async () => {
const { manager, router, wire } = makeManager();
router.addRoute("mesh-B", "bob", "host-X");
await wire.open("host-X", "wss://x");
await manager.route(remoteEnvelope);
expect(wire.sent).toHaveLength(1);
expect(wire.sent[0]?.hostId).toBe("host-X");
});
it("throws ROUTE_NOT_FOUND when the router cannot resolve", async () => {
const { manager } = makeManager();
await expect(manager.route(remoteEnvelope)).rejects.toThrow(
"relay.route-not-found"
);
});
it("uses the loop guard to stamp envelopes", async () => {
const { manager, router, wire } = makeManager();
router.addRoute("mesh-B", "bob", "host-X");
await wire.open("host-X", "wss://x");
await manager.route(remoteEnvelope);
const sentBytes = wire.sent[0]?.payload ?? new Uint8Array();
const decoded = JSON.parse(new TextDecoder().decode(sentBytes));
expect(decoded.hopCount).toBe(1);
expect(decoded.visitedNodes).toEqual(["host-A"]);
});
it("attachToBackbone wires inbound to deliverInbound", async () => {
const { manager, wire, delivered } = makeManager();
const off = manager.attachToBackbone();
wire.fire(
"host-Y",
new TextEncoder().encode(JSON.stringify(localEnvelope))
);
await Promise.resolve();
await Promise.resolve();
expect(delivered).toHaveLength(1);
off();
});
it("exposes local mesh / host / directory", () => {
const { manager } = makeManager();
expect(manager.getLocalMeshId()).toBe("mesh-A");
expect(manager.getLocalHostId()).toBe("host-A");
expect(manager.getDirectory()).toBeInstanceOf(MeshDirectory);
});
it("deliverInbound runs the same routing logic", async () => {
const { manager, router, wire } = makeManager();
router.addRoute("mesh-B", "bob", "host-X");
await wire.open("host-X", "wss://x");
await manager.deliverInbound(remoteEnvelope, "host-Y");
expect(wire.sent).toHaveLength(1);
});
});

View File

@@ -0,0 +1,84 @@
# RelayManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Plugin entry point that orchestrates the relay router, host-backbone transport, mesh directory, and loop guard into a single API. Two flows: `route(envelope)` for outbound traffic the local node originates, and `deliverInbound(envelope, fromHostId)` for envelopes arriving from another Tier 0 host. Both share the same `handle(envelope)` core so loop and TTL semantics are identical regardless of direction.
## Features
- Local-mesh short-circuit: if `targetMeshId === localMeshId`, calls the `deliverLocally` handler directly.
- Remote routing: stamps via loop guard, resolves the next hop, hands off to the backbone.
- `attachToBackbone()` — wires the backbone's onMessage into `deliverInbound`; returns an unsubscribe function for clean teardown.
- `ROUTE_NOT_FOUND` raised when the router cannot resolve `(meshId, peerId)`.
- Read-only `getLocalMeshId()`, `getLocalHostId()`, `getDirectory()` for downstream observers.
## Usage: Basic Example
```ts
import {
HostBackboneTransport,
LoopGuard,
MeshDirectory,
RelayManager,
RelayRouter,
} from "@suaveplan/network-plugin-relay";
const manager = new RelayManager({
localMeshId: "mesh-A",
localHostId: "host-A",
router: new RelayRouter(),
backbone: new HostBackboneTransport({ wire }),
directory: new MeshDirectory(),
loopGuard: new LoopGuard({ localHostId: "host-A" }),
deliverLocally: async (env) => {
await intraMeshTransport.send(env.targetId, env);
},
});
const off = manager.attachToBackbone();
await manager.route(crossMeshEnvelope);
off();
```
## Usage: Advanced Example
```ts
import {
RELAY_CODES,
RelayError,
RelayManager,
} from "@suaveplan/network-plugin-relay";
try {
await manager.route(envelope);
} catch (err) {
if (err instanceof RelayError) {
switch (err.code) {
case RELAY_CODES.ROUTE_NOT_FOUND:
await rebuildRoutesFromDirectory(manager.getDirectory());
return manager.route(envelope);
case RELAY_CODES.LOOP_DETECTED:
case RELAY_CODES.TTL_EXHAUSTED:
metrics.counter("relay.dropped").increment();
return;
}
}
throw err;
}
```
## API Reference
- `type LocalDeliveryHandler = (envelope) => void | Promise<void>`.
- `interface RelayManagerConfig { localMeshId, localHostId, router, backbone, directory, loopGuard, deliverLocally }`.
- `route(envelope): Promise<void>`.
- `deliverInbound(envelope, fromHostId): Promise<void>`.
- `attachToBackbone(): () => void`.
- `getLocalMeshId() / getLocalHostId() / getDirectory()`.
## Implementation Notes
Outbound and inbound paths share `handle(envelope)` deliberately — a relayed envelope arriving from a peer host is in every meaningful sense a fresh outbound from the relay's perspective, except for the visited-nodes history (which the loop guard consults via the envelope itself, not the call site). This keeps loop and TTL semantics identical regardless of direction. `deliverLocally` is a callback rather than a built-in transport because the manager has no opinion about how local delivery happens — that's the host's responsibility.

View File

@@ -0,0 +1,98 @@
import type { HostBackboneTransport } from "../backbone/host-backbone-transport.js";
import type { MeshDirectory } from "../directory/mesh-directory.js";
import type { CrossMeshEnvelope } from "../envelope/cross-mesh-envelope.js";
import { RELAY_CODES, RelayError } from "../error-codes.js";
import type { LoopGuard } from "../loop-prevention/loop-guard.js";
import type { RelayRouter } from "../router/relay-router.js";
/**
* Local-delivery callback invoked when an envelope's `targetMeshId`
* matches the local mesh.
*/
export type LocalDeliveryHandler = (
envelope: CrossMeshEnvelope
) => void | Promise<void>;
/**
* Configuration for `RelayManager`.
*/
export interface RelayManagerConfig {
readonly localMeshId: string;
readonly localHostId: string;
readonly router: RelayRouter;
readonly backbone: HostBackboneTransport;
readonly directory: MeshDirectory;
readonly loopGuard: LoopGuard;
readonly deliverLocally: LocalDeliveryHandler;
}
/**
* Plugin entry point — orchestrates the relay router, backbone, and
* loop guard. Two flows:
*
* - `route(envelope)` — outbound: stamp via loop guard, resolve next
* hop, send via backbone, or deliver locally if we are the destination.
* - `deliverInbound(envelope, fromHostId)` — inbound from the
* backbone: same flow but skips the assumption that we are the sender.
*/
export class RelayManager {
private readonly config: RelayManagerConfig;
public constructor(config: RelayManagerConfig) {
this.config = config;
}
public async route(envelope: CrossMeshEnvelope): Promise<void> {
await this.handle(envelope);
}
public async deliverInbound(
envelope: CrossMeshEnvelope,
_fromHostId: string
): Promise<void> {
await this.handle(envelope);
}
private async handle(envelope: CrossMeshEnvelope): Promise<void> {
if (envelope.targetMeshId === this.config.localMeshId) {
await this.config.deliverLocally(envelope);
return;
}
const stamped = this.config.loopGuard.stamp(envelope);
const nextHop = this.config.router.resolve(
stamped.targetMeshId,
stamped.targetId
);
if (nextHop === null) {
throw new RelayError(
RELAY_CODES.ROUTE_NOT_FOUND,
"relay.route-not-found",
{
context: {
targetMeshId: stamped.targetMeshId,
targetId: stamped.targetId,
},
}
);
}
await this.config.backbone.send(nextHop, stamped);
}
public attachToBackbone(): () => void {
return this.config.backbone.onMessage(async (env, fromHostId) => {
await this.deliverInbound(env, fromHostId);
});
}
public getLocalMeshId(): string {
return this.config.localMeshId;
}
public getLocalHostId(): string {
return this.config.localHostId;
}
public getDirectory(): MeshDirectory {
return this.config.directory;
}
}

View File

@@ -0,0 +1,58 @@
# RelayRouter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Cross-mesh routing table. Maps `(destMeshId, destPeerId)` to a next-hop host id. The relay manager consults this on every outbound cross-mesh send to decide whether to forward to a backbone peer or drop with `ROUTE_NOT_FOUND`. Centralising the table here means directory sync, manual ops tooling, and the manager all read/write the same source of truth.
## Features
- `addRoute(destMeshId, destPeerId, nextHopHostId)` — inserts or updates.
- `removeRoute(destMeshId, destPeerId)` — returns `true` if a route was removed.
- `resolve(targetMeshId, targetPeerId): string | null` — null on miss.
- `getRoutesForMesh(meshId)` — view of every per-peer entry inside one destination mesh.
- `size()` and `clear()` for observability and teardown.
## Usage: Basic Example
```ts
import { RelayRouter } from "@suaveplan/network-plugin-relay";
const router = new RelayRouter();
router.addRoute("mesh-B", "bob", "host-X");
const nextHop = router.resolve("mesh-B", "bob");
```
## Usage: Advanced Example
```ts
import { RelayRouter } from "@suaveplan/network-plugin-relay";
const router = new RelayRouter();
// Bulk-load from a directory snapshot.
for (const peer of meshDirectorySnapshot) {
router.addRoute(peer.meshId, peer.peerId, peer.nextHopHostId);
}
// Inspect every route into mesh-B.
const meshBRoutes = router.getRoutesForMesh("mesh-B");
for (const [peerId, nextHop] of meshBRoutes) {
console.log(peerId, "→", nextHop);
}
```
## API Reference
- `addRoute(destMeshId, destPeerId, nextHopHostId)`.
- `removeRoute(destMeshId, destPeerId): boolean`.
- `resolve(targetMeshId, targetPeerId): string | null`.
- `getRoutesForMesh(meshId): ReadonlyMap<string, string>`.
- `size(): number` / `clear()`.
## Implementation Notes
The internal key is `"${meshId}::${peerId}"` rather than nested `Map<Map>` so existence checks are a single hashmap lookup. We use `::` as a separator on the assumption that mesh and peer ids never embed it themselves; consumers that need to support arbitrary ids should normalise before passing in. `getRoutesForMesh` walks the full set on demand rather than keeping a per-mesh index because the target use-case (occasional ops tooling and snapshot exports) is much less hot than the per-call `resolve`.

View File

@@ -0,0 +1,46 @@
/**
* Routing table for cross-mesh relay. Maps `(destMeshId, destPeerId)`
* to the next-hop host id that knows how to deliver further.
*/
export class RelayRouter {
private readonly routes = new Map<string, string>();
public addRoute(
destMeshId: string,
destPeerId: string,
nextHopHostId: string
): void {
this.routes.set(this.key(destMeshId, destPeerId), nextHopHostId);
}
public removeRoute(destMeshId: string, destPeerId: string): boolean {
return this.routes.delete(this.key(destMeshId, destPeerId));
}
public resolve(targetMeshId: string, targetPeerId: string): string | null {
return this.routes.get(this.key(targetMeshId, targetPeerId)) ?? null;
}
public getRoutesForMesh(meshId: string): ReadonlyMap<string, string> {
const out = new Map<string, string>();
const prefix = `${meshId}::`;
for (const [key, hopId] of this.routes) {
if (key.startsWith(prefix)) {
out.set(key.slice(prefix.length), hopId);
}
}
return out;
}
public size(): number {
return this.routes.size;
}
public clear(): void {
this.routes.clear();
}
private key(meshId: string, peerId: string): string {
return `${meshId}::${peerId}`;
}
}

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RelayRouter } from "./relay-router.js";
describe("RelayRouter", () => {
it("adds and resolves routes", () => {
const r = new RelayRouter();
r.addRoute("mesh-B", "bob", "host-X");
expect(r.resolve("mesh-B", "bob")).toBe("host-X");
});
it("returns null for unknown routes", () => {
const r = new RelayRouter();
expect(r.resolve("mesh-Z", "ghost")).toBeNull();
});
it("removes routes and reports success", () => {
const r = new RelayRouter();
r.addRoute("mesh-B", "bob", "host-X");
expect(r.removeRoute("mesh-B", "bob")).toBe(true);
expect(r.removeRoute("mesh-B", "bob")).toBe(false);
expect(r.resolve("mesh-B", "bob")).toBeNull();
});
it("addRoute upserts existing entries", () => {
const r = new RelayRouter();
r.addRoute("mesh-B", "bob", "host-X");
r.addRoute("mesh-B", "bob", "host-Y");
expect(r.resolve("mesh-B", "bob")).toBe("host-Y");
});
it("getRoutesForMesh filters by mesh prefix", () => {
const r = new RelayRouter();
r.addRoute("mesh-B", "bob", "host-X");
r.addRoute("mesh-B", "carol", "host-Y");
r.addRoute("mesh-C", "dave", "host-Z");
const meshB = r.getRoutesForMesh("mesh-B");
expect([...meshB.keys()].sort()).toEqual(["bob", "carol"]);
expect(meshB.get("bob")).toBe("host-X");
});
it("getRoutesForMesh returns empty for unknown mesh", () => {
const r = new RelayRouter();
expect(r.getRoutesForMesh("ghost").size).toBe(0);
});
it("size and clear behave as expected", () => {
const r = new RelayRouter();
r.addRoute("mesh-B", "bob", "host-X");
r.addRoute("mesh-C", "carol", "host-Y");
expect(r.size()).toBe(2);
r.clear();
expect(r.size()).toBe(0);
});
});

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-plugin-relay dep-graph", () => {
it("loads index.js (acyclic static imports)", async () => {
const mod = await import("../src/index.js");
expect(mod.RelayManager).toBeDefined();
expect(mod.RelayRouter).toBeDefined();
expect(mod.MeshDirectory).toBeDefined();
expect(mod.LoopGuard).toBeDefined();
expect(mod.HostBackboneTransport).toBeDefined();
expect(mod.createCrossMeshEnvelope).toBeDefined();
});
});

View File

@@ -0,0 +1,137 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type BackboneWire,
createCrossMeshEnvelope,
HostBackboneTransport,
LoopGuard,
MeshDirectory,
RelayManager,
RelayRouter,
} from "../../src/index.js";
/**
* End-to-end two-mesh test: alice in mesh-A sends a message to bob in
* mesh-B; host-A relays via host-B over a shared backbone wire.
*/
describe("relay end-to-end", () => {
it("routes alice@A → bob@B via host backbone", async () => {
// Single shared in-memory wire that both backbones use to talk
// to each other. Each backbone is configured to address the
// *other* host id.
const aliceQueue: Array<{ hostId: string; payload: Uint8Array }> = [];
const bobQueue: Array<{ hostId: string; payload: Uint8Array }> = [];
let aliceHandler:
| ((hostId: string, payload: Uint8Array) => void)
| null = null;
let bobHandler: ((hostId: string, payload: Uint8Array) => void) | null =
null;
const aliceConnections = new Set<string>();
const bobConnections = new Set<string>();
const aliceWire: BackboneWire = {
async open(h) {
aliceConnections.add(h);
},
async close(h) {
aliceConnections.delete(h);
},
async write(hostId, payload) {
aliceQueue.push({ hostId, payload });
bobHandler?.("host-A", payload);
},
onPayload(h) {
aliceHandler = h;
return () => {
aliceHandler = null;
};
},
isConnected(h) {
return aliceConnections.has(h);
},
};
const bobWire: BackboneWire = {
async open(h) {
bobConnections.add(h);
},
async close(h) {
bobConnections.delete(h);
},
async write(hostId, payload) {
bobQueue.push({ hostId, payload });
aliceHandler?.("host-B", payload);
},
onPayload(h) {
bobHandler = h;
return () => {
bobHandler = null;
};
},
isConnected(h) {
return bobConnections.has(h);
},
};
const aliceBackbone = new HostBackboneTransport({ wire: aliceWire });
const bobBackbone = new HostBackboneTransport({ wire: bobWire });
aliceBackbone.start();
bobBackbone.start();
const aliceRouter = new RelayRouter();
aliceRouter.addRoute("mesh-B", "bob", "host-B");
const bobRouter = new RelayRouter();
const aliceDelivered: unknown[] = [];
const bobDelivered: unknown[] = [];
const aliceManager = new RelayManager({
localMeshId: "mesh-A",
localHostId: "host-A",
router: aliceRouter,
backbone: aliceBackbone,
directory: new MeshDirectory(),
loopGuard: new LoopGuard({ localHostId: "host-A" }),
deliverLocally: async (env) => {
aliceDelivered.push(env);
},
});
const bobManager = new RelayManager({
localMeshId: "mesh-B",
localHostId: "host-B",
router: bobRouter,
backbone: bobBackbone,
directory: new MeshDirectory(),
loopGuard: new LoopGuard({ localHostId: "host-B" }),
deliverLocally: async (env) => {
bobDelivered.push(env);
},
});
const offA = aliceManager.attachToBackbone();
const offB = bobManager.attachToBackbone();
await aliceBackbone.connect("host-B", "wss://b");
await bobBackbone.connect("host-A", "wss://a");
const envelope = createCrossMeshEnvelope({
id: "msg-1",
type: "chat",
senderId: "alice",
senderMeshId: "mesh-A",
targetId: "bob",
targetMeshId: "mesh-B",
payload: { text: "hi bob" },
timestamp: 0,
});
await aliceManager.route(envelope);
await Promise.resolve();
await Promise.resolve();
expect(bobDelivered).toHaveLength(1);
expect(aliceDelivered).toHaveLength(0);
offA();
offB();
});
});

View File

@@ -0,0 +1,8 @@
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"references": [{ "path": "./tsconfig.lib.json" }]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/network-plugin-relay",
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/network",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,39 @@
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: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});