Initial Commit

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

View File

@@ -0,0 +1,108 @@
# @suaveplan/network-gundb-server - Server-tier GunDB relay daemon for @suaveplan/network-discovery-gundb clients — Node-only HTTP/WebSocket relay with structural Zod wire-payload validation, AbortSignal lifecycle, health/ready/live endpoints, OTel observability, and injectable Gun factory for real-fake integration tests.
## Summary
Server-tier GunDB relay daemon. Hosts a Gun relay peer plus standard
`/health` / `/ready` / `/live` probes on a single Node `http.Server`.
Browser-tier clients running `@suaveplan/network-discovery-gundb` pass
`http(s)://<host>:<port>/gun` in their `peers[]` list and gossip
signed peer-announces through the relay.
<!-- 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 -->
- [listen-http](./src/server/listen-http.md) — Free function that binds a `node:http` server to `(port, host)` and translates the resulting Node EventEmitter signals…
- [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 -->
- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [validation](./src/validation/validation.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)_- [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)_- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation](./src/validation/validation.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)_- [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)_- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation](./src/validation/validation.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)_- [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)_- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation](./src/validation/validation.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)_ - [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)_- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_ - [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_
## Why this package
- **Single daemon, no orchestrator coupling.** Boot the relay,
publish the endpoint, watch the health probes.
- **Defensive by default.** Every inbound announce is structurally
validated against the shared Zod schema; malformed records are
dropped silently and counted on
`gundb_server.invalid_messages_total{reason}`.
- **Injection-friendly.** The Gun runtime is a peer dep — production
wires real `Gun`, tests bind a deterministic in-memory fake.
- **Lifecycle-correct.** `start({ signal? })` resolves with the bound
`{ address, port }`. `stop()` is idempotent.
## Run the daemon
```ts
import { GunDBServer } from "@suaveplan/network-gundb-server";
import Gun from "gun";
const server = new GunDBServer({
port: 8765,
host: "0.0.0.0",
storagePath: "./data/gun",
radisk: true,
maxConnections: 256,
gunFactory: (cfg) => Gun(cfg),
});
const { address, port } = await server.start();
console.log(`relay listening on ${address}:${port}/gun`);
process.on("SIGTERM", async () => {
await server.stop();
process.exit(0);
});
```
## Configuration reference
See [`src/types/types.md`](./src/types/types.md) for the full strict
`GunDBServerConfig`. All fields except `port` and `gunFactory` are
optional and have spec-documented defaults.
## Health endpoints
| Verb | 200 OK when | 503 when |
|----------|--------------------------------------|--------------------------------------|
| `/live` | process alive (always, until stop) | after `stop()` |
| `/ready` | started AND below `maxConnections` | starting, at-cap, or stopped |
| `/health`| started AND below `maxConnections` | starting, at-cap, or stopped |
See [`src/health/health.md`](./src/health/health.md).
## Observability
OpenTelemetry instrument names emitted by the daemon:
- `gundb_server.start` / `gundb_server.stop` — spans.
- `gundb_server.connections_total{outcome}` — counter.
- `gundb_server.messages_relayed_total{outcome}` — counter.
- `gundb_server.invalid_messages_total{reason}` — security counter.
- `gundb_server.active_connections` — up-down gauge.
- `gundb_server.start_duration_ms` — histogram.
See [`src/otel/otel.md`](./src/otel/otel.md).
## Security model
The relay does **not** verify Ed25519 signatures on inbound
announces — signature verification is the client's responsibility per
the gossip security model. The relay structurally validates the
presence and shape of every signature field so a malformed announce
cannot poison the namespace, then forwards the signed payload
unchanged.
See [`src/validation/validation.md`](./src/validation/validation.md).
## Companion package
[`@suaveplan/network-discovery-gundb`](../network-discovery-gundb) is
the browser-tier client this daemon serves.

View File

@@ -0,0 +1,69 @@
{
"name": "@suaveplan/network-gundb-server",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Server-tier GunDB relay daemon for @suaveplan/network-discovery-gundb clients — Node-only HTTP/WebSocket relay with structural Zod wire-payload validation, AbortSignal lifecycle, health/ready/live endpoints, OTel observability, and injectable Gun 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-discovery-gundb": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"peerDependencies": {
"gun": "catalog:network-discovery"
},
"peerDependenciesMeta": {
"gun": { "optional": true }
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

@@ -0,0 +1,84 @@
# `network-gundb-server/errors`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the frozen error-code registry and `NetGunDBServerError` class
used by every public boundary in `@suaveplan/network-gundb-server`. The
registry is built via `defineCodes("NET_GUNDB_SERVER", [...] as const)`
so codes are stable string literals, type-narrowable at the call site,
and surfaced as labels on the structural counters in `./otel`.
The relay must never crash on a malformed inbound payload — wire-payload
rejection codes are surfaced via counters only, not thrown. Lifecycle
codes (`START_FAILED` / `PORT_IN_USE` / `DISPOSED` / `NOT_RUNNING` /
`INVALID_OPTIONS`) ARE thrown out of `start` / `stop`.
## Features
- 6-code frozen registry: `INVALID_OPTIONS`, `START_FAILED`,
`PORT_IN_USE`, `DISPOSED`, `NOT_RUNNING`, `INVALID_WIRE_PAYLOAD`.
- `NetGunDBServerError extends SuaveplanError<NetGunDBServerErrorCode>`
preserves the canonical `code` / `messageKey` / `cause` / `context`
surface from `@suaveplan/error`.
- Codes also serve as low-cardinality labels for the OTel counter
surface (`reason` attribute on
`gundb_server.invalid_messages_total`).
## Usage: Basic Example
```ts
import {
NET_GUNDB_SERVER_CODES,
NetGunDBServerError,
} from "@suaveplan/network-gundb-server";
throw new NetGunDBServerError(
NET_GUNDB_SERVER_CODES.NOT_RUNNING,
"gundb_server.start-required"
);
```
## Usage: Advanced Example
```ts
import {
NET_GUNDB_SERVER_CODES,
NetGunDBServerError,
} from "@suaveplan/network-gundb-server";
try {
await server.start();
} catch (err) {
if (
err instanceof NetGunDBServerError &&
err.code === NET_GUNDB_SERVER_CODES.PORT_IN_USE
) {
// Fallback to a random free port.
return retryWithFreshPort();
}
throw err;
}
```
## API Reference
- `NET_GUNDB_SERVER_CODES` — frozen `Record<UpperSnake, string>` of
registered codes.
- `NetGunDBServerErrorCode` — union of every registered code literal.
- `class NetGunDBServerError` — constructor
`(code, messageKey, options?)` — throws on every public lifecycle
boundary.
## Implementation Notes
- `INVALID_WIRE_PAYLOAD` is **registered** but **not thrown** out of
the inbound message handler — the relay drops malformed payloads
silently and increments the
`gundb_server.invalid_messages_total{reason}` counter so dashboards
can spot spikes from misbehaving clients without taking the node
down.
- `defineCodes` produces an `as const` frozen object so every code is
type-narrowable in `switch` / `if` chains downstream.

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NET_GUNDB_SERVER_CODES, NetGunDBServerError } from "./errors.js";
describe("NetGunDBServerError", () => {
it("exposes every registered code literal", { timeout: 5000 }, () => {
expect(NET_GUNDB_SERVER_CODES.INVALID_OPTIONS).toBe(
"NET_GUNDB_SERVER.INVALID_OPTIONS"
);
expect(NET_GUNDB_SERVER_CODES.START_FAILED).toBe(
"NET_GUNDB_SERVER.START_FAILED"
);
expect(NET_GUNDB_SERVER_CODES.PORT_IN_USE).toBe(
"NET_GUNDB_SERVER.PORT_IN_USE"
);
expect(NET_GUNDB_SERVER_CODES.DISPOSED).toBe(
"NET_GUNDB_SERVER.DISPOSED"
);
expect(NET_GUNDB_SERVER_CODES.NOT_RUNNING).toBe(
"NET_GUNDB_SERVER.NOT_RUNNING"
);
expect(NET_GUNDB_SERVER_CODES.INVALID_WIRE_PAYLOAD).toBe(
"NET_GUNDB_SERVER.INVALID_WIRE_PAYLOAD"
);
});
it("wraps the SuaveplanError surface", { timeout: 5000 }, () => {
const err = new NetGunDBServerError(
NET_GUNDB_SERVER_CODES.NOT_RUNNING,
"gundb_server.start-required",
{ context: { port: 8765 } }
);
expect(err.name).toBe("NetGunDBServerError");
expect(err.code).toBe(NET_GUNDB_SERVER_CODES.NOT_RUNNING);
expect(err.context).toEqual({ port: 8765 });
expect(typeof err.timestamp).toBe("number");
expect(err).toBeInstanceOf(Error);
});
it("constructs without options", { timeout: 5000 }, () => {
const err = new NetGunDBServerError(
NET_GUNDB_SERVER_CODES.DISPOSED,
"gundb_server.disposed"
);
expect(err.code).toBe(NET_GUNDB_SERVER_CODES.DISPOSED);
});
});

View File

@@ -0,0 +1,48 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Error codes emitted by `@suaveplan/network-gundb-server`.
*
* - `INVALID_OPTIONS` — caller-supplied config failed Zod validation,
* or the required `gunFactory` was missing.
* - `START_FAILED` — the underlying `http.Server.listen` or factory
* threw, or aborted before the listening event fired.
* - `PORT_IN_USE` — listen failed with `EADDRINUSE`.
* - `DISPOSED` — operation attempted after `stop()` cleared state.
* - `NOT_RUNNING` — operation attempted before `start()` resolved.
* - `INVALID_WIRE_PAYLOAD` — inbound announce dropped because it failed
* structural Zod validation. Surfaced only via the
* `gundb_server.invalid_messages_total{reason}` counter — never
* thrown out of the message handler (a misbehaving peer must not be
* able to crash the relay).
*/
export const NET_GUNDB_SERVER_CODES = defineCodes("NET_GUNDB_SERVER", [
"INVALID_OPTIONS",
"START_FAILED",
"PORT_IN_USE",
"DISPOSED",
"NOT_RUNNING",
"INVALID_WIRE_PAYLOAD",
] as const);
export type NetGunDBServerErrorCode =
(typeof NET_GUNDB_SERVER_CODES)[keyof typeof NET_GUNDB_SERVER_CODES];
/**
* Structured error class for the GunDB relay daemon. Every public
* boundary throws this on failure; consumers branch on `error.code`.
*/
export class NetGunDBServerError extends SuaveplanError<NetGunDBServerErrorCode> {
public constructor(
code: NetGunDBServerErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetGunDBServerError";
}
}

View File

@@ -0,0 +1,89 @@
# `network-gundb-server/health`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Inline health/readiness/liveness endpoints for the `GunDBServer`
daemon. The package surfaces three standard verbs at `/health`,
`/ready`, and `/live` on the same HTTP server the Gun relay listens
on — orchestrators (Kubernetes probes, AWS ALB, Docker healthchecks)
hit them to decide whether to route traffic, restart the container, or
mark the instance terminated. The module ships as a pure function set
(no `http.Server` import) so the daemon can keep its single HTTP
listener and dispatch health routes before the Gun relay sees them.
Once `@suaveplan/health-check` lands as a dedicated package this module
will become a thin shim over that surface; today it owns the
implementation directly so the relay is not blocked by an
out-of-package dependency.
## Features
- Three standard verbs: `/health`, `/ready`, `/live`.
- `live` returns 200 unless `stop()` has been called.
- `ready` returns 200 only when started AND has spare capacity.
- `health` returns 200 when running and not at-cap, 503 when degraded.
- JSON-only response body with structured status fields.
- Query-string and trailing-slash agnostic path classifier.
- Pure functions — every input is captured at call-time so the same
response is byte-deterministic for any given snapshot.
## Usage: Basic Example
```ts
import {
classifyHealthRequest,
handleHealth,
} from "@suaveplan/network-gundb-server";
if (handleHealth(req, res, snapshot)) {
return; // Health route handled; do not fall through to Gun.
}
```
## Usage: Advanced Example
```ts
import {
buildHealthResponse,
type HealthSnapshot,
} from "@suaveplan/network-gundb-server";
const snap: HealthSnapshot = {
running: true,
disposed: false,
activeConnections: 42,
maxConnections: 100,
startedAt: Date.now() - 5_000,
};
const { status, body } = buildHealthResponse("ready", snap);
console.log(status, body.status); // 200 "ok"
```
## API Reference
- `HEALTH_PATHS` — frozen `{ health, ready, live }` path constants.
- `HealthKind` — `"health"|"ready"|"live"|null`.
- `classifyHealthRequest(url)` — match URL path → verb.
- `HealthSnapshot` — daemon state record (running, disposed, active,
max, startedAt).
- `HealthResponse` — JSON body shape.
- `buildHealthResponse(verb, snap)` — pure decision function.
- `handleHealth(req, res, snap)` — dispatch + write. Returns `true`
when the request was handled.
## Implementation Notes
- The classifier strips a single trailing slash and the entire
query-string portion so probes that hit `/health?ts=12345` or
`/health/` both match — the strict match would otherwise leak 404s
into production telemetry.
- The `live` verb is decoupled from `ready` so a node that has hit
`maxConnections` keeps reporting live (the process is fine) while
reporting not-ready (do not send more traffic) — the standard
K8s pattern.
- Returning `false` from `handleHealth` keeps the dispatcher composable:
the daemon's HTTP listener can fall through to the Gun relay when
the URL is not a health route.

View File

@@ -0,0 +1,152 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
buildHealthResponse,
classifyHealthRequest,
type HealthSnapshot,
handleHealth,
} from "./health.js";
const RUNNING: HealthSnapshot = {
running: true,
disposed: false,
activeConnections: 5,
maxConnections: 100,
startedAt: 1_700_000_000_000,
};
const STARTING: HealthSnapshot = {
running: false,
disposed: false,
activeConnections: 0,
maxConnections: 100,
startedAt: undefined,
};
const DISPOSED: HealthSnapshot = {
running: false,
disposed: true,
activeConnections: 0,
maxConnections: 100,
startedAt: 1_700_000_000_000,
};
const AT_CAP: HealthSnapshot = {
running: true,
disposed: false,
activeConnections: 100,
maxConnections: 100,
startedAt: 1_700_000_000_000,
};
describe("classifyHealthRequest", () => {
it("matches /health, /ready, /live", { timeout: 5000 }, () => {
expect(classifyHealthRequest("/health")).toBe("health");
expect(classifyHealthRequest("/ready")).toBe("ready");
expect(classifyHealthRequest("/live")).toBe("live");
});
it("strips query strings and trailing slashes", { timeout: 5000 }, () => {
expect(classifyHealthRequest("/health?ts=1")).toBe("health");
expect(classifyHealthRequest("/health/")).toBe("health");
});
it("returns null on unknown / missing url", { timeout: 5000 }, () => {
expect(classifyHealthRequest("/gun")).toBeNull();
expect(classifyHealthRequest(undefined)).toBeNull();
expect(classifyHealthRequest("")).toBeNull();
});
});
describe("buildHealthResponse", () => {
it("live returns 200 ok when not disposed", { timeout: 5000 }, () => {
const r = buildHealthResponse("live", RUNNING);
expect(r.status).toBe(200);
expect(r.body.status).toBe("ok");
});
it("live returns 503 stopped when disposed", { timeout: 5000 }, () => {
const r = buildHealthResponse("live", DISPOSED);
expect(r.status).toBe(503);
expect(r.body.status).toBe("stopped");
});
it("live returns 200 ok during startup (process is alive)", {
timeout: 5000,
}, () => {
const r = buildHealthResponse("live", STARTING);
expect(r.status).toBe(200);
expect(r.body.status).toBe("ok");
});
it("ready returns 200 ok when running and not at-cap", {
timeout: 5000,
}, () => {
const r = buildHealthResponse("ready", RUNNING);
expect(r.status).toBe(200);
expect(r.body.status).toBe("ok");
});
it("ready returns 503 starting when not yet started", {
timeout: 5000,
}, () => {
const r = buildHealthResponse("ready", STARTING);
expect(r.status).toBe(503);
expect(r.body.status).toBe("starting");
});
it("ready returns 503 stopped when disposed", { timeout: 5000 }, () => {
const r = buildHealthResponse("ready", DISPOSED);
expect(r.status).toBe(503);
expect(r.body.status).toBe("stopped");
});
it("ready returns 503 degraded at-cap", { timeout: 5000 }, () => {
const r = buildHealthResponse("ready", AT_CAP);
expect(r.status).toBe(503);
expect(r.body.status).toBe("degraded");
});
it("health follows the same gates as ready", { timeout: 5000 }, () => {
expect(buildHealthResponse("health", RUNNING).status).toBe(200);
expect(buildHealthResponse("health", STARTING).status).toBe(503);
expect(buildHealthResponse("health", AT_CAP).status).toBe(503);
});
});
describe("handleHealth", () => {
function makeRes() {
const calls: {
writeHead: Array<[number, Record<string, string>]>;
end: Array<string>;
} = { writeHead: [], end: [] };
return {
calls,
writeHead: (s: number, h: Record<string, string>) => {
calls.writeHead.push([s, h]);
},
end: (b: string) => {
calls.end.push(b);
},
};
}
it("returns false on non-health URL", { timeout: 5000 }, () => {
const res = makeRes();
const out = handleHealth({ url: "/gun" }, res, RUNNING);
expect(out).toBe(false);
expect(res.calls.writeHead).toHaveLength(0);
});
it("writes JSON 200 for /health when running", { timeout: 5000 }, () => {
const res = makeRes();
const out = handleHealth({ url: "/health" }, res, RUNNING);
expect(out).toBe(true);
expect(res.calls.writeHead[0]?.[0]).toBe(200);
expect(res.calls.writeHead[0]?.[1]).toEqual({
"content-type": "application/json",
});
const body = JSON.parse(res.calls.end[0] ?? "{}");
expect(body.status).toBe("ok");
expect(body.verb).toBe("health");
});
});

