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
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:
parent
4a0a3e7cd4
commit
1409941d11
4 changed files with 636 additions and 1 deletions
137
tests/helpers/mock-relay.ts
Normal file
137
tests/helpers/mock-relay.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue