@qntx/nostr 0.6.0 → 0.8.0
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/dist/{abort-Ba8tDrOI.mjs → abort-DBq2STPj.mjs} +13 -6
- package/dist/abort-DBq2STPj.mjs.map +1 -0
- package/dist/{builder-sSflu6nB.d.mts → builder-CTwkB2zM.d.mts} +14 -15
- package/dist/{builder-9tjI6WQ3.mjs → builder-aNU2gGFP.mjs} +11 -17
- package/dist/builder-aNU2gGFP.mjs.map +1 -0
- package/dist/{client-CadO5fAX.mjs → client-Cj2lUaVh.mjs} +141 -353
- package/dist/client-Cj2lUaVh.mjs.map +1 -0
- package/dist/client.d.mts +2 -2
- package/dist/client.mjs +2 -2
- package/dist/collections-DtwMc1NU.mjs +33 -0
- package/dist/collections-DtwMc1NU.mjs.map +1 -0
- package/dist/core.d.mts +9 -8
- package/dist/core.mjs +7 -7
- package/dist/emitter-DkCY6NkU.mjs +32 -0
- package/dist/emitter-DkCY6NkU.mjs.map +1 -0
- package/dist/{error-BW7re1UN.d.mts → error-BD_0QFKT.d.mts} +2 -2
- package/dist/{error-DrEIM7FO.mjs → error-BILMe3Gr.mjs} +2 -2
- package/dist/{error-DrEIM7FO.mjs.map → error-BILMe3Gr.mjs.map} +1 -1
- package/dist/{error-CR8Urc84.d.mts → error-BquXDmIN.d.mts} +4 -4
- package/dist/{error-B__OMBRY.mjs → error-DlhGW4mJ.mjs} +2 -2
- package/dist/{error-B__OMBRY.mjs.map → error-DlhGW4mJ.mjs.map} +1 -1
- package/dist/{event-D8Nfoh4K.mjs → event-CHf47dPM.mjs} +16 -10
- package/dist/event-CHf47dPM.mjs.map +1 -0
- package/dist/{event-fU3Y01PR.d.mts → event-DJeb_DIL.d.mts} +4 -2
- package/dist/{filter-C0IMByE3.d.mts → filter-jI_9htF7.d.mts} +2 -2
- package/dist/{gossip-oloDMWEj.mjs → gossip-DFxIBlsr.mjs} +23 -33
- package/dist/gossip-DFxIBlsr.mjs.map +1 -0
- package/dist/{gossip-DpIvkBuj.d.mts → gossip-OOeeFzG5.d.mts} +37 -28
- package/dist/gossip.d.mts +1 -1
- package/dist/gossip.mjs +1 -1
- package/dist/{index-D1bqFHKB.d.mts → index-BxCBX-8R.d.mts} +18 -18
- package/dist/{index-lvXeER5t.d.mts → index-CPVEyzNX.d.mts} +32 -50
- package/dist/{index-MxC28S4P.d.mts → index-C_w2WfVz.d.mts} +27 -29
- package/dist/{index-CNl2q67H.d.mts → index-Cl_1XdYP.d.mts} +111 -180
- package/dist/{index-CvkvqB16.d.mts → index-DU9tRFTZ.d.mts} +7 -20
- package/dist/{index-q4MDce_r.d.mts → index-DyKwQnbt.d.mts} +6 -8
- package/dist/index.d.mts +23 -21
- package/dist/index.mjs +21 -19
- package/dist/{instance-DQE8BoWt.mjs → instance-C62w3cEt.mjs} +26 -27
- package/dist/instance-C62w3cEt.mjs.map +1 -0
- package/dist/{instance-CIca-ynn.d.mts → instance-DjsL8h84.d.mts} +5 -5
- package/dist/{key-Cv4_pN3x.d.mts → key-D_5OgGT7.d.mts} +11 -7
- package/dist/{key-Bps-LqX7.mjs → key-DnVpchl6.mjs} +13 -9
- package/dist/key-DnVpchl6.mjs.map +1 -0
- package/dist/{loaders-o6YXB_Ri.mjs → loaders-BmPkQfzI.mjs} +73 -82
- package/dist/loaders-BmPkQfzI.mjs.map +1 -0
- package/dist/loaders.d.mts +3 -3
- package/dist/loaders.mjs +2 -2
- package/dist/{memory-C2EVx0_O.mjs → memory-CdJ07kub.mjs} +2 -2
- package/dist/memory-CdJ07kub.mjs.map +1 -0
- package/dist/{memory-index-Do1mz0WF.mjs → memory-index-Cbc83vOZ.mjs} +30 -247
- package/dist/memory-index-Cbc83vOZ.mjs.map +1 -0
- package/dist/message-oNr7t0SJ.mjs +194 -0
- package/dist/message-oNr7t0SJ.mjs.map +1 -0
- package/dist/{message-iGPIqnn0.d.mts → message-tclYCVNc.d.mts} +6 -6
- package/dist/{nip17-CKIufV98.d.mts → nip17-B5WgW4C4.d.mts} +6 -7
- package/dist/{nip19-BevCJZsp.d.mts → nip19-DpPN17bY.d.mts} +25 -31
- package/dist/{nip46-DDV4dC56.d.mts → nip46-BHaxC9TZ.d.mts} +19 -21
- package/dist/nip51-CR0Q8eym.d.mts +87 -0
- package/dist/{nip59-zQgdLcEr.d.mts → nip59-C-RWFQIv.d.mts} +11 -5
- package/dist/nip65-D1fbmhOg.d.mts +23 -0
- package/dist/{nip77-DH4RhS5r.d.mts → nip77-DzUwUxHq.d.mts} +8 -9
- package/dist/nips/blossom.d.mts +17 -13
- package/dist/nips/blossom.mjs +30 -9
- package/dist/nips/blossom.mjs.map +1 -1
- package/dist/nips/nip04.mjs +17 -7
- package/dist/nips/nip04.mjs.map +1 -1
- package/dist/nips/nip05.d.mts +9 -10
- package/dist/nips/nip05.mjs +1 -4
- package/dist/nips/nip05.mjs.map +1 -1
- package/dist/nips/nip10.d.mts +8 -8
- package/dist/nips/nip10.mjs +34 -32
- package/dist/nips/nip10.mjs.map +1 -1
- package/dist/nips/nip11.d.mts +28 -28
- package/dist/nips/nip11.mjs +4 -4
- package/dist/nips/nip11.mjs.map +1 -1
- package/dist/nips/nip13.d.mts +7 -7
- package/dist/nips/nip13.mjs +32 -10
- package/dist/nips/nip13.mjs.map +1 -1
- package/dist/nips/nip17.d.mts +1 -1
- package/dist/nips/nip17.mjs +16 -34
- package/dist/nips/nip17.mjs.map +1 -1
- package/dist/nips/nip19.d.mts +2 -2
- package/dist/nips/nip19.mjs +16 -28
- package/dist/nips/nip19.mjs.map +1 -1
- package/dist/nips/nip21.d.mts +9 -4
- package/dist/nips/nip21.mjs +15 -5
- package/dist/nips/nip21.mjs.map +1 -1
- package/dist/nips/nip27.d.mts +22 -22
- package/dist/nips/nip27.mjs +2 -2
- package/dist/nips/nip27.mjs.map +1 -1
- package/dist/nips/nip42.d.mts +1 -1
- package/dist/nips/nip42.mjs +2 -1
- package/dist/nips/nip42.mjs.map +1 -1
- package/dist/nips/nip44.mjs +2 -2
- package/dist/nips/nip44.mjs.map +1 -1
- package/dist/nips/nip46.d.mts +2 -2
- package/dist/nips/nip46.mjs +15 -19
- package/dist/nips/nip46.mjs.map +1 -1
- package/dist/nips/nip49.d.mts +9 -9
- package/dist/nips/nip49.mjs +4 -4
- package/dist/nips/nip49.mjs.map +1 -1
- package/dist/nips/nip51.d.mts +2 -86
- package/dist/nips/nip51.mjs +7 -23
- package/dist/nips/nip51.mjs.map +1 -1
- package/dist/nips/nip57.d.mts +25 -23
- package/dist/nips/nip57.mjs +12 -17
- package/dist/nips/nip57.mjs.map +1 -1
- package/dist/nips/nip59.d.mts +1 -1
- package/dist/nips/nip59.mjs +27 -19
- package/dist/nips/nip59.mjs.map +1 -1
- package/dist/nips/nip65.d.mts +2 -2
- package/dist/nips/nip65.mjs +14 -37
- package/dist/nips/nip65.mjs.map +1 -1
- package/dist/nips/nip77.d.mts +1 -1
- package/dist/nips/nip77.mjs +33 -32
- package/dist/nips/nip77.mjs.map +1 -1
- package/dist/nips/nip98.d.mts +5 -5
- package/dist/nips/nip98.mjs +8 -8
- package/dist/nips/nip98.mjs.map +1 -1
- package/dist/{pool-DBIYMtwy.d.mts → pool-CqQvUnsq.d.mts} +132 -108
- package/dist/pool-vlw74C4t.mjs +606 -0
- package/dist/pool-vlw74C4t.mjs.map +1 -0
- package/dist/put-w13ci3sC.mjs +187 -0
- package/dist/put-w13ci3sC.mjs.map +1 -0
- package/dist/{reactive-BfhPp9a3.mjs → reactive-Tz0otDIP.mjs} +125 -88
- package/dist/reactive-Tz0otDIP.mjs.map +1 -0
- package/dist/{reactive-7aqeip4A.d.mts → reactive-gGqUrzW8.d.mts} +20 -26
- package/dist/relay-quCs7MHD.mjs +1811 -0
- package/dist/relay-quCs7MHD.mjs.map +1 -0
- package/dist/relay.d.mts +4 -4
- package/dist/relay.mjs +3 -2
- package/dist/serial-Bzpw-xz3.mjs +21 -0
- package/dist/serial-Bzpw-xz3.mjs.map +1 -0
- package/dist/{signer-CYQutDzD.mjs → signer-poTGe6Er.mjs} +12 -14
- package/dist/signer-poTGe6Er.mjs.map +1 -0
- package/dist/signer.d.mts +2 -2
- package/dist/signer.mjs +2 -2
- package/dist/{storage-Dky3wuoH.mjs → storage-B5IXTVuS.mjs} +28 -51
- package/dist/storage-B5IXTVuS.mjs.map +1 -0
- package/dist/storage.d.mts +3 -3
- package/dist/storage.mjs +4 -4
- package/dist/store.d.mts +2 -2
- package/dist/store.mjs +1 -1
- package/dist/testing.d.mts +19 -19
- package/dist/testing.mjs +9 -9
- package/dist/testing.mjs.map +1 -1
- package/dist/{types-2pCUprRU.d.mts → types-BacwnDtU.d.mts} +10 -10
- package/dist/{types-CRQEk0Oi.d.mts → types-DNmiscfd.d.mts} +2 -2
- package/dist/{util-LfQirgDk.mjs → util-DMF9AIO0.mjs} +33 -7
- package/dist/util-DMF9AIO0.mjs.map +1 -0
- package/dist/{verifier-t5WoWbtY.mjs → verifier-B51Wkt5H.mjs} +3 -3
- package/dist/verifier-B51Wkt5H.mjs.map +1 -0
- package/dist/verifier-CgTFpgsC.d.mts +22 -0
- package/dist/wasm.browser.d.mts +3 -3
- package/dist/wasm.browser.mjs +3 -3
- package/dist/wasm.d.mts +3 -3
- package/dist/wasm.mjs +3 -3
- package/dist/wasm.mjs.map +1 -1
- package/package.json +5 -9
- package/dist/abort-Ba8tDrOI.mjs.map +0 -1
- package/dist/builder-9tjI6WQ3.mjs.map +0 -1
- package/dist/client-CadO5fAX.mjs.map +0 -1
- package/dist/event-D8Nfoh4K.mjs.map +0 -1
- package/dist/gossip-oloDMWEj.mjs.map +0 -1
- package/dist/instance-DQE8BoWt.mjs.map +0 -1
- package/dist/key-Bps-LqX7.mjs.map +0 -1
- package/dist/loaders-o6YXB_Ri.mjs.map +0 -1
- package/dist/memory-C2EVx0_O.mjs.map +0 -1
- package/dist/memory-index-Do1mz0WF.mjs.map +0 -1
- package/dist/message-DcY6Fi9o.mjs +0 -193
- package/dist/message-DcY6Fi9o.mjs.map +0 -1
- package/dist/nip65-CjgmdpPB.d.mts +0 -26
- package/dist/nips/nip96.d.mts +0 -40
- package/dist/nips/nip96.mjs +0 -118
- package/dist/nips/nip96.mjs.map +0 -1
- package/dist/pool-As20OGxB.mjs +0 -2166
- package/dist/pool-As20OGxB.mjs.map +0 -1
- package/dist/reactive-BfhPp9a3.mjs.map +0 -1
- package/dist/signer-CYQutDzD.mjs.map +0 -1
- package/dist/storage-Dky3wuoH.mjs.map +0 -1
- package/dist/util-LfQirgDk.mjs.map +0 -1
- package/dist/verifier-t5WoWbtY.mjs.map +0 -1
|
@@ -0,0 +1,1811 @@
|
|
|
1
|
+
import { _ as MessageError, b as WasmPoisonedError, u as normalizeURL, v as NostrError } from "./util-DMF9AIO0.mjs";
|
|
2
|
+
import { n as verifyEvent } from "./verifier-B51Wkt5H.mjs";
|
|
3
|
+
import { n as canonicalizeFilters, r as filterFingerprint, t as canonicalizeFilter } from "./filter-BQl3GlHt.mjs";
|
|
4
|
+
import { n as createSubscriptionId, r as encodeClientMessage, s as parseRelayMessage, t as assertSubscriptionId } from "./message-oNr7t0SJ.mjs";
|
|
5
|
+
import { t as NoSignerError } from "./error-DlhGW4mJ.mjs";
|
|
6
|
+
import { t as invokeSafely } from "./report-CHld4uWj.mjs";
|
|
7
|
+
import { isAuthRequired, makeAuthEvent } from "./nips/nip42.mjs";
|
|
8
|
+
import { i as throwIfAborted, n as onAbort, r as raceSignal, t as abortReason } from "./abort-DBq2STPj.mjs";
|
|
9
|
+
import { t as Emitter } from "./emitter-DkCY6NkU.mjs";
|
|
10
|
+
import { Nip77Error, runNegSession } from "./nips/nip77.mjs";
|
|
11
|
+
//#region src/relay/error.ts
|
|
12
|
+
/** Base class for relay errors; carries the relay URL when known. */
|
|
13
|
+
var RelayError = class extends NostrError {
|
|
14
|
+
name = "RelayError";
|
|
15
|
+
url;
|
|
16
|
+
constructor(message, url, options) {
|
|
17
|
+
super(url !== void 0 && url !== "" ? `${message} (${url})` : message, options);
|
|
18
|
+
this.url = url;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
/** A relay connection attempt or socket failed. */
|
|
22
|
+
var RelayConnectionError = class extends RelayError {
|
|
23
|
+
name = "RelayConnectionError";
|
|
24
|
+
};
|
|
25
|
+
/** A relay answered an EVENT with `ok: false`. */
|
|
26
|
+
var RelayPublishError = class extends RelayError {
|
|
27
|
+
name = "RelayPublishError";
|
|
28
|
+
};
|
|
29
|
+
/** A subscription was closed by the relay (`CLOSED`) or the socket dropped. */
|
|
30
|
+
var RelayClosedError = class extends RelayError {
|
|
31
|
+
name = "RelayClosedError";
|
|
32
|
+
};
|
|
33
|
+
/** A relay operation exceeded its deadline. */
|
|
34
|
+
var RelayTimeoutError = class extends RelayError {
|
|
35
|
+
name = "RelayTimeoutError";
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* A relay was suspended for delivering more events that fail id/signature verification than its
|
|
39
|
+
* {@link PoolOptions.invalidEventPolicy} allows. Carries `until`, the epoch-ms time when the relay
|
|
40
|
+
* may connect again.
|
|
41
|
+
*/
|
|
42
|
+
var RelaySuspendedError = class extends RelayError {
|
|
43
|
+
name = "RelaySuspendedError";
|
|
44
|
+
/** Epoch milliseconds when the suspension lifts. */
|
|
45
|
+
until;
|
|
46
|
+
constructor(url, until) {
|
|
47
|
+
super("relay suspended for delivering invalid events", url);
|
|
48
|
+
this.until = until;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/relay/websocket.ts
|
|
53
|
+
let impl;
|
|
54
|
+
try {
|
|
55
|
+
if (globalThis.WebSocket !== void 0) impl = globalThis.WebSocket;
|
|
56
|
+
} catch {}
|
|
57
|
+
/** Inject a WebSocket implementation (required on Node unless undici/global is present). */
|
|
58
|
+
function useWebSocketImplementation(websocketImplementation) {
|
|
59
|
+
impl = websocketImplementation;
|
|
60
|
+
}
|
|
61
|
+
function getWebSocketImplementation() {
|
|
62
|
+
if (!impl) throw new RelayConnectionError("No WebSocket implementation available; call useWebSocketImplementation() or run in a browser");
|
|
63
|
+
return impl;
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/relay/auth.ts
|
|
67
|
+
var RelayAuth = class {
|
|
68
|
+
#challenge;
|
|
69
|
+
#authedChallenge;
|
|
70
|
+
/** Challenge value already answered on this connection; duplicates are not re-signed. */
|
|
71
|
+
#answeredChallenge;
|
|
72
|
+
/** Settled OK verdict for `#answeredChallenge`; set only once the relay replies. */
|
|
73
|
+
#answeredResult;
|
|
74
|
+
#authPromise;
|
|
75
|
+
#url;
|
|
76
|
+
#isOpen;
|
|
77
|
+
#sendAuth;
|
|
78
|
+
#defaultTimeoutMs;
|
|
79
|
+
constructor(deps) {
|
|
80
|
+
this.#url = deps.url;
|
|
81
|
+
this.#isOpen = deps.isOpen;
|
|
82
|
+
this.#sendAuth = deps.sendAuth;
|
|
83
|
+
this.#defaultTimeoutMs = deps.defaultTimeoutMs;
|
|
84
|
+
}
|
|
85
|
+
/** Latest NIP-42 challenge, if any. */
|
|
86
|
+
get challenge() {
|
|
87
|
+
return this.#challenge;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A re-sent identical challenge keeps the in-flight/settled dedupe; only a new challenge value
|
|
91
|
+
* resets the answer state.
|
|
92
|
+
*/
|
|
93
|
+
handleChallenge(challenge) {
|
|
94
|
+
if (challenge !== this.#challenge) {
|
|
95
|
+
this.#authPromise = void 0;
|
|
96
|
+
this.#authedChallenge = void 0;
|
|
97
|
+
this.#answeredChallenge = void 0;
|
|
98
|
+
this.#answeredResult = void 0;
|
|
99
|
+
}
|
|
100
|
+
this.#challenge = challenge;
|
|
101
|
+
}
|
|
102
|
+
/** New connection: challenge and answer state belong to the old socket. */
|
|
103
|
+
resetConnection() {
|
|
104
|
+
this.#challenge = void 0;
|
|
105
|
+
this.#authPromise = void 0;
|
|
106
|
+
this.#authedChallenge = void 0;
|
|
107
|
+
this.#answeredChallenge = void 0;
|
|
108
|
+
this.#answeredResult = void 0;
|
|
109
|
+
}
|
|
110
|
+
/** Sign the current challenge and wait for OK. */
|
|
111
|
+
async auth(sign, opts) {
|
|
112
|
+
const challenge = this.#challenge;
|
|
113
|
+
if (challenge === void 0 || challenge === "") throw new RelayError("no AUTH challenge received from relay", this.#url);
|
|
114
|
+
if (this.#authPromise) return this.#authPromise;
|
|
115
|
+
if (this.#answeredChallenge === challenge && this.#answeredResult !== void 0) return this.#answeredResult;
|
|
116
|
+
const pending = (async () => {
|
|
117
|
+
const template = makeAuthEvent(this.#url, challenge);
|
|
118
|
+
let event;
|
|
119
|
+
try {
|
|
120
|
+
event = await sign(template);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error instanceof NoSignerError) return {
|
|
123
|
+
ok: false,
|
|
124
|
+
message: "auth: no signer"
|
|
125
|
+
};
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
if (!this.#isOpen()) throw new RelayClosedError("not connected", this.#url);
|
|
129
|
+
const result = await this.#sendAuth(event, opts?.timeoutMs ?? this.#defaultTimeoutMs);
|
|
130
|
+
if (this.#challenge === challenge) {
|
|
131
|
+
this.#answeredChallenge = challenge;
|
|
132
|
+
this.#answeredResult = result;
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
})();
|
|
136
|
+
this.#authPromise = pending;
|
|
137
|
+
try {
|
|
138
|
+
const result = await pending;
|
|
139
|
+
if (result.ok && this.#challenge === challenge) this.#authedChallenge = challenge;
|
|
140
|
+
return result;
|
|
141
|
+
} finally {
|
|
142
|
+
if (this.#authPromise === pending) this.#authPromise = void 0;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async ensureAuthed(signer) {
|
|
146
|
+
if (!signer) return false;
|
|
147
|
+
for (let i = 0; i < 3; i++) {
|
|
148
|
+
if (this.#challenge === void 0 || this.#challenge === "") return false;
|
|
149
|
+
if (this.#authedChallenge === this.#challenge) return true;
|
|
150
|
+
const signed = this.#challenge;
|
|
151
|
+
const result = await this.auth(signer);
|
|
152
|
+
if (this.#authedChallenge === this.#challenge) return true;
|
|
153
|
+
if (!result.ok) {
|
|
154
|
+
if (this.#challenge === void 0 || this.#challenge === "" || this.#challenge === signed) return false;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return this.#authedChallenge === this.#challenge;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Clear a cached AUTH rejection for the current challenge (a successful auth and in-flight
|
|
162
|
+
* answers are kept) and return the unanswered challenge to re-emit, if any.
|
|
163
|
+
*/
|
|
164
|
+
resetRejection() {
|
|
165
|
+
if (this.#answeredResult !== void 0 && !this.#answeredResult.ok) {
|
|
166
|
+
this.#answeredChallenge = void 0;
|
|
167
|
+
this.#answeredResult = void 0;
|
|
168
|
+
}
|
|
169
|
+
if (this.#challenge !== void 0 && this.#challenge !== this.#authedChallenge) return this.#challenge;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region src/relay/count-tracker.ts
|
|
174
|
+
/**
|
|
175
|
+
* COUNT request correlation: one waiter per request id with a timeout, abort cleanup and a re-send
|
|
176
|
+
* after AUTH.
|
|
177
|
+
*/
|
|
178
|
+
var CountTracker = class {
|
|
179
|
+
#entries = /* @__PURE__ */ new Map();
|
|
180
|
+
get size() {
|
|
181
|
+
return this.#entries.size;
|
|
182
|
+
}
|
|
183
|
+
get(id) {
|
|
184
|
+
return this.#entries.get(id);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Register a waiter, wire the abort listener, arm its timeout and call `send`. A send throw
|
|
188
|
+
* rejects (`sendError` when the throw is not an Error). resolve/reject unwind the timer, abort
|
|
189
|
+
* listener and map entry.
|
|
190
|
+
*/
|
|
191
|
+
async track(id, opts) {
|
|
192
|
+
return new Promise((resolve, reject) => {
|
|
193
|
+
const waiter = {
|
|
194
|
+
id,
|
|
195
|
+
resolve: (result) => {
|
|
196
|
+
this.#finish(waiter);
|
|
197
|
+
cleanup();
|
|
198
|
+
resolve(result);
|
|
199
|
+
},
|
|
200
|
+
reject: (err) => {
|
|
201
|
+
this.#finish(waiter);
|
|
202
|
+
cleanup();
|
|
203
|
+
reject(err);
|
|
204
|
+
},
|
|
205
|
+
timer: void 0,
|
|
206
|
+
filters: opts.filters,
|
|
207
|
+
authRetried: false,
|
|
208
|
+
timeoutMs: opts.timeoutMs,
|
|
209
|
+
timeoutError: opts.timeoutError
|
|
210
|
+
};
|
|
211
|
+
const cleanup = onAbort(opts.signal, () => {
|
|
212
|
+
if (opts.signal !== void 0) waiter.reject(abortReason(opts.signal));
|
|
213
|
+
});
|
|
214
|
+
waiter.timer = setTimeout(() => {
|
|
215
|
+
waiter.reject(opts.timeoutError());
|
|
216
|
+
}, opts.timeoutMs);
|
|
217
|
+
this.#entries.set(id, waiter);
|
|
218
|
+
try {
|
|
219
|
+
opts.send();
|
|
220
|
+
} catch (error) {
|
|
221
|
+
waiter.reject(error instanceof Error ? error : opts.sendError());
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
/** Stop a waiter's timeout without settling (AUTH retry window). */
|
|
226
|
+
clearTimer(waiter) {
|
|
227
|
+
if (waiter.timer !== void 0) {
|
|
228
|
+
clearTimeout(waiter.timer);
|
|
229
|
+
waiter.timer = void 0;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Re-arm the timeout for a re-sent COUNT (after an AUTH retry). */
|
|
233
|
+
restartTimer(id) {
|
|
234
|
+
const waiter = this.#entries.get(id);
|
|
235
|
+
if (waiter === void 0) return;
|
|
236
|
+
waiter.timer = setTimeout(() => {
|
|
237
|
+
waiter.reject(waiter.timeoutError());
|
|
238
|
+
}, waiter.timeoutMs);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Re-send a COUNT after the relay demanded AUTH: pause the timeout through the AUTH window,
|
|
242
|
+
* re-arm it and re-send on success, reject on failure.
|
|
243
|
+
*/
|
|
244
|
+
async authRetry(waiter, deps) {
|
|
245
|
+
this.clearTimer(waiter);
|
|
246
|
+
try {
|
|
247
|
+
const ok = await deps.ensureAuthed();
|
|
248
|
+
if (this.#entries.get(waiter.id) !== waiter) return;
|
|
249
|
+
if (!ok || !deps.isOpen()) {
|
|
250
|
+
waiter.reject(deps.failError());
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
this.restartTimer(waiter.id);
|
|
254
|
+
deps.send();
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (this.#entries.get(waiter.id) === void 0) return;
|
|
257
|
+
waiter.reject(error instanceof Error ? error : deps.failError());
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
rejectAll(err) {
|
|
261
|
+
for (const waiter of this.#entries.values()) {
|
|
262
|
+
this.clearTimer(waiter);
|
|
263
|
+
waiter.reject(err);
|
|
264
|
+
}
|
|
265
|
+
this.#entries.clear();
|
|
266
|
+
}
|
|
267
|
+
#finish(waiter) {
|
|
268
|
+
this.clearTimer(waiter);
|
|
269
|
+
this.#entries.delete(waiter.id);
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
//#endregion
|
|
273
|
+
//#region src/relay/subscription.ts
|
|
274
|
+
/**
|
|
275
|
+
* Internal REQ runtime: owns its lifecycle flags, watermark bookkeeping and handler dispatch.
|
|
276
|
+
* `onLocalClose` is supplied by the registry — it sends CLOSE / detaches from a live group — and is
|
|
277
|
+
* invoked by {@link close} only, never by {@link end}.
|
|
278
|
+
*/
|
|
279
|
+
var Subscription = class {
|
|
280
|
+
id;
|
|
281
|
+
filters;
|
|
282
|
+
closeOnEose;
|
|
283
|
+
#handlers;
|
|
284
|
+
#onLocalClose;
|
|
285
|
+
#abortDispose;
|
|
286
|
+
#eoseTimer;
|
|
287
|
+
#closed = false;
|
|
288
|
+
#eosed = false;
|
|
289
|
+
/** True after one CLOSED `auth-required:` retry. */
|
|
290
|
+
#authRetried = false;
|
|
291
|
+
/** Inclusive NIP-01 `since` watermark from verified EVENTs. */
|
|
292
|
+
#lastCreatedAt;
|
|
293
|
+
/** Event ids at `lastCreatedAt` (same-second reconnect dedup). Not all seen ids. */
|
|
294
|
+
#idsAtWatermark = /* @__PURE__ */ new Set();
|
|
295
|
+
constructor(filters, opts, onLocalClose) {
|
|
296
|
+
this.#onLocalClose = onLocalClose;
|
|
297
|
+
this.id = createSubscriptionId(opts.id);
|
|
298
|
+
this.filters = filters;
|
|
299
|
+
this.closeOnEose = opts.closeOnEose === true;
|
|
300
|
+
this.#handlers = {
|
|
301
|
+
onevent: opts.onevent,
|
|
302
|
+
oneose: opts.oneose,
|
|
303
|
+
onclose: opts.onclose,
|
|
304
|
+
alreadyHaveEvent: opts.alreadyHaveEvent,
|
|
305
|
+
receivedEvent: opts.receivedEvent
|
|
306
|
+
};
|
|
307
|
+
if (opts.eoseTimeoutMs !== void 0) this.#eoseTimer = setTimeout(() => {
|
|
308
|
+
this.#eoseTimer = void 0;
|
|
309
|
+
this.markEose();
|
|
310
|
+
}, opts.eoseTimeoutMs);
|
|
311
|
+
if (opts.signal) {
|
|
312
|
+
if (opts.signal.aborted) this.close("aborted");
|
|
313
|
+
else this.#abortDispose = onAbort(opts.signal, () => this.close("aborted"));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
get closed() {
|
|
317
|
+
return this.#closed;
|
|
318
|
+
}
|
|
319
|
+
get eosed() {
|
|
320
|
+
return this.#eosed;
|
|
321
|
+
}
|
|
322
|
+
get lastCreatedAt() {
|
|
323
|
+
return this.#lastCreatedAt;
|
|
324
|
+
}
|
|
325
|
+
get idsAtWatermark() {
|
|
326
|
+
return this.#idsAtWatermark;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Local close: idempotent; clears the EOSE timer and abort listener, detaches via `onLocalClose`
|
|
330
|
+
* (sends CLOSE / leaves the live group), then fires `onclose`.
|
|
331
|
+
*/
|
|
332
|
+
close(reason = "closed by client") {
|
|
333
|
+
if (this.#closed) return;
|
|
334
|
+
this.#closed = true;
|
|
335
|
+
this.#teardown();
|
|
336
|
+
this.#onLocalClose(this);
|
|
337
|
+
invokeSafely(() => this.#handlers.onclose?.(reason));
|
|
338
|
+
}
|
|
339
|
+
/** Remote/transport termination: like {@link close} but `onLocalClose` is not called. */
|
|
340
|
+
end(reason) {
|
|
341
|
+
if (this.#closed) return;
|
|
342
|
+
this.#closed = true;
|
|
343
|
+
this.#teardown();
|
|
344
|
+
invokeSafely(() => this.#handlers.onclose?.(reason));
|
|
345
|
+
}
|
|
346
|
+
#teardown() {
|
|
347
|
+
if (this.#eoseTimer !== void 0) {
|
|
348
|
+
clearTimeout(this.#eoseTimer);
|
|
349
|
+
this.#eoseTimer = void 0;
|
|
350
|
+
}
|
|
351
|
+
this.#abortDispose?.();
|
|
352
|
+
this.#abortDispose = void 0;
|
|
353
|
+
}
|
|
354
|
+
/** Mark EOSE: fires `oneose`, then closes when `closeOnEose`. No-op when closed or EOSE'd. */
|
|
355
|
+
markEose() {
|
|
356
|
+
if (this.#closed || this.#eosed) return;
|
|
357
|
+
this.#eosed = true;
|
|
358
|
+
if (this.#eoseTimer !== void 0) {
|
|
359
|
+
clearTimeout(this.#eoseTimer);
|
|
360
|
+
this.#eoseTimer = void 0;
|
|
361
|
+
}
|
|
362
|
+
invokeSafely(() => this.#handlers.oneose?.());
|
|
363
|
+
if (this.closeOnEose) this.close("eose");
|
|
364
|
+
}
|
|
365
|
+
/** Clear `eosed` for a replayed REQ (AUTH retry / reconnect). */
|
|
366
|
+
rearm() {
|
|
367
|
+
this.#eosed = false;
|
|
368
|
+
}
|
|
369
|
+
/** First `auth-required:` retry wins; later calls report false without side effects. */
|
|
370
|
+
beginAuthRetry() {
|
|
371
|
+
if (this.#authRetried) return false;
|
|
372
|
+
this.#authRetried = true;
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
resetAuthRetry() {
|
|
376
|
+
this.#authRetried = false;
|
|
377
|
+
}
|
|
378
|
+
/** `receivedEvent` for every EVENT id, before watermark/alreadyHaveEvent/verify. */
|
|
379
|
+
notifyReceived(id) {
|
|
380
|
+
invokeSafely(() => this.#handlers.receivedEvent?.(id));
|
|
381
|
+
}
|
|
382
|
+
alreadyHas(id) {
|
|
383
|
+
let have = false;
|
|
384
|
+
invokeSafely(() => {
|
|
385
|
+
have = Boolean(this.#handlers.alreadyHaveEvent?.(id));
|
|
386
|
+
});
|
|
387
|
+
return have;
|
|
388
|
+
}
|
|
389
|
+
isAtWatermark(id) {
|
|
390
|
+
return this.#idsAtWatermark.has(id);
|
|
391
|
+
}
|
|
392
|
+
/** Verified event: advance the watermark, then fire `onevent`. */
|
|
393
|
+
deliver(event) {
|
|
394
|
+
this.noteVerified(event);
|
|
395
|
+
invokeSafely(() => this.#handlers.onevent?.(event));
|
|
396
|
+
}
|
|
397
|
+
/** Advance the reconnect watermark after a verified EVENT. */
|
|
398
|
+
noteVerified(event) {
|
|
399
|
+
if (this.#lastCreatedAt === void 0 || event.created_at > this.#lastCreatedAt) {
|
|
400
|
+
this.#lastCreatedAt = event.created_at;
|
|
401
|
+
this.#idsAtWatermark.clear();
|
|
402
|
+
this.#idsAtWatermark.add(event.id);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (event.created_at === this.#lastCreatedAt) this.#idsAtWatermark.add(event.id);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Filters for re-REQ. Original `filters` stay unchanged. NIP-01 `since` is inclusive — never
|
|
409
|
+
* `lastCreatedAt + 1`.
|
|
410
|
+
*/
|
|
411
|
+
replayFilters() {
|
|
412
|
+
const since = this.#lastCreatedAt;
|
|
413
|
+
if (since === void 0) return [...this.filters];
|
|
414
|
+
return this.filters.map((f) => ({
|
|
415
|
+
...f,
|
|
416
|
+
since: f.since === void 0 ? since : Math.max(f.since, since)
|
|
417
|
+
}));
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
/** AsyncIterable wrapper over a subscription's events until EOSE or close. */
|
|
421
|
+
function subscriptionToAsyncIterable(start, opts) {
|
|
422
|
+
const queue = [];
|
|
423
|
+
let head = 0;
|
|
424
|
+
let done = false;
|
|
425
|
+
let error;
|
|
426
|
+
let wake;
|
|
427
|
+
let closer;
|
|
428
|
+
let localClose = false;
|
|
429
|
+
let disposeAbort;
|
|
430
|
+
const notify = () => {
|
|
431
|
+
wake?.();
|
|
432
|
+
wake = void 0;
|
|
433
|
+
};
|
|
434
|
+
const waitForWake = async () => new Promise((resolve) => {
|
|
435
|
+
wake = resolve;
|
|
436
|
+
});
|
|
437
|
+
const closeLocal = (reason) => {
|
|
438
|
+
localClose = true;
|
|
439
|
+
disposeAbort?.();
|
|
440
|
+
closer?.close(reason);
|
|
441
|
+
done = true;
|
|
442
|
+
notify();
|
|
443
|
+
};
|
|
444
|
+
closer = start({
|
|
445
|
+
onevent(event) {
|
|
446
|
+
queue.push(event);
|
|
447
|
+
notify();
|
|
448
|
+
},
|
|
449
|
+
oneose() {
|
|
450
|
+
if (opts?.includeEose === false) closeLocal("eose");
|
|
451
|
+
},
|
|
452
|
+
onclose(reason) {
|
|
453
|
+
if (opts?.signal?.aborted === true) localClose = true;
|
|
454
|
+
if (!localClose && reason) error = new RelayClosedError(reason);
|
|
455
|
+
done = true;
|
|
456
|
+
notify();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
if (opts?.signal) {
|
|
460
|
+
if (opts.signal.aborted) closeLocal("aborted");
|
|
461
|
+
else {
|
|
462
|
+
disposeAbort = onAbort(opts.signal, () => closeLocal("aborted"));
|
|
463
|
+
if (done) disposeAbort();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
close(reason) {
|
|
468
|
+
closeLocal(reason ?? "closed by client");
|
|
469
|
+
},
|
|
470
|
+
[Symbol.asyncIterator]() {
|
|
471
|
+
return {
|
|
472
|
+
async next() {
|
|
473
|
+
while (true) {
|
|
474
|
+
const value = head < queue.length ? queue[head] : void 0;
|
|
475
|
+
if (value !== void 0) {
|
|
476
|
+
head += 1;
|
|
477
|
+
if (head === queue.length) {
|
|
478
|
+
queue.length = 0;
|
|
479
|
+
head = 0;
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
value,
|
|
483
|
+
done: false
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
if (error !== void 0) throw error;
|
|
487
|
+
if (done) return {
|
|
488
|
+
value: void 0,
|
|
489
|
+
done: true
|
|
490
|
+
};
|
|
491
|
+
await waitForWake();
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
async return() {
|
|
495
|
+
closeLocal("iterator returned");
|
|
496
|
+
return {
|
|
497
|
+
value: void 0,
|
|
498
|
+
done: true
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
//#endregion
|
|
506
|
+
//#region src/relay/fetch.ts
|
|
507
|
+
function streamFilters(subscribe, filters, opts) {
|
|
508
|
+
return subscriptionToAsyncIterable((handlers) => subscribe(filters, {
|
|
509
|
+
...handlers,
|
|
510
|
+
id: opts?.id,
|
|
511
|
+
signal: opts?.signal
|
|
512
|
+
}), { signal: opts?.signal });
|
|
513
|
+
}
|
|
514
|
+
async function fetchFilters(subscribe, filters, opts) {
|
|
515
|
+
throwIfAborted(opts.signal);
|
|
516
|
+
const events = [];
|
|
517
|
+
const seen = /* @__PURE__ */ new Set();
|
|
518
|
+
let end = { type: "timeout" };
|
|
519
|
+
await new Promise((resolve, reject) => {
|
|
520
|
+
let settled = false;
|
|
521
|
+
const done = (err) => {
|
|
522
|
+
if (settled) return;
|
|
523
|
+
settled = true;
|
|
524
|
+
disposeAbort();
|
|
525
|
+
clearTimeout(timer);
|
|
526
|
+
let reason = "aborted";
|
|
527
|
+
if (err instanceof Error) reason = err.message;
|
|
528
|
+
else if (err === void 0) reason = "fetch complete";
|
|
529
|
+
sub.close(reason);
|
|
530
|
+
if (err === void 0) resolve();
|
|
531
|
+
else reject(err);
|
|
532
|
+
};
|
|
533
|
+
const disposeAbort = onAbort(opts.signal, () => {
|
|
534
|
+
if (opts.signal !== void 0) done(abortReason(opts.signal));
|
|
535
|
+
});
|
|
536
|
+
const timer = setTimeout(() => {
|
|
537
|
+
end = { type: "timeout" };
|
|
538
|
+
done();
|
|
539
|
+
}, opts.timeoutMs);
|
|
540
|
+
const sub = subscribe(filters, {
|
|
541
|
+
id: opts.id,
|
|
542
|
+
signal: opts.signal,
|
|
543
|
+
closeOnEose: true,
|
|
544
|
+
onevent(event) {
|
|
545
|
+
if (seen.has(event.id)) return;
|
|
546
|
+
seen.add(event.id);
|
|
547
|
+
events.push(event);
|
|
548
|
+
},
|
|
549
|
+
oneose() {
|
|
550
|
+
end = { type: "eose" };
|
|
551
|
+
done();
|
|
552
|
+
},
|
|
553
|
+
onclose(reason) {
|
|
554
|
+
if (settled) return;
|
|
555
|
+
if (opts.signal?.aborted === true) {
|
|
556
|
+
done(abortReason(opts.signal));
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
end = {
|
|
560
|
+
type: "closed",
|
|
561
|
+
reason
|
|
562
|
+
};
|
|
563
|
+
done();
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
return {
|
|
568
|
+
events,
|
|
569
|
+
end
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
//#endregion
|
|
573
|
+
//#region src/relay/neg-session.ts
|
|
574
|
+
function createNegSession() {
|
|
575
|
+
return {
|
|
576
|
+
queue: [],
|
|
577
|
+
waiter: void 0,
|
|
578
|
+
error: void 0
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
function pushNegMsg(session, hex) {
|
|
582
|
+
if (session.waiter) {
|
|
583
|
+
const { waiter } = session;
|
|
584
|
+
session.waiter = void 0;
|
|
585
|
+
waiter.resolve(hex);
|
|
586
|
+
} else session.queue.push(hex);
|
|
587
|
+
}
|
|
588
|
+
function failNegSession(session, err) {
|
|
589
|
+
session.error = err;
|
|
590
|
+
if (session.waiter) {
|
|
591
|
+
const { waiter } = session;
|
|
592
|
+
session.waiter = void 0;
|
|
593
|
+
waiter.reject(err);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
function failNegErr(session, reason) {
|
|
597
|
+
failNegSession(session, new Nip77Error(reason));
|
|
598
|
+
}
|
|
599
|
+
/** Route a NEG-MSG/NEG-ERR frame to its session; unknown ids are ignored. */
|
|
600
|
+
function dispatchNegMessage(sessions, msg) {
|
|
601
|
+
const session = sessions.get(msg[1]);
|
|
602
|
+
if (session === void 0) return;
|
|
603
|
+
if (msg[0] === "NEG-MSG") {
|
|
604
|
+
pushNegMsg(session, msg[2]);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
failNegErr(session, msg[2]);
|
|
608
|
+
}
|
|
609
|
+
/** Fail every tracked session (socket teardown). */
|
|
610
|
+
function failAllNegSessions(sessions, err) {
|
|
611
|
+
for (const session of sessions.values()) failNegSession(session, err);
|
|
612
|
+
sessions.clear();
|
|
613
|
+
}
|
|
614
|
+
/** Wire queue/timeout/abort around `runNegSession`. Not a second session class. */
|
|
615
|
+
async function runWiredNegSession(opts) {
|
|
616
|
+
const { session, storage, filter, id, timeoutMs, signal, send, url } = opts;
|
|
617
|
+
const deadline = Date.now() + timeoutMs;
|
|
618
|
+
const timedOut = () => new RelayTimeoutError("negentropy timed out", url);
|
|
619
|
+
const remainingMs = () => deadline - Date.now();
|
|
620
|
+
const next = async () => {
|
|
621
|
+
if (session.error !== void 0) throw session.error;
|
|
622
|
+
if (remainingMs() <= 0) throw timedOut();
|
|
623
|
+
const queued = session.queue.shift();
|
|
624
|
+
if (queued !== void 0) return queued;
|
|
625
|
+
return new Promise((resolve, reject) => {
|
|
626
|
+
const timer = setTimeout(() => {
|
|
627
|
+
session.waiter = void 0;
|
|
628
|
+
disposeAbort();
|
|
629
|
+
reject(timedOut());
|
|
630
|
+
}, remainingMs());
|
|
631
|
+
const fail = (err) => {
|
|
632
|
+
clearTimeout(timer);
|
|
633
|
+
session.waiter = void 0;
|
|
634
|
+
disposeAbort();
|
|
635
|
+
reject(err);
|
|
636
|
+
};
|
|
637
|
+
session.waiter = {
|
|
638
|
+
resolve: (hex) => {
|
|
639
|
+
clearTimeout(timer);
|
|
640
|
+
disposeAbort();
|
|
641
|
+
resolve(hex);
|
|
642
|
+
},
|
|
643
|
+
reject: (err) => fail(err)
|
|
644
|
+
};
|
|
645
|
+
const disposeAbort = onAbort(signal, () => {
|
|
646
|
+
if (signal) fail(abortReason(signal));
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
};
|
|
650
|
+
return runNegSession({
|
|
651
|
+
storage,
|
|
652
|
+
openingSend: (hex) => {
|
|
653
|
+
send([
|
|
654
|
+
"NEG-OPEN",
|
|
655
|
+
id,
|
|
656
|
+
filter,
|
|
657
|
+
hex
|
|
658
|
+
]);
|
|
659
|
+
},
|
|
660
|
+
msgSend: (hex) => {
|
|
661
|
+
send([
|
|
662
|
+
"NEG-MSG",
|
|
663
|
+
id,
|
|
664
|
+
hex
|
|
665
|
+
]);
|
|
666
|
+
},
|
|
667
|
+
next
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Drive a NIP-77 reconcile session against the session map: fail a same-id predecessor, register,
|
|
672
|
+
* wire queue/timeout/abort, and send NEG-CLOSE on exit.
|
|
673
|
+
*/
|
|
674
|
+
async function runTrackedNegSession(opts) {
|
|
675
|
+
const { sessions, id, send } = opts;
|
|
676
|
+
const prev = sessions.get(id);
|
|
677
|
+
if (prev) failNegSession(prev, new Nip77Error("closed: replaced by new NEG-OPEN"));
|
|
678
|
+
const session = createNegSession();
|
|
679
|
+
sessions.set(id, session);
|
|
680
|
+
try {
|
|
681
|
+
return await runWiredNegSession({
|
|
682
|
+
session,
|
|
683
|
+
storage: opts.storage,
|
|
684
|
+
filter: opts.filter,
|
|
685
|
+
id,
|
|
686
|
+
timeoutMs: opts.timeoutMs,
|
|
687
|
+
signal: opts.signal,
|
|
688
|
+
send,
|
|
689
|
+
url: opts.url
|
|
690
|
+
});
|
|
691
|
+
} finally {
|
|
692
|
+
if (sessions.get(id) === session) {
|
|
693
|
+
sessions.delete(id);
|
|
694
|
+
try {
|
|
695
|
+
send(["NEG-CLOSE", id]);
|
|
696
|
+
} catch {}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
//#endregion
|
|
701
|
+
//#region src/relay/ok-tracker.ts
|
|
702
|
+
var OkTracker = class {
|
|
703
|
+
#entries = /* @__PURE__ */ new Map();
|
|
704
|
+
get size() {
|
|
705
|
+
return this.#entries.size;
|
|
706
|
+
}
|
|
707
|
+
get(eventId) {
|
|
708
|
+
return this.#entries.get(eventId);
|
|
709
|
+
}
|
|
710
|
+
/** Joiner's view: only publish waiters (carrying an `event`) are joinable. */
|
|
711
|
+
inFlight(eventId) {
|
|
712
|
+
const waiter = this.#entries.get(eventId);
|
|
713
|
+
return waiter?.event === void 0 ? void 0 : waiter.promise;
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Register a waiter, arm its timeout and call `send`. A send throw removes the entry and rejects
|
|
717
|
+
* (`sendError` when the throw is not an Error). The timer only rejects while this waiter still
|
|
718
|
+
* owns the id.
|
|
719
|
+
*/
|
|
720
|
+
async track(eventId, opts) {
|
|
721
|
+
const promise = new Promise((resolve, reject) => {
|
|
722
|
+
const waiter = {
|
|
723
|
+
id: eventId,
|
|
724
|
+
resolve,
|
|
725
|
+
reject,
|
|
726
|
+
timer: setTimeout(() => {
|
|
727
|
+
if (this.#entries.get(eventId) === waiter) {
|
|
728
|
+
this.#entries.delete(eventId);
|
|
729
|
+
reject(opts.timeoutError());
|
|
730
|
+
}
|
|
731
|
+
}, opts.timeoutMs),
|
|
732
|
+
event: opts.event,
|
|
733
|
+
authRetried: false,
|
|
734
|
+
timeoutMs: opts.timeoutMs,
|
|
735
|
+
timeoutError: opts.timeoutError,
|
|
736
|
+
get promise() {
|
|
737
|
+
return promise;
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
this.#entries.set(eventId, waiter);
|
|
741
|
+
try {
|
|
742
|
+
opts.send();
|
|
743
|
+
} catch (error) {
|
|
744
|
+
this.#finish(waiter);
|
|
745
|
+
reject(error instanceof Error ? error : opts.sendError());
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
return promise;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* An OK reply arrived: `retry` is the caller's auth-required policy — a kept waiter is returned
|
|
752
|
+
* unsettled for the retry path; otherwise the entry is settled and undefined is returned.
|
|
753
|
+
*/
|
|
754
|
+
handleOk(eventId, ok, message, retry) {
|
|
755
|
+
const waiter = this.#entries.get(eventId);
|
|
756
|
+
if (waiter === void 0) return;
|
|
757
|
+
if (!retry(waiter)) {
|
|
758
|
+
this.settle(waiter, {
|
|
759
|
+
ok,
|
|
760
|
+
message
|
|
761
|
+
});
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
return waiter;
|
|
765
|
+
}
|
|
766
|
+
/** Settle a waiter: stop its timer, drop the entry it still owns, then resolve. */
|
|
767
|
+
settle(waiter, result) {
|
|
768
|
+
this.#finish(waiter);
|
|
769
|
+
waiter.resolve(result);
|
|
770
|
+
}
|
|
771
|
+
/** Stop a waiter's timeout without settling (AUTH retry window). */
|
|
772
|
+
clearTimer(waiter) {
|
|
773
|
+
if (waiter.timer !== void 0) {
|
|
774
|
+
clearTimeout(waiter.timer);
|
|
775
|
+
waiter.timer = void 0;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
/** Re-arm the timeout for a re-sent frame (after an AUTH retry). */
|
|
779
|
+
restartTimer(eventId) {
|
|
780
|
+
const waiter = this.#entries.get(eventId);
|
|
781
|
+
if (waiter === void 0) return;
|
|
782
|
+
waiter.timer = setTimeout(() => {
|
|
783
|
+
if (this.#entries.get(eventId) === waiter) {
|
|
784
|
+
this.#entries.delete(eventId);
|
|
785
|
+
waiter.reject(waiter.timeoutError());
|
|
786
|
+
}
|
|
787
|
+
}, waiter.timeoutMs);
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Re-send a tracked EVENT after the relay demanded AUTH: pause the timeout through the AUTH
|
|
791
|
+
* window, re-arm it and re-send on success, settle `{ok:false,message}` on failure. AUTH waiters
|
|
792
|
+
* (no `event`) settle immediately.
|
|
793
|
+
*/
|
|
794
|
+
async authRetry(waiter, message, deps) {
|
|
795
|
+
this.clearTimer(waiter);
|
|
796
|
+
const finish = (result) => {
|
|
797
|
+
this.settle(waiter, result);
|
|
798
|
+
};
|
|
799
|
+
try {
|
|
800
|
+
const { event } = waiter;
|
|
801
|
+
if (event === void 0) {
|
|
802
|
+
finish({
|
|
803
|
+
ok: false,
|
|
804
|
+
message
|
|
805
|
+
});
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
const ok = await deps.ensureAuthed();
|
|
809
|
+
if (this.#entries.get(waiter.id) !== waiter) return;
|
|
810
|
+
if (!ok || !deps.isOpen()) {
|
|
811
|
+
finish({
|
|
812
|
+
ok: false,
|
|
813
|
+
message
|
|
814
|
+
});
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
this.restartTimer(waiter.id);
|
|
818
|
+
deps.send(event);
|
|
819
|
+
} catch {
|
|
820
|
+
if (this.#entries.get(waiter.id) === void 0) return;
|
|
821
|
+
finish({
|
|
822
|
+
ok: false,
|
|
823
|
+
message
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
rejectAll(err) {
|
|
828
|
+
for (const waiter of this.#entries.values()) {
|
|
829
|
+
this.clearTimer(waiter);
|
|
830
|
+
waiter.reject(err);
|
|
831
|
+
}
|
|
832
|
+
this.#entries.clear();
|
|
833
|
+
}
|
|
834
|
+
#finish(waiter) {
|
|
835
|
+
this.clearTimer(waiter);
|
|
836
|
+
if (this.#entries.get(waiter.id) === waiter) this.#entries.delete(waiter.id);
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
const PING_DUMMY_FILTER = {
|
|
840
|
+
ids: ["a".repeat(64)],
|
|
841
|
+
limit: 0
|
|
842
|
+
};
|
|
843
|
+
/** Browser WebSocket and `ws` both use 1 for OPEN. */
|
|
844
|
+
const WS_OPEN = 1;
|
|
845
|
+
/** Node `ws` delivers `pong` on the EventEmitter, not via addEventListener. */
|
|
846
|
+
function canNativePing(ws) {
|
|
847
|
+
return typeof ws.ping === "function" && (typeof ws.once === "function" || typeof ws.on === "function");
|
|
848
|
+
}
|
|
849
|
+
/** REQ-dummy or native ping/pong keepalive. Does not import class Relay. */
|
|
850
|
+
var PingLoop = class {
|
|
851
|
+
#opts;
|
|
852
|
+
#timer;
|
|
853
|
+
#waiters = /* @__PURE__ */ new Map();
|
|
854
|
+
#gen = 0;
|
|
855
|
+
#nativePing;
|
|
856
|
+
constructor(opts) {
|
|
857
|
+
this.#opts = opts;
|
|
858
|
+
}
|
|
859
|
+
start() {
|
|
860
|
+
this.stop();
|
|
861
|
+
this.#timer = setInterval(() => {
|
|
862
|
+
this.#pingpong();
|
|
863
|
+
}, this.#opts.pingIntervalMs);
|
|
864
|
+
}
|
|
865
|
+
stop() {
|
|
866
|
+
if (this.#timer !== void 0) {
|
|
867
|
+
clearInterval(this.#timer);
|
|
868
|
+
this.#timer = void 0;
|
|
869
|
+
}
|
|
870
|
+
this.#abortCurrentPing();
|
|
871
|
+
}
|
|
872
|
+
hasWaiter(id) {
|
|
873
|
+
return this.#waiters.has(id);
|
|
874
|
+
}
|
|
875
|
+
finishDummyPing(id) {
|
|
876
|
+
const waiter = this.#waiters.get(id);
|
|
877
|
+
if (!waiter) return false;
|
|
878
|
+
this.#waiters.delete(id);
|
|
879
|
+
waiter.resolve(true);
|
|
880
|
+
try {
|
|
881
|
+
this.#opts.send(["CLOSE", id]);
|
|
882
|
+
} catch {}
|
|
883
|
+
return true;
|
|
884
|
+
}
|
|
885
|
+
#abortCurrentPing() {
|
|
886
|
+
this.#gen += 1;
|
|
887
|
+
const native = this.#nativePing;
|
|
888
|
+
this.#nativePing = void 0;
|
|
889
|
+
native?.abort();
|
|
890
|
+
for (const waiter of this.#waiters.values()) waiter.resolve(false);
|
|
891
|
+
this.#waiters.clear();
|
|
892
|
+
}
|
|
893
|
+
async #pingpong() {
|
|
894
|
+
const ws = this.#opts.getWs();
|
|
895
|
+
if (!ws || ws.readyState !== WS_OPEN) return;
|
|
896
|
+
if (this.#nativePing !== void 0 || this.#waiters.size > 0) return;
|
|
897
|
+
const gen = this.#gen;
|
|
898
|
+
const ping = canNativePing(ws) ? this.#waitForNativePing(ws) : this.#waitForDummyPing();
|
|
899
|
+
const ok = await new Promise((resolve) => {
|
|
900
|
+
let settled = false;
|
|
901
|
+
const done = (alive) => {
|
|
902
|
+
if (settled) return;
|
|
903
|
+
settled = true;
|
|
904
|
+
clearTimeout(timer);
|
|
905
|
+
resolve(alive);
|
|
906
|
+
};
|
|
907
|
+
const timer = setTimeout(() => {
|
|
908
|
+
if (this.#nativePing !== void 0 || this.#waiters.size > 0) done(false);
|
|
909
|
+
}, this.#opts.pingTimeoutMs);
|
|
910
|
+
(async () => {
|
|
911
|
+
done(await ping);
|
|
912
|
+
})();
|
|
913
|
+
});
|
|
914
|
+
if (gen !== this.#gen) return;
|
|
915
|
+
if (!ok) {
|
|
916
|
+
this.#abortCurrentPing();
|
|
917
|
+
this.#opts.closeWs();
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
async #waitForNativePing(ws) {
|
|
921
|
+
return new Promise((resolve) => {
|
|
922
|
+
let settled = false;
|
|
923
|
+
const finish = (alive) => {
|
|
924
|
+
if (settled) return;
|
|
925
|
+
settled = true;
|
|
926
|
+
this.#nativePing = void 0;
|
|
927
|
+
ws.off?.("pong", onPong);
|
|
928
|
+
resolve(alive);
|
|
929
|
+
};
|
|
930
|
+
const onPong = () => finish(true);
|
|
931
|
+
this.#nativePing = { abort: () => finish(false) };
|
|
932
|
+
if (typeof ws.on === "function" && typeof ws.off === "function") ws.on("pong", onPong);
|
|
933
|
+
else if (typeof ws.once === "function") ws.once("pong", onPong);
|
|
934
|
+
else ws.on?.("pong", onPong);
|
|
935
|
+
try {
|
|
936
|
+
if (typeof ws.ping === "function") ws.ping();
|
|
937
|
+
else finish(false);
|
|
938
|
+
} catch {
|
|
939
|
+
finish(false);
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
async #waitForDummyPing() {
|
|
944
|
+
return new Promise((resolve) => {
|
|
945
|
+
const id = this.#opts.nextSubId("__ping__");
|
|
946
|
+
this.#waiters.set(id, { resolve });
|
|
947
|
+
try {
|
|
948
|
+
this.#opts.send([
|
|
949
|
+
"REQ",
|
|
950
|
+
id,
|
|
951
|
+
PING_DUMMY_FILTER
|
|
952
|
+
]);
|
|
953
|
+
} catch {
|
|
954
|
+
this.#waiters.delete(id);
|
|
955
|
+
resolve(false);
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
//#endregion
|
|
961
|
+
//#region src/relay/subscription-registry.ts
|
|
962
|
+
/**
|
|
963
|
+
* Owns every open REQ of a relay: exclusive subscriptions keyed by id, and live coalescing groups
|
|
964
|
+
* (one wire REQ plus per-subscriber handles) keyed by fingerprint and wire id.
|
|
965
|
+
*/
|
|
966
|
+
var SubscriptionRegistry = class {
|
|
967
|
+
#subs = /* @__PURE__ */ new Map();
|
|
968
|
+
#liveByFp = /* @__PURE__ */ new Map();
|
|
969
|
+
#liveBySubId = /* @__PURE__ */ new Map();
|
|
970
|
+
#isOpen;
|
|
971
|
+
#enableReconnect;
|
|
972
|
+
#send;
|
|
973
|
+
#scheduleReconnect;
|
|
974
|
+
#acceptEvent;
|
|
975
|
+
constructor(deps) {
|
|
976
|
+
this.#isOpen = deps.isOpen;
|
|
977
|
+
this.#enableReconnect = deps.enableReconnect;
|
|
978
|
+
this.#send = deps.send;
|
|
979
|
+
this.#scheduleReconnect = deps.scheduleReconnect;
|
|
980
|
+
this.#acceptEvent = deps.acceptEvent;
|
|
981
|
+
}
|
|
982
|
+
/** User subscriptions only; dummy ping REQs are not counted. */
|
|
983
|
+
get size() {
|
|
984
|
+
return this.#subs.size;
|
|
985
|
+
}
|
|
986
|
+
get(subId) {
|
|
987
|
+
return this.#subs.get(subId);
|
|
988
|
+
}
|
|
989
|
+
/** Exclusive one-shot REQ when `closeOnEose`, live coalescing otherwise. */
|
|
990
|
+
open(filters, opts) {
|
|
991
|
+
if (opts.closeOnEose === true) return this.#openExclusive(filters, opts);
|
|
992
|
+
return this.#openLive(filters, opts);
|
|
993
|
+
}
|
|
994
|
+
handleEvent(subId, event) {
|
|
995
|
+
const sub = this.#subs.get(subId);
|
|
996
|
+
if (!sub || sub.closed) return;
|
|
997
|
+
const group = this.#liveBySubId.get(subId);
|
|
998
|
+
if (group) {
|
|
999
|
+
this.#deliverLiveEvent(group, event);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
sub.notifyReceived(event.id);
|
|
1003
|
+
if (sub.isAtWatermark(event.id)) return;
|
|
1004
|
+
if (sub.alreadyHas(event.id)) return;
|
|
1005
|
+
if (!this.#acceptEvent(event)) return;
|
|
1006
|
+
sub.deliver(event);
|
|
1007
|
+
}
|
|
1008
|
+
handleEose(subId) {
|
|
1009
|
+
const sub = this.#subs.get(subId);
|
|
1010
|
+
if (!sub || sub.closed || sub.eosed) return;
|
|
1011
|
+
const group = this.#liveBySubId.get(subId);
|
|
1012
|
+
if (group) {
|
|
1013
|
+
sub.markEose();
|
|
1014
|
+
this.#deliverLiveEose(group);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
sub.markEose();
|
|
1018
|
+
}
|
|
1019
|
+
/** Remote/transport termination of a REQ (CLOSED, auth-retry failure). */
|
|
1020
|
+
drop(sub, reason) {
|
|
1021
|
+
const group = this.#liveBySubId.get(sub.id);
|
|
1022
|
+
if (group) {
|
|
1023
|
+
this.#endLiveGroup(group, {
|
|
1024
|
+
sendClose: false,
|
|
1025
|
+
reason
|
|
1026
|
+
});
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
this.#subs.delete(sub.id);
|
|
1030
|
+
sub.end(reason);
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Re-REQ after the relay demanded AUTH: drop the subscription when auth fails or the
|
|
1034
|
+
* socket/subscription died during the AUTH window, replay the REQ on success.
|
|
1035
|
+
*/
|
|
1036
|
+
async authRetry(sub, reason, ensureAuthed) {
|
|
1037
|
+
try {
|
|
1038
|
+
if (!await ensureAuthed() || sub.closed || !this.#isOpen()) {
|
|
1039
|
+
this.drop(sub, reason);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
this.replay(sub);
|
|
1043
|
+
} catch {
|
|
1044
|
+
this.drop(sub, reason);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
/** Re-REQ after AUTH: rearm the wire and its handles, then send the replayed filters. */
|
|
1048
|
+
replay(sub) {
|
|
1049
|
+
sub.rearm();
|
|
1050
|
+
const group = this.#liveBySubId.get(sub.id);
|
|
1051
|
+
if (group) for (const att of group.attachments) att.rearm();
|
|
1052
|
+
this.#send([
|
|
1053
|
+
"REQ",
|
|
1054
|
+
sub.id,
|
|
1055
|
+
...sub.replayFilters()
|
|
1056
|
+
]);
|
|
1057
|
+
}
|
|
1058
|
+
/** Reconnect path: rearm every open REQ and re-send; false on send failure. */
|
|
1059
|
+
replayAll() {
|
|
1060
|
+
for (const sub of this.#subs.values()) {
|
|
1061
|
+
if (sub.closed) continue;
|
|
1062
|
+
sub.rearm();
|
|
1063
|
+
sub.resetAuthRetry();
|
|
1064
|
+
const group = this.#liveBySubId.get(sub.id);
|
|
1065
|
+
if (group) for (const att of group.attachments) {
|
|
1066
|
+
att.rearm();
|
|
1067
|
+
att.resetAuthRetry();
|
|
1068
|
+
}
|
|
1069
|
+
try {
|
|
1070
|
+
this.#send([
|
|
1071
|
+
"REQ",
|
|
1072
|
+
sub.id,
|
|
1073
|
+
...sub.replayFilters()
|
|
1074
|
+
]);
|
|
1075
|
+
} catch {
|
|
1076
|
+
return false;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return true;
|
|
1080
|
+
}
|
|
1081
|
+
closeAll(reason) {
|
|
1082
|
+
const groups = [...this.#liveBySubId.values()];
|
|
1083
|
+
for (const group of groups) invokeSafely(() => {
|
|
1084
|
+
this.#endLiveGroup(group, {
|
|
1085
|
+
sendClose: false,
|
|
1086
|
+
reason
|
|
1087
|
+
});
|
|
1088
|
+
});
|
|
1089
|
+
for (const sub of this.#subs.values()) sub.end(reason);
|
|
1090
|
+
this.#subs.clear();
|
|
1091
|
+
this.#liveByFp.clear();
|
|
1092
|
+
this.#liveBySubId.clear();
|
|
1093
|
+
}
|
|
1094
|
+
#openExclusive(filters, opts) {
|
|
1095
|
+
const sub = new Subscription(filters, opts, (closed) => {
|
|
1096
|
+
this.#subs.delete(closed.id);
|
|
1097
|
+
try {
|
|
1098
|
+
if (this.#isOpen()) this.#send(["CLOSE", closed.id]);
|
|
1099
|
+
} catch {}
|
|
1100
|
+
});
|
|
1101
|
+
this.#subs.set(sub.id, sub);
|
|
1102
|
+
if (this.#isOpen()) this.#send([
|
|
1103
|
+
"REQ",
|
|
1104
|
+
sub.id,
|
|
1105
|
+
...filters
|
|
1106
|
+
]);
|
|
1107
|
+
else if (this.#enableReconnect) this.#scheduleReconnect();
|
|
1108
|
+
return sub;
|
|
1109
|
+
}
|
|
1110
|
+
#openLive(filters, opts) {
|
|
1111
|
+
const fp = filterFingerprint(filters);
|
|
1112
|
+
let group = this.#liveByFp.get(fp);
|
|
1113
|
+
if (group?.sub.eosed === true) group = void 0;
|
|
1114
|
+
let created = false;
|
|
1115
|
+
if (!group) {
|
|
1116
|
+
created = true;
|
|
1117
|
+
const slot = { group: void 0 };
|
|
1118
|
+
const wire = new Subscription(filters, { id: opts.id }, () => {
|
|
1119
|
+
const g = slot.group;
|
|
1120
|
+
if (g) this.#endLiveGroup(g, {
|
|
1121
|
+
sendClose: true,
|
|
1122
|
+
reason: "closed by client"
|
|
1123
|
+
});
|
|
1124
|
+
});
|
|
1125
|
+
group = {
|
|
1126
|
+
fp,
|
|
1127
|
+
sub: wire,
|
|
1128
|
+
attachments: /* @__PURE__ */ new Set()
|
|
1129
|
+
};
|
|
1130
|
+
slot.group = group;
|
|
1131
|
+
this.#liveByFp.set(fp, group);
|
|
1132
|
+
this.#liveBySubId.set(wire.id, group);
|
|
1133
|
+
this.#subs.set(wire.id, wire);
|
|
1134
|
+
}
|
|
1135
|
+
const slot = { handle: void 0 };
|
|
1136
|
+
const attached = group;
|
|
1137
|
+
const handle = new Subscription(filters, {
|
|
1138
|
+
...opts,
|
|
1139
|
+
id: group.sub.id
|
|
1140
|
+
}, () => {
|
|
1141
|
+
const current = slot.handle;
|
|
1142
|
+
if (!current) return;
|
|
1143
|
+
this.#detachLive(attached, current);
|
|
1144
|
+
});
|
|
1145
|
+
slot.handle = handle;
|
|
1146
|
+
if (handle.closed) {
|
|
1147
|
+
if (created && group.attachments.size === 0) this.#forgetLiveGroup(group, "closed by client");
|
|
1148
|
+
return handle;
|
|
1149
|
+
}
|
|
1150
|
+
group.attachments.add(handle);
|
|
1151
|
+
if (created) {
|
|
1152
|
+
if (this.#isOpen()) this.#send([
|
|
1153
|
+
"REQ",
|
|
1154
|
+
group.sub.id,
|
|
1155
|
+
...filters
|
|
1156
|
+
]);
|
|
1157
|
+
else if (this.#enableReconnect) this.#scheduleReconnect();
|
|
1158
|
+
}
|
|
1159
|
+
return handle;
|
|
1160
|
+
}
|
|
1161
|
+
#forgetLiveGroup(group, reason) {
|
|
1162
|
+
if (this.#liveByFp.get(group.fp) === group) this.#liveByFp.delete(group.fp);
|
|
1163
|
+
this.#liveBySubId.delete(group.sub.id);
|
|
1164
|
+
this.#subs.delete(group.sub.id);
|
|
1165
|
+
group.sub.end(reason);
|
|
1166
|
+
}
|
|
1167
|
+
#detachLive(group, handle) {
|
|
1168
|
+
if (!group.attachments.delete(handle)) return;
|
|
1169
|
+
if (group.attachments.size > 0) return;
|
|
1170
|
+
this.#endLiveGroup(group, {
|
|
1171
|
+
sendClose: true,
|
|
1172
|
+
reason: "closed by client"
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
#endLiveGroup(group, opts) {
|
|
1176
|
+
const { id } = group.sub;
|
|
1177
|
+
const remaining = [...group.attachments];
|
|
1178
|
+
group.attachments.clear();
|
|
1179
|
+
this.#forgetLiveGroup(group, opts.reason);
|
|
1180
|
+
if (opts.sendClose) try {
|
|
1181
|
+
if (this.#isOpen()) this.#send(["CLOSE", id]);
|
|
1182
|
+
} catch {}
|
|
1183
|
+
for (const att of remaining) invokeSafely(() => {
|
|
1184
|
+
att.close(opts.reason);
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
#deliverLiveEvent(group, event) {
|
|
1188
|
+
const { sub } = group;
|
|
1189
|
+
const attachments = [...group.attachments];
|
|
1190
|
+
for (const att of attachments) att.notifyReceived(event.id);
|
|
1191
|
+
if (sub.isAtWatermark(event.id)) return;
|
|
1192
|
+
const recipients = [];
|
|
1193
|
+
for (const att of attachments) {
|
|
1194
|
+
if (att.closed) continue;
|
|
1195
|
+
if (!att.alreadyHas(event.id)) recipients.push(att);
|
|
1196
|
+
}
|
|
1197
|
+
if (recipients.length === 0) return;
|
|
1198
|
+
if (!this.#acceptEvent(event)) return;
|
|
1199
|
+
sub.noteVerified(event);
|
|
1200
|
+
for (const att of recipients) att.deliver(event);
|
|
1201
|
+
}
|
|
1202
|
+
#deliverLiveEose(group) {
|
|
1203
|
+
const attachments = [...group.attachments];
|
|
1204
|
+
for (const att of attachments) att.markEose();
|
|
1205
|
+
}
|
|
1206
|
+
};
|
|
1207
|
+
//#endregion
|
|
1208
|
+
//#region src/relay/relay.ts
|
|
1209
|
+
/**
|
|
1210
|
+
* Relay lifecycle states:
|
|
1211
|
+
*
|
|
1212
|
+
* - `initialized` — constructed; `connect()` not yet attempted.
|
|
1213
|
+
* - `connecting` — a `connect()` attempt is in flight (socket opening / resubscribing).
|
|
1214
|
+
* - `connected` — socket open; REQ/EVENT/COUNT flow.
|
|
1215
|
+
* - `disconnected` — socket down: a reconnect is scheduled when `enableReconnect` has live
|
|
1216
|
+
* subscriptions to restore, or `disconnect()` severed manually (REQ state kept).
|
|
1217
|
+
* - `closed` — terminal until the next `connect()`: `close()` or a socket death without a scheduled
|
|
1218
|
+
* reconnect.
|
|
1219
|
+
*/
|
|
1220
|
+
const RelayStatus = {
|
|
1221
|
+
Initialized: "initialized",
|
|
1222
|
+
Connecting: "connecting",
|
|
1223
|
+
Connected: "connected",
|
|
1224
|
+
Disconnected: "disconnected",
|
|
1225
|
+
Closed: "closed"
|
|
1226
|
+
};
|
|
1227
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 4400;
|
|
1228
|
+
const DEFAULT_BACKOFF = [
|
|
1229
|
+
1e3,
|
|
1230
|
+
2e3,
|
|
1231
|
+
5e3,
|
|
1232
|
+
1e4,
|
|
1233
|
+
2e4,
|
|
1234
|
+
3e4,
|
|
1235
|
+
6e4
|
|
1236
|
+
];
|
|
1237
|
+
const noop = () => {};
|
|
1238
|
+
/**
|
|
1239
|
+
* Single-relay NIP-01 client. Connection lifecycle, REQ/CLOSE, EVENT publish ACK, AUTH, COUNT,
|
|
1240
|
+
* NIP-77, optional reconnect.
|
|
1241
|
+
*/
|
|
1242
|
+
var Relay = class Relay {
|
|
1243
|
+
url;
|
|
1244
|
+
#ws;
|
|
1245
|
+
#gen = 0;
|
|
1246
|
+
#status = RelayStatus.Initialized;
|
|
1247
|
+
#connecting;
|
|
1248
|
+
#connectFinish;
|
|
1249
|
+
#connectTimer;
|
|
1250
|
+
#socketHandlers;
|
|
1251
|
+
#subscriptions;
|
|
1252
|
+
#ok = new OkTracker();
|
|
1253
|
+
#countRequests = new CountTracker();
|
|
1254
|
+
#authState;
|
|
1255
|
+
#ensureAuthed;
|
|
1256
|
+
#neg = /* @__PURE__ */ new Map();
|
|
1257
|
+
#WS;
|
|
1258
|
+
#verify;
|
|
1259
|
+
#verifyDead = false;
|
|
1260
|
+
#publishTimeoutMs;
|
|
1261
|
+
#connectTimeoutMs;
|
|
1262
|
+
#enableReconnect;
|
|
1263
|
+
#backoff;
|
|
1264
|
+
#serial = 0;
|
|
1265
|
+
#authSigner;
|
|
1266
|
+
/** `close()`/`disconnect()` was called; cleared by the next `connect()`. */
|
|
1267
|
+
#manualStop = false;
|
|
1268
|
+
#reconnectAttempts = 0;
|
|
1269
|
+
#reconnectTimer;
|
|
1270
|
+
#enablePing;
|
|
1271
|
+
#ping;
|
|
1272
|
+
#events = new Emitter();
|
|
1273
|
+
constructor(url, opts = {}) {
|
|
1274
|
+
this.url = normalizeURL(url);
|
|
1275
|
+
this.#WS = opts.websocketImplementation ?? getWebSocketImplementation();
|
|
1276
|
+
this.#verify = opts.verifyEvent ?? verifyEvent;
|
|
1277
|
+
this.#publishTimeoutMs = opts.publishTimeoutMs ?? 4400;
|
|
1278
|
+
this.#connectTimeoutMs = opts.connectTimeoutMs ?? 5e3;
|
|
1279
|
+
this.#enableReconnect = opts.enableReconnect ?? false;
|
|
1280
|
+
this.#backoff = opts.reconnectBackoffMs ?? DEFAULT_BACKOFF;
|
|
1281
|
+
this.#enablePing = opts.enablePing ?? false;
|
|
1282
|
+
this.#authSigner = opts.authSigner;
|
|
1283
|
+
this.#ping = new PingLoop({
|
|
1284
|
+
send: (message) => this.#send(message),
|
|
1285
|
+
nextSubId: (prefix) => this.nextSubId(prefix),
|
|
1286
|
+
getWs: () => this.#ws,
|
|
1287
|
+
closeWs: () => {
|
|
1288
|
+
const ws = this.#ws;
|
|
1289
|
+
if (ws && ws.readyState === this.#WS.OPEN) try {
|
|
1290
|
+
ws.close();
|
|
1291
|
+
} catch {}
|
|
1292
|
+
},
|
|
1293
|
+
pingIntervalMs: opts.pingIntervalMs ?? 29e3,
|
|
1294
|
+
pingTimeoutMs: opts.pingTimeoutMs ?? 2e4
|
|
1295
|
+
});
|
|
1296
|
+
this.#subscriptions = new SubscriptionRegistry({
|
|
1297
|
+
isOpen: () => this.#isOpen(),
|
|
1298
|
+
enableReconnect: this.#enableReconnect,
|
|
1299
|
+
send: (message) => this.#send(message),
|
|
1300
|
+
scheduleReconnect: () => this.#scheduleReconnect(),
|
|
1301
|
+
acceptEvent: (event) => this.#acceptEvent(event)
|
|
1302
|
+
});
|
|
1303
|
+
this.#authState = new RelayAuth({
|
|
1304
|
+
url: this.url,
|
|
1305
|
+
isOpen: () => this.#isOpen(),
|
|
1306
|
+
sendAuth: (event, timeoutMs) => this.#ok.track(event.id, {
|
|
1307
|
+
timeoutMs,
|
|
1308
|
+
timeoutError: () => new RelayTimeoutError("auth timed out", this.url),
|
|
1309
|
+
sendError: () => new RelayPublishError("auth send failed", this.url),
|
|
1310
|
+
send: () => this.#send(["AUTH", event])
|
|
1311
|
+
}),
|
|
1312
|
+
defaultTimeoutMs: this.#publishTimeoutMs
|
|
1313
|
+
});
|
|
1314
|
+
this.#ensureAuthed = this.#authState.ensureAuthed.bind(this.#authState, this.#authSigner);
|
|
1315
|
+
}
|
|
1316
|
+
/** Latest NIP-42 challenge, if any. */
|
|
1317
|
+
get challenge() {
|
|
1318
|
+
return this.#authState.challenge;
|
|
1319
|
+
}
|
|
1320
|
+
get connected() {
|
|
1321
|
+
return this.#isOpen();
|
|
1322
|
+
}
|
|
1323
|
+
get status() {
|
|
1324
|
+
return this.#status;
|
|
1325
|
+
}
|
|
1326
|
+
#isOpen() {
|
|
1327
|
+
return this.#status === RelayStatus.Connected;
|
|
1328
|
+
}
|
|
1329
|
+
#setStatus(next) {
|
|
1330
|
+
this.#status = next;
|
|
1331
|
+
}
|
|
1332
|
+
get generation() {
|
|
1333
|
+
return this.#gen;
|
|
1334
|
+
}
|
|
1335
|
+
get reconnectEnabled() {
|
|
1336
|
+
return this.#enableReconnect;
|
|
1337
|
+
}
|
|
1338
|
+
/** User subscriptions only; dummy ping REQs are not counted. */
|
|
1339
|
+
get subscriptionCount() {
|
|
1340
|
+
return this.#subscriptions.size;
|
|
1341
|
+
}
|
|
1342
|
+
/** One-shot requests still awaiting a relay reply (EVENT ACK, COUNT, NEG). */
|
|
1343
|
+
get inFlightCount() {
|
|
1344
|
+
return this.#ok.size + this.#countRequests.size + this.#neg.size;
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Listen for a relay event. Multiple listeners per type are allowed and fire in registration
|
|
1348
|
+
* order; a throwing listener is reported and does not break the dispatch. Returns an unsubscribe
|
|
1349
|
+
* function.
|
|
1350
|
+
*/
|
|
1351
|
+
on(type, listener) {
|
|
1352
|
+
return this.#events.on(type, listener);
|
|
1353
|
+
}
|
|
1354
|
+
static async connect(url, opts) {
|
|
1355
|
+
const relay = new Relay(url, opts);
|
|
1356
|
+
await relay.connect({ signal: opts?.signal });
|
|
1357
|
+
return relay;
|
|
1358
|
+
}
|
|
1359
|
+
async connect(opts) {
|
|
1360
|
+
if (this.#isOpen()) return;
|
|
1361
|
+
if (this.#connecting) {
|
|
1362
|
+
await raceSignal(this.#connecting, opts?.signal);
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
throwIfAborted(opts?.signal);
|
|
1366
|
+
const gen = ++this.#gen;
|
|
1367
|
+
this.#manualStop = false;
|
|
1368
|
+
this.#clearReconnectTimer();
|
|
1369
|
+
this.#setStatus(RelayStatus.Connecting);
|
|
1370
|
+
const timeoutMs = opts?.timeoutMs ?? this.#connectTimeoutMs;
|
|
1371
|
+
let resolveConnect = noop;
|
|
1372
|
+
let rejectConnect = noop;
|
|
1373
|
+
let settled = false;
|
|
1374
|
+
let ws;
|
|
1375
|
+
const connecting = new Promise((resolve, reject) => {
|
|
1376
|
+
resolveConnect = resolve;
|
|
1377
|
+
rejectConnect = reject;
|
|
1378
|
+
});
|
|
1379
|
+
const release = () => {
|
|
1380
|
+
if (ws === void 0) return;
|
|
1381
|
+
this.#removeSocketListeners(handlers);
|
|
1382
|
+
if (this.#socketHandlers === handlers) this.#socketHandlers = void 0;
|
|
1383
|
+
try {
|
|
1384
|
+
if (ws.readyState !== this.#WS.CLOSED && ws.readyState !== this.#WS.CLOSING) ws.close();
|
|
1385
|
+
} catch {}
|
|
1386
|
+
if (this.#ws === ws) this.#ws = void 0;
|
|
1387
|
+
};
|
|
1388
|
+
const guarded = (fn) => {
|
|
1389
|
+
return () => {
|
|
1390
|
+
if (gen !== this.#gen) {
|
|
1391
|
+
release();
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
fn();
|
|
1395
|
+
};
|
|
1396
|
+
};
|
|
1397
|
+
const finish = (err) => {
|
|
1398
|
+
if (settled) return;
|
|
1399
|
+
settled = true;
|
|
1400
|
+
clearTimeout(timer);
|
|
1401
|
+
if (this.#connectTimer === timer) this.#connectTimer = void 0;
|
|
1402
|
+
if (this.#connectFinish === finish) this.#connectFinish = void 0;
|
|
1403
|
+
if (this.#connecting === connecting) this.#connecting = void 0;
|
|
1404
|
+
if (err === void 0) resolveConnect();
|
|
1405
|
+
else rejectConnect(err);
|
|
1406
|
+
};
|
|
1407
|
+
this.#connecting = connecting;
|
|
1408
|
+
this.#connectFinish = finish;
|
|
1409
|
+
const timer = setTimeout(guarded(() => {
|
|
1410
|
+
release();
|
|
1411
|
+
finish(new RelayTimeoutError("connection timed out", this.url));
|
|
1412
|
+
if (gen === this.#gen) this.#handleSocketDeath("connection timed out", {
|
|
1413
|
+
fromConnectAttempt: true,
|
|
1414
|
+
gen
|
|
1415
|
+
});
|
|
1416
|
+
}), timeoutMs);
|
|
1417
|
+
this.#connectTimer = timer;
|
|
1418
|
+
try {
|
|
1419
|
+
ws = new this.#WS(this.url);
|
|
1420
|
+
} catch (error) {
|
|
1421
|
+
finish(error);
|
|
1422
|
+
if (gen === this.#gen) this.#handleSocketDeath("connection failed", {
|
|
1423
|
+
fromConnectAttempt: true,
|
|
1424
|
+
gen
|
|
1425
|
+
});
|
|
1426
|
+
await raceSignal(connecting, opts?.signal);
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
this.#ws = ws;
|
|
1430
|
+
const onOpen = guarded(() => {
|
|
1431
|
+
this.#setStatus(RelayStatus.Connected);
|
|
1432
|
+
const wasReconnect = this.#reconnectAttempts > 0;
|
|
1433
|
+
this.#authState.resetConnection();
|
|
1434
|
+
if (!this.#subscriptions.replayAll()) {
|
|
1435
|
+
this.#setStatus(RelayStatus.Disconnected);
|
|
1436
|
+
release();
|
|
1437
|
+
finish(new RelayConnectionError("connection failed", this.url));
|
|
1438
|
+
if (this.#enableReconnect && !this.#manualStop && this.#subscriptions.size > 0) this.#scheduleReconnect();
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
this.#reconnectAttempts = 0;
|
|
1442
|
+
if (this.#enablePing) this.#ping.start();
|
|
1443
|
+
else this.#ping.stop();
|
|
1444
|
+
if (wasReconnect) this.#events.emit("reconnect", void 0);
|
|
1445
|
+
finish();
|
|
1446
|
+
});
|
|
1447
|
+
const onError = guarded(() => {
|
|
1448
|
+
const fromConnectAttempt = !settled;
|
|
1449
|
+
release();
|
|
1450
|
+
finish(new RelayConnectionError("connection failed", this.url));
|
|
1451
|
+
if (gen === this.#gen) this.#handleSocketDeath("connection failed", {
|
|
1452
|
+
fromConnectAttempt,
|
|
1453
|
+
gen
|
|
1454
|
+
});
|
|
1455
|
+
});
|
|
1456
|
+
const onClose = guarded(() => {
|
|
1457
|
+
const fromConnectAttempt = !settled;
|
|
1458
|
+
release();
|
|
1459
|
+
if (fromConnectAttempt) finish(new RelayConnectionError("websocket closed", this.url));
|
|
1460
|
+
if (gen === this.#gen) this.#handleSocketDeath("websocket closed", {
|
|
1461
|
+
fromConnectAttempt,
|
|
1462
|
+
gen
|
|
1463
|
+
});
|
|
1464
|
+
});
|
|
1465
|
+
const onMessage = (ev) => {
|
|
1466
|
+
if (gen !== this.#gen) return;
|
|
1467
|
+
const data = typeof ev === "object" && ev !== null && "data" in ev ? ev.data : ev;
|
|
1468
|
+
this.#onMessage(String(data));
|
|
1469
|
+
};
|
|
1470
|
+
const handlers = {
|
|
1471
|
+
onOpen,
|
|
1472
|
+
onError,
|
|
1473
|
+
onClose,
|
|
1474
|
+
onMessage,
|
|
1475
|
+
ws
|
|
1476
|
+
};
|
|
1477
|
+
this.#socketHandlers = handlers;
|
|
1478
|
+
ws.addEventListener("open", onOpen);
|
|
1479
|
+
ws.addEventListener("error", onError);
|
|
1480
|
+
ws.addEventListener("close", onClose);
|
|
1481
|
+
ws.addEventListener("message", onMessage);
|
|
1482
|
+
await raceSignal(connecting, opts?.signal);
|
|
1483
|
+
}
|
|
1484
|
+
#removeSocketListeners(h) {
|
|
1485
|
+
try {
|
|
1486
|
+
h.ws.removeEventListener("open", h.onOpen);
|
|
1487
|
+
h.ws.removeEventListener("error", h.onError);
|
|
1488
|
+
h.ws.removeEventListener("close", h.onClose);
|
|
1489
|
+
h.ws.removeEventListener("message", h.onMessage);
|
|
1490
|
+
} catch {}
|
|
1491
|
+
}
|
|
1492
|
+
#detachSocketHandlers() {
|
|
1493
|
+
const h = this.#socketHandlers;
|
|
1494
|
+
if (!h) return;
|
|
1495
|
+
this.#removeSocketListeners(h);
|
|
1496
|
+
this.#socketHandlers = void 0;
|
|
1497
|
+
}
|
|
1498
|
+
/** Graceful shutdown: disables reconnect and closes all subscriptions. */
|
|
1499
|
+
close() {
|
|
1500
|
+
this.#gen += 1;
|
|
1501
|
+
this.#setStatus(RelayStatus.Closed);
|
|
1502
|
+
this.#manualStop = true;
|
|
1503
|
+
this.#clearReconnectTimer();
|
|
1504
|
+
if (this.#connectTimer !== void 0) {
|
|
1505
|
+
clearTimeout(this.#connectTimer);
|
|
1506
|
+
this.#connectTimer = void 0;
|
|
1507
|
+
}
|
|
1508
|
+
this.#ping.stop();
|
|
1509
|
+
this.#connectFinish?.(new RelayClosedError("relay closed", this.url));
|
|
1510
|
+
try {
|
|
1511
|
+
this.#subscriptions.closeAll("relay closed");
|
|
1512
|
+
} finally {
|
|
1513
|
+
this.#failPending(new RelayClosedError("relay closed", this.url));
|
|
1514
|
+
this.#detachSocketHandlers();
|
|
1515
|
+
this.#teardownSocket();
|
|
1516
|
+
this.#events.emit("close", void 0);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Sever the socket while keeping every subscription: unlike {@link close}, open REQ state is
|
|
1521
|
+
* preserved and reconnect stays suppressed — the next {@link connect} re-subscribes all of them.
|
|
1522
|
+
* {@link Pool} uses this to suspend a relay without tearing down its live subscriptions.
|
|
1523
|
+
*/
|
|
1524
|
+
disconnect() {
|
|
1525
|
+
this.#gen += 1;
|
|
1526
|
+
this.#setStatus(RelayStatus.Disconnected);
|
|
1527
|
+
this.#manualStop = true;
|
|
1528
|
+
this.#clearReconnectTimer();
|
|
1529
|
+
if (this.#connectTimer !== void 0) {
|
|
1530
|
+
clearTimeout(this.#connectTimer);
|
|
1531
|
+
this.#connectTimer = void 0;
|
|
1532
|
+
}
|
|
1533
|
+
this.#ping.stop();
|
|
1534
|
+
this.#connectFinish?.(new RelayClosedError("relay disconnected", this.url));
|
|
1535
|
+
this.#failPending(new RelayClosedError("relay disconnected", this.url));
|
|
1536
|
+
this.#detachSocketHandlers();
|
|
1537
|
+
this.#teardownSocket();
|
|
1538
|
+
}
|
|
1539
|
+
#teardownSocket() {
|
|
1540
|
+
try {
|
|
1541
|
+
this.#ws?.close();
|
|
1542
|
+
} catch {}
|
|
1543
|
+
this.#ws = void 0;
|
|
1544
|
+
}
|
|
1545
|
+
#clearReconnectTimer() {
|
|
1546
|
+
if (this.#reconnectTimer !== void 0) {
|
|
1547
|
+
clearTimeout(this.#reconnectTimer);
|
|
1548
|
+
this.#reconnectTimer = void 0;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
#failPending(err) {
|
|
1552
|
+
this.#ok.rejectAll(err);
|
|
1553
|
+
this.#countRequests.rejectAll(err);
|
|
1554
|
+
failAllNegSessions(this.#neg, err);
|
|
1555
|
+
}
|
|
1556
|
+
/** Unexpected socket death. Keep subscriptions if reconnecting. */
|
|
1557
|
+
#handleSocketDeath(reason, opts) {
|
|
1558
|
+
if (opts.gen !== this.#gen) return;
|
|
1559
|
+
this.#gen += 1;
|
|
1560
|
+
this.#ping.stop();
|
|
1561
|
+
this.#detachSocketHandlers();
|
|
1562
|
+
this.#ws = void 0;
|
|
1563
|
+
this.#failPending(new RelayClosedError(reason, this.url));
|
|
1564
|
+
if (this.#enableReconnect && !this.#manualStop && this.#subscriptions.size > 0) {
|
|
1565
|
+
this.#setStatus(RelayStatus.Disconnected);
|
|
1566
|
+
this.#scheduleReconnect();
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
this.#setStatus(RelayStatus.Disconnected);
|
|
1570
|
+
if (opts.fromConnectAttempt !== true || this.#subscriptions.size > 0) {
|
|
1571
|
+
this.#subscriptions.closeAll(reason);
|
|
1572
|
+
if (!this.#manualStop) this.#events.emit("close", void 0);
|
|
1573
|
+
}
|
|
1574
|
+
this.#setStatus(RelayStatus.Closed);
|
|
1575
|
+
}
|
|
1576
|
+
#scheduleReconnect() {
|
|
1577
|
+
if (this.#reconnectTimer !== void 0) return;
|
|
1578
|
+
if (this.#isOpen()) return;
|
|
1579
|
+
const delay = this.#backoff[Math.min(this.#reconnectAttempts, this.#backoff.length - 1)] ?? 6e4;
|
|
1580
|
+
this.#reconnectAttempts += 1;
|
|
1581
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
1582
|
+
this.#reconnectTimer = void 0;
|
|
1583
|
+
if (this.#manualStop || this.#isOpen()) return;
|
|
1584
|
+
(async () => {
|
|
1585
|
+
try {
|
|
1586
|
+
await this.connect();
|
|
1587
|
+
} catch {
|
|
1588
|
+
if (this.#enableReconnect && !this.#manualStop && this.#subscriptions.size > 0 && !this.#isOpen()) this.#scheduleReconnect();
|
|
1589
|
+
}
|
|
1590
|
+
})();
|
|
1591
|
+
}, delay);
|
|
1592
|
+
}
|
|
1593
|
+
#send(message) {
|
|
1594
|
+
if (!this.#ws || !this.#isOpen()) throw new RelayClosedError("not connected", this.url);
|
|
1595
|
+
const raw = typeof message === "string" ? message : encodeClientMessage(message);
|
|
1596
|
+
this.#ws.send(raw);
|
|
1597
|
+
}
|
|
1598
|
+
#onMessage(raw) {
|
|
1599
|
+
let msg;
|
|
1600
|
+
try {
|
|
1601
|
+
msg = parseRelayMessage(raw);
|
|
1602
|
+
} catch {
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
switch (msg[0]) {
|
|
1606
|
+
case "EVENT": {
|
|
1607
|
+
const [, subId, event] = msg;
|
|
1608
|
+
if (this.#ping.hasWaiter(subId)) return;
|
|
1609
|
+
this.#subscriptions.handleEvent(subId, event);
|
|
1610
|
+
break;
|
|
1611
|
+
}
|
|
1612
|
+
case "EOSE": {
|
|
1613
|
+
const [, subId] = msg;
|
|
1614
|
+
if (this.#ping.finishDummyPing(subId)) return;
|
|
1615
|
+
this.#subscriptions.handleEose(subId);
|
|
1616
|
+
break;
|
|
1617
|
+
}
|
|
1618
|
+
case "CLOSED": {
|
|
1619
|
+
const [, subId, reason] = msg;
|
|
1620
|
+
if (this.#ping.finishDummyPing(subId)) return;
|
|
1621
|
+
const countWaiter = this.#countRequests.get(subId);
|
|
1622
|
+
if (countWaiter) {
|
|
1623
|
+
if (isAuthRequired(reason) && this.#authSigner && !countWaiter.authRetried) {
|
|
1624
|
+
countWaiter.authRetried = true;
|
|
1625
|
+
this.#countRequests.authRetry(countWaiter, {
|
|
1626
|
+
ensureAuthed: this.#ensureAuthed,
|
|
1627
|
+
isOpen: () => this.#isOpen(),
|
|
1628
|
+
failError: () => new RelayClosedError(reason || "COUNT closed", this.url),
|
|
1629
|
+
send: () => this.#send([
|
|
1630
|
+
"COUNT",
|
|
1631
|
+
subId,
|
|
1632
|
+
...countWaiter.filters
|
|
1633
|
+
])
|
|
1634
|
+
});
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
countWaiter.reject(new RelayClosedError(reason || "COUNT closed", this.url));
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
const sub = this.#subscriptions.get(subId);
|
|
1641
|
+
if (!sub) return;
|
|
1642
|
+
if (isAuthRequired(reason) && this.#authSigner && sub.beginAuthRetry()) {
|
|
1643
|
+
this.#subscriptions.authRetry(sub, reason, this.#ensureAuthed);
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
this.#subscriptions.drop(sub, reason);
|
|
1647
|
+
break;
|
|
1648
|
+
}
|
|
1649
|
+
case "OK": {
|
|
1650
|
+
const [, eventId, ok, message] = msg;
|
|
1651
|
+
const waiter = this.#ok.handleOk(eventId, ok, message, (w) => !ok && isAuthRequired(message) && w.event !== void 0 && !w.authRetried && this.#authSigner !== void 0);
|
|
1652
|
+
if (!waiter) return;
|
|
1653
|
+
waiter.authRetried = true;
|
|
1654
|
+
this.#ok.authRetry(waiter, message, {
|
|
1655
|
+
ensureAuthed: this.#ensureAuthed,
|
|
1656
|
+
isOpen: () => this.#isOpen(),
|
|
1657
|
+
send: (event) => this.#send(["EVENT", event])
|
|
1658
|
+
});
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
case "COUNT": {
|
|
1662
|
+
const [, countId, payload] = msg;
|
|
1663
|
+
const waiter = this.#countRequests.get(countId);
|
|
1664
|
+
if (!waiter) return;
|
|
1665
|
+
waiter.resolve(payload);
|
|
1666
|
+
break;
|
|
1667
|
+
}
|
|
1668
|
+
case "NEG-MSG":
|
|
1669
|
+
case "NEG-ERR":
|
|
1670
|
+
dispatchNegMessage(this.#neg, msg);
|
|
1671
|
+
break;
|
|
1672
|
+
case "NOTICE": {
|
|
1673
|
+
const [, notice] = msg;
|
|
1674
|
+
this.#events.emit("notice", notice);
|
|
1675
|
+
break;
|
|
1676
|
+
}
|
|
1677
|
+
case "AUTH": {
|
|
1678
|
+
const [, authChallenge] = msg;
|
|
1679
|
+
this.#authState.handleChallenge(authChallenge);
|
|
1680
|
+
this.#events.emit("auth", authChallenge);
|
|
1681
|
+
break;
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
/** Low-level REQ with callbacks. Survives reconnect when enableReconnect is on. */
|
|
1686
|
+
subscribe(filters, opts = {}) {
|
|
1687
|
+
if (filters.length === 0) throw new MessageError("REQ requires at least one filter");
|
|
1688
|
+
if (!this.#isOpen() && !this.#enableReconnect) throw new RelayClosedError("not connected", this.url);
|
|
1689
|
+
const canonical = canonicalizeFilters(filters);
|
|
1690
|
+
return this.#subscriptions.open(canonical, opts);
|
|
1691
|
+
}
|
|
1692
|
+
#acceptEvent(event) {
|
|
1693
|
+
if (this.#verifyDead) return false;
|
|
1694
|
+
try {
|
|
1695
|
+
const ok = this.#verify(event);
|
|
1696
|
+
if (!ok) this.#events.emit("invalidevent", void 0);
|
|
1697
|
+
return ok;
|
|
1698
|
+
} catch (error) {
|
|
1699
|
+
if (error instanceof WasmPoisonedError) {
|
|
1700
|
+
this.#verifyDead = true;
|
|
1701
|
+
this.#events.emit("notice", "wasm-poisoned: instance aborted");
|
|
1702
|
+
return false;
|
|
1703
|
+
}
|
|
1704
|
+
throw error;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
/** AsyncIterable of events for filters until the subscription is closed. */
|
|
1708
|
+
stream(filters, opts) {
|
|
1709
|
+
return streamFilters((f, o) => this.subscribe(f, o), filters, opts);
|
|
1710
|
+
}
|
|
1711
|
+
/**
|
|
1712
|
+
* One-shot query: collect events until the REQ ends, then close. `end` reports how it ended —
|
|
1713
|
+
* relay `EOSE`, relay `CLOSED` (with its reason), or the local timeout; events received before a
|
|
1714
|
+
* CLOSED or the deadline are still returned. Abort rejects; a failed (re)connect rejects.
|
|
1715
|
+
*/
|
|
1716
|
+
async fetch(filters, opts) {
|
|
1717
|
+
if (filters.length === 0) throw new MessageError("REQ requires at least one filter");
|
|
1718
|
+
const canonical = canonicalizeFilters(filters);
|
|
1719
|
+
if (!this.#isOpen()) await this.connect({ signal: opts?.signal });
|
|
1720
|
+
return fetchFilters((f, o) => this.subscribe(f, o), canonical, {
|
|
1721
|
+
timeoutMs: opts?.timeoutMs ?? 4400,
|
|
1722
|
+
signal: opts?.signal,
|
|
1723
|
+
id: opts?.id
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* Publish an event and wait for OK. A concurrent publish of the same event id joins the in-flight
|
|
1728
|
+
* publish instead of racing it — the joiner's `timeoutMs` does not apply.
|
|
1729
|
+
*/
|
|
1730
|
+
async publish(event, opts) {
|
|
1731
|
+
if (!this.#isOpen()) throw new RelayClosedError("not connected", this.url);
|
|
1732
|
+
const existing = this.#ok.inFlight(event.id);
|
|
1733
|
+
if (existing !== void 0) return existing;
|
|
1734
|
+
const timeoutMs = opts?.timeoutMs ?? this.#publishTimeoutMs;
|
|
1735
|
+
return this.#ok.track(event.id, {
|
|
1736
|
+
timeoutMs,
|
|
1737
|
+
timeoutError: () => new RelayTimeoutError("publish timed out", this.url),
|
|
1738
|
+
sendError: () => new RelayPublishError("publish failed", this.url),
|
|
1739
|
+
event,
|
|
1740
|
+
send: () => this.#send(["EVENT", event])
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* NIP-45 COUNT: ask the relay how many events match `filters`. Resolves with the COUNT payload
|
|
1745
|
+
* (count / optional approximate / optional hll).
|
|
1746
|
+
*/
|
|
1747
|
+
async count(filters, opts) {
|
|
1748
|
+
if (!this.#isOpen()) throw new RelayClosedError("not connected", this.url);
|
|
1749
|
+
if (filters.length === 0) throw new RelayError("COUNT requires at least one filter", this.url);
|
|
1750
|
+
throwIfAborted(opts?.signal);
|
|
1751
|
+
const canonical = canonicalizeFilters(filters);
|
|
1752
|
+
const id = opts?.id === void 0 ? this.nextSubId("count") : createSubscriptionId(opts.id);
|
|
1753
|
+
const timeoutMs = opts?.timeoutMs ?? this.#publishTimeoutMs;
|
|
1754
|
+
return this.#countRequests.track(id, {
|
|
1755
|
+
timeoutMs,
|
|
1756
|
+
timeoutError: () => new RelayTimeoutError("count timed out", this.url),
|
|
1757
|
+
sendError: () => new RelayPublishError("count failed", this.url),
|
|
1758
|
+
filters: [...canonical],
|
|
1759
|
+
signal: opts?.signal,
|
|
1760
|
+
send: () => this.#send([
|
|
1761
|
+
"COUNT",
|
|
1762
|
+
id,
|
|
1763
|
+
...canonical
|
|
1764
|
+
])
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
/** NIP-42 AUTH: sign the current challenge and wait for OK. */
|
|
1768
|
+
async auth(sign, opts) {
|
|
1769
|
+
return this.#authState.auth(sign, opts);
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Clear a cached AUTH rejection for the current challenge so a later `auth()` signs again. A
|
|
1773
|
+
* successful auth and in-flight answers are kept. When a challenge is pending and unanswered, an
|
|
1774
|
+
* `auth` event is re-fired so the pool's automatic auth can run once more.
|
|
1775
|
+
*/
|
|
1776
|
+
resetAuth() {
|
|
1777
|
+
const challenge = this.#authState.resetRejection();
|
|
1778
|
+
if (challenge !== void 0) this.#events.emit("auth", challenge);
|
|
1779
|
+
}
|
|
1780
|
+
/**
|
|
1781
|
+
* NIP-77: run Negentropy reconciliation against this relay. Returns the local-only (`have`) and
|
|
1782
|
+
* remote-only (`need`) event ids. Does not upload or download events.
|
|
1783
|
+
*
|
|
1784
|
+
* `timeoutMs` is a single wall-clock deadline for the whole session (`NEG-OPEN` through the last
|
|
1785
|
+
* `NEG-MSG`), not a per-message budget. Default: {@link RelayOptions.publishTimeoutMs}.
|
|
1786
|
+
*/
|
|
1787
|
+
async negReconcile(filter, storage, opts) {
|
|
1788
|
+
if (!this.#isOpen()) throw new RelayClosedError("not connected", this.url);
|
|
1789
|
+
throwIfAborted(opts?.signal);
|
|
1790
|
+
const id = opts?.id === void 0 ? this.nextSubId("neg") : assertSubscriptionId(opts.id);
|
|
1791
|
+
return runTrackedNegSession({
|
|
1792
|
+
sessions: this.#neg,
|
|
1793
|
+
id,
|
|
1794
|
+
storage,
|
|
1795
|
+
filter: canonicalizeFilter(filter),
|
|
1796
|
+
timeoutMs: opts?.timeoutMs ?? this.#publishTimeoutMs,
|
|
1797
|
+
signal: opts?.signal,
|
|
1798
|
+
send: (message) => this.#send(message),
|
|
1799
|
+
url: this.url
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1802
|
+
/** Generate a unique subscription id for this relay instance. */
|
|
1803
|
+
nextSubId(prefix = "sub") {
|
|
1804
|
+
this.#serial += 1;
|
|
1805
|
+
return `${prefix}:${this.#serial}`;
|
|
1806
|
+
}
|
|
1807
|
+
};
|
|
1808
|
+
//#endregion
|
|
1809
|
+
export { getWebSocketImplementation as a, RelayConnectionError as c, RelaySuspendedError as d, RelayTimeoutError as f, subscriptionToAsyncIterable as i, RelayError as l, Relay as n, useWebSocketImplementation as o, RelayStatus as r, RelayClosedError as s, DEFAULT_REQUEST_TIMEOUT_MS as t, RelayPublishError as u };
|
|
1810
|
+
|
|
1811
|
+
//# sourceMappingURL=relay-quCs7MHD.mjs.map
|