Initial Commit

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

View File

@@ -0,0 +1,129 @@
# @suaveplan/network-libp2p-server - Server-tier libp2p relay daemon (bootstrap + circuit-relay-v2) paired with @suaveplan/network-transport-libp2p — Node-only HTTP/libp2p host with structural Zod option validation, AbortSignal lifecycle, health/ready/live endpoints, OTel observability, and injectable libp2p factory for real-fake integration tests.
## Summary
Server-tier libp2p relay daemon paired with
[`@suaveplan/network-transport-libp2p`](../network-transport-libp2p).
Hosts a libp2p node configured as a bootstrap + circuit-relay-v2 server
alongside an inline HTTP listener exposing `/health`, `/ready`, `/live`
verbs.
<!-- begin:auto-docs -->
## Documentation tree
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [health-listener](./src/server/health-listener.md) — Wraps the asynchronous `http.Server.listen(port, host)` flow with the listener-attach / `EADDRINUSE` classification / `…
- [peer-events](./src/server/peer-events.md) — Wires the `peer:connect` and `peer:disconnect` event listeners onto a `Libp2pServerNodeLike` and bridges them onto the…
- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [health-listener](./src/server/health-listener.md) — Wraps the asynchronous `http.Server.listen(port, host)` flow with the listener-attach / `EADDRINUSE` classification / `…
- [peer-events](./src/server/peer-events.md) — Wires the `peer:connect` and `peer:disconnect` event listeners onto a `Libp2pServerNodeLike` and bridges them onto the…
- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [health-listener](./src/server/health-listener.md) — Wraps the asynchronous `http.Server.listen(port, host)` flow with the listener-attach / `EADDRINUSE` classification / `…
- [peer-events](./src/server/peer-events.md) — Wires the `peer:connect` and `peer:disconnect` event listeners onto a `Libp2pServerNodeLike` and bridges them onto the…
- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [health-listener](./src/server/health-listener.md) — Wraps the asynchronous `http.Server.listen(port, host)` flow with the listener-attach / `EADDRINUSE` classification / `…
- [peer-events](./src/server/peer-events.md) — Wires the `peer:connect` and `peer:disconnect` event listeners onto a `Libp2pServerNodeLike` and bridges them onto the…
- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_ - [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ - [health-listener](./src/server/health-listener.md) — Wraps the asynchronous `http.Server.listen(port, host)` flow with the listener-attach / `EADDRINUSE` classification / `…
- [peer-events](./src/server/peer-events.md) — Wires the `peer:connect` and `peer:disconnect` event listeners onto a `Libp2pServerNodeLike` and bridges them onto the…
- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_
## Run the daemon
```ts
import { createLibp2p } from "libp2p";
import { tcp } from "@libp2p/tcp";
import { webSockets } from "@libp2p/websockets";
import { noise } from "@chainsafe/libp2p-noise";
import { yamux } from "@chainsafe/libp2p-yamux";
import { identify } from "@libp2p/identify";
import { circuitRelayServer } from "@libp2p/circuit-relay-v2";
import { bootstrap } from "@libp2p/bootstrap";
import { Libp2pServer } from "@suaveplan/network-libp2p-server";
const server = new Libp2pServer({
listen: ["/ip4/0.0.0.0/tcp/9090", "/ip4/0.0.0.0/tcp/9091/ws"],
bootstrap: ["/dnsaddr/bootstrap.libp2p.io/p2p/QmNnoo..."],
autoRelay: { enabled: true, maxConnections: 500 },
healthPort: 9099,
libp2pFactory: async (cfg) =>
createLibp2p({
addresses: { listen: [...cfg.listen] },
transports: [tcp(), webSockets()],
connectionEncrypters: [noise()],
streamMuxers: [yamux()],
peerDiscovery:
cfg.bootstrap.length > 0
? [bootstrap({ list: [...cfg.bootstrap] })]
: [],
services: {
identify: identify(),
relay: circuitRelayServer({
reservations: {
maxReservations: cfg.autoRelay.maxConnections,
},
}),
},
connectionManager: { maxConnections: cfg.maxConnections },
}),
});
const { peerId, multiaddrs } = await server.start();
console.log(`libp2p relay ${peerId} on:`);
for (const a of multiaddrs) console.log(` ${a}`);
// Graceful shutdown
process.on("SIGTERM", () => server.stop());
```
## Test environment
Tests inject a deterministic `FakeLibp2pServerNode` so the native
transitive deps of the real `libp2p` package never enter the test
surface — the daemon never resolves `libp2p` at module evaluation time
and the integration suite boots a real `Libp2pServer` inside the test
process.
```ts
import { Libp2pServer } from "@suaveplan/network-libp2p-server";
import {
FakeLibp2pHub,
makeFakeServerFactory,
} from "./tests/setup/fake-libp2p-server.js";
const hub = new FakeLibp2pHub();
const { factory } = makeFakeServerFactory(hub);
const server = new Libp2pServer({
libp2pFactory: factory,
listen: ["/ip4/0.0.0.0/tcp/0"],
healthPort: 0,
});
await server.start();
// ... drive the daemon, then:
await server.stop();
```
## Acceptance gates
- `bunx turbo lint typecheck test --filter=@suaveplan/network-libp2p-server`
- `bunx turbo test:coverage --filter=@suaveplan/network-libp2p-server` → 100/100/100/100
- `bun run docs:check --filter=@suaveplan/network-libp2p-server`
## API
See per-module `.md` docs:
- [`src/server/server.md`](src/server/server.md) — `Libp2pServer` daemon
- [`src/server/peer-events.md`](src/server/peer-events.md) — peer
connect/disconnect bridging
- [`src/server/health-listener.md`](src/server/health-listener.md) —
inline-HTTP `listenHttp` helper
- [`src/health/health.md`](src/health/health.md) — `/health` / `/ready`
/ `/live` verb pipeline
- [`src/otel/otel.md`](src/otel/otel.md) — OTel instrumentation surface
- [`src/errors/errors.md`](src/errors/errors.md) — error registry
- [`src/types/types.md`](src/types/types.md) — public type contracts

View File

@@ -0,0 +1,85 @@
{
"name": "@suaveplan/network-libp2p-server",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Server-tier libp2p relay daemon (bootstrap + circuit-relay-v2) paired with @suaveplan/network-transport-libp2p — Node-only HTTP/libp2p host with structural Zod option validation, AbortSignal lifecycle, health/ready/live endpoints, OTel observability, and injectable libp2p factory for real-fake integration tests.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"engines": {
"node": ">=20"
},
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./server": {
"import": { "types": "./dist/server/server.d.ts", "default": "./dist/server/server.js" }
},
"./health": {
"import": { "types": "./dist/health/health.d.ts", "default": "./dist/health/health.js" }
},
"./otel": {
"import": { "types": "./dist/otel/otel.d.ts", "default": "./dist/otel/otel.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@suaveplan/network-transport-libp2p": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"peerDependencies": {
"libp2p": "catalog:network-transport",
"@libp2p/tcp": "catalog:network-transport",
"@libp2p/websockets": "catalog:network-transport",
"@libp2p/circuit-relay-v2": "catalog:network-transport",
"@libp2p/identify": "catalog:network-transport",
"@libp2p/bootstrap": "catalog:network-transport",
"@chainsafe/libp2p-noise": "catalog:network-transport",
"@chainsafe/libp2p-yamux": "catalog:network-transport",
"@multiformats/multiaddr": "catalog:network-transport"
},
"peerDependenciesMeta": {
"libp2p": { "optional": true },
"@libp2p/tcp": { "optional": true },
"@libp2p/websockets": { "optional": true },
"@libp2p/circuit-relay-v2": { "optional": true },
"@libp2p/identify": { "optional": true },
"@libp2p/bootstrap": { "optional": true },
"@chainsafe/libp2p-noise": { "optional": true },
"@chainsafe/libp2p-yamux": { "optional": true },
"@multiformats/multiaddr": { "optional": true }
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

@@ -0,0 +1,89 @@
# `errors/errors.ts` — error registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `NET_LIBP2P_SERVER` code registry plus `NetLibp2pServerError`
class used throughout the libp2p relay daemon. Every public boundary in
`@suaveplan/network-libp2p-server` throws this typed error on failure so
callers can branch deterministically on `error.code` without parsing
messages or stack traces.
## Features
- Frozen `NET_LIBP2P_SERVER_CODES` registry — six codes covering option
validation, lifecycle, listener bind failures, and circuit-relay-v2
setup.
- `NetLibp2pServerError` extends `SuaveplanError<NetLibp2pServerErrorCode>`
so the cause-chain traversal, structured logging adapters, and OTel
span-event integrations already wired into the suaveplan error
framework all apply automatically.
- `NetLibp2pServerErrorCode` derived type exposed for exhaustive
switches in callers.
## Usage: basic example
```ts
import {
NET_LIBP2P_SERVER_CODES,
NetLibp2pServerError,
} from "@suaveplan/network-libp2p-server";
throw new NetLibp2pServerError(
NET_LIBP2P_SERVER_CODES.INVALID_OPTIONS,
"libp2p_server.invalid-config"
);
```
## Usage: advanced example
```ts
try {
await server.start();
} catch (e) {
if (e instanceof NetLibp2pServerError) {
switch (e.code) {
case NET_LIBP2P_SERVER_CODES.LISTEN_FAILED:
// health port in use — retry with another port
break;
case NET_LIBP2P_SERVER_CODES.START_FAILED:
// libp2p node failed to start
break;
default:
throw e;
}
}
}
```
## API reference
- `NET_LIBP2P_SERVER_CODES` — `Readonly<{ INVALID_OPTIONS, START_FAILED,
LISTEN_FAILED, DISPOSED, NOT_RUNNING, RELAY_FAILED }>`.
- `NetLibp2pServerError(code, messageKey, options?)` — constructor.
Sets `name = "NetLibp2pServerError"`. Forwards `options` (cause,
context) to `SuaveplanError`.
## Implementation notes
The registry is intentionally narrow. The relay daemon is not in the
business of forwarding application-level libp2p stream errors — those
belong to the consumer's protocol-level error model. The codes here
cover only daemon-lifecycle failures so dashboards and SLO panels can
key on a tight, stable surface.
`INVALID_OPTIONS` is the only code thrown synchronously from the
constructor; the remaining codes surface from `start()` and `stop()`.
`LISTEN_FAILED` covers every bind failure on the inline health HTTP
server, including `EADDRINUSE`. `START_FAILED` covers libp2p factory
failures, `node.start()` rejections, double-start attempts, and signal
aborts; aborts deliberately reuse the same code so dashboards see a
single bucket. `DISPOSED` is the post-`stop()` state guard; `NOT_RUNNING`
is reserved for future operations that are only valid mid-lifecycle.
`RELAY_FAILED` is reserved for the circuit-relay-v2 stream-setup
failure path and is exported now so consumers' switch-statements stay
exhaustive when the relay-stream-wiring story expands. All codes are
frozen at definition time by `defineCodes` so accidental mutation at
runtime is impossible.

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NET_LIBP2P_SERVER_CODES, NetLibp2pServerError } from "./errors.js";
describe("NET_LIBP2P_SERVER_CODES registry", () => {
it("exposes every documented code", { timeout: 5000 }, () => {
expect(NET_LIBP2P_SERVER_CODES.INVALID_OPTIONS).toBeDefined();
expect(NET_LIBP2P_SERVER_CODES.START_FAILED).toBeDefined();
expect(NET_LIBP2P_SERVER_CODES.LISTEN_FAILED).toBeDefined();
expect(NET_LIBP2P_SERVER_CODES.DISPOSED).toBeDefined();
expect(NET_LIBP2P_SERVER_CODES.NOT_RUNNING).toBeDefined();
expect(NET_LIBP2P_SERVER_CODES.RELAY_FAILED).toBeDefined();
});
});
describe("NetLibp2pServerError", () => {
it("constructs with code + messageKey", { timeout: 5000 }, () => {
const err = new NetLibp2pServerError(
NET_LIBP2P_SERVER_CODES.INVALID_OPTIONS,
"libp2p_server.test"
);
expect(err).toBeInstanceOf(NetLibp2pServerError);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("NetLibp2pServerError");
expect(err.code).toBe(NET_LIBP2P_SERVER_CODES.INVALID_OPTIONS);
});
it("forwards cause + context through SuaveplanError options", {
timeout: 5000,
}, () => {
const cause = new Error("root");
const err = new NetLibp2pServerError(
NET_LIBP2P_SERVER_CODES.START_FAILED,
"libp2p_server.test",
{ cause, context: { port: 9091 } }
);
expect(err.cause).toBe(cause);
expect(err.context).toMatchObject({ port: 9091 });
});
});

View File

@@ -0,0 +1,45 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Error codes emitted by `@suaveplan/network-libp2p-server`.
*
* - `INVALID_OPTIONS` — caller-supplied config failed Zod validation,
* or the required `libp2pFactory` was missing.
* - `START_FAILED` — `node.start()` threw or the inline health-port
* bind failed for a reason other than `EADDRINUSE`.
* - `LISTEN_FAILED` — the inline health HTTP server failed to bind
* (port-in-use or similar).
* - `DISPOSED` — operation attempted after `stop()` cleared state.
* - `NOT_RUNNING` — operation attempted before `start()` resolved.
* - `RELAY_FAILED` — circuit-relay-v2 stream failed to wire up.
*/
export const NET_LIBP2P_SERVER_CODES = defineCodes("NET_LIBP2P_SERVER", [
"INVALID_OPTIONS",
"START_FAILED",
"LISTEN_FAILED",
"DISPOSED",
"NOT_RUNNING",
"RELAY_FAILED",
] as const);
export type NetLibp2pServerErrorCode =
(typeof NET_LIBP2P_SERVER_CODES)[keyof typeof NET_LIBP2P_SERVER_CODES];
/**
* Structured error class for the libp2p relay daemon. Every public
* boundary throws this on failure; consumers branch on `error.code`.
*/
export class NetLibp2pServerError extends SuaveplanError<NetLibp2pServerErrorCode> {
public constructor(
code: NetLibp2pServerErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetLibp2pServerError";
}
}

View File

@@ -0,0 +1,72 @@
# `health/health.ts` — inline health endpoints
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes the `/health`, `/ready`, `/live` verb pipeline used by the
libp2p relay daemon's inline HTTP listener. Allows orchestrators
(Kubernetes, ECS, Nomad, docker-compose healthchecks) to probe the
daemon without pulling in `@suaveplan/health-check` as a hard dep.
Returns deterministic JSON bodies so dashboards can key on the
`status` field as well as inspect `activePeers` / `peerId` for at-a
glance triage.
## Features
- Three pure verbs: `live` (process alive, hasn't `stop()`'d), `ready`
(running with spare capacity), `health` (overall posture).
- Pure `classifyHealthRequest(url)` returning `"health" | "ready" |
"live" | null` — strips querystrings and trailing slashes.
- Pure `buildHealthResponse(verb, snap)` returning `{ status, body }`
for unit testability and reuse outside the inline HTTP server.
- `handleHealth(req, res, snap)` dispatcher returning a boolean so the
daemon can fall through to non-health routes deterministically.
## Usage: basic example
```ts
import { handleHealth } from "@suaveplan/network-libp2p-server/health";
import { createServer } from "node:http";
createServer((req, res) => {
if (handleHealth(req, res, server.snapshot())) return;
res.writeHead(404).end();
}).listen(9091);
```
## Usage: advanced example
```ts
import {
buildHealthResponse,
classifyHealthRequest,
HEALTH_PATHS,
} from "@suaveplan/network-libp2p-server/health";
// Custom routing — wire health into an existing fastify/express tree
const verb = classifyHealthRequest(req.url);
if (verb !== null) {
const { status, body } = buildHealthResponse(verb, snapshot);
reply.code(status).send(body);
}
```
## API reference
- `HEALTH_PATHS` — `Readonly<{ health: "/health", ready: "/ready",
live: "/live" }>`.
- `classifyHealthRequest(url) => HealthKind`.
- `buildHealthResponse(verb, snap) => { status, body }`.
- `handleHealth(req, res, snap) => boolean` — returns `true` if the
request was handled and the response was written.
## Implementation notes
The `live` verb intentionally returns 200 even when the libp2p node has
not yet finished `start()` — process liveness is independent of node
readiness so orchestrators don't churn restarts during slow bootstrap.
`ready` returns 503 with `status: "starting"` until `start()` resolves.
`health` returns 503 with `status: "degraded"` when at-cap so load
balancers shed traffic before connection rejections occur.

View File

@@ -0,0 +1,161 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
buildHealthResponse,
classifyHealthRequest,
HEALTH_PATHS,
type HealthSnapshot,
handleHealth,
} from "./health.js";
const runningSnap: HealthSnapshot = {
running: true,
disposed: false,
activePeers: 0,
maxConnections: 100,
startedAt: 1_700_000_000_000,
peerId: "peer-A",
};
const stoppedSnap: HealthSnapshot = {
running: false,
disposed: true,
activePeers: 0,
maxConnections: 100,
startedAt: 1_700_000_000_000,
peerId: "peer-A",
};
const startingSnap: HealthSnapshot = {
running: false,
disposed: false,
activePeers: 0,
maxConnections: 100,
startedAt: undefined,
peerId: undefined,
};
const atCapSnap: HealthSnapshot = {
running: true,
disposed: false,
activePeers: 100,
maxConnections: 100,
startedAt: 1,
peerId: "p",
};
describe("classifyHealthRequest", () => {
it("matches each health verb", { timeout: 5000 }, () => {
expect(classifyHealthRequest("/health")).toBe("health");
expect(classifyHealthRequest("/ready")).toBe("ready");
expect(classifyHealthRequest("/live")).toBe("live");
});
it("strips querystrings and trailing slashes", { timeout: 5000 }, () => {
expect(classifyHealthRequest("/health?probe=1")).toBe("health");
expect(classifyHealthRequest("/ready/")).toBe("ready");
expect(classifyHealthRequest("/live/?x=y")).toBe("live");
});
it("returns null for non-health paths and empty input", {
timeout: 5000,
}, () => {
expect(classifyHealthRequest("/metrics")).toBe(null);
expect(classifyHealthRequest("")).toBe(null);
expect(classifyHealthRequest(undefined)).toBe(null);
});
});
describe("buildHealthResponse", () => {
it("live: running -> 200 ok", { timeout: 5000 }, () => {
const { status, body } = buildHealthResponse("live", runningSnap);
expect(status).toBe(200);
expect(body.status).toBe("ok");
});
it("live: disposed -> 503 stopped", { timeout: 5000 }, () => {
const { status, body } = buildHealthResponse("live", stoppedSnap);
expect(status).toBe(503);
expect(body.status).toBe("stopped");
});
it("ready: not running -> 503 starting", { timeout: 5000 }, () => {
const { status, body } = buildHealthResponse("ready", startingSnap);
expect(status).toBe(503);
expect(body.status).toBe("starting");
});
it("ready: disposed -> 503 stopped", { timeout: 5000 }, () => {
const { status, body } = buildHealthResponse("ready", stoppedSnap);
expect(status).toBe(503);
expect(body.status).toBe("stopped");
});
it("health: at-cap -> 503 degraded", { timeout: 5000 }, () => {
const { status, body } = buildHealthResponse("health", atCapSnap);
expect(status).toBe(503);
expect(body.status).toBe("degraded");
});
it("health: running, capacity -> 200 ok", { timeout: 5000 }, () => {
const { status, body } = buildHealthResponse("health", runningSnap);
expect(status).toBe(200);
expect(body.status).toBe("ok");
});
it("ready: running, capacity -> 200 ok", { timeout: 5000 }, () => {
const { status, body } = buildHealthResponse("ready", runningSnap);
expect(status).toBe(200);
expect(body.status).toBe("ok");
});
});
interface FakeRes {
statusCode?: number;
headers?: Record<string, string>;
body?: string;
writeHead(s: number, h: Record<string, string>): void;
end(b?: string): void;
}
function makeFakeRes(): FakeRes {
const out: FakeRes = {
writeHead(s, h): void {
out.statusCode = s;
out.headers = h;
},
end(b): void {
out.body = b;
},
};
return out;
}
describe("handleHealth", () => {
it("dispatches to the verb pipeline and writes JSON", {
timeout: 5000,
}, () => {
const res = makeFakeRes();
const handled = handleHealth({ url: "/health" }, res, runningSnap);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.headers?.["content-type"]).toBe("application/json");
expect(JSON.parse(res.body ?? "")).toMatchObject({ status: "ok" });
});
it("returns false for non-health URLs and writes nothing", {
timeout: 5000,
}, () => {
const res = makeFakeRes();
const handled = handleHealth({ url: "/metrics" }, res, runningSnap);
expect(handled).toBe(false);
expect(res.statusCode).toBeUndefined();
});
});
describe("HEALTH_PATHS", () => {
it("exposes the three verb paths", { timeout: 5000 }, () => {
expect(HEALTH_PATHS.health).toBe("/health");
expect(HEALTH_PATHS.ready).toBe("/ready");
expect(HEALTH_PATHS.live).toBe("/live");
});
});

View File

@@ -0,0 +1,133 @@
import type { IncomingMessage, ServerResponse } from "node:http";
/**
* Snapshot of the daemon's runtime state used to decide each health
* verb's response. Held by the daemon and passed into `handleHealth`
* on every request — values are captured at call-time so the response
* always reflects current truth.
*/
export interface HealthSnapshot {
/** `true` once `start()` resolved successfully. */
readonly running: boolean;
/** `true` once `stop()` cleared state. */
readonly disposed: boolean;
/** Currently-connected libp2p peers. */
readonly activePeers: number;
/** Configured `maxConnections`. */
readonly maxConnections: number;
/** Unix-ms timestamp of the latest `start()` resolve. */
readonly startedAt: number | undefined;
/** Local node peer id once `start()` resolved. */
readonly peerId: string | undefined;
}
/** Routes the health/ready/live verbs understood by the daemon. */
export const HEALTH_PATHS = {
health: "/health",
ready: "/ready",
live: "/live",
} as const;
export type HealthPath = (typeof HEALTH_PATHS)[keyof typeof HEALTH_PATHS];
/** Discriminator returned to the daemon by {@link classifyHealthRequest}. */
export type HealthKind = "health" | "ready" | "live" | null;
/**
* Classify an inbound HTTP request URL by path. Returns the matching
* verb or `null` if the URL is not a known health route. Querystrings
* and trailing slashes are ignored.
*/
export function classifyHealthRequest(url: string | undefined): HealthKind {
if (!url) return null;
const qIdx = url.indexOf("?");
const noQuery = qIdx === -1 ? url : url.slice(0, qIdx);
const path = noQuery.endsWith("/") ? noQuery.slice(0, -1) : noQuery;
switch (path) {
case HEALTH_PATHS.health:
return "health";
case HEALTH_PATHS.ready:
return "ready";
case HEALTH_PATHS.live:
return "live";
default:
return null;
}
}
/**
* Body returned by each health verb. The daemon stamps it onto the
* `ServerResponse` JSON-encoded with the matching HTTP status.
*/
export interface HealthResponse {
readonly status: "ok" | "degraded" | "starting" | "stopped";
readonly running: boolean;
readonly disposed: boolean;
readonly activePeers: number;
readonly maxConnections: number;
readonly startedAt: number | undefined;
readonly peerId: string | undefined;
readonly verb: "health" | "ready" | "live";
}
/**
* Build the body and HTTP status for one health verb.
*
* - `live` returns 200 unless `stop()` has cleared state (503).
* - `ready` returns 200 only when the daemon has finished `start()`
* AND has spare connection capacity.
* - `health` returns 200 when running and not over capacity, 503 if
* degraded (at-cap, not running, or disposed).
*/
export function buildHealthResponse(
verb: "health" | "ready" | "live",
snap: HealthSnapshot
): { readonly status: number; readonly body: HealthResponse } {
const base = {
running: snap.running,
disposed: snap.disposed,
activePeers: snap.activePeers,
maxConnections: snap.maxConnections,
startedAt: snap.startedAt,
peerId: snap.peerId,
verb,
} as const;
if (verb === "live") {
if (snap.disposed) {
return {
status: 503,
body: { ...base, status: "stopped" },
};
}
return { status: 200, body: { ...base, status: "ok" } };
}
const atCap = snap.activePeers >= snap.maxConnections;
if (!snap.running || snap.disposed) {
return {
status: 503,
body: { ...base, status: snap.disposed ? "stopped" : "starting" },
};
}
if (atCap) {
return { status: 503, body: { ...base, status: "degraded" } };
}
return { status: 200, body: { ...base, status: "ok" } };
}
/**
* Dispatch one HTTP request to the health verb pipeline. Returns
* `true` when the request was handled, `false` when it should be
* forwarded to the next handler. Always writes JSON.
*/
export function handleHealth(
req: Pick<IncomingMessage, "url">,
res: Pick<ServerResponse, "writeHead" | "end">,
snap: HealthSnapshot
): boolean {
const verb = classifyHealthRequest(req.url);
if (verb === null) return false;
const { status, body } = buildHealthResponse(verb, snap);
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
return true;
}

View File

@@ -0,0 +1,55 @@
// Server-tier package — uses node:http (Node-only). Browser consumers cannot use this package.
// Errors
export {
NET_LIBP2P_SERVER_CODES,
NetLibp2pServerError,
type NetLibp2pServerErrorCode,
} from "./errors/errors.js";
// Health
export {
buildHealthResponse,
classifyHealthRequest,
HEALTH_PATHS,
type HealthKind,
type HealthPath,
type HealthResponse,
type HealthSnapshot,
handleHealth,
} from "./health/health.js";
// OTel
export {
adjustActivePeers,
type ConnectionOutcome,
recordConnection,
recordRelayedStream,
recordStartDuration,
withStartSpan,
withStopSpan,
} from "./otel/otel.js";
// Server
export { listenHttp } from "./server/health-listener.js";
export {
type PeerEventsBinding,
type PeerEventsContext,
unwirePeerEvents,
wirePeerEvents,
} from "./server/peer-events.js";
export { Libp2pServer } from "./server/server.js";
// Types
export type {
Libp2pServerConfig,
Libp2pServerEventMap,
Libp2pServerFactory,
Libp2pServerFactoryConfig,
Libp2pServerNodeLike,
Libp2pServerPeerConnectedEvent,
Libp2pServerPeerDisconnectedEvent,
Libp2pServerRelayStreamEvent,
Libp2pServerStartedEvent,
Libp2pServerStartResult,
Libp2pServerStoppedEvent,
ResolvedLibp2pServerConfig,
} from "./types/types.js";
// Schemas
export { Libp2pServerConfigSchema } from "./types/types.schemas.js";

View File

@@ -0,0 +1,75 @@
# `otel/otel.ts` — OTel instrumentation surface
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Centralises every OpenTelemetry instrument the libp2p relay daemon
emits. The daemon never touches `@opentelemetry/api` directly — it
always calls through the helpers in this module so the instrument names
are owned in exactly one place and dashboards stay stable across
refactors. Operators wire an OTel SDK once at process start; the
suaveplan no-op default applies otherwise so the package stays
zero-overhead by default.
## Features
- Two named spans: `libp2p_server.start`, `libp2p_server.stop`.
- One counter `libp2p_server.connections_total{outcome}` covering
`connected`/`disconnected`/`rejected` per-peer events.
- One counter `libp2p_server.relayed_streams_total` for circuit-relay-v2
stream brokering events.
- One up-down counter `libp2p_server.active_peers` tracking live peer
count.
- One histogram `libp2p_server.start_duration_ms` recording `start()`
wall-clock duration.
## Usage: basic example
```ts
import {
recordConnection,
adjustActivePeers,
} from "@suaveplan/network-libp2p-server/otel";
recordConnection("connected");
adjustActivePeers(+1);
```
## Usage: advanced example
```ts
import {
withStartSpan,
recordStartDuration,
recordRelayedStream,
} from "@suaveplan/network-libp2p-server/otel";
await withStartSpan(async (span) => {
span.setAttribute("listen", listen.join(","));
const t0 = Date.now();
await node.start();
recordStartDuration(Date.now() - t0);
});
// On a relayed stream:
recordRelayedStream();
```
## API reference
- `withStartSpan<T>(fn) => Promise<T>` — wraps `fn` in an active span.
- `withStopSpan<T>(fn) => Promise<T>` — wraps `fn` in an active span.
- `recordConnection(outcome)` — `connected | disconnected | rejected`.
- `recordRelayedStream()` — incremented per brokered relay stream.
- `adjustActivePeers(delta)` — `+1` or `-1` on connection events.
- `recordStartDuration(ms)` — non-negative ms.
## Implementation notes
The `recordStartDuration` helper clamps negative values to zero so
caller-side `Date.now()` clock skew never corrupts the histogram. All
labels are low-cardinality enums; no peer-id or multiaddr appears in
any metric attribute so we don't blow up the metrics backend cardinality
budget when the relay is busy.

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
adjustActivePeers,
recordConnection,
recordRelayedStream,
recordStartDuration,
withStartSpan,
withStopSpan,
} from "./otel.js";
describe("OTel helpers", () => {
it("withStartSpan runs the body and returns its value", {
timeout: 5000,
}, async () => {
const v = await withStartSpan(async () => 42);
expect(v).toBe(42);
});
it("withStartSpan ends the span even on throw", {
timeout: 5000,
}, async () => {
await expect(
withStartSpan(async () => {
throw new Error("boom");
})
).rejects.toThrow("boom");
});
it("withStopSpan runs the body and returns its value", {
timeout: 5000,
}, async () => {
const v = await withStopSpan(async () => "x");
expect(v).toBe("x");
});
it("withStopSpan ends the span even on throw", {
timeout: 5000,
}, async () => {
await expect(
withStopSpan(async () => {
throw new Error("oh-no");
})
).rejects.toThrow("oh-no");
});
it("counter + gauge + histogram helpers run without throwing", {
timeout: 5000,
}, () => {
expect(() => recordConnection("connected")).not.toThrow();
expect(() => recordConnection("disconnected")).not.toThrow();
expect(() => recordConnection("rejected")).not.toThrow();
expect(() => recordRelayedStream()).not.toThrow();
expect(() => adjustActivePeers(+1)).not.toThrow();
expect(() => adjustActivePeers(-1)).not.toThrow();
expect(() => adjustActivePeers(0)).not.toThrow();
expect(() => recordStartDuration(123)).not.toThrow();
expect(() => recordStartDuration(-5)).not.toThrow();
});
});

View File

@@ -0,0 +1,99 @@
import { metrics, type Span, type Tracer, trace } from "@opentelemetry/api";
const TRACER_NAME = "@suaveplan/network-libp2p-server";
const METER_NAME = "@suaveplan/network-libp2p-server";
const tracer: Tracer = trace.getTracer(TRACER_NAME);
const meter = metrics.getMeter(METER_NAME);
/**
* Counter incremented every time a libp2p peer attaches or detaches
* from the relay, labelled by outcome.
*/
const connectionsCounter = meter.createCounter(
"libp2p_server.connections_total",
{
description:
"Per-peer connection lifecycle events handled by the libp2p relay daemon.",
}
);
/**
* Counter incremented every time the relay services a circuit-relay-v2
* stream between two peers.
*/
const relayedStreamsCounter = meter.createCounter(
"libp2p_server.relayed_streams_total",
{
description:
"Circuit-relay-v2 streams the daemon brokered between client peers.",
}
);
/** Up-down counter tracking the live number of connected peers. */
const activePeersGauge = meter.createUpDownCounter(
"libp2p_server.active_peers",
{
description: "Currently connected libp2p peers.",
}
);
/** Histogram of `start()` wall-clock duration in milliseconds. */
const startDurationHistogram = meter.createHistogram(
"libp2p_server.start_duration_ms",
{
description:
"Wall-clock duration of each Libp2pServer.start() call, in ms.",
unit: "ms",
}
);
/** Wrap the `start()` body in an OTel span. */
export async function withStartSpan<T>(
fn: (span: Span) => Promise<T>
): Promise<T> {
return tracer.startActiveSpan("libp2p_server.start", async (span) => {
try {
return await fn(span);
} finally {
span.end();
}
});
}
/** Wrap the `stop()` body in an OTel span. */
export async function withStopSpan<T>(
fn: (span: Span) => Promise<T>
): Promise<T> {
return tracer.startActiveSpan("libp2p_server.stop", async (span) => {
try {
return await fn(span);
} finally {
span.end();
}
});
}
/** Outcome label for {@link recordConnection}. */
export type ConnectionOutcome = "connected" | "disconnected" | "rejected";
/** Increment the connections counter. */
export function recordConnection(outcome: ConnectionOutcome): void {
connectionsCounter.add(1, { outcome });
}
/** Increment the relayed-streams counter. */
export function recordRelayedStream(): void {
relayedStreamsCounter.add(1);
}
/** Adjust the active-peers gauge by `delta` (+1 / -1). */
export function adjustActivePeers(delta: number): void {
if (delta === 0) return;
activePeersGauge.add(delta);
}
/** Record one observation of `start()` wall-clock duration. */
export function recordStartDuration(ms: number): void {
startDurationHistogram.record(Math.max(0, ms));
}

View File

@@ -0,0 +1,62 @@
# `server/health-listener.ts` — inline health HTTP bind helper
## Purpose
Wraps the asynchronous `http.Server.listen(port, host)` flow with the
listener-attach / `EADDRINUSE` classification / `AbortSignal` plumbing
the libp2p relay daemon needs around its inline health HTTP listener.
Extracted as a sibling module to keep `server.ts` under the 300-line
hard cap mandated by CLAUDE.md while preserving the deterministic
error-code mapping callers depend on.
## Features
- Resolves once the `listening` event fires.
- Rejects with `NET_LIBP2P_SERVER.LISTEN_FAILED` + `messageKey =
"libp2p_server.port-in-use"` when the bind fails with `EADDRINUSE`.
- Rejects with `NET_LIBP2P_SERVER.LISTEN_FAILED` + `messageKey =
"libp2p_server.listen-failed"` for every other errno.
- Rejects with `NET_LIBP2P_SERVER.START_FAILED` + `messageKey =
"libp2p_server.aborted"` when a supplied `AbortSignal` fires before
the `listening` event, after closing the half-bound socket
best-effort.
- Cleans up its own listeners on every exit path so the `http.Server`
doesn't leak handlers when the daemon retries with a different port.
## Usage: basic example
```ts
import { listenHttp } from "@suaveplan/network-libp2p-server";
import { createServer } from "node:http";
const http = createServer();
await listenHttp(http, 9091, "127.0.0.1", undefined);
```
## Usage: advanced example
```ts
const ctrl = new AbortController();
queueMicrotask(() => ctrl.abort());
try {
await listenHttp(http, 0, "0.0.0.0", ctrl.signal);
} catch (e) {
if (e instanceof NetLibp2pServerError) {
// Match `code` to discriminate listen vs abort failure paths.
}
}
```
## API reference
- `listenHttp(http, port, host, signal?) => Promise<void>` — resolves
on `listening`, rejects per the mapping above.
## Implementation notes
The helper is responsible only for the listen lifecycle of the inline
health HTTP server — the libp2p node lifecycle is owned by the daemon
itself, so caller-side rollback (stopping the libp2p node on a failed
health-port bind) lives in `server.ts`. All listener attachments use
`once` so reject and resolve paths cannot fire twice and the `http`
instance never retains stale callbacks after this Promise settles.

View File

@@ -0,0 +1,108 @@
import { createServer } from "node:http";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NET_LIBP2P_SERVER_CODES } from "../errors/errors.js";
import { listenHttp } from "./health-listener.js";
describe("listenHttp", () => {
it("resolves on listening event", { timeout: 5000 }, async () => {
const http = createServer();
try {
await listenHttp(http, 0, "127.0.0.1", undefined);
const addr = http.address() as { port: number };
expect(addr.port).toBeGreaterThan(0);
} finally {
await new Promise<void>((r) => http.close(() => r()));
}
});
it("maps EADDRINUSE to LISTEN_FAILED", { timeout: 8000 }, async () => {
const a = createServer();
await listenHttp(a, 0, "127.0.0.1", undefined);
const port = (a.address() as { port: number }).port;
const b = createServer();
try {
await expect(
listenHttp(b, port, "127.0.0.1", undefined)
).rejects.toMatchObject({
code: NET_LIBP2P_SERVER_CODES.LISTEN_FAILED,
});
} finally {
await new Promise<void>((r) => a.close(() => r()));
await new Promise<void>((r) => b.close(() => r()));
}
});
it("maps non-EADDRINUSE errno to LISTEN_FAILED", {
timeout: 5000,
}, async () => {
// Trigger the generic-errno branch deterministically across
// hosts by emitting a synthetic non-EADDRINUSE error on the
// server — listening to `203.0.113.99` was OS-dependent and
// some runtimes (Bun under turbo) didn't surface a network
// error consistently.
const http = createServer();
const promise = listenHttp(http, 0, "127.0.0.1", undefined);
queueMicrotask(() => {
const err: NodeJS.ErrnoException = new Error("synthetic");
err.code = "EACCES";
http.emit("error", err);
});
try {
await expect(promise).rejects.toMatchObject({
code: NET_LIBP2P_SERVER_CODES.LISTEN_FAILED,
});
} finally {
await new Promise<void>((r) => http.close(() => r()));
}
});
it("aborts when signal fires mid-bind", { timeout: 5000 }, async () => {
const http = createServer();
const ctrl = new AbortController();
queueMicrotask(() => ctrl.abort());
try {
await expect(
listenHttp(http, 0, "127.0.0.1", ctrl.signal)
).rejects.toMatchObject({
code: NET_LIBP2P_SERVER_CODES.START_FAILED,
});
} finally {
await new Promise<void>((r) => http.close(() => r()));
}
});
it("maps EADDRINUSE to LISTEN_FAILED when a signal is also attached", {
timeout: 8000,
}, async () => {
const a = createServer();
await listenHttp(a, 0, "127.0.0.1", undefined);
const port = (a.address() as { port: number }).port;
const b = createServer();
const ctrl = new AbortController();
try {
await expect(
listenHttp(b, port, "127.0.0.1", ctrl.signal)
).rejects.toMatchObject({
code: NET_LIBP2P_SERVER_CODES.LISTEN_FAILED,
});
} finally {
await new Promise<void>((r) => a.close(() => r()));
await new Promise<void>((r) => b.close(() => r()));
}
});
it("succeeds with a non-aborted signal attached", {
timeout: 5000,
}, async () => {
const http = createServer();
const ctrl = new AbortController();
try {
await listenHttp(http, 0, "127.0.0.1", ctrl.signal);
expect((http.address() as { port: number }).port).toBeGreaterThan(
0
);
} finally {
await new Promise<void>((r) => http.close(() => r()));
}
});
});

View File

@@ -0,0 +1,74 @@
import type { Server as HttpServer } from "node:http";
import {
NET_LIBP2P_SERVER_CODES,
NetLibp2pServerError,
} from "../errors/errors.js";
/**
* Bind a Node `http.Server` to `(port, host)` and resolve once the
* `listening` event fires. Maps `EADDRINUSE` to
* `NET_LIBP2P_SERVER.LISTEN_FAILED` with `messageKey =
* "libp2p_server.port-in-use"`, every other errno to a generic
* `LISTEN_FAILED`, and a fired `AbortSignal` to `START_FAILED` with
* `messageKey = "libp2p_server.aborted"` so callers can roll back the
* libp2p node and unwind cleanly. Extracted from `server.ts` to keep
* that module under the 300-line cap.
*/
export function listenHttp(
http: HttpServer,
port: number,
host: string,
signal: AbortSignal | undefined
): Promise<void> {
return new Promise<void>((resolve, reject) => {
const onError = (err: NodeJS.ErrnoException): void => {
http.removeListener("listening", onListening);
if (signal !== undefined) {
signal.removeEventListener("abort", onAbort);
}
if (err.code === "EADDRINUSE") {
reject(
new NetLibp2pServerError(
NET_LIBP2P_SERVER_CODES.LISTEN_FAILED,
"libp2p_server.port-in-use",
{ cause: err, context: { port } }
)
);
return;
}
reject(
new NetLibp2pServerError(
NET_LIBP2P_SERVER_CODES.LISTEN_FAILED,
"libp2p_server.listen-failed",
{ cause: err }
)
);
};
const onListening = (): void => {
http.removeListener("error", onError);
if (signal !== undefined) {
signal.removeEventListener("abort", onAbort);
}
resolve();
};
const onAbort = (): void => {
http.removeListener("error", onError);
http.removeListener("listening", onListening);
http.close(() => {
/* best-effort */
});
reject(
new NetLibp2pServerError(
NET_LIBP2P_SERVER_CODES.START_FAILED,
"libp2p_server.aborted"
)
);
};
http.once("error", onError);
http.once("listening", onListening);
if (signal !== undefined) {
signal.addEventListener("abort", onAbort, { once: true });
}
http.listen(port, host);
});
}

View File

@@ -0,0 +1,70 @@
# `server/peer-events.ts` — peer connect/disconnect bridging
## Purpose
Wires the `peer:connect` and `peer:disconnect` event listeners onto a
`Libp2pServerNodeLike` and bridges them onto the daemon's internal peer
set, the OTel `libp2p_server.connections_total` counter / `active_peers`
gauge, and the optional `EventBusLike` lifecycle surface. Extracted from
`server.ts` to keep that module under the 300-line hard cap mandated by
CLAUDE.md.
## Features
- `wirePeerEvents(node, ctx)` — attaches both listeners and returns the
bound functions for later removal.
- `unwirePeerEvents(node, binding)` — removes previously-bound listeners
on graceful stop. Tolerates `undefined` for both arguments so the
daemon can call it unconditionally on `stop()`.
- Enforces `maxConnections` at the wire boundary — when a connect event
would push the peer set past the cap, the listener increments the
`connections_total{outcome="rejected"}` counter and bails without
recording the peer.
## Usage: basic example
```ts
import {
wirePeerEvents,
unwirePeerEvents,
} from "@suaveplan/network-libp2p-server";
const binding = wirePeerEvents(node, {
peers,
maxConnections: 1000,
emit: (topic, payload) => bus.emit(topic, payload),
});
// ... later, on shutdown
unwirePeerEvents(node, binding);
```
## Usage: advanced example
```ts
const ctx: PeerEventsContext = {
peers: new Set<string>(),
maxConnections: 100,
emit: (topic, payload) => {
if (topic === "libp2p_server:peer-connected") {
console.log("peer joined", (payload as { peerId: string }).peerId);
}
bus.emit(topic, payload);
},
};
const binding = wirePeerEvents(node, ctx);
```
## API reference
- `wirePeerEvents(node, ctx) => PeerEventsBinding` — `ctx` is `{ peers,
maxConnections, emit }`. Returns `{ onConnect, onDisconnect }`.
- `unwirePeerEvents(node?, binding?) => void` — idempotent.
## Implementation notes
The connect handler is a no-op when the peer id is already tracked so
duplicate `peer:connect` events from libp2p's internal dial fan-out
don't double-count. The disconnect handler is symmetric — only known
peers cause counter decrement. Both listeners derive the peer id via
`String(event.detail.toString())` so any structurally compatible peer-id
object satisfies the binding without leaking the concrete `PeerId` type.

View File

@@ -0,0 +1,154 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FakeLibp2pHub,
makeFakeServerFactory,
} from "../../tests/setup/fake-libp2p-server.js";
import type {
Libp2pServerEventMap,
Libp2pServerNodeLike,
} from "../types/types.js";
import {
type PeerEventsContext,
unwirePeerEvents,
wirePeerEvents,
} from "./peer-events.js";
async function makeNode(): Promise<Libp2pServerNodeLike> {
const hub = new FakeLibp2pHub();
const { factory } = makeFakeServerFactory(hub);
return factory({
listen: ["/ip4/0.0.0.0/tcp/0"],
bootstrap: [],
autoRelay: { enabled: false, maxConnections: 100 },
maxConnections: 100,
protocolId: "/test/1.0.0",
});
}
function makeCtx(maxConnections: number): {
readonly ctx: PeerEventsContext;
readonly events: Array<{
readonly topic: keyof Libp2pServerEventMap;
readonly peerId: string;
}>;
} {
const events: Array<{
readonly topic: keyof Libp2pServerEventMap;
readonly peerId: string;
}> = [];
const peers = new Set<string>();
const ctx: PeerEventsContext = {
peers,
maxConnections,
emit(topic, payload) {
events.push({
topic,
peerId: (payload as { peerId: string }).peerId,
});
},
};
return { ctx, events };
}
describe("wirePeerEvents", () => {
it("tracks a peer:connect event and emits peer-connected", {
timeout: 5000,
}, async () => {
const node = await makeNode();
const { ctx, events } = makeCtx(100);
wirePeerEvents(node, ctx);
node.addEventListener; // sanity — fake exists
// Trigger via the fake's mark-connected hook.
(node as unknown as { markConnected(id: string): void }).markConnected(
"peer-X"
);
expect(ctx.peers.has("peer-X")).toBe(true);
expect(events.map((e) => e.topic)).toContain(
"libp2p_server:peer-connected"
);
});
it("dedups duplicate peer:connect events", { timeout: 5000 }, async () => {
const node = await makeNode();
const { ctx, events } = makeCtx(100);
wirePeerEvents(node, ctx);
const cast = node as unknown as {
markConnected(id: string): void;
forceEmitConnect(id: string): void;
};
cast.markConnected("peer-X");
cast.forceEmitConnect("peer-X"); // second emit bypasses fake's dedup
expect(ctx.peers.size).toBe(1);
const connectEvents = events.filter(
(e) => e.topic === "libp2p_server:peer-connected"
);
expect(connectEvents).toHaveLength(1);
});
it("rejects connects past maxConnections", { timeout: 5000 }, async () => {
const node = await makeNode();
const { ctx, events } = makeCtx(1);
wirePeerEvents(node, ctx);
const cast = node as unknown as { forceEmitConnect(id: string): void };
cast.forceEmitConnect("peer-A");
cast.forceEmitConnect("peer-B");
expect(ctx.peers.size).toBe(1);
const connectEvents = events.filter(
(e) => e.topic === "libp2p_server:peer-connected"
);
expect(connectEvents).toHaveLength(1);
});
it("emits peer-disconnected when peer:disconnect fires", {
timeout: 5000,
}, async () => {
const node = await makeNode();
const { ctx, events } = makeCtx(100);
wirePeerEvents(node, ctx);
const cast = node as unknown as {
markConnected(id: string): void;
dropPeer(id: string): void;
};
cast.markConnected("peer-Y");
cast.dropPeer("peer-Y");
expect(ctx.peers.has("peer-Y")).toBe(false);
expect(events.map((e) => e.topic)).toContain(
"libp2p_server:peer-disconnected"
);
});
it("ignores peer:disconnect for unknown peers", {
timeout: 5000,
}, async () => {
const node = await makeNode();
const { ctx, events } = makeCtx(100);
wirePeerEvents(node, ctx);
const cast = node as unknown as {
forceEmitDisconnect(id: string): void;
};
cast.forceEmitDisconnect("ghost-peer");
expect(ctx.peers.size).toBe(0);
const disconnectEvents = events.filter(
(e) => e.topic === "libp2p_server:peer-disconnected"
);
expect(disconnectEvents).toHaveLength(0);
});
});
describe("unwirePeerEvents", () => {
it("removes both listeners so further events are ignored", {
timeout: 5000,
}, async () => {
const node = await makeNode();
const { ctx, events } = makeCtx(100);
const binding = wirePeerEvents(node, ctx);
unwirePeerEvents(node, binding);
const cast = node as unknown as { forceEmitConnect(id: string): void };
cast.forceEmitConnect("peer-Z");
expect(events).toHaveLength(0);
});
it("tolerates undefined node / binding", { timeout: 5000 }, () => {
expect(() => unwirePeerEvents(undefined, undefined)).not.toThrow();
});
});

View File

@@ -0,0 +1,78 @@
import { adjustActivePeers, recordConnection } from "../otel/otel.js";
import type {
Libp2pServerEventMap,
Libp2pServerNodeLike,
} from "../types/types.js";
/**
* Wires `peer:connect` / `peer:disconnect` listeners onto a
* `Libp2pServerNodeLike` and bridges them onto the daemon's internal
* peer set + OTel surface + optional event bus. Returns the bound
* listeners so the daemon can later remove them in `stop()` without
* keeping the closures alive on its own `this`.
*
* Extracted as a sibling module to keep `server.ts` under the 300-line
* hard cap.
*/
export interface PeerEventsContext {
readonly peers: Set<string>;
readonly maxConnections: number;
emit<K extends keyof Libp2pServerEventMap>(
topic: K,
payload: Libp2pServerEventMap[K]
): void;
}
export interface PeerEventsBinding {
readonly onConnect: (event: { detail: unknown }) => void;
readonly onDisconnect: (event: { detail: unknown }) => void;
}
/**
* Construct and attach the two peer-event listeners. Caller is
* responsible for calling {@link unwirePeerEvents} on `stop()`.
*/
export function wirePeerEvents(
node: Libp2pServerNodeLike,
ctx: PeerEventsContext
): PeerEventsBinding {
const onConnect = (event: { detail: unknown }): void => {
const id = String((event.detail as { toString(): string }).toString());
if (ctx.peers.has(id)) return;
if (ctx.peers.size >= ctx.maxConnections) {
recordConnection("rejected");
return;
}
ctx.peers.add(id);
adjustActivePeers(+1);
recordConnection("connected");
ctx.emit("libp2p_server:peer-connected", {
peerId: id,
at: Date.now(),
});
};
const onDisconnect = (event: { detail: unknown }): void => {
const id = String((event.detail as { toString(): string }).toString());
if (!ctx.peers.has(id)) return;
ctx.peers.delete(id);
adjustActivePeers(-1);
recordConnection("disconnected");
ctx.emit("libp2p_server:peer-disconnected", {
peerId: id,
at: Date.now(),
});
};
node.addEventListener("peer:connect", onConnect);
node.addEventListener("peer:disconnect", onDisconnect);
return { onConnect, onDisconnect };
}
/** Remove peer-event listeners previously attached by {@link wirePeerEvents}. */
export function unwirePeerEvents(
node: Libp2pServerNodeLike | undefined,
binding: PeerEventsBinding | undefined
): void {
if (node === undefined || binding === undefined) return;
node.removeEventListener("peer:connect", binding.onConnect);
node.removeEventListener("peer:disconnect", binding.onDisconnect);
}

View File

@@ -0,0 +1,105 @@
# `server/server.ts` — `Libp2pServer` daemon
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Hosts a libp2p server node — typically configured as a bootstrap +
circuit-relay-v2 relay — paired with an inline HTTP listener exposing
the `/health` / `/ready` / `/live` verbs. Browser-tier clients dial the
libp2p node via `@suaveplan/network-transport-libp2p`. The daemon is
designed for in-process integration testing as well as production
deployment: every dependency on the real `libp2p` runtime flows through
the injected `libp2pFactory`, so test environments can wire a
deterministic `FakeLibp2pServerNode` without pulling native transitive
deps into the test surface.
## Features
- Idempotent lifecycle: `start({ signal? })` resolves with
`{ peerId, multiaddrs, healthAddress, healthPort }`; `stop()` is safe
to call repeatedly and drains tracked peers via the OTel surface.
- Pre-aborted signal: `start({ signal })` with an already-aborted
signal short-circuits to `START_FAILED` before any side effects.
- Required injectable `libp2pFactory` — the package never resolves the
`libp2p` module at evaluation time.
- Inline health HTTP listener (`/health`, `/ready`, `/live`) bound to
`healthHost:healthPort`. Falls through to a plaintext `"libp2p relay"`
marker on non-health routes.
- Auto-relay configuration forwarded to the factory via
`Libp2pServerFactoryConfig.autoRelay`.
- Strict Zod validation of `Libp2pServerConfig` at the constructor
boundary — invalid options throw `INVALID_OPTIONS` with the Zod
issue list attached for diagnostics.
- Peer-event bridging (`peer:connect` / `peer:disconnect`) into the
internal peer set, OTel counters/gauge, and optional `EventBusLike`.
- Hand-off helper `observeRelayedStream(peerId)` so consumers wiring a
custom circuit-relay-v2 stream handler in the factory body keep the
`libp2p_server.relayed_streams_total` counter accurate.
## Usage: basic example
```ts
import { Libp2pServer } from "@suaveplan/network-libp2p-server";
const server = new Libp2pServer({
libp2pFactory: async (cfg) => createLibp2p({
addresses: { listen: cfg.listen },
// ... compose transports/encrypters/muxers/services here
}),
listen: ["/ip4/0.0.0.0/tcp/0", "/ip4/0.0.0.0/tcp/0/ws"],
healthPort: 9091,
});
const { peerId, multiaddrs } = await server.start();
console.log(`relay ${peerId} on ${multiaddrs.join(", ")}`);
// later
await server.stop();
```
## Usage: advanced example
```ts
const ctrl = new AbortController();
const server = new Libp2pServer({
libp2pFactory: myFactory,
listen: ["/ip4/0.0.0.0/tcp/9090"],
bootstrap: ["/dnsaddr/bootstrap.libp2p.io"],
autoRelay: { enabled: true, maxConnections: 500 },
maxConnections: 5000,
protocolId: "/myapp/1.0.0",
healthPort: 9091,
bus,
});
await server.start({ signal: ctrl.signal });
// On a circuit-relay-v2 stream brokered between two clients:
server.observeRelayedStream("12D3KooW...client");
```
## API reference
- `new Libp2pServer(config)` — validates `config`; throws
`INVALID_OPTIONS` on Zod failure or missing `libp2pFactory`.
- `server.start(options?) => Promise<Libp2pServerStartResult>` — throws
`DISPOSED`, `START_FAILED`, or `LISTEN_FAILED`.
- `server.stop() => Promise<void>` — idempotent.
- `server.observeRelayedStream(peerId)` — increments OTel counter and
emits `libp2p_server:relay-stream`.
- `server.snapshot() => HealthSnapshot` — for health-probe responses.
- `server.peers()` / `server.activePeers` — observed peers.
- `server.config` — resolved config snapshot.
- `server.healthAddress` / `server.healthPort` — bound HTTP listener
inspectors.
## Implementation notes
The libp2p node lifecycle and the inline-HTTP lifecycle are wound
independently: a successful libp2p start followed by a failed HTTP bind
rolls the libp2p node back via `stop()` so the daemon never leaks a
half-running node. `stop()` reverses the wiring in the opposite order
(unwire listeners, stop node, drain peers, close HTTP) and is wrapped
in a try-around-`node.stop()` so half-started instances cannot poison
the disposed state. The `#runStart` helper exists purely to keep the
public `start()` method short enough for the discipline-metrics cap on
this file (≤300 LOC).

View File

@@ -0,0 +1,384 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike } from "@suaveplan/types";
import {
FakeLibp2pHub,
FakeLibp2pServerNode,
makeFakeServerFactory,
} from "../../tests/setup/fake-libp2p-server.js";
import {
NET_LIBP2P_SERVER_CODES,
NetLibp2pServerError,
} from "../errors/errors.js";
import type {
Libp2pServerConfig,
Libp2pServerEventMap,
} from "../types/types.js";
import { Libp2pServer } from "./server.js";
function makeRecordingBus(): {
readonly bus: EventBusLike<Libp2pServerEventMap>;
readonly events: Array<{ readonly topic: keyof Libp2pServerEventMap }>;
} {
const events: Array<{ readonly topic: keyof Libp2pServerEventMap }> = [];
const bus: EventBusLike<Libp2pServerEventMap> = {
on: () => () => {},
emit: (topic) => {
events.push({ topic });
},
};
return { bus, events };
}
function makeThrowingBus(): EventBusLike<Libp2pServerEventMap> {
return {
on: () => () => {},
emit: () => {
throw new Error("bus oops");
},
};
}
function baseConfig(
overrides: Partial<Libp2pServerConfig> = {}
): Libp2pServerConfig {
const hub = new FakeLibp2pHub();
const { factory } = makeFakeServerFactory(hub);
return {
libp2pFactory: factory,
listen: ["/ip4/0.0.0.0/tcp/0"],
healthPort: 0,
healthHost: "127.0.0.1",
...overrides,
};
}
describe("Libp2pServer constructor", () => {
it("throws INVALID_OPTIONS on bad listen", { timeout: 5000 }, () => {
try {
new Libp2pServer({
libp2pFactory: async () => {
throw new Error("unused");
},
listen: [],
healthPort: 0,
});
throw new Error("should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(NetLibp2pServerError);
expect((e as NetLibp2pServerError).code).toBe(
NET_LIBP2P_SERVER_CODES.INVALID_OPTIONS
);
}
});
it("throws INVALID_OPTIONS when libp2pFactory is missing", {
timeout: 5000,
}, () => {
const bad: unknown = "nope";
try {
new Libp2pServer({
libp2pFactory: bad as Libp2pServerConfig["libp2pFactory"],
listen: ["/ip4/0.0.0.0/tcp/0"],
healthPort: 0,
});
throw new Error("should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(NetLibp2pServerError);
expect((e as NetLibp2pServerError).code).toBe(
NET_LIBP2P_SERVER_CODES.INVALID_OPTIONS
);
}
});
it("exposes the resolved config", { timeout: 5000 }, () => {
const s = new Libp2pServer(baseConfig({ maxConnections: 50 }));
expect(s.config.maxConnections).toBe(50);
expect(s.config.protocolId).toBe("/suaveplan/network-server/1.0.0");
expect(s.config.healthHost).toBe("127.0.0.1");
expect(s.config.autoRelay.enabled).toBe(false);
expect(s.config.autoRelay.maxConnections).toBe(100);
});
it("resolves autoRelay defaults when provided", { timeout: 5000 }, () => {
const s = new Libp2pServer(
baseConfig({ autoRelay: { enabled: true } })
);
expect(s.config.autoRelay.enabled).toBe(true);
expect(s.config.autoRelay.maxConnections).toBe(100);
});
it("preserves autoRelay maxConnections override", {
timeout: 5000,
}, () => {
const s = new Libp2pServer(
baseConfig({
autoRelay: { enabled: true, maxConnections: 7 },
})
);
expect(s.config.autoRelay.maxConnections).toBe(7);
});
});
describe("Libp2pServer lifecycle", () => {
it("start → snapshot → stop round-trip", { timeout: 5000 }, async () => {
const server = new Libp2pServer(baseConfig());
const res = await server.start();
expect(typeof res.peerId).toBe("string");
expect(res.multiaddrs.length).toBeGreaterThan(0);
expect(res.healthPort).toBeGreaterThan(0);
expect(server.healthAddress).toBeDefined();
expect(server.healthPort).toBeGreaterThan(0);
const snap = server.snapshot();
expect(snap.running).toBe(true);
expect(snap.disposed).toBe(false);
expect(snap.peerId).toBe(res.peerId);
await server.stop();
const s2 = server.snapshot();
expect(s2.running).toBe(false);
expect(s2.disposed).toBe(true);
});
it("stop is idempotent", { timeout: 5000 }, async () => {
const server = new Libp2pServer(baseConfig());
await server.start();
await server.stop();
await server.stop();
await server.stop();
expect(server.snapshot().disposed).toBe(true);
});
it("rejects start() after stop()", { timeout: 5000 }, async () => {
const server = new Libp2pServer(baseConfig());
await server.start();
await server.stop();
await expect(server.start()).rejects.toMatchObject({
code: NET_LIBP2P_SERVER_CODES.DISPOSED,
});
});
it("rejects second start() while running", { timeout: 5000 }, async () => {
const server = new Libp2pServer(baseConfig());
await server.start();
await expect(server.start()).rejects.toMatchObject({
code: NET_LIBP2P_SERVER_CODES.START_FAILED,
});
await server.stop();
});
it("rejects start() with pre-aborted signal", {
timeout: 5000,
}, async () => {
const server = new Libp2pServer(baseConfig());
const ctrl = new AbortController();
ctrl.abort();
await expect(
server.start({ signal: ctrl.signal })
).rejects.toMatchObject({
code: NET_LIBP2P_SERVER_CODES.START_FAILED,
});
});
it("propagates a factory throw as START_FAILED", {
timeout: 5000,
}, async () => {
const server = new Libp2pServer({
libp2pFactory: async () => {
throw new Error("factory boom");
},
listen: ["/ip4/0.0.0.0/tcp/0"],
healthPort: 0,
healthHost: "127.0.0.1",
});
await expect(server.start()).rejects.toMatchObject({
code: NET_LIBP2P_SERVER_CODES.START_FAILED,
});
await server.stop();
});
it("propagates a node.start throw as START_FAILED", {
timeout: 5000,
}, async () => {
const hub = new FakeLibp2pHub();
const { factory } = makeFakeServerFactory(hub, { startThrows: true });
const server = new Libp2pServer({
libp2pFactory: factory,
listen: ["/ip4/0.0.0.0/tcp/0"],
healthPort: 0,
healthHost: "127.0.0.1",
});
await expect(server.start()).rejects.toMatchObject({
code: NET_LIBP2P_SERVER_CODES.START_FAILED,
});
await server.stop();
});
it("surfaces LISTEN_FAILED when health port collides", {
timeout: 8000,
}, async () => {
const a = new Libp2pServer(baseConfig());
const r = await a.start();
const b = new Libp2pServer(baseConfig({ healthPort: r.healthPort }));
try {
await expect(b.start()).rejects.toMatchObject({
code: NET_LIBP2P_SERVER_CODES.LISTEN_FAILED,
});
} finally {
await a.stop();
await b.stop();
}
});
it("rolls back the libp2p node when the health bind fails", {
timeout: 8000,
}, async () => {
const a = new Libp2pServer(baseConfig());
const r = await a.start();
const hub = new FakeLibp2pHub();
const { factory, instances } = makeFakeServerFactory(hub);
const b = new Libp2pServer({
libp2pFactory: factory,
listen: ["/ip4/0.0.0.0/tcp/0"],
healthPort: r.healthPort,
healthHost: "127.0.0.1",
});
try {
await expect(b.start()).rejects.toMatchObject({
code: NET_LIBP2P_SERVER_CODES.LISTEN_FAILED,
});
// The fake node should have been started then stopped.
expect(instances[0]?.started).toBe(false);
} finally {
await a.stop();
await b.stop();
}
});
it("starts with a non-aborted signal attached", {
timeout: 5000,
}, async () => {
const server = new Libp2pServer(baseConfig());
const ctrl = new AbortController();
const r = await server.start({ signal: ctrl.signal });
expect(r.healthPort).toBeGreaterThan(0);
await server.stop();
});
});
describe("Libp2pServer peer wiring", () => {
it("admits peers via peer:connect events", {
timeout: 5000,
}, async () => {
const { bus, events } = makeRecordingBus();
const hub = new FakeLibp2pHub();
const { factory, instances } = makeFakeServerFactory(hub);
const server = new Libp2pServer({
libp2pFactory: factory,
listen: ["/ip4/0.0.0.0/tcp/0"],
healthPort: 0,
healthHost: "127.0.0.1",
bus,
});
await server.start();
const node = instances[0];
if (!(node instanceof FakeLibp2pServerNode)) {
throw new Error("expected fake node");
}
node.markConnected("client-A");
expect(server.activePeers).toBe(1);
expect(server.peers()).toContain("client-A");
expect(events.map((e) => e.topic)).toContain(
"libp2p_server:peer-connected"
);
node.dropPeer("client-A");
expect(server.activePeers).toBe(0);
expect(events.map((e) => e.topic)).toContain(
"libp2p_server:peer-disconnected"
);
await server.stop();
});
it("drains tracked peers on stop with disconnected emits", {
timeout: 5000,
}, async () => {
const { bus, events } = makeRecordingBus();
const hub = new FakeLibp2pHub();
const { factory, instances } = makeFakeServerFactory(hub);
const server = new Libp2pServer({
libp2pFactory: factory,
listen: ["/ip4/0.0.0.0/tcp/0"],
healthPort: 0,
healthHost: "127.0.0.1",
bus,
});
await server.start();
const node = instances[0];
if (!(node instanceof FakeLibp2pServerNode)) {
throw new Error("expected fake node");
}
node.markConnected("client-A");
await server.stop();
const disconnects = events.filter(
(e) => e.topic === "libp2p_server:peer-disconnected"
);
expect(disconnects.length).toBeGreaterThanOrEqual(1);
expect(events.map((e) => e.topic)).toContain("libp2p_server:stopped");
});
it("observeRelayedStream emits relay-stream", {
timeout: 5000,
}, async () => {
const { bus, events } = makeRecordingBus();
const server = new Libp2pServer(baseConfig({ bus }));
await server.start();
server.observeRelayedStream("client-X");
expect(events.map((e) => e.topic)).toContain(
"libp2p_server:relay-stream"
);
await server.stop();
});
});
describe("Libp2pServer bus error handling", () => {
it("swallows throws from a misbehaving bus", {
timeout: 5000,
}, async () => {
const bus = makeThrowingBus();
const hub = new FakeLibp2pHub();
const { factory, instances } = makeFakeServerFactory(hub);
const server = new Libp2pServer({
libp2pFactory: factory,
listen: ["/ip4/0.0.0.0/tcp/0"],
healthPort: 0,
healthHost: "127.0.0.1",
bus,
});
await server.start();
const node = instances[0];
if (!(node instanceof FakeLibp2pServerNode)) {
throw new Error("expected fake node");
}
node.markConnected("client-A");
await server.stop();
});
it("tolerates a node.stop throw silently in stop()", {
timeout: 5000,
}, async () => {
const hub = new FakeLibp2pHub();
const { factory, instances } = makeFakeServerFactory(hub);
const server = new Libp2pServer({
libp2pFactory: factory,
listen: ["/ip4/0.0.0.0/tcp/0"],
healthPort: 0,
healthHost: "127.0.0.1",
});
await server.start();
const node = instances[0];
if (!(node instanceof FakeLibp2pServerNode)) {
throw new Error("expected fake node");
}
node.stopThrows = true;
await server.stop();
expect(server.snapshot().disposed).toBe(true);
});
});

View File

@@ -0,0 +1,299 @@
// Server-tier package — uses node:http (Node-only). Browser consumers cannot use this package.
import { createServer, type Server as HttpServer } from "node:http";
import {
NET_LIBP2P_SERVER_CODES,
NetLibp2pServerError,
} from "../errors/errors.js";
import { type HealthSnapshot, handleHealth } from "../health/health.js";
import {
adjustActivePeers,
recordConnection,
recordRelayedStream,
recordStartDuration,
withStartSpan,
withStopSpan,
} from "../otel/otel.js";
import type {
Libp2pServerConfig,
Libp2pServerEventMap,
Libp2pServerFactory,
Libp2pServerNodeLike,
Libp2pServerStartResult,
ResolvedLibp2pServerConfig,
} from "../types/types.js";
import { Libp2pServerConfigSchema } from "../types/types.schemas.js";
import { listenHttp } from "./health-listener.js";
import {
type PeerEventsBinding,
unwirePeerEvents,
wirePeerEvents,
} from "./peer-events.js";
/**
* Server-tier libp2p relay daemon. Hosts a libp2p node configured as a
* bootstrap + circuit-relay-v2 server, alongside an inline HTTP listener
* exposing the `/health` / `/ready` / `/live` verbs. Browser-tier
* clients dial the libp2p node via `@suaveplan/network-transport-libp2p`.
*
* The daemon is **idempotent** on stop. `start()` rejects on second
* invocation; `stop()` is safe to call multiple times. Both honour an
* optional `AbortSignal`.
*/
export class Libp2pServer {
readonly #resolved: ResolvedLibp2pServerConfig;
#http: HttpServer | undefined;
#node: Libp2pServerNodeLike | undefined;
#binding: PeerEventsBinding | undefined;
#started = false;
#disposed = false;
#startedAt: number | undefined;
#healthAddress: string | undefined;
#healthPort: number | undefined;
#peerId: string | undefined;
readonly #peers = new Set<string>();
public constructor(config: Libp2pServerConfig) {
const parsed = Libp2pServerConfigSchema.safeParse(config);
if (!parsed.success) {
throw new NetLibp2pServerError(
NET_LIBP2P_SERVER_CODES.INVALID_OPTIONS,
"libp2p_server.invalid-config",
{ context: { issues: parsed.error.issues } }
);
}
if (typeof config.libp2pFactory !== "function") {
throw new NetLibp2pServerError(
NET_LIBP2P_SERVER_CODES.INVALID_OPTIONS,
"libp2p_server.factory-required"
);
}
const autoRelay = parsed.data.autoRelay ?? { enabled: false };
this.#resolved = {
libp2pFactory: config.libp2pFactory satisfies Libp2pServerFactory,
listen: parsed.data.listen,
bootstrap: parsed.data.bootstrap,
autoRelay: {
enabled: autoRelay.enabled,
maxConnections: autoRelay.maxConnections ?? 100,
},
maxConnections: parsed.data.maxConnections,
protocolId: parsed.data.protocolId,
healthPort: parsed.data.healthPort,
healthHost: parsed.data.healthHost,
bus: config.bus,
};
}
/** Resolved configuration snapshot. */
public get config(): ResolvedLibp2pServerConfig {
return this.#resolved;
}
/** Current live peer count. */
public get activePeers(): number {
return this.#peers.size;
}
/** Snapshot of the peer ids the daemon currently sees as connected. */
public peers(): readonly string[] {
return Array.from(this.#peers);
}
/** Read the daemon's runtime state for health-probe responses. */
public snapshot(): HealthSnapshot {
return {
running: this.#started,
disposed: this.#disposed,
activePeers: this.activePeers,
maxConnections: this.#resolved.maxConnections,
startedAt: this.#startedAt,
peerId: this.#peerId,
};
}
/**
* Boot the libp2p node, attach the inline health HTTP listener,
* and resolve with `{ peerId, multiaddrs, healthAddress, healthPort }`.
*
* @throws NET_LIBP2P_SERVER.DISPOSED — already stopped.
* @throws NET_LIBP2P_SERVER.START_FAILED — factory or node.start failed.
* @throws NET_LIBP2P_SERVER.LISTEN_FAILED — health-port bind failed.
*/
public async start(options?: {
readonly signal?: AbortSignal;
}): Promise<Libp2pServerStartResult> {
if (this.#disposed) {
throw new NetLibp2pServerError(
NET_LIBP2P_SERVER_CODES.DISPOSED,
"libp2p_server.start-after-stop"
);
}
if (this.#started) {
throw new NetLibp2pServerError(
NET_LIBP2P_SERVER_CODES.START_FAILED,
"libp2p_server.already-started"
);
}
const signal = options?.signal;
if (signal?.aborted === true) {
throw new NetLibp2pServerError(
NET_LIBP2P_SERVER_CODES.START_FAILED,
"libp2p_server.aborted"
);
}
const t0 = Date.now();
return withStartSpan(() => this.#runStart(signal, t0));
}
/**
* Drain in-flight connections, stop the libp2p node, close the
* health HTTP listener, and clear state. Safe to call repeatedly.
*/
public async stop(): Promise<void> {
if (this.#disposed) return;
return withStopSpan(async () => {
this.#disposed = true;
this.#started = false;
unwirePeerEvents(this.#node, this.#binding);
this.#binding = undefined;
await this.#stopNode();
this.#node = undefined;
const peerIds = Array.from(this.#peers);
this.#peers.clear();
for (const id of peerIds) {
adjustActivePeers(-1);
recordConnection("disconnected");
this.#emit("libp2p_server:peer-disconnected", {
peerId: id,
at: Date.now(),
});
}
await this.#closeHttp();
this.#emit("libp2p_server:stopped", { at: Date.now() });
});
}
/**
* Hand-emit a relay-stream observation so callers wiring a custom
* circuit-relay-v2 stream handler in the factory body can keep the
* `libp2p_server.relayed_streams_total` counter accurate.
*/
public observeRelayedStream(peerId: string): void {
recordRelayedStream();
this.#emit("libp2p_server:relay-stream", {
peerId,
at: Date.now(),
});
}
// ───────────────────────── private ─────────────────────────
async #runStart(
signal: AbortSignal | undefined,
t0: number
): Promise<Libp2pServerStartResult> {
try {
this.#node = await this.#resolved.libp2pFactory({
listen: this.#resolved.listen,
bootstrap: this.#resolved.bootstrap,
autoRelay: this.#resolved.autoRelay,
maxConnections: this.#resolved.maxConnections,
protocolId: this.#resolved.protocolId,
});
await this.#node.start();
} catch (cause) {
this.#node = undefined;
throw new NetLibp2pServerError(
NET_LIBP2P_SERVER_CODES.START_FAILED,
"libp2p_server.node-start-failed",
{ cause: cause as Error }
);
}
this.#binding = wirePeerEvents(this.#node, {
peers: this.#peers,
maxConnections: this.#resolved.maxConnections,
emit: (topic, payload) => this.#emit(topic, payload),
});
const http = createServer((req, res) => {
if (handleHealth(req, res, this.snapshot())) return;
res.writeHead(200, { "content-type": "text/plain" });
res.end("libp2p relay");
});
this.#http = http;
try {
await listenHttp(
http,
this.#resolved.healthPort,
this.#resolved.healthHost,
signal
);
} catch (err) {
unwirePeerEvents(this.#node, this.#binding);
this.#binding = undefined;
await this.#stopNode();
this.#node = undefined;
throw err;
}
const addr = http.address() as { address: string; port: number };
this.#healthAddress = addr.address;
this.#healthPort = addr.port;
this.#peerId = this.#node.peerId.toString();
this.#started = true;
this.#startedAt = Date.now();
const multiaddrs = this.#node.getMultiaddrs().map((m) => m.toString());
recordStartDuration(this.#startedAt - t0);
this.#emit("libp2p_server:started", {
peerId: this.#peerId,
multiaddrs,
at: this.#startedAt,
});
return {
peerId: this.#peerId,
multiaddrs,
healthAddress: this.#healthAddress,
healthPort: this.#healthPort,
};
}
async #closeHttp(): Promise<void> {
const http = this.#http;
if (!http) return;
this.#http = undefined;
await new Promise<void>((resolve) => {
http.close(() => resolve());
});
}
async #stopNode(): Promise<void> {
// Best-effort — half-started libp2p instances may throw.
try {
await this.#node?.stop();
} catch {
/* ignore */
}
}
#emit<K extends keyof Libp2pServerEventMap>(
topic: K,
payload: Libp2pServerEventMap[K]
): void {
const bus = this.#resolved.bus;
if (bus === undefined) return;
try {
bus.emit(topic, payload);
} catch {
// Bus failures must not crash the relay.
}
}
/** Bound inline-health-port address (after `start()` resolves). */
public get healthAddress(): string | undefined {
return this.#healthAddress;
}
/** Bound inline-health-port (after `start()` resolves). */
public get healthPort(): number | undefined {
return this.#healthPort;
}
}