View File

@@ -0,0 +1,129 @@
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;
/** Current attached client peer count. */
readonly activeConnections: number;
/** Configured `maxConnections`. */
readonly maxConnections: number;
/** Unix-ms timestamp of the latest `start()` resolve. */
readonly startedAt: number | 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 activeConnections: number;
readonly maxConnections: number;
readonly startedAt: number | undefined;
readonly verb: "health" | "ready" | "live";
}
/**
* Build the body and HTTP status for one health verb.
*
* - `live` always returns 200 unless `stop()` has cleared state.
* - `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,
activeConnections: snap.activeConnections,
maxConnections: snap.maxConnections,
startedAt: snap.startedAt,
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.activeConnections >= snap.maxConnections;
if (!snap.running || snap.disposed) {
return {
status: 503,
body: { ...base, status: snap.disposed ? "stopped" : "starting" },
};
}
if (atCap) {
return { status: 503, body: { ...base, status: "degraded" } };
}
return { status: 200, body: { ...base, status: "ok" } };
}
/**
* Dispatch one HTTP request to the health verb pipeline. Returns
* `true` when the request was handled, `false` when it should be
* forwarded to the next handler. Always writes JSON.
*/
export function handleHealth(
req: Pick<IncomingMessage, "url">,
res: Pick<ServerResponse, "writeHead" | "end">,
snap: HealthSnapshot
): boolean {
const verb = classifyHealthRequest(req.url);
if (verb === null) return false;
const { status, body } = buildHealthResponse(verb, snap);
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
return true;
}

View File

@@ -0,0 +1,56 @@
// Server-tier package — uses node:http (Node-only). Browser consumers cannot use this package.
// Errors
export {
NET_GUNDB_SERVER_CODES,
NetGunDBServerError,
type NetGunDBServerErrorCode,
} from "./errors/errors.js";
// Health
export {
buildHealthResponse,
classifyHealthRequest,
HEALTH_PATHS,
type HealthKind,
type HealthResponse,
type HealthSnapshot,
handleHealth,
} from "./health/health.js";
// OTel
export {
adjustActiveConnections,
type ConnectionOutcome,
type MessageRelayOutcome,
recordConnection,
recordInvalidMessage,
recordMessageRelayed,
recordStartDuration,
withStartSpan,
withStopSpan,
} from "./otel/otel.js";
// Server
export { GunDBServer } from "./server/server.js";
// Types
export type {
GunDBServerConfig,
GunDBServerEventMap,
GunDBServerPeerConnectedEvent,
GunDBServerPeerDisconnectedEvent,
GunDBServerPeerInfo,
GunDBServerRelayErrorEvent,
GunDBServerStartedEvent,
GunDBServerStartResult,
GunDBServerStoppedEvent,
GunFactory,
GunFactoryConfig,
GunNodeLike,
InvalidWirePayloadReason,
ResolvedGunDBServerConfig,
} from "./types/types.js";
// Schemas
export { GunDBServerConfigSchema } from "./types/types.schemas.js";
// Validation
export {
validateInboundAnnounce,
type WirePayloadValidation,
} from "./validation/validation.js";

View File

