clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

main
bindws / test / conformance / helpers.ts
helpers.ts 6.5K
1// Shared client for the black-box conformance suite. It speaks plain2// NIP-01 over a websocket to whatever relay RELAY_URL names.3import WebSocket from "ws";4import { afterEach } from "vitest";5import { finalizeEvent, generateSecretKey, getPublicKey, type Event as NostrEvent, type EventTemplate } from "nostr-tools/pure";6import { sha256 } from "@noble/hashes/sha2.js";7import { Negentropy, hexToBytes, type SyncItem } from "../../src/negentropy.ts";89export const RELAY_URL = process.env.RELAY_URL ?? "ws://127.0.0.1:7447";10export const HTTP_URL = RELAY_URL.replace(/^ws/, "http");11export const now = () => Math.floor(Date.now() / 1000);12export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));1314export type Key = Uint8Array;15export const newKey = (): Key => generateSecretKey();16export const pub = (sk: Key) => getPublicKey(sk);1718export function newEvent(sk: Key, kind: number, content: string, tags: string[][] = [], created_at = now()): NostrEvent {19 return finalizeEvent({ kind, content, tags, created_at }, sk);20}2122export function difficulty(id: string): number {23 let bits = 0;24 for (const ch of id) {25 const n = parseInt(ch, 16);26 if (n === 0) {27 bits += 4;28 continue;29 }30 bits += Math.clz32(n) - 28;31 break;32 }33 return bits;34}3536export function mine(sk: Key, t: EventTemplate, bits: number): NostrEvent {37 for (let n = 0; ; n++) {38 const e = finalizeEvent({ ...t, tags: [["nonce", String(n), String(bits)]] }, sk);39 if (difficulty(e.id) >= bits) return e;40 }41}4243export class Client {44 private ws!: WebSocket;45 private queue: any[][] = [];46 private waiters: ((m: any[]) => void)[] = [];47 challenge = "";48 closed = false;4950 static async connect(url = RELAY_URL): Promise<Client> {51 const c = new Client();52 c.ws = new WebSocket(url);53 // Listen before open: the relay's AUTH challenge can arrive in the same tick.54 c.ws.on("message", (data) => {55 const msg = JSON.parse(data.toString());56 const w = c.waiters.shift();57 if (w) w(msg);58 else c.queue.push(msg);59 });60 c.ws.on("close", () => {61 c.closed = true;62 });63 await new Promise<void>((resolve, reject) => {64 c.ws.once("open", () => resolve());65 c.ws.once("error", reject);66 });67 // NIP-42: every connection opens with a challenge.68 const first = await c.recv();69 if (first[0] !== "AUTH") throw new Error(`expected AUTH challenge, got ${JSON.stringify(first)}`);70 c.challenge = first[1];71 return c;72 }7374 send(...msg: any[]) {75 this.ws.send(JSON.stringify(msg));76 }7778 recv(timeoutMs = 15_000): Promise<any[]> {79 const m = this.queue.shift();80 if (m) return Promise.resolve(m);81 return new Promise((resolve, reject) => {82 const waiter = (msg: any[]) => {83 clearTimeout(t);84 resolve(msg);85 };86 const t = setTimeout(() => {87 this.waiters = this.waiters.filter((w) => w !== waiter);88 reject(new Error("timed out waiting for a message"));89 }, timeoutMs);90 this.waiters.push(waiter);91 });92 }9394 async expect(type: string): Promise<any[]> {95 const m = await this.recv();96 if (m[0] !== type) throw new Error(`expected ${type}, got ${JSON.stringify(m)}`);97 return m;98 }99100 async expectOK(id: string, ok: boolean): Promise<string> {101 const m = await this.expect("OK");102 if (m[1] !== id || m[2] !== ok) throw new Error(`OK mismatch: want (${id},${ok}) got ${JSON.stringify(m)}`);103 return m[3];104 }105106 async expectClosed(prefix: string): Promise<void> {107 const m = await this.expect("CLOSED");108 if (!String(m[2]).startsWith(prefix)) throw new Error(`expected CLOSED ${prefix}..., got ${JSON.stringify(m)}`);109 }110111 // drain reads until EOSE, returning the events seen and the EOSE hints.112 async drain(): Promise<{ events: NostrEvent[]; hints: string[] }> {113 const events: NostrEvent[] = [];114 for (;;) {115 const m = await this.recv();116 if (m[0] === "EVENT") events.push(m[2]);117 else if (m[0] === "EOSE") return { events, hints: Array.isArray(m[2]) ? m[2] : [] };118 else throw new Error(`unexpected ${JSON.stringify(m)}`);119 }120 }121122 async req(filter: Record<string, unknown>, id = "q" + Math.random().toString(36).slice(2, 8)) {123 this.send("REQ", id, filter);124 const r = await this.drain();125 this.send("CLOSE", id);126 return r;127 }128129 async publish(e: NostrEvent): Promise<string> {130 this.send("EVENT", e);131 return this.expectOK(e.id, true);132 }133134 async expectNothing(ms = 400) {135 try {136 const m = await this.recv(ms);137 throw new Error(`expected silence, got ${JSON.stringify(m)}`);138 } catch (err) {139 if (!(err instanceof Error) || !err.message.includes("timed out")) throw err;140 }141 }142143 async auth(sk: Key, relay = RELAY_URL) {144 const e = newEvent(sk, 22242, "", [["relay", relay], ["challenge", this.challenge]]);145 this.send("AUTH", e);146 await this.expectOK(e.id, true);147 }148149 // sync runs a full NIP-77 exchange for the filter and returns have/need.150 async sync(items: SyncItem[], filter: Record<string, unknown>, id = "s1"): Promise<{ have: string[]; need: string[] }> {151 const neg = new Negentropy(items, sha256);152 let msg: Uint8Array | null = neg.initiate();153 this.send("NEG-OPEN", id, filter, hex(msg));154 const have: string[] = [];155 const need: string[] = [];156 for (;;) {157 const m = await this.recv();158 if (m[0] === "NEG-ERR") throw new Error(`NEG-ERR ${m[2]}`);159 if (m[0] !== "NEG-MSG") throw new Error(`unexpected ${JSON.stringify(m)}`);160 const r = neg.reconcile(hexToBytes(m[2]));161 have.push(...r.have);162 need.push(...r.need);163 if (!r.reply) {164 this.send("NEG-CLOSE", id);165 return { have: have.sort(), need: need.sort() };166 }167 this.send("NEG-MSG", id, hex(r.reply));168 }169 }170171 close() {172 this.ws.close();173 }174}175176export function hex(b: Uint8Array): string {177 let s = "";178 for (const x of b) s += x.toString(16).padStart(2, "0");179 return s;180}181182export function item(e: NostrEvent): SyncItem {183 return { timestamp: e.created_at, id: hexToBytes(e.id) };184}185186export function rand(): string {187 return Math.random().toString(36).slice(2, 10);188}189190// randHex returns n random lowercase hex characters.191export function randHex(n = 64): string {192 let s = "";193 while (s.length < n) s += Math.floor(Math.random() * 16).toString(16);194 return s;195}196197// sockets returns a connect that keeps its clients and closes them after198// each test.199export function sockets() {200 const open: Client[] = [];201 afterEach(() => open.splice(0).forEach((c) => c.close()));202 return async () => {203 const c = await Client.connect();204 open.push(c);205 return c;206 };207}