View File

@@ -0,0 +1,75 @@
# `types/types.ts` — public type contracts
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines every TypeScript contract `@suaveplan/network-libp2p-server`
exposes at its public boundary. The daemon never depends on the concrete
`Libp2p` interface from the upstream `libp2p` package — only on the
structural `Libp2pServerNodeLike` shape declared here. This keeps the
relay's runtime cost zero on the test surface (no native transitive
deps pulled in) while still preserving full parity with the production
libp2p hosting story.
## Features
- `Libp2pServerNodeLike` structural interface — lifecycle + peer enum +
event listeners.
- `Libp2pServerFactory` / `Libp2pServerFactoryConfig` for injectable
factory wiring (production binds `createLibp2p({...})`, tests bind a
deterministic fake).
- Typed event map (`Libp2pServerEventMap`) covering start, stop,
peer-connect, peer-disconnect, and circuit-relay-v2 stream emissions.
- `Libp2pServerConfig` — strict callable boundary; required fields are
declared non-optional so consumers cannot silently fall through to an
implicit default.
- `ResolvedLibp2pServerConfig` — fully-defaulted snapshot returned via
the daemon's `config` getter for introspection.
## Usage: basic example
```ts
import type { Libp2pServerConfig } from "@suaveplan/network-libp2p-server";
const config: Libp2pServerConfig = {
libp2pFactory: async (cfg) => createLibp2p({ /* compose */ }),
listen: ["/ip4/0.0.0.0/tcp/0", "/ip4/0.0.0.0/tcp/0/ws"],
healthPort: 0,
};
```
## Usage: advanced example
```ts
const config: Libp2pServerConfig = {
libp2pFactory: myFactory,
listen: ["/ip4/0.0.0.0/tcp/9090"],
bootstrap: ["/dnsaddr/bootstrap.libp2p.io"],
autoRelay: { enabled: true, maxConnections: 200 },
maxConnections: 5000,
protocolId: "/myapp/1.0.0",
healthPort: 9091,
healthHost: "127.0.0.1",
bus: eventBus,
};
```
## API reference
- `Libp2pServerNodeLike` — structural libp2p node surface.
- `Libp2pServerFactory(config) => Promise<Libp2pServerNodeLike>` —
factory callable.
- `Libp2pServerConfig` — required `libp2pFactory`, `listen`,
`healthPort`; optional `bootstrap`, `autoRelay`, `maxConnections`,
`protocolId`, `healthHost`, `bus`.
- `Libp2pServerStartResult` — `{ peerId, multiaddrs, healthAddress,
healthPort }`.
## Implementation notes
Types-only module (excluded from coverage via `types.ts` filename). The
sibling `types.schemas.ts` carries the Zod boundary schemas; that file
is also excluded from coverage. Concrete enforcement (factory presence,
listen non-empty) lives in the constructor in `server/server.ts`.