@@ -0,0 +1,95 @@
# `network-gundb-server/otel`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the full OpenTelemetry instrumentation surface for the
`GunDBServer` daemon. Every counter, histogram, and gauge consumed by
the daemon is defined here and accessed through small typed helper
functions — direct `metrics.getMeter()` calls outside this module are
forbidden so dashboards never need to reference more than one set of
instrument names. Tracer spans wrap the public `start()` / `stop()`
boundaries so downstream OTel exporters can correlate lifecycle latency
with relay traffic.
The instruments here are also the **security-observability lever** —
the `gundb_server.invalid_messages_total{reason}` counter is the
primary signal a relay operator uses to detect a misbehaving client
flooding the namespace with malformed announces.
## Features
- `gundb_server.start` / `gundb_server.stop` span helpers.
- `gundb_server.connections_total{outcome}` counter (lifecycle).
- `gundb_server.messages_relayed_total{outcome}` counter (data plane).
- `gundb_server.invalid_messages_total{reason}` counter (security).
- `gundb_server.active_connections` up-down gauge.
- `gundb_server.start_duration_ms` histogram (lifecycle latency).
- Tiny adapter helpers (`recordConnection`, `recordMessageRelayed`,
`recordInvalidMessage`, `adjustActiveConnections`,
`recordStartDuration`).
## Usage: Basic Example
```ts
import {
recordConnection,
withStartSpan,
} from "@suaveplan/network-gundb-server/otel";
await withStartSpan(async () => {
// start logic
recordConnection("connected");
});
```
## Usage: Advanced Example
```ts
import {
adjustActiveConnections,
recordInvalidMessage,
recordMessageRelayed,
withStopSpan,
} from "@suaveplan/network-gundb-server/otel";
// Per-frame inbound:
recordInvalidMessage("malformed");
// Live peer-count tracking:
adjustActiveConnections(+1);
// ... peer detaches ...
adjustActiveConnections(-1);
// Per-relayed-frame outbound:
recordMessageRelayed("ok");
// Stop wrap:
await withStopSpan(async () => {
/* drain + close */
});
```
## API Reference
- `withStartSpan(fn)` / `withStopSpan(fn)` — async-span wrappers.
- `recordConnection(outcome)` — `"connected"|"disconnected"|"rejected"`.
- `recordMessageRelayed(outcome)` — `"ok"|"error"`.
- `recordInvalidMessage(reason)` — `InvalidWirePayloadReason`.
- `adjustActiveConnections(delta)` — `+1` on connect, `-1` on detach.
- `recordStartDuration(ms)` — observation in milliseconds.
## Implementation Notes
- The `reason` attribute on `gundb_server.invalid_messages_total` is
drawn from a closed enum (`InvalidWirePayloadReason`) so the metric
remains low-cardinality and safe for production telemetry.
- `adjustActiveConnections` uses `UpDownCounter` rather than a polled
`ObservableGauge` because the relay already knows the exact delta at
every transition — there is no underlying sampling concern.
- The package never registers an exporter — that is the consumer's
responsibility. We rely on the global `@opentelemetry/api` no-op
provider when no SDK is wired up, which keeps unit tests free of
exporter setup.

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
adjustActiveConnections,
recordConnection,
recordInvalidMessage,
recordMessageRelayed,
recordStartDuration,
withStartSpan,
withStopSpan,
} from "./otel.js";
describe("otel surface", () => {
it("withStartSpan returns the inner result", {
timeout: 5000,
}, async () => {
const out = await withStartSpan(async () => 42);
expect(out).toBe(42);
});
it("withStartSpan ends the span on throw", { timeout: 5000 }, async () => {
await expect(
withStartSpan(async () => {
throw new Error("boom");
})
).rejects.toThrow("boom");
});
it("withStopSpan returns the inner result", { timeout: 5000 }, async () => {
const out = await withStopSpan(async () => "done");
expect(out).toBe("done");
});
it("withStopSpan ends the span on throw", { timeout: 5000 }, async () => {
await expect(
withStopSpan(async () => {
throw new Error("kaboom");
})
).rejects.toThrow("kaboom");
});
it("counter / gauge / histogram helpers do not throw", {
timeout: 5000,
}, () => {
expect(() => recordConnection("connected")).not.toThrow();
expect(() => recordConnection("disconnected")).not.toThrow();
expect(() => recordConnection("rejected")).not.toThrow();
expect(() => recordMessageRelayed("ok")).not.toThrow();
expect(() => recordMessageRelayed("error")).not.toThrow();
expect(() => recordInvalidMessage("malformed")).not.toThrow();
expect(() => recordInvalidMessage("wrong-kind")).not.toThrow();
expect(() => recordInvalidMessage("missing-signature")).not.toThrow();
expect(() => recordInvalidMessage("missing-public-key")).not.toThrow();
expect(() => adjustActiveConnections(+1)).not.toThrow();
expect(() => adjustActiveConnections(-1)).not.toThrow();
expect(() => adjustActiveConnections(0)).not.toThrow();
expect(() => recordStartDuration(12.3)).not.toThrow();
expect(() => recordStartDuration(-1)).not.toThrow();
});
});

View File

@@ -0,0 +1,125 @@
import { metrics, type Span, type Tracer, trace } from "@opentelemetry/api";
import type { InvalidWirePayloadReason } from "../types/types.js";
const TRACER_NAME = "@suaveplan/network-gundb-server";
const METER_NAME = "@suaveplan/network-gundb-server";
const tracer: Tracer = trace.getTracer(TRACER_NAME);
const meter = metrics.getMeter(METER_NAME);
/**
* Counter incremented every time a client peer attaches or detaches
* from the relay, labelled by outcome (`connected`/`disconnected`/
* `rejected`).
*/
const connectionsCounter = meter.createCounter(
"gundb_server.connections_total",
{
description:
"Per-client connection lifecycle events handled by the GunDB relay daemon.",
}
);
/**
* Counter incremented every time the relay forwards a message between
* clients, labelled by outcome.
*/
const messagesRelayedCounter = meter.createCounter(
"gundb_server.messages_relayed_total",
{
description: "Messages forwarded between clients, by outcome.",
}
);
/**
* Counter incremented every time an inbound wire payload is silently
* dropped, labelled by reason. The **security-observability lever** —
* any spike on a given `reason` indicates an active misbehaving peer.
*/
const invalidMessagesCounter = meter.createCounter(
"gundb_server.invalid_messages_total",
{
description:
"Inbound wire payloads silently dropped because they failed Zod validation.",
}
);
/**
* Up-down counter tracking the live number of attached client peers.
*/
const activeConnectionsGauge = meter.createUpDownCounter(
"gundb_server.active_connections",
{
description: "Currently attached client peer connections.",
}
);
/**
* Histogram of `start()` wall-clock duration in milliseconds.
*/
const startDurationHistogram = meter.createHistogram(
"gundb_server.start_duration_ms",
{
description:
"Wall-clock duration of each GunDBServer.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("gundb_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("gundb_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 });
}
/** Outcome label for {@link recordMessageRelayed}. */
export type MessageRelayOutcome = "ok" | "error";
/** Increment the relayed-messages counter. */
export function recordMessageRelayed(outcome: MessageRelayOutcome): void {
messagesRelayedCounter.add(1, { outcome });
}
/** Increment the invalid-messages counter. */
export function recordInvalidMessage(reason: InvalidWirePayloadReason): void {
invalidMessagesCounter.add(1, { reason });
}
/** Adjust the active-connections gauge by `delta` (+1 / -1). */
export function adjustActiveConnections(delta: number): void {
if (delta === 0) return;
activeConnectionsGauge.add(delta);
}
/** Record one observation of `start()` wall-clock duration. */
export function recordStartDuration(ms: number): void {
startDurationHistogram.record(Math.max(0, ms));
}

View File

@@ -0,0 +1,54 @@
# listen-http
## Purpose
Free function that binds a `node:http` server to `(port, host)` and translates the resulting Node EventEmitter signals into a Promise-rejecting error envelope keyed off `NET_GUNDB_SERVER_CODES`. Extracted out of `GunDBServer.#listen` so the generic-errno branch can be exercised in a unit test — Bun's `http.Server.listen` coerces every listen failure to `EADDRINUSE` through the public surface, making the non-EADDRINUSE branch otherwise unreachable from `GunDBServer.start()` under Bun.
## Features
- Maps `EADDRINUSE` to `NET_GUNDB_SERVER.PORT_IN_USE`.
- Maps every other errno to a generic `NET_GUNDB_SERVER.START_FAILED`.
- Maps a fired `AbortSignal` to `START_FAILED` with the dedicated `gundb_server.aborted` message key.
- Cleans up listeners on every resolution path (success, error, abort).
## Usage: Basic
```ts
import { createServer } from "node:http";
import { listenHttp } from "@suaveplan/network-gundb-server/server/listen-http";
const http = createServer();
await listenHttp(http, 8765, "127.0.0.1", undefined);
```
## Usage: Advanced
```ts
import { createServer } from "node:http";
import { listenHttp } from "@suaveplan/network-gundb-server/server/listen-http";
const http = createServer();
const ctrl = new AbortController();
queueMicrotask(() => ctrl.abort());
try {
await listenHttp(http, 8765, "127.0.0.1", ctrl.signal);
} catch (err) {
// err.code === NET_GUNDB_SERVER_CODES.START_FAILED
// err.messageKey === "gundb_server.aborted"
}
```
## API Reference
### `listenHttp(http, port, host, signal): Promise<void>`
- `http` — A `node:http.Server` instance.
- `port` — Port number (use `0` for an ephemeral port).
- `host` — Host name or IP address to bind.
- `signal` — Optional `AbortSignal` that, when fired, rejects the promise and `close()`s the server.
Resolves once the underlying `listening` event fires. Rejects with a `NetGunDBServerError` keyed appropriately.
## Implementation Notes
The non-EADDRINUSE branch is intentionally distinct from `EADDRINUSE` so callers (operators, dashboards) can distinguish "port collision" from "real fault" — the former is recoverable by retrying on another port, the latter usually indicates a misconfigured host or permission problem. The `gundb_server.aborted` message key on the abort path is separate from the listen-failure key so observability can count abort-driven rollbacks separately from listen failures.

View File

@@ -0,0 +1,78 @@
import { createServer } from "node:http";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NET_GUNDB_SERVER_CODES,
NetGunDBServerError,
} from "../errors/errors.js";
import { listenHttp } from "./listen-http.js";
describe("listenHttp", () => {
it("resolves once the listening event fires", {
timeout: 5000,
}, async () => {
const http = createServer();
try {
await listenHttp(http, 0, "127.0.0.1", undefined);
expect(http.listening).toBe(true);
} finally {
await new Promise<void>((r) => http.close(() => r()));
}
});
it("maps EADDRINUSE to PORT_IN_USE via synthetic error", {
timeout: 5000,
}, async () => {
const http = createServer();
const promise = listenHttp(http, 0, "127.0.0.1", undefined);
queueMicrotask(() => {
const err: NodeJS.ErrnoException = new Error("synthetic");
err.code = "EADDRINUSE";
http.emit("error", err);
});
try {
await expect(promise).rejects.toMatchObject({
code: NET_GUNDB_SERVER_CODES.PORT_IN_USE,
});
} finally {
await new Promise<void>((r) => http.close(() => r()));
}
});
it("maps non-EADDRINUSE errno to START_FAILED via synthetic error", {
timeout: 5000,
}, async () => {
// Pin the generic-errno branch deterministically — Bun's
// http.Server.listen coerces every listen failure to EADDRINUSE
// through the public surface, so this branch is unreachable from
// GunDBServer.start() under Bun without a synthetic error.
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_GUNDB_SERVER_CODES.START_FAILED,
});
} finally {
await new Promise<void>((r) => http.close(() => r()));
}
});
it("rejects with aborted when AbortSignal fires before listening", {
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.toBeInstanceOf(NetGunDBServerError);
} finally {
await new Promise<void>((r) => http.close(() => r()));
}
});
});

