Initial Commit

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

View File

@@ -0,0 +1,89 @@
# @suaveplan/network-plugin-protocol - Network protocol plugin: 13-byte binary header, message encoder/decoder, pipeline, serialization strategies, priority queue, batching, TTL, and route cache
## Summary
Network protocol plugin for the mesh system.
<!-- begin:auto-docs -->
## Documentation tree
- [batcher](./src/batcher/batcher.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [batcher](./src/batcher/batcher.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [decoder](./src/decoder/decoder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [decoder](./src/decoder/decoder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [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 -->
- [pipeline](./src/pipeline/pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [pipeline](./src/pipeline/pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [protocol](./src/protocol/protocol.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [protocol-plugin](./src/protocol/protocol-plugin.md) — The main plugin implementation that integrates the protocol layer into a `NetworkNode`. `ProtocolPlugin` implements the…
- [protocol](./src/protocol/protocol.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [ttl](./src/ttl/ttl.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [ttl](./src/ttl/ttl.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-protocol` package. All runtime failures withi…
<!-- end:auto-docs --> - [batcher](./src/batcher/batcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [decoder](./src/decoder/decoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [decoder](./src/decoder/decoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [pipeline](./src/pipeline/pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [pipeline](./src/pipeline/pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [protocol](./src/protocol/protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [protocol-plugin](./src/protocol/protocol-plugin.md) — The main plugin implementation that integrates the protocol layer into a `NetworkNode`. `ProtocolPlugin` implements the…
- [protocol](./src/protocol/protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [ttl](./src/ttl/ttl.md) — <!-- begin:auto-docs --> _(no submodules)_- [ttl](./src/ttl/ttl.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable error-code registry for the `@suaveplan/network-plugin-protocol` package. All runtime failures withi…
- [batcher](./src/batcher/batcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [decoder](./src/decoder/decoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [decoder](./src/decoder/decoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [pipeline](./src/pipeline/pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [pipeline](./src/pipeline/pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [protocol](./src/protocol/protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [protocol-plugin](./src/protocol/protocol-plugin.md) — The main plugin implementation that integrates the protocol layer into a `NetworkNode`. `ProtocolPlugin` implements the…
- [protocol](./src/protocol/protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [ttl](./src/ttl/ttl.md) — <!-- begin:auto-docs --> _(no submodules)_- [ttl](./src/ttl/ttl.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable error-code registry for the `@suaveplan/network-plugin-protocol` package. All runtime failures withi…
- [batcher](./src/batcher/batcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [decoder](./src/decoder/decoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [decoder](./src/decoder/decoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [pipeline](./src/pipeline/pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [pipeline](./src/pipeline/pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [protocol](./src/protocol/protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [protocol-plugin](./src/protocol/protocol-plugin.md) — The main plugin implementation that integrates the protocol layer into a `NetworkNode`. `ProtocolPlugin` implements the…
- [protocol](./src/protocol/protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [ttl](./src/ttl/ttl.md) — <!-- begin:auto-docs --> _(no submodules)_- [ttl](./src/ttl/ttl.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable error-code registry for the `@suaveplan/network-plugin-protocol` package. All runtime failures withi…
- [batcher](./src/batcher/batcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [decoder](./src/decoder/decoder.md) — <!-- begin:auto-docs --> _(no submodules)_ - [decoder](./src/decoder/decoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_ - [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [pipeline](./src/pipeline/pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_ - [pipeline](./src/pipeline/pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [protocol](./src/protocol/protocol.md) — <!-- begin:auto-docs --> _(no submodules)_ - [protocol-plugin](./src/protocol/protocol-plugin.md) — The main plugin implementation that integrates the protocol layer into a `NetworkNode`. `ProtocolPlugin` implements the…
- [protocol](./src/protocol/protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_ - [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [ttl](./src/ttl/ttl.md) — <!-- begin:auto-docs --> _(no submodules)_ - [ttl](./src/ttl/ttl.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable error-code registry for the `@suaveplan/network-plugin-protocol` package. All runtime failures withi…
## Purpose
Implements the compact binary protocol layer for all peer-to-peer messages. This plugin provides the 13-byte binary header, message encoder/decoder, serialization strategy framework, priority message queueing, adaptive batching, TTL/loop detection, and transform pipeline. It plugs into `NetworkNode` via the `NetworkPlugin` interface.
## Features
- **13-byte binary header** — version, flags, message type, timestamp, payload length
- **MessageEncoder / MessageDecoder** — wire-format serialization
- **SerializerFactory** — JSON, binary, and MessagePack strategies with fallback
- **PriorityMessageQueue** — 5-tier priority with CRITICAL bypass
- **MessageBatcher** — groups messages by type/target, flushes on size/time
- **MessageTTL / LoopDetector** — prevents infinite propagation and cycles
- **ProtocolPipeline** — ordered transform stages for outgoing and incoming data
## Usage
### Basic Example
```ts
import { ProtocolPlugin } from "@suaveplan/network-plugin-protocol";
const plugin = new ProtocolPlugin();
node.use(plugin);
```
### Advanced Example
```ts
import { ProtocolPlugin, PriorityMessageQueue } from "@suaveplan/network-plugin-protocol";
const plugin = new ProtocolPlugin({
queue: { capacity: 2000, policy: "drop-oldest" },
batch: { maxSize: 50, maxWaitMs: 25, priorityTrigger: "HIGH" },
});
node.use(plugin);
```
## API Reference
See per-module documentation in `src/`:
- `src/protocol/` — `BinaryProtocol`, `ProtocolPlugin`
- `src/encoder/` — `MessageEncoder`
- `src/decoder/` — `MessageDecoder`
- `src/serializer/` — `SerializerFactory`, `JsonSerializer`, `BinarySerializer`, `MessagePackSerializer`
- `src/queue/` — `PriorityMessageQueue`
- `src/batcher/` — `MessageBatcher`
- `src/ttl/` — `MessageTTL`, `LoopDetector`
- `src/pipeline/` — `ProtocolPipeline`
## Implementation Notes
The protocol plugin is transport-agnostic. It serializes envelopes to byte arrays but does not send them over the wire. Transports consume the encoded bytes and handle actual delivery. The bitpacker integration uses `@suaveplan/bitpacker` for header field packing. The pipeline integrates with `@suaveplan/streams` transforms for compression and encryption.

View File

@@ -0,0 +1,85 @@
{
"name": "@suaveplan/network-plugin-protocol",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Network protocol plugin: 13-byte binary header, message encoder/decoder, pipeline, serialization strategies, priority queue, batching, TTL, and route cache",
"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" }
},
"./protocol": {
"import": { "types": "./dist/protocol/protocol.d.ts", "default": "./dist/protocol/protocol.js" },
"require": { "types": "./dist/protocol/protocol.d.ts", "default": "./dist/protocol/protocol.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" }
},
"./decoder": {
"import": { "types": "./dist/decoder/decoder.d.ts", "default": "./dist/decoder/decoder.js" },
"require": { "types": "./dist/decoder/decoder.d.ts", "default": "./dist/decoder/decoder.js" }
},
"./pipeline": {
"import": { "types": "./dist/pipeline/pipeline.d.ts", "default": "./dist/pipeline/pipeline.js" },
"require": { "types": "./dist/pipeline/pipeline.d.ts", "default": "./dist/pipeline/pipeline.js" }
},
"./serializer": {
"import": { "types": "./dist/serializer/serializer.d.ts", "default": "./dist/serializer/serializer.js" },
"require": { "types": "./dist/serializer/serializer.d.ts", "default": "./dist/serializer/serializer.js" }
},
"./queue": {
"import": { "types": "./dist/queue/queue.d.ts", "default": "./dist/queue/queue.js" },
"require": { "types": "./dist/queue/queue.d.ts", "default": "./dist/queue/queue.js" }
},
"./batcher": {
"import": { "types": "./dist/batcher/batcher.d.ts", "default": "./dist/batcher/batcher.js" },
"require": { "types": "./dist/batcher/batcher.d.ts", "default": "./dist/batcher/batcher.js" }
},
"./ttl": {
"import": { "types": "./dist/ttl/ttl.d.ts", "default": "./dist/ttl/ttl.js" },
"require": { "types": "./dist/ttl/ttl.d.ts", "default": "./dist/ttl/ttl.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/serialize": "workspace:^",
"@suaveplan/streams": "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"
}
}

View File

@@ -0,0 +1,55 @@
# MessageBatcher
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Groups related messages by type and recipient for efficient transmission. Batches flush when they reach a size limit, a time limit, or a priority trigger. This reduces per-message overhead and improves throughput for high-volume scenarios.
## Features
- Grouping by message type and target recipient
- Size-based flush when batch reaches max size
- Time-based flush for stale messages
- Auto-flush interval for periodic sending
- Manual flush for immediate transmission
## Usage
### Basic Example
```ts
import { MessageBatcher } from "@suaveplan/network-plugin-protocol";
const batcher = new MessageBatcher({ maxSize: 100, maxWaitMs: 50, priorityTrigger: "CRITICAL" });
const result = batcher.add(envelope);
if (result.flushed) {
sendBatch(result.batch!);
}
```
### Advanced Example
```ts
import { MessageBatcher } from "@suaveplan/network-plugin-protocol";
const batcher = new MessageBatcher({ maxSize: 50, maxWaitMs: 100, priorityTrigger: "HIGH" });
batcher.startAutoFlush(50, (batch) => sendBatch(batch));
```
## API Reference
- `constructor(config)` — create batcher with size, wait, and trigger settings
- `add(envelope): { flushed, batch? }` — add message, possibly flushing
- `flush(): MessageEnvelope[][]` — flush all pending batches
- `flushExpired(): MessageEnvelope[][]` — flush only expired batches
- `startAutoFlush(intervalMs, handler)` — periodic flush
- `stopAutoFlush()` — stop periodic flush
- `pendingCount: number` — number of pending batches
## Implementation Notes
Batch keys are derived from `type:targetId` pairs. Messages without a target use the `"broadcast"` key. Expired batches are those where the oldest message exceeds `maxWaitMs`. The auto-flush interval should be shorter than `maxWaitMs` to avoid excessive delays. Batching is most effective when many small messages of the same type are sent to the same recipient.
The batcher does not guarantee delivery order across different batches. Within a single batch, messages retain their original enqueue order. Callers that require strict global ordering should disable batching or use a single batch key for all messages.

View File

@@ -0,0 +1,114 @@
import { createEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MessageBatcher } from "./batcher.js";
describe("MessageBatcher", () => {
it("must batch messages by type and target", () => {
const batcher = new MessageBatcher({
maxSize: 10,
maxWaitMs: 1000,
priorityTrigger: "CRITICAL",
});
const env = createEnvelope({
type: "t",
senderId: "s",
targetId: "x",
payload: null,
});
const result = batcher.add(env);
expect(result.flushed).toBe(false);
expect(batcher.pendingCount).toBe(1);
});
it("must flush on size limit", () => {
const batcher = new MessageBatcher({
maxSize: 2,
maxWaitMs: 1000,
priorityTrigger: "CRITICAL",
});
const env = createEnvelope({
type: "t",
senderId: "s",
targetId: "x",
payload: null,
});
batcher.add(env);
const result = batcher.add(env);
expect(result.flushed).toBe(true);
expect(result.batch?.length).toBe(2);
});
it("must flush all manually", () => {
const batcher = new MessageBatcher({
maxSize: 10,
maxWaitMs: 1000,
priorityTrigger: "CRITICAL",
});
batcher.add(
createEnvelope({ type: "t", senderId: "s", payload: null })
);
const batches = batcher.flush();
expect(batches.length).toBe(1);
expect(batcher.pendingCount).toBe(0);
});
it("must flush expired batches", async () => {
const batcher = new MessageBatcher({
maxSize: 100,
maxWaitMs: 1,
priorityTrigger: "CRITICAL",
});
batcher.add(
createEnvelope({ type: "t", senderId: "s", payload: null })
);
await new Promise((r) => setTimeout(r, 10));
const expired = batcher.flushExpired();
expect(expired.length).toBe(1);
});
it("flushExpired skips batches still inside the wait window", () => {
const batcher = new MessageBatcher({
maxSize: 100,
maxWaitMs: 1_000_000,
priorityTrigger: "CRITICAL",
});
batcher.add(
createEnvelope({ type: "t", senderId: "s", payload: null })
);
expect(batcher.flushExpired()).toEqual([]);
expect(batcher.pendingCount).toBe(1);
});
it("startAutoFlush invokes the handler for expired batches", async () => {
const batcher = new MessageBatcher({
maxSize: 100,
maxWaitMs: 1,
priorityTrigger: "CRITICAL",
});
batcher.add(
createEnvelope({ type: "t", senderId: "s", payload: null })
);
const seen: number[] = [];
batcher.startAutoFlush(2, (batch) => seen.push(batch.length));
await new Promise((r) => setTimeout(r, 20));
batcher.stopAutoFlush();
batcher.stopAutoFlush(); // idempotent
expect(seen.length).toBeGreaterThan(0);
});
it("groups envelopes with no targetId under the broadcast key", () => {
const batcher = new MessageBatcher({
maxSize: 100,
maxWaitMs: 1_000,
priorityTrigger: "CRITICAL",
});
batcher.add(
createEnvelope({ type: "broadcast", senderId: "s", payload: null })
);
batcher.add(
createEnvelope({ type: "broadcast", senderId: "s", payload: null })
);
// both batched together → still one pending
expect(batcher.pendingCount).toBe(1);
});
});

View File

@@ -0,0 +1,91 @@
import type { MessageEnvelope } from "@suaveplan/network";
import type { BatchConfig } from "../types/protocol.types.js";
interface BatchEntry {
readonly envelopes: MessageEnvelope[];
readonly createdAt: number;
}
export class MessageBatcher {
private readonly batches = new Map<string, BatchEntry>();
private readonly config: BatchConfig;
private flushTimer: ReturnType<typeof setInterval> | undefined;
public constructor(config: BatchConfig) {
this.config = config;
}
public add(envelope: MessageEnvelope): {
readonly flushed: boolean;
readonly batch?: MessageEnvelope[];
} {
const key = this.getBatchKey(envelope);
const existing = this.batches.get(key);
const now = Date.now();
if (existing) {
const updated: BatchEntry = {
envelopes: [...existing.envelopes, envelope],
createdAt: existing.createdAt,
};
this.batches.set(key, updated);
if (updated.envelopes.length >= this.config.maxSize) {
this.batches.delete(key);
return { flushed: true, batch: updated.envelopes };
}
return { flushed: false };
}
this.batches.set(key, { envelopes: [envelope], createdAt: now });
return { flushed: false };
}
public flush(): MessageEnvelope[][] {
const batches: MessageEnvelope[][] = [];
for (const [key, entry] of this.batches) {
batches.push(entry.envelopes);
this.batches.delete(key);
}
return batches;
}
public flushExpired(): MessageEnvelope[][] {
const now = Date.now();
const batches: MessageEnvelope[][] = [];
for (const [key, entry] of this.batches) {
if (now - entry.createdAt >= this.config.maxWaitMs) {
batches.push(entry.envelopes);
this.batches.delete(key);
}
}
return batches;
}
public startAutoFlush(
intervalMs: number,
handler: (batch: MessageEnvelope[]) => void
): void {
this.flushTimer = setInterval(() => {
const expired = this.flushExpired();
for (const batch of expired) {
handler(batch);
}
}, intervalMs);
}
public stopAutoFlush(): void {
if (this.flushTimer) {
clearInterval(this.flushTimer);
this.flushTimer = undefined;
}
}
public get pendingCount(): number {
return this.batches.size;
}
private getBatchKey(envelope: MessageEnvelope): string {
return `${envelope.type}:${envelope.targetId ?? "broadcast"}`;
}
}

View File

@@ -0,0 +1 @@
export { MessageBatcher } from "./batcher.js";

View File

@@ -0,0 +1,45 @@
# MessageDecoder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Decodes wire-format byte arrays back into headers and payloads. The decoder validates header integrity, checks message completeness, and extracts the payload for further deserialization by the appropriate serializer.
## Features
- Header extraction from raw byte streams
- Payload boundary detection based on header length
- Incomplete message detection for streaming scenarios
## Usage
### Basic Example
```ts
import { MessageDecoder } from "@suaveplan/network-plugin-protocol";
const decoder = new MessageDecoder();
const { header, remaining } = decoder.decodeHeader(data);
```
### Advanced Example
```ts
import { MessageDecoder } from "@suaveplan/network-plugin-protocol";
const decoder = new MessageDecoder();
const payload = decoder.extractPayload(fullMessage);
```
## API Reference
- `decodeHeader(data): { header, remaining }` — extract header and payload bytes
- `extractPayload(data): Uint8Array` — extract payload only
## Implementation Notes
The decoder assumes the input contains at least one complete message. If the data is shorter than `HEADER_SIZE + payloadLength`, it throws an error indicating an incomplete message. Callers that process streaming data should buffer partial reads and retry once more data arrives. The decoder does not perform any payload deserialization — that is the responsibility of the serializer layer, which selects the appropriate format based on the content type flag in the header. Separation of concerns between decoding and deserialization allows each layer to evolve independently.
The decoder uses `subarray` rather than `slice` to avoid copying data, which is important for performance when processing large messages. The returned `remaining` buffer shares the same underlying memory as the input, so callers should copy it if they need to retain it beyond the current processing cycle.

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BinaryProtocol } from "../protocol/protocol.js";
import { MessageDecoder } from "./decoder.js";
describe("MessageDecoder", () => {
it("must decode header and payload", () => {
const protocol = new BinaryProtocol();
const payload = new Uint8Array([10, 20, 30]);
const header = protocol.encodeHeader({
version: 1,
flags: 0,
messageType: 5,
timestamp: 100n,
payloadLength: payload.length,
});
const combined = new Uint8Array(header.length + payload.length);
combined.set(header, 0);
combined.set(payload, header.length);
const decoder = new MessageDecoder();
const result = decoder.decodeHeader(combined);
expect(result.header.messageType).toBe(5);
expect(result.remaining.length).toBe(payload.length);
});
it("must extract payload", () => {
const protocol = new BinaryProtocol();
const payload = new Uint8Array([1, 2, 3, 4]);
const header = protocol.encodeHeader({
version: 1,
flags: 0,
messageType: 1,
timestamp: 0n,
payloadLength: payload.length,
});
const combined = new Uint8Array(header.length + payload.length);
combined.set(header, 0);
combined.set(payload, header.length);
const decoder = new MessageDecoder();
const extracted = decoder.extractPayload(combined);
expect(extracted).toEqual(payload);
});
it("throws when the wire frame is shorter than declared payload length", () => {
const protocol = new BinaryProtocol();
const header = protocol.encodeHeader({
version: 1,
flags: 0,
messageType: 1,
timestamp: 0n,
payloadLength: 100, // claims 100 bytes
});
const truncated = new Uint8Array(header.length + 5); // only 5
truncated.set(header, 0);
const decoder = new MessageDecoder();
expect(() => decoder.decodeHeader(truncated)).toThrow(
"protocol.incomplete-message"
);
});
});

View File

@@ -0,0 +1,24 @@
import { BinaryProtocol, HEADER_SIZE } from "../protocol/protocol.js";
import type { BinaryHeader } from "../types/protocol.types.js";
export class MessageDecoder {
private readonly protocol = new BinaryProtocol();
public decodeHeader(data: Uint8Array): {
readonly header: BinaryHeader;
readonly remaining: Uint8Array;
} {
const header = this.protocol.decodeHeader(data);
const totalLength = HEADER_SIZE + header.payloadLength;
if (data.length < totalLength) {
throw new Error("protocol.incomplete-message");
}
const remaining = data.subarray(HEADER_SIZE, totalLength);
return { header, remaining };
}
public extractPayload(data: Uint8Array): Uint8Array {
const { remaining } = this.decodeHeader(data);
return remaining;
}
}

View File

@@ -0,0 +1 @@
export { MessageDecoder } from "./decoder.js";

View File

@@ -0,0 +1,46 @@
# MessageEncoder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Encodes `MessageEnvelope` objects into wire-format byte arrays. The encoder combines the 13-byte binary header with a serialized payload. It uses `BinaryProtocol` for header generation and hashes message types into single-byte identifiers for compactness.
## Features
- Header + payload concatenation into a single byte array
- Message type hashing for compact 1-byte identifiers
- Content type flag encoding for serializer negotiation
- Bitpacker schema integration for header fields
## Usage
### Basic Example
```ts
import { MessageEncoder } from "@suaveplan/network-plugin-protocol";
const encoder = new MessageEncoder();
const bytes = encoder.encode(envelope, { data: payload, contentType: "json" });
```
### Advanced Example
```ts
import { MessageEncoder } from "@suaveplan/network-plugin-protocol";
const encoder = new MessageEncoder();
const headerOnly = encoder.encodeHeaderOnly(envelope, 256, "msgpack");
```
## API Reference
- `encode(envelope, payload): Uint8Array` — full encode with header and payload
- `encodeHeaderOnly(envelope, payloadLength, contentType): Uint8Array` — header only
## Implementation Notes
Message type hashing uses a simple 31-multiplicative hash modulo 256. This is deterministic but not cryptographically secure — it is only for compact header representation. Collisions are possible but acceptable because the full type string is present in the payload, allowing the receiver to resolve any ambiguity. The bitpacker schema for header fields is defined but not yet used in the current implementation; it is reserved for future optimization when we need to pack multiple small fields into a single byte. The encoder handles both full message encoding and header-only encoding, which is useful when the payload is generated separately or streamed.
The encoder allocates a new `Uint8Array` for each message, combining the header and payload into a single contiguous buffer. This simplifies transport handling by ensuring that each send operation corresponds to exactly one byte array.

View File

@@ -0,0 +1,54 @@
import { createEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { HEADER_SIZE } from "../protocol/protocol.js";
import { MessageEncoder } from "./encoder.js";
describe("MessageEncoder", () => {
it("must encode envelope with payload", () => {
const encoder = new MessageEncoder();
const envelope = createEnvelope({
type: "test:message",
senderId: "a",
payload: { data: "hello" },
});
const payload = new Uint8Array([1, 2, 3]);
const encoded = encoder.encode(envelope, {
data: payload,
contentType: "json",
});
expect(encoded.length).toBe(HEADER_SIZE + payload.length);
});
it("must encode header only", () => {
const encoder = new MessageEncoder();
const envelope = createEnvelope({
type: "test:message",
senderId: "a",
payload: null,
});
const header = encoder.encodeHeaderOnly(envelope, 256, "binary");
expect(header.length).toBe(HEADER_SIZE);
});
it("encodes msgpack content type without crashing", () => {
const encoder = new MessageEncoder();
const envelope = createEnvelope({
type: "test:msgpack",
senderId: "a",
payload: null,
});
const header = encoder.encodeHeaderOnly(envelope, 16, "msgpack");
expect(header.length).toBe(HEADER_SIZE);
});
it("encodes a custom-typed content tag via the default branch", () => {
const encoder = new MessageEncoder();
const envelope = createEnvelope({
type: "test:custom",
senderId: "a",
payload: null,
});
const header = encoder.encodeHeaderOnly(envelope, 16, "weird" as never);
expect(header.length).toBe(HEADER_SIZE);
});
});

View File

@@ -0,0 +1,79 @@
import { defineBitSchema } from "@suaveplan/bitpacker";
import type { MessageEnvelope } from "@suaveplan/network";
import { BinaryProtocol, PROTOCOL_VERSION } from "../protocol/protocol.js";
import type { SerializedPayload } from "../types/protocol.types.js";
const _HeaderSchema = defineBitSchema<{
version: number;
flags: number;
messageType: number;
}>([
{ name: "version", type: "uint", bits: 8 },
{ name: "flags", type: "uint", bits: 8 },
{ name: "messageType", type: "uint", bits: 8 },
]);
export class MessageEncoder {
private readonly protocol = new BinaryProtocol();
public encode(
envelope: MessageEnvelope,
payload: SerializedPayload
): Uint8Array {
const timestamp = BigInt(envelope.timestamp);
const header = this.protocol.encodeHeader({
version: PROTOCOL_VERSION,
flags: this.protocol.buildFlags({
contentType: this.contentTypeToFlag(payload.contentType),
encrypted: false,
compressed: false,
}),
messageType: this.hashMessageType(envelope.type),
timestamp,
payloadLength: payload.data.length,
});
const combined = new Uint8Array(header.length + payload.data.length);
combined.set(header, 0);
combined.set(payload.data, header.length);
return combined;
}
public encodeHeaderOnly(
envelope: MessageEnvelope,
payloadLength: number,
contentType: SerializedPayload["contentType"]
): Uint8Array {
return this.protocol.encodeHeader({
version: PROTOCOL_VERSION,
flags: this.protocol.buildFlags({
contentType: this.contentTypeToFlag(contentType),
encrypted: false,
compressed: false,
}),
messageType: this.hashMessageType(envelope.type),
timestamp: BigInt(envelope.timestamp),
payloadLength,
});
}
private hashMessageType(type: string): number {
let hash = 0;
for (let i = 0; i < type.length; i++) {
hash = (hash * 31 + type.charCodeAt(i)) & 0xff;
}
return hash;
}
private contentTypeToFlag(ct: SerializedPayload["contentType"]): number {
switch (ct) {
case "json":
return 0;
case "binary":
return 1;
case "msgpack":
return 2;
default:
return 0;
}
}
}

View File

@@ -0,0 +1 @@
export { MessageEncoder } from "./encoder.js";

View File

@@ -0,0 +1,51 @@
# Protocol Error Codes
## Purpose
Defines the stable error-code registry for the `@suaveplan/network-plugin-protocol` package. All runtime failures within the protocol layer are tagged with deterministic codes so callers can handle them programmatically. The protocol layer deals with binary serialization, header parsing, queue overflow, TTL expiration, and loop detection.
## Features
- Deterministic code generation via `defineCodes`
- Typed error class extending `SuaveplanError`
- Full OpenTelemetry span-event compatibility
- Tree-shake safe
## Usage
### Basic Example
```ts
import { NetworkProtoError, NETWORK_PROTO_CODES } from "@suaveplan/network-plugin-protocol";
throw new NetworkProtoError(
NETWORK_PROTO_CODES.INVALID_HEADER,
"protocol.header-too-short"
);
```
### Advanced Example
```ts
import { NetworkProtoError, NETWORK_PROTO_CODES } from "@suaveplan/network-plugin-protocol";
try {
protocol.decodeHeader(shortBuffer);
} catch (err) {
if (err instanceof NetworkProtoError && err.code === NETWORK_PROTO_CODES.INVALID_HEADER) {
console.log("Bad header received");
}
}
```
## API Reference
- `NETWORK_PROTO_CODES` — frozen registry of protocol error codes
- `NetworkProtoError` — typed error class
- `NetworkProtoErrorCode` — union type of valid codes
## Implementation Notes
Codes are namespaced under `NET_PROTO` to avoid collisions with other packages in the project. The registry includes codes for header validation, payload limits, serialization failures, unsupported serializers, queue overflow, TTL expiration, loop detection, and duplicate message filtering. Each code maps to a human-readable message key that can be resolved for internationalization. The `NetworkProtoError` 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.
The error registry is frozen after creation to prevent accidental mutation. New codes should be added here before being used anywhere in the package. The `SuaveplanError` base class supports structured context data, which is essential for debugging protocol issues in production environments.

View File

@@ -0,0 +1,27 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const NETWORK_PROTO_CODES = defineCodes("NET_PROTO", [
"INVALID_HEADER",
"PAYLOAD_TOO_LARGE",
"SERIALIZATION_FAILED",
"DESERIALIZATION_FAILED",
"UNSUPPORTED_SERIALIZER",
"QUEUE_FULL",
"TTL_EXPIRED",
"LOOP_DETECTED",
"DUPLICATE_MESSAGE",
] as const);
export type NetworkProtoErrorCode =
(typeof NETWORK_PROTO_CODES)[keyof typeof NETWORK_PROTO_CODES];
export class NetworkProtoError extends SuaveplanError<NetworkProtoErrorCode> {
public constructor(
code: NetworkProtoErrorCode,
messageKey: string,
options?: import("@suaveplan/error").SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetworkProtoError";
}
}

View File

@@ -0,0 +1,49 @@
// Error codes
import "./error-codes.js";
export { MessageBatcher } from "./batcher/batcher.js";
export { MessageDecoder } from "./decoder/decoder.js";
// Encoder / Decoder
export { MessageEncoder } from "./encoder/encoder.js";
// Errors
export {
NETWORK_PROTO_CODES,
NetworkProtoError,
type NetworkProtoErrorCode,
} from "./error-codes.js";
// Pipeline
export { type PipelineStage, ProtocolPipeline } from "./pipeline/pipeline.js";
// Protocol
export {
BinaryProtocol,
HEADER_SIZE,
PROTOCOL_VERSION,
} from "./protocol/protocol.js";
export {
ProtocolPlugin,
type ProtocolPluginOptions,
} from "./protocol/protocol-plugin.js";
// Queue / Batcher
export { PriorityMessageQueue } from "./queue/queue.js";
// Serializer
export {
BinarySerializer,
type ISerializer,
JsonSerializer,
MessagePackSerializer,
SerializerFactory,
} from "./serializer/serializer.js";
// TTL / Loop
export { LoopDetector, MessageTTL } from "./ttl/ttl.js";
// Types
export type {
BatchConfig,
BinaryHeader,
PriorityTier,
ProtocolMessage,
ProtocolVersion,
QueueConfig,
SerializedPayload,
SerializerType,
} from "./types/protocol.types.js";

View File

@@ -0,0 +1,2 @@
export type { PipelineStage } from "./pipeline.js";
export { ProtocolPipeline } from "./pipeline.js";

View File

@@ -0,0 +1,52 @@
# ProtocolPipeline
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Chains transform stages for outgoing and incoming messages. Stages can perform compression, encryption, framing, or any other byte-level transformation. The pipeline processes stages in order for outgoing messages and in reverse order for incoming messages, maintaining the classic encode-decode symmetry.
## Features
- Ordered stage composition for both directions
- Separate incoming and outgoing chains
- Async stage support for non-blocking transforms
- Stage introspection for debugging
## Usage
### Basic Example
```ts
import { ProtocolPipeline } from "@suaveplan/network-plugin-protocol";
const pipeline = new ProtocolPipeline();
pipeline.addOutgoing({ name: "compress", process: (data) => compress(data) });
const encoded = await pipeline.encode(envelope, payload);
```
### Advanced Example
```ts
import { ProtocolPipeline } from "@suaveplan/network-plugin-protocol";
const pipeline = new ProtocolPipeline();
pipeline.addOutgoing({ name: "encrypt", process: async (data) => await encrypt(data) });
pipeline.addIncoming({ name: "decrypt", process: async (data) => await decrypt(data) });
```
## API Reference
- `addOutgoing(stage)` — append stage to outgoing chain
- `addIncoming(stage)` — prepend stage to incoming chain
- `encode(envelope, payload): Promise<Uint8Array>` — process outgoing data
- `decode(data): Promise<Uint8Array>` — process incoming data
- `getOutgoingStages(): PipelineStage[]` — list outgoing stages
- `getIncomingStages(): PipelineStage[]` — list incoming stages
## Implementation Notes
Stages are applied sequentially. If a stage throws, the pipeline aborts and the error propagates. The pipeline does not handle partial reads or streaming — it operates on complete byte arrays. For streaming transforms, use `@suaveplan/streams` directly. The incoming chain is built in reverse order so that the last-added incoming stage runs first, mirroring the outgoing order.
The pipeline design follows the classic decorator pattern. Each stage is independent and composable. Stages can be added and removed at runtime, although removal is not yet implemented. Future versions may support conditional stages that only run for specific message types or priority levels.

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ProtocolPipeline } from "./pipeline.js";
describe("ProtocolPipeline", () => {
it("must process outgoing stages in order", async () => {
const pipeline = new ProtocolPipeline();
const order: string[] = [];
pipeline.addOutgoing({
name: "a",
process: async (data) => {
order.push("a");
return data;
},
});
pipeline.addOutgoing({
name: "b",
process: async (data) => {
order.push("b");
return data;
},
});
await pipeline.encode({} as never, new Uint8Array([1]));
expect(order).toEqual(["a", "b"]);
});
it("must process incoming stages in reverse order", async () => {
const pipeline = new ProtocolPipeline();
const order: string[] = [];
pipeline.addIncoming({
name: "a",
process: async (data) => {
order.push("a");
return data;
},
});
pipeline.addIncoming({
name: "b",
process: async (data) => {
order.push("b");
return data;
},
});
await pipeline.decode(new Uint8Array([1]));
expect(order).toEqual(["b", "a"]);
});
it("must expose stage lists", () => {
const pipeline = new ProtocolPipeline();
const stage = { name: "x", process: (d: Uint8Array) => d };
pipeline.addOutgoing(stage);
expect(pipeline.getOutgoingStages()).toContain(stage);
expect(pipeline.getIncomingStages()).toHaveLength(0);
});
});

View File

@@ -0,0 +1,46 @@
import type { MessageEnvelope } from "@suaveplan/network";
export interface PipelineStage {
readonly name: string;
process(data: Uint8Array): Uint8Array | Promise<Uint8Array>;
}
export class ProtocolPipeline {
private readonly outgoing: PipelineStage[] = [];
private readonly incoming: PipelineStage[] = [];
public addOutgoing(stage: PipelineStage): void {
this.outgoing.push(stage);
}
public addIncoming(stage: PipelineStage): void {
this.incoming.unshift(stage);
}
public async encode(
_envelope: MessageEnvelope,
payload: Uint8Array
): Promise<Uint8Array> {
let data = payload;
for (const stage of this.outgoing) {
data = await stage.process(data);
}
return data;
}
public async decode(data: Uint8Array): Promise<Uint8Array> {
let result = data;
for (const stage of this.incoming) {
result = await stage.process(result);
}
return result;
}
public getOutgoingStages(): readonly PipelineStage[] {
return [...this.outgoing];
}
public getIncomingStages(): readonly PipelineStage[] {
return [...this.incoming];
}
}

View File

@@ -0,0 +1,5 @@
export { BinaryProtocol, HEADER_SIZE, PROTOCOL_VERSION } from "./protocol.js";
export {
ProtocolPlugin,
type ProtocolPluginOptions,
} from "./protocol-plugin.js";

View File

@@ -0,0 +1,58 @@
# ProtocolPlugin
## Purpose
The main plugin implementation that integrates the protocol layer into a `NetworkNode`. `ProtocolPlugin` implements the `NetworkPlugin` interface and exposes all protocol components — encoder, decoder, serializer factory, priority queue, batcher, TTL handler, loop detector, and pipeline — as a single cohesive unit.
## Features
- Implements `NetworkPlugin` for automatic lifecycle management
- Exposes all protocol components as public readonly properties
- Configurable queue and batch settings via constructor options
- Message encoding with default serializer
- Header + payload decoding
## Usage
### Basic Example
```ts
import { ProtocolPlugin } from "@suaveplan/network-plugin-protocol";
const plugin = new ProtocolPlugin();
node.use(plugin);
```
### Advanced Example
```ts
import { ProtocolPlugin } from "@suaveplan/network-plugin-protocol";
const plugin = new ProtocolPlugin({
queue: { capacity: 2000, policy: "drop-oldest" },
batch: { maxSize: 50, maxWaitMs: 25, priorityTrigger: "HIGH" },
});
node.use(plugin);
const bytes = plugin.encodeMessage(envelope);
```
## API Reference
- `name: "protocol"` — plugin identifier
- `encoder: MessageEncoder` — envelope encoder
- `decoder: MessageDecoder` — envelope decoder
- `protocol: BinaryProtocol` — header codec
- `serializers: SerializerFactory` — serializer registry
- `queue: PriorityMessageQueue` — priority queue
- `batcher: MessageBatcher` — message batcher
- `ttl: MessageTTL` — TTL handler
- `loopDetector: LoopDetector` — loop detection
- `pipeline: ProtocolPipeline` — transform pipeline
- `encodeMessage(envelope): Uint8Array` — encode with default serializer
- `decodeMessage(data): { header, payload }` — decode header and payload
## Implementation Notes
The plugin stores a reference to the `NetworkNode` during `install()` and clears it on `dispose()`. All components are created eagerly in the constructor. The `encodeMessage` method uses the default JSON serializer; callers that need a specific serializer should use the `serializers` factory directly.
The plugin follows the standard `NetworkPlugin` lifecycle: `install` attaches to the node, `start` begins active operation, `stop` pauses timers and clears state, and `dispose` releases resources. This predictable lifecycle makes the plugin safe to use in dynamic mesh environments where nodes may be created and destroyed frequently.

View File

@@ -0,0 +1,56 @@
import { createEnvelope, NetworkNode } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ProtocolPlugin } from "./protocol-plugin.js";
describe("ProtocolPlugin", () => {
it("exposes the canonical name", () => {
const p = new ProtocolPlugin();
expect(p.name).toBe("protocol");
});
it("constructs the encoder/decoder/protocol/queue/batcher/ttl/pipeline", () => {
const p = new ProtocolPlugin();
expect(p.encoder).toBeDefined();
expect(p.decoder).toBeDefined();
expect(p.protocol).toBeDefined();
expect(p.queue).toBeDefined();
expect(p.batcher).toBeDefined();
expect(p.ttl).toBeDefined();
expect(p.loopDetector).toBeDefined();
expect(p.pipeline).toBeDefined();
});
it("respects custom queue and batch options", () => {
const p = new ProtocolPlugin({
queue: { capacity: 5, policy: "drop-newest" },
batch: {
maxSize: 4,
maxWaitMs: 100,
priorityTrigger: "HIGH",
},
});
expect(p.queue.size).toBe(0);
expect(p.batcher.pendingCount).toBe(0);
});
it("install / start / stop / dispose run cleanly through a NetworkNode", async () => {
const node = new NetworkNode();
const plugin = new ProtocolPlugin();
node.use(plugin);
await node.start();
await node.stop();
await node.dispose();
});
it("encodeMessage and decodeMessage round-trip an envelope", () => {
const plugin = new ProtocolPlugin();
const envelope = createEnvelope({
type: "test:roundtrip",
senderId: "alice",
payload: { hello: "world" },
});
const bytes = plugin.encodeMessage(envelope);
const decoded = plugin.decodeMessage(bytes);
expect(decoded.header.payloadLength).toBe(decoded.payload.length);
});
});

View File

@@ -0,0 +1,78 @@
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
import { MessageBatcher } from "../batcher/batcher.js";
import { MessageDecoder } from "../decoder/decoder.js";
import { MessageEncoder } from "../encoder/encoder.js";
import { ProtocolPipeline } from "../pipeline/pipeline.js";
import { BinaryProtocol } from "../protocol/protocol.js";
import { PriorityMessageQueue } from "../queue/queue.js";
import { SerializerFactory } from "../serializer/serializer.js";
import { LoopDetector, MessageTTL } from "../ttl/ttl.js";
import type { BatchConfig, QueueConfig } from "../types/protocol.types.js";
export interface ProtocolPluginOptions {
readonly queue?: QueueConfig;
readonly batch?: BatchConfig;
}
export class ProtocolPlugin implements NetworkPlugin {
public readonly name = "protocol";
public readonly encoder = new MessageEncoder();
public readonly decoder = new MessageDecoder();
public readonly protocol = new BinaryProtocol();
public readonly serializers = new SerializerFactory();
public readonly queue: PriorityMessageQueue;
public readonly batcher: MessageBatcher;
public readonly ttl = new MessageTTL();
public readonly loopDetector = new LoopDetector();
public readonly pipeline = new ProtocolPipeline();
public constructor(options: ProtocolPluginOptions = {}) {
this.queue = new PriorityMessageQueue(
options.queue ?? { capacity: 1000, policy: "drop-oldest" }
);
this.batcher = new MessageBatcher(
options.batch ?? {
maxSize: 100,
maxWaitMs: 50,
priorityTrigger: "CRITICAL",
}
);
}
public install(_node: NetworkNode): void {
// Store reference if needed in future
}
public start(): void {
// Plugin started
}
public stop(): void {
this.queue.stopDrains();
this.batcher.stopAutoFlush();
this.loopDetector.clear();
}
public dispose(): void {
// Cleanup if needed
}
public encodeMessage(
envelope: import("@suaveplan/network").MessageEnvelope
): Uint8Array {
const serializer = this.serializers.defaultSerializer();
const payload = serializer.serialize(envelope);
return this.encoder.encode(envelope, {
data: payload,
contentType: serializer.contentType,
});
}
public decodeMessage(data: Uint8Array): {
readonly header: import("../types/protocol.types.js").BinaryHeader;
readonly payload: Uint8Array;
} {
const { header, remaining } = this.decoder.decodeHeader(data);
return { header, payload: remaining };
}
}

View File

@@ -0,0 +1,55 @@
# BinaryProtocol
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Encodes and decodes the 13-byte binary header that prefixes every network message. The header contains version, flags, message type hash, timestamp, and payload length. This compact format minimizes wire overhead while providing enough metadata for routing, versioning, and parsing.
## Features
- Fixed 13-byte header size
- Version validation on decode
- Flag parsing for content type, encryption, and compression
- Big-endian encoding for cross-platform compatibility
- Payload length validation
## Usage
### Basic Example
```ts
import { BinaryProtocol } from "@suaveplan/network-plugin-protocol";
const protocol = new BinaryProtocol();
const header = protocol.encodeHeader({
version: 1,
flags: 0,
messageType: 42,
timestamp: BigInt(Date.now()),
payloadLength: 256,
});
```
### Advanced Example
```ts
import { BinaryProtocol } from "@suaveplan/network-plugin-protocol";
const protocol = new BinaryProtocol();
const flags = protocol.buildFlags({ contentType: 1, encrypted: true, compressed: false });
const parsed = protocol.parseFlags(flags);
console.log(parsed.encrypted); // true
```
## API Reference
- `encodeHeader(header): Uint8Array` — serialize header to 13 bytes
- `decodeHeader(data): BinaryHeader` — parse header from bytes
- `parseFlags(flags): { contentType, encrypted, compressed }` — decompose flags
- `buildFlags(opts): number` — compose flags from options
## Implementation Notes
The header uses `DataView` for explicit big-endian encoding, ensuring cross-platform consistency between browsers, Node.js, and Bun runtimes. Version mismatch throws `INVALID_HEADER` with details about the mismatched version. Payload length is capped at 65535 bytes (16-bit unsigned integer), which is sufficient for most network messages while keeping the header compact. Flags reserve the lower 2 bits for content type, bit 2 for encryption, and bit 3 for compression. Additional flag bits are reserved for future extensions such as checksums and priority indicators. The protocol version is validated on every decode to prevent incompatible peers from interpreting data incorrectly.

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BinaryProtocol, HEADER_SIZE, PROTOCOL_VERSION } from "./protocol.js";
describe("BinaryProtocol", () => {
it("must encode a 13-byte header", () => {
const protocol = new BinaryProtocol();
const header = protocol.encodeHeader({
version: PROTOCOL_VERSION,
flags: 0,
messageType: 42,
timestamp: 123456789n,
payloadLength: 256,
});
expect(header.length).toBe(HEADER_SIZE);
});
it("must decode a header roundtrip", () => {
const protocol = new BinaryProtocol();
const original = {
version: PROTOCOL_VERSION,
flags: 0b00001101,
messageType: 255,
timestamp: 9876543210n,
payloadLength: 65535,
};
const encoded = protocol.encodeHeader(original);
const decoded = protocol.decodeHeader(encoded);
expect(decoded.version).toBe(original.version);
expect(decoded.flags).toBe(original.flags);
expect(decoded.messageType).toBe(original.messageType);
expect(decoded.timestamp).toBe(original.timestamp);
expect(decoded.payloadLength).toBe(original.payloadLength);
});
it("must reject payload too large", () => {
const protocol = new BinaryProtocol();
expect(() =>
protocol.encodeHeader({
version: PROTOCOL_VERSION,
flags: 0,
messageType: 0,
timestamp: 0n,
payloadLength: 70000,
})
).toThrow("protocol.payload-too-large");
});
it("must reject header too short", () => {
const protocol = new BinaryProtocol();
expect(() => protocol.decodeHeader(new Uint8Array(5))).toThrow(
"protocol.header-too-short"
);
});
it("must reject wrong version", () => {
const protocol = new BinaryProtocol();
const buf = new Uint8Array(HEADER_SIZE);
buf[0] = 99;
expect(() => protocol.decodeHeader(buf)).toThrow(
"protocol.version-mismatch"
);
});
it("must parse and build flags", () => {
const protocol = new BinaryProtocol();
const flags = protocol.buildFlags({
contentType: 2,
encrypted: true,
compressed: true,
});
const parsed = protocol.parseFlags(flags);
expect(parsed.contentType).toBe(2);
expect(parsed.encrypted).toBe(true);
expect(parsed.compressed).toBe(true);
});
});

View File

@@ -0,0 +1,74 @@
import { NETWORK_PROTO_CODES, NetworkProtoError } from "../error-codes.js";
import type { BinaryHeader, ProtocolVersion } from "../types/protocol.types.js";
export const PROTOCOL_VERSION: ProtocolVersion = 1;
export const HEADER_SIZE = 13;
export class BinaryProtocol {
public encodeHeader(header: BinaryHeader): Uint8Array {
if (header.payloadLength > 65535) {
throw new NetworkProtoError(
NETWORK_PROTO_CODES.PAYLOAD_TOO_LARGE,
"protocol.payload-too-large",
{ context: { length: header.payloadLength } }
);
}
const buf = new Uint8Array(HEADER_SIZE);
const view = new DataView(buf.buffer);
view.setUint8(0, header.version);
view.setUint8(1, header.flags);
view.setUint8(2, header.messageType);
view.setBigUint64(3, header.timestamp);
view.setUint16(11, header.payloadLength);
return buf;
}
public decodeHeader(data: Uint8Array): BinaryHeader {
if (data.length < HEADER_SIZE) {
throw new NetworkProtoError(
NETWORK_PROTO_CODES.INVALID_HEADER,
"protocol.header-too-short",
{ context: { length: data.length } }
);
}
const view = new DataView(data.buffer, data.byteOffset, HEADER_SIZE);
const version = view.getUint8(0);
if (version !== PROTOCOL_VERSION) {
throw new NetworkProtoError(
NETWORK_PROTO_CODES.INVALID_HEADER,
"protocol.version-mismatch",
{ context: { version } }
);
}
return {
version: version as ProtocolVersion,
flags: view.getUint8(1),
messageType: view.getUint8(2),
timestamp: view.getBigUint64(3),
payloadLength: view.getUint16(11),
};
}
public parseFlags(flags: number): {
readonly contentType: number;
readonly encrypted: boolean;
readonly compressed: boolean;
} {
return {
contentType: flags & 0x03,
encrypted: (flags & 0x04) !== 0,
compressed: (flags & 0x08) !== 0,
};
}
public buildFlags(opts: {
readonly contentType: number;
readonly encrypted: boolean;
readonly compressed: boolean;
}): number {
let flags = opts.contentType & 0x03;
if (opts.encrypted) flags |= 0x04;
if (opts.compressed) flags |= 0x08;
return flags;
}
}

View File

@@ -0,0 +1 @@
export { PriorityMessageQueue } from "./queue.js";

View File

@@ -0,0 +1,55 @@
# PriorityMessageQueue
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Queues messages by priority tier. CRITICAL messages bypass the queue entirely. Within each tier, messages are processed in FIFO order. The queue supports configurable capacity and overflow policies to handle backpressure during traffic spikes.
## Features
- Five priority tiers: CRITICAL, HIGH, NORMAL, LOW, BULK
- CRITICAL messages bypass queueing for minimal latency
- FIFO ordering within the same tier
- Configurable overflow policies: drop-oldest, drop-newest, block
- Periodic drain interval support
- Per-tier size introspection
## Usage
### Basic Example
```ts
import { PriorityMessageQueue } from "@suaveplan/network-plugin-protocol";
const queue = new PriorityMessageQueue({ capacity: 1000, policy: "drop-oldest" });
await queue.enqueue(envelope, "HIGH");
```
### Advanced Example
```ts
import { PriorityMessageQueue } from "@suaveplan/network-plugin-protocol";
const queue = new PriorityMessageQueue({ capacity: 100, policy: "block" });
queue.startDrains(10, (msg) => send(msg.envelope));
```
## API Reference
- `constructor(config)` — create queue with capacity and policy
- `enqueue(envelope, priority): Promise<void>` — add message
- `dequeue(): QueuedMessage | undefined` — remove highest-priority message
- `peek(): QueuedMessage | undefined` — inspect next message without removing
- `size: number` — total queue size across all tiers
- `getSizeForTier(tier): number` — size per tier
- `startDrains(intervalMs, drainFn)` — periodic draining
- `stopDrains()` — stop periodic draining
- `clear()` — remove all messages
## Implementation Notes
Each tier maintains its own array for O(1) enqueue and dequeue. The dequeue operation scans tiers in priority order from CRITICAL to BULK. CRITICAL messages resolve immediately without entering the queue. The block policy allows unbounded growth if not monitored — use it with care.
The queue is designed for single-producer, single-consumer scenarios. Concurrent access from multiple producers or consumers is not synchronized and requires external locking if needed. The drain interval runs on a standard JavaScript interval timer, which may drift under heavy CPU load.

View File

@@ -0,0 +1,183 @@
import { createEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PriorityMessageQueue } from "./queue.js";
describe("PriorityMessageQueue", () => {
it("must enqueue and dequeue by priority", async () => {
const queue = new PriorityMessageQueue({
capacity: 10,
policy: "drop-oldest",
});
const envA = createEnvelope({
type: "a",
senderId: "s",
payload: null,
});
const envB = createEnvelope({
type: "b",
senderId: "s",
payload: null,
});
await queue.enqueue(envA, "NORMAL");
await queue.enqueue(envB, "HIGH");
const next = queue.dequeue();
expect(next?.envelope.type).toBe("b");
});
it("must bypass CRITICAL messages", async () => {
const queue = new PriorityMessageQueue({
capacity: 10,
policy: "drop-oldest",
});
const env = createEnvelope({ type: "c", senderId: "s", payload: null });
const result = queue.enqueue(env, "CRITICAL");
expect(result).toBeInstanceOf(Promise);
await expect(result).resolves.toBeUndefined();
});
it("must track size", async () => {
const queue = new PriorityMessageQueue({
capacity: 10,
policy: "drop-oldest",
});
expect(queue.size).toBe(0);
await queue.enqueue(
createEnvelope({ type: "t", senderId: "s", payload: null }),
"LOW"
);
expect(queue.size).toBe(1);
});
it("must clear all messages", async () => {
const queue = new PriorityMessageQueue({
capacity: 10,
policy: "drop-oldest",
});
await queue.enqueue(
createEnvelope({ type: "t", senderId: "s", payload: null }),
"NORMAL"
);
queue.clear();
expect(queue.size).toBe(0);
});
it("drop-oldest replaces the oldest entry once at capacity", async () => {
const q = new PriorityMessageQueue({
capacity: 2,
policy: "drop-oldest",
});
await q.enqueue(
createEnvelope({ type: "a", senderId: "s", payload: null }),
"NORMAL"
);
await q.enqueue(
createEnvelope({ type: "b", senderId: "s", payload: null }),
"NORMAL"
);
await q.enqueue(
createEnvelope({ type: "c", senderId: "s", payload: null }),
"NORMAL"
);
expect(q.size).toBe(2);
expect(q.dequeue()?.envelope.type).toBe("b");
});
it("drop-newest rejects new entries when at capacity", async () => {
const q = new PriorityMessageQueue({
capacity: 1,
policy: "drop-newest",
});
await q.enqueue(
createEnvelope({ type: "a", senderId: "s", payload: null }),
"NORMAL"
);
await q.enqueue(
createEnvelope({ type: "b", senderId: "s", payload: null }),
"NORMAL"
);
expect(q.size).toBe(1);
expect(q.dequeue()?.envelope.type).toBe("a");
});
it("block policy still appends to the queue", async () => {
const q = new PriorityMessageQueue({
capacity: 1,
policy: "block",
});
await q.enqueue(
createEnvelope({ type: "a", senderId: "s", payload: null }),
"NORMAL"
);
await q.enqueue(
createEnvelope({ type: "b", senderId: "s", payload: null }),
"NORMAL"
);
expect(q.size).toBe(2);
});
it("peek returns highest-priority entry without removing it", async () => {
const q = new PriorityMessageQueue({
capacity: 10,
policy: "drop-oldest",
});
await q.enqueue(
createEnvelope({ type: "a", senderId: "s", payload: null }),
"LOW"
);
await q.enqueue(
createEnvelope({ type: "b", senderId: "s", payload: null }),
"HIGH"
);
expect(q.peek()?.envelope.type).toBe("b");
expect(q.size).toBe(2);
});
it("peek and dequeue return undefined when empty", () => {
const q = new PriorityMessageQueue({
capacity: 10,
policy: "drop-oldest",
});
expect(q.peek()).toBeUndefined();
expect(q.dequeue()).toBeUndefined();
});
it("getSizeForTier reports per-tier size", async () => {
const q = new PriorityMessageQueue({
capacity: 10,
policy: "drop-oldest",
});
await q.enqueue(
createEnvelope({ type: "a", senderId: "s", payload: null }),
"LOW"
);
await q.enqueue(
createEnvelope({ type: "b", senderId: "s", payload: null }),
"LOW"
);
await q.enqueue(
createEnvelope({ type: "c", senderId: "s", payload: null }),
"HIGH"
);
expect(q.getSizeForTier("LOW")).toBe(2);
expect(q.getSizeForTier("HIGH")).toBe(1);
});
it("startDrains/stopDrains exercise the timer path", async () => {
const q = new PriorityMessageQueue({
capacity: 10,
policy: "drop-oldest",
});
await q.enqueue(
createEnvelope({ type: "a", senderId: "s", payload: null }),
"NORMAL"
);
const drained: string[] = [];
q.startDrains(1, (msg) => drained.push(msg.envelope.type));
await new Promise((r) => setTimeout(r, 10));
q.stopDrains();
q.stopDrains(); // idempotent
expect(drained).toContain("a");
});
});

View File

@@ -0,0 +1,113 @@
import type { MessageEnvelope } from "@suaveplan/network";
import type { PriorityTier, QueueConfig } from "../types/protocol.types.js";
interface QueuedMessage {
readonly envelope: MessageEnvelope;
}
export class PriorityMessageQueue {
private readonly queues: Record<PriorityTier, QueuedMessage[]> = {
CRITICAL: [],
HIGH: [],
NORMAL: [],
LOW: [],
BULK: [],
};
private readonly config: QueueConfig;
private drainTimer: ReturnType<typeof setInterval> | undefined;
public constructor(config: QueueConfig) {
this.config = config;
}
public enqueue(
envelope: MessageEnvelope,
priority: PriorityTier
): Promise<void> {
if (priority === "CRITICAL") {
return Promise.resolve();
}
const queue = this.queues[priority];
if (queue.length >= this.config.capacity) {
switch (this.config.policy) {
case "drop-oldest":
queue.shift();
break;
case "drop-newest":
return Promise.resolve();
case "block":
break;
}
}
queue.push({ envelope });
return Promise.resolve();
}
public dequeue(): QueuedMessage | undefined {
const tiers: PriorityTier[] = [
"CRITICAL",
"HIGH",
"NORMAL",
"LOW",
"BULK",
];
for (const tier of tiers) {
const queue = this.queues[tier];
if (queue.length > 0) {
return queue.shift()!;
}
}
return undefined;
}
public peek(): QueuedMessage | undefined {
const tiers: PriorityTier[] = [
"CRITICAL",
"HIGH",
"NORMAL",
"LOW",
"BULK",
];
for (const tier of tiers) {
const queue = this.queues[tier];
if (queue.length > 0) {
return queue[0];
}
}
return undefined;
}
public get size(): number {
return Object.values(this.queues).reduce((sum, q) => sum + q.length, 0);
}
public getSizeForTier(tier: PriorityTier): number {
return this.queues[tier].length;
}
public startDrains(
intervalMs: number,
drainFn: (msg: QueuedMessage) => void
): void {
this.drainTimer = setInterval(() => {
const msg = this.dequeue();
if (msg) {
drainFn(msg);
}
}, intervalMs);
}
public stopDrains(): void {
if (this.drainTimer) {
clearInterval(this.drainTimer);
this.drainTimer = undefined;
}
}
public clear(): void {
for (const tier of Object.keys(this.queues) as PriorityTier[]) {
this.queues[tier] = [];
}
}
}

View File

@@ -0,0 +1,7 @@
export type { ISerializer } from "./serializer.js";
export {
BinarySerializer,
JsonSerializer,
MessagePackSerializer,
SerializerFactory,
} from "./serializer.js";

View File

@@ -0,0 +1,52 @@
# Serializer Framework
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides pluggable serialization strategies for message envelopes. The framework supports JSON, binary, and MessagePack formats, with automatic fallback to JSON. A `SerializerFactory` manages registration and lookup by content type.
## Features
- JSON serializer (human-readable, widely compatible)
- Binary serializer (compact byte output)
- MessagePack serializer (binary with extensions)
- Factory pattern for registration and lookup
- Flag-to-content-type mapping for header integration
## Usage
### Basic Example
```ts
import { SerializerFactory } from "@suaveplan/network-plugin-protocol";
const factory = new SerializerFactory();
const serializer = factory.get("json");
const bytes = serializer.serialize(envelope);
```
### Advanced Example
```ts
import { SerializerFactory, JsonSerializer } from "@suaveplan/network-plugin-protocol";
const factory = new SerializerFactory();
factory.register(new JsonSerializer());
const defaultSerializer = factory.defaultSerializer();
```
## API Reference
- `ISerializer` — interface for custom serializers
- `JsonSerializer` — JSON encoding
- `BinarySerializer` — binary encoding
- `MessagePackSerializer` — MessagePack encoding
- `SerializerFactory` — registration, lookup, and defaults
## Implementation Notes
Currently binary and MessagePack serializers use JSON as an underlying format for simplicity and compatibility. Future iterations will implement true binary serialization using `@suaveplan/bitpacker` schemas per message type, achieving significantly smaller payload sizes for structured data. The factory throws `UNSUPPORTED_SERIALIZER` for unknown content types, preventing silent fallback errors. Content types are mapped to 2-bit flags in the header: JSON=0, Binary=1, MessagePack=2. This compact representation leaves room for additional formats in future protocol versions. All serializers implement the `ISerializer` interface, making it easy to add custom formats without modifying existing code.
The serializer framework is designed for extensibility. New formats can be registered at runtime, and the factory will automatically include them in its lookup table. The default serializer is always JSON for maximum compatibility with existing consumers.

View File

@@ -0,0 +1,65 @@
import { createEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BinarySerializer,
JsonSerializer,
MessagePackSerializer,
SerializerFactory,
} from "./serializer.js";
describe("Serializers", () => {
const envelope = createEnvelope({
type: "test",
senderId: "a",
payload: { value: 42 },
});
it("must roundtrip with JSON serializer", () => {
const serializer = new JsonSerializer();
const bytes = serializer.serialize(envelope);
const decoded = serializer.deserialize(bytes);
expect(decoded).toEqual(envelope);
});
it("must roundtrip with binary serializer", () => {
const serializer = new BinarySerializer();
const bytes = serializer.serialize(envelope);
const decoded = serializer.deserialize(bytes);
expect(decoded).toEqual(envelope);
});
it("must roundtrip with MessagePack serializer", () => {
const serializer = new MessagePackSerializer();
const bytes = serializer.serialize(envelope);
const decoded = serializer.deserialize(bytes);
expect(decoded).toEqual(envelope);
});
it("must return default serializer", () => {
const factory = new SerializerFactory();
const serializer = factory.defaultSerializer();
expect(serializer.contentType).toBe("json");
});
it("must look up serializer by content type", () => {
const factory = new SerializerFactory();
expect(factory.get("json").contentType).toBe("json");
expect(factory.get("binary").contentType).toBe("binary");
expect(factory.get("msgpack").contentType).toBe("msgpack");
});
it("must throw on unsupported serializer", () => {
const factory = new SerializerFactory();
expect(() => factory.get("yaml" as never)).toThrow(
"serializer.unsupported"
);
});
it("must map flags to content types", () => {
const factory = new SerializerFactory();
expect(factory.flagToContentType(0)).toBe("json");
expect(factory.flagToContentType(1)).toBe("binary");
expect(factory.flagToContentType(2)).toBe("msgpack");
expect(factory.flagToContentType(99)).toBe("json");
});
});

View File

@@ -0,0 +1,95 @@
import type { MessageEnvelope } from "@suaveplan/network";
import { deserialize, serialize } from "@suaveplan/serialize";
import { NETWORK_PROTO_CODES, NetworkProtoError } from "../error-codes.js";
import type { SerializerType } from "../types/protocol.types.js";
export interface ISerializer {
readonly contentType: SerializerType;
serialize(envelope: MessageEnvelope): Uint8Array;
deserialize(data: Uint8Array): unknown;
}
export class JsonSerializer implements ISerializer {
public readonly contentType: SerializerType = "json";
public serialize(envelope: MessageEnvelope): Uint8Array {
const json = serialize(envelope);
return new TextEncoder().encode(json);
}
public deserialize(data: Uint8Array): unknown {
const json = new TextDecoder().decode(data);
return deserialize(json);
}
}
export class BinarySerializer implements ISerializer {
public readonly contentType: SerializerType = "binary";
public serialize(envelope: MessageEnvelope): Uint8Array {
const json = JSON.stringify(envelope);
return new TextEncoder().encode(json);
}
public deserialize(data: Uint8Array): unknown {
const json = new TextDecoder().decode(data);
return JSON.parse(json);
}
}
export class MessagePackSerializer implements ISerializer {
public readonly contentType: SerializerType = "msgpack";
public serialize(envelope: MessageEnvelope): Uint8Array {
const json = JSON.stringify(envelope);
return new TextEncoder().encode(json);
}
public deserialize(data: Uint8Array): unknown {
const json = new TextDecoder().decode(data);
return JSON.parse(json);
}
}
export class SerializerFactory {
private readonly serializers = new Map<SerializerType, ISerializer>();
public constructor() {
this.register(new JsonSerializer());
this.register(new BinarySerializer());
this.register(new MessagePackSerializer());
}
public register(serializer: ISerializer): void {
this.serializers.set(serializer.contentType, serializer);
}
public get(contentType: SerializerType): ISerializer {
const serializer = this.serializers.get(contentType);
if (!serializer) {
throw new NetworkProtoError(
NETWORK_PROTO_CODES.UNSUPPORTED_SERIALIZER,
"serializer.unsupported",
{ context: { contentType } }
);
}
return serializer;
}
public defaultSerializer(): ISerializer {
return this.serializers.get("json")!;
}
public flagToContentType(flag: number): SerializerType {
switch (flag) {
case 0:
return "json";
case 1:
return "binary";
case 2:
return "msgpack";
default:
return "json";
}
}
}

View File

@@ -0,0 +1,4 @@
export {
LoopDetector,
MessageTTL,
} from "./ttl.js";

View File

@@ -0,0 +1,52 @@
# MessageTTL and LoopDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Prevents infinite message propagation in mesh networks. `MessageTTL` decrements the time-to-live counter and drops messages when they expire. `LoopDetector` tracks visited nodes and detects cycles. A bounded deduplicator also catches duplicate broadcasts to prevent amplification attacks.
## Features
- TTL decrement and expiration with clear errors
- Visited-node cycle detection before forwarding
- Duplicate message filtering with bounded memory
- Per-node loop detection
## Usage
### Basic Example
```ts
import { MessageTTL, LoopDetector } from "@suaveplan/network-plugin-protocol";
const ttl = new MessageTTL();
const loop = new LoopDetector();
const updated = ttl.process(envelope);
loop.check(updated, "node-a");
const forwarded = loop.markVisited(updated, "node-a");
```
### Advanced Example
```ts
import { LoopDetector } from "@suaveplan/network-plugin-protocol";
const detector = new LoopDetector(5000);
detector.deduplicate(envelope);
detector.clear();
```
## API Reference
- `MessageTTL.process(envelope): MessageEnvelope` — decrement TTL by 1
- `LoopDetector.check(envelope, nodeId)` — throw on cycle detection
- `LoopDetector.markVisited(envelope, nodeId): MessageEnvelope` — add node to path
- `LoopDetector.deduplicate(envelope): boolean` — throw on duplicate
- `LoopDetector.clear()` — reset deduplication state
## Implementation Notes
TTL starts at 64 by default, which is sufficient for most mesh network diameters while preventing excessive hop counts. When TTL reaches zero, `TTL_EXPIRED` is thrown and the message is dropped. Loop detection uses the `visitedNodes` array in the envelope header, which records every node that has forwarded the message. The deduplicator uses a bounded Set keyed by `id:senderId` to prevent unbounded memory growth during high-volume broadcast scenarios. When the limit is exceeded, oldest entries are evicted using a FIFO strategy. These three mechanisms — TTL, loop detection, and deduplication — work together to ensure that even in complex mesh topologies with cycles, messages cannot circulate indefinitely or be amplified.

View File

@@ -0,0 +1,117 @@
import { createEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LoopDetector, MessageTTL } from "./ttl.js";
describe("MessageTTL", () => {
it("must decrement TTL", () => {
const ttl = new MessageTTL();
const envelope = createEnvelope({
type: "t",
senderId: "s",
payload: null,
ttl: 5,
});
const result = ttl.process(envelope);
expect(result.ttl).toBe(4);
});
it("must throw when TTL expires", () => {
const ttl = new MessageTTL();
const envelope = createEnvelope({
type: "t",
senderId: "s",
payload: null,
ttl: 0,
});
expect(() => ttl.process(envelope)).toThrow("ttl.expired");
});
});
describe("LoopDetector", () => {
it("must detect loops", () => {
const detector = new LoopDetector();
const envelope = createEnvelope({
type: "t",
senderId: "s",
payload: null,
visitedNodes: ["node-a"],
});
expect(() => detector.check(envelope, "node-a")).toThrow(
"loop.detected"
);
});
it("check passes when the node id is not in visitedNodes", () => {
const detector = new LoopDetector();
const envelope = createEnvelope({
type: "t",
senderId: "s",
payload: null,
visitedNodes: ["node-a"],
});
expect(() => detector.check(envelope, "node-b")).not.toThrow();
});
it("must mark visited nodes", () => {
const detector = new LoopDetector();
const envelope = createEnvelope({
type: "t",
senderId: "s",
payload: null,
});
const result = detector.markVisited(envelope, "node-b");
expect(result.visitedNodes).toContain("node-b");
});
it("must detect duplicates", () => {
const detector = new LoopDetector();
const envelope = createEnvelope({
type: "t",
senderId: "s",
payload: null,
});
detector.deduplicate(envelope);
expect(() => detector.deduplicate(envelope)).toThrow(
"duplicate.detected"
);
});
it("must clear state", () => {
const detector = new LoopDetector();
const envelope = createEnvelope({
type: "t",
senderId: "s",
payload: null,
});
detector.deduplicate(envelope);
detector.clear();
expect(() => detector.deduplicate(envelope)).not.toThrow();
});
it("evicts the oldest entry once the cap is reached", () => {
const detector = new LoopDetector(2);
const a = createEnvelope({
id: "a",
type: "t",
senderId: "s",
payload: null,
});
const b = createEnvelope({
id: "b",
type: "t",
senderId: "s",
payload: null,
});
const c = createEnvelope({
id: "c",
type: "t",
senderId: "s",
payload: null,
});
detector.deduplicate(a);
detector.deduplicate(b);
detector.deduplicate(c); // pushes a out
// a's slot should be free again — re-deduping a is fine
expect(() => detector.deduplicate(a)).not.toThrow();
});
});

View File

@@ -0,0 +1,70 @@
import type { MessageEnvelope } from "@suaveplan/network";
import { NETWORK_PROTO_CODES, NetworkProtoError } from "../error-codes.js";
export class MessageTTL {
public process(envelope: MessageEnvelope): MessageEnvelope {
if (envelope.ttl <= 0) {
throw new NetworkProtoError(
NETWORK_PROTO_CODES.TTL_EXPIRED,
"ttl.expired",
{ context: { envelopeId: envelope.id } }
);
}
return {
...envelope,
ttl: envelope.ttl - 1,
};
}
}
export class LoopDetector {
private readonly seenMessages = new Set<string>();
private readonly maxSize: number;
public constructor(maxSize = 10000) {
this.maxSize = maxSize;
}
public check(envelope: MessageEnvelope, currentNodeId: string): void {
if (envelope.visitedNodes.includes(currentNodeId)) {
throw new NetworkProtoError(
NETWORK_PROTO_CODES.LOOP_DETECTED,
"loop.detected",
{ context: { envelopeId: envelope.id, nodeId: currentNodeId } }
);
}
}
public markVisited(
envelope: MessageEnvelope,
currentNodeId: string
): MessageEnvelope {
return {
...envelope,
visitedNodes: [...envelope.visitedNodes, currentNodeId],
};
}
public deduplicate(envelope: MessageEnvelope): boolean {
const key = `${envelope.id}:${envelope.senderId}`;
if (this.seenMessages.has(key)) {
throw new NetworkProtoError(
NETWORK_PROTO_CODES.DUPLICATE_MESSAGE,
"duplicate.detected",
{ context: { envelopeId: envelope.id } }
);
}
this.seenMessages.add(key);
if (this.seenMessages.size > this.maxSize) {
// size > 0 guaranteed by the size check above, so .next() always
// yields a defined value.
const first = this.seenMessages.values().next().value as string;
this.seenMessages.delete(first);
}
return true;
}
public clear(): void {
this.seenMessages.clear();
}
}

View File

@@ -0,0 +1,10 @@
export type {
BatchConfig,
BinaryHeader,
PriorityTier,
ProtocolMessage,
ProtocolVersion,
QueueConfig,
SerializedPayload,
SerializerType,
} from "./protocol.types.js";

View File

@@ -0,0 +1,55 @@
# Protocol Types
## Purpose
Shared type definitions for the protocol plugin. Centralizing these types ensures consistency between the encoder, decoder, serializer, queue, batcher, and pipeline modules. Every component that touches the wire format uses these types as its source of truth.
## Features
- Binary header shape with fixed fields
- Serializer type union
- Serialized payload shape
- Protocol message composite type
- Priority tier union
- Configuration interfaces for queue and batch
## Usage
### Basic Example
```ts
import type { BinaryHeader, SerializerType } from "@suaveplan/network-plugin-protocol";
const header: BinaryHeader = {
version: 1,
flags: 0,
messageType: 42,
timestamp: BigInt(Date.now()),
payloadLength: 256,
};
```
### Advanced Example
```ts
import type { BatchConfig, QueueConfig } from "@suaveplan/network-plugin-protocol";
const batchConfig: BatchConfig = { maxSize: 100, maxWaitMs: 50, priorityTrigger: "CRITICAL" };
const queueConfig: QueueConfig = { capacity: 1000, policy: "drop-oldest" };
```
## API Reference
- `BinaryHeader` — 13-byte header fields
- `SerializedPayload` — payload bytes and content type
- `ProtocolMessage` — header + payload + envelope composite
- `SerializerType` — `"json" | "binary" | "msgpack"`
- `PriorityTier` — `"CRITICAL" | "HIGH" | "NORMAL" | "LOW" | "BULK"`
- `BatchConfig` — size, wait, and trigger settings
- `QueueConfig` — capacity and overflow policy
## Implementation Notes
The `ProtocolVersion` type is currently a literal `1`. Future protocol versions will extend this union when breaking changes are introduced. The `BinaryHeader.timestamp` uses `bigint` to accommodate 64-bit Unix timestamps without precision loss, which is important for long-running systems where millisecond timestamps would eventually overflow. All configuration types use `readonly` properties to prevent accidental mutation after creation, ensuring that queue and batch settings remain stable throughout the plugin lifecycle. Type consistency across modules is enforced by these shared definitions.
These types are exported from the package root so that consumers can write their own protocol extensions while remaining compatible with the core system. The `SerializedPayload` type uses a `Uint8Array` for binary data rather than a generic `Buffer`, ensuring cross-platform compatibility between Node.js, Bun, and browser environments.

View File

@@ -0,0 +1,37 @@
import type { MessageEnvelope } from "@suaveplan/network";
export type ProtocolVersion = 1;
export interface BinaryHeader {
readonly version: ProtocolVersion;
readonly flags: number;
readonly messageType: number;
readonly timestamp: bigint;
readonly payloadLength: number;
}
export type SerializerType = "json" | "binary" | "msgpack";
export interface SerializedPayload {
readonly data: Uint8Array;
readonly contentType: SerializerType;
}
export interface ProtocolMessage {
readonly header: BinaryHeader;
readonly payload: SerializedPayload;
readonly envelope: MessageEnvelope;
}
export type PriorityTier = "CRITICAL" | "HIGH" | "NORMAL" | "LOW" | "BULK";
export interface BatchConfig {
readonly maxSize: number;
readonly maxWaitMs: number;
readonly priorityTrigger: PriorityTier;
}
export interface QueueConfig {
readonly capacity: number;
readonly policy: "drop-oldest" | "drop-newest" | "block";
}

View File

@@ -0,0 +1,9 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-plugin-protocol dep-graph", () => {
it("must not have circular dependencies", async () => {
const mod = await import("../src/index.js");
expect(mod).toBeDefined();
expect(mod.ProtocolPlugin).toBeDefined();
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});