View File

@@ -0,0 +1,29 @@
import z from "zod";
/**
* Zod boundary schemas for every public option struct in
* `@suaveplan/network-libp2p-server`. Callable contracts (factory,
* `EventBusLike`) are type-only and accepted here as `z.unknown()` —
* the constructor enforces presence via a runtime `typeof` check.
*/
export const Libp2pServerConfigSchema = z
.object({
libp2pFactory: z.unknown(),
listen: z.array(z.string().min(1)).min(1).readonly(),
bootstrap: z.array(z.string().min(1)).readonly().default([]),
autoRelay: z
.object({
enabled: z.boolean(),
maxConnections: z.number().int().positive().optional(),
})
.optional(),
maxConnections: z.number().int().positive().default(1000),
protocolId: z
.string()
.min(1)
.default("/suaveplan/network-server/1.0.0"),
healthPort: z.number().int().min(0).max(65535),
healthHost: z.string().min(1).default("0.0.0.0"),
bus: z.unknown().optional(),
})
.strict();

View File

@@ -0,0 +1,146 @@
import type { EventBusLike } from "@suaveplan/types";
/**
* Structural surface of a libp2p server node hosted by the relay daemon.
* Production code wires the real `libp2p` package via the injected
* factory (`(config) => createLibp2p({...})`). Tests bind a deterministic
* in-memory implementation that satisfies the same shape — so the relay
* never resolves the `libp2p` module at module-evaluation time, allowing
* the test environment to skip pulling its native transitive deps.
*
* Only the surface the daemon actually uses is declared here: start /
* stop lifecycle, multiaddr advertisement, peer enumeration, and the
* `peer:connect` / `peer:disconnect` event listeners.
*/
export interface Libp2pServerNodeLike {
start(): Promise<void>;
stop(): Promise<void>;
/** Multiaddrs the node is currently listening on after `start()`. */
getMultiaddrs(): readonly { toString(): string }[];
/** Peer ids of currently-connected peers. */
getPeers(): readonly { toString(): string }[];
/** Local node peer id, available after `start()` resolves. */
readonly peerId: { toString(): string };
addEventListener(
type: string,
listener: (event: { detail: unknown }) => void
): void;
removeEventListener(
type: string,
listener: (event: { detail: unknown }) => void
): void;
}
/**
* Configuration forwarded to the libp2p factory. The relay never
* synthesises an opinionated default chain — the caller composes the
* factory body with the transports, muxers, and encrypters they want.
*/
export interface Libp2pServerFactoryConfig {
readonly listen: readonly string[];
readonly bootstrap: readonly string[];
readonly autoRelay: {
readonly enabled: boolean;
readonly maxConnections: number;
};
readonly maxConnections: number;
readonly protocolId: string;
}
/**
* Factory wiring `(config) => Libp2pServerNodeLike`. Production passes a
* function that calls `createLibp2p({ ... })`; tests bind a deterministic
* in-memory implementation. Async return is required since real libp2p
* setup is async.
*/
export type Libp2pServerFactory = (
config: Libp2pServerFactoryConfig
) => Promise<Libp2pServerNodeLike>;
/** Lifecycle event payloads emitted on the optional `EventBusLike`. */
export interface Libp2pServerStartedEvent {
readonly peerId: string;
readonly multiaddrs: readonly string[];
readonly at: number;
}
export interface Libp2pServerStoppedEvent {
readonly at: number;
}
export interface Libp2pServerPeerConnectedEvent {
readonly peerId: string;
readonly at: number;
}
export interface Libp2pServerPeerDisconnectedEvent {
readonly peerId: string;
readonly at: number;
}
export interface Libp2pServerRelayStreamEvent {
readonly peerId: string;
readonly at: number;
}
export type Libp2pServerEventMap = {
"libp2p_server:started": Libp2pServerStartedEvent;
"libp2p_server:stopped": Libp2pServerStoppedEvent;
"libp2p_server:peer-connected": Libp2pServerPeerConnectedEvent;
"libp2p_server:peer-disconnected": Libp2pServerPeerDisconnectedEvent;
"libp2p_server:relay-stream": Libp2pServerRelayStreamEvent;
};
/**
* Strict configuration for `Libp2pServer`. The `libp2pFactory` is
* REQUIRED — production wires the real `createLibp2p`; tests bind a
* fake. There is no implicit default — the daemon never resolves the
* `libp2p` module itself.
*/
export interface Libp2pServerConfig {
/** Required factory wiring the underlying libp2p node. */
readonly libp2pFactory: Libp2pServerFactory;
/** Listen multiaddrs. Empty array rejected at the boundary. */
readonly listen: readonly string[];
/** Bootstrap multiaddrs (federation). Default `[]`. */
readonly bootstrap?: readonly string[];
/** Auto-relay configuration. Default `{ enabled: false }`. */
readonly autoRelay?: {
readonly enabled: boolean;
readonly maxConnections?: number;
};
/** Hard cap on concurrent connections. Default 1000. */
readonly maxConnections?: number;
/** Application-level protocol id. Default `/suaveplan/network-server/1.0.0`. */
readonly protocolId?: string;
/** TCP port for inline health endpoints. Must be in [0, 65535]. */
readonly healthPort: number;
/** Bind address for inline health endpoints. Default `"0.0.0.0"`. */
readonly healthHost?: string;
/** Optional event bus for typed lifecycle emissions. */
readonly bus?: EventBusLike<Libp2pServerEventMap>;
}
/** Fully-resolved configuration — every optional field defaulted. */
export interface ResolvedLibp2pServerConfig {
readonly libp2pFactory: Libp2pServerFactory;
readonly listen: readonly string[];
readonly bootstrap: readonly string[];
readonly autoRelay: {
readonly enabled: boolean;
readonly maxConnections: number;
};
readonly maxConnections: number;
readonly protocolId: string;
readonly healthPort: number;
readonly healthHost: string;
readonly bus: EventBusLike<Libp2pServerEventMap> | undefined;
}
/** Result of `Libp2pServer.start()`. */
export interface Libp2pServerStartResult {
readonly peerId: string;
readonly multiaddrs: readonly string[];
readonly healthAddress: string;
readonly healthPort: number;
}

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-libp2p-server dep-graph", () => {
it("must not have circular dependencies", { timeout: 5000 }, async () => {
const mod = await import("../src/index.js");
expect(mod).toBeDefined();
expect(mod.Libp2pServer).toBeDefined();
expect(mod.NET_LIBP2P_SERVER_CODES).toBeDefined();
expect(mod.NetLibp2pServerError).toBeDefined();
expect(mod.Libp2pServerConfigSchema).toBeDefined();
expect(mod.handleHealth).toBeDefined();
expect(mod.classifyHealthRequest).toBeDefined();
expect(mod.buildHealthResponse).toBeDefined();
expect(mod.HEALTH_PATHS).toBeDefined();
expect(mod.recordConnection).toBeDefined();
expect(mod.recordRelayedStream).toBeDefined();
expect(mod.adjustActivePeers).toBeDefined();
expect(mod.withStartSpan).toBeDefined();
expect(mod.withStopSpan).toBeDefined();
expect(mod.listenHttp).toBeDefined();
expect(mod.wirePeerEvents).toBeDefined();
expect(mod.unwirePeerEvents).toBeDefined();
});
});