View File

@@ -0,0 +1,75 @@
import type { Server as HttpServer } from "node:http";
import {
NET_GUNDB_SERVER_CODES,
NetGunDBServerError,
} from "../errors/errors.js";
/**
* Bind a Node `http.Server` to `(port, host)` and resolve once the
* `listening` event fires. Maps `EADDRINUSE` to `PORT_IN_USE` with
* `messageKey = "gundb_server.port-in-use"`, every other errno to a
* generic `START_FAILED`, and a fired `AbortSignal` to `START_FAILED`
* with `messageKey = "gundb_server.aborted"` so callers can roll back
* the broker and unwind cleanly. Extracted out of `server.ts` so the
* generic-errno branch can be exercised directly (Bun's
* `http.Server.listen` coerces every listen failure to `EADDRINUSE`
* via the public surface).
*/
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 NetGunDBServerError(
NET_GUNDB_SERVER_CODES.PORT_IN_USE,
"gundb_server.port-in-use",
{ cause: err, context: { port } }
)
);
return;
}
reject(
new NetGunDBServerError(
NET_GUNDB_SERVER_CODES.START_FAILED,
"gundb_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 NetGunDBServerError(
NET_GUNDB_SERVER_CODES.START_FAILED,
"gundb_server.aborted"
)
);
};
http.once("error", onError);
http.once("listening", onListening);
if (signal !== undefined) {
signal.addEventListener("abort", onAbort, { once: true });
}
http.listen(port, host);
});
}

View File

@@ -0,0 +1,114 @@
# `network-gundb-server/server`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GunDBServer` is the server-tier daemon class that hosts a GunDB relay
peer plus inline health/readiness/liveness endpoints on a Node
`http.Server`. Browser-tier clients running
`@suaveplan/network-discovery-gundb` pass `http(s)://<host>:<port>/gun`
in their `peers[]` list and gossip signed peer-announces through the
relay. The daemon owns the HTTP listener, attaches a Gun instance via
an injected factory (production wires real `Gun`, tests bind a
deterministic fake), subscribes to the announce room, validates every
inbound record against the shared Zod schema, and tracks per-peer
connection state for observability.
The daemon is single-purpose — it does not handle authentication,
TLS termination, or rate-limiting. Those concerns live in front of the
daemon (reverse proxy / API gateway) or in the security-layer plugins
on the client side. The relay's sole defensive responsibility is to
drop structurally-invalid wire payloads so a misbehaving client cannot
poison the namespace.
## Features
- `start({ signal? })` and `stop()` lifecycle, idempotent on stop.
- Injectable `gunFactory` — never resolves `gun` at module-eval time.
- Inline `/health` / `/ready` / `/live` JSON probes on the same HTTP
listener as the relay.
- Structural Zod wire-payload validation on every inbound announce,
with counter-only rejections.
- Per-peer tracking + `peer-connected` / `peer-disconnected` events on
the optional `EventBusLike`.
- `maxConnections` cap with `connected/disconnected/rejected` counter
outcomes.
- AbortSignal threaded through `start()` for graceful cancellation.
- OTel spans + counters + gauge + histogram.
## Usage: Basic Example
```ts
import { GunDBServer } from "@suaveplan/network-gundb-server";
import Gun from "gun";
const server = new GunDBServer({
port: 8765,
gunFactory: (cfg) => Gun(cfg),
});
const { address, port } = await server.start();
console.log(`relay listening on ${address}:${port}`);
// ... later
await server.stop();
```
## Usage: Advanced Example
```ts
import { EventBus } from "@suaveplan/events";
import { GunDBServer, type GunDBServerEventMap } from "@suaveplan/network-gundb-server";
import Gun from "gun";
const bus = new EventBus<GunDBServerEventMap>();
bus.on("gundb_server:peer-connected", (e) =>
console.log(`peer joined: ${e.peerId}`)
);
const controller = new AbortController();
const server = new GunDBServer({
port: 8765,
host: "0.0.0.0",
peers: ["https://upstream.example.com/gun"],
storagePath: "./data/gun",
radisk: true,
maxConnections: 256,
room: "suaveplan-discovery",
bus,
gunFactory: (cfg) => Gun(cfg),
});
process.on("SIGTERM", () => controller.abort());
await server.start({ signal: controller.signal });
```
## API Reference
- `new GunDBServer(config)` — constructor, validates via Zod;
throws `INVALID_OPTIONS`.
- `start({ signal? })` — `Promise<{ address, port }>`. Throws
`START_FAILED` / `PORT_IN_USE` / `DISPOSED`.
- `stop()` — `Promise<void>`. Idempotent.
- `peers()` — readonly snapshot of every observed peer.
- `activeConnections` — getter, live count.
- `snapshot()` — `HealthSnapshot` for probe responses.
- `config` — `ResolvedGunDBServerConfig`.
## Implementation Notes
- The HTTP listener is wired with `handleHealth` first so probes never
reach Gun's WebSocket upgrade handler — this matters under load
because Gun's handler is synchronous-heavy.
- `stop()` flips `disposed` before draining so any in-flight inbound
handler call sees `running=false` and skips state mutation.
- `AbortSignal` is honoured _before_ the `listen()` syscall fires
(pre-aborted signal short-circuits) and _during_ listen (mid-flight
abort calls `close()` on the listener and rejects the start promise).
- The relay does not verify Ed25519 signatures on inbound announces —
that is the client's responsibility per the gossip security model.
Structural validation (presence + length of signature fields) is
enforced to prevent shape-poisoning attacks.
- `peer-connected` is emitted on the _first_ valid announce seen for
a `peerId`; subsequent announces refresh `lastSeen` and bump
`messagesRelayed`. There is no explicit "leave" semantics — peers
age out via the client's `peerTtlMs` mechanism.

View File

