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