View File

@@ -0,0 +1,166 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Libp2pServer } from "../src/server/server.js";
import {
FakeLibp2pHub,
FakeLibp2pServerNode,
makeFakeServerFactory,
} from "./setup/fake-libp2p-server.js";
async function fetchJson(
url: string
): Promise<{ readonly status: number; readonly body: unknown }> {
const res = await fetch(url);
const body = await res.json();
return { status: res.status, body };
}
describe("integration: real HTTP boot + health probes", () => {
it("health/ready/live respond with 200 while running", {
timeout: 10_000,
}, async () => {
const hub = new FakeLibp2pHub();
const { factory } = makeFakeServerFactory(hub);
const server = new Libp2pServer({
libp2pFactory: factory,
listen: ["/ip4/0.0.0.0/tcp/0"],
healthPort: 0,
healthHost: "127.0.0.1",
});
const { healthAddress, healthPort } = await server.start();
const base = `http://${
healthAddress === "::" || healthAddress === "0.0.0.0"
? "127.0.0.1"
: healthAddress
}:${healthPort}`;
try {
const health = await fetchJson(`${base}/health`);
expect(health.status).toBe(200);
expect((health.body as { status: string }).status).toBe("ok");
const ready = await fetchJson(`${base}/ready`);
expect(ready.status).toBe(200);
const live = await fetchJson(`${base}/live`);
expect(live.status).toBe(200);
// Non-health route falls through to the plaintext marker.
const fall = await fetch(`${base}/something-else`);
expect(fall.status).toBe(200);
expect(await fall.text()).toContain("libp2p");
} finally {
await server.stop();
}
});
it("a client dial is recorded as peer-connected end-to-end", {
timeout: 10_000,
}, async () => {
const hub = new FakeLibp2pHub();
const { factory, instances } = makeFakeServerFactory(hub);
const server = new Libp2pServer({
libp2pFactory: factory,
listen: ["/ip4/0.0.0.0/tcp/0"],
healthPort: 0,
healthHost: "127.0.0.1",
});
await server.start();
try {
const serverNode = instances[0];
if (!(serverNode instanceof FakeLibp2pServerNode)) {
throw new Error("expected fake node");
}
// A second fake node in the same hub simulates a browser-tier
// client dialling the server. The hub wires both ends' peer
// events deterministically.
const client = new FakeLibp2pServerNode(
hub,
{
listen: ["/ip4/0.0.0.0/tcp/0"],
bootstrap: [],
autoRelay: { enabled: false, maxConnections: 100 },
maxConnections: 100,
protocolId: "/suaveplan/network-server/1.0.0",
},
"client-1"
);
hub.connectPair(serverNode, client);
expect(server.peers()).toContain("client-1");
expect(server.activePeers).toBe(1);
} finally {
await server.stop();
}
});
it("server relays a stream observation across two clients", {
timeout: 10_000,
}, async () => {
const hub = new FakeLibp2pHub();
const { factory, instances } = makeFakeServerFactory(hub);
const server = new Libp2pServer({
libp2pFactory: factory,
listen: ["/ip4/0.0.0.0/tcp/0"],
healthPort: 0,
healthHost: "127.0.0.1",
autoRelay: { enabled: true, maxConnections: 50 },
});
await server.start();
try {
const serverNode = instances[0];
if (!(serverNode instanceof FakeLibp2pServerNode)) {
throw new Error("expected fake node");
}
// Two clients sit behind NAT; both connect to the server.
const a = new FakeLibp2pServerNode(
hub,
{
listen: ["/ip4/0.0.0.0/tcp/0"],
bootstrap: [],
autoRelay: { enabled: false, maxConnections: 100 },
maxConnections: 100,
protocolId: "/suaveplan/network-server/1.0.0",
},
"client-A"
);
const b = new FakeLibp2pServerNode(
hub,
{
listen: ["/ip4/0.0.0.0/tcp/0"],
bootstrap: [],
autoRelay: { enabled: false, maxConnections: 100 },
maxConnections: 100,
protocolId: "/suaveplan/network-server/1.0.0",
},
"client-B"
);
hub.connectPair(serverNode, a);
hub.connectPair(serverNode, b);
expect(server.peers()).toContain("client-A");
expect(server.peers()).toContain("client-B");
// Daemon observes a relayed stream between the two clients.
server.observeRelayedStream("client-A->client-B");
} finally {
await server.stop();
}
});
it("graceful stop closes the inline health listener", {
timeout: 10_000,
}, async () => {
const hub = new FakeLibp2pHub();
const { factory } = makeFakeServerFactory(hub);
const server = new Libp2pServer({
libp2pFactory: factory,
listen: ["/ip4/0.0.0.0/tcp/0"],
healthPort: 0,
healthHost: "127.0.0.1",
});
const { healthAddress, healthPort } = await server.start();
const base = `http://${
healthAddress === "::" || healthAddress === "0.0.0.0"
? "127.0.0.1"
: healthAddress
}:${healthPort}`;
await server.stop();
await expect(fetch(`${base}/health`)).rejects.toBeDefined();
});
});