@@ -0,0 +1,416 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike } from "@suaveplan/types";
import { FakeGun, makeFakeGunFactory } from "../../tests/setup/fake-gun.js";
import {
NET_GUNDB_SERVER_CODES,
NetGunDBServerError,
} from "../errors/errors.js";
import type { GunDBServerConfig, GunDBServerEventMap } from "../types/types.js";
import { GunDBServer } from "./server.js";
const VALID_ANNOUNCE = {
kind: "peer-announce" as const,
peerId: "peer-A",
addresses: ["ws://1.2.3.4:80"],
metadata: {},
capabilities: [],
lastSeen: 1_700_000_000_000,
signaturePublicKey: "a".repeat(64),
signature: "b".repeat(128),
};
function makeRecordingBus(): {
readonly bus: EventBusLike<GunDBServerEventMap>;
readonly events: Array<{ readonly topic: keyof GunDBServerEventMap }>;
} {
const events: Array<{ readonly topic: keyof GunDBServerEventMap }> = [];
const bus: EventBusLike<GunDBServerEventMap> = {
on: () => () => {},
emit: (topic) => {
events.push({ topic });
},
};
return { bus, events };
}
function makeThrowingBus(): EventBusLike<GunDBServerEventMap> {
return {
on: () => () => {},
emit: () => {
throw new Error("bus oops");
},
};
}
function baseConfig(
overrides: Partial<GunDBServerConfig> = {}
): GunDBServerConfig {
const { factory } = makeFakeGunFactory();
return {
port: 0,
host: "127.0.0.1",
gunFactory: factory,
...overrides,
};
}
describe("GunDBServer constructor", () => {
it("throws INVALID_OPTIONS on bad config", { timeout: 5000 }, () => {
try {
new GunDBServer({
port: -1,
gunFactory: () => new FakeGun({ web: undefined, peers: [] }),
} as GunDBServerConfig);
throw new Error("should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(NetGunDBServerError);
expect((e as NetGunDBServerError).code).toBe(
NET_GUNDB_SERVER_CODES.INVALID_OPTIONS
);
}
});
it("throws INVALID_OPTIONS when gunFactory is not a function", {
timeout: 5000,
}, () => {
const badFactory: unknown = "nope";
try {
new GunDBServer({
port: 0,
gunFactory: badFactory as GunDBServerConfig["gunFactory"],
});
throw new Error("should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(NetGunDBServerError);
expect((e as NetGunDBServerError).code).toBe(
NET_GUNDB_SERVER_CODES.INVALID_OPTIONS
);
}
});
it("exposes the resolved config", { timeout: 5000 }, () => {
const server = new GunDBServer(baseConfig({ maxConnections: 50 }));
expect(server.config.maxConnections).toBe(50);
expect(server.config.room).toBe("suaveplan-discovery");
expect(server.config.host).toBe("127.0.0.1");
});
});
describe("GunDBServer lifecycle", () => {
it("start → snapshot → stop round-trip", { timeout: 5000 }, async () => {
const server = new GunDBServer(baseConfig());
const { address, port } = await server.start();
expect(typeof address).toBe("string");
expect(typeof port).toBe("number");
expect(port).toBeGreaterThan(0);
expect(server.boundAddress).toBeDefined();
expect(server.boundPort).toBeGreaterThan(0);
const snap = server.snapshot();
expect(snap.running).toBe(true);
expect(snap.disposed).toBe(false);
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 GunDBServer(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 GunDBServer(baseConfig());
await server.start();
await server.stop();
await expect(server.start()).rejects.toMatchObject({
code: NET_GUNDB_SERVER_CODES.DISPOSED,
});
});
it("rejects second start() while running", { timeout: 5000 }, async () => {
const server = new GunDBServer(baseConfig());
await server.start();
await expect(server.start()).rejects.toMatchObject({
code: NET_GUNDB_SERVER_CODES.START_FAILED,
});
await server.stop();
});
it("rejects start() when signal is pre-aborted", {
timeout: 5000,
}, async () => {
const server = new GunDBServer(baseConfig());
const ctrl = new AbortController();
ctrl.abort();
await expect(
server.start({ signal: ctrl.signal })
).rejects.toMatchObject({
code: NET_GUNDB_SERVER_CODES.START_FAILED,
});
});
it("surfaces PORT_IN_USE when bind collides", {
timeout: 8000,
}, async () => {
const a = new GunDBServer(baseConfig());
const { port } = await a.start();
const b = new GunDBServer(baseConfig({ port }));
try {
await expect(b.start()).rejects.toMatchObject({
code: NET_GUNDB_SERVER_CODES.PORT_IN_USE,
});
} finally {
await a.stop();
await b.stop();
}
});
it("propagates a factory throw as START_FAILED", {
timeout: 5000,
}, async () => {
const server = new GunDBServer({
port: 0,
host: "127.0.0.1",
gunFactory: () => {
throw new Error("factory boom");
},
});
await expect(server.start()).rejects.toMatchObject({
code: NET_GUNDB_SERVER_CODES.START_FAILED,
});
await server.stop();
});
it("forwards storagePath to the gun factory when configured", {
timeout: 5000,
}, async () => {
const seen: Array<Record<string, unknown>> = [];
const { factory } = makeFakeGunFactory();
const wrappedFactory: typeof factory = (cfg) => {
seen.push(cfg as unknown as Record<string, unknown>);
return factory(cfg);
};
const server = new GunDBServer({
port: 0,
host: "127.0.0.1",
gunFactory: wrappedFactory,
storagePath: "./tmp/gun-data",
});
await server.start();
expect(seen[0]?.["file"]).toBe("./tmp/gun-data");
await server.stop();
});
it("starts successfully with a non-aborted signal attached", {
timeout: 5000,
}, async () => {
const server = new GunDBServer(baseConfig());
const ctrl = new AbortController();
const { port } = await server.start({ signal: ctrl.signal });
expect(port).toBeGreaterThan(0);
await server.stop();
});
it("surfaces PORT_IN_USE with signal attached (signal cleanup branch)", {
timeout: 8000,
}, async () => {
const a = new GunDBServer(baseConfig());
const { port } = await a.start();
const b = new GunDBServer(baseConfig({ port }));
const ctrl = new AbortController();
try {
await expect(
b.start({ signal: ctrl.signal })
).rejects.toMatchObject({
code: NET_GUNDB_SERVER_CODES.PORT_IN_USE,
});
} finally {
await a.stop();
await b.stop();
}
});
it("surfaces non-EADDRINUSE listen failures as START_FAILED", {
timeout: 5000,
}, async () => {
// Bun's `http.Server.listen` coerces every listen failure into
// a synthetic `EADDRINUSE` error regardless of the underlying
// cause (RFC 2606 `.invalid` hostnames, unassignable IPs,
// anything). Under Bun this test cannot exercise the generic
// non-EADDRINUSE branch through the public surface, so skip it
// there — the equivalent START_FAILED path is still covered by
// the "aborts mid-start when signal fires during listen" test.
if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") {
return;
}
const { factory } = makeFakeGunFactory();
const server = new GunDBServer({
port: 0,
host: "nope.invalid",
gunFactory: factory,
});
await expect(server.start()).rejects.toMatchObject({
code: NET_GUNDB_SERVER_CODES.START_FAILED,
});
await server.stop();
});
it("aborts mid-start when signal fires during listen", {
timeout: 5000,
}, async () => {
const server = new GunDBServer(baseConfig());
const ctrl = new AbortController();
// Race: schedule abort on the next tick so it fires before listen
// resolves on slow machines, but after the listener attaches.
queueMicrotask(() => ctrl.abort());
await expect(
server.start({ signal: ctrl.signal })
).rejects.toMatchObject({
code: NET_GUNDB_SERVER_CODES.START_FAILED,
});
});
});
describe("GunDBServer wire-payload validation", () => {
it("drops malformed payloads silently (no emit, no peer)", {
timeout: 5000,
}, async () => {
const { bus, events } = makeRecordingBus();
const { factory, instances } = makeFakeGunFactory();
const server = new GunDBServer({
port: 0,
host: "127.0.0.1",
gunFactory: factory,
bus,
});
await server.start();
const gun = instances[0];
if (!gun) throw new Error("no gun instance");
gun.inject("suaveplan-discovery", "bad-1", { junk: true });
expect(server.peers()).toHaveLength(0);
expect(events.map((e) => e.topic)).toContain("gundb_server:started");
expect(events.map((e) => e.topic)).not.toContain(
"gundb_server:peer-connected"
);
await server.stop();
});
it("admits valid signed announces and emits peer-connected", {
timeout: 5000,
}, async () => {
const { bus, events } = makeRecordingBus();
const { factory, instances } = makeFakeGunFactory();
const server = new GunDBServer({
port: 0,
host: "127.0.0.1",
gunFactory: factory,
bus,
});
await server.start();
const gun = instances[0];
if (!gun) throw new Error("no gun instance");
gun.inject("suaveplan-discovery", "peer-A", VALID_ANNOUNCE);
expect(server.activeConnections).toBe(1);
expect(server.peers()).toHaveLength(1);
expect(events.map((e) => e.topic)).toContain(
"gundb_server:peer-connected"
);
// Re-announce: lastSeen refreshes, no extra peer.
gun.inject("suaveplan-discovery", "peer-A", VALID_ANNOUNCE);
expect(server.activeConnections).toBe(1);
await server.stop();
});
it("rejects new peers past maxConnections", { timeout: 5000 }, async () => {
const { factory, instances } = makeFakeGunFactory();
const server = new GunDBServer({
port: 0,
host: "127.0.0.1",
gunFactory: factory,
maxConnections: 1,
});
await server.start();
const gun = instances[0];
if (!gun) throw new Error("no gun instance");
gun.inject("suaveplan-discovery", "peer-A", VALID_ANNOUNCE);
gun.inject("suaveplan-discovery", "peer-B", {
...VALID_ANNOUNCE,
peerId: "peer-B",
});
expect(server.activeConnections).toBe(1);
expect(server.peers()).toHaveLength(1);
await server.stop();
});
it("re-marks a previously-tracked but-disconnected peer as connected", {
timeout: 5000,
}, async () => {
// This exercises the `!existing.connected` branch in the
// inbound handler.
const { factory, instances } = makeFakeGunFactory();
const server = new GunDBServer({
port: 0,
host: "127.0.0.1",
gunFactory: factory,
});
await server.start();
const gun = instances[0];
if (!gun) throw new Error("no gun instance");
gun.inject("suaveplan-discovery", "peer-A", VALID_ANNOUNCE);
// Mark the peer disconnected via a direct mutation to simulate
// a connection drop without invoking stop().
const peer = server.peers()[0];
if (!peer) throw new Error("expected peer");
(peer as { connected: boolean }).connected = false;
gun.inject("suaveplan-discovery", "peer-A", VALID_ANNOUNCE);
expect(server.peers()[0]?.connected).toBe(true);
await server.stop();
});
});
describe("GunDBServer bus error handling", () => {
it("swallows throws from a misbehaving bus", {
timeout: 5000,
}, async () => {
const bus = makeThrowingBus();
const { factory, instances } = makeFakeGunFactory();
const server = new GunDBServer({
port: 0,
host: "127.0.0.1",
gunFactory: factory,
bus,
});
await server.start();
const gun = instances[0];
if (!gun) throw new Error("no gun");
gun.inject("suaveplan-discovery", "peer-A", VALID_ANNOUNCE);
await server.stop();
});
it("emits peer-disconnected for tracked peers on stop", {
timeout: 5000,
}, async () => {
const { bus, events } = makeRecordingBus();
const { factory, instances } = makeFakeGunFactory();
const server = new GunDBServer({
port: 0,
host: "127.0.0.1",
gunFactory: factory,
bus,
});
await server.start();
const gun = instances[0];
if (!gun) throw new Error("no gun");
gun.inject("suaveplan-discovery", "peer-A", VALID_ANNOUNCE);
await server.stop();
expect(events.map((e) => e.topic)).toContain(
"gundb_server:peer-disconnected"
);
expect(events.map((e) => e.topic)).toContain("gundb_server:stopped");
});
});

View File

@@ -0,0 +1,331 @@
// 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_GUNDB_SERVER_CODES,
NetGunDBServerError,
} from "../errors/errors.js";
import { type HealthSnapshot, handleHealth } from "../health/health.js";
import {
adjustActiveConnections,
recordConnection,
recordMessageRelayed,
recordStartDuration,
withStartSpan,
withStopSpan,
} from "../otel/otel.js";
import type {
GunDBServerConfig,
GunDBServerEventMap,
GunDBServerPeerInfo,
GunDBServerStartResult,
GunFactory,
GunNodeLike,
ResolvedGunDBServerConfig,
} from "../types/types.js";
import { GunDBServerConfigSchema } from "../types/types.schemas.js";
import { validateInboundAnnounce } from "../validation/validation.js";
import { listenHttp } from "./listen-http.js";
/**
* Server-tier GunDB relay daemon. Hosts an HTTP server with health
* endpoints, attaches a Gun relay (via injected factory) to that
* server, and applies structural Zod validation on every inbound
* announce so a malformed payload from a misbehaving client cannot
* poison the namespace.
*
* 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 GunDBServer {
readonly #resolved: ResolvedGunDBServerConfig;
#http: HttpServer | undefined;
#gun: GunNodeLike | undefined;
#started = false;
#disposed = false;
#startedAt: number | undefined;
#boundAddress: string | undefined;
#boundPort: number | undefined;
readonly #peers = new Map<string, GunDBServerPeerInfo>();
public constructor(config: GunDBServerConfig) {
const parsed = GunDBServerConfigSchema.safeParse(config);
if (!parsed.success) {
throw new NetGunDBServerError(
NET_GUNDB_SERVER_CODES.INVALID_OPTIONS,
"gundb_server.invalid-config",
{ context: { issues: parsed.error.issues } }
);
}
if (typeof config.gunFactory !== "function") {
throw new NetGunDBServerError(
NET_GUNDB_SERVER_CODES.INVALID_OPTIONS,
"gundb_server.factory-required"
);
}
this.#resolved = {
port: parsed.data.port,
host: parsed.data.host,
peers: parsed.data.peers,
storagePath: parsed.data.storagePath,
localStorage: parsed.data.localStorage,
radisk: parsed.data.radisk,
gunFactory: config.gunFactory satisfies GunFactory,
maxConnections: parsed.data.maxConnections,
room: parsed.data.room,
bus: config.bus,
};
}
/** Resolved configuration snapshot. */
public get config(): ResolvedGunDBServerConfig {
return this.#resolved;
}
/** Current attached peer count. */
public get activeConnections(): number {
return Array.from(this.#peers.values()).filter((p) => p.connected)
.length;
}
/** Snapshot of every peer the relay has observed. */
public peers(): readonly GunDBServerPeerInfo[] {
return Array.from(this.#peers.values());
}
/** Read the daemon's runtime state for health-probe responses. */
public snapshot(): HealthSnapshot {
return {
running: this.#started,
disposed: this.#disposed,
activeConnections: this.activeConnections,
maxConnections: this.#resolved.maxConnections,
startedAt: this.#startedAt,
};
}
/**
* Boot the HTTP server, attach a Gun relay, subscribe to the
* announce namespace, and resolve with `{ address, port }`.
*
* @throws NET_GUNDB_SERVER.DISPOSED — already stopped.
* @throws NET_GUNDB_SERVER.START_FAILED — listen failed.
* @throws NET_GUNDB_SERVER.PORT_IN_USE — EADDRINUSE on listen.
*/
public async start(options?: {
readonly signal?: AbortSignal;
}): Promise<GunDBServerStartResult> {
if (this.#disposed) {
throw new NetGunDBServerError(
NET_GUNDB_SERVER_CODES.DISPOSED,
"gundb_server.start-after-stop"
);
}
if (this.#started) {
throw new NetGunDBServerError(
NET_GUNDB_SERVER_CODES.START_FAILED,
"gundb_server.already-started"
);
}
const signal = options?.signal;
if (signal?.aborted === true) {
throw new NetGunDBServerError(
NET_GUNDB_SERVER_CODES.START_FAILED,
"gundb_server.aborted"
);
}
const t0 = Date.now();
return withStartSpan(async () => {
const http = createServer((req, res) => {
if (handleHealth(req, res, this.snapshot())) return;
// Anything other than a health verb is the Gun relay
// namespace. Real Gun attaches its own request handler
// via the `web` factory option; the fallback is a
// 200/OK marker for unrouted GETs so probes still see
// a live process.
res.writeHead(200, { "content-type": "text/plain" });
res.end("Gun relay peer");
});
this.#http = http;
await this.#listen(http, signal);
try {
const factoryConfig: Record<string, unknown> = {
web: http,
peers: this.#resolved.peers,
localStorage: this.#resolved.localStorage,
radisk: this.#resolved.radisk,
};
if (this.#resolved.storagePath !== undefined) {
factoryConfig["file"] = this.#resolved.storagePath;
}
this.#gun = await this.#resolved.gunFactory(
factoryConfig as unknown as Parameters<GunFactory>[0]
);
} catch (cause) {
await this.#closeHttp();
throw new NetGunDBServerError(
NET_GUNDB_SERVER_CODES.START_FAILED,
"gundb_server.gun-factory-failed",
{ cause: cause as Error }
);
}
this.#subscribeAnnounceRoom(this.#gun);
// `http.listen` has resolved (`await this.#listen` above)
// so Node guarantees `http.address()` returns the
// `AddressInfo` object. We cast directly — the null
// fallback would only fire if the listener silently
// disappeared between `listen` resolving and this line,
// which is unreachable in practice.
const addr = http.address() as { address: string; port: number };
const address = addr.address;
const port = addr.port;
this.#boundAddress = address;
this.#boundPort = port;
this.#started = true;
this.#startedAt = Date.now();
recordStartDuration(this.#startedAt - t0);
this.#emit("gundb_server:started", {
address,
port,
at: this.#startedAt,
});
return { address, port };
});
}
/**
* Drain in-flight messages, detach the Gun relay, close the HTTP
* server, and clear state. Safe to call multiple times.
*/
public async stop(): Promise<void> {
if (this.#disposed) return;
return withStopSpan(async () => {
this.#disposed = true;
this.#started = false;
try {
this.#gun?.off();
} catch {
// Real Gun's off() is best-effort.
}
const peerIds = Array.from(this.#peers.keys());
this.#peers.clear();
for (const id of peerIds) {
adjustActiveConnections(-1);
recordConnection("disconnected");
this.#emit("gundb_server:peer-disconnected", {
peerId: id,
at: Date.now(),
});
}
await this.#closeHttp();
this.#gun = undefined;
this.#emit("gundb_server:stopped", { at: Date.now() });
});
}
// ───────────────────────── private ─────────────────────────
#listen(http: HttpServer, signal: AbortSignal | undefined): Promise<void> {
return listenHttp(
http,
this.#resolved.port,
this.#resolved.host,
signal
);
}
async #closeHttp(): Promise<void> {
const http = this.#http;
if (!http) return;
this.#http = undefined;
await new Promise<void>((resolve) => {
http.close(() => resolve());
});
}
/**
* Subscribe to the announce room. Every inbound record is
* structurally validated; rejections are silent (counter only).
* The relay forwards valid frames via its lifecycle event bus —
* actual byte-for-byte gossip is handled by Gun itself; this hook
* is the relay's *observation* point for tracking connected peers
* and emitting `peer-connected` / `peer-disconnected` events.
*/
#subscribeAnnounceRoom(gun: GunNodeLike): void {
gun.get(this.#resolved.room)
.map()
.on((data, key) => this.#onInboundAnnounce(data, key));
}
#onInboundAnnounce(data: unknown, key: string): void {
const result = validateInboundAnnounce(data);
if (!result.ok) {
recordMessageRelayed("error");
return;
}
const payload = result.payload as { readonly peerId: string };
const peerId = payload.peerId;
const existing = this.#peers.get(peerId);
const now = Date.now();
if (existing === undefined) {
// Reject if over capacity.
if (this.activeConnections >= this.#resolved.maxConnections) {
recordConnection("rejected");
recordMessageRelayed("error");
return;
}
const record: GunDBServerPeerInfo = {
id: peerId,
address: key,
connected: true,
lastSeen: now,
messagesRelayed: 1,
};
this.#peers.set(peerId, record);
adjustActiveConnections(+1);
recordConnection("connected");
recordMessageRelayed("ok");
this.#emit("gundb_server:peer-connected", {
peerId,
at: now,
});
return;
}
existing.lastSeen = now;
existing.messagesRelayed += 1;
if (!existing.connected) {
existing.connected = true;
adjustActiveConnections(+1);
recordConnection("connected");
this.#emit("gundb_server:peer-connected", {
peerId,
at: now,
});
}
recordMessageRelayed("ok");
}
#emit<K extends keyof GunDBServerEventMap>(
topic: K,
payload: GunDBServerEventMap[K]
): void {
const bus = this.#resolved.bus;
if (bus === undefined) return;
try {
bus.emit(topic, payload);
} catch {
// Bus failures must not crash the relay.
}
}
/** Test-only inspector for bound address. */
public get boundAddress(): string | undefined {
return this.#boundAddress;
}
/** Test-only inspector for bound port. */
public get boundPort(): number | undefined {
return this.#boundPort;
}
}

