first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
# @suaveplan/network-trystero-server - Server-tier Trystero signaling daemon — hosts an MQTT broker shim or Nostr relay shim over plain WebSocket for browser-tier @suaveplan/network-discovery-trystero clients. Construction-time mode selection, injectable broker factory, AbortSignal lifecycle, health/ready/live endpoints, OTel observability, real-fake integration tests.
## Summary
Server-tier Trystero signaling daemon. Hosts an MQTT-style or
Nostr-style WebSocket broker shim plus standard `/health` / `/ready` /
`/live` probes on a single Node `http.Server`. Browser-tier clients
running `@suaveplan/network-discovery-trystero` connect to the
daemon's published `relayUrl` for relay-backed P2P signaling.
<!-- 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 -->
- [broker-select](./src/server/broker-select.md) — The broker-select module resolves which broker factory the daemon hosts and bundles the matching mode-specific options…
- [health-listener](./src/server/health-listener.md) — The health-listener helper extracts the `http.Server.listen` promise adapter out of the main daemon module so the daemo…
- [peer-events](./src/server/peer-events.md) — The peer-events module owns the daemon-side wiring of broker lifecycle callbacks into the daemon's connection table, OT…
- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [broker-select](./src/server/broker-select.md) — The broker-select module resolves which broker factory the daemon hosts and bundles the matching mode-specific options…
- [health-listener](./src/server/health-listener.md) — The health-listener helper extracts the `http.Server.listen` promise adapter out of the main daemon module so the daemo…
- [peer-events](./src/server/peer-events.md) — The peer-events module owns the daemon-side wiring of broker lifecycle callbacks into the daemon's connection table, OT…
- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [broker-select](./src/server/broker-select.md) — The broker-select module resolves which broker factory the daemon hosts and bundles the matching mode-specific options…
- [health-listener](./src/server/health-listener.md) — The health-listener helper extracts the `http.Server.listen` promise adapter out of the main daemon module so the daemo…
- [peer-events](./src/server/peer-events.md) — The peer-events module owns the daemon-side wiring of broker lifecycle callbacks into the daemon's connection table, OT…
- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [broker-select](./src/server/broker-select.md) — The broker-select module resolves which broker factory the daemon hosts and bundles the matching mode-specific options…
- [health-listener](./src/server/health-listener.md) — The health-listener helper extracts the `http.Server.listen` promise adapter out of the main daemon module so the daemo…
- [peer-events](./src/server/peer-events.md) — The peer-events module owns the daemon-side wiring of broker lifecycle callbacks into the daemon's connection table, OT…
- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_ - [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ - [broker-select](./src/server/broker-select.md) — The broker-select module resolves which broker factory the daemon hosts and bundles the matching mode-specific options…
- [health-listener](./src/server/health-listener.md) — The health-listener helper extracts the `http.Server.listen` promise adapter out of the main daemon module so the daemo…
- [peer-events](./src/server/peer-events.md) — The peer-events module owns the daemon-side wiring of broker lifecycle callbacks into the daemon's connection table, OT…
- [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)_
## Why this package
- **One daemon, one mode.** Construction-time selection between
`"mqtt"` and `"nostr"`. No runtime branching past the broker
factory, so dashboards split cleanly by `mode` label.
- **Defensive by default.** Malformed frames flow through
`onMalformedFrame` and increment
`trystero_server.messages_relayed_total{outcome="error"}` without
ever crashing the relay.
- **Injection-friendly.** The broker shim is a peer dependency on
intent — production wires a real WebSocket broker, tests bind a
deterministic in-memory `FakeBroker`.
- **Lifecycle-correct.** `start({ signal? })` resolves with
`{ address, port, mode, relayUrl }`. `stop()` is idempotent and
drains in-flight peers.
## Run the daemon
```ts
import {
TrysteroServer,
type BrokerFactory,
} from "@suaveplan/network-trystero-server";
const mqttBrokerFactory: BrokerFactory = (deps) => {
// attach to the daemon's http.Server, handle WS upgrade, etc.
return {
mode: deps.mode,
activeConnections: 0,
async start({ httpServer, callbacks }) {
// real broker wiring
},
async stop() {
// drain sockets, close listeners
},
};
};
const server = new TrysteroServer({
mode: "mqtt",
port: 8765,
host: "0.0.0.0",
maxConnections: 256,
mqttBrokerFactory,
});
const { relayUrl } = await server.start();
console.log(`trystero relay listening on ${relayUrl}`);
process.on("SIGTERM", async () => {
await server.stop();
process.exit(0);
});
```
## Modes
| Mode | Wire path | Mode-specific options |
|---------|------------|------------------------------------|
| `mqtt` | `/mqtt` | `{ password? }` |
| `nostr` | `/nostr` | `{ relayName? }` |
Both modes follow the same wire-formatting contract from the
browser-tier companion package
[`@suaveplan/network-discovery-trystero`](../network-discovery-trystero) —
the daemon publishes the matching `relayUrl` and the discovery client
treats it as a relay endpoint.
## Configuration reference
See [`src/types/types.md`](./src/types/types.md) for the full strict
`TrysteroServerConfig`. The only required fields are `mode`, `port`,
and the per-mode broker factory. Every other field is optional with
a spec-documented default.
## 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:
- `trystero_server.start` / `trystero_server.stop` — spans.
- `trystero_server.connections_total{mode,outcome}` — counter.
- `trystero_server.messages_relayed_total{mode,outcome}` — counter.
- `trystero_server.active_connections{mode}` — up-down gauge.
- `trystero_server.start_duration_ms{mode}` — histogram.
See [`src/otel/otel.md`](./src/otel/otel.md).
## Companion package
[`@suaveplan/network-discovery-trystero`](../network-discovery-trystero) is
the browser-tier client this daemon serves.

View File

@@ -0,0 +1,90 @@
# `errors`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The errors module owns the structured-error contract for
`@suaveplan/network-trystero-server`. Every public boundary on the
daemon — the constructor, `start()`, `stop()`, the broker factory
adapter, and the inline HTTP listener — throws a
`NetTrysteroServerError` carrying a stable code from the
`NET_TRYSTERO_SERVER_CODES` registry. Callers branch on `error.code`
to recover (port collision, factory failure, abort mid-startup) rather
than parsing message strings, so the registry doubles as the
package's runtime stability contract.
## Features
- Frozen `NET_TRYSTERO_SERVER_CODES` registry built via the shared
`defineCodes` helper from `@suaveplan/error`.
- `NetTrysteroServerError` extends `SuaveplanError` and ships with the
standard cause-chain, context bag, and i18n `messageKey` slot.
- Six discriminants covering Zod validation, mode resolution,
start-up failure, listen failure, disposed state, and not-running.
## Usage: Basic Example
```ts
import {
NET_TRYSTERO_SERVER_CODES,
NetTrysteroServerError,
} from "@suaveplan/network-trystero-server";
throw new NetTrysteroServerError(
NET_TRYSTERO_SERVER_CODES.INVALID_OPTIONS,
"trystero_server.invalid-config",
{ context: { issues: [] } }
);
```
## Usage: Advanced Example
```ts
import {
NET_TRYSTERO_SERVER_CODES,
NetTrysteroServerError,
} from "@suaveplan/network-trystero-server";
try {
await server.start();
} catch (err) {
if (err instanceof NetTrysteroServerError) {
switch (err.code) {
case NET_TRYSTERO_SERVER_CODES.LISTEN_FAILED:
// port collision — retry with a new port
break;
case NET_TRYSTERO_SERVER_CODES.MODE_UNSUPPORTED:
// caller asked for ipfs/torrent — not hosted server-side
break;
default:
throw err;
}
}
}
```
## API Reference
- `NET_TRYSTERO_SERVER_CODES` — frozen object literal mapping
identifier → uppercase code string. Six members: `INVALID_OPTIONS`,
`MODE_UNSUPPORTED`, `START_FAILED`, `LISTEN_FAILED`, `DISPOSED`,
`NOT_RUNNING`.
- `NetTrysteroServerErrorCode` — string-literal union of every code
value in the registry.
- `NetTrysteroServerError extends SuaveplanError<NetTrysteroServerErrorCode>`
— constructor takes the code, an i18n message key, and optional
`{ cause?, context? }`.
## Implementation Notes
- Codes are append-only across the package's lifetime. Renaming a
code is a breaking change because consumer error-handling code
branches on the literal value.
- The class sets `name = "NetTrysteroServerError"` so
`instanceof`-guarded handlers in cross-package transport layers can
identify the source without importing the constructor.
- The errors module has zero runtime dependencies on the rest of the
package — every other module imports from here. That keeps the
dependency graph acyclic by construction.

View File

@@ -0,0 +1,91 @@
# `health`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The health module is the daemon's probe surface. It owns the
`/health` / `/ready` / `/live` verb routing, the per-verb status
matrix, and the JSON body shape every probe receives. The daemon
passes a `HealthSnapshot` on every request so responses always
reflect current truth — never a stale cached view.
## Features
- Three verbs routed via `HEALTH_PATHS`: `/health`, `/ready`, `/live`.
- `classifyHealthRequest(url)` accepts the raw `req.url`, strips
querystring + trailing slash, returns the matching kind or `null`
for non-health routes.
- `buildHealthResponse(verb, snapshot)` returns the matching HTTP
status code (200 / 503) and JSON body for the verb.
- `handleHealth(req, res, snapshot)` writes the response directly and
returns `true` so the daemon's request handler can early-return.
## Usage: Basic Example
```ts
import {
handleHealth,
type HealthSnapshot,
} from "@suaveplan/network-trystero-server";
import { createServer } from "node:http";
const snap: HealthSnapshot = {
running: true,
disposed: false,
activeConnections: 0,
maxConnections: 100,
startedAt: Date.now(),
mode: "mqtt",
relayUrl: "ws://0.0.0.0:8765/mqtt",
};
createServer((req, res) => {
if (handleHealth(req, res, snap)) return;
res.writeHead(404).end();
}).listen(8765);
```
## Usage: Advanced Example
```ts
import {
buildHealthResponse,
classifyHealthRequest,
} from "@suaveplan/network-trystero-server";
const verb = classifyHealthRequest("/ready?delay=1");
if (verb !== null) {
const { status, body } = buildHealthResponse(verb, mySnapshot());
console.log(status, body.status, body.mode);
}
```
## API Reference
- `HEALTH_PATHS` — `{ health: "/health", ready: "/ready", live:
"/live" }`.
- `HealthPath` — literal union of the three values.
- `HealthKind` — `"health" | "ready" | "live" | null`.
- `HealthSnapshot` — runtime-state struct the daemon supplies.
- `HealthResponse` — JSON body shape, includes the matching `verb`.
- `classifyHealthRequest(url)` — null-safe URL → kind classifier.
- `buildHealthResponse(verb, snap)` — pure function returning
`{ status, body }`.
- `handleHealth(req, res, snap)` — combined classifier + writer;
returns `true` when the request was handled.
## Implementation Notes
- The matrix:
- `/live` returns 200 unless `stop()` cleared state (then 503).
- `/ready` returns 200 only when `running` AND below
`maxConnections`.
- `/health` matches `/ready` for 200; degrades to 503 with status
`degraded` when at-cap, `starting` when not yet started, or
`stopped` after stop.
- `mode` and `relayUrl` are echoed on every response so a probe
client can dual-purpose the health verb as a discovery endpoint.
- The `handleHealth` writer signature accepts `Pick<...>` so unit
tests can pass cheap stand-ins instead of a real
`IncomingMessage`/`ServerResponse`.

View File

@@ -0,0 +1,85 @@
# `otel`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The OTel module owns every observability emission for
`@suaveplan/network-trystero-server`. The daemon exposes spans for
lifecycle methods (`start` / `stop`), counters for connection
lifecycle and frame relay, an up-down gauge for live client-socket
count, and a histogram for `start()` duration. Every metric carries
the `mode` attribute so dashboards split mqtt vs nostr without
client-side filtering.
## Features
- Two spans — `trystero_server.start` and `trystero_server.stop` —
produced via `withStartSpan` / `withStopSpan`.
- Counter `trystero_server.connections_total{mode,outcome}` —
outcome ∈ `connected | disconnected | rejected`.
- Counter `trystero_server.messages_relayed_total{mode,outcome}` —
outcome ∈ `ok | error`.
- Up-down counter `trystero_server.active_connections{mode}` — driven
by `adjustActiveConnections`.
- Histogram `trystero_server.start_duration_ms{mode}` — populated
from the wall-clock delta between method entry and the bound listen
resolution.
## Usage: Basic Example
```ts
import {
recordConnection,
withStartSpan,
} from "@suaveplan/network-trystero-server";
await withStartSpan(async () => {
// ... start the broker
});
recordConnection("mqtt", "connected");
```
## Usage: Advanced Example
```ts
import {
adjustActiveConnections,
recordMessageRelayed,
recordStartDuration,
} from "@suaveplan/network-trystero-server";
adjustActiveConnections("nostr", +1);
recordMessageRelayed("nostr", "ok");
recordStartDuration("nostr", performance.now() - t0);
```
## API Reference
- `withStartSpan(fn)` / `withStopSpan(fn)` — wrap the body of the
matching lifecycle method in an active span; the span ends in a
`finally` so errors are observable.
- `recordConnection(mode, outcome)` — add 1 to the connections
counter with the matching labels.
- `recordMessageRelayed(mode, outcome)` — add 1 to the relayed
messages counter.
- `adjustActiveConnections(mode, delta)` — apply ±1 to the gauge.
Zero deltas short-circuit so callers can wire unconditional
`adjust(±delta)` calls without polluting cardinality.
- `recordStartDuration(mode, ms)` — record one observation; negative
inputs are clamped to zero so a malformed `Date.now()` skew can
never poison the histogram.
- `ConnectionOutcome` / `MessageRelayOutcome` — exported string
unions consumers can use to type their own outcome branches.
## Implementation Notes
- The tracer and meter are resolved at module-evaluation time against
the global OTel API. Production wires a real SDK via
`@suaveplan/opentelemetry`; tests run with the default no-op
provider so assertions focus on behaviour, not metric collection.
- Every metric name is namespaced with `trystero_server.` so
multi-package dashboards can filter the entire family in one query.
- `mode` is always present on every emission so `outcome`-only
rollups can be pre-computed without losing the mode dimension.

View File

@@ -0,0 +1,74 @@
# `broker-select`
## Purpose
The broker-select module resolves which broker factory the daemon
hosts and bundles the matching mode-specific options into a single
struct. The daemon constructor calls this exactly once and freezes
the result into its resolved config so the rest of the lifetime is
mode-stable — no runtime branching, no factory swaps.
## Features
- Construction-time mode → factory dispatch covering `"mqtt"` and
`"nostr"`.
- Throws `INVALID_OPTIONS` with a discriminating `messageKey` when
the matching factory is missing.
- Throws `MODE_UNSUPPORTED` when callers widen the mode type past the
Zod enum (defensive — Zod normally catches this first).
- Returns the per-mode options struct with `{}` substituted when the
caller omitted it, so downstream consumers can always read an
object.
## Usage: Basic Example
```ts
import { selectBrokerFactory } from "@suaveplan/network-trystero-server";
const { factory, mode, options } = selectBrokerFactory({
mode: "mqtt",
port: 0,
mqttBrokerFactory: realMqttFactory,
});
```
## Usage: Advanced Example
```ts
import {
NET_TRYSTERO_SERVER_CODES,
NetTrysteroServerError,
selectBrokerFactory,
} from "@suaveplan/network-trystero-server";
try {
selectBrokerFactory({ mode: "nostr", port: 0 } as never);
} catch (err) {
if (
err instanceof NetTrysteroServerError &&
err.code === NET_TRYSTERO_SERVER_CODES.INVALID_OPTIONS
) {
// missing nostrRelayFactory
}
}
```
## API Reference
- `selectBrokerFactory(config)` — returns `{ factory, mode, options
}`. `factory` is the caller-supplied `BrokerFactory`, `mode` is the
string literal, `options` is the mode-specific opts struct
defaulted to `{}`.
## Implementation Notes
- The function is pure and synchronous; it does not invoke the
factory. That keeps construction cheap and the broker startup
observable inside the `start()` span.
- The `MODE_UNSUPPORTED` branch is defensive: Zod's
`TrysteroServerConfigSchema` constrains `mode` to the two-member
enum, so this branch can only fire when consumers cast past the
schema. It still exists so the daemon emits a stable code instead
of a generic type error.
- Returning the struct keeps the surface explicit — the daemon does
not need to remember which property the factory came from.

View File

@@ -0,0 +1,71 @@
# `health-listener`
## Purpose
The health-listener helper extracts the `http.Server.listen` promise
adapter out of the main daemon module so the daemon stays under the
300-line discipline cap. It binds an existing `node:http` server to
`(port, host)`, resolves on the `listening` event, and maps every
known failure mode to a typed `NetTrysteroServerError`. It also wires
an `AbortSignal` so a slow bind can be cancelled mid-flight.
## Features
- Promise-based listen wrapper over `node:http.Server.listen`.
- `EADDRINUSE` → `LISTEN_FAILED` with `messageKey =
"trystero_server.port-in-use"` and `context: { port }`.
- Any other errno → `LISTEN_FAILED` with `messageKey =
"trystero_server.listen-failed"` and the original errno preserved
as `cause`.
- `AbortSignal` fire → `START_FAILED` with `messageKey =
"trystero_server.aborted"` plus a best-effort `http.close()` so the
socket does not leak.
- Symmetric listener cleanup — `error` / `listening` / `abort`
handlers are removed on every termination path so a single
`listenHttp` call cannot accumulate stale listeners.
## Usage: Basic Example
```ts
import { listenHttp } from "@suaveplan/network-trystero-server";
import { createServer } from "node:http";
const http = createServer();
await listenHttp(http, 0, "127.0.0.1", undefined);
const addr = http.address() as { address: string; port: number };
```
## Usage: Advanced Example
```ts
import { listenHttp } from "@suaveplan/network-trystero-server";
import { createServer } from "node:http";
const ctrl = new AbortController();
const http = createServer();
setTimeout(() => ctrl.abort(), 100);
try {
await listenHttp(http, 0, "127.0.0.1", ctrl.signal);
} catch (err) {
// err.code === "NET_TRYSTERO_SERVER.START_FAILED"
}
```
## API Reference
- `listenHttp(http, port, host, signal)` — returns `Promise<void>`
resolved once the server is listening, rejected with a
`NetTrysteroServerError` on any failure or abort.
## Implementation Notes
- The handlers are wired with `once()` so a single resolution path
always wins. Combined with the symmetric removal logic, there is no
way for both `onError` and `onListening` to fire.
- `onAbort` calls `http.close(noop)` as a best-effort cleanup; if the
underlying socket has not yet entered the listening state the close
is a noop, otherwise the half-bound socket is freed before the
caller sees the rejection.
- The function is intentionally narrow — it does not own the
`http.Server` lifecycle, only its binding. The daemon retains the
reference and closes it on `stop()`.

View File

@@ -0,0 +1,86 @@
# `peer-events`
## Purpose
The peer-events module owns the daemon-side wiring of broker
lifecycle callbacks into the daemon's connection table, OTel
emissions, and typed event bus. It exists as a sibling of `server.ts`
so that file stays under the 300-line cap and so callbacks can be
unit-tested without spinning up a full HTTP server.
## Features
- `buildPeerCallbacks(ctx)` returns a `BrokerCallbacks` struct bound
to the supplied context.
- Connection-cap enforcement on `onConnect` — sockets over capacity
are rejected with the `rejected` counter outcome and closed via
`socket.close()`.
- `onDisconnect` only fires the gauge/counter when the socket was
actually `connected` so double-disconnect from a misbehaving
broker does not drive the gauge negative.
- `onFrame` and `onMalformedFrame` update `lastSeen` /
`messagesRelayed` and emit the matching OTel counter outcome.
- `relay-error` event emitted on every malformed frame so consumers
with structured logging can alert on a misbehaving peer.
## Usage: Basic Example
```ts
import {
buildPeerCallbacks,
type TrysteroServerPeerInfo,
} from "@suaveplan/network-trystero-server";
const peers = new Map<string, TrysteroServerPeerInfo>();
const cb = buildPeerCallbacks({
peers,
mode: "mqtt",
maxConnections: 100,
activeCount: () =>
Array.from(peers.values()).filter((p) => p.connected).length,
emit: () => {},
});
```
## Usage: Advanced Example
```ts
import {
buildPeerCallbacks,
type PeerEventsContext,
} from "@suaveplan/network-trystero-server";
const ctx: PeerEventsContext = {
peers: new Map(),
mode: "nostr",
maxConnections: 1,
activeCount: () => 0,
emit: () => {},
};
const cb = buildPeerCallbacks(ctx);
cb.onConnect({ id: "a", send: () => {}, close: () => {} });
cb.onFrame("a");
cb.onMalformedFrame("a");
cb.onDisconnect("a");
```
## API Reference
- `PeerEventsContext` — `{ peers, mode, maxConnections, activeCount,
emit }`.
- `buildPeerCallbacks(ctx)` — returns a `BrokerCallbacks` struct with
four bound handlers.
## Implementation Notes
- Every handler reads `ctx.activeCount()` on demand rather than
caching it in a local variable, so the cap check always sees fresh
truth across interleaved connects/disconnects.
- The malformed-frame handler updates `lastSeen` for a known peer
even though it does not increment `messagesRelayed`. This keeps the
liveness timestamp accurate while denying the offending socket
relay-rate credit.
- The bus emit is wrapped through the daemon-side `emit` shim and so
is already exception-safe by the time it lands here. The module
itself never catches — failures propagate to the surrounding broker
callback, which is the broker's contract to honour.

View File

@@ -0,0 +1,119 @@
# `server`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `TrysteroServer` class is the public daemon entry point. It owns
the inline `node:http` listener, the broker shim attached to it, the
peer connection table, OTel observability, and the typed lifecycle
event bus. One daemon hosts exactly one mode (`mqtt` or `nostr`)
selected at construction time; runtime mode-branching is restricted
to the broker factory itself.
## Features
- Construction-time mode selection via injected
`mqttBrokerFactory` / `nostrRelayFactory` — no implicit defaults.
- Inline HTTP `/health` / `/ready` / `/live` on the same port the
broker shim attaches to.
- Idempotent `stop()` and single-shot `start()` with `AbortSignal`
threaded throughout.
- `relayUrl` of the form `ws://<host>:<port>/<mode>` published on
every started event so browser-tier clients consume the daemon
output without manual stitching.
- Optional `EventBusLike` injection for typed `started` / `stopped` /
`peer-connected` / `peer-disconnected` / `relay-error` emissions.
- OTel spans for both lifecycle methods plus the matrix of counters
/ gauge / histogram emitted via the package's `otel` module.
## Usage: Basic Example
```ts
import {
TrysteroServer,
type BrokerFactory,
} from "@suaveplan/network-trystero-server";
const mqttFactory: BrokerFactory = (deps) => ({
mode: deps.mode,
activeConnections: 0,
async start() {
/* real broker wiring */
},
async stop() {},
});
const server = new TrysteroServer({
mode: "mqtt",
port: 0,
host: "127.0.0.1",
mqttBrokerFactory: mqttFactory,
});
const { relayUrl } = await server.start();
// → publish `relayUrl` to discovery-trystero clients
await server.stop();
```
## Usage: Advanced Example
```ts
import {
TrysteroServer,
type TrysteroServerEventMap,
} from "@suaveplan/network-trystero-server";
import type { EventBusLike } from "@suaveplan/types";
const events: Array<keyof TrysteroServerEventMap> = [];
const bus: EventBusLike<TrysteroServerEventMap> = {
on: () => () => {},
emit: (topic) => {
events.push(topic);
},
};
const server = new TrysteroServer({
mode: "nostr",
port: 0,
host: "127.0.0.1",
nostrRelayFactory: myNostrFactory,
nostr: { relayName: "edge-1" },
maxConnections: 256,
bus,
});
const ctrl = new AbortController();
await server.start({ signal: ctrl.signal });
```
## API Reference
- `new TrysteroServer(config)` — throws `INVALID_OPTIONS` on Zod
failure or missing per-mode factory.
- `start(options?)` — `Promise<TrysteroServerStartResult>`.
- `stop()` — idempotent, drains peers, closes HTTP, returns
`Promise<void>`.
- `config` — read-only `ResolvedTrysteroServerConfig`.
- `mode` — `TrysteroServerMode`.
- `activeConnections` — live peer count.
- `peers()` — snapshot array of every observed peer record.
- `snapshot()` — `HealthSnapshot` for probe responses.
- `relayUrl` / `boundAddress` / `boundPort` — populated after
`start()` resolves.
## Implementation Notes
- The broker factory is invoked once per `start()` call. On failure
the HTTP listener is closed and `START_FAILED` is thrown with the
original cause preserved.
- Peer state changes flow through `buildPeerCallbacks` (sibling
module) so the daemon stays under the 300-line discipline cap. The
factory observes the daemon only through the wired callbacks —
it never reaches into the peer table directly.
- `host === "0.0.0.0"` / `::` is rewritten to `127.0.0.1` in the
published `relayUrl` so clients dialling the loopback consumer get
a working address out of the box. Operators binding to an
externally-routable host receive that host unchanged.
- Bus emissions are wrapped in `try/catch` — a misbehaving bus must
never crash the relay.

View File

@@ -0,0 +1,103 @@
# `types`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The types module owns every public structural interface and union
emitted by `@suaveplan/network-trystero-server`. The daemon's
`TrysteroServerConfig` discriminates on `mode` at construction time —
mqtt and nostr broker shims are hosted via the same `BrokerLike`
contract — so consumers wire a single factory per mode and the daemon
itself contains no runtime mode-branching beyond construction.
## Features
- Discriminated `TrysteroServerMode = "mqtt" | "nostr"` union.
- Structural `BrokerLike` surface every hosted broker conforms to,
letting tests bind in-memory implementations without pulling a real
WebSocket runtime.
- Mode-specific options (`TrysteroServerMqttOptions`,
`TrysteroServerNostrOptions`) the daemon forwards verbatim to the
matching factory.
- Lifecycle event map (`TrysteroServerEventMap`) used with the
optional `EventBusLike` injection.
- `ResolvedTrysteroServerConfig` — fully-defaulted snapshot held by
the daemon for the lifetime of the process.
## Usage: Basic Example
```ts
import type {
BrokerFactory,
TrysteroServerConfig,
} from "@suaveplan/network-trystero-server";
const cfg: TrysteroServerConfig = {
mode: "mqtt",
port: 8765,
host: "0.0.0.0",
mqttBrokerFactory: ((deps) =>
myRealMqttBroker(deps)) as BrokerFactory,
};
```
## Usage: Advanced Example
```ts
import type {
BrokerCallbacks,
BrokerLike,
BrokerSocketLike,
} from "@suaveplan/network-trystero-server";
class MyBroker implements BrokerLike {
public readonly mode = "nostr" as const;
public activeConnections = 0;
async start(_opts: {
readonly callbacks: BrokerCallbacks;
readonly maxConnections: number;
}): Promise<void> {
// attach to the daemon's http.Server, handle WS upgrade, etc.
}
async stop(): Promise<void> {
// drain sockets, close listeners
}
}
```
## API Reference
- `TrysteroServerMode` — `"mqtt" | "nostr"` discriminator.
- `BrokerSocketLike` — `{ id, send, close }`.
- `BrokerLike` — `{ mode, start, stop, activeConnections }`.
- `BrokerCallbacks` — `onConnect`, `onDisconnect`, `onFrame`,
`onMalformedFrame`.
- `BrokerFactory` — `(deps: BrokerFactoryDeps) => BrokerLike |
Promise<BrokerLike>`.
- `BrokerFactoryDeps` — `{ mode, options }`.
- `TrysteroServerMqttOptions` — `{ password? }`.
- `TrysteroServerNostrOptions` — `{ relayName? }`.
- `TrysteroServerConfig` — strict caller-facing surface.
- `ResolvedTrysteroServerConfig` — fully-defaulted snapshot.
- `TrysteroServerStartResult` — `{ address, port, mode, relayUrl }`.
- `TrysteroServerPeerInfo` — `{ id, connected, lastSeen,
messagesRelayed }`.
- Lifecycle events — `TrysteroServerStartedEvent`,
`TrysteroServerStoppedEvent`, `TrysteroServerPeerConnectedEvent`,
`TrysteroServerPeerDisconnectedEvent`,
`TrysteroServerRelayErrorEvent`.
## Implementation Notes
- `BrokerLike` only exposes lifecycle methods — the broker is
observation-only from the daemon's perspective. Every state change
flows through the wired callbacks. This keeps the trust boundary
one-way: the broker never reaches into the daemon's connection
table directly.
- `mode` lives on `BrokerLike` so multi-broker test rigs can be
asserted positionally without rebuilding the daemon.
- The two mode-options structs are independent rather than a single
discriminated union so callers don't have to repeat `mode` twice in
config payloads.