View File

@@ -0,0 +1,202 @@
import type {
Libp2pServerFactory,
Libp2pServerFactoryConfig,
Libp2pServerNodeLike,
} from "../../src/types/types.js";
let nextNodeNum = 1;
interface NodeListeners {
readonly connect: Set<(event: { detail: unknown }) => void>;
readonly disconnect: Set<(event: { detail: unknown }) => void>;
}
/** Deterministic peer-id object. */
export class FakePeerId {
public constructor(private readonly id: string) {}
public toString(): string {
return this.id;
}
}
/** Deterministic multiaddr value object. */
export class FakeMultiaddr {
public constructor(private readonly value: string) {}
public toString(): string {
return this.value;
}
}
/**
* Real in-memory `Libp2pServerNodeLike` for tests. Not a mock — every
* method does real work over an internal peer Set + listener registry.
* The `FakeLibp2pHub` coordinator wires `peer:connect` /
* `peer:disconnect` events between paired nodes so the daemon's
* `wirePeerEvents` bridging can be exercised end-to-end without
* pulling the real `libp2p` module into the test surface.
*/
export class FakeLibp2pServerNode implements Libp2pServerNodeLike {
public readonly localId: string;
public readonly peerId: FakePeerId;
readonly #hub: FakeLibp2pHub;
readonly #peers = new Set<string>();
readonly #multiaddrs: FakeMultiaddr[];
readonly #listeners: NodeListeners = {
connect: new Set(),
disconnect: new Set(),
};
#started = false;
#stopped = false;
public startThrows = false;
public stopThrows = false;
public constructor(
hub: FakeLibp2pHub,
config: Libp2pServerFactoryConfig,
id?: string
) {
this.localId = id ?? `peer-${nextNodeNum++}`;
this.peerId = new FakePeerId(this.localId);
this.#hub = hub;
this.#multiaddrs = config.listen.map(
(a) => new FakeMultiaddr(`${a}/p2p/${this.localId}`)
);
hub.register(this);
}
public async start(): Promise<void> {
if (this.startThrows) {
throw new Error("fake-libp2p-server.start-failed");
}
this.#started = true;
}
public async stop(): Promise<void> {
if (this.stopThrows) {
throw new Error("fake-libp2p-server.stop-failed");
}
if (this.#stopped) return;
this.#stopped = true;
this.#started = false;
for (const peerId of Array.from(this.#peers)) {
this.dropPeer(peerId);
}
this.#hub.unregister(this);
}
public getMultiaddrs(): readonly { toString(): string }[] {
return this.#multiaddrs;
}
public getPeers(): readonly { toString(): string }[] {
return Array.from(this.#peers).map((id) => new FakePeerId(id));
}
public addEventListener(
type: string,
listener: (event: { detail: unknown }) => void
): void {
if (type === "peer:connect") this.#listeners.connect.add(listener);
else if (type === "peer:disconnect") {
this.#listeners.disconnect.add(listener);
}
}
public removeEventListener(
type: string,
listener: (event: { detail: unknown }) => void
): void {
if (type === "peer:connect") this.#listeners.connect.delete(listener);
else if (type === "peer:disconnect") {
this.#listeners.disconnect.delete(listener);
}
}
/** Hub uses this to fire peer:connect for both ends of a pairing. */
public markConnected(peerId: string): void {
if (this.#peers.has(peerId)) return;
this.#peers.add(peerId);
for (const listener of this.#listeners.connect) {
listener({ detail: new FakePeerId(peerId) });
}
}
/** Hub uses this to fire peer:disconnect from either end. */
public dropPeer(peerId: string): void {
if (!this.#peers.has(peerId)) return;
this.#peers.delete(peerId);
for (const listener of this.#listeners.disconnect) {
listener({ detail: new FakePeerId(peerId) });
}
}
/** Test-only: re-fire a peer:connect event bypassing dedup. */
public forceEmitConnect(peerId: string): void {
for (const listener of this.#listeners.connect) {
listener({ detail: new FakePeerId(peerId) });
}
}
/** Test-only: re-fire a peer:disconnect event bypassing membership. */
public forceEmitDisconnect(peerId: string): void {
for (const listener of this.#listeners.disconnect) {
listener({ detail: new FakePeerId(peerId) });
}
}
public get started(): boolean {
return this.#started;
}
}
/**
* Hub coordinating multiple `FakeLibp2pServerNode` instances. Tests
* call `connectPair(a, b)` to wire each side's `peer:connect` event.
*/
export class FakeLibp2pHub {
readonly #nodes = new Map<string, FakeLibp2pServerNode>();
public register(node: FakeLibp2pServerNode): void {
this.#nodes.set(node.localId, node);
}
public unregister(node: FakeLibp2pServerNode): void {
this.#nodes.delete(node.localId);
for (const other of this.#nodes.values()) {
other.dropPeer(node.localId);
}
}
public get(id: string): FakeLibp2pServerNode | undefined {
return this.#nodes.get(id);
}
public connectPair(a: FakeLibp2pServerNode, b: FakeLibp2pServerNode): void {
a.markConnected(b.localId);
b.markConnected(a.localId);
}
}
/** Build a `Libp2pServerFactory` bound to a hub. */
export function makeFakeServerFactory(
hub: FakeLibp2pHub,
opts?: { readonly idPrefix?: string; readonly startThrows?: boolean }
): {
readonly factory: Libp2pServerFactory;
readonly instances: FakeLibp2pServerNode[];
} {
const instances: FakeLibp2pServerNode[] = [];
let counter = 0;
const factory: Libp2pServerFactory = async (
config: Libp2pServerFactoryConfig
) => {
const id = opts?.idPrefix ? `${opts.idPrefix}-${++counter}` : undefined;
const node = new FakeLibp2pServerNode(hub, config, id);
if (opts?.startThrows === true) {
node.startThrows = true;
}
instances.push(node);
return node;
};
return { factory, instances };
}

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/network-libp2p-server",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/logger",
"@suaveplan/network-transport-libp2p",
"@suaveplan/types",
"libp2p",
"@libp2p/tcp",
"@libp2p/websockets",
"@libp2p/circuit-relay-v2",
"@libp2p/identify",
"@libp2p/bootstrap",
"@chainsafe/libp2p-noise",
"@chainsafe/libp2p-yamux",
"@multiformats/multiaddr",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

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