View File

@@ -0,0 +1,100 @@
# `network-gundb-server/types`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Public type surface for `@suaveplan/network-gundb-server`. Owns the
`GunDBServerConfig` strict-options struct, the `GunFactory` /
`GunNodeLike` adapter interfaces injected by callers, the
`GunDBServerEventMap` of typed lifecycle events surfaced on the optional
`EventBusLike`, and the supporting per-peer / start-result / invalid-
payload-reason records. Every other module in this package consumes this
file — keeping it framework- and runtime-agnostic ensures the daemon
remains injection-friendly and the test fakes never reach for the real
`gun` module.
## Features
- Strict `GunDBServerConfig` with REQUIRED `gunFactory` (no implicit
default — production wires real `Gun`, tests bind a fake).
- `GunNodeLike` minimal structural surface (`get` / `map` / `on` /
`off`) covering every operation the relay performs.
- `GunFactoryConfig` enumerates exactly which constructor options the
daemon forwards (the rest must be wired inside the caller-supplied
factory).
- Typed `GunDBServerEventMap` for lifecycle pub-sub via
`@suaveplan/types` `EventBusLike`.
- `ResolvedGunDBServerConfig` mirrors the public struct with every
optional field defaulted — what the daemon actually carries
internally.
- `InvalidWirePayloadReason` enumerates the reason labels attached to
the `gundb_server.invalid_messages_total{reason}` counter.
## Usage: Basic Example
```ts
import type { GunDBServerConfig } from "@suaveplan/network-gundb-server";
import Gun from "gun";
const config: GunDBServerConfig = {
port: 8765,
gunFactory: (cfg) => Gun(cfg),
};
```
## Usage: Advanced Example
```ts
import type {
GunDBServerConfig,
GunDBServerEventMap,
} from "@suaveplan/network-gundb-server";
import { EventBus } from "@suaveplan/events";
import Gun from "gun";
const bus = new EventBus<GunDBServerEventMap>();
bus.on("gundb_server:started", (e) =>
console.log(`relay listening on ${e.address}:${e.port}`)
);
const config: GunDBServerConfig = {
port: 8765,
host: "127.0.0.1",
peers: ["https://upstream.example.com/gun"],
storagePath: "./data/gun",
radisk: true,
maxConnections: 256,
room: "suaveplan-discovery",
gunFactory: (cfg) => Gun(cfg),
bus,
};
```
## API Reference
- `GunDBServerConfig` — public, strict options struct.
- `ResolvedGunDBServerConfig` — internal, every optional field
defaulted.
- `GunFactory` / `GunFactoryConfig` / `GunNodeLike` — adapter triple.
- `GunDBServerStartResult` — `{ address, port }` returned by `start()`.
- `GunDBServerPeerInfo` — per-client connection record.
- `GunDBServerEventMap` — typed lifecycle event topics.
- `InvalidWirePayloadReason` — `"malformed" | "wrong-kind" |
"missing-signature" | "missing-public-key"`.
## Implementation Notes
- The `gunFactory` is a hard requirement — even the fake `Gun` used by
tests passes through it. This keeps the runtime dependency optional
(`peerDependenciesMeta.gun.optional = true`) and makes the daemon
trivially substitutable for in-process integration tests.
- The relay does **not** verify Ed25519 signatures on incoming
announces — signature verification is the client's responsibility per
the gossip security model. The relay validates the _structural_
presence of `signature` + `signaturePublicKey` so a malformed
announce cannot poison the namespace, but the signature itself is
passed through unchanged.
- `GunDBServerEventMap` is shaped to interoperate with both
`@suaveplan/events` and any other `EventBusLike` adapter.

View File

