tinyrelayfile
tinyrelay/scripts/conformance/helpers.ts/

scripts/conformance/helpers.ts raw
1// Shared client for the black-box conformance suite. It speaks plain
2// 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 "./negentropy.ts";
8
9export 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));
13
14export type Key = Uint8Array;
15export const newKey = (): Key => generateSecretKey();
16export const pub = (sk: Key) => getPublicKey(sk);
17
18export function newEvent(sk: Key, kind: number, content: string, tags: string[][] = [], created_at = now()): NostrEvent {
19 return finalizeEvent({ kind, content, tags, created_at }, sk);
20}
21
22export 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}
35
36export 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}
42
43export class Client {
44 private ws!: WebSocket;
45 private queue: any[][] = [];
46 private waiters: ((m: any[]) => void)[] = [];
47 challenge = "";
48 closed = false;
49
50 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 }
73
74 send(...msg: any[]) {
75 this.ws.send(JSON.stringify(msg));
76 }
77
78 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 }
93
94 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 }
99
100 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 }
105
106 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 }
110
111 // 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 }
121
122 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 }
128
129 async publish(e: NostrEvent): Promise<string> {
130 this.send("EVENT", e);
131 return this.expectOK(e.id, true);
132 }
133
134 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 }
142
143 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 }
148
149 // 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 }
170
171 close() {
172 this.ws.close();
173 }
174}
175
176export 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}
181
182export function item(e: NostrEvent): SyncItem {
183 return { timestamp: e.created_at, id: hexToBytes(e.id) };
184}
185
186export function rand(): string {
187 return Math.random().toString(36).slice(2, 10);
188}
189
190// 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}
196
197// sockets returns a connect that keeps its clients and closes them after
198// 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs