Initial Commit
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
# @suaveplan/network-plugin-registry - Fixed-capacity peer registry with bitpacker-based binary message encoding
|
||||
|
||||
## Summary
|
||||
|
||||
Fixed-capacity peer registry with bitpacker-based binary encoding.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes.md) — Defines the stable error-code registry for the `@suaveplan/network-plugin-registry` package. All runtime failures withi…
|
||||
- [registry-plugin](./src/registry-plugin.md) — Integrates the registry system into a `NetworkNode`. `RegistryPlugin` implements `NetworkPlugin` and wires together the…
|
||||
<!-- end:auto-docs --> - [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable error-code registry for the `@suaveplan/network-plugin-registry` package. All runtime failures withi…
|
||||
- [registry-plugin](./src/registry-plugin.md) — Integrates the registry system into a `NetworkNode`. `RegistryPlugin` implements `NetworkPlugin` and wires together the…
|
||||
- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable error-code registry for the `@suaveplan/network-plugin-registry` package. All runtime failures withi…
|
||||
- [registry-plugin](./src/registry-plugin.md) — Integrates the registry system into a `NetworkNode`. `RegistryPlugin` implements `NetworkPlugin` and wires together the…
|
||||
- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable error-code registry for the `@suaveplan/network-plugin-registry` package. All runtime failures withi…
|
||||
- [registry-plugin](./src/registry-plugin.md) — Integrates the registry system into a `NetworkNode`. `RegistryPlugin` implements `NetworkPlugin` and wires together the…
|
||||
- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable error-code registry for the `@suaveplan/network-plugin-registry` package. All runtime failures withi…
|
||||
- [registry-plugin](./src/registry-plugin.md) — Integrates the registry system into a `NetworkNode`. `RegistryPlugin` implements `NetworkPlugin` and wires together the…
|
||||
|
||||
## Purpose
|
||||
|
||||
Manages a small, fixed-capacity registry of mesh peers. Provides CRUD operations, compact binary encoding via `@suaveplan/bitpacker`, and periodic sync to the network bus. This plugin is essential for mesh membership tracking and peer discovery.
|
||||
|
||||
## Features
|
||||
|
||||
- **Registry** — fixed-capacity peer storage with add/remove/get/list
|
||||
- **BinaryMessageEncoder** — bitpacker-based encoding, target ≤ 62 bytes
|
||||
- **RegistrySyncManager** — periodic `registry:update` bus events
|
||||
- **RegistryPlugin** — integrates everything into `NetworkNode`
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```ts
|
||||
import { RegistryPlugin } from "@suaveplan/network-plugin-registry";
|
||||
|
||||
const plugin = new RegistryPlugin(32);
|
||||
node.use(plugin);
|
||||
plugin.registry.add({ peerId: "p1", address: "10.0.0.1", tier: 1, latencyMs: 5 });
|
||||
```
|
||||
|
||||
### Advanced Example
|
||||
|
||||
```ts
|
||||
import { Registry, BinaryMessageEncoder } from "@suaveplan/network-plugin-registry";
|
||||
|
||||
const registry = new Registry(16);
|
||||
const encoder = new BinaryMessageEncoder();
|
||||
registry.add(peer);
|
||||
const bytes = encoder.encode(peer);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
See per-module documentation in `src/`:
|
||||
|
||||
- `src/registry/` — `Registry`, `PeerEntry`
|
||||
- `src/encoder/` — `BinaryMessageEncoder`
|
||||
- `src/sync/` — `RegistrySyncManager`
|
||||
- `src/registry-plugin.ts` — `RegistryPlugin`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry uses a `Map` for O(1) operations. Capacity is strictly enforced. The binary encoder uses a `@suaveplan/bitpacker` schema for tier (8 bits), latency (16 bits), and address length (8 bits). The 62-byte limit ensures registry messages are small enough for high-frequency updates.
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@suaveplan/network-plugin-registry",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Fixed-capacity peer registry with bitpacker-based binary message encoding",
|
||||
"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" }
|
||||
},
|
||||
"./registry": {
|
||||
"import": { "types": "./dist/registry/registry.d.ts", "default": "./dist/registry/registry.js" },
|
||||
"require": { "types": "./dist/registry/registry.d.ts", "default": "./dist/registry/registry.js" }
|
||||
},
|
||||
"./encoder": {
|
||||
"import": { "types": "./dist/encoder/encoder.d.ts", "default": "./dist/encoder/encoder.js" },
|
||||
"require": { "types": "./dist/encoder/encoder.d.ts", "default": "./dist/encoder/encoder.js" }
|
||||
},
|
||||
"./sync": {
|
||||
"import": { "types": "./dist/sync/sync.d.ts", "default": "./dist/sync/sync.js" },
|
||||
"require": { "types": "./dist/sync/sync.d.ts", "default": "./dist/sync/sync.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/bitpacker": "workspace:^",
|
||||
"@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",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# BinaryMessageEncoder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Packs `PeerEntry` objects into compact binary format using `@suaveplan/bitpacker`. The encoded format is designed to fit within 62 bytes for efficient wire transmission. This encoder replaces verbose JSON representations with a tight binary schema, reducing bandwidth and parsing overhead in high-frequency mesh updates.
|
||||
|
||||
## Features
|
||||
|
||||
- Bitpacker schema for structured peer data
|
||||
- 62-byte size limit enforcement
|
||||
- Tier, latency, and address encoding
|
||||
- Roundtrip encoding and decoding
|
||||
- Automatic latency clamping to 16-bit maximum
|
||||
- UTF-8 peer ID and address encoding
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```ts
|
||||
import { BinaryMessageEncoder } from "@suaveplan/network-plugin-registry";
|
||||
|
||||
const encoder = new BinaryMessageEncoder();
|
||||
const bytes = encoder.encode(peer);
|
||||
```
|
||||
|
||||
### Advanced Example
|
||||
|
||||
```ts
|
||||
import { BinaryMessageEncoder } from "@suaveplan/network-plugin-registry";
|
||||
|
||||
const encoder = new BinaryMessageEncoder();
|
||||
const decoded = encoder.decode(bytes);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encode(peer): Uint8Array` — pack peer into binary
|
||||
- `decode(data): PeerEntry` — unpack peer from binary
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema encodes tier as 8 bits, latency as 16 bits, and address length as 8 bits. The peer ID is encoded as UTF-8 bytes. Total payload must not exceed 62 bytes; larger payloads throw `INVALID_PEER_DATA`. Latency is clamped to 65535ms to fit the 16-bit field. The address length field allows variable-length addresses without delimiter overhead. Decoding strips null padding from the peer ID field so that fixed-width IDs do not leak trailing zeros into the string representation. The encoder is stateless and safe to reuse across many peers without re-instantiation. Because the encoder is pure, it can be shared between multiple registry instances or used in worker threads without synchronization concerns.
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BinaryMessageEncoder } from "./encoder.js";
|
||||
|
||||
describe("BinaryMessageEncoder", () => {
|
||||
it("must encode and decode peer data", () => {
|
||||
const encoder = new BinaryMessageEncoder();
|
||||
const peer = {
|
||||
peerId: "peer-123",
|
||||
address: "192.168.1.1",
|
||||
tier: 2,
|
||||
latencyMs: 150,
|
||||
};
|
||||
const encoded = encoder.encode(peer);
|
||||
const decoded = encoder.decode(encoded);
|
||||
expect(decoded.peerId).toBe(peer.peerId);
|
||||
expect(decoded.address).toBe(peer.address);
|
||||
expect(decoded.tier).toBe(peer.tier);
|
||||
expect(decoded.latencyMs).toBe(peer.latencyMs);
|
||||
});
|
||||
|
||||
it("must enforce 62-byte limit", () => {
|
||||
const encoder = new BinaryMessageEncoder();
|
||||
const peer = {
|
||||
peerId: "p",
|
||||
address: "a".repeat(100),
|
||||
tier: 1,
|
||||
latencyMs: 10,
|
||||
};
|
||||
expect(() => encoder.encode(peer)).toThrow("encoder.payload-too-large");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { defineBitSchema } from "@suaveplan/bitpacker";
|
||||
import { NETWORK_REG_CODES, NetworkRegError } from "../error-codes.js";
|
||||
import type { PeerEntry } from "../registry/registry.js";
|
||||
|
||||
const PeerSchema = defineBitSchema<{
|
||||
tier: number;
|
||||
latencyMs: number;
|
||||
addressLength: number;
|
||||
}>([
|
||||
{ name: "tier", type: "uint", bits: 8 },
|
||||
{ name: "latencyMs", type: "uint", bits: 16 },
|
||||
{ name: "addressLength", type: "uint", bits: 8 },
|
||||
]);
|
||||
|
||||
export class BinaryMessageEncoder {
|
||||
public encode(peer: PeerEntry): Uint8Array {
|
||||
const addressBytes = new TextEncoder().encode(peer.address);
|
||||
const peerIdBytes = new TextEncoder().encode(peer.peerId);
|
||||
const header = PeerSchema.pack({
|
||||
tier: peer.tier,
|
||||
latencyMs: Math.min(peer.latencyMs, 65535),
|
||||
addressLength: addressBytes.length,
|
||||
});
|
||||
const totalSize =
|
||||
header.length + peerIdBytes.length + addressBytes.length;
|
||||
if (totalSize > 62) {
|
||||
throw new NetworkRegError(
|
||||
NETWORK_REG_CODES.INVALID_PEER_DATA,
|
||||
"encoder.payload-too-large",
|
||||
{ context: { size: totalSize } }
|
||||
);
|
||||
}
|
||||
const result = new Uint8Array(totalSize);
|
||||
result.set(header, 0);
|
||||
result.set(peerIdBytes, header.length);
|
||||
result.set(addressBytes, header.length + peerIdBytes.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
public decode(data: Uint8Array): PeerEntry {
|
||||
const header = PeerSchema.unpack(data.subarray(0, PeerSchema.byteSize));
|
||||
const idStart = PeerSchema.byteSize;
|
||||
const idEnd = idStart + 8;
|
||||
const addrStart = idEnd;
|
||||
const addrEnd = addrStart + header.addressLength;
|
||||
const peerId = new TextDecoder()
|
||||
.decode(data.subarray(idStart, idEnd))
|
||||
.replace(/\0/g, "");
|
||||
const address = new TextDecoder().decode(
|
||||
data.subarray(addrStart, addrEnd)
|
||||
);
|
||||
return {
|
||||
peerId,
|
||||
address,
|
||||
tier: header.tier,
|
||||
latencyMs: header.latencyMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BinaryMessageEncoder } from "./encoder.js";
|
||||
@@ -0,0 +1,51 @@
|
||||
# Registry Error Codes
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable error-code registry for the `@suaveplan/network-plugin-registry` package. All runtime failures within the registry layer are tagged with deterministic codes so callers can handle them programmatically. The registry is a critical component for mesh membership tracking, and clear error semantics are essential for reliable operation in distributed environments where peers may join, leave, or send malformed data at any time.
|
||||
|
||||
## Features
|
||||
|
||||
- Deterministic code generation via `defineCodes`
|
||||
- Typed error class extending `SuaveplanError`
|
||||
- Full OpenTelemetry span-event compatibility
|
||||
- Tree-shake safe code definitions
|
||||
- Machine-readable error categorization
|
||||
- Human-readable message keys for i18n
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```ts
|
||||
import { NetworkRegError, NETWORK_REG_CODES } from "@suaveplan/network-plugin-registry";
|
||||
|
||||
throw new NetworkRegError(
|
||||
NETWORK_REG_CODES.REGISTRY_FULL,
|
||||
"registry.full"
|
||||
);
|
||||
```
|
||||
|
||||
### Advanced Example
|
||||
|
||||
```ts
|
||||
import { NetworkRegError, NETWORK_REG_CODES } from "@suaveplan/network-plugin-registry";
|
||||
|
||||
try {
|
||||
registry.add(peer);
|
||||
} catch (err) {
|
||||
if (err instanceof NetworkRegError && err.code === NETWORK_REG_CODES.PEER_ALREADY_EXISTS) {
|
||||
console.log("Peer already registered");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NETWORK_REG_CODES` — frozen registry of registry error codes
|
||||
- `NetworkRegError` — typed error class
|
||||
- `NetworkRegErrorCode` — union type of valid codes
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Codes are namespaced under `NET_REG` to avoid collisions with other network subsystems. The registry includes codes for capacity limits (`REGISTRY_FULL`), missing peers (`PEER_NOT_FOUND`), duplicate entries (`PEER_ALREADY_EXISTS`), and invalid peer data (`INVALID_PEER_DATA`). Each code maps to a human-readable message key that can be resolved for internationalization. The `NetworkRegError` class extends `SuaveplanError`, which provides automatic stack trace capture, cause chaining, and OpenTelemetry span event conversion. Error codes are tree-shake safe, meaning unused codes are removed from production bundles by the bundler. This keeps the final payload small even when many error codes are defined across the project.
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_REG_CODES, NetworkRegError } from "./error-codes.js";
|
||||
|
||||
describe("NETWORK_REG_CODES", () => {
|
||||
it("registers under the NET_REG namespace", () => {
|
||||
for (const value of Object.values(NETWORK_REG_CODES)) {
|
||||
expect(value).toMatch(/^NET_REG\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("is a frozen registry", () => {
|
||||
expect(Object.isFrozen(NETWORK_REG_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("contains the documented codes", () => {
|
||||
const expected = [
|
||||
"REGISTRY_FULL",
|
||||
"PEER_ALREADY_EXISTS",
|
||||
"INVALID_PEER_DATA",
|
||||
] as const;
|
||||
for (const key of expected) {
|
||||
expect(NETWORK_REG_CODES).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("NetworkRegError", () => {
|
||||
it("extends SuaveplanError with the registry code type", () => {
|
||||
const err = new NetworkRegError(
|
||||
NETWORK_REG_CODES.REGISTRY_FULL,
|
||||
"test.full",
|
||||
{ context: { capacity: 32 } }
|
||||
);
|
||||
expect(err.name).toBe("NetworkRegError");
|
||||
expect(err.code).toBe(NETWORK_REG_CODES.REGISTRY_FULL);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("preserves cause when provided", () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new NetworkRegError(
|
||||
NETWORK_REG_CODES.PEER_ALREADY_EXISTS,
|
||||
"with-cause",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
export const NETWORK_REG_CODES = defineCodes("NET_REG", [
|
||||
"REGISTRY_FULL",
|
||||
"PEER_ALREADY_EXISTS",
|
||||
"INVALID_PEER_DATA",
|
||||
] as const);
|
||||
|
||||
export type NetworkRegErrorCode =
|
||||
(typeof NETWORK_REG_CODES)[keyof typeof NETWORK_REG_CODES];
|
||||
|
||||
export class NetworkRegError extends SuaveplanError<NetworkRegErrorCode> {
|
||||
public constructor(
|
||||
code: NetworkRegErrorCode,
|
||||
messageKey: string,
|
||||
options?: import("@suaveplan/error").SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "NetworkRegError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Error codes
|
||||
import "./error-codes.js";
|
||||
|
||||
// Encoder
|
||||
export { BinaryMessageEncoder } from "./encoder/encoder.js";
|
||||
// Errors
|
||||
export {
|
||||
NETWORK_REG_CODES,
|
||||
NetworkRegError,
|
||||
type NetworkRegErrorCode,
|
||||
} from "./error-codes.js";
|
||||
// Registry
|
||||
export { type PeerEntry, Registry } from "./registry/registry.js";
|
||||
|
||||
// Plugin
|
||||
export { RegistryPlugin } from "./registry-plugin.js";
|
||||
// Sync
|
||||
export { RegistrySyncManager } from "./sync/sync.js";
|
||||
@@ -0,0 +1,50 @@
|
||||
# RegistryPlugin
|
||||
|
||||
## Purpose
|
||||
|
||||
Integrates the registry system into a `NetworkNode`. `RegistryPlugin` implements `NetworkPlugin` and wires together the `Registry`, `BinaryMessageEncoder`, and `RegistrySyncManager` as a single cohesive unit. This plugin is the recommended way to add peer-registry capabilities to a node because it handles lifecycle ordering automatically.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `NetworkPlugin` for automatic lifecycle management
|
||||
- Exposes registry and encoder as public readonly properties
|
||||
- Automatic sync management via node lifecycle hooks
|
||||
- Configurable capacity at construction time
|
||||
- Clean shutdown via `dispose()`
|
||||
- Zero external dependencies beyond the network package
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```ts
|
||||
import { RegistryPlugin } from "@suaveplan/network-plugin-registry";
|
||||
|
||||
const plugin = new RegistryPlugin(32);
|
||||
node.use(plugin);
|
||||
```
|
||||
|
||||
### Advanced Example
|
||||
|
||||
```ts
|
||||
import { RegistryPlugin } from "@suaveplan/network-plugin-registry";
|
||||
|
||||
const plugin = new RegistryPlugin(16);
|
||||
node.use(plugin);
|
||||
plugin.registry.add(peer);
|
||||
const bytes = plugin.encoder.encode(peer);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `name: "registry"` — plugin identifier
|
||||
- `registry: Registry` — peer registry
|
||||
- `encoder: BinaryMessageEncoder` — binary encoder
|
||||
- `install(node)` — wire sync manager to bus
|
||||
- `start()` — begin periodic sync
|
||||
- `stop()` — stop periodic sync
|
||||
- `dispose()` — clear registry and release sync manager
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The plugin creates the registry and encoder eagerly in the constructor so they are immediately available for inspection or manual mutation before the node is started. The sync manager is created lazily during `install()` when the node (and its bus) becomes available. This separation allows tests to construct and exercise the registry without needing a full `NetworkNode`. Disposing the plugin clears all registry state and drops the sync manager reference, which is important for clean shutdown and test isolation. The plugin never restarts automatically after disposal; a new instance must be created if registry functionality is needed again.
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NetworkNode } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { RegistryPlugin } from "./registry-plugin.js";
|
||||
|
||||
describe("RegistryPlugin", () => {
|
||||
it("exposes the canonical plugin name", () => {
|
||||
const p = new RegistryPlugin();
|
||||
expect(p.name).toBe("registry");
|
||||
});
|
||||
|
||||
it("starts and stops the sync manager via node lifecycle", async () => {
|
||||
const node = new NetworkNode();
|
||||
const plugin = new RegistryPlugin(8);
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
plugin.registry.add({
|
||||
peerId: "alice",
|
||||
address: "addr",
|
||||
tier: 0,
|
||||
latencyMs: 0,
|
||||
});
|
||||
expect(plugin.registry.list().length).toBe(1);
|
||||
await node.stop();
|
||||
});
|
||||
|
||||
it("dispose clears the registry and releases the sync manager", () => {
|
||||
const plugin = new RegistryPlugin(4);
|
||||
plugin.registry.add({
|
||||
peerId: "a",
|
||||
address: "x",
|
||||
tier: 0,
|
||||
latencyMs: 0,
|
||||
});
|
||||
plugin.dispose();
|
||||
expect(plugin.registry.list()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("install wires the sync manager to the node bus", async () => {
|
||||
const node = new NetworkNode();
|
||||
const plugin = new RegistryPlugin(2);
|
||||
plugin.install(node);
|
||||
await plugin.start();
|
||||
await plugin.stop();
|
||||
plugin.dispose();
|
||||
});
|
||||
|
||||
it("start/stop are no-ops without install", async () => {
|
||||
const plugin = new RegistryPlugin();
|
||||
await plugin.start();
|
||||
await plugin.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
|
||||
import { BinaryMessageEncoder } from "./encoder/encoder.js";
|
||||
import { Registry } from "./registry/registry.js";
|
||||
import { RegistrySyncManager } from "./sync/sync.js";
|
||||
|
||||
export class RegistryPlugin implements NetworkPlugin {
|
||||
public readonly name = "registry";
|
||||
public readonly registry: Registry;
|
||||
public readonly encoder = new BinaryMessageEncoder();
|
||||
private syncManager: RegistrySyncManager | undefined;
|
||||
|
||||
public constructor(capacity = 32) {
|
||||
this.registry = new Registry(capacity);
|
||||
}
|
||||
|
||||
public install(node: NetworkNode): void {
|
||||
this.syncManager = new RegistrySyncManager(
|
||||
this.registry,
|
||||
node.networkBus
|
||||
);
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this.syncManager?.start();
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.syncManager?.stop();
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.registry.clear();
|
||||
this.syncManager = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { PeerEntry } from "./registry.js";
|
||||
export { Registry } from "./registry.js";
|
||||
@@ -0,0 +1,54 @@
|
||||
# Registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Maintains a fixed-capacity in-memory registry of mesh peers. The registry provides CRUD operations: add, remove, get, list, and capacity enforcement. It is the authoritative source of peer membership information within a single network node. By capping the number of tracked peers, the registry prevents unbounded memory growth in large or adversarial meshes.
|
||||
|
||||
## Features
|
||||
|
||||
- Fixed capacity with configurable limit (default 32)
|
||||
- O(1) add, remove, get, and has operations via native Map
|
||||
- List all peers as an immutable read-only array
|
||||
- Duplicate detection with clear, typed errors
|
||||
- Full registry clear for shutdown or reset scenarios
|
||||
- Size getter for quick capacity monitoring
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```ts
|
||||
import { Registry } from "@suaveplan/network-plugin-registry";
|
||||
|
||||
const registry = new Registry(32);
|
||||
registry.add({ peerId: "p1", address: "192.168.1.1", tier: 1, latencyMs: 10 });
|
||||
```
|
||||
|
||||
### Advanced Example
|
||||
|
||||
```ts
|
||||
import { Registry } from "@suaveplan/network-plugin-registry";
|
||||
|
||||
const registry = new Registry(16);
|
||||
registry.add(peer);
|
||||
console.log(registry.list());
|
||||
registry.remove("p1");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `constructor(capacity?)` — create registry with capacity limit
|
||||
- `add(entry): void` — add peer, throws if full or duplicate
|
||||
- `remove(peerId): boolean` — remove peer by ID
|
||||
- `get(peerId): PeerEntry | undefined` — lookup peer
|
||||
- `list(): PeerEntry[]` — list all peers
|
||||
- `has(peerId): boolean` — check membership
|
||||
- `size: number` — current peer count
|
||||
- `clear(): void` — remove all peers
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry uses a JavaScript `Map` for O(1) operations. Capacity is enforced strictly: adding a peer when at capacity throws `REGISTRY_FULL`. Duplicate peer IDs throw `PEER_ALREADY_EXISTS`. The `list()` method returns a shallow copy of the internal values to prevent external mutation of internal state. Removing a peer that does not exist returns `false` rather than throwing, making idempotent cleanup easy. The registry is not thread-safe in the traditional sense, but because JavaScript is single-threaded, concurrent mutation is impossible as long as async boundaries are respected.
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Registry } from "./registry.js";
|
||||
|
||||
describe("Registry", () => {
|
||||
it("must add and retrieve peers", () => {
|
||||
const registry = new Registry(32);
|
||||
const peer = { peerId: "p1", address: "a", tier: 1, latencyMs: 10 };
|
||||
registry.add(peer);
|
||||
expect(registry.get("p1")).toEqual(peer);
|
||||
});
|
||||
|
||||
it("must enforce capacity", () => {
|
||||
const registry = new Registry(1);
|
||||
registry.add({ peerId: "p1", address: "a", tier: 1, latencyMs: 10 });
|
||||
expect(() =>
|
||||
registry.add({ peerId: "p2", address: "b", tier: 2, latencyMs: 20 })
|
||||
).toThrow("registry.full");
|
||||
});
|
||||
|
||||
it("must reject duplicates", () => {
|
||||
const registry = new Registry(32);
|
||||
registry.add({ peerId: "p1", address: "a", tier: 1, latencyMs: 10 });
|
||||
expect(() =>
|
||||
registry.add({ peerId: "p1", address: "b", tier: 2, latencyMs: 20 })
|
||||
).toThrow("registry.peer-already-exists");
|
||||
});
|
||||
|
||||
it("must remove peers", () => {
|
||||
const registry = new Registry(32);
|
||||
registry.add({ peerId: "p1", address: "a", tier: 1, latencyMs: 10 });
|
||||
expect(registry.remove("p1")).toBe(true);
|
||||
expect(registry.has("p1")).toBe(false);
|
||||
});
|
||||
|
||||
it("must list all peers", () => {
|
||||
const registry = new Registry(32);
|
||||
registry.add({ peerId: "p1", address: "a", tier: 1, latencyMs: 10 });
|
||||
registry.add({ peerId: "p2", address: "b", tier: 2, latencyMs: 20 });
|
||||
expect(registry.list().length).toBe(2);
|
||||
});
|
||||
|
||||
it("must clear all peers", () => {
|
||||
const registry = new Registry(32);
|
||||
registry.add({ peerId: "p1", address: "a", tier: 1, latencyMs: 10 });
|
||||
registry.clear();
|
||||
expect(registry.size).toBe(0);
|
||||
});
|
||||
|
||||
it("must default to capacity 32 when not configured", () => {
|
||||
const registry = new Registry();
|
||||
expect(registry.capacity).toBe(32);
|
||||
});
|
||||
|
||||
it("at-capacity duplicate add raises PEER_ALREADY_EXISTS, not REGISTRY_FULL", () => {
|
||||
const registry = new Registry(1);
|
||||
registry.add({ peerId: "p1", address: "a", tier: 1, latencyMs: 10 });
|
||||
// capacity reached + peer already present — must throw the duplicate
|
||||
// code, not the capacity code
|
||||
expect(() =>
|
||||
registry.add({ peerId: "p1", address: "x", tier: 2, latencyMs: 20 })
|
||||
).toThrow("registry.peer-already-exists");
|
||||
});
|
||||
|
||||
it("remove of unknown peer returns false", () => {
|
||||
const registry = new Registry();
|
||||
expect(registry.remove("ghost")).toBe(false);
|
||||
});
|
||||
|
||||
it("get of unknown peer returns undefined", () => {
|
||||
const registry = new Registry();
|
||||
expect(registry.get("ghost")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NETWORK_REG_CODES, NetworkRegError } from "../error-codes.js";
|
||||
|
||||
export interface PeerEntry {
|
||||
readonly peerId: string;
|
||||
readonly address: string;
|
||||
readonly tier: number;
|
||||
readonly latencyMs: number;
|
||||
}
|
||||
|
||||
export class Registry {
|
||||
private readonly peers = new Map<string, PeerEntry>();
|
||||
public readonly capacity: number;
|
||||
|
||||
public constructor(capacity = 32) {
|
||||
this.capacity = capacity;
|
||||
}
|
||||
|
||||
public add(entry: PeerEntry): void {
|
||||
if (this.peers.size >= this.capacity && !this.peers.has(entry.peerId)) {
|
||||
throw new NetworkRegError(
|
||||
NETWORK_REG_CODES.REGISTRY_FULL,
|
||||
"registry.full",
|
||||
{ context: { capacity: this.capacity } }
|
||||
);
|
||||
}
|
||||
if (this.peers.has(entry.peerId)) {
|
||||
throw new NetworkRegError(
|
||||
NETWORK_REG_CODES.PEER_ALREADY_EXISTS,
|
||||
"registry.peer-already-exists",
|
||||
{ context: { peerId: entry.peerId } }
|
||||
);
|
||||
}
|
||||
this.peers.set(entry.peerId, entry);
|
||||
}
|
||||
|
||||
public remove(peerId: string): boolean {
|
||||
return this.peers.delete(peerId);
|
||||
}
|
||||
|
||||
public get(peerId: string): PeerEntry | undefined {
|
||||
return this.peers.get(peerId);
|
||||
}
|
||||
|
||||
public list(): readonly PeerEntry[] {
|
||||
return Array.from(this.peers.values());
|
||||
}
|
||||
|
||||
public has(peerId: string): boolean {
|
||||
return this.peers.has(peerId);
|
||||
}
|
||||
|
||||
public get size(): number {
|
||||
return this.peers.size;
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.peers.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { RegistrySyncManager } from "./sync.js";
|
||||
@@ -0,0 +1,48 @@
|
||||
# RegistrySyncManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Periodically syncs registry state to the `NetworkBus` via `registry:update` events. This keeps other plugins informed of mesh membership changes without requiring them to poll the registry directly. The sync manager is the bridge between the local registry and the rest of the network stack.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable sync interval (default 5000ms)
|
||||
- Start/stop lifecycle matching node lifecycle
|
||||
- One-shot sync method for on-demand updates
|
||||
- Automatic timer cleanup on stop
|
||||
- Emits full peer list with timestamp
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```ts
|
||||
import { RegistrySyncManager } from "@suaveplan/network-plugin-registry";
|
||||
|
||||
const sync = new RegistrySyncManager(registry, bus, 5000);
|
||||
sync.start();
|
||||
```
|
||||
|
||||
### Advanced Example
|
||||
|
||||
```ts
|
||||
import { RegistrySyncManager } from "@suaveplan/network-plugin-registry";
|
||||
|
||||
const sync = new RegistrySyncManager(registry, bus);
|
||||
sync.sync();
|
||||
sync.stop();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `constructor(registry, bus, intervalMs?)` — create sync manager
|
||||
- `start(): void` — begin periodic sync
|
||||
- `stop(): void` — stop periodic sync
|
||||
- `sync(): void` — emit single update event
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The sync interval defaults to 5000ms, which is a balance between timeliness and event bus load. The `registry:update` event payload includes the full peer list and a timestamp. Callers should filter or debounce if processing these events is expensive. The manager does not diff changes — it always emits the complete state. This simplifies implementation and guarantees that subscribers always have an accurate snapshot, at the cost of slightly larger event payloads. Stopping the manager clears the internal timer and sets the reference to `undefined`, preventing memory leaks during long-running application shutdown.
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NetworkBus } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Registry } from "../registry/registry.js";
|
||||
import { RegistrySyncManager } from "./sync.js";
|
||||
|
||||
describe("RegistrySyncManager", () => {
|
||||
it("emits registry:update on demand sync", () => {
|
||||
const bus = new NetworkBus<{
|
||||
"registry:update": { peers: unknown; timestamp: number };
|
||||
}>();
|
||||
const registry = new Registry(8);
|
||||
registry.add({
|
||||
peerId: "alice",
|
||||
address: "addr-a",
|
||||
tier: 0,
|
||||
latencyMs: 0,
|
||||
});
|
||||
const manager = new RegistrySyncManager(registry, bus, 1000);
|
||||
const seen: Array<{ peers: unknown; timestamp: number }> = [];
|
||||
bus.on("registry:update", (payload) => seen.push(payload));
|
||||
manager.sync();
|
||||
expect(seen).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("emits periodic registry:update events", async () => {
|
||||
const bus = new NetworkBus<{
|
||||
"registry:update": { peers: unknown; timestamp: number };
|
||||
}>();
|
||||
const registry = new Registry(8);
|
||||
const manager = new RegistrySyncManager(registry, bus, 5);
|
||||
const seen: number[] = [];
|
||||
bus.on("registry:update", () => seen.push(Date.now()));
|
||||
manager.start();
|
||||
await new Promise((r) => setTimeout(r, 22));
|
||||
manager.stop();
|
||||
expect(seen.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("stop() is idempotent", () => {
|
||||
const bus = new NetworkBus();
|
||||
const manager = new RegistrySyncManager(new Registry(2), bus, 1000);
|
||||
manager.stop();
|
||||
manager.start();
|
||||
manager.stop();
|
||||
manager.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { NetworkBus } from "@suaveplan/network";
|
||||
import type { Registry } from "../registry/registry.js";
|
||||
|
||||
export class RegistrySyncManager {
|
||||
private readonly registry: Registry;
|
||||
private readonly bus: NetworkBus;
|
||||
private intervalMs: number;
|
||||
private timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
public constructor(registry: Registry, bus: NetworkBus, intervalMs = 5000) {
|
||||
this.registry = registry;
|
||||
this.bus = bus;
|
||||
this.intervalMs = intervalMs;
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this.timer = setInterval(() => {
|
||||
this.sync();
|
||||
}, this.intervalMs);
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public sync(): void {
|
||||
this.bus.emit("registry:update", {
|
||||
peers: this.registry.list(),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("network-plugin-registry dep-graph", () => {
|
||||
it("must not have circular dependencies", async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod.RegistryPlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"references": [{ "path": "./tsconfig.lib.json" }]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/network-plugin-registry",
|
||||
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/bitpacker",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/network",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user