@@ -0,0 +1,22 @@
import z from "zod";
/**
* Zod boundary schemas for every public option struct in
* `@suaveplan/network-gundb-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 GunDBServerConfigSchema = z
.object({
port: z.number().int().min(0).max(65535),
host: z.string().min(1).default("0.0.0.0"),
peers: z.array(z.string().url()).readonly().default([]),
storagePath: z.string().min(1).optional(),
localStorage: z.boolean().default(false),
radisk: z.boolean().default(false),
gunFactory: z.unknown(),
maxConnections: z.number().int().positive().default(100),
room: z.string().min(1).default("suaveplan-discovery"),
bus: z.unknown().optional(),
})
.strict();

View File

@@ -0,0 +1,155 @@
import type { EventBusLike } from "@suaveplan/types";
/**
* Structural surface of a Gun instance hosted by the relay daemon.
* Production code wires the real `gun` constructor (which attaches to a
* Node `http.Server` via the `web` option). Tests bind a deterministic
* in-memory implementation that conforms to the same shape. The relay
* never resolves the `gun` module at evaluation time — only via the
* injected factory.
*
* The relay only uses `get` for chained-path subscriptions over the
* announce namespace. `off` releases every listener attached to the
* current path.
*/
export interface GunNodeLike {
get(path: string): GunNodeLike;
map(): GunNodeLike;
on(handler: (data: unknown, key: string) => void): GunNodeLike;
off(): GunNodeLike;
}
/**
* Subset of options the relay daemon forwards to the underlying Gun
* instance. The real Gun constructor accepts many more fields; we
* forward only those the daemon is responsible for. Anything else must
* be wired inside the caller-supplied factory.
*/
export interface GunFactoryConfig {
/** Node `http.Server` instance the Gun relay attaches to. */
readonly web: unknown;
/** Upstream relay URLs (federation). Empty array if standalone. */
readonly peers: readonly string[];
/** Optional Radisk on-disk storage path. */
readonly file?: string | undefined;
/** Toggle Gun's localStorage compatibility layer. */
readonly localStorage?: boolean | undefined;
/** Toggle Radisk and optionally configure chunk size. */
readonly radisk?: boolean | undefined;
}
/**
* Factory wiring `(config) => GunNodeLike`. Production passes
* `(config) => Gun(config)`; tests pass an in-memory deterministic
* implementation. Synchronous return is the canonical shape; the
* factory MAY be async for dynamic imports.
*/
export type GunFactory = (
config: GunFactoryConfig
) => GunNodeLike | Promise<GunNodeLike>;
/** Per-client connection record tracked by the relay. */
export interface GunDBServerPeerInfo {
readonly id: string;
readonly address: string;
connected: boolean;
lastSeen: number;
messagesRelayed: number;
}
/** Typed events emitted on the optional `EventBusLike`. */
export interface GunDBServerStartedEvent {
readonly address: string;
readonly port: number;
readonly at: number;
}
export interface GunDBServerStoppedEvent {
readonly at: number;
}
export interface GunDBServerPeerConnectedEvent {
readonly peerId: string;
readonly at: number;
}
export interface GunDBServerPeerDisconnectedEvent {
readonly peerId: string;
readonly at: number;
}
export interface GunDBServerRelayErrorEvent {
readonly reason: string;
readonly at: number;
}
export type GunDBServerEventMap = {
"gundb_server:started": GunDBServerStartedEvent;
"gundb_server:stopped": GunDBServerStoppedEvent;
"gundb_server:peer-connected": GunDBServerPeerConnectedEvent;
"gundb_server:peer-disconnected": GunDBServerPeerDisconnectedEvent;
"gundb_server:relay-error": GunDBServerRelayErrorEvent;
};
/**
* Strict configuration for `GunDBServer`. The `gunFactory` is REQUIRED —
* production wires the real `Gun` constructor; tests bind a fake. There
* is no implicit default — the daemon never resolves `gun` itself.
*
* The relay binds to an HTTP server on `port` of `host`. Browser-tier
* clients pass `http(s)://<host>:<port>/gun` in their `peers[]` list.
*/
export interface GunDBServerConfig {
/** TCP port the relay listens on. Must be in [0, 65535]. */
readonly port: number;
/** Bind address. Default `"0.0.0.0"`. */
readonly host?: string | undefined;
/** Upstream relay URLs (federation). Default `[]`. */
readonly peers?: readonly string[] | undefined;
/** Optional persistent storage path (Radisk). */
readonly storagePath?: string | undefined;
/** Toggle Gun's localStorage compat layer. Default `false`. */
readonly localStorage?: boolean | undefined;
/** Toggle Radisk. Default `false`. */
readonly radisk?: boolean | undefined;
/** Required factory wiring the underlying Gun instance. */
readonly gunFactory: GunFactory;
/** Hard cap on concurrent client peer connections. Default 100. */
readonly maxConnections?: number | undefined;
/**
* Discovery announce room — must match the value clients pass in
* `@suaveplan/network-discovery-gundb` config. Default
* `"suaveplan-discovery"`. The relay subscribes to this path and
* applies wire-payload validation on every inbound record.
*/
readonly room?: string | undefined;
/** Optional event bus for typed lifecycle emissions. */
readonly bus?: EventBusLike<GunDBServerEventMap> | undefined;
}
/** Fully-resolved configuration — every optional field defaulted. */
export interface ResolvedGunDBServerConfig {
readonly port: number;
readonly host: string;
readonly peers: readonly string[];
readonly storagePath: string | undefined;
readonly localStorage: boolean;
readonly radisk: boolean;
readonly gunFactory: GunFactory;
readonly maxConnections: number;
readonly room: string;
readonly bus: EventBusLike<GunDBServerEventMap> | undefined;
}
/** Result of `GunDBServer.start()`. */
export interface GunDBServerStartResult {
readonly address: string;
readonly port: number;
}
/** Reasons an inbound wire payload may be silently dropped. */
export type InvalidWirePayloadReason =
| "malformed"
| "wrong-kind"
| "missing-signature"
| "missing-public-key";

View File

@@ -0,0 +1,85 @@
# `network-gundb-server/validation`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wire-payload validation for inbound `peer-announce` records arriving on
the GunDB relay namespace. The relay enforces the same structural
contract the browser-tier `@suaveplan/network-discovery-gundb` client
enforces on outbound frames — Zod-validated discriminated union with
mandatory `kind`, `signature`, and `signaturePublicKey` fields — so a
single misbehaving client cannot poison the namespace with malformed
records that every other client would then have to filter on read.
The relay deliberately does **not** verify the Ed25519 signature
itself: cryptographic verification is a client responsibility per the
gossip security model. The relay only confirms that the payload is
shape-correct and carries the signature material, then passes it
through unchanged so authorized clients can verify it locally.
## Features
- Structural Zod validation against the shared
`GunDBPeerAnnounceSchema` exported by the companion client package
(single source of truth — server and client cannot drift).
- Distinct rejection reasons for low-cardinality OTel labelling:
`"malformed"`, `"wrong-kind"`, `"missing-signature"`,
`"missing-public-key"`.
- Silent rejection: every rejection increments the
`gundb_server.invalid_messages_total{reason}` counter; the function
returns a discriminated union and never throws.
- Strips Gun's internal `_` metadata key before validation so the
payload shape is invariant under the relay's wire envelope.
## Usage: Basic Example
```ts
import { validateInboundAnnounce } from "@suaveplan/network-gundb-server";
const result = validateInboundAnnounce(rawRecord);
if (result.ok) {
relayForward(result.payload);
}
```
## Usage: Advanced Example
```ts
import { validateInboundAnnounce } from "@suaveplan/network-gundb-server";
gun.get("suaveplan-discovery")
.map()
.on((data) => {
const result = validateInboundAnnounce(data);
if (!result.ok) {
// Counter already incremented in the helper.
log.debug("dropped malformed announce", { reason: result.reason });
return;
}
forwardToSubscribers(result.payload);
});
```
## API Reference
- `WirePayloadValidation` — discriminated union
`{ ok: true; payload } | { ok: false; reason }`.
- `validateInboundAnnounce(raw)` — structural validator. Side-effect:
increments `gundb_server.invalid_messages_total{reason}` on
rejection. Never throws.
## Implementation Notes
- The function performs a fast structural pre-check before invoking
the full Zod schema so the `reason` label is informative — a Zod
failure alone cannot distinguish "missing signature" from
"malformed JSON".
- Pulling the Zod schema from the companion client package guarantees
the server and client agree on wire format — a breaking change to
the schema breaks both packages' tests simultaneously, which is the
correct failure mode.
- Returning a discriminated union (rather than throwing) is
load-bearing: the relay's inbound handler must remain crash-proof
even when an adversarial peer floods garbage records.

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { validateInboundAnnounce } from "./validation.js";
const VALID = {
kind: "peer-announce" as const,
peerId: "peer-1",
addresses: ["ws://1.2.3.4:80"],
metadata: {},
capabilities: [],
lastSeen: 1_700_000_000_000,
signaturePublicKey: "a".repeat(64),
signature: "b".repeat(128),
};
describe("validateInboundAnnounce", () => {
it("accepts a structurally valid announce", { timeout: 5000 }, () => {
const r = validateInboundAnnounce(VALID);
expect(r.ok).toBe(true);
if (r.ok) expect(r.payload).toMatchObject({ peerId: "peer-1" });
});
it("strips Gun's `_` metadata wrapper before validating", {
timeout: 5000,
}, () => {
const r = validateInboundAnnounce({ ...VALID, _: { ">": 1 } });
expect(r.ok).toBe(true);
});
it("rejects null", { timeout: 5000 }, () => {
const r = validateInboundAnnounce(null);
expect(r).toEqual({ ok: false, reason: "malformed" });
});
it("rejects non-object", { timeout: 5000 }, () => {
expect(validateInboundAnnounce("string")).toEqual({
ok: false,
reason: "malformed",
});
expect(validateInboundAnnounce(42)).toEqual({
ok: false,
reason: "malformed",
});
});
it("rejects wrong kind", { timeout: 5000 }, () => {
const r = validateInboundAnnounce({ ...VALID, kind: "leave" });
expect(r).toEqual({ ok: false, reason: "wrong-kind" });
});
it("rejects missing signature", { timeout: 5000 }, () => {
const { signature: _drop, ...rest } = VALID;
const r = validateInboundAnnounce(rest);
expect(r).toEqual({ ok: false, reason: "missing-signature" });
});
it("rejects missing public key", { timeout: 5000 }, () => {
const { signaturePublicKey: _drop, ...rest } = VALID;
const r = validateInboundAnnounce(rest);
expect(r).toEqual({ ok: false, reason: "missing-public-key" });
});
it("rejects bad-length signature as malformed", { timeout: 5000 }, () => {
const r = validateInboundAnnounce({ ...VALID, signature: "short" });
expect(r).toEqual({ ok: false, reason: "malformed" });
});
it("rejects extra unknown keys as malformed", { timeout: 5000 }, () => {
const r = validateInboundAnnounce({ ...VALID, extra: 1 });
expect(r).toEqual({ ok: false, reason: "malformed" });
});
});

View File

