Initial Commit
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 ?? [],
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user