1// The manually invoked network test client: signed management calls and a2// bounded, read-only WebSocket sample. It keeps retries narrow because a3// mutation whose result is unclear must remain visible to the harness.4import WebSocket from "ws";5import { finalizeEvent, verifyEvent } from "nostr-tools/pure";6import { getToken } from "nostr-tools/nip98";78const REQUEST_TIMEOUT = 20_000;9const SAMPLE_TIMEOUT = 15_000;10const SAMPLE_BYTES = 256 * 1024;11const RETRIES = 30;12const RETRY_DELAY = 500;13const RETRY_MAX_WAIT = 2_000;14const RETRY_WINDOW = 60_000;1516export const clientMetrics = { requests: 0, operationRetries: 0, operationWaitMs: 0 };1718const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));1920export class NetworkError extends Error {21 constructor(message, status, body) {22 super(message);23 this.name = "NetworkError";24 this.status = status;25 this.body = body;26 }27}2829const parseBody = async (response) => {30 const text = await response.text();31 if (!text) return null;32 try {33 return JSON.parse(text);34 } catch {35 return text;36 }37};3839const retryable = (status, body) => {40 if (status !== 429) return false;41 const reason = body && typeof body === "object"42 ? `${body.error ?? ""} ${body.message ?? ""}`43 : String(body ?? "");44 return /operation in progress/i.test(reason);45};4647const errorText = (body) => {48 if (body && typeof body === "object") return String(body.error ?? body.message ?? "request failed").slice(0, 300);49 return String(body ?? "request failed").slice(0, 300);50};5152// request sends one signed JSON POST and returns its parsed response. Only a53// relay's explicit transient operation refusal is retried; timeout, network54// and other HTTP failures remain errors because their mutation outcome is55// unknown.56export async function request(node, sk, body, rpc = false) {57 const url = typeof node === "string" ? node : node.url;58 const payload = body ?? {};59 const retryUntil = Date.now() + RETRY_WINDOW;60 for (let attempt = 0; attempt <= RETRIES; attempt++) {61 const controller = new AbortController();62 const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);63 try {64 clientMetrics.requests++;65 const authorization = await getToken(url, "POST", (event) => finalizeEvent(event, sk), true, payload);66 const response = await fetch(url, {67 method: "POST",68 redirect: "error",69 signal: controller.signal,70 headers: { "content-type": rpc ? "application/nostr+json+rpc" : "application/json", authorization },71 body: JSON.stringify(payload),72 });73 const result = await parseBody(response);74 if (response.ok) return result;75 if (retryable(response.status, result) && attempt < RETRIES && Date.now() < retryUntil) {76 const delay = Math.min(RETRY_MAX_WAIT, RETRY_DELAY * (attempt + 1), retryUntil - Date.now());77 if (delay <= 0) throw new NetworkError(errorText(result), response.status, result);78 clientMetrics.operationRetries++;79 clientMetrics.operationWaitMs += delay;80 await sleep(delay);81 continue;82 }83 throw new NetworkError(errorText(result), response.status, result);84 } catch (error) {85 if (error instanceof NetworkError) throw error;86 throw new NetworkError(error instanceof Error ? error.message : String(error), 0, null);87 } finally {88 clearTimeout(timer);89 }90 }91 throw new NetworkError("request retries exhausted", 429, null);92}9394// rpc calls one NIP-86 method and returns its result. A relay error remains a95// NetworkError so the harness can stop before issuing the next mutation.96export async function rpc(node, sk, method, ...params) {97 const response = await request(node, sk, { method, params }, true);98 if (response && typeof response === "object" && "error" in response) {99 throw new NetworkError(errorText(response), 200, response);100 }101 return response && typeof response === "object" && "result" in response ? response.result : response;102}103104const validSample = (event, filter) => {105 if (!event || typeof event !== "object" || !verifyEvent(event)) return false;106 if (filter.kinds?.length && !filter.kinds.includes(event.kind)) return false;107 if (filter.since !== undefined && event.created_at < filter.since) return false;108 if (filter.until !== undefined && event.created_at > filter.until) return false;109 if (filter.authors?.length && !filter.authors.includes(event.pubkey)) return false;110 return true;111};112113// sample reads at most limit valid signed events from a relay. It never114// answers AUTH or sends EVENT. An authenticated source is allowed to complete115// its read or refuse it. Incoming data and time are bounded for endpoints.116export function sample(url, filter = {}, limit = 3) {117 const requested = { ...filter, kinds: filter.kinds?.length ? [...filter.kinds] : [1], limit: Math.min(Math.max(limit, 1), 3) };118 const wsURL = url.replace(/^https:/, "wss:").replace(/^http:/, "ws:");119 return new Promise((resolve) => {120 const events = [];121 let settled = false;122 let timer;123 let socket;124 const finish = (status, error = "") => {125 if (settled) return;126 settled = true;127 clearTimeout(timer);128 if (socket) {129 socket.removeAllListeners("open");130 socket.removeAllListeners("message");131 try {132 if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CLOSING) socket.close();133 else socket.terminate();134 } catch {135 try { socket.terminate(); } catch { /* already closed */ }136 }137 }138 resolve({ events, status, error });139 };140 try {141 socket = new WebSocket(wsURL, { maxPayload: SAMPLE_BYTES, followRedirects: false });142 socket.on("open", () => socket.send(JSON.stringify(["REQ", "network-sample", requested])));143 socket.on("message", (data) => {144 if (settled) return;145 const raw = Buffer.isBuffer(data) ? data : Buffer.from(String(data));146 if (raw.length > SAMPLE_BYTES) return finish("failed", "sample response exceeded 256 KiB");147 let message;148 try { message = JSON.parse(raw.toString("utf8")); } catch { return; }149 if (!Array.isArray(message)) return;150 if (message[0] === "AUTH") return;151 if (message[0] === "CLOSED") return finish("refused", String(message[2] ?? "source refused query").slice(0, 300));152 if (message[0] === "EOSE" && message[1] === "network-sample") return finish("complete");153 if (message[0] !== "EVENT" || message[1] !== "network-sample") return;154 let valid = false;155 try { valid = validSample(message[2], requested); } catch { return; }156 if (!valid) return;157 if (!events.some((event) => event.id === message[2].id)) events.push(message[2]);158 if (events.length >= requested.limit) finish("limit");159 });160 socket.on("error", (error) => finish("failed", error.message.slice(0, 300)));161 socket.on("close", () => finish(events.length ? "partial" : "failed", events.length ? "source closed before EOSE" : "connection closed"));162 timer = setTimeout(() => finish("timeout", "source did not answer within 15 seconds"), SAMPLE_TIMEOUT);163 } catch (error) {164 finish("failed", error instanceof Error ? error.message.slice(0, 300) : String(error).slice(0, 300));165 }166 });167}