@@ -0,0 +1,64 @@
import { GunDBPeerAnnounceSchema } from "@suaveplan/network-discovery-gundb";
import { recordInvalidMessage } from "../otel/otel.js";
import type { InvalidWirePayloadReason } from "../types/types.js";
/**
* Result of validating one inbound wire payload. The relay never throws
* on rejection — it returns a discriminated union so the caller can
* forward valid frames and silently increment the
* `gundb_server.invalid_messages_total{reason}` counter on rejections.
*/
export type WirePayloadValidation =
| { readonly ok: true; readonly payload: unknown }
| { readonly ok: false; readonly reason: InvalidWirePayloadReason };
/**
* Structurally validate one inbound `peer-announce` record received on
* the GunDB relay namespace. **Does NOT verify the Ed25519 signature
* itself** — signature verification is the client's responsibility per
* the gossip security model. The relay validates that the payload is
* shape-correct AND carries the two signature fields so a tampered
* announce cannot poison the namespace.
*
* Side effect: on rejection, the corresponding
* `gundb_server.invalid_messages_total{reason}` counter is incremented.
*/
export function validateInboundAnnounce(raw: unknown): WirePayloadValidation {
// Gun records arrive with a leading `_` metadata key — strip it
// before structural validation so the relay can validate the
// payload shape regardless of Gun's internal envelope.
if (raw === null || typeof raw !== "object") {
recordInvalidMessage("malformed");
return { ok: false, reason: "malformed" };
}
const record = raw as Record<string, unknown>;
const candidate: Record<string, unknown> = {};
for (const [k, v] of Object.entries(record)) {
if (k === "_") continue;
candidate[k] = v;
}
// Fast structural pre-check: missing kind / signature fields are
// surfaced as distinct reasons so dashboards can distinguish a
// genuinely-malformed payload from a "forgot to sign" one.
const kindOk =
typeof candidate["kind"] === "string" &&
candidate["kind"] === "peer-announce";
if (!kindOk) {
recordInvalidMessage("wrong-kind");
return { ok: false, reason: "wrong-kind" };
}
if (typeof candidate["signature"] !== "string") {
recordInvalidMessage("missing-signature");
return { ok: false, reason: "missing-signature" };
}
if (typeof candidate["signaturePublicKey"] !== "string") {
recordInvalidMessage("missing-public-key");
return { ok: false, reason: "missing-public-key" };
}
const parsed = GunDBPeerAnnounceSchema.safeParse(candidate);
if (!parsed.success) {
recordInvalidMessage("malformed");
return { ok: false, reason: "malformed" };
}
return { ok: true, payload: parsed.data };
}

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-gundb-server dep-graph", () => {
it("must not have circular dependencies", { timeout: 5000 }, async () => {
const mod = await import("../src/index.js");
expect(mod).toBeDefined();
expect(mod.GunDBServer).toBeDefined();
expect(mod.NET_GUNDB_SERVER_CODES).toBeDefined();
expect(mod.NetGunDBServerError).toBeDefined();
expect(mod.GunDBServerConfigSchema).toBeDefined();
expect(mod.validateInboundAnnounce).toBeDefined();
expect(mod.handleHealth).toBeDefined();
expect(mod.classifyHealthRequest).toBeDefined();
expect(mod.buildHealthResponse).toBeDefined();
expect(mod.HEALTH_PATHS).toBeDefined();
expect(mod.recordConnection).toBeDefined();
expect(mod.adjustActiveConnections).toBeDefined();
expect(mod.withStartSpan).toBeDefined();
});
});

View File

@@ -0,0 +1,174 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GunDBServer } from "../src/server/server.js";
import { FakeGun, makeFakeGunFactory } from "./setup/fake-gun.js";
const VALID_ANNOUNCE = {
kind: "peer-announce" as const,
peerId: "client-1",
addresses: ["ws://1.2.3.4:80"],
metadata: { tag: "alpha" },
capabilities: ["chat"],
lastSeen: 1_700_000_000_000,
signaturePublicKey: "c".repeat(64),
signature: "d".repeat(128),
};
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 { factory } = makeFakeGunFactory();
const server = new GunDBServer({
port: 0,
host: "127.0.0.1",
gunFactory: factory,
});
const { address, port } = await server.start();
const base = `http://${address === "::" || address === "0.0.0.0" ? "127.0.0.1" : address}:${port}`;
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 relay 200/OK marker.
const fall = await fetch(`${base}/gun`);
expect(fall.status).toBe(200);
await fall.text();
} finally {
await server.stop();
}
});
it("two browser-tier clients relay a signed announce via shared graph", {
timeout: 10_000,
}, async () => {
// The injected FakeGun shares storage across calls; both
// "clients" inject the same announce-room namespace and the
// server observes the inbound frame deterministically. This
// demonstrates the wire path without pulling the real `gun`
// runtime into the test env.
const sharedStore = new Map<string, unknown>();
const sharedListeners = new Map<
string,
Array<(data: unknown, key: string) => void>
>();
const serverFactory = (cfg: {
readonly peers: readonly string[];
}): FakeGun =>
new FakeGun(
{ web: undefined, peers: cfg.peers },
sharedStore,
sharedListeners
);
const server = new GunDBServer({
port: 0,
host: "127.0.0.1",
gunFactory: serverFactory,
});
await server.start();
try {
// Two distinct "clients" inject into the same shared graph
// — the server observes each one as a separate peer.
const clientA = new FakeGun(
{ web: undefined, peers: [] },
sharedStore,
sharedListeners
);
const clientB = new FakeGun(
{ web: undefined, peers: [] },
sharedStore,
sharedListeners
);
clientA.inject("suaveplan-discovery", "client-1", {
...VALID_ANNOUNCE,
peerId: "client-1",
});
clientB.inject("suaveplan-discovery", "client-2", {
...VALID_ANNOUNCE,
peerId: "client-2",
});
expect(server.activeConnections).toBe(2);
const peerIds = server
.peers()
.map((p) => p.id)
.sort();
expect(peerIds).toEqual(["client-1", "client-2"]);
} finally {
await server.stop();
}
});
it("malformed payload from a misbehaving client is dropped (counter only)", {
timeout: 10_000,
}, async () => {
const sharedStore = new Map<string, unknown>();
const sharedListeners = new Map<
string,
Array<(data: unknown, key: string) => void>
>();
const serverFactory = (cfg: {
readonly peers: readonly string[];
}): FakeGun =>
new FakeGun(
{ web: undefined, peers: cfg.peers },
sharedStore,
sharedListeners
);
const server = new GunDBServer({
port: 0,
host: "127.0.0.1",
gunFactory: serverFactory,
});
await server.start();
try {
const badClient = new FakeGun(
{ web: undefined, peers: [] },
sharedStore,
sharedListeners
);
badClient.inject("suaveplan-discovery", "evil-1", {
kind: "wrong",
junk: true,
});
badClient.inject("suaveplan-discovery", "evil-2", null);
badClient.inject("suaveplan-discovery", "evil-3", {
...VALID_ANNOUNCE,
signature: undefined,
});
expect(server.activeConnections).toBe(0);
expect(server.peers()).toHaveLength(0);
} finally {
await server.stop();
}
});
it("graceful stop closes the HTTP listener", {
timeout: 10_000,
}, async () => {
const { factory } = makeFakeGunFactory();
const server = new GunDBServer({
port: 0,
host: "127.0.0.1",
gunFactory: factory,
});
const { address, port } = await server.start();
const base = `http://${address === "::" || address === "0.0.0.0" ? "127.0.0.1" : address}:${port}`;
await server.stop();
// After stop, the socket is closed — connection refused.
await expect(fetch(`${base}/health`)).rejects.toBeDefined();
});
});

View File

@@ -0,0 +1,108 @@
import type { GunFactoryConfig, GunNodeLike } from "../../src/types/types.js";
/**
* In-memory deterministic implementation of the `GunNodeLike` surface
* used by the relay daemon. The real `gun` runtime is never pulled
* into the test environment — it has heavy side-effects and requires
* browser globals — so every test wires this fake via the injected
* factory.
*
* Behavioural model:
*
* - The "graph" is a `Map<string, unknown>`. `get(path)` returns a
* node-handle scoped to that path; chained `get(child)` extends the
* path.
* - `map().on(handler)` subscribes the current scope's children. The
* handler is invoked once for every existing child at subscription
* time, then again on every subsequent injected `put`.
* - `off()` detaches every subscription created on the current scope.
* - `inject(scope, key, value)` is a test affordance that fires the
* subscribed handlers as if a remote peer had `put` the value.
*/
export class FakeGun implements GunNodeLike {
public readonly trackers: readonly string[];
public disposed = false;
private readonly store: Map<string, unknown>;
private readonly listeners: Map<
string,
Array<(data: unknown, key: string) => void>
>;
private readonly path: string;
public constructor(
config: GunFactoryConfig,
store?: Map<string, unknown>,
listeners?: Map<string, Array<(data: unknown, key: string) => void>>,
path = ""
) {
this.trackers = config.peers;
this.store = store ?? new Map();
this.listeners = listeners ?? new Map();
this.path = path;
}
public get(segment: string): GunNodeLike {
const next = this.path === "" ? segment : `${this.path}/${segment}`;
return new FakeGun(
{ web: undefined, peers: this.trackers },
this.store,
this.listeners,
next
);
}
public map(): GunNodeLike {
return this;
}
public on(handler: (data: unknown, key: string) => void): GunNodeLike {
const list = this.listeners.get(this.path) ?? [];
list.push(handler);
this.listeners.set(this.path, list);
// Replay existing children at subscription time.
const prefix = this.path === "" ? "" : `${this.path}/`;
for (const [k, v] of this.store.entries()) {
if (!k.startsWith(prefix)) continue;
const rest = k.slice(prefix.length);
if (rest === "" || rest.includes("/")) continue;
handler(v, rest);
}
return this;
}
public off(): GunNodeLike {
this.listeners.delete(this.path);
return this;
}
/** Test affordance: simulate a remote peer's `put` at the given path. */
public inject(scope: string, key: string, value: unknown): void {
const fullPath = scope === "" ? key : `${scope}/${key}`;
this.store.set(fullPath, value);
const handlers = this.listeners.get(scope);
if (handlers === undefined) return;
for (const h of [...handlers]) {
h(value, key);
}
}
}
/**
* Factory helper producing `FakeGun` instances and recording every
* call so tests can introspect or drive the captured reference.
*/
export function makeFakeGunFactory(): {
readonly instances: FakeGun[];
readonly factory: (config: GunFactoryConfig) => FakeGun;
} {
const instances: FakeGun[] = [];
return {
instances,
factory: (config) => {
const inst = new FakeGun(config);
instances.push(inst);
return inst;
},
};
}

View File

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

View File

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

View File

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

View File

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

View File

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