Initial Commit
This commit is contained in:
140
packages/networking/network/network-trystero-server/README.md
Normal file
140
packages/networking/network/network-trystero-server/README.md
Normal 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.
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@suaveplan/network-trystero-server",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "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.",
|
||||
"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-trystero": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"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,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.
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NET_TRYSTERO_SERVER_CODES, NetTrysteroServerError } from "./errors.js";
|
||||
|
||||
describe("NET_TRYSTERO_SERVER_CODES", () => {
|
||||
it("exposes the six expected codes", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(NET_TRYSTERO_SERVER_CODES).sort()).toEqual([
|
||||
"DISPOSED",
|
||||
"INVALID_OPTIONS",
|
||||
"LISTEN_FAILED",
|
||||
"MODE_UNSUPPORTED",
|
||||
"NOT_RUNNING",
|
||||
"START_FAILED",
|
||||
]);
|
||||
});
|
||||
|
||||
it("namespaces every value with NET_TRYSTERO_SERVER", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const value of Object.values(NET_TRYSTERO_SERVER_CODES)) {
|
||||
expect(value.startsWith("NET_TRYSTERO_SERVER.")).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("NetTrysteroServerError", () => {
|
||||
it("preserves the code and class name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new NetTrysteroServerError(
|
||||
NET_TRYSTERO_SERVER_CODES.INVALID_OPTIONS,
|
||||
"trystero_server.invalid"
|
||||
);
|
||||
expect(err.code).toBe(NET_TRYSTERO_SERVER_CODES.INVALID_OPTIONS);
|
||||
expect(err.name).toBe("NetTrysteroServerError");
|
||||
});
|
||||
|
||||
it("attaches cause and context when supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("boom");
|
||||
const err = new NetTrysteroServerError(
|
||||
NET_TRYSTERO_SERVER_CODES.START_FAILED,
|
||||
"trystero_server.start-failed",
|
||||
{ cause, context: { port: 1234 } }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ port: 1234 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Error codes emitted by `@suaveplan/network-trystero-server`.
|
||||
*
|
||||
* - `INVALID_OPTIONS` — caller-supplied config failed Zod validation,
|
||||
* or the mode-specific broker factory was missing.
|
||||
* - `MODE_UNSUPPORTED` — caller asked for a mode that has not been
|
||||
* wired (currently anything outside `"mqtt" | "nostr"`).
|
||||
* - `START_FAILED` — broker factory threw, abort fired mid-start, or
|
||||
* the daemon was already started/disposed.
|
||||
* - `LISTEN_FAILED` — the inline HTTP/WebSocket server failed to bind
|
||||
* (port-in-use or similar errno from `node:http.listen`).
|
||||
* - `DISPOSED` — operation attempted after `stop()` cleared state.
|
||||
* - `NOT_RUNNING` — operation attempted before `start()` resolved.
|
||||
*/
|
||||
export const NET_TRYSTERO_SERVER_CODES = defineCodes("NET_TRYSTERO_SERVER", [
|
||||
"INVALID_OPTIONS",
|
||||
"MODE_UNSUPPORTED",
|
||||
"START_FAILED",
|
||||
"LISTEN_FAILED",
|
||||
"DISPOSED",
|
||||
"NOT_RUNNING",
|
||||
] as const);
|
||||
|
||||
export type NetTrysteroServerErrorCode =
|
||||
(typeof NET_TRYSTERO_SERVER_CODES)[keyof typeof NET_TRYSTERO_SERVER_CODES];
|
||||
|
||||
/**
|
||||
* Structured error class for the Trystero signaling daemon. Every
|
||||
* public boundary throws this on failure; consumers branch on
|
||||
* `error.code`.
|
||||
*/
|
||||
export class NetTrysteroServerError extends SuaveplanError<NetTrysteroServerErrorCode> {
|
||||
public constructor(
|
||||
code: NetTrysteroServerErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "NetTrysteroServerError";
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
buildHealthResponse,
|
||||
classifyHealthRequest,
|
||||
HEALTH_PATHS,
|
||||
type HealthSnapshot,
|
||||
handleHealth,
|
||||
} from "./health.js";
|
||||
|
||||
function snap(overrides: Partial<HealthSnapshot> = {}): HealthSnapshot {
|
||||
return {
|
||||
running: true,
|
||||
disposed: false,
|
||||
activeConnections: 0,
|
||||
maxConnections: 10,
|
||||
startedAt: 1_700_000_000_000,
|
||||
mode: "mqtt",
|
||||
relayUrl: "ws://127.0.0.1:1/mqtt",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("classifyHealthRequest", () => {
|
||||
it("returns null for undefined / empty urls", { timeout: 5000 }, () => {
|
||||
expect(classifyHealthRequest(undefined)).toBeNull();
|
||||
expect(classifyHealthRequest("")).toBeNull();
|
||||
});
|
||||
|
||||
it("classifies the three verbs", { timeout: 5000 }, () => {
|
||||
expect(classifyHealthRequest("/health")).toBe("health");
|
||||
expect(classifyHealthRequest("/ready")).toBe("ready");
|
||||
expect(classifyHealthRequest("/live")).toBe("live");
|
||||
});
|
||||
|
||||
it("ignores querystring and trailing slash", { timeout: 5000 }, () => {
|
||||
expect(classifyHealthRequest("/health?x=1")).toBe("health");
|
||||
expect(classifyHealthRequest("/ready/")).toBe("ready");
|
||||
});
|
||||
|
||||
it("returns null for unmatched paths", { timeout: 5000 }, () => {
|
||||
expect(classifyHealthRequest("/mqtt")).toBeNull();
|
||||
expect(classifyHealthRequest("/other")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildHealthResponse — /live", () => {
|
||||
it("returns 200/ok while running", { timeout: 5000 }, () => {
|
||||
const { status, body } = buildHealthResponse("live", snap());
|
||||
expect(status).toBe(200);
|
||||
expect(body.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns 503/stopped after dispose", { timeout: 5000 }, () => {
|
||||
const { status, body } = buildHealthResponse(
|
||||
"live",
|
||||
snap({ disposed: true, running: false })
|
||||
);
|
||||
expect(status).toBe(503);
|
||||
expect(body.status).toBe("stopped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildHealthResponse — /ready and /health", () => {
|
||||
it("returns 200/ok when running and below capacity", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(buildHealthResponse("ready", snap()).status).toBe(200);
|
||||
expect(buildHealthResponse("health", snap()).status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 503/starting before start resolves", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { status, body } = buildHealthResponse(
|
||||
"health",
|
||||
snap({ running: false })
|
||||
);
|
||||
expect(status).toBe(503);
|
||||
expect(body.status).toBe("starting");
|
||||
});
|
||||
|
||||
it("returns 503/stopped after stop", { timeout: 5000 }, () => {
|
||||
const { status, body } = buildHealthResponse(
|
||||
"health",
|
||||
snap({ running: false, disposed: true })
|
||||
);
|
||||
expect(status).toBe(503);
|
||||
expect(body.status).toBe("stopped");
|
||||
});
|
||||
|
||||
it("returns 503/degraded at capacity", { timeout: 5000 }, () => {
|
||||
const { status, body } = buildHealthResponse(
|
||||
"ready",
|
||||
snap({ activeConnections: 10, maxConnections: 10 })
|
||||
);
|
||||
expect(status).toBe(503);
|
||||
expect(body.status).toBe("degraded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleHealth", () => {
|
||||
it("returns false for non-health routes", { timeout: 5000 }, () => {
|
||||
let written = false;
|
||||
const ok = handleHealth(
|
||||
{ url: "/mqtt" },
|
||||
{
|
||||
writeHead: () => {
|
||||
written = true;
|
||||
return {} as never;
|
||||
},
|
||||
end: () => {
|
||||
written = true;
|
||||
return {} as never;
|
||||
},
|
||||
},
|
||||
snap()
|
||||
);
|
||||
expect(ok).toBe(false);
|
||||
expect(written).toBe(false);
|
||||
});
|
||||
|
||||
it("writes JSON and returns true for /health", { timeout: 5000 }, () => {
|
||||
let writtenStatus = 0;
|
||||
let body = "";
|
||||
const ok = handleHealth(
|
||||
{ url: "/health" },
|
||||
{
|
||||
writeHead: ((s: number) => {
|
||||
writtenStatus = s;
|
||||
return {} as never;
|
||||
}) as never,
|
||||
end: ((b: string) => {
|
||||
body = b;
|
||||
return {} as never;
|
||||
}) as never,
|
||||
},
|
||||
snap()
|
||||
);
|
||||
expect(ok).toBe(true);
|
||||
expect(writtenStatus).toBe(200);
|
||||
const parsed = JSON.parse(body) as { mode: string; verb: string };
|
||||
expect(parsed.mode).toBe("mqtt");
|
||||
expect(parsed.verb).toBe("health");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HEALTH_PATHS", () => {
|
||||
it("exposes the three canonical paths", { timeout: 5000 }, () => {
|
||||
expect(HEALTH_PATHS.health).toBe("/health");
|
||||
expect(HEALTH_PATHS.ready).toBe("/ready");
|
||||
expect(HEALTH_PATHS.live).toBe("/live");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { TrysteroServerMode } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* 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 socket count. */
|
||||
readonly activeConnections: number;
|
||||
/** Configured `maxConnections`. */
|
||||
readonly maxConnections: number;
|
||||
/** Unix-ms timestamp of the latest `start()` resolve. */
|
||||
readonly startedAt: number | undefined;
|
||||
/** Operating mode the daemon is hosting. */
|
||||
readonly mode: TrysteroServerMode;
|
||||
/** Bootstrap URL clients consume. */
|
||||
readonly relayUrl: string | undefined;
|
||||
}
|
||||
|
||||
/** Routes the health/ready/live verbs understood by the daemon. */
|
||||
export const HEALTH_PATHS = {
|
||||
health: "/health",
|
||||
ready: "/ready",
|
||||
live: "/live",
|
||||
} as const;
|
||||
|
||||
export type HealthPath = (typeof HEALTH_PATHS)[keyof typeof HEALTH_PATHS];
|
||||
|
||||
/** Discriminator returned to the daemon by {@link classifyHealthRequest}. */
|
||||
export type HealthKind = "health" | "ready" | "live" | null;
|
||||
|
||||
/**
|
||||
* Classify an inbound HTTP request URL by path. Returns the matching
|
||||
* verb or `null` if the URL is not a known health route. Querystrings
|
||||
* and trailing slashes are ignored.
|
||||
*/
|
||||
export function classifyHealthRequest(url: string | undefined): HealthKind {
|
||||
if (!url) return null;
|
||||
const qIdx = url.indexOf("?");
|
||||
const noQuery = qIdx === -1 ? url : url.slice(0, qIdx);
|
||||
const path = noQuery.endsWith("/") ? noQuery.slice(0, -1) : noQuery;
|
||||
switch (path) {
|
||||
case HEALTH_PATHS.health:
|
||||
return "health";
|
||||
case HEALTH_PATHS.ready:
|
||||
return "ready";
|
||||
case HEALTH_PATHS.live:
|
||||
return "live";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Body returned by each health verb. The daemon stamps it onto the
|
||||
* `ServerResponse` JSON-encoded with the matching HTTP status.
|
||||
*/
|
||||
export interface HealthResponse {
|
||||
readonly status: "ok" | "degraded" | "starting" | "stopped";
|
||||
readonly running: boolean;
|
||||
readonly disposed: boolean;
|
||||
readonly activeConnections: number;
|
||||
readonly maxConnections: number;
|
||||
readonly startedAt: number | undefined;
|
||||
readonly mode: TrysteroServerMode;
|
||||
readonly relayUrl: string | 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,
|
||||
mode: snap.mode,
|
||||
relayUrl: snap.relayUrl,
|
||||
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,67 @@
|
||||
// Server-tier package — uses node:http (Node-only). Browser consumers cannot use this package.
|
||||
|
||||
// Errors
|
||||
export {
|
||||
NET_TRYSTERO_SERVER_CODES,
|
||||
NetTrysteroServerError,
|
||||
type NetTrysteroServerErrorCode,
|
||||
} from "./errors/errors.js";
|
||||
// Health
|
||||
export {
|
||||
buildHealthResponse,
|
||||
classifyHealthRequest,
|
||||
HEALTH_PATHS,
|
||||
type HealthKind,
|
||||
type HealthPath,
|
||||
type HealthResponse,
|
||||
type HealthSnapshot,
|
||||
handleHealth,
|
||||
} from "./health/health.js";
|
||||
// OTel
|
||||
export {
|
||||
adjustActiveConnections,
|
||||
type ConnectionOutcome,
|
||||
type MessageRelayOutcome,
|
||||
recordConnection,
|
||||
recordMessageRelayed,
|
||||
recordStartDuration,
|
||||
withStartSpan,
|
||||
withStopSpan,
|
||||
} from "./otel/otel.js";
|
||||
// Server
|
||||
export { selectBrokerFactory } from "./server/broker-select.js";
|
||||
export { listenHttp } from "./server/health-listener.js";
|
||||
export {
|
||||
buildPeerCallbacks,
|
||||
type PeerEventsContext,
|
||||
} from "./server/peer-events.js";
|
||||
export { TrysteroServer } from "./server/server.js";
|
||||
// Types
|
||||
export type {
|
||||
BrokerCallbacks,
|
||||
BrokerFactory,
|
||||
BrokerFactoryDeps,
|
||||
BrokerLike,
|
||||
BrokerSocketLike,
|
||||
BrokerStartOptions,
|
||||
ResolvedTrysteroServerConfig,
|
||||
TrysteroServerConfig,
|
||||
TrysteroServerEventMap,
|
||||
TrysteroServerMode,
|
||||
TrysteroServerModeOptions,
|
||||
TrysteroServerMqttOptions,
|
||||
TrysteroServerNostrOptions,
|
||||
TrysteroServerPeerConnectedEvent,
|
||||
TrysteroServerPeerDisconnectedEvent,
|
||||
TrysteroServerPeerInfo,
|
||||
TrysteroServerRelayErrorEvent,
|
||||
TrysteroServerStartedEvent,
|
||||
TrysteroServerStartResult,
|
||||
TrysteroServerStoppedEvent,
|
||||
} from "./types/types.js";
|
||||
// Schemas
|
||||
export {
|
||||
TrysteroServerConfigSchema,
|
||||
TrysteroServerMqttOptionsSchema,
|
||||
TrysteroServerNostrOptionsSchema,
|
||||
} from "./types/types.schemas.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
adjustActiveConnections,
|
||||
recordConnection,
|
||||
recordMessageRelayed,
|
||||
recordStartDuration,
|
||||
withStartSpan,
|
||||
withStopSpan,
|
||||
} from "./otel.js";
|
||||
|
||||
describe("otel surface", () => {
|
||||
it("withStartSpan resolves the inner result", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const out = await withStartSpan(async () => "result");
|
||||
expect(out).toBe("result");
|
||||
});
|
||||
|
||||
it("withStartSpan ends the span on throw", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
withStartSpan(async () => {
|
||||
throw new Error("inner");
|
||||
})
|
||||
).rejects.toThrow("inner");
|
||||
});
|
||||
|
||||
it("withStopSpan resolves the inner result", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const out = await withStopSpan(async () => 42);
|
||||
expect(out).toBe(42);
|
||||
});
|
||||
|
||||
it("withStopSpan ends the span on throw", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
withStopSpan(async () => {
|
||||
throw new Error("inner2");
|
||||
})
|
||||
).rejects.toThrow("inner2");
|
||||
});
|
||||
|
||||
it("recordConnection / recordMessageRelayed do not throw", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordConnection("mqtt", "connected");
|
||||
recordConnection("mqtt", "disconnected");
|
||||
recordConnection("nostr", "rejected");
|
||||
recordMessageRelayed("mqtt", "ok");
|
||||
recordMessageRelayed("nostr", "error");
|
||||
});
|
||||
|
||||
it("adjustActiveConnections short-circuits on zero delta", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
adjustActiveConnections("mqtt", 0);
|
||||
adjustActiveConnections("mqtt", +1);
|
||||
adjustActiveConnections("mqtt", -1);
|
||||
});
|
||||
|
||||
it("recordStartDuration clamps negative inputs to zero", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordStartDuration("mqtt", -5);
|
||||
recordStartDuration("nostr", 12);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { metrics, type Span, type Tracer, trace } from "@opentelemetry/api";
|
||||
import type { TrysteroServerMode } from "../types/types.js";
|
||||
|
||||
const TRACER_NAME = "@suaveplan/network-trystero-server";
|
||||
const METER_NAME = "@suaveplan/network-trystero-server";
|
||||
|
||||
const tracer: Tracer = trace.getTracer(TRACER_NAME);
|
||||
const meter = metrics.getMeter(METER_NAME);
|
||||
|
||||
/**
|
||||
* Counter incremented every time a client socket attaches or detaches
|
||||
* from the relay, labelled by mode and outcome
|
||||
* (`connected`/`disconnected`/`rejected`).
|
||||
*/
|
||||
const connectionsCounter = meter.createCounter(
|
||||
"trystero_server.connections_total",
|
||||
{
|
||||
description:
|
||||
"Per-client connection lifecycle events handled by the Trystero relay daemon.",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Counter incremented every time the daemon relays a frame between
|
||||
* clients, labelled by mode and outcome.
|
||||
*/
|
||||
const messagesRelayedCounter = meter.createCounter(
|
||||
"trystero_server.messages_relayed_total",
|
||||
{
|
||||
description: "Frames forwarded between clients, by mode and outcome.",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Up-down counter tracking the live number of attached client sockets
|
||||
* per mode.
|
||||
*/
|
||||
const activeConnectionsGauge = meter.createUpDownCounter(
|
||||
"trystero_server.active_connections",
|
||||
{
|
||||
description: "Currently attached client sockets, by mode.",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Histogram of `start()` wall-clock duration in milliseconds, by mode.
|
||||
*/
|
||||
const startDurationHistogram = meter.createHistogram(
|
||||
"trystero_server.start_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Wall-clock duration of each TrysteroServer.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("trystero_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("trystero_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(
|
||||
mode: TrysteroServerMode,
|
||||
outcome: ConnectionOutcome
|
||||
): void {
|
||||
connectionsCounter.add(1, { mode, outcome });
|
||||
}
|
||||
|
||||
/** Outcome label for {@link recordMessageRelayed}. */
|
||||
export type MessageRelayOutcome = "ok" | "error";
|
||||
|
||||
/** Increment the relayed-messages counter. */
|
||||
export function recordMessageRelayed(
|
||||
mode: TrysteroServerMode,
|
||||
outcome: MessageRelayOutcome
|
||||
): void {
|
||||
messagesRelayedCounter.add(1, { mode, outcome });
|
||||
}
|
||||
|
||||
/** Adjust the active-connections gauge by `delta` (+1 / -1). */
|
||||
export function adjustActiveConnections(
|
||||
mode: TrysteroServerMode,
|
||||
delta: number
|
||||
): void {
|
||||
if (delta === 0) return;
|
||||
activeConnectionsGauge.add(delta, { mode });
|
||||
}
|
||||
|
||||
/** Record one observation of `start()` wall-clock duration. */
|
||||
export function recordStartDuration(
|
||||
mode: TrysteroServerMode,
|
||||
ms: number
|
||||
): void {
|
||||
startDurationHistogram.record(Math.max(0, ms), { mode });
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
NET_TRYSTERO_SERVER_CODES,
|
||||
NetTrysteroServerError,
|
||||
} from "../errors/errors.js";
|
||||
import type { BrokerFactory, TrysteroServerConfig } from "../types/types.js";
|
||||
import { selectBrokerFactory } from "./broker-select.js";
|
||||
|
||||
const dummyFactory: BrokerFactory = () => ({
|
||||
mode: "mqtt",
|
||||
activeConnections: 0,
|
||||
start: async () => {},
|
||||
stop: async () => {},
|
||||
});
|
||||
|
||||
describe("selectBrokerFactory", () => {
|
||||
it("selects the mqtt factory and forwards mqtt options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = selectBrokerFactory({
|
||||
mode: "mqtt",
|
||||
port: 0,
|
||||
mqttBrokerFactory: dummyFactory,
|
||||
mqtt: { password: "x" },
|
||||
});
|
||||
expect(out.mode).toBe("mqtt");
|
||||
expect(out.factory).toBe(dummyFactory);
|
||||
expect((out.options as { password?: string }).password).toBe("x");
|
||||
});
|
||||
|
||||
it("selects the nostr factory and forwards nostr options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const factory: BrokerFactory = (deps) => ({
|
||||
mode: deps.mode,
|
||||
activeConnections: 0,
|
||||
start: async () => {},
|
||||
stop: async () => {},
|
||||
});
|
||||
const out = selectBrokerFactory({
|
||||
mode: "nostr",
|
||||
port: 0,
|
||||
nostrRelayFactory: factory,
|
||||
nostr: { relayName: "test" },
|
||||
});
|
||||
expect(out.mode).toBe("nostr");
|
||||
expect((out.options as { relayName?: string }).relayName).toBe("test");
|
||||
});
|
||||
|
||||
it("defaults options to {} when omitted", { timeout: 5000 }, () => {
|
||||
const out = selectBrokerFactory({
|
||||
mode: "mqtt",
|
||||
port: 0,
|
||||
mqttBrokerFactory: dummyFactory,
|
||||
});
|
||||
expect(out.options).toEqual({});
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS when mqtt factory is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
selectBrokerFactory({ mode: "mqtt", port: 0 })
|
||||
).toThrowError(NetTrysteroServerError);
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS when nostr factory is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
selectBrokerFactory({ mode: "nostr", port: 0 });
|
||||
throw new Error("should throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(NetTrysteroServerError);
|
||||
expect((e as NetTrysteroServerError).code).toBe(
|
||||
NET_TRYSTERO_SERVER_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws MODE_UNSUPPORTED when mode is widened past the enum", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cfg = {
|
||||
mode: "ipfs",
|
||||
port: 0,
|
||||
} as unknown as TrysteroServerConfig;
|
||||
try {
|
||||
selectBrokerFactory(cfg);
|
||||
throw new Error("should throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(NetTrysteroServerError);
|
||||
expect((e as NetTrysteroServerError).code).toBe(
|
||||
NET_TRYSTERO_SERVER_CODES.MODE_UNSUPPORTED
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
NET_TRYSTERO_SERVER_CODES,
|
||||
NetTrysteroServerError,
|
||||
} from "../errors/errors.js";
|
||||
import type {
|
||||
BrokerFactory,
|
||||
TrysteroServerConfig,
|
||||
TrysteroServerMode,
|
||||
TrysteroServerModeOptions,
|
||||
} from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Resolve the broker factory + per-mode options struct the daemon
|
||||
* will hand to that factory at start-time. Construction-time
|
||||
* selection — never branched on at runtime.
|
||||
*
|
||||
* Rejects with `INVALID_OPTIONS` when the matching factory is
|
||||
* missing, and with `MODE_UNSUPPORTED` if the caller somehow drives
|
||||
* past the Zod enum (e.g. by widening the type at the boundary).
|
||||
*/
|
||||
export function selectBrokerFactory(config: TrysteroServerConfig): {
|
||||
readonly factory: BrokerFactory;
|
||||
readonly mode: TrysteroServerMode;
|
||||
readonly options: TrysteroServerModeOptions;
|
||||
} {
|
||||
const mode = config.mode;
|
||||
if (mode === "mqtt") {
|
||||
if (typeof config.mqttBrokerFactory !== "function") {
|
||||
throw new NetTrysteroServerError(
|
||||
NET_TRYSTERO_SERVER_CODES.INVALID_OPTIONS,
|
||||
"trystero_server.mqtt-factory-required"
|
||||
);
|
||||
}
|
||||
return {
|
||||
factory: config.mqttBrokerFactory,
|
||||
mode,
|
||||
options: config.mqtt ?? {},
|
||||
};
|
||||
}
|
||||
if (mode === "nostr") {
|
||||
if (typeof config.nostrRelayFactory !== "function") {
|
||||
throw new NetTrysteroServerError(
|
||||
NET_TRYSTERO_SERVER_CODES.INVALID_OPTIONS,
|
||||
"trystero_server.nostr-factory-required"
|
||||
);
|
||||
}
|
||||
return {
|
||||
factory: config.nostrRelayFactory,
|
||||
mode,
|
||||
options: config.nostr ?? {},
|
||||
};
|
||||
}
|
||||
throw new NetTrysteroServerError(
|
||||
NET_TRYSTERO_SERVER_CODES.MODE_UNSUPPORTED,
|
||||
"trystero_server.mode-unsupported",
|
||||
{ context: { mode } }
|
||||
);
|
||||
}
|
||||
@@ -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()`.
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createServer } from "node:http";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
NET_TRYSTERO_SERVER_CODES,
|
||||
NetTrysteroServerError,
|
||||
} from "../errors/errors.js";
|
||||
import { listenHttp } from "./health-listener.js";
|
||||
|
||||
describe("listenHttp", () => {
|
||||
it("maps non-EADDRINUSE errno to LISTEN_FAILED via synthetic error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Pin the generic-errno branch deterministically across hosts —
|
||||
// emitting the synthetic error on the server bypasses OS-level
|
||||
// behaviour differences between Node and Bun.
|
||||
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_TRYSTERO_SERVER_CODES.LISTEN_FAILED,
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((r) => http.close(() => r()));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects when AbortSignal fires before listening completes", {
|
||||
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(NetTrysteroServerError);
|
||||
} finally {
|
||||
await new Promise<void>((r) => http.close(() => r()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Server as HttpServer } from "node:http";
|
||||
import {
|
||||
NET_TRYSTERO_SERVER_CODES,
|
||||
NetTrysteroServerError,
|
||||
} from "../errors/errors.js";
|
||||
|
||||
/**
|
||||
* Bind a Node `http.Server` to `(port, host)` and resolve once the
|
||||
* `listening` event fires. Maps `EADDRINUSE` to
|
||||
* `NET_TRYSTERO_SERVER.LISTEN_FAILED` with `messageKey =
|
||||
* "trystero_server.port-in-use"`, every other errno to a generic
|
||||
* `LISTEN_FAILED`, and a fired `AbortSignal` to `START_FAILED` with
|
||||
* `messageKey = "trystero_server.aborted"` so callers can roll back
|
||||
* the broker and unwind cleanly. Extracted from `server.ts` to keep
|
||||
* that module under the 300-line cap.
|
||||
*/
|
||||
export function listenHttp(
|
||||
http: HttpServer,
|
||||
port: number,
|
||||
host: string,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: NodeJS.ErrnoException): void => {
|
||||
http.removeListener("listening", onListening);
|
||||
if (signal !== undefined) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
if (err.code === "EADDRINUSE") {
|
||||
reject(
|
||||
new NetTrysteroServerError(
|
||||
NET_TRYSTERO_SERVER_CODES.LISTEN_FAILED,
|
||||
"trystero_server.port-in-use",
|
||||
{ cause: err, context: { port } }
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
reject(
|
||||
new NetTrysteroServerError(
|
||||
NET_TRYSTERO_SERVER_CODES.LISTEN_FAILED,
|
||||
"trystero_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 NetTrysteroServerError(
|
||||
NET_TRYSTERO_SERVER_CODES.START_FAILED,
|
||||
"trystero_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,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.
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
BrokerSocketLike,
|
||||
TrysteroServerEventMap,
|
||||
TrysteroServerPeerInfo,
|
||||
} from "../types/types.js";
|
||||
import { buildPeerCallbacks, type PeerEventsContext } from "./peer-events.js";
|
||||
|
||||
function makeCtx(
|
||||
opts: {
|
||||
maxConnections?: number;
|
||||
emit?: <K extends keyof TrysteroServerEventMap>(
|
||||
topic: K,
|
||||
payload: TrysteroServerEventMap[K]
|
||||
) => void;
|
||||
} = {}
|
||||
): { ctx: PeerEventsContext; peers: Map<string, TrysteroServerPeerInfo> } {
|
||||
const peers = new Map<string, TrysteroServerPeerInfo>();
|
||||
const ctx: PeerEventsContext = {
|
||||
peers,
|
||||
mode: "mqtt",
|
||||
maxConnections: opts.maxConnections ?? 100,
|
||||
activeCount: () =>
|
||||
Array.from(peers.values()).filter((p) => p.connected).length,
|
||||
emit: opts.emit ?? (() => {}),
|
||||
};
|
||||
return { ctx, peers };
|
||||
}
|
||||
|
||||
function socket(id: string): BrokerSocketLike & { closed: boolean } {
|
||||
let closed = false;
|
||||
return {
|
||||
id,
|
||||
get closed() {
|
||||
return closed;
|
||||
},
|
||||
send: () => {},
|
||||
close: () => {
|
||||
closed = true;
|
||||
},
|
||||
} as BrokerSocketLike & { closed: boolean };
|
||||
}
|
||||
|
||||
describe("buildPeerCallbacks", () => {
|
||||
it("onConnect admits a new peer", { timeout: 5000 }, () => {
|
||||
const { ctx, peers } = makeCtx();
|
||||
const cb = buildPeerCallbacks(ctx);
|
||||
cb.onConnect(socket("a"));
|
||||
expect(peers.size).toBe(1);
|
||||
expect(peers.get("a")?.connected).toBe(true);
|
||||
});
|
||||
|
||||
it("onConnect rejects past maxConnections (and closes socket)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { ctx, peers } = makeCtx({ maxConnections: 1 });
|
||||
const cb = buildPeerCallbacks(ctx);
|
||||
cb.onConnect(socket("a"));
|
||||
const sb = socket("b");
|
||||
cb.onConnect(sb);
|
||||
expect(peers.size).toBe(1);
|
||||
expect(sb.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("onDisconnect flips connected and emits the topic", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const seen: Array<keyof TrysteroServerEventMap> = [];
|
||||
const { ctx, peers } = makeCtx({
|
||||
emit: (t) => {
|
||||
seen.push(t);
|
||||
},
|
||||
});
|
||||
const cb = buildPeerCallbacks(ctx);
|
||||
cb.onConnect(socket("a"));
|
||||
cb.onDisconnect("a");
|
||||
expect(peers.get("a")?.connected).toBe(false);
|
||||
expect(seen).toContain("trystero_server:peer-disconnected");
|
||||
});
|
||||
|
||||
it("onDisconnect is a noop for an unknown peer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { ctx, peers } = makeCtx();
|
||||
const cb = buildPeerCallbacks(ctx);
|
||||
cb.onDisconnect("ghost");
|
||||
expect(peers.size).toBe(0);
|
||||
});
|
||||
|
||||
it("onDisconnect is a noop for an already-disconnected peer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { ctx, peers } = makeCtx();
|
||||
const cb = buildPeerCallbacks(ctx);
|
||||
cb.onConnect(socket("a"));
|
||||
cb.onDisconnect("a");
|
||||
cb.onDisconnect("a");
|
||||
expect(peers.get("a")?.connected).toBe(false);
|
||||
});
|
||||
|
||||
it("onFrame updates lastSeen + messagesRelayed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { ctx, peers } = makeCtx();
|
||||
const cb = buildPeerCallbacks(ctx);
|
||||
cb.onConnect(socket("a"));
|
||||
cb.onFrame("a");
|
||||
cb.onFrame("a");
|
||||
expect(peers.get("a")?.messagesRelayed).toBe(2);
|
||||
});
|
||||
|
||||
it("onFrame is silent for an unknown peer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { ctx } = makeCtx();
|
||||
const cb = buildPeerCallbacks(ctx);
|
||||
cb.onFrame("ghost");
|
||||
});
|
||||
|
||||
it("onMalformedFrame emits relay-error and bumps lastSeen", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const seen: Array<keyof TrysteroServerEventMap> = [];
|
||||
const { ctx, peers } = makeCtx({
|
||||
emit: (t) => {
|
||||
seen.push(t);
|
||||
},
|
||||
});
|
||||
const cb = buildPeerCallbacks(ctx);
|
||||
cb.onConnect(socket("a"));
|
||||
const before = peers.get("a")?.lastSeen ?? 0;
|
||||
cb.onMalformedFrame("a");
|
||||
expect(seen).toContain("trystero_server:relay-error");
|
||||
expect(peers.get("a")?.lastSeen).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
|
||||
it("onMalformedFrame is safe for an unknown peer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const seen: Array<keyof TrysteroServerEventMap> = [];
|
||||
const { ctx } = makeCtx({
|
||||
emit: (t) => {
|
||||
seen.push(t);
|
||||
},
|
||||
});
|
||||
const cb = buildPeerCallbacks(ctx);
|
||||
cb.onMalformedFrame("ghost");
|
||||
expect(seen).toContain("trystero_server:relay-error");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
adjustActiveConnections,
|
||||
recordConnection,
|
||||
recordMessageRelayed,
|
||||
} from "../otel/otel.js";
|
||||
import type {
|
||||
BrokerCallbacks,
|
||||
BrokerSocketLike,
|
||||
TrysteroServerEventMap,
|
||||
TrysteroServerMode,
|
||||
TrysteroServerPeerInfo,
|
||||
} from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Per-server context handed to {@link buildPeerCallbacks}. Holds the
|
||||
* mutable peer table, the mode literal, the connection cap, and the
|
||||
* daemon's typed emit. Extracted from `server.ts` so that file stays
|
||||
* under the 300-line discipline cap.
|
||||
*/
|
||||
export interface PeerEventsContext {
|
||||
readonly peers: Map<string, TrysteroServerPeerInfo>;
|
||||
readonly mode: TrysteroServerMode;
|
||||
readonly maxConnections: number;
|
||||
activeCount(): number;
|
||||
emit<K extends keyof TrysteroServerEventMap>(
|
||||
topic: K,
|
||||
payload: TrysteroServerEventMap[K]
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `BrokerCallbacks` struct the daemon hands to every
|
||||
* broker factory. The returned object is bound to `ctx` — every
|
||||
* lifecycle event updates the daemon's connection table, emits the
|
||||
* matching OTel signal, and forwards a typed event on the optional
|
||||
* bus.
|
||||
*/
|
||||
export function buildPeerCallbacks(ctx: PeerEventsContext): BrokerCallbacks {
|
||||
return {
|
||||
onConnect: (socket) => onConnect(ctx, socket),
|
||||
onDisconnect: (id) => onDisconnect(ctx, id),
|
||||
onFrame: (id) => onFrame(ctx, id),
|
||||
onMalformedFrame: (id) => onMalformedFrame(ctx, id),
|
||||
};
|
||||
}
|
||||
|
||||
function onConnect(ctx: PeerEventsContext, socket: BrokerSocketLike): void {
|
||||
if (ctx.activeCount() >= ctx.maxConnections) {
|
||||
recordConnection(ctx.mode, "rejected");
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
ctx.peers.set(socket.id, {
|
||||
id: socket.id,
|
||||
connected: true,
|
||||
lastSeen: Date.now(),
|
||||
messagesRelayed: 0,
|
||||
});
|
||||
adjustActiveConnections(ctx.mode, +1);
|
||||
recordConnection(ctx.mode, "connected");
|
||||
ctx.emit("trystero_server:peer-connected", {
|
||||
peerId: socket.id,
|
||||
mode: ctx.mode,
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
function onDisconnect(ctx: PeerEventsContext, id: string): void {
|
||||
const existing = ctx.peers.get(id);
|
||||
if (existing === undefined || !existing.connected) return;
|
||||
existing.connected = false;
|
||||
adjustActiveConnections(ctx.mode, -1);
|
||||
recordConnection(ctx.mode, "disconnected");
|
||||
ctx.emit("trystero_server:peer-disconnected", {
|
||||
peerId: id,
|
||||
mode: ctx.mode,
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
function onFrame(ctx: PeerEventsContext, id: string): void {
|
||||
const existing = ctx.peers.get(id);
|
||||
if (existing !== undefined) {
|
||||
existing.lastSeen = Date.now();
|
||||
existing.messagesRelayed += 1;
|
||||
}
|
||||
recordMessageRelayed(ctx.mode, "ok");
|
||||
}
|
||||
|
||||
function onMalformedFrame(ctx: PeerEventsContext, id: string): void {
|
||||
recordMessageRelayed(ctx.mode, "error");
|
||||
ctx.emit("trystero_server:relay-error", {
|
||||
reason: "malformed-frame",
|
||||
mode: ctx.mode,
|
||||
at: Date.now(),
|
||||
});
|
||||
const existing = ctx.peers.get(id);
|
||||
if (existing !== undefined) {
|
||||
existing.lastSeen = Date.now();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,417 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { EventBusLike } from "@suaveplan/types";
|
||||
import {
|
||||
FakeBroker,
|
||||
makeFakeBrokerFactory,
|
||||
} from "../../tests/setup/fake-trystero-server.js";
|
||||
import {
|
||||
NET_TRYSTERO_SERVER_CODES,
|
||||
NetTrysteroServerError,
|
||||
} from "../errors/errors.js";
|
||||
import type {
|
||||
BrokerFactory,
|
||||
TrysteroServerConfig,
|
||||
TrysteroServerEventMap,
|
||||
} from "../types/types.js";
|
||||
import { TrysteroServer } from "./server.js";
|
||||
|
||||
function makeRecordingBus(): {
|
||||
readonly bus: EventBusLike<TrysteroServerEventMap>;
|
||||
readonly events: Array<keyof TrysteroServerEventMap>;
|
||||
} {
|
||||
const events: Array<keyof TrysteroServerEventMap> = [];
|
||||
const bus: EventBusLike<TrysteroServerEventMap> = {
|
||||
on: () => () => {},
|
||||
emit: (topic) => {
|
||||
events.push(topic);
|
||||
},
|
||||
};
|
||||
return { bus, events };
|
||||
}
|
||||
|
||||
function makeThrowingBus(): EventBusLike<TrysteroServerEventMap> {
|
||||
return {
|
||||
on: () => () => {},
|
||||
emit: () => {
|
||||
throw new Error("bus oops");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mqttConfig(
|
||||
overrides: Partial<TrysteroServerConfig> = {}
|
||||
): TrysteroServerConfig {
|
||||
const { factory } = makeFakeBrokerFactory("mqtt");
|
||||
return {
|
||||
mode: "mqtt",
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
mqttBrokerFactory: factory,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function nostrConfig(
|
||||
overrides: Partial<TrysteroServerConfig> = {}
|
||||
): TrysteroServerConfig {
|
||||
const { factory } = makeFakeBrokerFactory("nostr");
|
||||
return {
|
||||
mode: "nostr",
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
nostrRelayFactory: factory,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TrysteroServer constructor", () => {
|
||||
it("throws INVALID_OPTIONS on bad port", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new TrysteroServer({
|
||||
mode: "mqtt",
|
||||
port: -1,
|
||||
mqttBrokerFactory: () => new FakeBroker("mqtt"),
|
||||
} as TrysteroServerConfig);
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(NetTrysteroServerError);
|
||||
expect((e as NetTrysteroServerError).code).toBe(
|
||||
NET_TRYSTERO_SERVER_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS when mqtt factory is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new TrysteroServer({ mode: "mqtt", port: 0 });
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(NetTrysteroServerError);
|
||||
expect((e as NetTrysteroServerError).code).toBe(
|
||||
NET_TRYSTERO_SERVER_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS when nostr factory is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new TrysteroServer({ mode: "nostr", port: 0 });
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(NetTrysteroServerError);
|
||||
expect((e as NetTrysteroServerError).code).toBe(
|
||||
NET_TRYSTERO_SERVER_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes mode + resolved config", { timeout: 5000 }, () => {
|
||||
const server = new TrysteroServer(mqttConfig({ maxConnections: 42 }));
|
||||
expect(server.mode).toBe("mqtt");
|
||||
expect(server.config.maxConnections).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TrysteroServer lifecycle (mqtt)", () => {
|
||||
it("start → snapshot → stop round-trip", { timeout: 5000 }, async () => {
|
||||
const server = new TrysteroServer(mqttConfig());
|
||||
const res = await server.start();
|
||||
expect(res.mode).toBe("mqtt");
|
||||
expect(res.port).toBeGreaterThan(0);
|
||||
expect(res.relayUrl.startsWith("ws://")).toBe(true);
|
||||
expect(res.relayUrl.endsWith("/mqtt")).toBe(true);
|
||||
expect(server.boundAddress).toBeDefined();
|
||||
expect(server.boundPort).toBeGreaterThan(0);
|
||||
expect(server.relayUrl).toBe(res.relayUrl);
|
||||
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 TrysteroServer(mqttConfig());
|
||||
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 TrysteroServer(mqttConfig());
|
||||
await server.start();
|
||||
await server.stop();
|
||||
await expect(server.start()).rejects.toMatchObject({
|
||||
code: NET_TRYSTERO_SERVER_CODES.DISPOSED,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects second start() while running", { timeout: 5000 }, async () => {
|
||||
const server = new TrysteroServer(mqttConfig());
|
||||
await server.start();
|
||||
await expect(server.start()).rejects.toMatchObject({
|
||||
code: NET_TRYSTERO_SERVER_CODES.START_FAILED,
|
||||
});
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
it("rejects start() when signal is pre-aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const server = new TrysteroServer(mqttConfig());
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
server.start({ signal: ctrl.signal })
|
||||
).rejects.toMatchObject({
|
||||
code: NET_TRYSTERO_SERVER_CODES.START_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces LISTEN_FAILED when bind collides", {
|
||||
timeout: 8000,
|
||||
}, async () => {
|
||||
const a = new TrysteroServer(mqttConfig());
|
||||
const { port } = await a.start();
|
||||
const b = new TrysteroServer(mqttConfig({ port }));
|
||||
try {
|
||||
await expect(b.start()).rejects.toMatchObject({
|
||||
code: NET_TRYSTERO_SERVER_CODES.LISTEN_FAILED,
|
||||
});
|
||||
} finally {
|
||||
await a.stop();
|
||||
await b.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("propagates a broker.start() throw as START_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broken = new FakeBroker("mqtt");
|
||||
broken.startError = new Error("broker boom");
|
||||
const factory: BrokerFactory = () => broken;
|
||||
const server = new TrysteroServer(
|
||||
mqttConfig({ mqttBrokerFactory: factory })
|
||||
);
|
||||
await expect(server.start()).rejects.toMatchObject({
|
||||
code: NET_TRYSTERO_SERVER_CODES.START_FAILED,
|
||||
});
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
it("propagates a broker factory throw as START_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const factory: BrokerFactory = () => {
|
||||
throw new Error("factory boom");
|
||||
};
|
||||
const server = new TrysteroServer(
|
||||
mqttConfig({ mqttBrokerFactory: factory })
|
||||
);
|
||||
await expect(server.start()).rejects.toMatchObject({
|
||||
code: NET_TRYSTERO_SERVER_CODES.START_FAILED,
|
||||
});
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
it("starts successfully with a non-aborted signal attached", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const server = new TrysteroServer(mqttConfig());
|
||||
const ctrl = new AbortController();
|
||||
const { port } = await server.start({ signal: ctrl.signal });
|
||||
expect(port).toBeGreaterThan(0);
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
it("aborts mid-start when signal fires during listen", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const server = new TrysteroServer(mqttConfig());
|
||||
const ctrl = new AbortController();
|
||||
queueMicrotask(() => ctrl.abort());
|
||||
await expect(
|
||||
server.start({ signal: ctrl.signal })
|
||||
).rejects.toMatchObject({
|
||||
code: NET_TRYSTERO_SERVER_CODES.START_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces LISTEN_FAILED with signal attached (signal-cleanup branch)", {
|
||||
timeout: 8000,
|
||||
}, async () => {
|
||||
const a = new TrysteroServer(mqttConfig());
|
||||
const { port } = await a.start();
|
||||
const b = new TrysteroServer(mqttConfig({ port }));
|
||||
const ctrl = new AbortController();
|
||||
try {
|
||||
await expect(
|
||||
b.start({ signal: ctrl.signal })
|
||||
).rejects.toMatchObject({
|
||||
code: NET_TRYSTERO_SERVER_CODES.LISTEN_FAILED,
|
||||
});
|
||||
} finally {
|
||||
await a.stop();
|
||||
await b.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("rewrites 0.0.0.0 to 127.0.0.1 in relayUrl", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const server = new TrysteroServer(mqttConfig({ host: "0.0.0.0" }));
|
||||
const { relayUrl, address } = await server.start();
|
||||
expect(address === "0.0.0.0" || address === "::").toBe(true);
|
||||
expect(relayUrl).toContain("127.0.0.1");
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
it("surfaces non-EADDRINUSE listen failures as LISTEN_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Reserved TEST-NET-3 address — listen() rejects with
|
||||
// EADDRNOTAVAIL on Linux. The dedicated listen-http unit test
|
||||
// exercises the synthetic-error path for runtime-independent
|
||||
// coverage of the generic-errno branch.
|
||||
const server = new TrysteroServer(mqttConfig({ host: "203.0.113.99" }));
|
||||
await expect(server.start()).rejects.toMatchObject({
|
||||
code: NET_TRYSTERO_SERVER_CODES.LISTEN_FAILED,
|
||||
});
|
||||
await server.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TrysteroServer lifecycle (nostr)", () => {
|
||||
it("publishes a /nostr relayUrl", { timeout: 5000 }, async () => {
|
||||
const server = new TrysteroServer(nostrConfig());
|
||||
const { relayUrl, mode } = await server.start();
|
||||
expect(mode).toBe("nostr");
|
||||
expect(relayUrl.endsWith("/nostr")).toBe(true);
|
||||
await server.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TrysteroServer peer wiring", () => {
|
||||
it("emits peer-connected and peer-disconnected on broker events", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { bus, events } = makeRecordingBus();
|
||||
const { factory, instances } = makeFakeBrokerFactory("mqtt");
|
||||
const server = new TrysteroServer(
|
||||
mqttConfig({ mqttBrokerFactory: factory, bus })
|
||||
);
|
||||
await server.start();
|
||||
const broker = instances[0];
|
||||
if (!broker) throw new Error("expected broker");
|
||||
broker.connect("peer-A");
|
||||
expect(server.activeConnections).toBe(1);
|
||||
expect(events).toContain("trystero_server:peer-connected");
|
||||
broker.frame("peer-A");
|
||||
broker.malformed("peer-A");
|
||||
expect(server.peers()[0]?.messagesRelayed).toBe(1);
|
||||
broker.disconnect("peer-A");
|
||||
expect(server.activeConnections).toBe(0);
|
||||
expect(events).toContain("trystero_server:peer-disconnected");
|
||||
expect(events).toContain("trystero_server:relay-error");
|
||||
await server.stop();
|
||||
expect(events).toContain("trystero_server:stopped");
|
||||
});
|
||||
|
||||
it("rejects new peers past maxConnections", { timeout: 5000 }, async () => {
|
||||
const { factory, instances } = makeFakeBrokerFactory("mqtt");
|
||||
const server = new TrysteroServer(
|
||||
mqttConfig({ mqttBrokerFactory: factory, maxConnections: 1 })
|
||||
);
|
||||
await server.start();
|
||||
const broker = instances[0];
|
||||
if (!broker) throw new Error("expected broker");
|
||||
broker.connect("peer-A");
|
||||
const second = broker.connect("peer-B");
|
||||
expect(server.activeConnections).toBe(1);
|
||||
expect(second.closed).toBe(true);
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
it("emits peer-disconnected for tracked peers on stop", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { bus, events } = makeRecordingBus();
|
||||
const { factory, instances } = makeFakeBrokerFactory("mqtt");
|
||||
const server = new TrysteroServer(
|
||||
mqttConfig({ mqttBrokerFactory: factory, bus })
|
||||
);
|
||||
await server.start();
|
||||
const broker = instances[0];
|
||||
if (!broker) throw new Error("expected broker");
|
||||
broker.connect("peer-A");
|
||||
await server.stop();
|
||||
expect(events).toContain("trystero_server:peer-disconnected");
|
||||
expect(events).toContain("trystero_server:stopped");
|
||||
});
|
||||
|
||||
it("swallows throws from a misbehaving bus", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bus = makeThrowingBus();
|
||||
const { factory, instances } = makeFakeBrokerFactory("mqtt");
|
||||
const server = new TrysteroServer(
|
||||
mqttConfig({ mqttBrokerFactory: factory, bus })
|
||||
);
|
||||
await server.start();
|
||||
const broker = instances[0];
|
||||
if (!broker) throw new Error("expected broker");
|
||||
broker.connect("peer-A");
|
||||
broker.frame("peer-A");
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
it("survives a broker.stop() that throws", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeBroker("mqtt");
|
||||
broker.stopError = new Error("stop boom");
|
||||
const factory: BrokerFactory = () => broker;
|
||||
const server = new TrysteroServer(
|
||||
mqttConfig({ mqttBrokerFactory: factory })
|
||||
);
|
||||
await server.start();
|
||||
await server.stop();
|
||||
expect(server.snapshot().disposed).toBe(true);
|
||||
});
|
||||
|
||||
it("malformed-frame updates lastSeen for unknown peer (no entry)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory, instances } = makeFakeBrokerFactory("mqtt");
|
||||
const server = new TrysteroServer(
|
||||
mqttConfig({ mqttBrokerFactory: factory })
|
||||
);
|
||||
await server.start();
|
||||
const broker = instances[0];
|
||||
if (!broker) throw new Error("expected broker");
|
||||
broker.malformed("ghost");
|
||||
broker.frame("ghost");
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
it("ignores duplicate disconnects for an unknown peer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory, instances } = makeFakeBrokerFactory("mqtt");
|
||||
const server = new TrysteroServer(
|
||||
mqttConfig({ mqttBrokerFactory: factory })
|
||||
);
|
||||
await server.start();
|
||||
const broker = instances[0];
|
||||
if (!broker) throw new Error("expected broker");
|
||||
broker.disconnect("ghost");
|
||||
expect(server.activeConnections).toBe(0);
|
||||
await server.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
// 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_TRYSTERO_SERVER_CODES,
|
||||
NetTrysteroServerError,
|
||||
} from "../errors/errors.js";
|
||||
import { type HealthSnapshot, handleHealth } from "../health/health.js";
|
||||
import {
|
||||
adjustActiveConnections,
|
||||
recordConnection,
|
||||
recordStartDuration,
|
||||
withStartSpan,
|
||||
withStopSpan,
|
||||
} from "../otel/otel.js";
|
||||
import type {
|
||||
BrokerLike,
|
||||
ResolvedTrysteroServerConfig,
|
||||
TrysteroServerConfig,
|
||||
TrysteroServerEventMap,
|
||||
TrysteroServerMode,
|
||||
TrysteroServerPeerInfo,
|
||||
TrysteroServerStartResult,
|
||||
} from "../types/types.js";
|
||||
import { TrysteroServerConfigSchema } from "../types/types.schemas.js";
|
||||
import { selectBrokerFactory } from "./broker-select.js";
|
||||
import { listenHttp } from "./health-listener.js";
|
||||
import { buildPeerCallbacks } from "./peer-events.js";
|
||||
|
||||
/**
|
||||
* Server-tier Trystero signaling daemon. Hosts an MQTT-style or
|
||||
* Nostr-style WebSocket broker shim (selected at construction time)
|
||||
* alongside an inline HTTP listener exposing `/health` / `/ready` /
|
||||
* `/live`. Browser-tier `network-discovery-trystero` clients connect
|
||||
* to the published `relayUrl` for relay-backed P2P signaling.
|
||||
*
|
||||
* 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 TrysteroServer {
|
||||
readonly #resolved: ResolvedTrysteroServerConfig;
|
||||
readonly #peers = new Map<string, TrysteroServerPeerInfo>();
|
||||
#http: HttpServer | undefined;
|
||||
#broker: BrokerLike | undefined;
|
||||
#started = false;
|
||||
#disposed = false;
|
||||
#startedAt: number | undefined;
|
||||
#relayUrl: string | undefined;
|
||||
#boundAddress: string | undefined;
|
||||
#boundPort: number | undefined;
|
||||
|
||||
public constructor(config: TrysteroServerConfig) {
|
||||
const parsed = TrysteroServerConfigSchema.safeParse(config);
|
||||
if (!parsed.success) {
|
||||
throw new NetTrysteroServerError(
|
||||
NET_TRYSTERO_SERVER_CODES.INVALID_OPTIONS,
|
||||
"trystero_server.invalid-config",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const { factory, mode, options } = selectBrokerFactory(config);
|
||||
this.#resolved = {
|
||||
mode,
|
||||
port: parsed.data.port,
|
||||
host: parsed.data.host,
|
||||
maxConnections: parsed.data.maxConnections,
|
||||
mqtt:
|
||||
mode === "mqtt"
|
||||
? (options as ResolvedTrysteroServerConfig["mqtt"])
|
||||
: (parsed.data.mqtt ?? {}),
|
||||
nostr:
|
||||
mode === "nostr"
|
||||
? (options as ResolvedTrysteroServerConfig["nostr"])
|
||||
: (parsed.data.nostr ?? {
|
||||
relayName: "suaveplan-trystero-nostr",
|
||||
}),
|
||||
factory,
|
||||
bus: config.bus,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolved configuration snapshot. */
|
||||
public get config(): ResolvedTrysteroServerConfig {
|
||||
return this.#resolved;
|
||||
}
|
||||
|
||||
/** Operating mode the daemon is hosting. */
|
||||
public get mode(): TrysteroServerMode {
|
||||
return this.#resolved.mode;
|
||||
}
|
||||
|
||||
/** Current attached client socket count. */
|
||||
public get activeConnections(): number {
|
||||
return Array.from(this.#peers.values()).filter((p) => p.connected)
|
||||
.length;
|
||||
}
|
||||
|
||||
/** Snapshot of every client socket the daemon has observed. */
|
||||
public peers(): readonly TrysteroServerPeerInfo[] {
|
||||
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,
|
||||
mode: this.#resolved.mode,
|
||||
relayUrl: this.#relayUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the HTTP listener, attach the broker shim, and resolve
|
||||
* with `{ address, port, mode, relayUrl }`.
|
||||
*
|
||||
* @throws NET_TRYSTERO_SERVER.DISPOSED — already stopped.
|
||||
* @throws NET_TRYSTERO_SERVER.START_FAILED — factory threw or
|
||||
* `start()` invoked twice / pre-aborted.
|
||||
* @throws NET_TRYSTERO_SERVER.LISTEN_FAILED — port bind failed.
|
||||
*/
|
||||
public async start(options?: {
|
||||
readonly signal?: AbortSignal;
|
||||
}): Promise<TrysteroServerStartResult> {
|
||||
if (this.#disposed) {
|
||||
throw new NetTrysteroServerError(
|
||||
NET_TRYSTERO_SERVER_CODES.DISPOSED,
|
||||
"trystero_server.start-after-stop"
|
||||
);
|
||||
}
|
||||
if (this.#started) {
|
||||
throw new NetTrysteroServerError(
|
||||
NET_TRYSTERO_SERVER_CODES.START_FAILED,
|
||||
"trystero_server.already-started"
|
||||
);
|
||||
}
|
||||
const signal = options?.signal;
|
||||
if (signal?.aborted === true) {
|
||||
throw new NetTrysteroServerError(
|
||||
NET_TRYSTERO_SERVER_CODES.START_FAILED,
|
||||
"trystero_server.aborted"
|
||||
);
|
||||
}
|
||||
const t0 = Date.now();
|
||||
return withStartSpan(() => this.#runStart(signal, t0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain in-flight clients, stop the broker, close the HTTP
|
||||
* listener, 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;
|
||||
await this.#stopBroker();
|
||||
const peerIds = Array.from(this.#peers.keys());
|
||||
this.#peers.clear();
|
||||
for (const id of peerIds) {
|
||||
adjustActiveConnections(this.#resolved.mode, -1);
|
||||
recordConnection(this.#resolved.mode, "disconnected");
|
||||
this.#emit("trystero_server:peer-disconnected", {
|
||||
peerId: id,
|
||||
mode: this.#resolved.mode,
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
await this.#closeHttp();
|
||||
this.#broker = undefined;
|
||||
this.#emit("trystero_server:stopped", {
|
||||
mode: this.#resolved.mode,
|
||||
at: Date.now(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ───────────────────────── private ─────────────────────────
|
||||
|
||||
async #runStart(
|
||||
signal: AbortSignal | undefined,
|
||||
t0: number
|
||||
): Promise<TrysteroServerStartResult> {
|
||||
const http = createServer((req, res) => {
|
||||
if (handleHealth(req, res, this.snapshot())) return;
|
||||
res.writeHead(200, { "content-type": "text/plain" });
|
||||
res.end(`trystero relay (${this.#resolved.mode})`);
|
||||
});
|
||||
this.#http = http;
|
||||
await listenHttp(
|
||||
http,
|
||||
this.#resolved.port,
|
||||
this.#resolved.host,
|
||||
signal
|
||||
);
|
||||
try {
|
||||
this.#broker = await this.#resolved.factory({
|
||||
mode: this.#resolved.mode,
|
||||
options:
|
||||
this.#resolved.mode === "mqtt"
|
||||
? this.#resolved.mqtt
|
||||
: this.#resolved.nostr,
|
||||
});
|
||||
await this.#broker.start({
|
||||
httpServer: http,
|
||||
signal,
|
||||
callbacks: buildPeerCallbacks({
|
||||
peers: this.#peers,
|
||||
mode: this.#resolved.mode,
|
||||
maxConnections: this.#resolved.maxConnections,
|
||||
activeCount: () => this.activeConnections,
|
||||
emit: (t, p) => this.#emit(t, p),
|
||||
}),
|
||||
maxConnections: this.#resolved.maxConnections,
|
||||
});
|
||||
} catch (cause) {
|
||||
await this.#closeHttp();
|
||||
this.#broker = undefined;
|
||||
throw new NetTrysteroServerError(
|
||||
NET_TRYSTERO_SERVER_CODES.START_FAILED,
|
||||
"trystero_server.broker-start-failed",
|
||||
{ cause: cause as Error }
|
||||
);
|
||||
}
|
||||
const addr = http.address() as { address: string; port: number };
|
||||
this.#boundAddress = addr.address;
|
||||
this.#boundPort = addr.port;
|
||||
const hostForUrl =
|
||||
addr.address === "0.0.0.0" || addr.address === "::"
|
||||
? "127.0.0.1"
|
||||
: addr.address;
|
||||
this.#relayUrl = `ws://${hostForUrl}:${addr.port}/${this.#resolved.mode}`;
|
||||
this.#started = true;
|
||||
this.#startedAt = Date.now();
|
||||
recordStartDuration(this.#resolved.mode, this.#startedAt - t0);
|
||||
this.#emit("trystero_server:started", {
|
||||
address: addr.address,
|
||||
port: addr.port,
|
||||
mode: this.#resolved.mode,
|
||||
relayUrl: this.#relayUrl,
|
||||
at: this.#startedAt,
|
||||
});
|
||||
return {
|
||||
address: addr.address,
|
||||
port: addr.port,
|
||||
mode: this.#resolved.mode,
|
||||
relayUrl: this.#relayUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async #closeHttp(): Promise<void> {
|
||||
const http = this.#http;
|
||||
if (!http) return;
|
||||
this.#http = undefined;
|
||||
await new Promise<void>((resolve) => {
|
||||
http.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async #stopBroker(): Promise<void> {
|
||||
try {
|
||||
await this.#broker?.stop();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
#emit<K extends keyof TrysteroServerEventMap>(
|
||||
topic: K,
|
||||
payload: TrysteroServerEventMap[K]
|
||||
): void {
|
||||
const bus = this.#resolved.bus;
|
||||
if (bus === undefined) return;
|
||||
try {
|
||||
bus.emit(topic, payload);
|
||||
} catch {
|
||||
// Bus failures must not crash the relay.
|
||||
}
|
||||
}
|
||||
|
||||
/** Bound HTTP address (after `start()` resolves). */
|
||||
public get boundAddress(): string | undefined {
|
||||
return this.#boundAddress;
|
||||
}
|
||||
|
||||
/** Bound HTTP port (after `start()` resolves). */
|
||||
public get boundPort(): number | undefined {
|
||||
return this.#boundPort;
|
||||
}
|
||||
|
||||
/** Bootstrap `ws://...` URL (after `start()` resolves). */
|
||||
public get relayUrl(): string | undefined {
|
||||
return this.#relayUrl;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,34 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod boundary schemas for every public option struct in
|
||||
* `@suaveplan/network-trystero-server`. Callable contracts (broker
|
||||
* factories, `EventBusLike`) are type-only and accepted here as
|
||||
* `z.unknown()` — the daemon constructor enforces presence via a
|
||||
* runtime `typeof` check against the mode-resolved factory.
|
||||
*/
|
||||
export const TrysteroServerMqttOptionsSchema = z
|
||||
.object({
|
||||
password: z.string().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const TrysteroServerNostrOptionsSchema = z
|
||||
.object({
|
||||
relayName: z.string().min(1).default("suaveplan-trystero-nostr"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const TrysteroServerConfigSchema = z
|
||||
.object({
|
||||
mode: z.enum(["mqtt", "nostr"]),
|
||||
port: z.number().int().min(0).max(65535),
|
||||
host: z.string().min(1).default("0.0.0.0"),
|
||||
maxConnections: z.number().int().positive().default(100),
|
||||
mqtt: TrysteroServerMqttOptionsSchema.optional(),
|
||||
nostr: TrysteroServerNostrOptionsSchema.optional(),
|
||||
mqttBrokerFactory: z.unknown().optional(),
|
||||
nostrRelayFactory: z.unknown().optional(),
|
||||
bus: z.unknown().optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { EventBusLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Operating mode the daemon hosts. Selected at construction time —
|
||||
* never branched on at runtime by the orchestrator.
|
||||
*
|
||||
* - `mqtt` — hosts an MQTT-style WebSocket broker shim that mirrors
|
||||
* the wire path browser-tier `network-discovery-trystero` clients
|
||||
* use when configured for `mqtt` strategy. Clients connect to
|
||||
* `ws://<host>:<port>/mqtt` and frame `{ topic, payload }` JSON.
|
||||
* - `nostr` — hosts a Nostr-relay-style WebSocket shim. Clients
|
||||
* connect to `ws://<host>:<port>/nostr` and frame
|
||||
* `["EVENT", { kind, content, ... }]` JSON (NIP-01 lite).
|
||||
*/
|
||||
export type TrysteroServerMode = "mqtt" | "nostr";
|
||||
|
||||
/**
|
||||
* Structural view of a single hosted client socket the broker shim
|
||||
* presents to the daemon. Production wires an adapter over the real
|
||||
* `node:ws` WebSocket; tests bind an in-memory implementation.
|
||||
*/
|
||||
export interface BrokerSocketLike {
|
||||
/** Stable id assigned by the daemon at attach time. */
|
||||
readonly id: string;
|
||||
/** Send a frame down the socket. */
|
||||
send(payload: string): void;
|
||||
/** Close the socket. */
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural view of a hosted broker. The daemon owns the lifecycle —
|
||||
* `start({ httpServer, signal? })` attaches to the inline `http.Server`
|
||||
* already bound by the daemon and resolves once the broker is ready to
|
||||
* accept clients. `stop()` is called during graceful shutdown and must
|
||||
* drain in-flight clients.
|
||||
*
|
||||
* Brokers emit lifecycle events via the `onConnect` / `onDisconnect` /
|
||||
* `onFrame` / `onMalformedFrame` callbacks the daemon wires at attach
|
||||
* time. The daemon's counters and connection table are driven entirely
|
||||
* from those callbacks — the broker itself is observation-only.
|
||||
*/
|
||||
export interface BrokerLike {
|
||||
readonly mode: TrysteroServerMode;
|
||||
start(options: BrokerStartOptions): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
/** Live socket count, used by the gauge. */
|
||||
readonly activeConnections: number;
|
||||
}
|
||||
|
||||
/** Argument struct handed to `BrokerLike.start`. */
|
||||
export interface BrokerStartOptions {
|
||||
readonly httpServer: unknown;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
readonly callbacks: BrokerCallbacks;
|
||||
readonly maxConnections: number;
|
||||
}
|
||||
|
||||
/** Lifecycle callbacks the daemon wires into every hosted broker. */
|
||||
export interface BrokerCallbacks {
|
||||
onConnect(socket: BrokerSocketLike): void;
|
||||
onDisconnect(socketId: string): void;
|
||||
onFrame(socketId: string): void;
|
||||
onMalformedFrame(socketId: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory wiring `(deps) => BrokerLike` for one specific mode.
|
||||
* Production passes a real WS-broker constructor; tests pass an
|
||||
* in-memory deterministic implementation. Synchronous return is the
|
||||
* canonical shape; the factory MAY be async for dynamic imports.
|
||||
*/
|
||||
export type BrokerFactory = (
|
||||
deps: BrokerFactoryDeps
|
||||
) => BrokerLike | Promise<BrokerLike>;
|
||||
|
||||
/** Construction-time dependencies handed to every broker factory. */
|
||||
export interface BrokerFactoryDeps {
|
||||
readonly mode: TrysteroServerMode;
|
||||
/** Mode-specific options forwarded verbatim. */
|
||||
readonly options: TrysteroServerModeOptions;
|
||||
}
|
||||
|
||||
/** Mode-specific options for the MQTT broker shim. */
|
||||
export interface TrysteroServerMqttOptions {
|
||||
/**
|
||||
* Optional auth password — when set, clients must present
|
||||
* `?password=<value>` on the upgrade URL. Unset (the default)
|
||||
* disables auth.
|
||||
*/
|
||||
readonly password?: string | undefined;
|
||||
}
|
||||
|
||||
/** Mode-specific options for the Nostr relay shim. */
|
||||
export interface TrysteroServerNostrOptions {
|
||||
/**
|
||||
* Optional relay name advertised on the NIP-11 `info` document.
|
||||
* Default `"suaveplan-trystero-nostr"`.
|
||||
*/
|
||||
readonly relayName?: string | undefined;
|
||||
}
|
||||
|
||||
/** Discriminated mode-options surface. */
|
||||
export type TrysteroServerModeOptions =
|
||||
| TrysteroServerMqttOptions
|
||||
| TrysteroServerNostrOptions;
|
||||
|
||||
/** Per-client connection record tracked by the daemon. */
|
||||
export interface TrysteroServerPeerInfo {
|
||||
readonly id: string;
|
||||
connected: boolean;
|
||||
lastSeen: number;
|
||||
messagesRelayed: number;
|
||||
}
|
||||
|
||||
/** Typed events emitted on the optional `EventBusLike`. */
|
||||
export interface TrysteroServerStartedEvent {
|
||||
readonly address: string;
|
||||
readonly port: number;
|
||||
readonly mode: TrysteroServerMode;
|
||||
readonly relayUrl: string;
|
||||
readonly at: number;
|
||||
}
|
||||
|
||||
export interface TrysteroServerStoppedEvent {
|
||||
readonly mode: TrysteroServerMode;
|
||||
readonly at: number;
|
||||
}
|
||||
|
||||
export interface TrysteroServerPeerConnectedEvent {
|
||||
readonly peerId: string;
|
||||
readonly mode: TrysteroServerMode;
|
||||
readonly at: number;
|
||||
}
|
||||
|
||||
export interface TrysteroServerPeerDisconnectedEvent {
|
||||
readonly peerId: string;
|
||||
readonly mode: TrysteroServerMode;
|
||||
readonly at: number;
|
||||
}
|
||||
|
||||
export interface TrysteroServerRelayErrorEvent {
|
||||
readonly reason: string;
|
||||
readonly mode: TrysteroServerMode;
|
||||
readonly at: number;
|
||||
}
|
||||
|
||||
export type TrysteroServerEventMap = {
|
||||
"trystero_server:started": TrysteroServerStartedEvent;
|
||||
"trystero_server:stopped": TrysteroServerStoppedEvent;
|
||||
"trystero_server:peer-connected": TrysteroServerPeerConnectedEvent;
|
||||
"trystero_server:peer-disconnected": TrysteroServerPeerDisconnectedEvent;
|
||||
"trystero_server:relay-error": TrysteroServerRelayErrorEvent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Strict configuration for `TrysteroServer`. `mode` selects which
|
||||
* broker shim is hosted; the matching factory MUST be supplied.
|
||||
* There is no default mode — callers are forced to choose explicitly
|
||||
* so consumer telemetry stays unambiguous.
|
||||
*/
|
||||
export interface TrysteroServerConfig {
|
||||
/** Operating mode — `"mqtt"` or `"nostr"`. */
|
||||
readonly mode: TrysteroServerMode;
|
||||
/** TCP port the daemon listens on. Must be in [0, 65535]. */
|
||||
readonly port: number;
|
||||
/** Bind address. Default `"0.0.0.0"`. */
|
||||
readonly host?: string | undefined;
|
||||
/** Hard cap on concurrent hosted client sockets. Default 100. */
|
||||
readonly maxConnections?: number | undefined;
|
||||
/**
|
||||
* MQTT-specific options — required when `mode === "mqtt"`,
|
||||
* ignored otherwise.
|
||||
*/
|
||||
readonly mqtt?: TrysteroServerMqttOptions | undefined;
|
||||
/**
|
||||
* Nostr-specific options — required when `mode === "nostr"`,
|
||||
* ignored otherwise.
|
||||
*/
|
||||
readonly nostr?: TrysteroServerNostrOptions | undefined;
|
||||
/**
|
||||
* Required factory wiring the MQTT broker shim. The factory
|
||||
* receives `BrokerFactoryDeps` and returns a `BrokerLike`.
|
||||
*/
|
||||
readonly mqttBrokerFactory?: BrokerFactory | undefined;
|
||||
/**
|
||||
* Required factory wiring the Nostr relay shim. The factory
|
||||
* receives `BrokerFactoryDeps` and returns a `BrokerLike`.
|
||||
*/
|
||||
readonly nostrRelayFactory?: BrokerFactory | undefined;
|
||||
/** Optional event bus for typed lifecycle emissions. */
|
||||
readonly bus?: EventBusLike<TrysteroServerEventMap> | undefined;
|
||||
}
|
||||
|
||||
/** Fully-resolved configuration — every optional field defaulted. */
|
||||
export interface ResolvedTrysteroServerConfig {
|
||||
readonly mode: TrysteroServerMode;
|
||||
readonly port: number;
|
||||
readonly host: string;
|
||||
readonly maxConnections: number;
|
||||
readonly mqtt: TrysteroServerMqttOptions;
|
||||
readonly nostr: TrysteroServerNostrOptions;
|
||||
readonly factory: BrokerFactory;
|
||||
readonly bus: EventBusLike<TrysteroServerEventMap> | undefined;
|
||||
}
|
||||
|
||||
/** Result of `TrysteroServer.start()`. */
|
||||
export interface TrysteroServerStartResult {
|
||||
readonly address: string;
|
||||
readonly port: number;
|
||||
readonly mode: TrysteroServerMode;
|
||||
/**
|
||||
* Bootstrap URL clients consume — `ws://<host>:<port>/<mode>`. The
|
||||
* daemon publishes this so the same string can be dropped straight
|
||||
* into the matching browser-tier discovery config.
|
||||
*/
|
||||
readonly relayUrl: string;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("network-trystero-server dep-graph", () => {
|
||||
it("must not have circular dependencies", { timeout: 5000 }, async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod.TrysteroServer).toBeDefined();
|
||||
expect(mod.NET_TRYSTERO_SERVER_CODES).toBeDefined();
|
||||
expect(mod.NetTrysteroServerError).toBeDefined();
|
||||
expect(mod.TrysteroServerConfigSchema).toBeDefined();
|
||||
expect(mod.TrysteroServerMqttOptionsSchema).toBeDefined();
|
||||
expect(mod.TrysteroServerNostrOptionsSchema).toBeDefined();
|
||||
expect(mod.selectBrokerFactory).toBeDefined();
|
||||
expect(mod.buildPeerCallbacks).toBeDefined();
|
||||
expect(mod.handleHealth).toBeDefined();
|
||||
expect(mod.classifyHealthRequest).toBeDefined();
|
||||
expect(mod.buildHealthResponse).toBeDefined();
|
||||
expect(mod.HEALTH_PATHS).toBeDefined();
|
||||
expect(mod.recordConnection).toBeDefined();
|
||||
expect(mod.recordMessageRelayed).toBeDefined();
|
||||
expect(mod.adjustActiveConnections).toBeDefined();
|
||||
expect(mod.recordStartDuration).toBeDefined();
|
||||
expect(mod.withStartSpan).toBeDefined();
|
||||
expect(mod.withStopSpan).toBeDefined();
|
||||
expect(mod.listenHttp).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TrysteroServer } from "../src/server/server.js";
|
||||
import { makeFakeBrokerFactory } from "./setup/fake-trystero-server.js";
|
||||
|
||||
async function fetchJson(
|
||||
url: string
|
||||
): Promise<{ readonly status: number; readonly body: unknown }> {
|
||||
const res = await fetch(url);
|
||||
const body = await res.json();
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
function baseUrl(address: string, port: number): string {
|
||||
const host =
|
||||
address === "::" || address === "0.0.0.0" ? "127.0.0.1" : address;
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
describe("integration: real HTTP boot + health probes (mqtt)", () => {
|
||||
it("health/ready/live respond with 200 while running", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
const { factory } = makeFakeBrokerFactory("mqtt");
|
||||
const server = new TrysteroServer({
|
||||
mode: "mqtt",
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
mqttBrokerFactory: factory,
|
||||
});
|
||||
const { address, port, mode, relayUrl } = await server.start();
|
||||
expect(mode).toBe("mqtt");
|
||||
expect(relayUrl.endsWith("/mqtt")).toBe(true);
|
||||
const base = baseUrl(address, port);
|
||||
try {
|
||||
const health = await fetchJson(`${base}/health`);
|
||||
expect(health.status).toBe(200);
|
||||
expect((health.body as { status: string }).status).toBe("ok");
|
||||
expect((health.body as { mode: string }).mode).toBe("mqtt");
|
||||
|
||||
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 marker.
|
||||
const fall = await fetch(`${base}/mqtt`);
|
||||
expect(fall.status).toBe(200);
|
||||
const txt = await fall.text();
|
||||
expect(txt).toContain("mqtt");
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("two clients connect via fake broker; daemon tracks both", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
const { factory, instances } = makeFakeBrokerFactory("mqtt");
|
||||
const server = new TrysteroServer({
|
||||
mode: "mqtt",
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
mqttBrokerFactory: factory,
|
||||
});
|
||||
await server.start();
|
||||
try {
|
||||
const broker = instances[0];
|
||||
if (!broker) throw new Error("expected broker");
|
||||
broker.connect("client-1");
|
||||
broker.connect("client-2");
|
||||
broker.frame("client-1");
|
||||
broker.frame("client-2");
|
||||
expect(server.activeConnections).toBe(2);
|
||||
const ids = server
|
||||
.peers()
|
||||
.map((p) => p.id)
|
||||
.sort();
|
||||
expect(ids).toEqual(["client-1", "client-2"]);
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("malformed frame from a misbehaving client is dropped", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
const { factory, instances } = makeFakeBrokerFactory("mqtt");
|
||||
const server = new TrysteroServer({
|
||||
mode: "mqtt",
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
mqttBrokerFactory: factory,
|
||||
});
|
||||
await server.start();
|
||||
try {
|
||||
const broker = instances[0];
|
||||
if (!broker) throw new Error("expected broker");
|
||||
broker.connect("client-1");
|
||||
broker.malformed("client-1");
|
||||
// Still tracked, no extra peers.
|
||||
expect(server.activeConnections).toBe(1);
|
||||
expect(server.peers()).toHaveLength(1);
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("graceful stop closes the HTTP listener", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
const { factory } = makeFakeBrokerFactory("mqtt");
|
||||
const server = new TrysteroServer({
|
||||
mode: "mqtt",
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
mqttBrokerFactory: factory,
|
||||
});
|
||||
const { address, port } = await server.start();
|
||||
const base = baseUrl(address, port);
|
||||
await server.stop();
|
||||
await expect(fetch(`${base}/health`)).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration: real HTTP boot + health probes (nostr)", () => {
|
||||
it("health/ready/live respond with 200 while running", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
const { factory } = makeFakeBrokerFactory("nostr");
|
||||
const server = new TrysteroServer({
|
||||
mode: "nostr",
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
nostrRelayFactory: factory,
|
||||
nostr: { relayName: "edge-it" },
|
||||
});
|
||||
const { address, port, mode, relayUrl } = await server.start();
|
||||
expect(mode).toBe("nostr");
|
||||
expect(relayUrl.endsWith("/nostr")).toBe(true);
|
||||
const base = baseUrl(address, port);
|
||||
try {
|
||||
const health = await fetchJson(`${base}/health`);
|
||||
expect(health.status).toBe(200);
|
||||
expect((health.body as { mode: string }).mode).toBe("nostr");
|
||||
|
||||
const fall = await fetch(`${base}/nostr`);
|
||||
expect(fall.status).toBe(200);
|
||||
const txt = await fall.text();
|
||||
expect(txt).toContain("nostr");
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("two clients relay a frame between modes (nostr)", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
const { factory, instances } = makeFakeBrokerFactory("nostr");
|
||||
const server = new TrysteroServer({
|
||||
mode: "nostr",
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
nostrRelayFactory: factory,
|
||||
});
|
||||
await server.start();
|
||||
try {
|
||||
const broker = instances[0];
|
||||
if (!broker) throw new Error("expected broker");
|
||||
broker.connect("client-1");
|
||||
broker.connect("client-2");
|
||||
broker.frame("client-1");
|
||||
expect(server.activeConnections).toBe(2);
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import type {
|
||||
BrokerCallbacks,
|
||||
BrokerFactory,
|
||||
BrokerLike,
|
||||
BrokerSocketLike,
|
||||
BrokerStartOptions,
|
||||
TrysteroServerMode,
|
||||
} from "../../src/types/types.js";
|
||||
|
||||
/**
|
||||
* In-memory deterministic implementation of the `BrokerLike` surface
|
||||
* used by the daemon. The real broker shims (which would `upgrade`
|
||||
* the daemon's `http.Server` and pump WebSocket frames between peers)
|
||||
* are intentionally not pulled into the test environment — they
|
||||
* require browser-WS clients and binding to an OS socket. Every test
|
||||
* wires this fake via the injected factory and drives lifecycle
|
||||
* events directly through the captured callbacks.
|
||||
*/
|
||||
export class FakeBroker implements BrokerLike {
|
||||
public readonly mode: TrysteroServerMode;
|
||||
public activeConnections = 0;
|
||||
public startCalls = 0;
|
||||
public stopCalls = 0;
|
||||
public startError: Error | undefined;
|
||||
public stopError: Error | undefined;
|
||||
public lastStartOptions: BrokerStartOptions | undefined;
|
||||
public callbacks: BrokerCallbacks | undefined;
|
||||
private readonly sockets = new Map<string, FakeSocket>();
|
||||
|
||||
public constructor(mode: TrysteroServerMode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public async start(options: BrokerStartOptions): Promise<void> {
|
||||
this.startCalls += 1;
|
||||
this.lastStartOptions = options;
|
||||
this.callbacks = options.callbacks;
|
||||
if (this.startError !== undefined) {
|
||||
throw this.startError;
|
||||
}
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
this.stopCalls += 1;
|
||||
for (const id of [...this.sockets.keys()]) {
|
||||
this.disconnect(id);
|
||||
}
|
||||
if (this.stopError !== undefined) {
|
||||
throw this.stopError;
|
||||
}
|
||||
}
|
||||
|
||||
/** Test affordance: simulate a new client connecting. */
|
||||
public connect(id: string): FakeSocket {
|
||||
const socket = new FakeSocket(id, () => this.disconnect(id));
|
||||
this.sockets.set(id, socket);
|
||||
this.activeConnections += 1;
|
||||
this.callbacks?.onConnect(socket);
|
||||
return socket;
|
||||
}
|
||||
|
||||
/** Test affordance: simulate a client disconnecting cleanly. */
|
||||
public disconnect(id: string): void {
|
||||
const socket = this.sockets.get(id);
|
||||
if (socket === undefined) return;
|
||||
this.sockets.delete(id);
|
||||
this.activeConnections = Math.max(0, this.activeConnections - 1);
|
||||
this.callbacks?.onDisconnect(id);
|
||||
}
|
||||
|
||||
/** Test affordance: simulate a valid frame from a client. */
|
||||
public frame(id: string): void {
|
||||
this.callbacks?.onFrame(id);
|
||||
}
|
||||
|
||||
/** Test affordance: simulate a malformed frame from a client. */
|
||||
public malformed(id: string): void {
|
||||
this.callbacks?.onMalformedFrame(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trivial `BrokerSocketLike` implementation backed by a string array
|
||||
* tests can introspect.
|
||||
*/
|
||||
export class FakeSocket implements BrokerSocketLike {
|
||||
public readonly id: string;
|
||||
public readonly sent: string[] = [];
|
||||
public closed = false;
|
||||
private readonly onClose: () => void;
|
||||
|
||||
public constructor(id: string, onClose: () => void) {
|
||||
this.id = id;
|
||||
this.onClose = onClose;
|
||||
}
|
||||
|
||||
public send(payload: string): void {
|
||||
this.sent.push(payload);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory helper producing `FakeBroker` instances and recording
|
||||
* every call so tests can introspect or drive the captured
|
||||
* reference.
|
||||
*/
|
||||
export function makeFakeBrokerFactory(mode: TrysteroServerMode): {
|
||||
readonly instances: FakeBroker[];
|
||||
readonly factory: BrokerFactory;
|
||||
} {
|
||||
const instances: FakeBroker[] = [];
|
||||
return {
|
||||
instances,
|
||||
factory: (deps) => {
|
||||
const inst = new FakeBroker(deps.mode);
|
||||
instances.push(inst);
|
||||
void mode;
|
||||
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,40 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/network-trystero-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-trystero",
|
||||
"@suaveplan/types",
|
||||
"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