feat(transport): nostr-tools relay pool that re-subscribes on reconnect (#42)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled

First increment of the NDK -> nostr-tools transport swap (#42), the root fix
for #41 (bunker goes silently deaf after a relay flap).

NDK does not replay subscriptions on reconnect: a NDKRelaySubscription registers
`relay.once("ready", execute)` and never re-arms, so after a flap the socket
reconnects but the kind:24133 REQ is never re-sent. We chased that through
#4/#7/#20/#21 without closing it because it is structural in NDK.

`RelayPool` (src/daemon/lib/relay-pool.ts) owns the connect loop, modelled on
lightning.pub's RelayConnection and signet's relay-pool (both nostr-tools, both
bind resubscribe to reconnect). Every (re)connect re-subscribes the whole
registry, so subscription liveness can't drift from socket liveness. It also
exposes `healthy()` (connected AND registry subscribed on the wire) — the
session-liveness signal the old connectedRelays()-only watchdog couldn't make,
which is what let #20's reconnect mask the deaf state.

We disable nostr-tools' own `enableReconnect`: its auto-resubscribe is
version-fragile right now (regressed in 2.23.0 fb7de7f; the 455124e fix is
unreleased as of 2026-06-26), so the resubscribe is OUR code, not a function of
which nostr-tools version is installed.

Regression test (tests/relay-pool.test.ts + tests/helpers/mock-relay.ts): an
in-process mock relay flaps mid-session (down + back up on the same port) and we
assert a subsequent inbound kind:24133 is still delivered — the exact #41
scenario, and the test that was missing every prior round. Green; existing
lifecycle suite unchanged.

Next increments on this branch: port Backend (NIP-46) + AdminInterface (RPC)
onto the pool, wire run.ts, retire relay-reconnect.ts + the connection-only
watchdog.

Refs: #42, #41, #21, #20, #9
This commit is contained in:
Padreug 2026-06-26 23:42:36 +02:00
commit 1409941d11
4 changed files with 636 additions and 1 deletions

137
tests/helpers/mock-relay.ts Normal file
View file

@ -0,0 +1,137 @@
import type { AddressInfo } from "net";
// `ws` has no @types in this tree; require it (any) — same as src/relay-pool.ts.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { WebSocketServer, WebSocket } = require("ws");
/**
* Minimal in-process nostr relay for transport tests (#42). Speaks just enough
* of the protocol REQ / CLOSE / EVENT EVENT / EOSE / OK to exercise the
* RelayPool's subscribe + resubscribe-on-reconnect behaviour. It can be
* `flap()`ped: drop every client + close the server, then re-listen on the SAME
* port, simulating the relay restart that took the bunker deaf (#41).
*/
type Filter = Record<string, any>;
interface Sub {
subId: string;
filters: Filter[];
socket: any;
}
function matches(filter: Filter, event: any): boolean {
if (filter.ids && !filter.ids.includes(event.id)) return false;
if (filter.kinds && !filter.kinds.includes(event.kind)) return false;
if (filter.authors && !filter.authors.includes(event.pubkey)) return false;
for (const key of Object.keys(filter)) {
if (key.startsWith("#")) {
const tag = key.slice(1);
const want: string[] = filter[key];
const have = (event.tags ?? [])
.filter((t: string[]) => t[0] === tag)
.map((t: string[]) => t[1]);
if (!want.some((v) => have.includes(v))) return false;
}
}
return true;
}
export class MockRelay {
private wss: any = null;
private subs: Sub[] = [];
private sockets: Set<any> = new Set();
public port = 0;
/** Total REQs seen across the relay's life — proves a (re)subscribe landed. */
public reqCount = 0;
get url(): string {
return `ws://127.0.0.1:${this.port}`;
}
async start(port = 0): Promise<void> {
await new Promise<void>((resolve) => {
this.wss = new WebSocketServer({ port }, () => {
this.port = (this.wss!.address() as AddressInfo).port;
resolve();
});
this.wss.on("connection", (socket: any) => {
this.sockets.add(socket);
socket.on("message", (data: any) => this.onMessage(socket, data.toString()));
socket.on("close", () => {
this.sockets.delete(socket);
this.subs = this.subs.filter((s) => s.socket !== socket);
});
socket.on("error", () => {
/* ignore — flapping closes sockets abruptly */
});
});
});
}
private onMessage(socket: any, raw: string): void {
let msg: any[];
try {
msg = JSON.parse(raw);
} catch {
return;
}
const [type, ...rest] = msg;
if (type === "REQ") {
const [subId, ...filters] = rest;
this.reqCount++;
this.subs.push({ subId, filters, socket });
// No stored events in this mock; just acknowledge the REQ is live.
socket.send(JSON.stringify(["EOSE", subId]));
} else if (type === "CLOSE") {
const [subId] = rest;
this.subs = this.subs.filter((s) => !(s.socket === socket && s.subId === subId));
} else if (type === "EVENT") {
const [event] = rest;
socket.send(JSON.stringify(["OK", event.id, true, ""]));
this.deliver(event);
}
}
/** Push a server-originated event to every matching live subscription. */
inject(event: any): void {
this.deliver(event);
}
private deliver(event: any): void {
for (const sub of this.subs) {
if (sub.socket.readyState !== WebSocket.OPEN) continue;
if (sub.filters.some((f) => matches(f, event))) {
sub.socket.send(JSON.stringify(["EVENT", sub.subId, event]));
}
}
}
/** Drop all clients + close the server, keeping the port for a restart. */
private async down(): Promise<void> {
for (const s of this.sockets) {
try {
s.terminate();
} catch {
/* ignore */
}
}
this.sockets.clear();
this.subs = [];
await new Promise<void>((resolve) => {
if (!this.wss) return resolve();
this.wss.close(() => resolve());
this.wss = null;
});
}
/** Simulate a relay restart: go down, then come back on the SAME port. */
async flap(): Promise<void> {
const port = this.port;
await this.down();
await this.start(port);
}
async stop(): Promise<void> {
await this.down();
}
}

104
tests/relay-pool.test.ts Normal file
View file

@ -0,0 +1,104 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools";
import { MockRelay } from "./helpers/mock-relay";
import { RelayPool } from "../src/daemon/lib/relay-pool";
// A throwaway client key. Events must be REAL (valid id + sig): nostr-tools
// verifies inbound events and silently drops invalid ones, so the test has to
// sign them exactly as a real bunker client would.
const CLIENT_SK = generateSecretKey();
// The spire pubkey the bunker subscribes for (`#p`), a fixture here.
const PUBKEY = "1508b42094e65dff982ac8ca5a264089f7de2d4bbda81bf32a91678f337ced3b";
function makeEvent(): { id: string; [k: string]: any } {
return finalizeEvent(
{
kind: 24133,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", PUBKEY]],
content: "encrypted-blob",
},
CLIENT_SK,
) as any;
}
void getPublicKey; // (kept available for future signed-response assertions)
async function waitFor(pred: () => boolean, timeoutMs = 5000, stepMs = 25): Promise<void> {
const start = Date.now();
while (!pred()) {
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
await new Promise((r) => setTimeout(r, stepMs));
}
}
/**
* The regression that was missing every prior round (#4/#7/#20/#21): flap the
* relay mid-session and assert a subsequent inbound kind:24133 is still
* delivered. With the old NDK transport the socket reconnected but the REQ was
* never re-sent, so the bunker went silently deaf (#41). The RelayPool owns the
* connect loop and re-subscribes on every (re)connect, so this must pass.
*/
test("RelayPool re-subscribes after a relay flap — inbound events still delivered (#41/#42)", async () => {
const relay = new MockRelay();
await relay.start();
const received: string[] = [];
const pool = new RelayPool([relay.url], { log: () => {} });
pool.start();
// Subscribe for the bunker's kind:24133 channel; await the REQ landing.
await pool.subscribeAwaitingEose([{ kinds: [24133], "#p": [PUBKEY] }], (e) =>
received.push(e.id),
);
// Baseline: a matching event before any flap is delivered.
const before = makeEvent();
relay.inject(before);
await waitFor(() => received.includes(before.id));
// FLAP: relay down + back up on the same port (the relay-restart that took
// the demo bunker deaf). The pool must reconnect AND re-subscribe.
const reqsBefore = relay.reqCount;
await relay.flap();
// Wait until reconnected AND the subscription is re-established on the wire.
await waitFor(() => pool.healthy() && relay.reqCount > reqsBefore);
// THE assertion: a matching event AFTER the flap is still delivered.
const after = makeEvent();
relay.inject(after);
await waitFor(() => received.includes(after.id));
assert.ok(received.includes(before.id), "event before flap delivered");
assert.ok(received.includes(after.id), "event after flap delivered (resubscribed)");
assert.ok(pool.healthy(), "pool healthy (connected + subscribed) after flap");
pool.stop();
await relay.stop();
});
/**
* healthy() must distinguish a connected-but-deaf relay (the #41 state the old
* connectedRelays()-only watchdog could not see) from a genuinely serving one.
*/
test("RelayPool.healthy() is false until the registry is subscribed on the wire (#42)", async () => {
const relay = new MockRelay();
await relay.start();
const pool = new RelayPool([relay.url], { log: () => {} });
pool.start();
// No subscriptions registered yet — but once connected with an empty
// registry, healthy() is trivially true (nothing to be deaf about).
await waitFor(() => pool.connectedCount() === 1);
assert.equal(pool.healthy(), true, "connected, empty registry -> healthy");
// Register a sub; healthy stays true once it lands.
await pool.subscribeAwaitingEose([{ kinds: [24133], "#p": [PUBKEY] }], () => {});
assert.equal(pool.healthy(), true, "connected + subscribed -> healthy");
pool.stop();
await relay.stop();
});