Initial Commit
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
# connection
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Manages the libp2p node lifecycle on behalf of `Libp2pTransport`. Three small async functions wrap the injected factory: one to construct and start a node, one to dial a peer by multiaddr, and one to stop a node cleanly. All three translate every underlying libp2p surface error into a typed `NetLibp2pError` so callers branch on `error.code` without needing to introspect the libp2p exception hierarchy.
|
||||
|
||||
## Features
|
||||
|
||||
- `createAndStartNode(factory, config, signal?)` — composes factory + `start()`; emits OTel outcome on every path.
|
||||
- `dialPeerAddress(node, address, signal?)` — wraps `node.dial(multiaddr)` with OTel dial-duration tracking.
|
||||
- `stopNode(node)` — translates `node.stop()` failures to `TRANSPORT_CLOSED`.
|
||||
- Honours pre-aborted `AbortSignal` at every step; aborted-between-factory-and-start is treated as a complete failure and the freshly-created node is stopped to avoid leaking native handles.
|
||||
- Records `connections_opened_total{outcome}` and `dial_ms` automatically.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createAndStartNode,
|
||||
dialPeerAddress,
|
||||
stopNode,
|
||||
} from "@suaveplan/network-transport-libp2p";
|
||||
|
||||
const node = await createAndStartNode(libp2pFactory, {
|
||||
subTransport: "websockets",
|
||||
addresses: { listen: ["/ip4/0.0.0.0/tcp/0/ws"] },
|
||||
bootstrap: ["/dns4/bootstrap.example/tcp/443/wss/p2p/Qm…"],
|
||||
maxConnections: 100,
|
||||
dialTimeoutMs: 10_000,
|
||||
protocolId: "/suaveplan/network/1.0.0",
|
||||
});
|
||||
|
||||
await dialPeerAddress(node, "/dns4/peer.example/tcp/443/wss/p2p/Qm…");
|
||||
await stopNode(node);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createAndStartNode,
|
||||
dialPeerAddress,
|
||||
NET_LIBP2P_CODES,
|
||||
NetLibp2pError,
|
||||
} from "@suaveplan/network-transport-libp2p";
|
||||
|
||||
const ac = new AbortController();
|
||||
const cancel = setTimeout(() => ac.abort(), 30_000);
|
||||
try {
|
||||
const node = await createAndStartNode(factory, baseConfig, ac.signal);
|
||||
for (const addr of bootstrapAddresses) {
|
||||
try {
|
||||
await dialPeerAddress(node, addr, ac.signal);
|
||||
} catch (err) {
|
||||
if (err instanceof NetLibp2pError && err.code === NET_LIBP2P_CODES.DIAL_FAILED) {
|
||||
console.warn(`bootstrap miss: ${addr}`);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(cancel);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createAndStartNode(factory, config, signal?): Promise<Libp2pNodeLike>` — throws `BOOTSTRAP_FAILED` on factory or start failure (and on signal pre-abort).
|
||||
- `dialPeerAddress(node, address, signal?): Promise<void>` — throws `DIAL_FAILED` with `context: { address }` on failure.
|
||||
- `stopNode(node): Promise<void>` — throws `TRANSPORT_CLOSED` on stop failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory pattern is the single critical design choice here — the package never imports `libp2p` at module-eval. The "aborted after factory but before start" branch is intentionally treated as a hard failure (rather than letting the node start and trusting the caller to stop it later); leaking a half-started libp2p node into the runtime is the kind of bug that's near-impossible to reproduce in CI.
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
FakeLibp2pHub,
|
||||
FakeLibp2pNode,
|
||||
makeFakeFactory,
|
||||
} from "../../tests/setup/fake-libp2p.js";
|
||||
import { NET_LIBP2P_CODES } from "../errors/errors.js";
|
||||
import type { Libp2pFactoryConfig } from "../types/types.js";
|
||||
import { createAndStartNode, dialPeerAddress, stopNode } from "./connection.js";
|
||||
|
||||
const baseConfig: Libp2pFactoryConfig = {
|
||||
subTransport: "websockets",
|
||||
addresses: { listen: ["/memory"] },
|
||||
bootstrap: ["/memory/seed-1"],
|
||||
maxConnections: 50,
|
||||
dialTimeoutMs: 1000,
|
||||
protocolId: "/test/1.0.0",
|
||||
};
|
||||
|
||||
describe("createAndStartNode", () => {
|
||||
it("creates and starts a node via the factory", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory, instances } = makeFakeFactory(hub);
|
||||
const node = await createAndStartNode(factory, baseConfig);
|
||||
expect(instances).toHaveLength(1);
|
||||
expect((node as FakeLibp2pNode).started).toBe(true);
|
||||
});
|
||||
|
||||
it("translates factory failure to BOOTSTRAP_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const factory = async () => {
|
||||
throw new Error("factory-boom");
|
||||
};
|
||||
try {
|
||||
await createAndStartNode(factory, baseConfig);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.BOOTSTRAP_FAILED);
|
||||
}
|
||||
});
|
||||
|
||||
it("translates start() failure to BOOTSTRAP_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub, {
|
||||
nodeOptions: { startThrows: true },
|
||||
});
|
||||
try {
|
||||
await createAndStartNode(factory, baseConfig);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.BOOTSTRAP_FAILED);
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts before factory call when signal pre-aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub);
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
await createAndStartNode(factory, baseConfig, ac.signal);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.BOOTSTRAP_FAILED);
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts after factory call when signal aborts between factory and start", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const ac = new AbortController();
|
||||
const factory = async () => {
|
||||
const node = new FakeLibp2pNode(hub);
|
||||
ac.abort();
|
||||
return node;
|
||||
};
|
||||
try {
|
||||
await createAndStartNode(factory, baseConfig, ac.signal);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.BOOTSTRAP_FAILED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("dialPeerAddress", () => {
|
||||
it("dials successfully via the node", { timeout: 5000 }, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const a = new FakeLibp2pNode(hub, "a");
|
||||
const b = new FakeLibp2pNode(hub, "b");
|
||||
await dialPeerAddress(a, "/memory/b");
|
||||
expect(a.getPeers().map((p) => p.toString())).toContain("b");
|
||||
void b;
|
||||
});
|
||||
|
||||
it("translates dial errors to DIAL_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const a = new FakeLibp2pNode(hub, "a");
|
||||
a.dialThrows = true;
|
||||
try {
|
||||
await dialPeerAddress(a, "/memory/x");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; context?: { address: string } };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.DIAL_FAILED);
|
||||
expect(e.context?.address).toBe("/memory/x");
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts when signal is pre-aborted", { timeout: 5000 }, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const a = new FakeLibp2pNode(hub, "a");
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
await dialPeerAddress(a, "/memory/x", ac.signal);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.DIAL_FAILED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopNode", () => {
|
||||
it("stops the node cleanly", { timeout: 5000 }, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const a = new FakeLibp2pNode(hub, "a");
|
||||
await a.start();
|
||||
await stopNode(a);
|
||||
expect(a.started).toBe(false);
|
||||
});
|
||||
|
||||
it("translates stop() failure to TRANSPORT_CLOSED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake: import("../types/types.js").Libp2pNodeLike = {
|
||||
start: async () => {
|
||||
/* noop */
|
||||
},
|
||||
stop: async () => {
|
||||
throw new Error("stop-boom");
|
||||
},
|
||||
dial: async () => ({}),
|
||||
dialProtocol: async () => {
|
||||
throw new Error("unimpl");
|
||||
},
|
||||
handle: async () => {
|
||||
/* noop */
|
||||
},
|
||||
unhandle: async () => {
|
||||
/* noop */
|
||||
},
|
||||
getPeers: () => [],
|
||||
addEventListener: () => {
|
||||
/* noop */
|
||||
},
|
||||
removeEventListener: () => {
|
||||
/* noop */
|
||||
},
|
||||
};
|
||||
try {
|
||||
await stopNode(fake);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.TRANSPORT_CLOSED);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { NET_LIBP2P_CODES, NetLibp2pError } from "../errors/errors.js";
|
||||
import { recordConnectionOutcome, recordDial } from "../otel/otel.js";
|
||||
import type {
|
||||
Libp2pFactory,
|
||||
Libp2pFactoryConfig,
|
||||
Libp2pNodeLike,
|
||||
} from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Create a libp2p node via the injected factory and start it. Returns
|
||||
* the started node. Failure surfaces as `BOOTSTRAP_FAILED` with the
|
||||
* underlying cause attached.
|
||||
*/
|
||||
export async function createAndStartNode(
|
||||
factory: Libp2pFactory,
|
||||
config: Libp2pFactoryConfig,
|
||||
signal?: AbortSignal
|
||||
): Promise<Libp2pNodeLike> {
|
||||
if (signal?.aborted) {
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.BOOTSTRAP_FAILED,
|
||||
"libp2p.create-aborted"
|
||||
);
|
||||
}
|
||||
let node: Libp2pNodeLike;
|
||||
try {
|
||||
node = await factory(config);
|
||||
} catch (cause) {
|
||||
recordConnectionOutcome("error");
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.BOOTSTRAP_FAILED,
|
||||
"libp2p.create-node-failed",
|
||||
{ cause: cause as Error }
|
||||
);
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
try {
|
||||
await node.stop();
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.BOOTSTRAP_FAILED,
|
||||
"libp2p.start-aborted"
|
||||
);
|
||||
}
|
||||
try {
|
||||
await node.start();
|
||||
} catch (cause) {
|
||||
recordConnectionOutcome("error");
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.BOOTSTRAP_FAILED,
|
||||
"libp2p.start-node-failed",
|
||||
{ cause: cause as Error }
|
||||
);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dial a remote peer by multiaddr string. Records dial-duration to OTel
|
||||
* on success and translates every failure into a typed `DIAL_FAILED`.
|
||||
*/
|
||||
export async function dialPeerAddress(
|
||||
node: Libp2pNodeLike,
|
||||
address: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.DIAL_FAILED,
|
||||
"libp2p.dial-aborted"
|
||||
);
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await node.dial(address);
|
||||
recordDial(Date.now() - startedAt);
|
||||
recordConnectionOutcome("ok");
|
||||
} catch (cause) {
|
||||
recordDial(Date.now() - startedAt);
|
||||
recordConnectionOutcome("error");
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.DIAL_FAILED,
|
||||
"libp2p.dial-failed",
|
||||
{ cause: cause as Error, context: { address } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanly shut down a libp2p node. Best-effort; failures are translated
|
||||
* to `TRANSPORT_CLOSED` but the node reference must be considered gone
|
||||
* either way.
|
||||
*/
|
||||
export async function stopNode(node: Libp2pNodeLike): Promise<void> {
|
||||
try {
|
||||
await node.stop();
|
||||
} catch (cause) {
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.TRANSPORT_CLOSED,
|
||||
"libp2p.stop-failed",
|
||||
{ cause: cause as Error }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# errors
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the structured-error registry for `@suaveplan/network-transport-libp2p`. Every public boundary in the package raises a `NetLibp2pError` carrying a stable `code` drawn from `NET_LIBP2P_CODES`. Consumers branch on `error.code` rather than parsing message text — this is the recommended pattern across the entire `@suaveplan/*` ecosystem.
|
||||
|
||||
## Features
|
||||
|
||||
- 11-code registry (`BOOTSTRAP_FAILED`, `DIAL_FAILED`, `STREAM_FAILED`, `PROTOCOL_NOT_SUPPORTED`, `IDENTIFY_REJECTED`, `INVALID_OPTIONS`, `DISPOSED`, `NOT_RUNNING`, `PEER_ALREADY_CONNECTED`, `PEER_UNKNOWN`, `TRANSPORT_CLOSED`).
|
||||
- Built via `defineCodes("NET_LIBP2P", [...])` — yields fully qualified codes like `"NET_LIBP2P.DIAL_FAILED"` that don't collide with other packages.
|
||||
- `NetLibp2pError extends SuaveplanError<NetLibp2pErrorCode>` so callers get cause-chain traversal, structured context, redaction, and OTel span-event integration for free.
|
||||
- Frozen registry — adding new codes requires editing this file and bumping the package version.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NET_LIBP2P_CODES,
|
||||
NetLibp2pError,
|
||||
} from "@suaveplan/network-transport-libp2p";
|
||||
|
||||
try {
|
||||
await transport.start();
|
||||
} catch (err) {
|
||||
if (err instanceof NetLibp2pError && err.code === NET_LIBP2P_CODES.BOOTSTRAP_FAILED) {
|
||||
// retry with backoff
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { NET_LIBP2P_CODES, NetLibp2pError } from "@suaveplan/network-transport-libp2p";
|
||||
|
||||
function classify(err: unknown): "retryable" | "fatal" | "config" {
|
||||
if (!(err instanceof NetLibp2pError)) return "fatal";
|
||||
switch (err.code) {
|
||||
case NET_LIBP2P_CODES.INVALID_OPTIONS:
|
||||
return "config";
|
||||
case NET_LIBP2P_CODES.DIAL_FAILED:
|
||||
case NET_LIBP2P_CODES.STREAM_FAILED:
|
||||
case NET_LIBP2P_CODES.BOOTSTRAP_FAILED:
|
||||
return "retryable";
|
||||
default:
|
||||
return "fatal";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NET_LIBP2P_CODES`: frozen lookup of code-name → fully qualified code string.
|
||||
- `NetLibp2pErrorCode`: literal union over the registry.
|
||||
- `NetLibp2pError(code, messageKey, options?)`: constructor accepts the standard `SuaveplanErrorOptions` shape, including `cause` (Error chaining) and `context` (structured details).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
We deliberately avoid a single catch-all code. Hyperswarm's reputation engine taught us that operators need to slice failures by reason to decide what to do — `STREAM_FAILED` warrants reputation tracking; `DIAL_FAILED` warrants retry; `INVALID_OPTIONS` warrants a config-fix; `BOOTSTRAP_FAILED` warrants surfacing to the user. Adding more codes is a non-breaking change as long as nothing existing is removed or repurposed.
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NET_LIBP2P_CODES, NetLibp2pError } from "./errors.js";
|
||||
|
||||
describe("NET_LIBP2P error codes", () => {
|
||||
it("registers all expected codes", { timeout: 5000 }, () => {
|
||||
expect(NET_LIBP2P_CODES.BOOTSTRAP_FAILED).toBe(
|
||||
"NET_LIBP2P.BOOTSTRAP_FAILED"
|
||||
);
|
||||
expect(NET_LIBP2P_CODES.DIAL_FAILED).toBe("NET_LIBP2P.DIAL_FAILED");
|
||||
expect(NET_LIBP2P_CODES.STREAM_FAILED).toBe("NET_LIBP2P.STREAM_FAILED");
|
||||
expect(NET_LIBP2P_CODES.PROTOCOL_NOT_SUPPORTED).toBe(
|
||||
"NET_LIBP2P.PROTOCOL_NOT_SUPPORTED"
|
||||
);
|
||||
expect(NET_LIBP2P_CODES.IDENTIFY_REJECTED).toBe(
|
||||
"NET_LIBP2P.IDENTIFY_REJECTED"
|
||||
);
|
||||
expect(NET_LIBP2P_CODES.INVALID_OPTIONS).toBe(
|
||||
"NET_LIBP2P.INVALID_OPTIONS"
|
||||
);
|
||||
expect(NET_LIBP2P_CODES.DISPOSED).toBe("NET_LIBP2P.DISPOSED");
|
||||
expect(NET_LIBP2P_CODES.NOT_RUNNING).toBe("NET_LIBP2P.NOT_RUNNING");
|
||||
expect(NET_LIBP2P_CODES.PEER_ALREADY_CONNECTED).toBe(
|
||||
"NET_LIBP2P.PEER_ALREADY_CONNECTED"
|
||||
);
|
||||
expect(NET_LIBP2P_CODES.PEER_UNKNOWN).toBe("NET_LIBP2P.PEER_UNKNOWN");
|
||||
expect(NET_LIBP2P_CODES.TRANSPORT_CLOSED).toBe(
|
||||
"NET_LIBP2P.TRANSPORT_CLOSED"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NetLibp2pError", () => {
|
||||
it("constructs with code, message key, and context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.DIAL_FAILED,
|
||||
"libp2p.dial-failed",
|
||||
{ context: { address: "/ip4/1.2.3.4/tcp/9000/ws" } }
|
||||
);
|
||||
expect(err.code).toBe("NET_LIBP2P.DIAL_FAILED");
|
||||
expect(err.name).toBe("NetLibp2pError");
|
||||
expect(err.context).toEqual({ address: "/ip4/1.2.3.4/tcp/9000/ws" });
|
||||
});
|
||||
|
||||
it("propagates cause when supplied", { timeout: 5000 }, () => {
|
||||
const cause = new Error("underlying");
|
||||
const err = new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.STREAM_FAILED,
|
||||
"libp2p.stream-failed",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Error codes emitted by `@suaveplan/network-transport-libp2p`.
|
||||
*
|
||||
* - `BOOTSTRAP_FAILED` — bootstrap-list dial round produced zero connections.
|
||||
* - `DIAL_FAILED` — single-peer dial-by-multiaddr failed.
|
||||
* - `STREAM_FAILED` — duplex stream emitted an error or closed unexpectedly.
|
||||
* - `PROTOCOL_NOT_SUPPORTED` — remote rejected the application protocol id.
|
||||
* - `IDENTIFY_REJECTED` — libp2p identify negotiation rejected the peer.
|
||||
* - `INVALID_OPTIONS` — configuration failed Zod validation.
|
||||
* - `DISPOSED` — operation requested after `close()` ran.
|
||||
* - `NOT_RUNNING` — operation requires `start()` first.
|
||||
* - `PEER_ALREADY_CONNECTED` — duplicate `connect(peerId, ...)` call.
|
||||
* - `PEER_UNKNOWN` — `send()` targeted a peer that is not connected.
|
||||
* - `TRANSPORT_CLOSED` — generic "underlying transport is gone" code.
|
||||
*/
|
||||
export const NET_LIBP2P_CODES = defineCodes("NET_LIBP2P", [
|
||||
"BOOTSTRAP_FAILED",
|
||||
"DIAL_FAILED",
|
||||
"STREAM_FAILED",
|
||||
"PROTOCOL_NOT_SUPPORTED",
|
||||
"IDENTIFY_REJECTED",
|
||||
"INVALID_OPTIONS",
|
||||
"DISPOSED",
|
||||
"NOT_RUNNING",
|
||||
"PEER_ALREADY_CONNECTED",
|
||||
"PEER_UNKNOWN",
|
||||
"TRANSPORT_CLOSED",
|
||||
] as const);
|
||||
|
||||
export type NetLibp2pErrorCode =
|
||||
(typeof NET_LIBP2P_CODES)[keyof typeof NET_LIBP2P_CODES];
|
||||
|
||||
/**
|
||||
* Structured error class. Every public boundary raises this; callers
|
||||
* branch on `error.code`.
|
||||
*/
|
||||
export class NetLibp2pError extends SuaveplanError<NetLibp2pErrorCode> {
|
||||
public constructor(
|
||||
code: NetLibp2pErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "NetLibp2pError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Connection
|
||||
export {
|
||||
createAndStartNode,
|
||||
dialPeerAddress,
|
||||
stopNode,
|
||||
} from "./connection/connection.js";
|
||||
// Errors
|
||||
export {
|
||||
NET_LIBP2P_CODES,
|
||||
NetLibp2pError,
|
||||
type NetLibp2pErrorCode,
|
||||
} from "./errors/errors.js";
|
||||
// Messaging
|
||||
export {
|
||||
DEFAULT_MAX_FRAME_SIZE,
|
||||
encodeFrame,
|
||||
FrameDecoder,
|
||||
} from "./messaging/frame.js";
|
||||
export {
|
||||
decodePeerMessage,
|
||||
encodePeerMessage,
|
||||
peerIdToString,
|
||||
} from "./messaging/peer-message.js";
|
||||
export { sendEnvelope, sendEnvelopeFrame } from "./messaging/send.js";
|
||||
// OTel
|
||||
export {
|
||||
adjustActivePeers,
|
||||
recordBytes,
|
||||
recordConnectionOutcome,
|
||||
recordDial,
|
||||
recordStreamOpened,
|
||||
recordStreamTtfb,
|
||||
withLibp2pSpan,
|
||||
} from "./otel/otel.js";
|
||||
// Plugin
|
||||
export { Libp2pPlugin } from "./plugin/plugin.js";
|
||||
// Streams
|
||||
export {
|
||||
consumeInboundStream,
|
||||
registerStreamHandler,
|
||||
} from "./streams/streams.js";
|
||||
// Transport
|
||||
export { Libp2pTransport } from "./transport/transport.js";
|
||||
// Schemas
|
||||
export { Libp2pConfigSchema } from "./types/libp2p.schemas.js";
|
||||
// Types
|
||||
export type {
|
||||
Libp2pConfig,
|
||||
Libp2pFactory,
|
||||
Libp2pFactoryConfig,
|
||||
Libp2pNodeLike,
|
||||
Libp2pPeerIdLike,
|
||||
Libp2pStreamHandler,
|
||||
Libp2pStreamLike,
|
||||
Libp2pSubTransport,
|
||||
MessageHandler,
|
||||
PeerEventHandler,
|
||||
} from "./types/types.js";
|
||||
@@ -0,0 +1,60 @@
|
||||
# frame
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements length-prefixed binary framing for envelopes traversing a libp2p stream. Each frame is a 4-byte big-endian length header followed by an exact-length MessagePack payload. The `FrameDecoder` is stateful — it buffers partial reads across `push()` calls so callers can stream chunks directly from `stream.source` without re-implementing the buffering protocol on every transport.
|
||||
|
||||
## Features
|
||||
|
||||
- `encodeFrame(envelope)` → `Uint8Array` length-prefix + MessagePack payload.
|
||||
- `FrameDecoder.push(chunk)` → `MessageEnvelope[]`, emits zero or more complete envelopes.
|
||||
- `FrameDecoder.bufferedBytes` — observability hook.
|
||||
- `FrameDecoder.reset()` — drops the partial buffer on socket close.
|
||||
- `DEFAULT_MAX_FRAME_SIZE = 1 MiB` plaintext cap (configurable per-decoder).
|
||||
- `STREAM_FAILED` on zero-length headers and oversized frames; `INVALID_OPTIONS` if `maxFrameSize` is non-positive.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { encodeFrame, FrameDecoder } from "@suaveplan/network-transport-libp2p";
|
||||
|
||||
const frame = encodeFrame({ id: "m1", type: "ping", payload: { n: 1 } });
|
||||
const decoder = new FrameDecoder();
|
||||
const envelopes = decoder.push(frame);
|
||||
console.log(envelopes[0]?.id); // "m1"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { FrameDecoder } from "@suaveplan/network-transport-libp2p";
|
||||
|
||||
async function* drainStream(
|
||||
stream: { source: AsyncIterable<{ subarray(): Uint8Array }> },
|
||||
maxFrameSize = 256 * 1024
|
||||
) {
|
||||
const decoder = new FrameDecoder(maxFrameSize);
|
||||
for await (const chunk of stream.source) {
|
||||
for (const envelope of decoder.push(chunk.subarray())) {
|
||||
yield envelope;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for await (const envelope of drainStream(someStream, 65_536)) {
|
||||
handleEnvelope(envelope);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DEFAULT_MAX_FRAME_SIZE: 1048576`.
|
||||
- `encodeFrame(envelope: MessageEnvelope): Uint8Array`.
|
||||
- `class FrameDecoder` — constructor: `(maxFrameSize?: number)` (default `DEFAULT_MAX_FRAME_SIZE`).
|
||||
- `push(chunk: Uint8Array): MessageEnvelope[]` — appends chunk, returns every complete envelope; throws `NetLibp2pError(STREAM_FAILED)` on zero-length, oversized, or malformed payloads.
|
||||
- `get bufferedBytes(): number`.
|
||||
- `reset(): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The decoder's hot path uses a single `Uint8Array.slice(4 + length)` per emitted frame. For workloads dominated by tiny frames this allocates more than a copy-into-ring-buffer design, but the operational simplicity has been worth the trade — the equivalent ring-buffer in the upstream Hyperswarm package was a recurring source of off-by-one bugs. We may revisit this design if a benchmarked workload demands it.
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NET_LIBP2P_CODES } from "../errors/errors.js";
|
||||
import { DEFAULT_MAX_FRAME_SIZE, encodeFrame, FrameDecoder } from "./frame.js";
|
||||
|
||||
function envelope(id: string): MessageEnvelope {
|
||||
return {
|
||||
id,
|
||||
type: "test",
|
||||
payload: { x: 1 },
|
||||
senderId: "s",
|
||||
timestamp: 1,
|
||||
headers: { version: "1.0.0" },
|
||||
ttl: 8,
|
||||
visitedNodes: [],
|
||||
} as unknown as MessageEnvelope;
|
||||
}
|
||||
|
||||
describe("encodeFrame", () => {
|
||||
it("prefixes the payload with a 4-byte big-endian length", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const frame = encodeFrame(envelope("a"));
|
||||
expect(frame.byteLength).toBeGreaterThan(4);
|
||||
const view = new DataView(frame.buffer);
|
||||
const length = view.getUint32(0, false);
|
||||
expect(length).toBe(frame.byteLength - 4);
|
||||
});
|
||||
|
||||
it("uses the default cap", { timeout: 5000 }, () => {
|
||||
expect(DEFAULT_MAX_FRAME_SIZE).toBe(1024 * 1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FrameDecoder", () => {
|
||||
it("decodes a single complete frame", { timeout: 5000 }, () => {
|
||||
const decoder = new FrameDecoder();
|
||||
const frame = encodeFrame(envelope("a"));
|
||||
const decoded = decoder.push(frame);
|
||||
expect(decoded).toHaveLength(1);
|
||||
expect(decoded[0]?.id).toBe("a");
|
||||
});
|
||||
|
||||
it("buffers partial frames", { timeout: 5000 }, () => {
|
||||
const decoder = new FrameDecoder();
|
||||
const frame = encodeFrame(envelope("b"));
|
||||
const first = frame.subarray(0, 6);
|
||||
const second = frame.subarray(6);
|
||||
expect(decoder.push(first)).toHaveLength(0);
|
||||
expect(decoder.bufferedBytes).toBe(6);
|
||||
const decoded = decoder.push(second);
|
||||
expect(decoded).toHaveLength(1);
|
||||
expect(decoded[0]?.id).toBe("b");
|
||||
});
|
||||
|
||||
it("decodes multiple frames in one push", { timeout: 5000 }, () => {
|
||||
const decoder = new FrameDecoder();
|
||||
const f1 = encodeFrame(envelope("a"));
|
||||
const f2 = encodeFrame(envelope("b"));
|
||||
const combined = new Uint8Array(f1.byteLength + f2.byteLength);
|
||||
combined.set(f1, 0);
|
||||
combined.set(f2, f1.byteLength);
|
||||
const decoded = decoder.push(combined);
|
||||
expect(decoded).toHaveLength(2);
|
||||
expect(decoded[0]?.id).toBe("a");
|
||||
expect(decoded[1]?.id).toBe("b");
|
||||
});
|
||||
|
||||
it("rejects zero-length frames", { timeout: 5000 }, () => {
|
||||
const decoder = new FrameDecoder();
|
||||
const bad = new Uint8Array([0, 0, 0, 0]);
|
||||
try {
|
||||
decoder.push(bad);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.STREAM_FAILED);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects oversized frames", { timeout: 5000 }, () => {
|
||||
const decoder = new FrameDecoder(100);
|
||||
const oversized = new Uint8Array(4);
|
||||
new DataView(oversized.buffer).setUint32(0, 200, false);
|
||||
try {
|
||||
decoder.push(oversized);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; context?: { length: number } };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.STREAM_FAILED);
|
||||
expect(e.context?.length).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-positive max-frame-size in constructor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new FrameDecoder(0);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.INVALID_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
it("supports reset()", { timeout: 5000 }, () => {
|
||||
const decoder = new FrameDecoder();
|
||||
const frame = encodeFrame(envelope("a"));
|
||||
decoder.push(frame.subarray(0, 3));
|
||||
expect(decoder.bufferedBytes).toBe(3);
|
||||
decoder.reset();
|
||||
expect(decoder.bufferedBytes).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import { NET_LIBP2P_CODES, NetLibp2pError } from "../errors/errors.js";
|
||||
import { decodePeerMessage, encodePeerMessage } from "./peer-message.js";
|
||||
|
||||
/** Default wire-frame plaintext size cap (1 MiB). */
|
||||
export const DEFAULT_MAX_FRAME_SIZE = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Encode an envelope into a length-prefixed binary frame.
|
||||
* Wire format: `[length:4 BE][msgpack-payload:length]`.
|
||||
*/
|
||||
export function encodeFrame(envelope: MessageEnvelope): Uint8Array {
|
||||
const payload = encodePeerMessage(envelope);
|
||||
const frame = new Uint8Array(4 + payload.byteLength);
|
||||
const view = new DataView(frame.buffer);
|
||||
view.setUint32(0, payload.byteLength, false);
|
||||
frame.set(payload, 4);
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful inbound-frame decoder: buffers partial reads from a libp2p
|
||||
* stream and emits one decoded envelope per complete frame.
|
||||
*
|
||||
* Throws `STREAM_FAILED` on zero-length or oversized frames; throws
|
||||
* the same code (with the underlying cause) when MessagePack decoding
|
||||
* fails on a complete frame.
|
||||
*/
|
||||
export class FrameDecoder {
|
||||
readonly #maxFrameSize: number;
|
||||
#buffer: Uint8Array = new Uint8Array(0);
|
||||
|
||||
public constructor(maxFrameSize: number = DEFAULT_MAX_FRAME_SIZE) {
|
||||
if (maxFrameSize <= 0) {
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.INVALID_OPTIONS,
|
||||
"libp2p.max-frame-size-nonpositive"
|
||||
);
|
||||
}
|
||||
this.#maxFrameSize = maxFrameSize;
|
||||
}
|
||||
|
||||
public get bufferedBytes(): number {
|
||||
return this.#buffer.byteLength;
|
||||
}
|
||||
|
||||
public push(chunk: Uint8Array): MessageEnvelope[] {
|
||||
const merged = new Uint8Array(
|
||||
this.#buffer.byteLength + chunk.byteLength
|
||||
);
|
||||
merged.set(this.#buffer, 0);
|
||||
merged.set(chunk, this.#buffer.byteLength);
|
||||
this.#buffer = merged;
|
||||
const out: MessageEnvelope[] = [];
|
||||
while (this.#buffer.byteLength >= 4) {
|
||||
const view = new DataView(
|
||||
this.#buffer.buffer,
|
||||
this.#buffer.byteOffset,
|
||||
this.#buffer.byteLength
|
||||
);
|
||||
const length = view.getUint32(0, false);
|
||||
if (length === 0) {
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.STREAM_FAILED,
|
||||
"libp2p.zero-length-frame"
|
||||
);
|
||||
}
|
||||
if (length > this.#maxFrameSize) {
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.STREAM_FAILED,
|
||||
"libp2p.frame-too-large",
|
||||
{
|
||||
context: {
|
||||
length,
|
||||
maxFrameSize: this.#maxFrameSize,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
if (this.#buffer.byteLength < 4 + length) {
|
||||
break;
|
||||
}
|
||||
const payload = this.#buffer.subarray(4, 4 + length);
|
||||
out.push(decodePeerMessage(payload));
|
||||
this.#buffer = this.#buffer.slice(4 + length);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.#buffer = new Uint8Array(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# peer-message
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the wire-format codec for envelopes flowing between libp2p peers. Encoding uses the `@suaveplan/serialize` MessagePack binary profile, which round-trips Date / Map / Set / BigInt / Uint8Array natively — strictly more capable than `JSON.stringify`. Every error surfaces as a typed `NetLibp2pError(STREAM_FAILED)` so the transport layer can route malformed inputs to its reputation engine.
|
||||
|
||||
## Features
|
||||
|
||||
- `encodePeerMessage(envelope)` → `Uint8Array` using the shared MessagePack profile.
|
||||
- `decodePeerMessage(bytes)` → `MessageEnvelope`, throwing `STREAM_FAILED` on malformed input.
|
||||
- `peerIdToString(id)` → `string`, the single point that normalises libp2p's peer-id object to a string `PeerId`.
|
||||
- Zero dependency on the concrete `libp2p` package — types only.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodePeerMessage,
|
||||
encodePeerMessage,
|
||||
} from "@suaveplan/network-transport-libp2p";
|
||||
|
||||
const bytes = encodePeerMessage({ id: "m1", type: "ping", payload: { n: 1 } });
|
||||
const back = decodePeerMessage(bytes);
|
||||
console.log(back.id); // "m1"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodePeerMessage,
|
||||
encodePeerMessage,
|
||||
NET_LIBP2P_CODES,
|
||||
NetLibp2pError,
|
||||
peerIdToString,
|
||||
} from "@suaveplan/network-transport-libp2p";
|
||||
|
||||
function safeDecode(bytes: Uint8Array): { ok: true; envelope: unknown } | { ok: false; code: string } {
|
||||
try {
|
||||
const envelope = decodePeerMessage(bytes);
|
||||
return { ok: true, envelope };
|
||||
} catch (err) {
|
||||
if (err instanceof NetLibp2pError && err.code === NET_LIBP2P_CODES.STREAM_FAILED) {
|
||||
return { ok: false, code: err.code };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const sender = peerIdToString({ toString: () => "Qm-sender" });
|
||||
const encoded = encodePeerMessage({ id: "x", type: "tx", payload: new Map([["k", 1n]]) });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encodePeerMessage(envelope: MessageEnvelope): Uint8Array`.
|
||||
- `decodePeerMessage(bytes: Uint8Array): MessageEnvelope` — throws `NetLibp2pError(STREAM_FAILED)` on malformed input.
|
||||
- `peerIdToString(id: { toString(): string }): string`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Choosing `@suaveplan/serialize`'s MessagePack profile over raw JSON gives ≈30 % smaller frames on typical payloads while preserving Map / Set / BigInt / Date / Uint8Array round-trip semantics. The decode path swallows the underlying `msgpack-decode` error and rebrands it as `STREAM_FAILED` because the transport layer cannot distinguish "wire format violation" from "byzantine peer" — both warrant the same reputation impact and stream tear-down.
|
||||
|
||||
The peer-id pass-through is intentionally minimal. libp2p peer-ids are multihash-encoded buffers but their `toString()` returns the stable base58btc form; we treat that string as opaque throughout the transport so the package never needs to depend on `@libp2p/peer-id` or any specific peer-id concrete class. The decoupling pays off in two places: tests can supply a trivial `{ toString }` object as a peer-id without instantiating real cryptographic identities, and downstream consumers can integrate alternative peer-id systems by wrapping their identifier behind the same single-method contract.
|
||||
|
||||
Future work: stream-level codec swapping (caller-injected encode/decode) so the wire format can be replaced with CBOR, Protobuf, or any other byte-exact codec without changing the transport. The structural pass-through cost of MessagePack on every send is negligible compared to the libp2p transport stack itself, so the optimisation has not yet been worth the API surface.
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NET_LIBP2P_CODES } from "../errors/errors.js";
|
||||
import {
|
||||
decodePeerMessage,
|
||||
encodePeerMessage,
|
||||
peerIdToString,
|
||||
} from "./peer-message.js";
|
||||
|
||||
function envelope(id: string): MessageEnvelope {
|
||||
return {
|
||||
id,
|
||||
type: "test:msg",
|
||||
payload: { hello: "world", n: 7 },
|
||||
senderId: "sender",
|
||||
timestamp: 12345,
|
||||
headers: { version: "1.0.0" },
|
||||
ttl: 8,
|
||||
visitedNodes: [],
|
||||
} as unknown as MessageEnvelope;
|
||||
}
|
||||
|
||||
describe("peer-message wire format", () => {
|
||||
it("round-trips a basic envelope", { timeout: 5000 }, () => {
|
||||
const env = envelope("e1");
|
||||
const bytes = encodePeerMessage(env);
|
||||
const back = decodePeerMessage(bytes);
|
||||
expect(back.id).toBe("e1");
|
||||
expect(back.type).toBe("test:msg");
|
||||
expect((back.payload as { hello: string }).hello).toBe("world");
|
||||
});
|
||||
|
||||
it("preserves nested structures", { timeout: 5000 }, () => {
|
||||
const env = {
|
||||
...envelope("e2"),
|
||||
payload: {
|
||||
nested: { deep: { value: [1, 2, 3] } },
|
||||
set: new Set([1, 2]),
|
||||
map: new Map([["k", "v"]]),
|
||||
},
|
||||
};
|
||||
const bytes = encodePeerMessage(env);
|
||||
const back = decodePeerMessage(bytes);
|
||||
expect(back.payload).toBeDefined();
|
||||
});
|
||||
|
||||
it("throws STREAM_FAILED on malformed payload", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Provide garbage that's not valid MessagePack
|
||||
const garbage = new Uint8Array([0xc1, 0xc1, 0xc1, 0xc1, 0xc1]);
|
||||
try {
|
||||
decodePeerMessage(garbage);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
// Some malformed bytes still decode to a primitive; the
|
||||
// contract is that *invalid msgpack* surfaces our code.
|
||||
// Force a real invalid pattern instead.
|
||||
void err;
|
||||
}
|
||||
// Try truly broken: an incomplete fixarray declaration
|
||||
const broken = new Uint8Array([0x91]); // fixarray length 1 with no element
|
||||
expect(() => decodePeerMessage(broken)).toThrowError();
|
||||
try {
|
||||
decodePeerMessage(broken);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.STREAM_FAILED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("peerIdToString", () => {
|
||||
it("calls toString on the given object", { timeout: 5000 }, () => {
|
||||
expect(peerIdToString({ toString: () => "Qm-abc-123" })).toBe(
|
||||
"Qm-abc-123"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import { decodeBinary, encodeBinary } from "@suaveplan/serialize";
|
||||
import { NET_LIBP2P_CODES, NetLibp2pError } from "../errors/errors.js";
|
||||
|
||||
/**
|
||||
* Encode a `MessageEnvelope` for the wire. Uses `@suaveplan/serialize`'s
|
||||
* MessagePack binary profile for round-trip-preserving structured encoding
|
||||
* — handles Date / Map / Set / BigInt / Uint8Array natively.
|
||||
*/
|
||||
export function encodePeerMessage(envelope: MessageEnvelope): Uint8Array {
|
||||
return encodeBinary(envelope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a wire-format byte buffer back into a `MessageEnvelope`.
|
||||
*
|
||||
* Throws `STREAM_FAILED` on malformed input — callers must catch and
|
||||
* either close the offending stream or surface to the reputation layer.
|
||||
*/
|
||||
export function decodePeerMessage(bytes: Uint8Array): MessageEnvelope {
|
||||
try {
|
||||
return decodeBinary(bytes) as MessageEnvelope;
|
||||
} catch (cause) {
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.STREAM_FAILED,
|
||||
"libp2p.peer-message-decode-failed",
|
||||
{ cause: cause as Error }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an opaque libp2p peer-id object as a string suitable for use as
|
||||
* a genesis `PeerId`. Always calls `.toString()` — libp2p ids implement
|
||||
* a stable string serialization (multihash-base58).
|
||||
*/
|
||||
export function peerIdToString(id: { toString(): string }): string {
|
||||
return id.toString();
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# send
|
||||
|
||||
## Purpose
|
||||
|
||||
Outbound stream-based envelope shipping over libp2p. Each `send` opens a new protocol stream, writes a single framed envelope, and closes — modelled after libp2p's standard request/response idiom. The functions thread an `AbortSignal` to cancel the dial and bound the in-flight write with a per-call timeout so a stalled remote cannot pin the calling fiber.
|
||||
|
||||
## Features
|
||||
|
||||
- `sendEnvelopeFrame(node, peerId, protocolId, frame, timeoutMs, signal?)` for callers that pre-encoded a frame (useful for broadcast fan-out).
|
||||
- `sendEnvelope(node, peerId, protocolId, envelope, timeoutMs, signal?)` convenience that encodes inline.
|
||||
- Translates every libp2p surface error into `NetLibp2pError(STREAM_FAILED)` with the cause preserved.
|
||||
- Records `streams_opened_total`, `stream_ttfb_ms`, and `bytes_total{direction=out}` on success.
|
||||
- Honours pre-aborted `AbortSignal` instantly with `STREAM_FAILED`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { sendEnvelope } from "@suaveplan/network-transport-libp2p";
|
||||
|
||||
await sendEnvelope(
|
||||
node,
|
||||
{ toString: () => "Qm-peer-id" },
|
||||
"/suaveplan/network/1.0.0",
|
||||
{ id: "m1", type: "ping", payload: { n: 1 } },
|
||||
5_000
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
encodeFrame,
|
||||
sendEnvelopeFrame,
|
||||
} from "@suaveplan/network-transport-libp2p";
|
||||
|
||||
async function broadcast(node: Libp2pNodeLike, peers: Libp2pPeerIdLike[]) {
|
||||
const envelope = { id: "bcast", type: "tick", payload: {} } as MessageEnvelope;
|
||||
const frame = encodeFrame(envelope); // encode once, reuse
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 1_000);
|
||||
await Promise.allSettled(
|
||||
peers.map((p) =>
|
||||
sendEnvelopeFrame(
|
||||
node,
|
||||
p,
|
||||
"/suaveplan/network/1.0.0",
|
||||
frame,
|
||||
5_000,
|
||||
controller.signal
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `sendEnvelopeFrame(node, peerId, protocolId, frame, timeoutMs, signal?): Promise<void>`.
|
||||
- `sendEnvelope(node, peerId, protocolId, envelope, timeoutMs, signal?): Promise<void>`.
|
||||
|
||||
Both throw `NetLibp2pError(STREAM_FAILED)` on dialProtocol failure, on `sink`/`close` failure, on timeout, or on a pre-aborted signal.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function builds a manual `Promise` rather than using `async`/`await` because we need an explicit `setTimeout` cancellable from both the success and failure paths. The pattern mirrors the upstream Hyperswarm and WebRTC transport implementations exactly. Reusing a pre-encoded frame across many peers (broadcast) saves N-1 MessagePack encodes — for chatty fan-out workloads this is significant.
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
FakeLibp2pHub,
|
||||
FakeLibp2pNode,
|
||||
FakePeerId,
|
||||
} from "../../tests/setup/fake-libp2p.js";
|
||||
import { NET_LIBP2P_CODES } from "../errors/errors.js";
|
||||
import { registerStreamHandler } from "../streams/streams.js";
|
||||
import { encodeFrame } from "./frame.js";
|
||||
import { sendEnvelope, sendEnvelopeFrame } from "./send.js";
|
||||
|
||||
function envelope(id: string): MessageEnvelope {
|
||||
return {
|
||||
id,
|
||||
type: "t",
|
||||
payload: { v: id },
|
||||
senderId: "s",
|
||||
timestamp: 1,
|
||||
headers: { version: "1.0.0" },
|
||||
ttl: 8,
|
||||
visitedNodes: [],
|
||||
} as unknown as MessageEnvelope;
|
||||
}
|
||||
|
||||
async function pair(): Promise<{
|
||||
a: FakeLibp2pNode;
|
||||
b: FakeLibp2pNode;
|
||||
inboxB: MessageEnvelope[];
|
||||
}> {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const a = new FakeLibp2pNode(hub, "a");
|
||||
const b = new FakeLibp2pNode(hub, "b");
|
||||
const inboxB: MessageEnvelope[] = [];
|
||||
await registerStreamHandler(b, "/p/1.0.0", 65536, (_peer, env) =>
|
||||
inboxB.push(env)
|
||||
);
|
||||
return { a, b, inboxB };
|
||||
}
|
||||
|
||||
describe("sendEnvelopeFrame", () => {
|
||||
it("writes a single framed envelope to the remote handler", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { a, inboxB } = await pair();
|
||||
await sendEnvelopeFrame(
|
||||
a,
|
||||
new FakePeerId("b"),
|
||||
"/p/1.0.0",
|
||||
encodeFrame(envelope("m1")),
|
||||
1000
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(inboxB.map((m) => m.id)).toEqual(["m1"]);
|
||||
});
|
||||
|
||||
it("translates dialProtocol failure to STREAM_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { a } = await pair();
|
||||
a.dialProtocolThrows = true;
|
||||
try {
|
||||
await sendEnvelopeFrame(
|
||||
a,
|
||||
new FakePeerId("b"),
|
||||
"/p/1.0.0",
|
||||
new Uint8Array([1, 2, 3]),
|
||||
1000
|
||||
);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.STREAM_FAILED);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects on timeout", { timeout: 5000 }, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const a = new FakeLibp2pNode(hub, "a");
|
||||
// dialProtocol that never resolves
|
||||
a.dialProtocol = async () =>
|
||||
new Promise(() => {
|
||||
/* hang */
|
||||
});
|
||||
try {
|
||||
await sendEnvelopeFrame(
|
||||
a,
|
||||
new FakePeerId("zzz"),
|
||||
"/p/1.0.0",
|
||||
new Uint8Array([1]),
|
||||
25
|
||||
);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.STREAM_FAILED);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects when signal is pre-aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { a } = await pair();
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
await sendEnvelopeFrame(
|
||||
a,
|
||||
new FakePeerId("b"),
|
||||
"/p/1.0.0",
|
||||
new Uint8Array([1]),
|
||||
1000,
|
||||
ac.signal
|
||||
);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.STREAM_FAILED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendEnvelope", () => {
|
||||
it("encodes then sends in one call", { timeout: 5000 }, async () => {
|
||||
const { a, inboxB } = await pair();
|
||||
await sendEnvelope(
|
||||
a,
|
||||
new FakePeerId("b"),
|
||||
"/p/1.0.0",
|
||||
envelope("conv"),
|
||||
1000
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(inboxB[0]?.id).toBe("conv");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendEnvelopeFrame race conditions", () => {
|
||||
it("ignores a late success when timeout already fired", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const a = new FakeLibp2pNode(hub, "a");
|
||||
let resolveStream:
|
||||
| ((s: import("../types/types.js").Libp2pStreamLike) => void)
|
||||
| undefined;
|
||||
a.dialProtocol = async () =>
|
||||
new Promise((res) => {
|
||||
resolveStream = res;
|
||||
});
|
||||
const sendPromise = sendEnvelopeFrame(
|
||||
a,
|
||||
new FakePeerId("b"),
|
||||
"/p/1.0.0",
|
||||
new Uint8Array([1]),
|
||||
20
|
||||
);
|
||||
const settled = sendPromise.catch((err) => err);
|
||||
// Wait past the timeout
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// Now resolve the dial — success path hits `if (settled) return;`
|
||||
resolveStream?.({
|
||||
source: (async function* () {
|
||||
/* empty */
|
||||
})(),
|
||||
sink: async () => {
|
||||
/* noop */
|
||||
},
|
||||
close: async () => {
|
||||
/* noop */
|
||||
},
|
||||
});
|
||||
const err = (await settled) as { code?: string };
|
||||
expect(err.code).toBe("NET_LIBP2P.STREAM_FAILED");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
|
||||
it("ignores a late failure when timeout already fired", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const a = new FakeLibp2pNode(hub, "a");
|
||||
let rejectStream: ((err: Error) => void) | undefined;
|
||||
a.dialProtocol = async () =>
|
||||
new Promise((_res, rej) => {
|
||||
rejectStream = rej;
|
||||
});
|
||||
const sendPromise = sendEnvelopeFrame(
|
||||
a,
|
||||
new FakePeerId("b"),
|
||||
"/p/1.0.0",
|
||||
new Uint8Array([1]),
|
||||
20
|
||||
);
|
||||
// Attach a swallowing handler immediately so the eventual
|
||||
// rejection doesn't bubble as unhandled.
|
||||
const settled = sendPromise.catch((err) => err);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// Trigger the catch path AFTER timeout — should be swallowed
|
||||
rejectStream?.(new Error("late-dial-error"));
|
||||
const err = (await settled) as { code?: string };
|
||||
expect(err.code).toBe("NET_LIBP2P.STREAM_FAILED");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { NET_LIBP2P_CODES, NetLibp2pError } from "../errors/errors.js";
|
||||
import {
|
||||
recordBytes,
|
||||
recordStreamOpened,
|
||||
recordStreamTtfb,
|
||||
} from "../otel/otel.js";
|
||||
import type { Libp2pNodeLike, Libp2pPeerIdLike } from "../types/types.js";
|
||||
import { encodeFrame } from "./frame.js";
|
||||
|
||||
/**
|
||||
* Open a libp2p protocol stream to `peerId`, write a single length-
|
||||
* prefixed envelope frame, and close. Resolves on flush; rejects with
|
||||
* `STREAM_FAILED` on libp2p errors and on timeout.
|
||||
*
|
||||
* `signal` aborts the dial; the in-flight write itself is bounded by
|
||||
* `timeoutMs` so a hung remote can't pin a fiber indefinitely.
|
||||
*/
|
||||
export async function sendEnvelopeFrame(
|
||||
node: Libp2pNodeLike,
|
||||
peerId: Libp2pPeerIdLike,
|
||||
protocolId: string,
|
||||
frame: Uint8Array,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.STREAM_FAILED,
|
||||
"libp2p.send-aborted"
|
||||
);
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
settled = true;
|
||||
reject(
|
||||
new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.STREAM_FAILED,
|
||||
"libp2p.send-timeout",
|
||||
{ context: { timeoutMs } }
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const stream = await node.dialProtocol(peerId, protocolId);
|
||||
recordStreamOpened();
|
||||
recordStreamTtfb(Date.now() - startedAt);
|
||||
await stream.sink([frame]);
|
||||
await stream.close();
|
||||
recordBytes("out", frame.byteLength);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
} catch (cause) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(
|
||||
new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.STREAM_FAILED,
|
||||
"libp2p.send-failed",
|
||||
{ cause: cause as Error }
|
||||
)
|
||||
);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper: encode + send in one call. Most callers use this
|
||||
* unless they need to pre-encode for fan-out efficiency.
|
||||
*/
|
||||
export function sendEnvelope(
|
||||
node: Libp2pNodeLike,
|
||||
peerId: Libp2pPeerIdLike,
|
||||
protocolId: string,
|
||||
envelope: import("@suaveplan/network").MessageEnvelope,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
return sendEnvelopeFrame(
|
||||
node,
|
||||
peerId,
|
||||
protocolId,
|
||||
encodeFrame(envelope),
|
||||
timeoutMs,
|
||||
signal
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# otel
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Centralises the OpenTelemetry instrument creation and span helpers for the libp2p transport. Every counter, histogram, and gauge that other modules emit goes through a helper exported here — this keeps OTel boilerplate out of the messaging and transport code and makes the metric surface easy to audit in one place.
|
||||
|
||||
## Features
|
||||
|
||||
- `connections_opened_total{outcome}` counter — total connection-open attempts.
|
||||
- `streams_opened_total` counter — opened libp2p protocol streams.
|
||||
- `bytes_total{direction}` counter — bytes flowing in/out.
|
||||
- `dial_ms` histogram — wall-clock duration of dialing a peer.
|
||||
- `stream_ttfb_ms` histogram — wall-clock time to first byte on outbound streams.
|
||||
- `active_peers` up/down counter (gauge) — currently-connected peer count.
|
||||
- `withLibp2pSpan(name, fn)` — async-span wrapper that always ends the span.
|
||||
- All record helpers clamp non-positive values to zero so out-of-order timestamps don't poison the histograms.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordDial, recordBytes, withLibp2pSpan } from "@suaveplan/network-transport-libp2p";
|
||||
|
||||
await withLibp2pSpan("custom.op", async () => {
|
||||
const t0 = Date.now();
|
||||
// ...do work
|
||||
recordDial(Date.now() - t0);
|
||||
recordBytes("out", 1024);
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
adjustActivePeers,
|
||||
recordConnectionOutcome,
|
||||
recordStreamOpened,
|
||||
recordStreamTtfb,
|
||||
} from "@suaveplan/network-transport-libp2p";
|
||||
|
||||
class ObservabilityAdapter {
|
||||
onPeerJoin(peer: string): void {
|
||||
adjustActivePeers(+1);
|
||||
recordConnectionOutcome("ok");
|
||||
}
|
||||
onPeerLeave(peer: string): void {
|
||||
adjustActivePeers(-1);
|
||||
}
|
||||
onStreamOpened(ttfbMs: number): void {
|
||||
recordStreamOpened();
|
||||
recordStreamTtfb(ttfbMs);
|
||||
}
|
||||
onPeerRejected(reason: string): void {
|
||||
recordConnectionOutcome("rejected");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `withLibp2pSpan<T>(name: string, fn: (span) => Promise<T>): Promise<T>` — wraps `fn` in a span; always ends.
|
||||
- `recordConnectionOutcome(outcome: string): void` — `ok` / `error` / `rejected`.
|
||||
- `recordStreamOpened(): void` — increment streams counter.
|
||||
- `recordBytes(direction: "in" | "out", count: number): void` — count > 0 only.
|
||||
- `recordDial(ms: number): void` — clamped non-negative.
|
||||
- `recordStreamTtfb(ms: number): void` — clamped non-negative.
|
||||
- `adjustActivePeers(delta: number): void` — gauge delta.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The tracer and meter are constructed at module-eval time via `trace.getTracer` and `metrics.getMeter`. Both APIs return no-op instances when no SDK is registered, so importing this module never forces an OTel SDK initialisation. The counter/histogram names are stable public API — operators may have dashboards keyed on them — so we never rename without a major-version bump and a deprecation alias.
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
adjustActivePeers,
|
||||
recordBytes,
|
||||
recordConnectionOutcome,
|
||||
recordDial,
|
||||
recordStreamOpened,
|
||||
recordStreamTtfb,
|
||||
withLibp2pSpan,
|
||||
} from "./otel.js";
|
||||
|
||||
describe("OTel helpers", () => {
|
||||
it("wraps an operation in a span", { timeout: 5000 }, async () => {
|
||||
const result = await withLibp2pSpan("test.op", async () => 42);
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it("ends the span even when the operation throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
withLibp2pSpan("test.op", async () => {
|
||||
throw new Error("boom");
|
||||
})
|
||||
).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
it("records connection outcomes without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => recordConnectionOutcome("ok")).not.toThrow();
|
||||
expect(() => recordConnectionOutcome("error")).not.toThrow();
|
||||
expect(() => recordConnectionOutcome("rejected")).not.toThrow();
|
||||
});
|
||||
|
||||
it("ignores non-positive byte counts", { timeout: 5000 }, () => {
|
||||
expect(() => recordBytes("in", 0)).not.toThrow();
|
||||
expect(() => recordBytes("out", -1)).not.toThrow();
|
||||
expect(() => recordBytes("in", 100)).not.toThrow();
|
||||
});
|
||||
|
||||
it("clamps negative durations to zero", { timeout: 5000 }, () => {
|
||||
expect(() => recordDial(-100)).not.toThrow();
|
||||
expect(() => recordDial(50)).not.toThrow();
|
||||
expect(() => recordStreamTtfb(-5)).not.toThrow();
|
||||
expect(() => recordStreamTtfb(10)).not.toThrow();
|
||||
});
|
||||
|
||||
it("counts opened streams", { timeout: 5000 }, () => {
|
||||
expect(() => recordStreamOpened()).not.toThrow();
|
||||
});
|
||||
|
||||
it("adjusts the active-peers gauge", { timeout: 5000 }, () => {
|
||||
expect(() => adjustActivePeers(+1)).not.toThrow();
|
||||
expect(() => adjustActivePeers(-1)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { metrics, type Span, type Tracer, trace } from "@opentelemetry/api";
|
||||
|
||||
const TRACER_NAME = "@suaveplan/network-transport-libp2p";
|
||||
const METER_NAME = "@suaveplan/network-transport-libp2p";
|
||||
|
||||
const tracer: Tracer = trace.getTracer(TRACER_NAME);
|
||||
const meter = metrics.getMeter(METER_NAME);
|
||||
|
||||
const connectionsCounter = meter.createCounter(
|
||||
"libp2p.connections_opened_total",
|
||||
{
|
||||
description: "Total connection-open attempts, by outcome.",
|
||||
}
|
||||
);
|
||||
|
||||
const streamsCounter = meter.createCounter("libp2p.streams_opened_total", {
|
||||
description: "Total streams opened by the libp2p transport.",
|
||||
});
|
||||
|
||||
const bytesCounter = meter.createCounter("libp2p.bytes_total", {
|
||||
description: "Total bytes flowing through libp2p streams, by direction.",
|
||||
});
|
||||
|
||||
const dialHist = meter.createHistogram("libp2p.dial_ms", {
|
||||
description: "Wall-clock duration of dialing a libp2p peer.",
|
||||
unit: "ms",
|
||||
});
|
||||
|
||||
const streamTtfbHist = meter.createHistogram("libp2p.stream_ttfb_ms", {
|
||||
description: "Wall-clock time to first byte from an outbound stream.",
|
||||
unit: "ms",
|
||||
});
|
||||
|
||||
const activePeersGauge = meter.createUpDownCounter("libp2p.active_peers", {
|
||||
description: "Current count of active libp2p peer connections.",
|
||||
});
|
||||
|
||||
/** Wrap a span around a transport-internal operation. */
|
||||
export async function withLibp2pSpan<T>(
|
||||
name: string,
|
||||
fn: (span: Span) => Promise<T>
|
||||
): Promise<T> {
|
||||
return tracer.startActiveSpan(name, async (span) => {
|
||||
try {
|
||||
return await fn(span);
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Record a connection outcome (`ok`, `error`, `rejected`). */
|
||||
export function recordConnectionOutcome(outcome: string): void {
|
||||
connectionsCounter.add(1, { outcome });
|
||||
}
|
||||
|
||||
/** Record opening of a stream. */
|
||||
export function recordStreamOpened(): void {
|
||||
streamsCounter.add(1);
|
||||
}
|
||||
|
||||
/** Record bytes flowing in/out (`in` | `out`). */
|
||||
export function recordBytes(direction: "in" | "out", count: number): void {
|
||||
if (count <= 0) {
|
||||
return;
|
||||
}
|
||||
bytesCounter.add(count, { direction });
|
||||
}
|
||||
|
||||
/** Record dial duration in ms (clamped non-negative). */
|
||||
export function recordDial(ms: number): void {
|
||||
dialHist.record(Math.max(0, ms));
|
||||
}
|
||||
|
||||
/** Record stream time-to-first-byte in ms (clamped non-negative). */
|
||||
export function recordStreamTtfb(ms: number): void {
|
||||
streamTtfbHist.record(Math.max(0, ms));
|
||||
}
|
||||
|
||||
/** Adjust the active-peers gauge by `delta` (±1 typically). */
|
||||
export function adjustActivePeers(delta: number): void {
|
||||
activePeersGauge.add(delta);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
# plugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`Libp2pPlugin implements NetworkPlugin` — the canonical wrapper that lets `@suaveplan/network`'s `NetworkNode` install, start, stop, and dispose a `Libp2pTransport` through a uniform plugin contract. Every transport in the `@suaveplan/network-transport-*` family follows this exact shape so the `network-plugin-transport-manager` can swap transports without code changes.
|
||||
|
||||
## Features
|
||||
|
||||
- `install(node)` registers the transport with the network node.
|
||||
- `start()` brings the transport up — composes the libp2p stack, registers the inbound stream handler.
|
||||
- `stop()` closes the transport and unregisters it from the node.
|
||||
- `dispose()` releases the node reference; idempotent across repeated calls.
|
||||
- Exposes the underlying `Libp2pTransport` instance via the `transport` field for tests and tracing hooks.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Libp2pPlugin } from "@suaveplan/network-transport-libp2p";
|
||||
import { NetworkNode } from "@suaveplan/network";
|
||||
|
||||
const node = new NetworkNode({ id: "node-1" });
|
||||
const plugin = new Libp2pPlugin({
|
||||
libp2pFactory: realLibp2pFactory,
|
||||
bootstrap: ["/dns4/bootstrap.example/tcp/443/wss/p2p/Qm…"],
|
||||
});
|
||||
|
||||
plugin.install(node);
|
||||
await plugin.start();
|
||||
// ... node is now reachable over libp2p
|
||||
await plugin.stop();
|
||||
plugin.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Libp2pPlugin } from "@suaveplan/network-transport-libp2p";
|
||||
import { NetworkNode } from "@suaveplan/network";
|
||||
|
||||
const wsPlugin = new Libp2pPlugin({
|
||||
libp2pFactory: wsFactory,
|
||||
bootstrap: bootstrapList,
|
||||
subTransport: "websockets",
|
||||
});
|
||||
const rtcPlugin = new Libp2pPlugin({
|
||||
libp2pFactory: rtcFactory,
|
||||
bootstrap: bootstrapList,
|
||||
subTransport: "webrtc",
|
||||
});
|
||||
|
||||
const node = new NetworkNode({ id: "dual" });
|
||||
node.use(wsPlugin);
|
||||
node.use(rtcPlugin);
|
||||
await node.start();
|
||||
|
||||
// Later:
|
||||
await node.stop();
|
||||
wsPlugin.dispose();
|
||||
rtcPlugin.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new Libp2pPlugin(config: Libp2pConfig)` — constructs the transport eagerly so `config` validation runs at plugin instantiation, not at first `start()`.
|
||||
- `name: "libp2p"` (readonly).
|
||||
- `transport: Libp2pTransport` (readonly) — exposed for tracing/tests.
|
||||
- `install(node: NetworkNode): void`.
|
||||
- `start(): Promise<void>`.
|
||||
- `stop(): Promise<void>`.
|
||||
- `dispose(): void` — idempotent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Like the other transport plugins, this class is a deliberately thin wrapper — the heavy lifting lives in `Libp2pTransport`. The split exists so that `network-plugin-transport-manager` can introspect and swap plugins uniformly without knowing transport-specific configuration shapes. `dispose()` only clears the node reference; the transport's `close()` runs on `stop()` so a caller may safely re-install the same plugin instance against a different node after `stop()`.
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { NetworkNode } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
FakeLibp2pHub,
|
||||
makeFakeFactory,
|
||||
} from "../../tests/setup/fake-libp2p.js";
|
||||
import { Libp2pPlugin } from "./plugin.js";
|
||||
|
||||
function fakeNode(): {
|
||||
node: NetworkNode;
|
||||
registered: string[];
|
||||
unregistered: string[];
|
||||
} {
|
||||
const registered: string[] = [];
|
||||
const unregistered: string[] = [];
|
||||
const stub = {
|
||||
registerTransport: (t: { id: string }) => {
|
||||
registered.push(t.id);
|
||||
},
|
||||
unregisterTransport: (id: string) => {
|
||||
unregistered.push(id);
|
||||
},
|
||||
} as unknown as NetworkNode;
|
||||
return { node: stub, registered, unregistered };
|
||||
}
|
||||
|
||||
describe("Libp2pPlugin", () => {
|
||||
it("exposes name 'libp2p' and constructs the transport", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub);
|
||||
const plugin = new Libp2pPlugin({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
expect(plugin.name).toBe("libp2p");
|
||||
expect(plugin.transport.id).toBe("libp2p");
|
||||
});
|
||||
|
||||
it("registers and unregisters the transport across the lifecycle", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub);
|
||||
const plugin = new Libp2pPlugin({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
const { node, registered, unregistered } = fakeNode();
|
||||
plugin.install(node);
|
||||
expect(registered).toEqual(["libp2p"]);
|
||||
await plugin.start();
|
||||
await plugin.stop();
|
||||
expect(unregistered).toEqual(["libp2p"]);
|
||||
});
|
||||
|
||||
it("dispose() is idempotent", { timeout: 5000 }, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub);
|
||||
const plugin = new Libp2pPlugin({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
plugin.dispose();
|
||||
plugin.dispose();
|
||||
// no throw, no side effects
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("stop() without install does not throw", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub);
|
||||
const plugin = new Libp2pPlugin({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
await plugin.start();
|
||||
await plugin.stop();
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
|
||||
import { Libp2pTransport } from "../transport/transport.js";
|
||||
import type { Libp2pConfig } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* `NetworkPlugin` wrapper around `Libp2pTransport`. Mirrors the
|
||||
* `WebRTCPlugin` / `HyperswarmPlugin` / `WebSocketPlugin` shape exactly
|
||||
* so the `network-plugin-transport-manager` treats every transport
|
||||
* uniformly.
|
||||
*
|
||||
* - `install(node)` registers the transport with the node.
|
||||
* - `start()` brings up the libp2p node and registers the inbound handler.
|
||||
* - `stop()` closes the transport and unregisters from the node.
|
||||
* - `dispose()` releases the node reference; idempotent.
|
||||
*/
|
||||
export class Libp2pPlugin implements NetworkPlugin {
|
||||
public readonly name = "libp2p";
|
||||
public readonly transport: Libp2pTransport;
|
||||
#node: NetworkNode | null = null;
|
||||
#disposed = false;
|
||||
|
||||
public constructor(config: Libp2pConfig) {
|
||||
this.transport = new Libp2pTransport(config);
|
||||
}
|
||||
|
||||
public install(node: NetworkNode): void {
|
||||
this.#node = node;
|
||||
node.registerTransport(this.transport);
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
await this.transport.start();
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
await this.transport.close();
|
||||
this.#node?.unregisterTransport(this.transport.id);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this.#disposed) {
|
||||
return;
|
||||
}
|
||||
this.#disposed = true;
|
||||
this.#node = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# streams
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Inbound protocol-handler registration and stream draining. `registerStreamHandler` binds the application protocol id to a callback that ingests each inbound stream through a `FrameDecoder` and emits one `MessageEnvelope` per complete frame. `consumeInboundStream` is the underlying coroutine — exposed separately so callers can re-use it without registering a handler.
|
||||
|
||||
## Features
|
||||
|
||||
- `registerStreamHandler(node, protocolId, maxFrameSize, onEnvelope, signal?)` — single call to wire the inbound handler.
|
||||
- `consumeInboundStream(stream, maxFrameSize, onEnvelope, signal?)` — drains a single stream, returns the count of envelopes delivered.
|
||||
- Records `bytes_total{direction=in}` on every chunk.
|
||||
- Honours pre-aborted `AbortSignal` instantly and cuts the loop on mid-iteration abort.
|
||||
- Always closes the stream in a `finally` block, swallowing any close failure (stream is already torn down).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { registerStreamHandler } from "@suaveplan/network-transport-libp2p";
|
||||
|
||||
await registerStreamHandler(
|
||||
node,
|
||||
"/suaveplan/network/1.0.0",
|
||||
1024 * 1024,
|
||||
(peerId, envelope) => {
|
||||
console.log(`Received ${envelope.id} from ${peerId}`);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
consumeInboundStream,
|
||||
NET_LIBP2P_CODES,
|
||||
NetLibp2pError,
|
||||
} from "@suaveplan/network-transport-libp2p";
|
||||
|
||||
async function drainWithReputation(stream: Libp2pStreamLike, peer: string) {
|
||||
try {
|
||||
const count = await consumeInboundStream(
|
||||
stream,
|
||||
64 * 1024,
|
||||
(envelope) => routeEnvelope(peer, envelope)
|
||||
);
|
||||
recordPeerHealthy(peer, count);
|
||||
} catch (err) {
|
||||
if (err instanceof NetLibp2pError && err.code === NET_LIBP2P_CODES.STREAM_FAILED) {
|
||||
recordPeerByzantine(peer);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `registerStreamHandler(node, protocolId, maxFrameSize, onEnvelope, signal?): Promise<void>` — throws `NetLibp2pError(STREAM_FAILED)` if `signal` is pre-aborted.
|
||||
- `consumeInboundStream(stream, maxFrameSize, onEnvelope, signal?): Promise<number>` — throws `NetLibp2pError(STREAM_FAILED)` on any framing/decoding error.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The handler returns synchronously after firing the consume coroutine (`void` return) — libp2p's handler API does not await its callback, so we never block its event loop. Errors raised during decoding propagate up through the `consumeInboundStream` promise, but because the transport never awaits that promise (it's a fire-and-forget on each new stream), errors surface in OTel counters rather than as uncaught exceptions. Future work: explicit reputation hook to tear down the offending peer's other streams when a single stream signals byzantine behaviour.
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
FakeLibp2pHub,
|
||||
FakeLibp2pNode,
|
||||
FakePeerId,
|
||||
FakeStream,
|
||||
} from "../../tests/setup/fake-libp2p.js";
|
||||
import { NET_LIBP2P_CODES } from "../errors/errors.js";
|
||||
import { encodeFrame } from "../messaging/frame.js";
|
||||
import { consumeInboundStream, registerStreamHandler } from "./streams.js";
|
||||
|
||||
function envelope(id: string): MessageEnvelope {
|
||||
return {
|
||||
id,
|
||||
type: "t",
|
||||
payload: { v: 1 },
|
||||
senderId: "s",
|
||||
timestamp: 1,
|
||||
headers: { version: "1.0.0" },
|
||||
ttl: 8,
|
||||
visitedNodes: [],
|
||||
} as unknown as MessageEnvelope;
|
||||
}
|
||||
|
||||
describe("registerStreamHandler", () => {
|
||||
it("registers a protocol handler on the node", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const a = new FakeLibp2pNode(hub, "a");
|
||||
const b = new FakeLibp2pNode(hub, "b");
|
||||
const seen: Array<[string, string]> = [];
|
||||
await registerStreamHandler(a, "/test/1.0.0", 65536, (peer, env) =>
|
||||
seen.push([peer, String(env.id)])
|
||||
);
|
||||
|
||||
// b dials a's protocol; a's handler must fire
|
||||
const stream = await b.dialProtocol(new FakePeerId("a"), "/test/1.0.0");
|
||||
await stream.sink([encodeFrame(envelope("m1"))]);
|
||||
await stream.close();
|
||||
// give microtasks a turn
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]).toEqual(["b", "m1"]);
|
||||
});
|
||||
|
||||
it("rejects when signal is pre-aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const a = new FakeLibp2pNode(hub, "a");
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
await registerStreamHandler(
|
||||
a,
|
||||
"/p/1.0.0",
|
||||
65536,
|
||||
() => {
|
||||
/* noop */
|
||||
},
|
||||
ac.signal
|
||||
);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.STREAM_FAILED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumeInboundStream", () => {
|
||||
it("decodes envelopes from the stream source", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const a = new FakeStream();
|
||||
const b = new FakeStream();
|
||||
a.pair(b);
|
||||
b.pair(a);
|
||||
const collected: MessageEnvelope[] = [];
|
||||
const consumer = consumeInboundStream(a, 65536, (env) =>
|
||||
collected.push(env)
|
||||
);
|
||||
await b.sink([encodeFrame(envelope("x1"))]);
|
||||
await b.close();
|
||||
const count = await consumer;
|
||||
expect(count).toBe(1);
|
||||
expect(collected[0]?.id).toBe("x1");
|
||||
});
|
||||
|
||||
it("stops on signal abort", { timeout: 5000 }, async () => {
|
||||
const a = new FakeStream();
|
||||
const b = new FakeStream();
|
||||
a.pair(b);
|
||||
b.pair(a);
|
||||
const ac = new AbortController();
|
||||
const consumer = consumeInboundStream(
|
||||
a,
|
||||
65536,
|
||||
() => {
|
||||
/* noop */
|
||||
},
|
||||
ac.signal
|
||||
);
|
||||
ac.abort();
|
||||
// send something after abort
|
||||
await b.sink([encodeFrame(envelope("late"))]);
|
||||
await b.close();
|
||||
await consumer;
|
||||
});
|
||||
|
||||
it("swallows close failures", { timeout: 5000 }, async () => {
|
||||
const a = new FakeStream();
|
||||
const b = new FakeStream();
|
||||
a.pair(b);
|
||||
b.pair(a);
|
||||
// monkey-patch close to throw — but use FakeStream directly
|
||||
const original = a.close.bind(a);
|
||||
a.close = async () => {
|
||||
await original();
|
||||
throw new Error("close-boom");
|
||||
};
|
||||
await b.close();
|
||||
await expect(
|
||||
consumeInboundStream(a, 65536, () => {
|
||||
/* noop */
|
||||
})
|
||||
).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it("propagates decoder errors", { timeout: 5000 }, async () => {
|
||||
const a = new FakeStream();
|
||||
const b = new FakeStream();
|
||||
a.pair(b);
|
||||
b.pair(a);
|
||||
// Send a malformed length-prefix (zero-length frame)
|
||||
const bad = new Uint8Array([0, 0, 0, 0]);
|
||||
const consumer = consumeInboundStream(a, 65536, () => {
|
||||
/* noop */
|
||||
});
|
||||
await b.sink([bad]);
|
||||
await b.close();
|
||||
await expect(consumer).rejects.toMatchObject({
|
||||
code: NET_LIBP2P_CODES.STREAM_FAILED,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import { NET_LIBP2P_CODES, NetLibp2pError } from "../errors/errors.js";
|
||||
import { FrameDecoder } from "../messaging/frame.js";
|
||||
import { recordBytes } from "../otel/otel.js";
|
||||
import type {
|
||||
Libp2pNodeLike,
|
||||
Libp2pStreamHandler,
|
||||
Libp2pStreamLike,
|
||||
} from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Register a stream handler against a protocol id on the libp2p node.
|
||||
* The handler consumes the inbound source through a `FrameDecoder` and
|
||||
* forwards each fully-decoded envelope to `onEnvelope`.
|
||||
*
|
||||
* Errors raised during decoding terminate the stream — callers wiring
|
||||
* this into the transport should handle that as a hostile / malformed
|
||||
* peer event (and apply reputation tracking at the transport layer).
|
||||
*/
|
||||
export async function registerStreamHandler(
|
||||
node: Libp2pNodeLike,
|
||||
protocolId: string,
|
||||
maxFrameSize: number,
|
||||
onEnvelope: (remotePeerId: string, envelope: MessageEnvelope) => void,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.STREAM_FAILED,
|
||||
"libp2p.handler-register-aborted"
|
||||
);
|
||||
}
|
||||
const handler: Libp2pStreamHandler = ({ stream, connection }) => {
|
||||
const remote = connection.remotePeer.toString();
|
||||
void consumeInboundStream(
|
||||
stream,
|
||||
maxFrameSize,
|
||||
(env) => {
|
||||
onEnvelope(remote, env);
|
||||
},
|
||||
signal
|
||||
);
|
||||
};
|
||||
await node.handle(protocolId, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain an inbound libp2p stream, pushing every chunk through a
|
||||
* `FrameDecoder` and emitting decoded envelopes. Aborting the supplied
|
||||
* signal closes the stream and exits the loop quietly.
|
||||
*
|
||||
* Returns the count of envelopes decoded; throws `STREAM_FAILED` if the
|
||||
* decoder rejects a frame.
|
||||
*/
|
||||
export async function consumeInboundStream(
|
||||
stream: Libp2pStreamLike,
|
||||
maxFrameSize: number,
|
||||
onEnvelope: (envelope: MessageEnvelope) => void,
|
||||
signal?: AbortSignal
|
||||
): Promise<number> {
|
||||
const decoder = new FrameDecoder(maxFrameSize);
|
||||
let count = 0;
|
||||
try {
|
||||
for await (const chunk of stream.source) {
|
||||
if (signal?.aborted) {
|
||||
break;
|
||||
}
|
||||
const bytes = chunk.subarray();
|
||||
recordBytes("in", bytes.byteLength);
|
||||
const envelopes = decoder.push(bytes);
|
||||
for (const envelope of envelopes) {
|
||||
onEnvelope(envelope);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await stream.close();
|
||||
} catch {
|
||||
/* close failures swallowed — stream already ended */
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
# transport
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`Libp2pTransport implements Transport` from `@suaveplan/network`. The class composes a libp2p stack (production or fake) through an injectable factory and wires the universal `Transport` contract: `start` / `connect` / `disconnect` / `send` / `onMessage` / `onPeerEvent` / `getConnectedPeers` / `close`. It owns the lifecycle of the libp2p node, registers the inbound stream handler, and converts libp2p's `peer:connect` / `peer:disconnect` events into the `Transport.onPeerEvent` shape.
|
||||
|
||||
## Features
|
||||
|
||||
- Universal — zero direct dependency on the concrete `libp2p` package at module-eval; production wiring lives in caller code.
|
||||
- Mandatory `libp2pFactory` and non-empty `bootstrap` enforced by `Libp2pConfigSchema` at construction.
|
||||
- Sub-transport selection (`websockets` | `webrtc`); TCP / mDNS deferred to a server-tier sibling.
|
||||
- Idempotent `start()` and `close()`; `start()` after `close()` rejects with `DISPOSED`.
|
||||
- Translates every internal failure into a typed `NetLibp2pError`.
|
||||
- Records `connections_opened_total`, `active_peers`, and (via `send` / `streams` modules) `bytes_total` + `stream_ttfb_ms`.
|
||||
- Honours pre-aborted `AbortSignal` on `start()`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Libp2pTransport } from "@suaveplan/network-transport-libp2p";
|
||||
|
||||
const transport = new Libp2pTransport({
|
||||
libp2pFactory: myLibp2pFactory,
|
||||
bootstrap: ["/dns4/bootstrap.example/tcp/443/wss/p2p/Qm…"],
|
||||
});
|
||||
|
||||
await transport.start();
|
||||
transport.onMessage((peerId, envelope) => {
|
||||
console.log(`recv ${envelope.id} from ${peerId}`);
|
||||
});
|
||||
await transport.connect("Qm-remote", "/dns4/remote/tcp/443/wss/p2p/Qm-remote");
|
||||
await transport.send("Qm-remote", { id: "m1", type: "ping", payload: {} });
|
||||
await transport.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
Libp2pTransport,
|
||||
NET_LIBP2P_CODES,
|
||||
NetLibp2pError,
|
||||
} from "@suaveplan/network-transport-libp2p";
|
||||
|
||||
const transport = new Libp2pTransport({
|
||||
libp2pFactory,
|
||||
bootstrap: bootstrapList,
|
||||
subTransport: "webrtc",
|
||||
addresses: { listen: ["/webrtc"] },
|
||||
protocolId: "/my-app/2.0.0",
|
||||
maxConnections: 50,
|
||||
dialTimeoutMs: 5_000,
|
||||
maxFrameSize: 256 * 1024,
|
||||
messageTimeoutMs: 3_000,
|
||||
});
|
||||
|
||||
const startController = new AbortController();
|
||||
setTimeout(() => startController.abort(), 15_000);
|
||||
|
||||
try {
|
||||
await transport.start(startController.signal);
|
||||
} catch (err) {
|
||||
if (err instanceof NetLibp2pError && err.code === NET_LIBP2P_CODES.BOOTSTRAP_FAILED) {
|
||||
// surface to user / retry / etc.
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const off = transport.onPeerEvent((peer, event) => {
|
||||
metrics.gauge("peers", transport.getConnectedPeers().length, { peer, event });
|
||||
});
|
||||
// later: off();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new Libp2pTransport(config: Libp2pConfig)` — throws `INVALID_OPTIONS` if `libp2pFactory` missing or `bootstrap` empty.
|
||||
- `id: "libp2p"` (readonly).
|
||||
- `subTransport: Libp2pSubTransport` (readonly).
|
||||
- `protocolId: string` (readonly).
|
||||
- `start(signal?: AbortSignal): Promise<void>` — idempotent; throws `DISPOSED` after `close()`.
|
||||
- `connect(peerId, address): Promise<void>` — throws `NOT_RUNNING` before `start()`, `DISPOSED` after `close()`, `DIAL_FAILED` on dial failure.
|
||||
- `disconnect(peerId): Promise<void>` — idempotent.
|
||||
- `send(peerId, envelope): Promise<void>` — throws `PEER_UNKNOWN` for unknown peer, `STREAM_FAILED` on protocol failure.
|
||||
- `onMessage(handler): () => void` — returns disposer.
|
||||
- `onPeerEvent(handler): () => void` — returns disposer.
|
||||
- `getConnectedPeers(): readonly PeerId[]` — snapshot.
|
||||
- `close(): Promise<void>` — idempotent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The transport stores peer ids as plain strings to keep `getConnectedPeers()` O(1) on the hot path. The `peer:connect` / `peer:disconnect` event listeners use ordinary functions held in instance fields so `close()` can pass exact references to `removeEventListener` — anonymous arrow handlers would silently leak. Sub-transport defaults to `websockets` because it is the universally-supported wire on both server and browser; selecting `webrtc` is a deliberate browser-tier opt-in. All error translation runs at the boundary; the internal `connection` / `streams` / `send` modules already produce typed errors, so the transport class itself contains essentially zero error-translation logic.
|
||||
@@ -0,0 +1,419 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
FakeLibp2pHub,
|
||||
makeFakeFactory,
|
||||
} from "../../tests/setup/fake-libp2p.js";
|
||||
import { NET_LIBP2P_CODES } from "../errors/errors.js";
|
||||
import { Libp2pTransport } from "./transport.js";
|
||||
|
||||
function envelope(id: string): MessageEnvelope {
|
||||
return {
|
||||
id,
|
||||
type: "t",
|
||||
payload: { v: id },
|
||||
senderId: "s",
|
||||
timestamp: 1,
|
||||
headers: { version: "1.0.0" },
|
||||
ttl: 8,
|
||||
visitedNodes: [],
|
||||
} as unknown as MessageEnvelope;
|
||||
}
|
||||
|
||||
async function tick(n = 2): Promise<void> {
|
||||
for (let i = 0; i < n; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
describe("Libp2pTransport construction", () => {
|
||||
it("rejects missing bootstrap with INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub);
|
||||
try {
|
||||
new Libp2pTransport({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: [] as unknown as readonly string[],
|
||||
});
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.INVALID_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects when libp2pFactory is missing", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new Libp2pTransport({
|
||||
libp2pFactory: undefined as unknown as never,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.INVALID_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes id, subTransport, and protocolId getters", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub);
|
||||
const t = new Libp2pTransport({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
subTransport: "webrtc",
|
||||
protocolId: "/x/2.0.0",
|
||||
});
|
||||
expect(t.id).toBe("libp2p");
|
||||
expect(t.subTransport).toBe("webrtc");
|
||||
expect(t.protocolId).toBe("/x/2.0.0");
|
||||
});
|
||||
|
||||
it("defaults to websockets sub-transport", { timeout: 5000 }, () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub);
|
||||
const t = new Libp2pTransport({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
expect(t.subTransport).toBe("websockets");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Libp2pTransport lifecycle", () => {
|
||||
it("start is idempotent", { timeout: 5000 }, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub);
|
||||
const t = new Libp2pTransport({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
await t.start();
|
||||
await t.start();
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("close is idempotent", { timeout: 5000 }, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub);
|
||||
const t = new Libp2pTransport({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
await t.start();
|
||||
await t.close();
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("start after close throws DISPOSED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub);
|
||||
const t = new Libp2pTransport({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
await t.start();
|
||||
await t.close();
|
||||
try {
|
||||
await t.start();
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.DISPOSED);
|
||||
}
|
||||
});
|
||||
|
||||
it("send before start throws NOT_RUNNING", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub);
|
||||
const t = new Libp2pTransport({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
try {
|
||||
await t.send("peer-x", envelope("e"));
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.NOT_RUNNING);
|
||||
}
|
||||
});
|
||||
|
||||
it("send after close throws DISPOSED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub);
|
||||
const t = new Libp2pTransport({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
await t.start();
|
||||
await t.close();
|
||||
try {
|
||||
await t.send("peer-x", envelope("e"));
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.DISPOSED);
|
||||
}
|
||||
});
|
||||
|
||||
it("connect before start throws NOT_RUNNING", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub);
|
||||
const t = new Libp2pTransport({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
try {
|
||||
await t.connect("peer-x", "/memory/x");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.NOT_RUNNING);
|
||||
}
|
||||
});
|
||||
|
||||
it("connect after close throws DISPOSED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub);
|
||||
const t = new Libp2pTransport({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
await t.start();
|
||||
await t.close();
|
||||
try {
|
||||
await t.connect("peer-x", "/memory/x");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.DISPOSED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Libp2pTransport peer & message paths", () => {
|
||||
it("emits peer:connect via injected node lifecycle event", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory: fA } = makeFakeFactory(hub, { idPrefix: "a" });
|
||||
const { factory: fB } = makeFakeFactory(hub, { idPrefix: "b" });
|
||||
const a = new Libp2pTransport({
|
||||
libp2pFactory: fA,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
const b = new Libp2pTransport({
|
||||
libp2pFactory: fB,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
await a.start();
|
||||
await b.start();
|
||||
|
||||
const events: Array<[string, string]> = [];
|
||||
a.onPeerEvent((peer, ev) => events.push([ev, peer]));
|
||||
|
||||
await a.connect("b-1", "/memory/b-1");
|
||||
await tick(2);
|
||||
expect(events.some((e) => e[0] === "connected")).toBe(true);
|
||||
|
||||
await a.close();
|
||||
await b.close();
|
||||
});
|
||||
|
||||
it("send to unknown peer rejects PEER_UNKNOWN", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub);
|
||||
const t = new Libp2pTransport({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
await t.start();
|
||||
try {
|
||||
await t.send("does-not-exist", envelope("e"));
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(NET_LIBP2P_CODES.PEER_UNKNOWN);
|
||||
}
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("disconnect emits peer:disconnect once and is idempotent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory: fA } = makeFakeFactory(hub, { idPrefix: "a" });
|
||||
const { factory: fB } = makeFakeFactory(hub, { idPrefix: "b" });
|
||||
const a = new Libp2pTransport({
|
||||
libp2pFactory: fA,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
const b = new Libp2pTransport({
|
||||
libp2pFactory: fB,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
await a.start();
|
||||
await b.start();
|
||||
await a.connect("b-1", "/memory/b-1");
|
||||
await tick(2);
|
||||
|
||||
const events: string[] = [];
|
||||
a.onPeerEvent((_p, e) => events.push(e));
|
||||
await a.disconnect("b-1");
|
||||
await a.disconnect("b-1"); // idempotent
|
||||
expect(events.filter((e) => e === "disconnected")).toHaveLength(1);
|
||||
await a.close();
|
||||
await b.close();
|
||||
});
|
||||
|
||||
it("getConnectedPeers returns a snapshot list", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub);
|
||||
const t = new Libp2pTransport({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
await t.start();
|
||||
expect(t.getConnectedPeers()).toEqual([]);
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("onMessage handler can be removed via returned disposer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub);
|
||||
const t = new Libp2pTransport({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
const off = t.onMessage(() => {
|
||||
/* noop */
|
||||
});
|
||||
off();
|
||||
expect(typeof off).toBe("function");
|
||||
});
|
||||
|
||||
it("onPeerEvent disposer removes the handler", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub);
|
||||
const t = new Libp2pTransport({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
const fires: string[] = [];
|
||||
const off = t.onPeerEvent((p) => fires.push(p));
|
||||
off();
|
||||
// Calling disconnect on a non-tracked peer should not fire after off()
|
||||
await t.disconnect("anyone");
|
||||
expect(fires).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores duplicate peer:connect for already-tracked peer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub, { idPrefix: "x" });
|
||||
const t = new Libp2pTransport({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
await t.start();
|
||||
const node = hub.get("x-1");
|
||||
if (!node) throw new Error("no node");
|
||||
const events: string[] = [];
|
||||
t.onPeerEvent((_p, e) => events.push(e));
|
||||
// Fire peer:connect twice for the same peer — the second time
|
||||
// hits the `if (this.#peerIds.has(id)) return;` early-return.
|
||||
node.forceEmitConnect("dup-peer");
|
||||
node.forceEmitConnect("dup-peer");
|
||||
await tick(2);
|
||||
const connects = events.filter((e) => e === "connected");
|
||||
expect(connects).toHaveLength(1);
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("close() swallows unhandle errors", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub, { idPrefix: "u" });
|
||||
const t = new Libp2pTransport({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
await t.start();
|
||||
const node = hub.get("u-1");
|
||||
if (!node) throw new Error("no node");
|
||||
node.unhandle = async () => {
|
||||
throw new Error("unhandle-fail");
|
||||
};
|
||||
// close() should swallow the unhandle error and still proceed
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("close() is safe when start() failed before listeners were wired", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Factory that creates a node whose start() throws — after the
|
||||
// failed start, no listeners are wired. close() must not blow up.
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory, instances } = makeFakeFactory(hub, {
|
||||
nodeOptions: { startThrows: true },
|
||||
});
|
||||
const t = new Libp2pTransport({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
try {
|
||||
await t.start();
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
// No node assigned because connection.ts threw before assignment;
|
||||
// close() should still be safe.
|
||||
void instances;
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("ignores peer:disconnect when the peer was never tracked", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory } = makeFakeFactory(hub, { idPrefix: "z" });
|
||||
const t = new Libp2pTransport({
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/memory/seed"],
|
||||
});
|
||||
await t.start();
|
||||
const node = hub.get("z-1");
|
||||
if (!node) throw new Error("no node");
|
||||
const events: string[] = [];
|
||||
t.onPeerEvent((_p, e) => events.push(e));
|
||||
// The disconnect listener fires on a peer the transport never
|
||||
// saw connect — the `if (!this.#peerIds.has(id)) return;` branch.
|
||||
node.forceEmitDisconnect("ghost-peer");
|
||||
await tick(2);
|
||||
expect(events.filter((e) => e === "disconnected")).toHaveLength(0);
|
||||
await t.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
// Universal package — composes libp2p via injected factory.
|
||||
// Zero direct `libp2p` import: the package never bundles libp2p at
|
||||
// module-eval; production callers wire the real factory.
|
||||
import type { MessageEnvelope, PeerId, Transport } from "@suaveplan/network";
|
||||
import {
|
||||
createAndStartNode,
|
||||
dialPeerAddress,
|
||||
stopNode,
|
||||
} from "../connection/connection.js";
|
||||
import { NET_LIBP2P_CODES, NetLibp2pError } from "../errors/errors.js";
|
||||
import { encodeFrame } from "../messaging/frame.js";
|
||||
import { peerIdToString } from "../messaging/peer-message.js";
|
||||
import { sendEnvelopeFrame } from "../messaging/send.js";
|
||||
import {
|
||||
adjustActivePeers,
|
||||
recordConnectionOutcome,
|
||||
withLibp2pSpan,
|
||||
} from "../otel/otel.js";
|
||||
import { registerStreamHandler } from "../streams/streams.js";
|
||||
import { Libp2pConfigSchema } from "../types/libp2p.schemas.js";
|
||||
import type {
|
||||
Libp2pConfig,
|
||||
Libp2pFactory,
|
||||
Libp2pNodeLike,
|
||||
Libp2pSubTransport,
|
||||
MessageHandler,
|
||||
PeerEventHandler,
|
||||
} from "../types/types.js";
|
||||
|
||||
const DEFAULT_PROTOCOL_ID = "/suaveplan/network/1.0.0";
|
||||
|
||||
/**
|
||||
* `Libp2pTransport implements Transport` from `@suaveplan/network`. The
|
||||
* transport composes a libp2p stack (real or fake) via an injectable
|
||||
* factory, registers an inbound stream handler that decodes binary
|
||||
* envelope frames, and emits canonical `Transport.onMessage` /
|
||||
* `onPeerEvent` events to callers.
|
||||
*/
|
||||
export class Libp2pTransport implements Transport {
|
||||
public readonly id = "libp2p";
|
||||
|
||||
readonly #factory: Libp2pFactory;
|
||||
readonly #subTransport: Libp2pSubTransport;
|
||||
readonly #addresses: { readonly listen: readonly string[] };
|
||||
readonly #bootstrap: readonly string[];
|
||||
readonly #protocolId: string;
|
||||
readonly #maxConnections: number;
|
||||
readonly #dialTimeoutMs: number;
|
||||
readonly #maxFrameSize: number;
|
||||
readonly #messageTimeoutMs: number;
|
||||
|
||||
readonly #peerIds = new Map<PeerId, { firstSeen: number }>();
|
||||
readonly #messageHandlers = new Set<MessageHandler>();
|
||||
readonly #peerEventHandlers = new Set<PeerEventHandler>();
|
||||
|
||||
#node: Libp2pNodeLike | undefined;
|
||||
#connectListener: ((event: { detail: unknown }) => void) | undefined;
|
||||
#disconnectListener: ((event: { detail: unknown }) => void) | undefined;
|
||||
#closed = false;
|
||||
#started = false;
|
||||
|
||||
public constructor(config: Libp2pConfig) {
|
||||
const parsed = Libp2pConfigSchema.safeParse(config);
|
||||
if (!parsed.success) {
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.INVALID_OPTIONS,
|
||||
"libp2p.invalid-config",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
this.#factory = config.libp2pFactory;
|
||||
this.#subTransport = config.subTransport ?? "websockets";
|
||||
this.#addresses = config.addresses ?? {
|
||||
listen:
|
||||
this.#subTransport === "webrtc"
|
||||
? ["/webrtc"]
|
||||
: ["/ip4/0.0.0.0/tcp/0/ws"],
|
||||
};
|
||||
this.#bootstrap = config.bootstrap;
|
||||
this.#protocolId = config.protocolId ?? DEFAULT_PROTOCOL_ID;
|
||||
this.#maxConnections = config.maxConnections ?? 100;
|
||||
this.#dialTimeoutMs = config.dialTimeoutMs ?? 10_000;
|
||||
this.#maxFrameSize = config.maxFrameSize ?? 1024 * 1024;
|
||||
this.#messageTimeoutMs = config.messageTimeoutMs ?? 5_000;
|
||||
}
|
||||
|
||||
/** Selected sub-transport (`websockets` | `webrtc`). */
|
||||
public get subTransport(): Libp2pSubTransport {
|
||||
return this.#subTransport;
|
||||
}
|
||||
|
||||
/** Currently-registered application protocol id. */
|
||||
public get protocolId(): string {
|
||||
return this.#protocolId;
|
||||
}
|
||||
|
||||
public async start(signal?: AbortSignal): Promise<void> {
|
||||
if (this.#closed) {
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.DISPOSED,
|
||||
"libp2p.start-after-close"
|
||||
);
|
||||
}
|
||||
if (this.#started) {
|
||||
return;
|
||||
}
|
||||
return withLibp2pSpan("libp2p.start", async () => {
|
||||
const node = await createAndStartNode(
|
||||
this.#factory,
|
||||
{
|
||||
subTransport: this.#subTransport,
|
||||
addresses: this.#addresses,
|
||||
bootstrap: this.#bootstrap,
|
||||
maxConnections: this.#maxConnections,
|
||||
dialTimeoutMs: this.#dialTimeoutMs,
|
||||
protocolId: this.#protocolId,
|
||||
},
|
||||
signal
|
||||
);
|
||||
this.#node = node;
|
||||
await registerStreamHandler(
|
||||
node,
|
||||
this.#protocolId,
|
||||
this.#maxFrameSize,
|
||||
(peerId, envelope) => {
|
||||
this.#deliverMessage(peerId, envelope);
|
||||
},
|
||||
signal
|
||||
);
|
||||
this.#wireLifecycleEvents(node);
|
||||
this.#started = true;
|
||||
});
|
||||
}
|
||||
|
||||
public async connect(_peerId: PeerId, address: string): Promise<void> {
|
||||
if (this.#closed) {
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.DISPOSED,
|
||||
"libp2p.transport-closed"
|
||||
);
|
||||
}
|
||||
if (!this.#node || !this.#started) {
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.NOT_RUNNING,
|
||||
"libp2p.transport-not-running"
|
||||
);
|
||||
}
|
||||
await dialPeerAddress(this.#node, address);
|
||||
}
|
||||
|
||||
public async disconnect(peerId: PeerId): Promise<void> {
|
||||
if (!this.#peerIds.has(peerId)) {
|
||||
return;
|
||||
}
|
||||
this.#peerIds.delete(peerId);
|
||||
adjustActivePeers(-1);
|
||||
this.#emitPeerEvent(peerId, "disconnected");
|
||||
}
|
||||
|
||||
public async send(
|
||||
peerId: PeerId,
|
||||
envelope: MessageEnvelope
|
||||
): Promise<void> {
|
||||
if (this.#closed) {
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.DISPOSED,
|
||||
"libp2p.transport-closed"
|
||||
);
|
||||
}
|
||||
const node = this.#node;
|
||||
if (!node || !this.#started) {
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.NOT_RUNNING,
|
||||
"libp2p.transport-not-running"
|
||||
);
|
||||
}
|
||||
if (!this.#peerIds.has(peerId)) {
|
||||
throw new NetLibp2pError(
|
||||
NET_LIBP2P_CODES.PEER_UNKNOWN,
|
||||
"libp2p.send-unknown-peer",
|
||||
{ context: { peerId } }
|
||||
);
|
||||
}
|
||||
const frame = encodeFrame(envelope);
|
||||
await sendEnvelopeFrame(
|
||||
node,
|
||||
{ toString: () => peerId },
|
||||
this.#protocolId,
|
||||
frame,
|
||||
this.#messageTimeoutMs
|
||||
);
|
||||
}
|
||||
|
||||
public onMessage(handler: MessageHandler): () => void {
|
||||
this.#messageHandlers.add(handler);
|
||||
return () => {
|
||||
this.#messageHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
public onPeerEvent(handler: PeerEventHandler): () => void {
|
||||
this.#peerEventHandlers.add(handler);
|
||||
return () => {
|
||||
this.#peerEventHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
public getConnectedPeers(): readonly PeerId[] {
|
||||
return Array.from(this.#peerIds.keys());
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
if (this.#closed) {
|
||||
return;
|
||||
}
|
||||
this.#closed = true;
|
||||
const node = this.#node;
|
||||
const peersBefore = Array.from(this.#peerIds.keys());
|
||||
for (const peerId of peersBefore) {
|
||||
this.#peerIds.delete(peerId);
|
||||
adjustActivePeers(-1);
|
||||
this.#emitPeerEvent(peerId, "disconnected");
|
||||
}
|
||||
if (node && this.#connectListener) {
|
||||
node.removeEventListener("peer:connect", this.#connectListener);
|
||||
}
|
||||
if (node && this.#disconnectListener) {
|
||||
node.removeEventListener(
|
||||
"peer:disconnect",
|
||||
this.#disconnectListener
|
||||
);
|
||||
}
|
||||
if (node) {
|
||||
try {
|
||||
await node.unhandle(this.#protocolId);
|
||||
} catch {
|
||||
/* protocol may already be unregistered */
|
||||
}
|
||||
await stopNode(node);
|
||||
}
|
||||
this.#messageHandlers.clear();
|
||||
this.#peerEventHandlers.clear();
|
||||
this.#node = undefined;
|
||||
this.#connectListener = undefined;
|
||||
this.#disconnectListener = undefined;
|
||||
this.#started = false;
|
||||
}
|
||||
|
||||
// ───────────────────────── private ─────────────────────────
|
||||
|
||||
#wireLifecycleEvents(node: Libp2pNodeLike): void {
|
||||
const onConnect = (event: { detail: unknown }): void => {
|
||||
const peerIdObj = event.detail as { toString(): string };
|
||||
const id = peerIdToString(peerIdObj);
|
||||
if (this.#peerIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
this.#peerIds.set(id, { firstSeen: Date.now() });
|
||||
adjustActivePeers(+1);
|
||||
recordConnectionOutcome("ok");
|
||||
this.#emitPeerEvent(id, "connected");
|
||||
};
|
||||
const onDisconnect = (event: { detail: unknown }): void => {
|
||||
const peerIdObj = event.detail as { toString(): string };
|
||||
const id = peerIdToString(peerIdObj);
|
||||
if (!this.#peerIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
this.#peerIds.delete(id);
|
||||
adjustActivePeers(-1);
|
||||
this.#emitPeerEvent(id, "disconnected");
|
||||
};
|
||||
node.addEventListener("peer:connect", onConnect);
|
||||
node.addEventListener("peer:disconnect", onDisconnect);
|
||||
this.#connectListener = onConnect;
|
||||
this.#disconnectListener = onDisconnect;
|
||||
}
|
||||
|
||||
#deliverMessage(peerId: PeerId, envelope: MessageEnvelope): void {
|
||||
for (const handler of this.#messageHandlers) {
|
||||
handler(peerId, envelope);
|
||||
}
|
||||
}
|
||||
|
||||
#emitPeerEvent(peerId: PeerId, event: "connected" | "disconnected"): void {
|
||||
for (const handler of this.#peerEventHandlers) {
|
||||
handler(peerId, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod boundary schema for `Libp2pConfig`. Mandates a non-empty
|
||||
* `bootstrap` list (security-hardened — no public-bootstrap fallback)
|
||||
* and a callable `libp2pFactory` function.
|
||||
*/
|
||||
export const Libp2pConfigSchema = z
|
||||
.object({
|
||||
libp2pFactory: z.custom<(...args: unknown[]) => unknown>(
|
||||
(v) => typeof v === "function",
|
||||
"libp2p.factory-required"
|
||||
),
|
||||
bootstrap: z.array(z.string().min(1)).min(1),
|
||||
subTransport: z.enum(["websockets", "webrtc"]).optional(),
|
||||
addresses: z.object({ listen: z.array(z.string().min(1)) }).optional(),
|
||||
protocolId: z.string().min(1).optional(),
|
||||
maxConnections: z.number().int().positive().optional(),
|
||||
dialTimeoutMs: z.number().int().positive().optional(),
|
||||
maxFrameSize: z.number().int().positive().optional(),
|
||||
messageTimeoutMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,91 @@
|
||||
# types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the universal structural types consumed by every other module in `@suaveplan/network-transport-libp2p`. The package never imports the concrete `libp2p` package at module-eval time — instead, callers supply a `Libp2pFactory` and the package operates on structural `Libp2pNodeLike` / `Libp2pStreamLike` / `Libp2pPeerIdLike` interfaces. This keeps the package portable across browsers and Node, makes tests trivially injectable, and limits the bundle surface to the abstractions the transport actually exercises.
|
||||
|
||||
## Features
|
||||
|
||||
- `Libp2pSubTransport` discriminated union (`websockets` | `webrtc`).
|
||||
- `Libp2pNodeLike` structural interface mirroring the subset of `libp2p`'s `Libp2p` instance that this package uses (`start`/`stop`/`dial`/`dialProtocol`/`handle`/`unhandle`/`getPeers`/`addEventListener`/`removeEventListener`).
|
||||
- `Libp2pStreamLike` structural duplex stream with async-iterable `source` and async `sink`.
|
||||
- `Libp2pPeerIdLike` opaque peer-id with stable `toString()`.
|
||||
- `Libp2pStreamHandler` callback signature accepted by `handle(protocolId, handler)`.
|
||||
- `Libp2pFactory` async factory function — sole production wiring boundary.
|
||||
- `Libp2pFactoryConfig` configuration passed from the transport to the factory.
|
||||
- `Libp2pConfig` public boundary type validated by `Libp2pConfigSchema`.
|
||||
- `MessageHandler` / `PeerEventHandler` matching the `Transport` contract.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
Libp2pConfig,
|
||||
Libp2pFactory,
|
||||
} from "@suaveplan/network-transport-libp2p";
|
||||
|
||||
const factory: Libp2pFactory = async (cfg) => {
|
||||
const { createLibp2p } = await import("libp2p");
|
||||
return createLibp2p({
|
||||
addresses: { listen: cfg.addresses.listen },
|
||||
// ...wire bootstrap, identify, ping, etc.
|
||||
}) as unknown as import("@suaveplan/network-transport-libp2p").Libp2pNodeLike;
|
||||
};
|
||||
|
||||
const config: Libp2pConfig = {
|
||||
libp2pFactory: factory,
|
||||
bootstrap: ["/dns4/bootstrap.example/tcp/443/wss/p2p/Qm…"],
|
||||
subTransport: "websockets",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
Libp2pConfig,
|
||||
Libp2pNodeLike,
|
||||
} from "@suaveplan/network-transport-libp2p";
|
||||
|
||||
function wrapForObservability(
|
||||
next: Libp2pNodeLike,
|
||||
metricsHook: (event: string) => void
|
||||
): Libp2pNodeLike {
|
||||
return new Proxy(next, {
|
||||
get(target, prop, recv) {
|
||||
metricsHook(String(prop));
|
||||
return Reflect.get(target, prop, recv);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const config: Libp2pConfig = {
|
||||
libp2pFactory: async (cfg) => wrapForObservability(await realFactory(cfg), () => {}),
|
||||
bootstrap: ["/dns4/example/tcp/443/wss/p2p/Qm…"],
|
||||
subTransport: "webrtc",
|
||||
addresses: { listen: ["/webrtc"] },
|
||||
maxConnections: 50,
|
||||
dialTimeoutMs: 5_000,
|
||||
maxFrameSize: 256 * 1024,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `Libp2pSubTransport`: `"websockets" | "webrtc"`.
|
||||
- `Libp2pNodeLike`: structural libp2p node surface.
|
||||
- `Libp2pStreamLike`: structural duplex stream.
|
||||
- `Libp2pPeerIdLike`: structural peer-id.
|
||||
- `Libp2pStreamHandler`: `(args: { stream; connection }) => void`.
|
||||
- `Libp2pFactory`: `(config: Libp2pFactoryConfig) => Promise<Libp2pNodeLike>`.
|
||||
- `Libp2pFactoryConfig`: subset of `Libp2pConfig` re-shaped for the factory call.
|
||||
- `Libp2pConfig`: validated public config; `libp2pFactory` and non-empty `bootstrap` are mandatory.
|
||||
- `MessageHandler`: `(peerId, envelope) => void`.
|
||||
- `PeerEventHandler`: `(peerId, "connected" | "disconnected") => void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The structural-type pattern is the single most load-bearing design choice in the package. By never importing `libp2p` at module-eval, we avoid pulling in transitive Node-only dependencies into browser bundles and give tests a clean injection seam (`FakeLibp2pNode`). Tradeoff: callers must take responsibility for wiring `createLibp2p` correctly — including Noise, Yamux, identify, ping, and bootstrap services. The Wave-7 demo package documents the canonical wiring.
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { MessageEnvelope, PeerId } from "@suaveplan/network";
|
||||
|
||||
/**
|
||||
* Sub-transport selector. Universal package — TCP and mDNS belong
|
||||
* to the server-tier sibling and are NOT exposed here.
|
||||
*/
|
||||
export type Libp2pSubTransport = "websockets" | "webrtc";
|
||||
|
||||
/**
|
||||
* Minimal structural interface representing a libp2p duplex stream.
|
||||
* The transport never depends on the concrete `@libp2p/interface.Stream`
|
||||
* shape; only on the async-iterable `source` + `sink` + `close` surface
|
||||
* used to push framed envelopes back and forth.
|
||||
*/
|
||||
export interface Libp2pStreamLike {
|
||||
readonly source: AsyncIterable<{ subarray(): Uint8Array }>;
|
||||
sink(data: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal structural interface for a libp2p peer id. We treat peer ids
|
||||
* as opaque values addressable via `toString()`.
|
||||
*/
|
||||
export interface Libp2pPeerIdLike {
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream-handler callback registered against a protocol id. Mirrors the
|
||||
* shape libp2p's `node.handle(protocolId, handler)` accepts.
|
||||
*/
|
||||
export type Libp2pStreamHandler = (args: {
|
||||
stream: Libp2pStreamLike;
|
||||
connection: { remotePeer: Libp2pPeerIdLike };
|
||||
}) => void;
|
||||
|
||||
/**
|
||||
* Structural interface representing the libp2p node API surface used
|
||||
* by this package. Production wires this to a real `Libp2p` instance;
|
||||
* tests wire it to `FakeLibp2pNode`.
|
||||
*/
|
||||
export interface Libp2pNodeLike {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
dial(addr: string): Promise<unknown>;
|
||||
dialProtocol(
|
||||
peerId: Libp2pPeerIdLike,
|
||||
protocolId: string
|
||||
): Promise<Libp2pStreamLike>;
|
||||
handle(protocolId: string, handler: Libp2pStreamHandler): Promise<void>;
|
||||
unhandle(protocolId: string): Promise<void>;
|
||||
getPeers(): Libp2pPeerIdLike[];
|
||||
addEventListener(
|
||||
type: string,
|
||||
listener: (event: { detail: unknown }) => void
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: string,
|
||||
listener: (event: { detail: unknown }) => void
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory that produces a `Libp2pNodeLike` from a sub-transport-aware
|
||||
* configuration. Wired to real `libp2p.createLibp2p()` in production
|
||||
* and to `FakeLibp2pNode` under test.
|
||||
*/
|
||||
export type Libp2pFactory = (
|
||||
config: Libp2pFactoryConfig
|
||||
) => Promise<Libp2pNodeLike>;
|
||||
|
||||
/**
|
||||
* Configuration passed to the libp2p factory. Carries everything the
|
||||
* production wiring needs to compose the libp2p stack without leaking
|
||||
* the libp2p API into the transport.
|
||||
*/
|
||||
export interface Libp2pFactoryConfig {
|
||||
readonly subTransport: Libp2pSubTransport;
|
||||
readonly addresses: { readonly listen: readonly string[] };
|
||||
readonly bootstrap: readonly string[];
|
||||
readonly maxConnections: number;
|
||||
readonly dialTimeoutMs: number;
|
||||
readonly protocolId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public configuration accepted by `Libp2pTransport`.
|
||||
*/
|
||||
export interface Libp2pConfig {
|
||||
/** Mandatory libp2p factory; no built-in default. */
|
||||
readonly libp2pFactory: Libp2pFactory;
|
||||
/**
|
||||
* REQUIRED bootstrap multiaddr list (no public-bootstrap fallback).
|
||||
* Empty list rejected with `INVALID_OPTIONS`.
|
||||
*/
|
||||
readonly bootstrap: readonly string[];
|
||||
/** Sub-transport selection. Universal tier: `websockets` | `webrtc`. */
|
||||
readonly subTransport?: Libp2pSubTransport;
|
||||
/** Listen multiaddrs. Defaults to `['/ip4/0.0.0.0/tcp/0/ws']` for ws. */
|
||||
readonly addresses?: { readonly listen: readonly string[] };
|
||||
/** Application-level protocol id. Defaults to `/suaveplan/network/1.0.0`. */
|
||||
readonly protocolId?: string;
|
||||
/** Maximum concurrent connections. Defaults to 100. */
|
||||
readonly maxConnections?: number;
|
||||
/** Dial timeout in milliseconds. Defaults to 10_000. */
|
||||
readonly dialTimeoutMs?: number;
|
||||
/** Maximum wire-frame plaintext size in bytes. Defaults to 1 MiB. */
|
||||
readonly maxFrameSize?: number;
|
||||
/** Per-stream send timeout in milliseconds. Defaults to 5_000. */
|
||||
readonly messageTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message handler signature for `Transport.onMessage`.
|
||||
*/
|
||||
export type MessageHandler = (
|
||||
peerId: PeerId,
|
||||
envelope: MessageEnvelope
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Peer-event handler signature for `Transport.onPeerEvent`.
|
||||
*/
|
||||
export type PeerEventHandler = (
|
||||
peerId: PeerId,
|
||||
event: "connected" | "disconnected"
|
||||
) => void;
|
||||
Reference in New Issue
Block a user