@qntx/nostr 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +25 -0
- package/dist/builder-BphJa-Y-.d.mts +67 -0
- package/dist/builder-q0VsvnYE.mjs +158 -0
- package/dist/builder-q0VsvnYE.mjs.map +1 -0
- package/dist/client-Bd8B1mYm.mjs +835 -0
- package/dist/client-Bd8B1mYm.mjs.map +1 -0
- package/dist/client.d.mts +2 -0
- package/dist/client.mjs +2 -0
- package/dist/core.d.mts +8 -0
- package/dist/core.mjs +9 -0
- package/dist/error-B49rbDzT.d.mts +7 -0
- package/dist/error-CwBxle6N.mjs +13 -0
- package/dist/error-CwBxle6N.mjs.map +1 -0
- package/dist/error-DZEs5OuZ.mjs +24 -0
- package/dist/error-DZEs5OuZ.mjs.map +1 -0
- package/dist/error-WEFS-R-4.d.mts +20 -0
- package/dist/event-B5D2jxt_.d.mts +108 -0
- package/dist/event-CzoQEFMF.mjs +188 -0
- package/dist/event-CzoQEFMF.mjs.map +1 -0
- package/dist/filter-BxwjEkZp.mjs +113 -0
- package/dist/filter-BxwjEkZp.mjs.map +1 -0
- package/dist/filter-DUEKP3q1.d.mts +40 -0
- package/dist/gossip-3xkRRCm_.mjs +220 -0
- package/dist/gossip-3xkRRCm_.mjs.map +1 -0
- package/dist/gossip-C5RVB1UB.d.mts +71 -0
- package/dist/gossip.d.mts +3 -0
- package/dist/gossip.mjs +2 -0
- package/dist/http-6vevbBlu.d.mts +22 -0
- package/dist/http-D2rhokZo.mjs +24 -0
- package/dist/http-D2rhokZo.mjs.map +1 -0
- package/dist/index-BYMnhRIC.d.mts +161 -0
- package/dist/index-C0C4e-Gn.d.mts +55 -0
- package/dist/index-CaxrMD1A.d.mts +1 -0
- package/dist/index-DaRyjo7O.d.mts +77 -0
- package/dist/index-DgOugrpF.d.mts +275 -0
- package/dist/index-EwL6fHLo.d.mts +267 -0
- package/dist/index-k2YhCgsh.d.mts +57 -0
- package/dist/index.d.mts +22 -0
- package/dist/index.mjs +23 -0
- package/dist/key-BiGxGLhg.d.mts +37 -0
- package/dist/key-Dn-aX4x4.mjs +130 -0
- package/dist/key-Dn-aX4x4.mjs.map +1 -0
- package/dist/kind-BLl4cLOc.mjs +59 -0
- package/dist/kind-BLl4cLOc.mjs.map +1 -0
- package/dist/loaders-BNQszDAn.mjs +767 -0
- package/dist/loaders-BNQszDAn.mjs.map +1 -0
- package/dist/loaders.d.mts +3 -0
- package/dist/loaders.mjs +2 -0
- package/dist/memory-BCmhZPek.mjs +461 -0
- package/dist/memory-BCmhZPek.mjs.map +1 -0
- package/dist/message-C_Uj-Y-c.mjs +179 -0
- package/dist/message-C_Uj-Y-c.mjs.map +1 -0
- package/dist/message-DJC-Jz6T.d.mts +32 -0
- package/dist/nip17-BWq-Kfq5.d.mts +38 -0
- package/dist/nip19-D7905oIv.d.mts +63 -0
- package/dist/nip46-C_DoxiS_.d.mts +51 -0
- package/dist/nip59-DljW_sYh.d.mts +76 -0
- package/dist/nip65-Di5hZRuV.d.mts +21 -0
- package/dist/nip77-ByxlgoQO.d.mts +55 -0
- package/dist/nips/blossom.d.mts +93 -0
- package/dist/nips/blossom.mjs +325 -0
- package/dist/nips/blossom.mjs.map +1 -0
- package/dist/nips/nip04.d.mts +8 -0
- package/dist/nips/nip04.mjs +41 -0
- package/dist/nips/nip04.mjs.map +1 -0
- package/dist/nips/nip05.d.mts +89 -0
- package/dist/nips/nip05.mjs +147 -0
- package/dist/nips/nip05.mjs.map +1 -0
- package/dist/nips/nip10.d.mts +43 -0
- package/dist/nips/nip10.mjs +194 -0
- package/dist/nips/nip10.mjs.map +1 -0
- package/dist/nips/nip11.d.mts +43 -0
- package/dist/nips/nip11.mjs +99 -0
- package/dist/nips/nip11.mjs.map +1 -0
- package/dist/nips/nip13.d.mts +23 -0
- package/dist/nips/nip13.mjs +88 -0
- package/dist/nips/nip13.mjs.map +1 -0
- package/dist/nips/nip17.d.mts +2 -0
- package/dist/nips/nip17.mjs +138 -0
- package/dist/nips/nip17.mjs.map +1 -0
- package/dist/nips/nip19.d.mts +2 -0
- package/dist/nips/nip19.mjs +174 -0
- package/dist/nips/nip19.mjs.map +1 -0
- package/dist/nips/nip21.d.mts +17 -0
- package/dist/nips/nip21.mjs +29 -0
- package/dist/nips/nip21.mjs.map +1 -0
- package/dist/nips/nip27.d.mts +42 -0
- package/dist/nips/nip27.mjs +175 -0
- package/dist/nips/nip27.mjs.map +1 -0
- package/dist/nips/nip42.d.mts +12 -0
- package/dist/nips/nip42.mjs +22 -0
- package/dist/nips/nip42.mjs.map +1 -0
- package/dist/nips/nip44.d.mts +12 -0
- package/dist/nips/nip44.mjs +125 -0
- package/dist/nips/nip44.mjs.map +1 -0
- package/dist/nips/nip46.d.mts +2 -0
- package/dist/nips/nip46.mjs +132 -0
- package/dist/nips/nip46.mjs.map +1 -0
- package/dist/nips/nip49.d.mts +12 -0
- package/dist/nips/nip49.mjs +80 -0
- package/dist/nips/nip49.mjs.map +1 -0
- package/dist/nips/nip51.d.mts +88 -0
- package/dist/nips/nip51.mjs +246 -0
- package/dist/nips/nip51.mjs.map +1 -0
- package/dist/nips/nip57.d.mts +43 -0
- package/dist/nips/nip57.mjs +230 -0
- package/dist/nips/nip57.mjs.map +1 -0
- package/dist/nips/nip59.d.mts +2 -0
- package/dist/nips/nip59.mjs +171 -0
- package/dist/nips/nip59.mjs.map +1 -0
- package/dist/nips/nip65.d.mts +2 -0
- package/dist/nips/nip65.mjs +76 -0
- package/dist/nips/nip65.mjs.map +1 -0
- package/dist/nips/nip77.d.mts +2 -0
- package/dist/nips/nip77.mjs +421 -0
- package/dist/nips/nip77.mjs.map +1 -0
- package/dist/nips/nip96.d.mts +31 -0
- package/dist/nips/nip96.mjs +95 -0
- package/dist/nips/nip96.mjs.map +1 -0
- package/dist/nips/nip98.d.mts +28 -0
- package/dist/nips/nip98.mjs +91 -0
- package/dist/nips/nip98.mjs.map +1 -0
- package/dist/nostr_crypto_wasm_bg-hi5aXi5P.mjs +4 -0
- package/dist/nostr_crypto_wasm_bg.wasm +0 -0
- package/dist/pool-C_j1OmBB.mjs +1881 -0
- package/dist/pool-C_j1OmBB.mjs.map +1 -0
- package/dist/pool-DhRi7l-b.d.mts +297 -0
- package/dist/relay.d.mts +4 -0
- package/dist/relay.mjs +2 -0
- package/dist/signer-CZ6K6FUc.mjs +473 -0
- package/dist/signer-CZ6K6FUc.mjs.map +1 -0
- package/dist/signer.d.mts +3 -0
- package/dist/signer.mjs +2 -0
- package/dist/storage-DOy0iLRE.mjs +870 -0
- package/dist/storage-DOy0iLRE.mjs.map +1 -0
- package/dist/storage.d.mts +4 -0
- package/dist/storage.mjs +4 -0
- package/dist/types-BzyADqnw.d.mts +19 -0
- package/dist/types-Dn24TPFs.d.mts +43 -0
- package/dist/util-6tfLQ9JF.mjs +73 -0
- package/dist/util-6tfLQ9JF.mjs.map +1 -0
- package/dist/wasm.d.mts +19 -0
- package/dist/wasm.mjs +237 -0
- package/dist/wasm.mjs.map +1 -0
- package/package.json +204 -0
|
@@ -0,0 +1,1881 @@
|
|
|
1
|
+
import { s as normalizeURL } from "./util-6tfLQ9JF.mjs";
|
|
2
|
+
import { a as NostrError, i as MessageError, s as WasmVerifyPoisonedError } from "./error-DZEs5OuZ.mjs";
|
|
3
|
+
import { s as verifyEvent } from "./key-Dn-aX4x4.mjs";
|
|
4
|
+
import { i as filterFingerprint, n as canonicalizeFilters, t as canonicalizeFilter } from "./filter-BxwjEkZp.mjs";
|
|
5
|
+
import { n as createSubscriptionId, r as encodeClientMessage, s as parseRelayMessage, t as assertSubscriptionId } from "./message-C_Uj-Y-c.mjs";
|
|
6
|
+
import { isAuthRequired, makeAuthEvent } from "./nips/nip42.mjs";
|
|
7
|
+
import { Nip77Error, runNegSession } from "./nips/nip77.mjs";
|
|
8
|
+
//#region src/relay/error.ts
|
|
9
|
+
var RelayError = class extends NostrError {
|
|
10
|
+
url;
|
|
11
|
+
constructor(message, url) {
|
|
12
|
+
super(url ? `${message} (${url})` : message);
|
|
13
|
+
this.url = url;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
var RelayConnectionError = class extends RelayError {};
|
|
17
|
+
var RelayPublishError = class extends RelayError {};
|
|
18
|
+
var RelayClosedError = class extends RelayError {};
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/relay/websocket.ts
|
|
21
|
+
let impl;
|
|
22
|
+
try {
|
|
23
|
+
if (typeof globalThis.WebSocket !== "undefined") impl = globalThis.WebSocket;
|
|
24
|
+
} catch {}
|
|
25
|
+
/** Inject a WebSocket implementation (required on Node unless undici/global is present). */
|
|
26
|
+
function useWebSocketImplementation(websocketImplementation) {
|
|
27
|
+
impl = websocketImplementation;
|
|
28
|
+
}
|
|
29
|
+
function getWebSocketImplementation() {
|
|
30
|
+
if (!impl) throw new RelayConnectionError("No WebSocket implementation available; call useWebSocketImplementation() or run in a browser");
|
|
31
|
+
return impl;
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/relay/url.ts
|
|
35
|
+
/**
|
|
36
|
+
* True for `ws:` / `http:` URLs that are not `.onion`.
|
|
37
|
+
* Local-network `ws://` is still insecure; allow those via a trusted set.
|
|
38
|
+
*/
|
|
39
|
+
function isInsecureRelayUrl(url) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = new URL(url);
|
|
42
|
+
if (parsed.hostname.endsWith(".onion")) return false;
|
|
43
|
+
return parsed.protocol === "ws:" || parsed.protocol === "http:";
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region src/relay/subscription.ts
|
|
50
|
+
var Subscription = class {
|
|
51
|
+
sendClose;
|
|
52
|
+
id;
|
|
53
|
+
filters;
|
|
54
|
+
handlers;
|
|
55
|
+
closeOnEose;
|
|
56
|
+
eosed = false;
|
|
57
|
+
closed = false;
|
|
58
|
+
/** True after one CLOSED `auth-required:` retry. */
|
|
59
|
+
authRetried = false;
|
|
60
|
+
/** Inclusive NIP-01 `since` watermark from verified EVENTs. */
|
|
61
|
+
lastCreatedAt;
|
|
62
|
+
/** Event ids at `lastCreatedAt` (same-second reconnect dedup). Not all seen ids. */
|
|
63
|
+
idsAtWatermark = /* @__PURE__ */ new Set();
|
|
64
|
+
#abort;
|
|
65
|
+
constructor(filters, opts, sendClose) {
|
|
66
|
+
this.sendClose = sendClose;
|
|
67
|
+
this.id = createSubscriptionId(opts.id);
|
|
68
|
+
this.filters = filters;
|
|
69
|
+
this.closeOnEose = opts.closeOnEose === true;
|
|
70
|
+
this.handlers = {
|
|
71
|
+
onevent: opts.onevent,
|
|
72
|
+
oneose: opts.oneose,
|
|
73
|
+
onclose: opts.onclose,
|
|
74
|
+
alreadyHaveEvent: opts.alreadyHaveEvent,
|
|
75
|
+
receivedEvent: opts.receivedEvent
|
|
76
|
+
};
|
|
77
|
+
if (opts.signal) {
|
|
78
|
+
if (opts.signal.aborted) this.close("aborted");
|
|
79
|
+
else {
|
|
80
|
+
const onAbort = () => this.close("aborted");
|
|
81
|
+
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
82
|
+
this.#abort = () => opts.signal?.removeEventListener("abort", onAbort);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
close(reason = "closed by client") {
|
|
87
|
+
if (this.closed) return;
|
|
88
|
+
this.closed = true;
|
|
89
|
+
this.#abort?.();
|
|
90
|
+
this.sendClose(this.id);
|
|
91
|
+
this.handlers.onclose?.(reason);
|
|
92
|
+
}
|
|
93
|
+
/** Advance the reconnect watermark after a verified EVENT. */
|
|
94
|
+
noteVerified(event) {
|
|
95
|
+
if (this.lastCreatedAt === void 0 || event.created_at > this.lastCreatedAt) {
|
|
96
|
+
this.lastCreatedAt = event.created_at;
|
|
97
|
+
this.idsAtWatermark.clear();
|
|
98
|
+
this.idsAtWatermark.add(event.id);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (event.created_at === this.lastCreatedAt) this.idsAtWatermark.add(event.id);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Filters for re-REQ. Original `filters` stay unchanged.
|
|
105
|
+
* NIP-01 `since` is inclusive — never `lastCreatedAt + 1`.
|
|
106
|
+
*/
|
|
107
|
+
replayFilters() {
|
|
108
|
+
const since = this.lastCreatedAt;
|
|
109
|
+
if (since === void 0) return this.filters;
|
|
110
|
+
return this.filters.map((f) => ({
|
|
111
|
+
...f,
|
|
112
|
+
since: f.since === void 0 ? since : Math.max(f.since, since)
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
/** AsyncIterable wrapper over a subscription's events until EOSE or close. */
|
|
117
|
+
function subscriptionToAsyncIterable(start, opts) {
|
|
118
|
+
const queue = [];
|
|
119
|
+
let done = false;
|
|
120
|
+
let error;
|
|
121
|
+
let wake;
|
|
122
|
+
let closer;
|
|
123
|
+
const notify = () => {
|
|
124
|
+
wake?.();
|
|
125
|
+
wake = void 0;
|
|
126
|
+
};
|
|
127
|
+
closer = start({
|
|
128
|
+
onevent(event) {
|
|
129
|
+
queue.push(event);
|
|
130
|
+
notify();
|
|
131
|
+
},
|
|
132
|
+
oneose() {
|
|
133
|
+
if (opts?.includeEose === false) {
|
|
134
|
+
done = true;
|
|
135
|
+
closer?.close("eose");
|
|
136
|
+
notify();
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
onclose(reason) {
|
|
140
|
+
if (reason && reason !== "eose" && reason !== "closed by client" && reason !== "aborted") error = new RelayClosedError(reason);
|
|
141
|
+
done = true;
|
|
142
|
+
notify();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
if (opts?.signal) {
|
|
146
|
+
if (opts.signal.aborted) {
|
|
147
|
+
closer.close("aborted");
|
|
148
|
+
done = true;
|
|
149
|
+
} else opts.signal.addEventListener("abort", () => {
|
|
150
|
+
closer?.close("aborted");
|
|
151
|
+
done = true;
|
|
152
|
+
notify();
|
|
153
|
+
}, { once: true });
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
close(reason) {
|
|
157
|
+
closer?.close(reason);
|
|
158
|
+
done = true;
|
|
159
|
+
notify();
|
|
160
|
+
},
|
|
161
|
+
[Symbol.asyncIterator]() {
|
|
162
|
+
return {
|
|
163
|
+
async next() {
|
|
164
|
+
while (true) {
|
|
165
|
+
if (queue.length > 0) return {
|
|
166
|
+
value: queue.shift(),
|
|
167
|
+
done: false
|
|
168
|
+
};
|
|
169
|
+
if (error) throw error;
|
|
170
|
+
if (done) return {
|
|
171
|
+
value: void 0,
|
|
172
|
+
done: true
|
|
173
|
+
};
|
|
174
|
+
await new Promise((resolve) => {
|
|
175
|
+
wake = resolve;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
async return() {
|
|
180
|
+
closer?.close("iterator returned");
|
|
181
|
+
done = true;
|
|
182
|
+
return {
|
|
183
|
+
value: void 0,
|
|
184
|
+
done: true
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region src/relay/subscribe.ts
|
|
193
|
+
function captureListenerError(errors, fn) {
|
|
194
|
+
try {
|
|
195
|
+
fn();
|
|
196
|
+
} catch (err) {
|
|
197
|
+
errors.push(err);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function flushListenerErrors(errors) {
|
|
201
|
+
if (errors.length === 0) return;
|
|
202
|
+
if (errors.length === 1) throw errors[0];
|
|
203
|
+
throw new AggregateError(errors);
|
|
204
|
+
}
|
|
205
|
+
function openExclusive(ctx, filters, opts) {
|
|
206
|
+
const sub = new Subscription(filters, opts, (id) => {
|
|
207
|
+
ctx.subs.delete(id);
|
|
208
|
+
try {
|
|
209
|
+
if (ctx.connected()) ctx.send(["CLOSE", id]);
|
|
210
|
+
} catch {}
|
|
211
|
+
});
|
|
212
|
+
ctx.subs.set(sub.id, sub);
|
|
213
|
+
if (ctx.connected()) ctx.send([
|
|
214
|
+
"REQ",
|
|
215
|
+
sub.id,
|
|
216
|
+
...filters
|
|
217
|
+
]);
|
|
218
|
+
else if (ctx.enableReconnect()) ctx.scheduleReconnect();
|
|
219
|
+
if (opts.eoseTimeoutMs !== void 0) ctx.armEoseTimeout(sub, opts.eoseTimeoutMs);
|
|
220
|
+
return sub;
|
|
221
|
+
}
|
|
222
|
+
function subscribeLive(ctx, filters, opts) {
|
|
223
|
+
const fp = filterFingerprint(filters);
|
|
224
|
+
let group = ctx.liveByFp.get(fp);
|
|
225
|
+
let created = false;
|
|
226
|
+
if (!group) {
|
|
227
|
+
created = true;
|
|
228
|
+
const wire = new Subscription(filters, { id: opts.id }, () => {
|
|
229
|
+
endLiveGroup(ctx, fp, {
|
|
230
|
+
sendClose: true,
|
|
231
|
+
reason: "closed by client"
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
group = {
|
|
235
|
+
fp,
|
|
236
|
+
sub: wire,
|
|
237
|
+
attachments: /* @__PURE__ */ new Set()
|
|
238
|
+
};
|
|
239
|
+
ctx.liveByFp.set(fp, group);
|
|
240
|
+
ctx.liveBySubId.set(wire.id, group);
|
|
241
|
+
ctx.subs.set(wire.id, wire);
|
|
242
|
+
}
|
|
243
|
+
const slot = { handle: void 0 };
|
|
244
|
+
const handle = new Subscription(filters, {
|
|
245
|
+
...opts,
|
|
246
|
+
id: group.sub.id
|
|
247
|
+
}, () => {
|
|
248
|
+
const current = slot.handle;
|
|
249
|
+
if (!current) return;
|
|
250
|
+
detachLive(ctx, fp, current);
|
|
251
|
+
});
|
|
252
|
+
slot.handle = handle;
|
|
253
|
+
if (handle.closed) {
|
|
254
|
+
if (created && group.attachments.size === 0) forgetLiveGroup(ctx, group);
|
|
255
|
+
return handle;
|
|
256
|
+
}
|
|
257
|
+
group.attachments.add(handle);
|
|
258
|
+
if (created) {
|
|
259
|
+
if (ctx.connected()) ctx.send([
|
|
260
|
+
"REQ",
|
|
261
|
+
group.sub.id,
|
|
262
|
+
...filters
|
|
263
|
+
]);
|
|
264
|
+
else if (ctx.enableReconnect()) ctx.scheduleReconnect();
|
|
265
|
+
}
|
|
266
|
+
if (opts.eoseTimeoutMs !== void 0) ctx.armEoseTimeout(handle, opts.eoseTimeoutMs);
|
|
267
|
+
if (group.sub.eosed) queueMicrotask(() => {
|
|
268
|
+
if (handle.closed || handle.eosed) return;
|
|
269
|
+
handle.eosed = true;
|
|
270
|
+
handle.handlers.oneose?.();
|
|
271
|
+
});
|
|
272
|
+
return handle;
|
|
273
|
+
}
|
|
274
|
+
function forgetLiveGroup(ctx, group) {
|
|
275
|
+
ctx.liveByFp.delete(group.fp);
|
|
276
|
+
ctx.liveBySubId.delete(group.sub.id);
|
|
277
|
+
ctx.subs.delete(group.sub.id);
|
|
278
|
+
group.sub.closed = true;
|
|
279
|
+
}
|
|
280
|
+
function detachLive(ctx, fp, handle) {
|
|
281
|
+
const group = ctx.liveByFp.get(fp);
|
|
282
|
+
if (!group) return;
|
|
283
|
+
if (!group.attachments.delete(handle)) return;
|
|
284
|
+
if (group.attachments.size > 0) return;
|
|
285
|
+
endLiveGroup(ctx, fp, {
|
|
286
|
+
sendClose: true,
|
|
287
|
+
reason: "closed by client"
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
function endLiveGroup(ctx, fp, opts) {
|
|
291
|
+
const group = ctx.liveByFp.get(fp);
|
|
292
|
+
if (!group) return;
|
|
293
|
+
const id = group.sub.id;
|
|
294
|
+
const remaining = [...group.attachments];
|
|
295
|
+
group.attachments.clear();
|
|
296
|
+
forgetLiveGroup(ctx, group);
|
|
297
|
+
if (opts.sendClose) try {
|
|
298
|
+
if (ctx.connected()) ctx.send(["CLOSE", id]);
|
|
299
|
+
} catch {}
|
|
300
|
+
const errors = [];
|
|
301
|
+
for (const att of remaining) captureListenerError(errors, () => {
|
|
302
|
+
att.close(opts.reason);
|
|
303
|
+
});
|
|
304
|
+
flushListenerErrors(errors);
|
|
305
|
+
}
|
|
306
|
+
function deliverLiveEvent(ctx, group, event) {
|
|
307
|
+
const sub = group.sub;
|
|
308
|
+
const attachments = [...group.attachments];
|
|
309
|
+
const errors = [];
|
|
310
|
+
for (const att of attachments) captureListenerError(errors, () => {
|
|
311
|
+
att.handlers.receivedEvent?.(event.id);
|
|
312
|
+
});
|
|
313
|
+
if (sub.idsAtWatermark.has(event.id)) {
|
|
314
|
+
flushListenerErrors(errors);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const recipients = [];
|
|
318
|
+
for (const att of attachments) {
|
|
319
|
+
if (att.closed) continue;
|
|
320
|
+
let skip = false;
|
|
321
|
+
captureListenerError(errors, () => {
|
|
322
|
+
skip = Boolean(att.handlers.alreadyHaveEvent?.(event.id));
|
|
323
|
+
});
|
|
324
|
+
if (!skip) recipients.push(att);
|
|
325
|
+
}
|
|
326
|
+
if (recipients.length === 0) {
|
|
327
|
+
flushListenerErrors(errors);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (!ctx.acceptEvent(event)) {
|
|
331
|
+
flushListenerErrors(errors);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
sub.noteVerified(event);
|
|
335
|
+
for (const att of recipients) att.noteVerified(event);
|
|
336
|
+
for (const att of recipients) captureListenerError(errors, () => {
|
|
337
|
+
att.handlers.onevent?.(event);
|
|
338
|
+
});
|
|
339
|
+
flushListenerErrors(errors);
|
|
340
|
+
}
|
|
341
|
+
function deliverLiveEose(group) {
|
|
342
|
+
const attachments = Array.from(group.attachments);
|
|
343
|
+
const errors = [];
|
|
344
|
+
for (const att of attachments) {
|
|
345
|
+
if (att.closed || att.eosed) continue;
|
|
346
|
+
att.eosed = true;
|
|
347
|
+
captureListenerError(errors, () => {
|
|
348
|
+
att.handlers.oneose?.();
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
flushListenerErrors(errors);
|
|
352
|
+
}
|
|
353
|
+
function closeAllSubscriptions(ctx, reason) {
|
|
354
|
+
const errors = [];
|
|
355
|
+
const fps = Array.from(ctx.liveByFp.keys());
|
|
356
|
+
for (const fp of fps) captureListenerError(errors, () => {
|
|
357
|
+
endLiveGroup(ctx, fp, {
|
|
358
|
+
sendClose: false,
|
|
359
|
+
reason
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
for (const sub of ctx.subs.values()) if (!sub.closed) {
|
|
363
|
+
sub.closed = true;
|
|
364
|
+
captureListenerError(errors, () => {
|
|
365
|
+
sub.handlers.onclose?.(reason);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
ctx.subs.clear();
|
|
369
|
+
ctx.liveByFp.clear();
|
|
370
|
+
ctx.liveBySubId.clear();
|
|
371
|
+
flushListenerErrors(errors);
|
|
372
|
+
}
|
|
373
|
+
function dropSubscription(ctx, sub, reason) {
|
|
374
|
+
const group = ctx.liveBySubId.get(sub.id);
|
|
375
|
+
if (group) {
|
|
376
|
+
endLiveGroup(ctx, group.fp, {
|
|
377
|
+
sendClose: false,
|
|
378
|
+
reason
|
|
379
|
+
});
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
ctx.subs.delete(sub.id);
|
|
383
|
+
sub.closed = true;
|
|
384
|
+
sub.handlers.onclose?.(reason);
|
|
385
|
+
}
|
|
386
|
+
function onSubEvent(ctx, subId, event) {
|
|
387
|
+
const sub = ctx.subs.get(subId);
|
|
388
|
+
if (!sub || sub.closed) return;
|
|
389
|
+
const group = ctx.liveBySubId.get(subId);
|
|
390
|
+
if (group) {
|
|
391
|
+
deliverLiveEvent(ctx, group, event);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
sub.handlers.receivedEvent?.(event.id);
|
|
395
|
+
if (sub.idsAtWatermark.has(event.id)) return;
|
|
396
|
+
if (sub.handlers.alreadyHaveEvent?.(event.id)) return;
|
|
397
|
+
if (!ctx.acceptEvent(event)) return;
|
|
398
|
+
sub.noteVerified(event);
|
|
399
|
+
sub.handlers.onevent?.(event);
|
|
400
|
+
}
|
|
401
|
+
function onSubEose(ctx, subId) {
|
|
402
|
+
const sub = ctx.subs.get(subId);
|
|
403
|
+
if (!sub || sub.closed) return;
|
|
404
|
+
if (sub.eosed) return;
|
|
405
|
+
sub.eosed = true;
|
|
406
|
+
const group = ctx.liveBySubId.get(subId);
|
|
407
|
+
if (group) deliverLiveEose(group);
|
|
408
|
+
else {
|
|
409
|
+
sub.handlers.oneose?.();
|
|
410
|
+
if (sub.closeOnEose) sub.close("eose");
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function resubscribeAll(ctx) {
|
|
414
|
+
for (const sub of ctx.subs.values()) {
|
|
415
|
+
if (sub.closed) continue;
|
|
416
|
+
sub.eosed = false;
|
|
417
|
+
sub.authRetried = false;
|
|
418
|
+
const group = ctx.liveBySubId.get(sub.id);
|
|
419
|
+
if (group) for (const att of group.attachments) att.eosed = false;
|
|
420
|
+
try {
|
|
421
|
+
ctx.send([
|
|
422
|
+
"REQ",
|
|
423
|
+
sub.id,
|
|
424
|
+
...sub.replayFilters()
|
|
425
|
+
]);
|
|
426
|
+
} catch {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
function streamFilters(subscribe, filters, opts) {
|
|
433
|
+
return subscriptionToAsyncIterable((handlers) => subscribe(filters, {
|
|
434
|
+
...handlers,
|
|
435
|
+
id: opts?.id,
|
|
436
|
+
signal: opts?.signal
|
|
437
|
+
}), { signal: opts?.signal });
|
|
438
|
+
}
|
|
439
|
+
async function fetchFilters(subscribe, filters, opts) {
|
|
440
|
+
const events = [];
|
|
441
|
+
const seen = /* @__PURE__ */ new Set();
|
|
442
|
+
await new Promise((resolve, reject) => {
|
|
443
|
+
let settled = false;
|
|
444
|
+
const done = (err) => {
|
|
445
|
+
if (settled) return;
|
|
446
|
+
settled = true;
|
|
447
|
+
clearTimeout(timer);
|
|
448
|
+
sub.close(err ? err.message : "fetch complete");
|
|
449
|
+
if (err) reject(err);
|
|
450
|
+
else resolve();
|
|
451
|
+
};
|
|
452
|
+
const timer = setTimeout(() => done(), opts.timeoutMs);
|
|
453
|
+
const sub = subscribe(filters, {
|
|
454
|
+
id: opts.id,
|
|
455
|
+
signal: opts.signal,
|
|
456
|
+
closeOnEose: true,
|
|
457
|
+
onevent(event) {
|
|
458
|
+
if (seen.has(event.id)) return;
|
|
459
|
+
seen.add(event.id);
|
|
460
|
+
events.push(event);
|
|
461
|
+
},
|
|
462
|
+
oneose() {
|
|
463
|
+
done();
|
|
464
|
+
},
|
|
465
|
+
onclose() {
|
|
466
|
+
if (!settled) done();
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
opts.signal?.addEventListener("abort", () => done(new RelayConnectionError("fetch aborted", opts.url)), { once: true });
|
|
470
|
+
});
|
|
471
|
+
return events;
|
|
472
|
+
}
|
|
473
|
+
function armEoseTimeout(sub, eoseTimeoutMs) {
|
|
474
|
+
const timer = setTimeout(() => {
|
|
475
|
+
if (sub.eosed || sub.closed) return;
|
|
476
|
+
sub.eosed = true;
|
|
477
|
+
sub.handlers.oneose?.();
|
|
478
|
+
}, eoseTimeoutMs);
|
|
479
|
+
const prevClose = sub.handlers.onclose;
|
|
480
|
+
sub.handlers.onclose = (reason) => {
|
|
481
|
+
clearTimeout(timer);
|
|
482
|
+
prevClose?.(reason);
|
|
483
|
+
};
|
|
484
|
+
const prevEose = sub.handlers.oneose;
|
|
485
|
+
sub.handlers.oneose = () => {
|
|
486
|
+
clearTimeout(timer);
|
|
487
|
+
prevEose?.();
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
//#endregion
|
|
491
|
+
//#region src/relay/neg-session.ts
|
|
492
|
+
function createNegSession() {
|
|
493
|
+
return {
|
|
494
|
+
queue: [],
|
|
495
|
+
waiter: void 0,
|
|
496
|
+
error: void 0
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
function pushNegMsg(session, hex) {
|
|
500
|
+
if (session.waiter) {
|
|
501
|
+
const waiter = session.waiter;
|
|
502
|
+
session.waiter = void 0;
|
|
503
|
+
waiter.resolve(hex);
|
|
504
|
+
} else session.queue.push(hex);
|
|
505
|
+
}
|
|
506
|
+
function failNegSession(session, err) {
|
|
507
|
+
session.error = err;
|
|
508
|
+
if (session.waiter) {
|
|
509
|
+
const waiter = session.waiter;
|
|
510
|
+
session.waiter = void 0;
|
|
511
|
+
waiter.reject(err);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
function failNegErr(session, reason) {
|
|
515
|
+
failNegSession(session, new Nip77Error(reason));
|
|
516
|
+
}
|
|
517
|
+
/** Wire queue/timeout/abort around `runNegSession`. Not a second session class. */
|
|
518
|
+
async function runWiredNegSession(opts) {
|
|
519
|
+
const { session, storage, filter, id, timeoutMs, signal, send, url } = opts;
|
|
520
|
+
const deadline = Date.now() + timeoutMs;
|
|
521
|
+
const timedOut = () => new RelayPublishError("negentropy timed out", url);
|
|
522
|
+
const remainingMs = () => deadline - Date.now();
|
|
523
|
+
const next = () => {
|
|
524
|
+
if (session.error) return Promise.reject(session.error);
|
|
525
|
+
if (remainingMs() <= 0) return Promise.reject(timedOut());
|
|
526
|
+
const queued = session.queue.shift();
|
|
527
|
+
if (queued !== void 0) return Promise.resolve(queued);
|
|
528
|
+
return new Promise((resolve, reject) => {
|
|
529
|
+
const timer = setTimeout(() => {
|
|
530
|
+
session.waiter = void 0;
|
|
531
|
+
reject(timedOut());
|
|
532
|
+
}, remainingMs());
|
|
533
|
+
const fail = (err) => {
|
|
534
|
+
clearTimeout(timer);
|
|
535
|
+
session.waiter = void 0;
|
|
536
|
+
reject(err);
|
|
537
|
+
};
|
|
538
|
+
const onAbort = () => fail(new RelayConnectionError("negentropy aborted", url));
|
|
539
|
+
session.waiter = {
|
|
540
|
+
resolve: (hex) => {
|
|
541
|
+
clearTimeout(timer);
|
|
542
|
+
signal?.removeEventListener("abort", onAbort);
|
|
543
|
+
resolve(hex);
|
|
544
|
+
},
|
|
545
|
+
reject: (err) => fail(err)
|
|
546
|
+
};
|
|
547
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
548
|
+
});
|
|
549
|
+
};
|
|
550
|
+
return await runNegSession({
|
|
551
|
+
storage,
|
|
552
|
+
openingSend: (hex) => {
|
|
553
|
+
send([
|
|
554
|
+
"NEG-OPEN",
|
|
555
|
+
id,
|
|
556
|
+
filter,
|
|
557
|
+
hex
|
|
558
|
+
]);
|
|
559
|
+
},
|
|
560
|
+
msgSend: (hex) => {
|
|
561
|
+
send([
|
|
562
|
+
"NEG-MSG",
|
|
563
|
+
id,
|
|
564
|
+
hex
|
|
565
|
+
]);
|
|
566
|
+
},
|
|
567
|
+
next
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
const PING_DUMMY_FILTER = {
|
|
571
|
+
ids: ["a".repeat(64)],
|
|
572
|
+
limit: 0
|
|
573
|
+
};
|
|
574
|
+
/** Browser WebSocket and `ws` both use 1 for OPEN. */
|
|
575
|
+
const WS_OPEN = 1;
|
|
576
|
+
/** node `ws` delivers `pong` on the EventEmitter, not via addEventListener. */
|
|
577
|
+
function canNativePing(ws) {
|
|
578
|
+
return typeof ws.ping === "function" && (typeof ws.once === "function" || typeof ws.on === "function");
|
|
579
|
+
}
|
|
580
|
+
/** REQ-dummy or native ping/pong keepalive. Does not import class Relay. */
|
|
581
|
+
var PingLoop = class {
|
|
582
|
+
#opts;
|
|
583
|
+
#timer;
|
|
584
|
+
#waiters = /* @__PURE__ */ new Map();
|
|
585
|
+
#gen = 0;
|
|
586
|
+
#nativePing;
|
|
587
|
+
constructor(opts) {
|
|
588
|
+
this.#opts = opts;
|
|
589
|
+
}
|
|
590
|
+
start() {
|
|
591
|
+
this.stop();
|
|
592
|
+
this.#timer = setInterval(() => {
|
|
593
|
+
this.#pingpong();
|
|
594
|
+
}, this.#opts.pingIntervalMs);
|
|
595
|
+
}
|
|
596
|
+
stop() {
|
|
597
|
+
if (this.#timer !== void 0) {
|
|
598
|
+
clearInterval(this.#timer);
|
|
599
|
+
this.#timer = void 0;
|
|
600
|
+
}
|
|
601
|
+
this.#abortCurrentPing();
|
|
602
|
+
}
|
|
603
|
+
hasWaiter(id) {
|
|
604
|
+
return this.#waiters.has(id);
|
|
605
|
+
}
|
|
606
|
+
finishDummyPing(id) {
|
|
607
|
+
const waiter = this.#waiters.get(id);
|
|
608
|
+
if (!waiter) return false;
|
|
609
|
+
this.#waiters.delete(id);
|
|
610
|
+
waiter.resolve(true);
|
|
611
|
+
try {
|
|
612
|
+
this.#opts.send(["CLOSE", id]);
|
|
613
|
+
} catch {}
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
#abortCurrentPing() {
|
|
617
|
+
this.#gen += 1;
|
|
618
|
+
const native = this.#nativePing;
|
|
619
|
+
this.#nativePing = void 0;
|
|
620
|
+
native?.abort();
|
|
621
|
+
for (const waiter of this.#waiters.values()) waiter.resolve(false);
|
|
622
|
+
this.#waiters.clear();
|
|
623
|
+
}
|
|
624
|
+
async #pingpong() {
|
|
625
|
+
const ws = this.#opts.getWs();
|
|
626
|
+
if (!ws || ws.readyState !== WS_OPEN) return;
|
|
627
|
+
if (this.#nativePing || this.#waiters.size > 0) return;
|
|
628
|
+
const gen = this.#gen;
|
|
629
|
+
const ping = canNativePing(ws) ? this.#waitForNativePing(ws) : this.#waitForDummyPing();
|
|
630
|
+
const ok = await new Promise((resolve) => {
|
|
631
|
+
let settled = false;
|
|
632
|
+
const done = (alive) => {
|
|
633
|
+
if (settled) return;
|
|
634
|
+
settled = true;
|
|
635
|
+
clearTimeout(timer);
|
|
636
|
+
resolve(alive);
|
|
637
|
+
};
|
|
638
|
+
const timer = setTimeout(() => {
|
|
639
|
+
if (this.#nativePing || this.#waiters.size > 0) done(false);
|
|
640
|
+
}, this.#opts.pingTimeoutMs);
|
|
641
|
+
ping.then(done);
|
|
642
|
+
});
|
|
643
|
+
if (gen !== this.#gen) return;
|
|
644
|
+
if (!ok) {
|
|
645
|
+
this.#abortCurrentPing();
|
|
646
|
+
this.#opts.closeWs();
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
#waitForNativePing(ws) {
|
|
650
|
+
return new Promise((resolve) => {
|
|
651
|
+
let settled = false;
|
|
652
|
+
const finish = (alive) => {
|
|
653
|
+
if (settled) return;
|
|
654
|
+
settled = true;
|
|
655
|
+
this.#nativePing = void 0;
|
|
656
|
+
ws.off?.("pong", onPong);
|
|
657
|
+
resolve(alive);
|
|
658
|
+
};
|
|
659
|
+
const onPong = () => finish(true);
|
|
660
|
+
this.#nativePing = { abort: () => finish(false) };
|
|
661
|
+
if (typeof ws.on === "function" && typeof ws.off === "function") ws.on("pong", onPong);
|
|
662
|
+
else if (typeof ws.once === "function") ws.once("pong", onPong);
|
|
663
|
+
else ws.on("pong", onPong);
|
|
664
|
+
try {
|
|
665
|
+
ws.ping();
|
|
666
|
+
} catch {
|
|
667
|
+
finish(false);
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
#waitForDummyPing() {
|
|
672
|
+
return new Promise((resolve) => {
|
|
673
|
+
const id = this.#opts.nextSubId("__ping__");
|
|
674
|
+
this.#waiters.set(id, { resolve });
|
|
675
|
+
try {
|
|
676
|
+
this.#opts.send([
|
|
677
|
+
"REQ",
|
|
678
|
+
id,
|
|
679
|
+
PING_DUMMY_FILTER
|
|
680
|
+
]);
|
|
681
|
+
} catch {
|
|
682
|
+
this.#waiters.delete(id);
|
|
683
|
+
resolve(false);
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
//#endregion
|
|
689
|
+
//#region src/relay/relay.ts
|
|
690
|
+
const RelayStatus = {
|
|
691
|
+
Initialized: "initialized",
|
|
692
|
+
Connecting: "connecting",
|
|
693
|
+
Connected: "connected",
|
|
694
|
+
Disconnected: "disconnected",
|
|
695
|
+
Closed: "closed"
|
|
696
|
+
};
|
|
697
|
+
const DEFAULT_BACKOFF = [
|
|
698
|
+
1e3,
|
|
699
|
+
2e3,
|
|
700
|
+
5e3,
|
|
701
|
+
1e4,
|
|
702
|
+
2e4,
|
|
703
|
+
3e4,
|
|
704
|
+
6e4
|
|
705
|
+
];
|
|
706
|
+
/**
|
|
707
|
+
* Single-relay NIP-01 client.
|
|
708
|
+
* Connection lifecycle, REQ/CLOSE, EVENT publish ACK, AUTH, COUNT, NIP-77, optional reconnect.
|
|
709
|
+
*/
|
|
710
|
+
var Relay = class Relay {
|
|
711
|
+
url;
|
|
712
|
+
#ws;
|
|
713
|
+
#connected = false;
|
|
714
|
+
#gen = 0;
|
|
715
|
+
#status = RelayStatus.Initialized;
|
|
716
|
+
#connecting;
|
|
717
|
+
#connectFinish;
|
|
718
|
+
#connectTimer;
|
|
719
|
+
#socketHandlers;
|
|
720
|
+
#subs = /* @__PURE__ */ new Map();
|
|
721
|
+
#liveByFp = /* @__PURE__ */ new Map();
|
|
722
|
+
#liveBySubId = /* @__PURE__ */ new Map();
|
|
723
|
+
#live;
|
|
724
|
+
#publishes = /* @__PURE__ */ new Map();
|
|
725
|
+
#counts = /* @__PURE__ */ new Map();
|
|
726
|
+
#neg = /* @__PURE__ */ new Map();
|
|
727
|
+
#WS;
|
|
728
|
+
#verify;
|
|
729
|
+
#verifyDead = false;
|
|
730
|
+
#publishTimeoutMs;
|
|
731
|
+
#connectTimeoutMs;
|
|
732
|
+
#enableReconnect;
|
|
733
|
+
#backoff;
|
|
734
|
+
#serial = 0;
|
|
735
|
+
#challenge;
|
|
736
|
+
#authedChallenge;
|
|
737
|
+
#authPromise;
|
|
738
|
+
#authSigner;
|
|
739
|
+
#intentionalClose = false;
|
|
740
|
+
#reconnectAttempts = 0;
|
|
741
|
+
#reconnectTimer;
|
|
742
|
+
#skipReconnect = false;
|
|
743
|
+
/** Collapses error+close pairs into a single terminal/reconnect action. */
|
|
744
|
+
#deathHandled = false;
|
|
745
|
+
#enablePing;
|
|
746
|
+
#ping;
|
|
747
|
+
onnotice = null;
|
|
748
|
+
onclose = null;
|
|
749
|
+
/** Fired when the relay sends a NIP-42 AUTH challenge. */
|
|
750
|
+
onauth = null;
|
|
751
|
+
/** Fired after a successful reconnect (not the initial connect). */
|
|
752
|
+
onreconnect = null;
|
|
753
|
+
constructor(url, opts = {}) {
|
|
754
|
+
this.url = normalizeURL(url);
|
|
755
|
+
this.#WS = opts.websocketImplementation ?? getWebSocketImplementation();
|
|
756
|
+
this.#verify = opts.verifyEvent ?? verifyEvent;
|
|
757
|
+
this.#publishTimeoutMs = opts.publishTimeoutMs ?? 4400;
|
|
758
|
+
this.#connectTimeoutMs = opts.connectTimeoutMs ?? 5e3;
|
|
759
|
+
this.#enableReconnect = opts.enableReconnect ?? false;
|
|
760
|
+
this.#backoff = opts.reconnectBackoffMs ?? DEFAULT_BACKOFF;
|
|
761
|
+
this.#enablePing = opts.enablePing ?? false;
|
|
762
|
+
this.#authSigner = opts.authSigner;
|
|
763
|
+
this.#ping = new PingLoop({
|
|
764
|
+
send: (message) => this.#send(message),
|
|
765
|
+
nextSubId: (prefix) => this.nextSubId(prefix),
|
|
766
|
+
getWs: () => this.#ws,
|
|
767
|
+
closeWs: () => {
|
|
768
|
+
const ws = this.#ws;
|
|
769
|
+
if (ws && ws.readyState === this.#WS.OPEN) try {
|
|
770
|
+
ws.close();
|
|
771
|
+
} catch {}
|
|
772
|
+
},
|
|
773
|
+
pingIntervalMs: opts.pingIntervalMs ?? 29e3,
|
|
774
|
+
pingTimeoutMs: opts.pingTimeoutMs ?? 2e4
|
|
775
|
+
});
|
|
776
|
+
this.#live = {
|
|
777
|
+
liveByFp: this.#liveByFp,
|
|
778
|
+
liveBySubId: this.#liveBySubId,
|
|
779
|
+
subs: this.#subs,
|
|
780
|
+
connected: () => this.#connected,
|
|
781
|
+
enableReconnect: () => this.#enableReconnect,
|
|
782
|
+
send: (message) => this.#send(message),
|
|
783
|
+
scheduleReconnect: () => this.#scheduleReconnect(),
|
|
784
|
+
acceptEvent: (event) => this.#acceptEvent(event),
|
|
785
|
+
armEoseTimeout
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
/** Latest NIP-42 challenge, if any. */
|
|
789
|
+
get challenge() {
|
|
790
|
+
return this.#challenge;
|
|
791
|
+
}
|
|
792
|
+
get connected() {
|
|
793
|
+
return this.#connected;
|
|
794
|
+
}
|
|
795
|
+
get status() {
|
|
796
|
+
return this.#status;
|
|
797
|
+
}
|
|
798
|
+
get generation() {
|
|
799
|
+
return this.#gen;
|
|
800
|
+
}
|
|
801
|
+
get reconnectEnabled() {
|
|
802
|
+
return this.#enableReconnect;
|
|
803
|
+
}
|
|
804
|
+
/** User subscriptions only; dummy ping REQs are not counted. */
|
|
805
|
+
get subscriptionCount() {
|
|
806
|
+
return this.#subs.size;
|
|
807
|
+
}
|
|
808
|
+
static async connect(url, opts) {
|
|
809
|
+
const relay = new Relay(url, opts);
|
|
810
|
+
await relay.connect({ signal: opts?.signal });
|
|
811
|
+
return relay;
|
|
812
|
+
}
|
|
813
|
+
async connect(opts) {
|
|
814
|
+
if (this.#connected) return;
|
|
815
|
+
if (this.#connecting) return await this.#connecting;
|
|
816
|
+
const gen = ++this.#gen;
|
|
817
|
+
this.#intentionalClose = false;
|
|
818
|
+
this.#skipReconnect = false;
|
|
819
|
+
this.#deathHandled = false;
|
|
820
|
+
this.#clearReconnectTimer();
|
|
821
|
+
this.#status = RelayStatus.Connecting;
|
|
822
|
+
const timeoutMs = opts?.timeoutMs ?? this.#connectTimeoutMs;
|
|
823
|
+
const isReconnect = this.#reconnectAttempts > 0;
|
|
824
|
+
let resolveConnect = () => {};
|
|
825
|
+
let rejectConnect = (_err) => {};
|
|
826
|
+
let settled = false;
|
|
827
|
+
let timer;
|
|
828
|
+
let ws;
|
|
829
|
+
let handlers;
|
|
830
|
+
const connecting = new Promise((resolve, reject) => {
|
|
831
|
+
resolveConnect = resolve;
|
|
832
|
+
rejectConnect = reject;
|
|
833
|
+
});
|
|
834
|
+
const release = () => {
|
|
835
|
+
if (!ws) return;
|
|
836
|
+
if (handlers) {
|
|
837
|
+
try {
|
|
838
|
+
ws.removeEventListener("open", handlers.onOpen);
|
|
839
|
+
ws.removeEventListener("error", handlers.onError);
|
|
840
|
+
ws.removeEventListener("close", handlers.onClose);
|
|
841
|
+
ws.removeEventListener("message", handlers.onMessage);
|
|
842
|
+
} catch {}
|
|
843
|
+
if (this.#socketHandlers === handlers) this.#socketHandlers = void 0;
|
|
844
|
+
}
|
|
845
|
+
try {
|
|
846
|
+
if (ws.readyState !== this.#WS.CLOSED && ws.readyState !== this.#WS.CLOSING) ws.close();
|
|
847
|
+
} catch {}
|
|
848
|
+
if (this.#ws === ws) this.#ws = void 0;
|
|
849
|
+
};
|
|
850
|
+
const onAbort = () => {
|
|
851
|
+
if (gen !== this.#gen) {
|
|
852
|
+
release();
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
this.#intentionalClose = true;
|
|
856
|
+
this.#skipReconnect = true;
|
|
857
|
+
release();
|
|
858
|
+
finish(new RelayConnectionError("connection aborted", this.url));
|
|
859
|
+
if (gen === this.#gen) this.#handleSocketDeath("connection aborted", {
|
|
860
|
+
fromConnectAttempt: true,
|
|
861
|
+
gen
|
|
862
|
+
});
|
|
863
|
+
};
|
|
864
|
+
const finish = (err) => {
|
|
865
|
+
if (settled) return;
|
|
866
|
+
settled = true;
|
|
867
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
868
|
+
if (this.#connectTimer === timer) this.#connectTimer = void 0;
|
|
869
|
+
opts?.signal?.removeEventListener("abort", onAbort);
|
|
870
|
+
if (this.#connectFinish === finish) this.#connectFinish = void 0;
|
|
871
|
+
if (this.#connecting === connecting) this.#connecting = void 0;
|
|
872
|
+
if (err) rejectConnect(err instanceof Error ? err : new RelayConnectionError("connection failed", this.url));
|
|
873
|
+
else resolveConnect();
|
|
874
|
+
};
|
|
875
|
+
this.#connecting = connecting;
|
|
876
|
+
this.#connectFinish = finish;
|
|
877
|
+
timer = setTimeout(() => {
|
|
878
|
+
if (gen !== this.#gen) {
|
|
879
|
+
release();
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
if (!isReconnect && !this.#enableReconnect) this.#skipReconnect = true;
|
|
883
|
+
release();
|
|
884
|
+
finish(new RelayConnectionError("connection timed out", this.url));
|
|
885
|
+
if (gen === this.#gen) this.#handleSocketDeath("connection timed out", {
|
|
886
|
+
fromConnectAttempt: true,
|
|
887
|
+
gen
|
|
888
|
+
});
|
|
889
|
+
}, timeoutMs);
|
|
890
|
+
this.#connectTimer = timer;
|
|
891
|
+
opts?.signal?.addEventListener("abort", onAbort, { once: true });
|
|
892
|
+
try {
|
|
893
|
+
ws = new this.#WS(this.url);
|
|
894
|
+
} catch (err) {
|
|
895
|
+
if (!isReconnect && !this.#enableReconnect) this.#skipReconnect = true;
|
|
896
|
+
finish(err);
|
|
897
|
+
if (gen === this.#gen) this.#handleSocketDeath("connection failed", {
|
|
898
|
+
fromConnectAttempt: true,
|
|
899
|
+
gen
|
|
900
|
+
});
|
|
901
|
+
await connecting;
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
this.#ws = ws;
|
|
905
|
+
const onOpen = () => {
|
|
906
|
+
if (gen !== this.#gen) {
|
|
907
|
+
release();
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
this.#connected = true;
|
|
911
|
+
this.#status = RelayStatus.Connected;
|
|
912
|
+
this.#deathHandled = false;
|
|
913
|
+
const wasReconnect = this.#reconnectAttempts > 0;
|
|
914
|
+
this.#challenge = void 0;
|
|
915
|
+
this.#authPromise = void 0;
|
|
916
|
+
this.#authedChallenge = void 0;
|
|
917
|
+
if (!resubscribeAll(this.#live)) {
|
|
918
|
+
this.#connected = false;
|
|
919
|
+
this.#status = RelayStatus.Disconnected;
|
|
920
|
+
if (!isReconnect && !this.#enableReconnect) this.#skipReconnect = true;
|
|
921
|
+
release();
|
|
922
|
+
finish(new RelayConnectionError("connection failed", this.url));
|
|
923
|
+
if (this.#enableReconnect && !this.#intentionalClose && !this.#skipReconnect && this.#subs.size > 0) this.#scheduleReconnect();
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
this.#reconnectAttempts = 0;
|
|
927
|
+
if (this.#enablePing) this.#ping.start();
|
|
928
|
+
else this.#ping.stop();
|
|
929
|
+
if (wasReconnect) this.onreconnect?.();
|
|
930
|
+
finish();
|
|
931
|
+
};
|
|
932
|
+
const onError = () => {
|
|
933
|
+
if (gen !== this.#gen) {
|
|
934
|
+
release();
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
this.#connected = false;
|
|
938
|
+
const fromConnectAttempt = !settled;
|
|
939
|
+
if (!settled && !isReconnect && !this.#enableReconnect) this.#skipReconnect = true;
|
|
940
|
+
release();
|
|
941
|
+
finish(new RelayConnectionError("connection failed", this.url));
|
|
942
|
+
if (gen === this.#gen) this.#handleSocketDeath("connection failed", {
|
|
943
|
+
fromConnectAttempt,
|
|
944
|
+
gen
|
|
945
|
+
});
|
|
946
|
+
};
|
|
947
|
+
const onClose = () => {
|
|
948
|
+
if (gen !== this.#gen) {
|
|
949
|
+
release();
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
this.#connected = false;
|
|
953
|
+
const fromConnectAttempt = !settled;
|
|
954
|
+
if (!settled && !isReconnect && !this.#enableReconnect) this.#skipReconnect = true;
|
|
955
|
+
release();
|
|
956
|
+
if (fromConnectAttempt) finish(new RelayConnectionError("websocket closed", this.url));
|
|
957
|
+
if (gen === this.#gen) this.#handleSocketDeath("websocket closed", {
|
|
958
|
+
fromConnectAttempt,
|
|
959
|
+
gen
|
|
960
|
+
});
|
|
961
|
+
};
|
|
962
|
+
const onMessage = (ev) => {
|
|
963
|
+
if (gen !== this.#gen) return;
|
|
964
|
+
const data = typeof ev === "object" && ev !== null && "data" in ev ? ev.data : ev;
|
|
965
|
+
this.#onMessage(String(data));
|
|
966
|
+
};
|
|
967
|
+
handlers = {
|
|
968
|
+
onOpen,
|
|
969
|
+
onError,
|
|
970
|
+
onClose,
|
|
971
|
+
onMessage,
|
|
972
|
+
ws
|
|
973
|
+
};
|
|
974
|
+
this.#socketHandlers = handlers;
|
|
975
|
+
ws.addEventListener("open", onOpen);
|
|
976
|
+
ws.addEventListener("error", onError);
|
|
977
|
+
ws.addEventListener("close", onClose);
|
|
978
|
+
ws.addEventListener("message", onMessage);
|
|
979
|
+
await connecting;
|
|
980
|
+
}
|
|
981
|
+
#detachSocketHandlers() {
|
|
982
|
+
const h = this.#socketHandlers;
|
|
983
|
+
if (!h) return;
|
|
984
|
+
try {
|
|
985
|
+
h.ws.removeEventListener("open", h.onOpen);
|
|
986
|
+
h.ws.removeEventListener("error", h.onError);
|
|
987
|
+
h.ws.removeEventListener("close", h.onClose);
|
|
988
|
+
h.ws.removeEventListener("message", h.onMessage);
|
|
989
|
+
} catch {}
|
|
990
|
+
this.#socketHandlers = void 0;
|
|
991
|
+
}
|
|
992
|
+
/** Graceful shutdown: disables reconnect and closes all subscriptions. */
|
|
993
|
+
close() {
|
|
994
|
+
this.#gen += 1;
|
|
995
|
+
this.#status = RelayStatus.Closed;
|
|
996
|
+
this.#intentionalClose = true;
|
|
997
|
+
this.#skipReconnect = true;
|
|
998
|
+
this.#clearReconnectTimer();
|
|
999
|
+
if (this.#connectTimer !== void 0) {
|
|
1000
|
+
clearTimeout(this.#connectTimer);
|
|
1001
|
+
this.#connectTimer = void 0;
|
|
1002
|
+
}
|
|
1003
|
+
this.#ping.stop();
|
|
1004
|
+
this.#connectFinish?.(new RelayClosedError("relay closed", this.url));
|
|
1005
|
+
try {
|
|
1006
|
+
closeAllSubscriptions(this.#live, "relay closed");
|
|
1007
|
+
} finally {
|
|
1008
|
+
this.#rejectPublishes(new RelayClosedError("relay closed", this.url));
|
|
1009
|
+
this.#rejectCounts(new RelayClosedError("relay closed", this.url));
|
|
1010
|
+
this.#rejectNeg(new RelayClosedError("relay closed", this.url));
|
|
1011
|
+
this.#detachSocketHandlers();
|
|
1012
|
+
this.#teardownSocket();
|
|
1013
|
+
this.#connected = false;
|
|
1014
|
+
this.onclose?.();
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
#teardownSocket() {
|
|
1018
|
+
try {
|
|
1019
|
+
this.#ws?.close();
|
|
1020
|
+
} catch {}
|
|
1021
|
+
this.#ws = void 0;
|
|
1022
|
+
}
|
|
1023
|
+
#clearReconnectTimer() {
|
|
1024
|
+
if (this.#reconnectTimer !== void 0) {
|
|
1025
|
+
clearTimeout(this.#reconnectTimer);
|
|
1026
|
+
this.#reconnectTimer = void 0;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
#rejectPublishes(err) {
|
|
1030
|
+
for (const [, waiter] of this.#publishes) {
|
|
1031
|
+
clearTimeout(waiter.timer);
|
|
1032
|
+
waiter.reject(err);
|
|
1033
|
+
}
|
|
1034
|
+
this.#publishes.clear();
|
|
1035
|
+
}
|
|
1036
|
+
#rejectCounts(err) {
|
|
1037
|
+
for (const [, waiter] of this.#counts) {
|
|
1038
|
+
clearTimeout(waiter.timer);
|
|
1039
|
+
waiter.reject(err);
|
|
1040
|
+
}
|
|
1041
|
+
this.#counts.clear();
|
|
1042
|
+
}
|
|
1043
|
+
#rejectNeg(err) {
|
|
1044
|
+
for (const session of this.#neg.values()) failNegSession(session, err);
|
|
1045
|
+
this.#neg.clear();
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Unexpected socket death. Keep subscriptions if reconnecting.
|
|
1049
|
+
*/
|
|
1050
|
+
#handleSocketDeath(reason, opts) {
|
|
1051
|
+
if (opts.gen !== this.#gen) return;
|
|
1052
|
+
if (this.#deathHandled) return;
|
|
1053
|
+
this.#deathHandled = true;
|
|
1054
|
+
this.#ping.stop();
|
|
1055
|
+
this.#detachSocketHandlers();
|
|
1056
|
+
this.#connected = false;
|
|
1057
|
+
this.#ws = void 0;
|
|
1058
|
+
this.#rejectPublishes(new RelayClosedError(reason, this.url));
|
|
1059
|
+
this.#rejectCounts(new RelayClosedError(reason, this.url));
|
|
1060
|
+
this.#rejectNeg(new RelayClosedError(reason, this.url));
|
|
1061
|
+
if (this.#enableReconnect && !this.#intentionalClose && !this.#skipReconnect && this.#subs.size > 0) {
|
|
1062
|
+
this.#status = RelayStatus.Disconnected;
|
|
1063
|
+
this.#scheduleReconnect();
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
if (!this.#intentionalClose) this.#status = RelayStatus.Disconnected;
|
|
1067
|
+
if (!opts.fromConnectAttempt || this.#subs.size > 0) {
|
|
1068
|
+
closeAllSubscriptions(this.#live, reason);
|
|
1069
|
+
if (!this.#intentionalClose) this.onclose?.();
|
|
1070
|
+
}
|
|
1071
|
+
this.#status = RelayStatus.Closed;
|
|
1072
|
+
}
|
|
1073
|
+
#scheduleReconnect() {
|
|
1074
|
+
if (this.#reconnectTimer !== void 0) return;
|
|
1075
|
+
if (this.#connected) return;
|
|
1076
|
+
const delay = this.#backoff[Math.min(this.#reconnectAttempts, this.#backoff.length - 1)] ?? 6e4;
|
|
1077
|
+
this.#reconnectAttempts += 1;
|
|
1078
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
1079
|
+
this.#reconnectTimer = void 0;
|
|
1080
|
+
if (this.#intentionalClose || this.#connected) return;
|
|
1081
|
+
this.connect().catch(() => {
|
|
1082
|
+
if (this.#enableReconnect && !this.#intentionalClose && !this.#skipReconnect && this.#subs.size > 0 && !this.#connected) this.#scheduleReconnect();
|
|
1083
|
+
});
|
|
1084
|
+
}, delay);
|
|
1085
|
+
}
|
|
1086
|
+
#send(message) {
|
|
1087
|
+
if (!this.#ws || !this.#connected) throw new RelayClosedError("not connected", this.url);
|
|
1088
|
+
const raw = typeof message === "string" ? message : encodeClientMessage(message);
|
|
1089
|
+
this.#ws.send(raw);
|
|
1090
|
+
}
|
|
1091
|
+
#onMessage(raw) {
|
|
1092
|
+
let msg;
|
|
1093
|
+
try {
|
|
1094
|
+
msg = parseRelayMessage(raw);
|
|
1095
|
+
} catch {
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
switch (msg[0]) {
|
|
1099
|
+
case "EVENT": {
|
|
1100
|
+
const [, subId, event] = msg;
|
|
1101
|
+
if (this.#ping.hasWaiter(subId)) return;
|
|
1102
|
+
onSubEvent(this.#live, subId, event);
|
|
1103
|
+
break;
|
|
1104
|
+
}
|
|
1105
|
+
case "EOSE": {
|
|
1106
|
+
const [, subId] = msg;
|
|
1107
|
+
if (this.#ping.finishDummyPing(subId)) return;
|
|
1108
|
+
onSubEose(this.#live, subId);
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
1111
|
+
case "CLOSED": {
|
|
1112
|
+
const [, subId, reason] = msg;
|
|
1113
|
+
if (this.#ping.finishDummyPing(subId)) return;
|
|
1114
|
+
const countWaiter = this.#counts.get(subId);
|
|
1115
|
+
if (countWaiter) {
|
|
1116
|
+
if (isAuthRequired(reason) && this.#authSigner && !countWaiter.authRetried) {
|
|
1117
|
+
countWaiter.authRetried = true;
|
|
1118
|
+
this.#authThenRecount(subId, countWaiter, reason);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
countWaiter.reject(new RelayClosedError(reason || "COUNT closed", this.url));
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
const sub = this.#subs.get(subId);
|
|
1125
|
+
if (!sub) return;
|
|
1126
|
+
if (isAuthRequired(reason) && this.#authSigner && !sub.authRetried) {
|
|
1127
|
+
sub.authRetried = true;
|
|
1128
|
+
this.#authThenResubscribe(sub, reason);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
dropSubscription(this.#live, sub, reason);
|
|
1132
|
+
break;
|
|
1133
|
+
}
|
|
1134
|
+
case "OK": {
|
|
1135
|
+
const [, eventId, ok, message] = msg;
|
|
1136
|
+
const waiter = this.#publishes.get(eventId);
|
|
1137
|
+
if (!waiter) return;
|
|
1138
|
+
if (!ok && isAuthRequired(message) && waiter.event && !waiter.authRetried && this.#authSigner) {
|
|
1139
|
+
waiter.authRetried = true;
|
|
1140
|
+
this.#authThenRepublish(waiter, eventId, message);
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
clearTimeout(waiter.timer);
|
|
1144
|
+
this.#publishes.delete(eventId);
|
|
1145
|
+
waiter.resolve({
|
|
1146
|
+
ok,
|
|
1147
|
+
message
|
|
1148
|
+
});
|
|
1149
|
+
break;
|
|
1150
|
+
}
|
|
1151
|
+
case "COUNT": {
|
|
1152
|
+
const [, countId, payload] = msg;
|
|
1153
|
+
const waiter = this.#counts.get(countId);
|
|
1154
|
+
if (!waiter) return;
|
|
1155
|
+
waiter.resolve(payload);
|
|
1156
|
+
break;
|
|
1157
|
+
}
|
|
1158
|
+
case "NEG-MSG": {
|
|
1159
|
+
const [, negId, hex] = msg;
|
|
1160
|
+
const session = this.#neg.get(negId);
|
|
1161
|
+
if (!session) return;
|
|
1162
|
+
pushNegMsg(session, hex);
|
|
1163
|
+
break;
|
|
1164
|
+
}
|
|
1165
|
+
case "NEG-ERR": {
|
|
1166
|
+
const [, negId, reason] = msg;
|
|
1167
|
+
const session = this.#neg.get(negId);
|
|
1168
|
+
if (!session) return;
|
|
1169
|
+
failNegErr(session, reason);
|
|
1170
|
+
break;
|
|
1171
|
+
}
|
|
1172
|
+
case "NOTICE":
|
|
1173
|
+
this.onnotice?.(msg[1]);
|
|
1174
|
+
break;
|
|
1175
|
+
case "AUTH":
|
|
1176
|
+
this.#challenge = msg[1];
|
|
1177
|
+
this.#authPromise = void 0;
|
|
1178
|
+
this.#authedChallenge = void 0;
|
|
1179
|
+
this.onauth?.(msg[1]);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
/** Low-level REQ with callbacks. Survives reconnect when enableReconnect is on. */
|
|
1183
|
+
subscribe(filters, opts = {}) {
|
|
1184
|
+
if (filters.length === 0) throw new MessageError("REQ requires at least one filter");
|
|
1185
|
+
if (!this.#connected && !this.#enableReconnect) throw new RelayClosedError("not connected", this.url);
|
|
1186
|
+
const canonical = canonicalizeFilters(filters);
|
|
1187
|
+
if (opts.closeOnEose === true) return openExclusive(this.#live, canonical, opts);
|
|
1188
|
+
return subscribeLive(this.#live, canonical, opts);
|
|
1189
|
+
}
|
|
1190
|
+
#acceptEvent(event) {
|
|
1191
|
+
if (this.#verifyDead) return false;
|
|
1192
|
+
try {
|
|
1193
|
+
return this.#verify(event);
|
|
1194
|
+
} catch (e) {
|
|
1195
|
+
if (e instanceof WasmVerifyPoisonedError || e instanceof WebAssembly.RuntimeError) {
|
|
1196
|
+
this.#verifyDead = true;
|
|
1197
|
+
this.onnotice?.("verify-poisoned: wasm instance aborted");
|
|
1198
|
+
return false;
|
|
1199
|
+
}
|
|
1200
|
+
throw e;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
/** AsyncIterable of events for filters until the subscription is closed. */
|
|
1204
|
+
stream(filters, opts) {
|
|
1205
|
+
return streamFilters((f, o) => this.subscribe(f, o), filters, opts);
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* One-shot query: collect events until EOSE or timeout, then close.
|
|
1209
|
+
*/
|
|
1210
|
+
async fetch(filters, opts) {
|
|
1211
|
+
if (filters.length === 0) throw new MessageError("REQ requires at least one filter");
|
|
1212
|
+
filters = canonicalizeFilters(filters);
|
|
1213
|
+
if (!this.#connected) await this.connect({ signal: opts?.signal });
|
|
1214
|
+
return fetchFilters((f, o) => this.subscribe(f, o), filters, {
|
|
1215
|
+
timeoutMs: opts?.timeoutMs ?? 4400,
|
|
1216
|
+
signal: opts?.signal,
|
|
1217
|
+
id: opts?.id,
|
|
1218
|
+
url: this.url
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
/** Publish an event and wait for OK. */
|
|
1222
|
+
async publish(event, opts) {
|
|
1223
|
+
if (!this.#connected) throw new RelayClosedError("not connected", this.url);
|
|
1224
|
+
const timeoutMs = opts?.timeoutMs ?? this.#publishTimeoutMs;
|
|
1225
|
+
return await new Promise((resolve, reject) => {
|
|
1226
|
+
const timer = setTimeout(() => {
|
|
1227
|
+
this.#publishes.delete(event.id);
|
|
1228
|
+
reject(new RelayPublishError("publish timed out", this.url));
|
|
1229
|
+
}, timeoutMs);
|
|
1230
|
+
this.#publishes.set(event.id, {
|
|
1231
|
+
resolve,
|
|
1232
|
+
reject,
|
|
1233
|
+
timer,
|
|
1234
|
+
event,
|
|
1235
|
+
timeoutMs
|
|
1236
|
+
});
|
|
1237
|
+
try {
|
|
1238
|
+
this.#send(["EVENT", event]);
|
|
1239
|
+
} catch (err) {
|
|
1240
|
+
clearTimeout(timer);
|
|
1241
|
+
this.#publishes.delete(event.id);
|
|
1242
|
+
reject(err instanceof Error ? err : new RelayPublishError("publish failed", this.url));
|
|
1243
|
+
}
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* NIP-45 COUNT: ask the relay how many events match `filters`.
|
|
1248
|
+
* Resolves with the COUNT payload (count / optional approximate / optional hll).
|
|
1249
|
+
*/
|
|
1250
|
+
async count(filters, opts) {
|
|
1251
|
+
if (!this.#connected) throw new RelayClosedError("not connected", this.url);
|
|
1252
|
+
if (filters.length === 0) throw new RelayError("COUNT requires at least one filter", this.url);
|
|
1253
|
+
if (opts?.signal?.aborted) throw new RelayConnectionError("count aborted", this.url);
|
|
1254
|
+
filters = canonicalizeFilters(filters);
|
|
1255
|
+
const id = opts?.id !== void 0 ? createSubscriptionId(opts.id) : this.nextSubId("count");
|
|
1256
|
+
const timeoutMs = opts?.timeoutMs ?? this.#publishTimeoutMs;
|
|
1257
|
+
return await new Promise((resolve, reject) => {
|
|
1258
|
+
const cleanup = () => {
|
|
1259
|
+
opts?.signal?.removeEventListener("abort", onAbort);
|
|
1260
|
+
};
|
|
1261
|
+
const waiter = {
|
|
1262
|
+
resolve: (result) => {
|
|
1263
|
+
if (waiter.timer !== void 0) clearTimeout(waiter.timer);
|
|
1264
|
+
waiter.timer = void 0;
|
|
1265
|
+
this.#counts.delete(id);
|
|
1266
|
+
cleanup();
|
|
1267
|
+
resolve(result);
|
|
1268
|
+
},
|
|
1269
|
+
reject: (err) => {
|
|
1270
|
+
if (waiter.timer !== void 0) clearTimeout(waiter.timer);
|
|
1271
|
+
waiter.timer = void 0;
|
|
1272
|
+
this.#counts.delete(id);
|
|
1273
|
+
cleanup();
|
|
1274
|
+
reject(err);
|
|
1275
|
+
},
|
|
1276
|
+
timer: void 0,
|
|
1277
|
+
filters: [...filters],
|
|
1278
|
+
authRetried: false,
|
|
1279
|
+
timeoutMs
|
|
1280
|
+
};
|
|
1281
|
+
const onAbort = () => waiter.reject(new RelayConnectionError("count aborted", this.url));
|
|
1282
|
+
waiter.timer = setTimeout(() => {
|
|
1283
|
+
waiter.reject(new RelayPublishError("count timed out", this.url));
|
|
1284
|
+
}, timeoutMs);
|
|
1285
|
+
this.#counts.set(id, waiter);
|
|
1286
|
+
opts?.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1287
|
+
try {
|
|
1288
|
+
this.#send([
|
|
1289
|
+
"COUNT",
|
|
1290
|
+
id,
|
|
1291
|
+
...filters
|
|
1292
|
+
]);
|
|
1293
|
+
} catch (err) {
|
|
1294
|
+
waiter.reject(err instanceof Error ? err : new RelayPublishError("count failed", this.url));
|
|
1295
|
+
}
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* NIP-42 AUTH: sign the current challenge and wait for OK.
|
|
1300
|
+
*/
|
|
1301
|
+
async auth(sign, opts) {
|
|
1302
|
+
const challenge = this.#challenge;
|
|
1303
|
+
if (!challenge) throw new RelayError("no AUTH challenge received from relay", this.url);
|
|
1304
|
+
if (this.#authPromise) return this.#authPromise;
|
|
1305
|
+
const pending = (async () => {
|
|
1306
|
+
const event = await sign(makeAuthEvent(this.url, challenge));
|
|
1307
|
+
if (!this.#connected) throw new RelayClosedError("not connected", this.url);
|
|
1308
|
+
const timeoutMs = opts?.timeoutMs ?? this.#publishTimeoutMs;
|
|
1309
|
+
return await new Promise((resolve, reject) => {
|
|
1310
|
+
const timer = setTimeout(() => {
|
|
1311
|
+
this.#publishes.delete(event.id);
|
|
1312
|
+
reject(new RelayPublishError("auth timed out", this.url));
|
|
1313
|
+
}, timeoutMs);
|
|
1314
|
+
this.#publishes.set(event.id, {
|
|
1315
|
+
resolve,
|
|
1316
|
+
reject,
|
|
1317
|
+
timer,
|
|
1318
|
+
timeoutMs
|
|
1319
|
+
});
|
|
1320
|
+
try {
|
|
1321
|
+
this.#send(["AUTH", event]);
|
|
1322
|
+
} catch (err) {
|
|
1323
|
+
clearTimeout(timer);
|
|
1324
|
+
this.#publishes.delete(event.id);
|
|
1325
|
+
reject(err instanceof Error ? err : new RelayPublishError("auth send failed", this.url));
|
|
1326
|
+
}
|
|
1327
|
+
});
|
|
1328
|
+
})();
|
|
1329
|
+
this.#authPromise = pending;
|
|
1330
|
+
try {
|
|
1331
|
+
const result = await pending;
|
|
1332
|
+
if (result.ok && this.#challenge === challenge) this.#authedChallenge = challenge;
|
|
1333
|
+
return result;
|
|
1334
|
+
} finally {
|
|
1335
|
+
if (this.#authPromise === pending) this.#authPromise = void 0;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
async #ensureAuthed() {
|
|
1339
|
+
const signer = this.#authSigner;
|
|
1340
|
+
if (!signer) return false;
|
|
1341
|
+
for (let i = 0; i < 3; i++) {
|
|
1342
|
+
if (!this.#challenge) return false;
|
|
1343
|
+
if (this.#authedChallenge === this.#challenge) return true;
|
|
1344
|
+
const signed = this.#challenge;
|
|
1345
|
+
const result = await this.auth(signer);
|
|
1346
|
+
if (this.#authedChallenge === this.#challenge) return true;
|
|
1347
|
+
if (!result.ok) {
|
|
1348
|
+
if (!this.#challenge || this.#challenge === signed) return false;
|
|
1349
|
+
continue;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
return this.#authedChallenge === this.#challenge;
|
|
1353
|
+
}
|
|
1354
|
+
async #authThenResubscribe(sub, reason) {
|
|
1355
|
+
try {
|
|
1356
|
+
if (!await this.#ensureAuthed()) {
|
|
1357
|
+
dropSubscription(this.#live, sub, reason);
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
if (sub.closed || !this.#connected) {
|
|
1361
|
+
dropSubscription(this.#live, sub, reason);
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
sub.eosed = false;
|
|
1365
|
+
const group = this.#liveBySubId.get(sub.id);
|
|
1366
|
+
if (group) for (const att of group.attachments) att.eosed = false;
|
|
1367
|
+
this.#send([
|
|
1368
|
+
"REQ",
|
|
1369
|
+
sub.id,
|
|
1370
|
+
...sub.replayFilters()
|
|
1371
|
+
]);
|
|
1372
|
+
} catch {
|
|
1373
|
+
dropSubscription(this.#live, sub, reason);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
async #authThenRecount(id, waiter, reason) {
|
|
1377
|
+
if (waiter.timer !== void 0) {
|
|
1378
|
+
clearTimeout(waiter.timer);
|
|
1379
|
+
waiter.timer = void 0;
|
|
1380
|
+
}
|
|
1381
|
+
const fail = () => {
|
|
1382
|
+
waiter.reject(new RelayClosedError(reason || "COUNT closed", this.url));
|
|
1383
|
+
};
|
|
1384
|
+
try {
|
|
1385
|
+
const ok = await this.#ensureAuthed();
|
|
1386
|
+
if (this.#counts.get(id) !== waiter) return;
|
|
1387
|
+
if (!ok) {
|
|
1388
|
+
fail();
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
if (!this.#connected) {
|
|
1392
|
+
fail();
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
waiter.timer = setTimeout(() => {
|
|
1396
|
+
waiter.reject(new RelayPublishError("count timed out", this.url));
|
|
1397
|
+
}, waiter.timeoutMs);
|
|
1398
|
+
this.#send([
|
|
1399
|
+
"COUNT",
|
|
1400
|
+
id,
|
|
1401
|
+
...waiter.filters
|
|
1402
|
+
]);
|
|
1403
|
+
} catch (err) {
|
|
1404
|
+
if (!this.#counts.has(id)) return;
|
|
1405
|
+
waiter.reject(err instanceof Error ? err : new RelayClosedError(reason || "COUNT closed", this.url));
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
async #authThenRepublish(waiter, eventId, message) {
|
|
1409
|
+
if (waiter.timer !== void 0) {
|
|
1410
|
+
clearTimeout(waiter.timer);
|
|
1411
|
+
waiter.timer = void 0;
|
|
1412
|
+
}
|
|
1413
|
+
const finish = (result) => {
|
|
1414
|
+
if (waiter.timer !== void 0) clearTimeout(waiter.timer);
|
|
1415
|
+
this.#publishes.delete(eventId);
|
|
1416
|
+
waiter.resolve(result);
|
|
1417
|
+
};
|
|
1418
|
+
try {
|
|
1419
|
+
if (!waiter.event) {
|
|
1420
|
+
finish({
|
|
1421
|
+
ok: false,
|
|
1422
|
+
message
|
|
1423
|
+
});
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
const ok = await this.#ensureAuthed();
|
|
1427
|
+
if (this.#publishes.get(eventId) !== waiter) return;
|
|
1428
|
+
if (!ok) {
|
|
1429
|
+
finish({
|
|
1430
|
+
ok: false,
|
|
1431
|
+
message
|
|
1432
|
+
});
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
if (!this.#connected) {
|
|
1436
|
+
finish({
|
|
1437
|
+
ok: false,
|
|
1438
|
+
message
|
|
1439
|
+
});
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
waiter.timer = setTimeout(() => {
|
|
1443
|
+
this.#publishes.delete(eventId);
|
|
1444
|
+
waiter.reject(new RelayPublishError("publish timed out", this.url));
|
|
1445
|
+
}, waiter.timeoutMs);
|
|
1446
|
+
this.#send(["EVENT", waiter.event]);
|
|
1447
|
+
} catch {
|
|
1448
|
+
if (!this.#publishes.has(eventId)) return;
|
|
1449
|
+
finish({
|
|
1450
|
+
ok: false,
|
|
1451
|
+
message
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* NIP-77: run Negentropy reconciliation against this relay.
|
|
1457
|
+
* Returns the local-only (`have`) and remote-only (`need`) event ids.
|
|
1458
|
+
* Does not upload or download events.
|
|
1459
|
+
*
|
|
1460
|
+
* `timeoutMs` is a single wall-clock deadline for the whole session
|
|
1461
|
+
* (`NEG-OPEN` through the last `NEG-MSG`), not a per-message budget.
|
|
1462
|
+
* Default: {@link RelayOptions.publishTimeoutMs}.
|
|
1463
|
+
*/
|
|
1464
|
+
async negReconcile(filter, storage, opts) {
|
|
1465
|
+
if (!this.#connected) throw new RelayClosedError("not connected", this.url);
|
|
1466
|
+
if (opts?.signal?.aborted) throw new RelayConnectionError("negentropy aborted", this.url);
|
|
1467
|
+
filter = canonicalizeFilter(filter);
|
|
1468
|
+
const id = opts?.id !== void 0 ? assertSubscriptionId(opts.id) : this.nextSubId("neg");
|
|
1469
|
+
const timeoutMs = opts?.timeoutMs ?? this.#publishTimeoutMs;
|
|
1470
|
+
const prev = this.#neg.get(id);
|
|
1471
|
+
if (prev) failNegSession(prev, new Nip77Error("closed: replaced by new NEG-OPEN"));
|
|
1472
|
+
const session = createNegSession();
|
|
1473
|
+
this.#neg.set(id, session);
|
|
1474
|
+
try {
|
|
1475
|
+
return await runWiredNegSession({
|
|
1476
|
+
session,
|
|
1477
|
+
storage,
|
|
1478
|
+
filter,
|
|
1479
|
+
id,
|
|
1480
|
+
timeoutMs,
|
|
1481
|
+
signal: opts?.signal,
|
|
1482
|
+
send: (message) => this.#send(message),
|
|
1483
|
+
url: this.url
|
|
1484
|
+
});
|
|
1485
|
+
} finally {
|
|
1486
|
+
if (this.#neg.get(id) === session) {
|
|
1487
|
+
this.#neg.delete(id);
|
|
1488
|
+
try {
|
|
1489
|
+
this.#send(["NEG-CLOSE", id]);
|
|
1490
|
+
} catch {}
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
/** Generate a unique subscription id for this relay instance. */
|
|
1495
|
+
nextSubId(prefix = "sub") {
|
|
1496
|
+
this.#serial += 1;
|
|
1497
|
+
return `${prefix}:${this.#serial}`;
|
|
1498
|
+
}
|
|
1499
|
+
};
|
|
1500
|
+
//#endregion
|
|
1501
|
+
//#region src/relay/fan-in.ts
|
|
1502
|
+
/**
|
|
1503
|
+
* Aggregate EOSE/close across routed jobs. One `seen` set.
|
|
1504
|
+
* `pending` counts URL list entries (duplicates included).
|
|
1505
|
+
* `pendingEose` counts unique `jobIndex:url` keys.
|
|
1506
|
+
*/
|
|
1507
|
+
function fanIn(pool, jobs, opts = {}) {
|
|
1508
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1509
|
+
const closers = [];
|
|
1510
|
+
let closed = false;
|
|
1511
|
+
let eoseFired = false;
|
|
1512
|
+
let eoseTimer;
|
|
1513
|
+
const eoseDone = /* @__PURE__ */ new Set();
|
|
1514
|
+
const eoseAttempted = /* @__PURE__ */ new Set();
|
|
1515
|
+
let pendingEose = 0;
|
|
1516
|
+
let pending = 0;
|
|
1517
|
+
const fireEose = () => {
|
|
1518
|
+
if (closed || eoseFired) return;
|
|
1519
|
+
eoseFired = true;
|
|
1520
|
+
if (eoseTimer !== void 0) {
|
|
1521
|
+
clearTimeout(eoseTimer);
|
|
1522
|
+
eoseTimer = void 0;
|
|
1523
|
+
}
|
|
1524
|
+
opts.oneose?.();
|
|
1525
|
+
};
|
|
1526
|
+
const markEose = (jobIndex, url) => {
|
|
1527
|
+
const key = `${jobIndex}:${url}`;
|
|
1528
|
+
if (eoseDone.has(key)) return;
|
|
1529
|
+
eoseDone.add(key);
|
|
1530
|
+
pendingEose -= 1;
|
|
1531
|
+
if (pendingEose === 0) fireEose();
|
|
1532
|
+
};
|
|
1533
|
+
const settleClose = () => {
|
|
1534
|
+
closed = true;
|
|
1535
|
+
if (eoseTimer !== void 0) {
|
|
1536
|
+
clearTimeout(eoseTimer);
|
|
1537
|
+
eoseTimer = void 0;
|
|
1538
|
+
}
|
|
1539
|
+
};
|
|
1540
|
+
const closeAll = (reason) => {
|
|
1541
|
+
if (closed) return;
|
|
1542
|
+
settleClose();
|
|
1543
|
+
for (const c of closers) c.close(reason);
|
|
1544
|
+
opts.onclose?.(reason ?? "closed by client");
|
|
1545
|
+
};
|
|
1546
|
+
if (opts.signal?.aborted) {
|
|
1547
|
+
closed = true;
|
|
1548
|
+
opts.onclose?.("aborted");
|
|
1549
|
+
return { close: closeAll };
|
|
1550
|
+
}
|
|
1551
|
+
opts.signal?.addEventListener("abort", () => closeAll("aborted"), { once: true });
|
|
1552
|
+
const attach = (relay, job, jobIndex) => {
|
|
1553
|
+
if (closed) return;
|
|
1554
|
+
const sub = relay.subscribe([...job.filters], {
|
|
1555
|
+
id: jobs.length === 1 ? job.id : void 0,
|
|
1556
|
+
closeOnEose: opts.closeOnEose,
|
|
1557
|
+
alreadyHaveEvent: (id) => Boolean(opts.alreadyHaveEvent?.(id) || seen.has(id)),
|
|
1558
|
+
receivedEvent: opts.receivedEvent,
|
|
1559
|
+
onevent: (event) => {
|
|
1560
|
+
seen.add(event.id);
|
|
1561
|
+
opts.onevent?.(event);
|
|
1562
|
+
},
|
|
1563
|
+
oneose: () => markEose(jobIndex, relay.url),
|
|
1564
|
+
onclose: (reason) => {
|
|
1565
|
+
markEose(jobIndex, relay.url);
|
|
1566
|
+
pending -= 1;
|
|
1567
|
+
if (pending <= 0 && !closed) {
|
|
1568
|
+
settleClose();
|
|
1569
|
+
opts.onclose?.(reason);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
});
|
|
1573
|
+
closers.push(sub);
|
|
1574
|
+
};
|
|
1575
|
+
for (let jobIndex = 0; jobIndex < jobs.length; jobIndex++) {
|
|
1576
|
+
const job = jobs[jobIndex];
|
|
1577
|
+
for (const url of job.urls) {
|
|
1578
|
+
let key;
|
|
1579
|
+
try {
|
|
1580
|
+
key = normalizeURL(url);
|
|
1581
|
+
} catch {
|
|
1582
|
+
key = url;
|
|
1583
|
+
}
|
|
1584
|
+
pending += 1;
|
|
1585
|
+
const eoseKey = `${jobIndex}:${key}`;
|
|
1586
|
+
if (!eoseAttempted.has(eoseKey)) {
|
|
1587
|
+
eoseAttempted.add(eoseKey);
|
|
1588
|
+
pendingEose += 1;
|
|
1589
|
+
}
|
|
1590
|
+
const failUrl = () => {
|
|
1591
|
+
markEose(jobIndex, key);
|
|
1592
|
+
pending -= 1;
|
|
1593
|
+
if (pending <= 0 && closers.length === 0 && !closed) {
|
|
1594
|
+
settleClose();
|
|
1595
|
+
opts.onclose?.("all relays failed");
|
|
1596
|
+
}
|
|
1597
|
+
};
|
|
1598
|
+
const tryAttach = (relay) => {
|
|
1599
|
+
try {
|
|
1600
|
+
attach(relay, job, jobIndex);
|
|
1601
|
+
} catch (err) {
|
|
1602
|
+
if (err instanceof RelayClosedError) {
|
|
1603
|
+
failUrl();
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
throw err;
|
|
1607
|
+
}
|
|
1608
|
+
};
|
|
1609
|
+
pool.ensureRelay(url, {
|
|
1610
|
+
signal: opts.signal,
|
|
1611
|
+
timeoutMs: opts.connectTimeoutMs
|
|
1612
|
+
}).then((relay) => tryAttach(relay), () => {
|
|
1613
|
+
const relay = pool.getRelay(key);
|
|
1614
|
+
if (relay) {
|
|
1615
|
+
tryAttach(relay);
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
failUrl();
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
if (opts.eoseTimeoutMs !== void 0 && pending > 0) eoseTimer = setTimeout(() => {
|
|
1623
|
+
eoseTimer = void 0;
|
|
1624
|
+
fireEose();
|
|
1625
|
+
}, opts.eoseTimeoutMs);
|
|
1626
|
+
if (pending === 0) queueMicrotask(() => {
|
|
1627
|
+
if (closed) return;
|
|
1628
|
+
settleClose();
|
|
1629
|
+
opts.onclose?.("no relays");
|
|
1630
|
+
});
|
|
1631
|
+
return { close: closeAll };
|
|
1632
|
+
}
|
|
1633
|
+
async function fetchRouted(pool, jobs, opts = {}) {
|
|
1634
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1635
|
+
await Promise.all(jobs.flatMap((job) => job.urls.map(async (url) => {
|
|
1636
|
+
try {
|
|
1637
|
+
const batch = await (await pool.ensureRelay(url, {
|
|
1638
|
+
signal: opts.signal,
|
|
1639
|
+
timeoutMs: opts.connectTimeoutMs
|
|
1640
|
+
})).fetch([...job.filters], {
|
|
1641
|
+
timeoutMs: opts.timeoutMs,
|
|
1642
|
+
signal: opts.signal
|
|
1643
|
+
});
|
|
1644
|
+
for (const event of batch) if (!byId.has(event.id)) byId.set(event.id, event);
|
|
1645
|
+
} catch {}
|
|
1646
|
+
})));
|
|
1647
|
+
return [...byId.values()];
|
|
1648
|
+
}
|
|
1649
|
+
//#endregion
|
|
1650
|
+
//#region src/relay/pool.ts
|
|
1651
|
+
/**
|
|
1652
|
+
* Multi-relay coordinator: connection reuse, cross-relay event dedup, fan-out publish.
|
|
1653
|
+
*/
|
|
1654
|
+
var Pool = class {
|
|
1655
|
+
#relays = /* @__PURE__ */ new Map();
|
|
1656
|
+
#opts;
|
|
1657
|
+
#lastActivity = /* @__PURE__ */ new Map();
|
|
1658
|
+
#idleTimeoutMs;
|
|
1659
|
+
#idleTimer;
|
|
1660
|
+
#allowInsecure;
|
|
1661
|
+
#trustedInsecure;
|
|
1662
|
+
constructor(opts = {}) {
|
|
1663
|
+
this.#opts = opts;
|
|
1664
|
+
this.#allowInsecure = opts.allowInsecure ?? true;
|
|
1665
|
+
this.#trustedInsecure = new Set((opts.trustedInsecureUrls ?? []).map(normalizeURL));
|
|
1666
|
+
this.#idleTimeoutMs = opts.idleTimeoutMs ?? 0;
|
|
1667
|
+
if (this.#idleTimeoutMs > 0) this.#idleTimer = setInterval(() => this.cleanIdleRelays(), opts.idleCleanupIntervalMs ?? 3e4);
|
|
1668
|
+
}
|
|
1669
|
+
setAllowInsecure(allow) {
|
|
1670
|
+
this.#allowInsecure = allow;
|
|
1671
|
+
}
|
|
1672
|
+
setTrustedInsecureUrls(urls) {
|
|
1673
|
+
this.#trustedInsecure = new Set(urls.map(normalizeURL));
|
|
1674
|
+
}
|
|
1675
|
+
cleanIdleRelays() {
|
|
1676
|
+
if (this.#idleTimeoutMs <= 0) return;
|
|
1677
|
+
const now = Date.now();
|
|
1678
|
+
const idle = [];
|
|
1679
|
+
for (const [url, relay] of this.#relays) {
|
|
1680
|
+
if (relay.subscriptionCount > 0) continue;
|
|
1681
|
+
const last = this.#lastActivity.get(url) ?? 0;
|
|
1682
|
+
if (!relay.connected || now - last >= this.#idleTimeoutMs) idle.push(url);
|
|
1683
|
+
}
|
|
1684
|
+
for (const url of this.#lastActivity.keys()) if (!this.#relays.has(url)) this.#lastActivity.delete(url);
|
|
1685
|
+
if (idle.length === 0) return;
|
|
1686
|
+
this.close(idle);
|
|
1687
|
+
this.#opts.onIdleRelaysClosed?.(idle);
|
|
1688
|
+
}
|
|
1689
|
+
#touch(url) {
|
|
1690
|
+
this.#lastActivity.set(url, Date.now());
|
|
1691
|
+
}
|
|
1692
|
+
#rejectInsecure(url, norm) {
|
|
1693
|
+
if (this.#allowInsecure) return;
|
|
1694
|
+
if (!isInsecureRelayUrl(url)) return;
|
|
1695
|
+
if (this.#trustedInsecure.has(norm)) return;
|
|
1696
|
+
throw new RelayConnectionError("insecure relay connection blocked", norm);
|
|
1697
|
+
}
|
|
1698
|
+
#stopIdleCleanup() {
|
|
1699
|
+
if (this.#idleTimer === void 0) return;
|
|
1700
|
+
clearInterval(this.#idleTimer);
|
|
1701
|
+
this.#idleTimer = void 0;
|
|
1702
|
+
}
|
|
1703
|
+
async ensureRelay(url, opts) {
|
|
1704
|
+
const norm = normalizeURL(url);
|
|
1705
|
+
this.#rejectInsecure(url, norm);
|
|
1706
|
+
let relay = this.#relays.get(norm);
|
|
1707
|
+
if (!relay) {
|
|
1708
|
+
const signFn = this.#opts.automaticallyAuth?.(norm) ?? void 0;
|
|
1709
|
+
const created = new Relay(norm, {
|
|
1710
|
+
websocketImplementation: this.#opts.websocketImplementation,
|
|
1711
|
+
verifyEvent: this.#opts.verifyEvent,
|
|
1712
|
+
publishTimeoutMs: this.#opts.publishTimeoutMs,
|
|
1713
|
+
connectTimeoutMs: this.#opts.connectTimeoutMs,
|
|
1714
|
+
enableReconnect: this.#opts.enableReconnect,
|
|
1715
|
+
reconnectBackoffMs: this.#opts.reconnectBackoffMs,
|
|
1716
|
+
enablePing: this.#opts.enablePing,
|
|
1717
|
+
pingIntervalMs: this.#opts.pingIntervalMs,
|
|
1718
|
+
pingTimeoutMs: this.#opts.pingTimeoutMs,
|
|
1719
|
+
authSigner: signFn
|
|
1720
|
+
});
|
|
1721
|
+
created.onclose = () => {
|
|
1722
|
+
this.#relays.delete(norm);
|
|
1723
|
+
this.#lastActivity.delete(norm);
|
|
1724
|
+
};
|
|
1725
|
+
if (signFn) created.onauth = () => {
|
|
1726
|
+
created.auth(signFn).catch(() => {});
|
|
1727
|
+
};
|
|
1728
|
+
this.#relays.set(norm, created);
|
|
1729
|
+
relay = created;
|
|
1730
|
+
}
|
|
1731
|
+
this.#touch(norm);
|
|
1732
|
+
if (!relay.connected) try {
|
|
1733
|
+
await relay.connect({
|
|
1734
|
+
signal: opts?.signal,
|
|
1735
|
+
timeoutMs: opts?.timeoutMs ?? this.#opts.connectTimeoutMs
|
|
1736
|
+
});
|
|
1737
|
+
} catch (err) {
|
|
1738
|
+
if (!this.#opts.enableReconnect) {
|
|
1739
|
+
this.#relays.delete(norm);
|
|
1740
|
+
this.#lastActivity.delete(norm);
|
|
1741
|
+
}
|
|
1742
|
+
throw err;
|
|
1743
|
+
}
|
|
1744
|
+
return relay;
|
|
1745
|
+
}
|
|
1746
|
+
close(urls) {
|
|
1747
|
+
if (!urls) {
|
|
1748
|
+
this.#stopIdleCleanup();
|
|
1749
|
+
for (const relay of this.#relays.values()) relay.close();
|
|
1750
|
+
this.#relays.clear();
|
|
1751
|
+
this.#lastActivity.clear();
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
for (const url of urls) {
|
|
1755
|
+
const norm = normalizeURL(url);
|
|
1756
|
+
this.#relays.get(norm)?.close();
|
|
1757
|
+
this.#relays.delete(norm);
|
|
1758
|
+
this.#lastActivity.delete(norm);
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* Subscribe across relays. Deduplicates by event id.
|
|
1763
|
+
* Returns a closer; callbacks receive every new event once.
|
|
1764
|
+
*/
|
|
1765
|
+
subscribe(relays, filters, opts = {}) {
|
|
1766
|
+
if (filters.length === 0) throw new MessageError("REQ requires at least one filter");
|
|
1767
|
+
if (opts.id !== void 0) assertSubscriptionId(opts.id);
|
|
1768
|
+
filters = canonicalizeFilters(filters);
|
|
1769
|
+
return fanIn(this, [{
|
|
1770
|
+
urls: relays,
|
|
1771
|
+
filters,
|
|
1772
|
+
id: opts.id
|
|
1773
|
+
}], {
|
|
1774
|
+
onevent: opts.onevent,
|
|
1775
|
+
oneose: opts.oneose,
|
|
1776
|
+
onclose: opts.onclose,
|
|
1777
|
+
signal: opts.signal,
|
|
1778
|
+
eoseTimeoutMs: opts.eoseTimeoutMs,
|
|
1779
|
+
alreadyHaveEvent: opts.alreadyHaveEvent,
|
|
1780
|
+
receivedEvent: opts.receivedEvent,
|
|
1781
|
+
closeOnEose: opts.closeOnEose,
|
|
1782
|
+
connectTimeoutMs: this.#opts.connectTimeoutMs
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
/** Fetch events until each connected relay EOSE or timeout; dedupe by id. */
|
|
1786
|
+
async fetch(relays, filters, opts) {
|
|
1787
|
+
if (filters.length === 0) throw new MessageError("REQ requires at least one filter");
|
|
1788
|
+
return fetchRouted(this, [{
|
|
1789
|
+
urls: relays,
|
|
1790
|
+
filters: canonicalizeFilters(filters)
|
|
1791
|
+
}], {
|
|
1792
|
+
timeoutMs: opts?.timeoutMs,
|
|
1793
|
+
signal: opts?.signal,
|
|
1794
|
+
connectTimeoutMs: this.#opts.connectTimeoutMs
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
/** Publish to all listed relays; returns per-relay outcomes. */
|
|
1798
|
+
async publish(relays, event, opts) {
|
|
1799
|
+
return await Promise.all(relays.map(async (url) => {
|
|
1800
|
+
try {
|
|
1801
|
+
const relay = await this.ensureRelay(url, { timeoutMs: this.#opts.connectTimeoutMs });
|
|
1802
|
+
this.#touch(relay.url);
|
|
1803
|
+
const result = await relay.publish(event, { timeoutMs: opts?.timeoutMs });
|
|
1804
|
+
return {
|
|
1805
|
+
url: relay.url,
|
|
1806
|
+
result
|
|
1807
|
+
};
|
|
1808
|
+
} catch (err) {
|
|
1809
|
+
return {
|
|
1810
|
+
url,
|
|
1811
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
}));
|
|
1815
|
+
}
|
|
1816
|
+
/** First successful publish (Promise.any semantics). */
|
|
1817
|
+
async publishAny(relays, event, opts) {
|
|
1818
|
+
return Promise.any(relays.map(async (url) => {
|
|
1819
|
+
const relay = await this.ensureRelay(url, { timeoutMs: this.#opts.connectTimeoutMs });
|
|
1820
|
+
this.#touch(relay.url);
|
|
1821
|
+
const result = await relay.publish(event, { timeoutMs: opts?.timeoutMs });
|
|
1822
|
+
if (!result.ok) throw new RelayPublishError(result.message || "rejected", relay.url);
|
|
1823
|
+
return {
|
|
1824
|
+
url: relay.url,
|
|
1825
|
+
result
|
|
1826
|
+
};
|
|
1827
|
+
}));
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* NIP-45 COUNT across relays. Per-relay outcomes; failures do not throw.
|
|
1831
|
+
* Counts are not summed — each relay reports independently (may overlap).
|
|
1832
|
+
*/
|
|
1833
|
+
async count(relays, filters, opts) {
|
|
1834
|
+
filters = canonicalizeFilters(filters);
|
|
1835
|
+
return await Promise.all(relays.map(async (url) => {
|
|
1836
|
+
try {
|
|
1837
|
+
const relay = await this.ensureRelay(url, {
|
|
1838
|
+
signal: opts?.signal,
|
|
1839
|
+
timeoutMs: this.#opts.connectTimeoutMs
|
|
1840
|
+
});
|
|
1841
|
+
this.#touch(relay.url);
|
|
1842
|
+
const payload = await relay.count(filters, {
|
|
1843
|
+
timeoutMs: opts?.timeoutMs,
|
|
1844
|
+
signal: opts?.signal
|
|
1845
|
+
});
|
|
1846
|
+
return {
|
|
1847
|
+
url: relay.url,
|
|
1848
|
+
count: payload.count,
|
|
1849
|
+
approximate: payload.approximate,
|
|
1850
|
+
hll: payload.hll
|
|
1851
|
+
};
|
|
1852
|
+
} catch (err) {
|
|
1853
|
+
return {
|
|
1854
|
+
url,
|
|
1855
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
}));
|
|
1859
|
+
}
|
|
1860
|
+
listRelays() {
|
|
1861
|
+
return [...this.#relays.keys()];
|
|
1862
|
+
}
|
|
1863
|
+
/** Lookup a pooled relay, including disconnected reconnecting entries. */
|
|
1864
|
+
getRelay(url) {
|
|
1865
|
+
try {
|
|
1866
|
+
return this.#relays.get(normalizeURL(url));
|
|
1867
|
+
} catch {
|
|
1868
|
+
return this.#relays.get(url);
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
/** Currently connected URLs. Unlike listRelays(), excludes reconnecting/disconnected entries. */
|
|
1872
|
+
connectedUrls() {
|
|
1873
|
+
const urls = [];
|
|
1874
|
+
for (const [url, relay] of this.#relays) if (relay.connected) urls.push(url);
|
|
1875
|
+
return urls;
|
|
1876
|
+
}
|
|
1877
|
+
};
|
|
1878
|
+
//#endregion
|
|
1879
|
+
export { RelayStatus as a, isInsecureRelayUrl as c, RelayClosedError as d, RelayConnectionError as f, Relay as i, getWebSocketImplementation as l, RelayPublishError as m, fanIn as n, Subscription as o, RelayError as p, fetchRouted as r, subscriptionToAsyncIterable as s, Pool as t, useWebSocketImplementation as u };
|
|
1880
|
+
|
|
1881
|
+
//# sourceMappingURL=pool-C_j1OmBB.mjs.map
|