Initial Commit
This commit is contained in:
129
packages/networking/network/network-libp2p-server/README.md
Normal file
129
packages/networking/network/network-libp2p-server/README.md
Normal 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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"references": [{ "path": "./tsconfig.lib.json" }]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: [
|
||||
"src/__benchmarks__/**",
|
||||
"benchmarks/**",
|
||||
"e2e/**",
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user