@orangecheck/nostr-core 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OrangeCheck
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @orangecheck/nostr-core
2
+
3
+ Browser-compatible Nostr client used by every OrangeCheck family web app. Raw NIP-01 over WebSocket against a list of relays.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ yarn add @orangecheck/nostr-core
9
+ # or
10
+ npm install @orangecheck/nostr-core
11
+ ```
12
+
13
+ No dependencies — uses the platform `WebSocket` global. Works in any runtime that ships a WHATWG WebSocket (browsers, Node 22+, Deno, Bun, Cloudflare Workers).
14
+
15
+ ## Use
16
+
17
+ ```ts
18
+ import { DEFAULT_RELAYS, publishEvent, queryEvents } from '@orangecheck/nostr-core';
19
+ import type { NostrEvent, Filter } from '@orangecheck/nostr-core';
20
+
21
+ // Publish a kind-30078 event to the family default relay set.
22
+ const event: NostrEvent = { /* signed by your wallet, see oc-pledge-protocol */ };
23
+ const results = await publishEvent(event);
24
+ const accepted = results.filter((r) => r.ok).length;
25
+ console.log(`${accepted}/${results.length} relays accepted`);
26
+
27
+ // Query events across the racing read pool.
28
+ const filter: Filter = { kinds: [30078], '#t': ['bc1q…'] };
29
+ const { events, relayStatus } = await queryEvents(filter);
30
+ console.log(`${events.length} unique events from ${relayStatus.filter(r => r.ok).length} relays`);
31
+ ```
32
+
33
+ ## What's in `DEFAULT_RELAYS`
34
+
35
+ Five relays, in order:
36
+
37
+ 1. `wss://relay.nostr.band`
38
+ 2. `wss://nos.lol`
39
+ 3. `wss://relay.primal.net`
40
+ 4. `wss://offchain.pub`
41
+ 5. `wss://relay.ochk.io` — the OC family's first-party kind-allowlisted relay (see [`oc-relay-infra`](https://github.com/orangecheck/oc-relay-infra))
42
+
43
+ **`DEFAULT_RELAYS` is enforced at the type level to never collapse to `relay.ochk.io` alone or to fewer than two entries.** See the `_ValidRelaySet` invariant in `src/index.ts`. A future change that violates the invariant fails `tsc` at build time.
44
+
45
+ ## Why it exists
46
+
47
+ Five OC web repos (`oc-vote-web`, `oc-pledge-web`, `oc-stamp-web`, `oc-lock-web`, `oc-fleet-web`) used to carry near-identical 200-line `client.ts` files via fork-and-paste. Drift between them was already visible before extraction. Now each repo imports from this package; the family stays in sync via a single `yarn upgrade @orangecheck/nostr-core`.
48
+
49
+ Product-specific helpers (`fetchPollEvent` for OC Vote, `fetchPledgeOutcomes` for OC Pledge, etc.) stay local to each app — they're shape-specific to one verb and don't generalize.
50
+
51
+ ## License
52
+
53
+ MIT.
@@ -0,0 +1,43 @@
1
+ declare const DEFAULT_RELAYS: readonly string[];
2
+ interface NostrEvent {
3
+ id: string;
4
+ kind: number;
5
+ pubkey: string;
6
+ created_at: number;
7
+ content: string;
8
+ tags: string[][];
9
+ sig: string;
10
+ }
11
+ interface PublishResult {
12
+ relay: string;
13
+ ok: boolean;
14
+ reason?: string;
15
+ attempts: number;
16
+ }
17
+ interface Filter {
18
+ kinds?: number[];
19
+ authors?: string[];
20
+ ids?: string[];
21
+ limit?: number;
22
+ since?: number;
23
+ until?: number;
24
+ '#d'?: string[];
25
+ '#t'?: string[];
26
+ '#poll_id'?: string[];
27
+ '#voter'?: string[];
28
+ '#creator'?: string[];
29
+ [key: `#${string}`]: string[] | undefined;
30
+ }
31
+ interface QueryResult {
32
+ events: NostrEvent[];
33
+ relayStatus: {
34
+ relay: string;
35
+ ok: boolean;
36
+ reason?: string;
37
+ events: number;
38
+ }[];
39
+ }
40
+ declare function publishEvent(event: NostrEvent, relays?: readonly string[], timeoutMs?: number): Promise<PublishResult[]>;
41
+ declare function queryEvents(filter: Filter, relays?: readonly string[], timeoutMs?: number): Promise<QueryResult>;
42
+
43
+ export { DEFAULT_RELAYS, type Filter, type NostrEvent, type PublishResult, type QueryResult, publishEvent, queryEvents };
@@ -0,0 +1,43 @@
1
+ declare const DEFAULT_RELAYS: readonly string[];
2
+ interface NostrEvent {
3
+ id: string;
4
+ kind: number;
5
+ pubkey: string;
6
+ created_at: number;
7
+ content: string;
8
+ tags: string[][];
9
+ sig: string;
10
+ }
11
+ interface PublishResult {
12
+ relay: string;
13
+ ok: boolean;
14
+ reason?: string;
15
+ attempts: number;
16
+ }
17
+ interface Filter {
18
+ kinds?: number[];
19
+ authors?: string[];
20
+ ids?: string[];
21
+ limit?: number;
22
+ since?: number;
23
+ until?: number;
24
+ '#d'?: string[];
25
+ '#t'?: string[];
26
+ '#poll_id'?: string[];
27
+ '#voter'?: string[];
28
+ '#creator'?: string[];
29
+ [key: `#${string}`]: string[] | undefined;
30
+ }
31
+ interface QueryResult {
32
+ events: NostrEvent[];
33
+ relayStatus: {
34
+ relay: string;
35
+ ok: boolean;
36
+ reason?: string;
37
+ events: number;
38
+ }[];
39
+ }
40
+ declare function publishEvent(event: NostrEvent, relays?: readonly string[], timeoutMs?: number): Promise<PublishResult[]>;
41
+ declare function queryEvents(filter: Filter, relays?: readonly string[], timeoutMs?: number): Promise<QueryResult>;
42
+
43
+ export { DEFAULT_RELAYS, type Filter, type NostrEvent, type PublishResult, type QueryResult, publishEvent, queryEvents };
package/dist/index.js ADDED
@@ -0,0 +1,233 @@
1
+ 'use strict';
2
+
3
+ // src/index.ts
4
+ var _RELAYS = [
5
+ "wss://relay.nostr.band",
6
+ "wss://nos.lol",
7
+ "wss://relay.primal.net",
8
+ "wss://offchain.pub",
9
+ // First-party family relay — kind allowlist 30078–30086 + canonical
10
+ // OC d-tag prefixes. Always co-published with public relays; never
11
+ // the only copy. See https://github.com/orangecheck/oc-relay-infra.
12
+ "wss://relay.ochk.io"
13
+ ];
14
+ var DEFAULT_RELAYS = Object.freeze([..._RELAYS]);
15
+ function parseFrame(raw) {
16
+ try {
17
+ const arr = JSON.parse(raw);
18
+ if (!Array.isArray(arr) || arr.length === 0) return null;
19
+ const type = arr[0];
20
+ if (type === "OK" || type === "EVENT" || type === "EOSE" || type === "NOTICE" || type === "CLOSED") {
21
+ return { type, payload: arr.slice(1) };
22
+ }
23
+ return null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+ var DEFAULT_RETRY = {
29
+ attempts: 3,
30
+ timeoutMs: 5e3,
31
+ initialBackoffMs: 500,
32
+ maxBackoffMs: 4e3
33
+ };
34
+ function delay(ms) {
35
+ return new Promise((resolve) => setTimeout(resolve, ms));
36
+ }
37
+ async function publishOne(url, event, retry) {
38
+ let attempts = 0;
39
+ let backoff = retry.initialBackoffMs;
40
+ let lastReason;
41
+ while (attempts < retry.attempts) {
42
+ attempts++;
43
+ const attempt = await attemptPublish(url, event, retry.timeoutMs);
44
+ if (attempt.ok) {
45
+ return {
46
+ relay: url,
47
+ ok: true,
48
+ attempts,
49
+ ...attempt.reason ? { reason: attempt.reason } : {}
50
+ };
51
+ }
52
+ lastReason = attempt.reason;
53
+ if (attempt.retryable && attempts < retry.attempts) {
54
+ await delay(backoff);
55
+ backoff = Math.min(backoff * 2, retry.maxBackoffMs);
56
+ continue;
57
+ }
58
+ break;
59
+ }
60
+ return {
61
+ relay: url,
62
+ ok: false,
63
+ attempts,
64
+ ...lastReason ? { reason: lastReason } : {}
65
+ };
66
+ }
67
+ function attemptPublish(url, event, timeoutMs) {
68
+ return new Promise((resolve) => {
69
+ let settled = false;
70
+ let ws = null;
71
+ const timer = setTimeout(() => {
72
+ if (settled) return;
73
+ settled = true;
74
+ try {
75
+ ws?.close();
76
+ } catch {
77
+ }
78
+ resolve({ ok: false, reason: "timeout", retryable: true });
79
+ }, timeoutMs);
80
+ try {
81
+ ws = new WebSocket(url);
82
+ ws.onopen = () => ws?.send(JSON.stringify(["EVENT", event]));
83
+ ws.onmessage = (msg) => {
84
+ const frame = parseFrame(msg.data);
85
+ if (!frame) return;
86
+ if (frame.type === "OK" && frame.payload[0] === event.id) {
87
+ if (settled) return;
88
+ settled = true;
89
+ clearTimeout(timer);
90
+ try {
91
+ ws?.close();
92
+ } catch {
93
+ }
94
+ const ok = frame.payload[1] === true;
95
+ const reason = frame.payload[2];
96
+ resolve({
97
+ ok,
98
+ ...reason ? { reason } : {},
99
+ retryable: false
100
+ });
101
+ }
102
+ };
103
+ ws.onerror = () => {
104
+ if (settled) return;
105
+ settled = true;
106
+ clearTimeout(timer);
107
+ resolve({ ok: false, reason: "websocket_error", retryable: true });
108
+ };
109
+ ws.onclose = () => {
110
+ if (settled) return;
111
+ settled = true;
112
+ clearTimeout(timer);
113
+ resolve({ ok: false, reason: "closed_early", retryable: true });
114
+ };
115
+ } catch (err) {
116
+ if (settled) return;
117
+ settled = true;
118
+ clearTimeout(timer);
119
+ resolve({
120
+ ok: false,
121
+ reason: err instanceof Error ? err.message : "unknown",
122
+ retryable: true
123
+ });
124
+ }
125
+ });
126
+ }
127
+ async function publishEvent(event, relays = DEFAULT_RELAYS, timeoutMs = 5e3) {
128
+ const retry = { ...DEFAULT_RETRY, timeoutMs };
129
+ return Promise.all(relays.map((r) => publishOne(r, event, retry)));
130
+ }
131
+ async function queryEvents(filter, relays = DEFAULT_RELAYS, timeoutMs = 1500) {
132
+ const subId = "ocnc-" + Math.random().toString(36).slice(2, 10);
133
+ const byId = /* @__PURE__ */ new Map();
134
+ const status = [];
135
+ await Promise.all(
136
+ relays.map(
137
+ (url) => new Promise((resolve) => {
138
+ let settled = false;
139
+ let count = 0;
140
+ let reason;
141
+ let ws = null;
142
+ const timer = setTimeout(() => {
143
+ if (settled) return;
144
+ settled = true;
145
+ try {
146
+ ws?.close();
147
+ } catch {
148
+ }
149
+ status.push({
150
+ relay: url,
151
+ ok: count > 0,
152
+ reason: reason ?? "timeout",
153
+ events: count
154
+ });
155
+ resolve();
156
+ }, timeoutMs);
157
+ try {
158
+ ws = new WebSocket(url);
159
+ ws.onopen = () => ws?.send(JSON.stringify(["REQ", subId, filter]));
160
+ ws.onmessage = (msg) => {
161
+ const frame = parseFrame(msg.data);
162
+ if (!frame) return;
163
+ if (frame.type === "EVENT" && frame.payload[0] === subId) {
164
+ const event = frame.payload[1];
165
+ if (event && event.id) {
166
+ byId.set(event.id, event);
167
+ count++;
168
+ }
169
+ } else if (frame.type === "EOSE" && frame.payload[0] === subId) {
170
+ if (settled) return;
171
+ settled = true;
172
+ clearTimeout(timer);
173
+ try {
174
+ ws?.send(JSON.stringify(["CLOSE", subId]));
175
+ ws?.close();
176
+ } catch {
177
+ }
178
+ status.push({ relay: url, ok: true, events: count });
179
+ resolve();
180
+ } else if (frame.type === "NOTICE") {
181
+ reason = String(frame.payload[0] ?? "notice");
182
+ }
183
+ };
184
+ ws.onerror = () => {
185
+ if (settled) return;
186
+ settled = true;
187
+ clearTimeout(timer);
188
+ status.push({
189
+ relay: url,
190
+ ok: false,
191
+ reason: "ws_error",
192
+ events: count
193
+ });
194
+ resolve();
195
+ };
196
+ ws.onclose = () => {
197
+ if (settled) return;
198
+ settled = true;
199
+ clearTimeout(timer);
200
+ status.push({
201
+ relay: url,
202
+ ok: count > 0,
203
+ reason: reason ?? "closed_early",
204
+ events: count
205
+ });
206
+ resolve();
207
+ };
208
+ } catch (err) {
209
+ if (settled) return;
210
+ settled = true;
211
+ clearTimeout(timer);
212
+ status.push({
213
+ relay: url,
214
+ ok: false,
215
+ reason: err instanceof Error ? err.message : "unknown",
216
+ events: count
217
+ });
218
+ resolve();
219
+ }
220
+ })
221
+ )
222
+ );
223
+ return {
224
+ events: Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at),
225
+ relayStatus: status
226
+ };
227
+ }
228
+
229
+ exports.DEFAULT_RELAYS = DEFAULT_RELAYS;
230
+ exports.publishEvent = publishEvent;
231
+ exports.queryEvents = queryEvents;
232
+ //# sourceMappingURL=index.js.map
233
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;AA0CA,IAAM,OAAA,GAMD;AAAA,EACD,wBAAA;AAAA,EACA,eAAA;AAAA,EACA,wBAAA;AAAA,EACA,oBAAA;AAAA;AAAA;AAAA;AAAA,EAIA;AACJ,CAAA;AAQO,IAAM,iBAAoC,MAAA,CAAO,MAAA,CAAO,CAAC,GAAG,OAAO,CAAC;AA2D3E,SAAS,WAAW,GAAA,EAAgC;AAChD,EAAA,IAAI;AACA,IAAA,MAAM,GAAA,GAAM,IAAA,CAAK,KAAA,CAAM,GAAG,CAAA;AAC1B,IAAA,IAAI,CAAC,MAAM,OAAA,CAAQ,GAAG,KAAK,GAAA,CAAI,MAAA,KAAW,GAAG,OAAO,IAAA;AACpD,IAAA,MAAM,IAAA,GAAO,IAAI,CAAC,CAAA;AAClB,IAAA,IACI,IAAA,KAAS,QACT,IAAA,KAAS,OAAA,IACT,SAAS,MAAA,IACT,IAAA,KAAS,QAAA,IACT,IAAA,KAAS,QAAA,EACX;AACE,MAAA,OAAO,EAAE,IAAA,EAAyB,OAAA,EAAS,GAAA,CAAI,KAAA,CAAM,CAAC,CAAA,EAAE;AAAA,IAC5D;AACA,IAAA,OAAO,IAAA;AAAA,EACX,CAAA,CAAA,MAAQ;AACJ,IAAA,OAAO,IAAA;AAAA,EACX;AACJ;AASA,IAAM,aAAA,GAA8B;AAAA,EAChC,QAAA,EAAU,CAAA;AAAA,EACV,SAAA,EAAW,GAAA;AAAA,EACX,gBAAA,EAAkB,GAAA;AAAA,EAClB,YAAA,EAAc;AAClB,CAAA;AAEA,SAAS,MAAM,EAAA,EAA2B;AACtC,EAAA,OAAO,IAAI,OAAA,CAAQ,CAAC,YAAY,UAAA,CAAW,OAAA,EAAS,EAAE,CAAC,CAAA;AAC3D;AAMA,eAAe,UAAA,CACX,GAAA,EACA,KAAA,EACA,KAAA,EACsB;AACtB,EAAA,IAAI,QAAA,GAAW,CAAA;AACf,EAAA,IAAI,UAAU,KAAA,CAAM,gBAAA;AACpB,EAAA,IAAI,UAAA;AAEJ,EAAA,OAAO,QAAA,GAAW,MAAM,QAAA,EAAU;AAC9B,IAAA,QAAA,EAAA;AACA,IAAA,MAAM,UAAU,MAAM,cAAA,CAAe,GAAA,EAAK,KAAA,EAAO,MAAM,SAAS,CAAA;AAChE,IAAA,IAAI,QAAQ,EAAA,EAAI;AACZ,MAAA,OAAO;AAAA,QACH,KAAA,EAAO,GAAA;AAAA,QACP,EAAA,EAAI,IAAA;AAAA,QACJ,QAAA;AAAA,QACA,GAAI,QAAQ,MAAA,GAAS,EAAE,QAAQ,OAAA,CAAQ,MAAA,KAAW;AAAC,OACvD;AAAA,IACJ;AACA,IAAA,UAAA,GAAa,OAAA,CAAQ,MAAA;AACrB,IAAA,IAAI,OAAA,CAAQ,SAAA,IAAa,QAAA,GAAW,KAAA,CAAM,QAAA,EAAU;AAChD,MAAA,MAAM,MAAM,OAAO,CAAA;AACnB,MAAA,OAAA,GAAU,IAAA,CAAK,GAAA,CAAI,OAAA,GAAU,CAAA,EAAG,MAAM,YAAY,CAAA;AAClD,MAAA;AAAA,IACJ;AACA,IAAA;AAAA,EACJ;AACA,EAAA,OAAO;AAAA,IACH,KAAA,EAAO,GAAA;AAAA,IACP,EAAA,EAAI,KAAA;AAAA,IACJ,QAAA;AAAA,IACA,GAAI,UAAA,GAAa,EAAE,MAAA,EAAQ,UAAA,KAAe;AAAC,GAC/C;AACJ;AAEA,SAAS,cAAA,CACL,GAAA,EACA,KAAA,EACA,SAAA,EAC6D;AAC7D,EAAA,OAAO,IAAI,OAAA,CAAQ,CAAC,OAAA,KAAY;AAC5B,IAAA,IAAI,OAAA,GAAU,KAAA;AACd,IAAA,IAAI,EAAA,GAAuB,IAAA;AAC3B,IAAA,MAAM,KAAA,GAAQ,WAAW,MAAM;AAC3B,MAAA,IAAI,OAAA,EAAS;AACb,MAAA,OAAA,GAAU,IAAA;AACV,MAAA,IAAI;AACA,QAAA,EAAA,EAAI,KAAA,EAAM;AAAA,MACd,CAAA,CAAA,MAAQ;AAAA,MAAC;AACT,MAAA,OAAA,CAAQ,EAAE,EAAA,EAAI,KAAA,EAAO,QAAQ,SAAA,EAAW,SAAA,EAAW,MAAM,CAAA;AAAA,IAC7D,GAAG,SAAS,CAAA;AACZ,IAAA,IAAI;AACA,MAAA,EAAA,GAAK,IAAI,UAAU,GAAG,CAAA;AACtB,MAAA,EAAA,CAAG,MAAA,GAAS,MAAM,EAAA,EAAI,IAAA,CAAK,IAAA,CAAK,UAAU,CAAC,OAAA,EAAS,KAAK,CAAC,CAAC,CAAA;AAC3D,MAAA,EAAA,CAAG,SAAA,GAAY,CAAC,GAAA,KAAQ;AACpB,QAAA,MAAM,KAAA,GAAQ,UAAA,CAAW,GAAA,CAAI,IAAc,CAAA;AAC3C,QAAA,IAAI,CAAC,KAAA,EAAO;AACZ,QAAA,IAAI,KAAA,CAAM,SAAS,IAAA,IAAQ,KAAA,CAAM,QAAQ,CAAC,CAAA,KAAM,MAAM,EAAA,EAAI;AACtD,UAAA,IAAI,OAAA,EAAS;AACb,UAAA,OAAA,GAAU,IAAA;AACV,UAAA,YAAA,CAAa,KAAK,CAAA;AAClB,UAAA,IAAI;AACA,YAAA,EAAA,EAAI,KAAA,EAAM;AAAA,UACd,CAAA,CAAA,MAAQ;AAAA,UAAC;AACT,UAAA,MAAM,EAAA,GAAK,KAAA,CAAM,OAAA,CAAQ,CAAC,CAAA,KAAM,IAAA;AAChC,UAAA,MAAM,MAAA,GAAS,KAAA,CAAM,OAAA,CAAQ,CAAC,CAAA;AAC9B,UAAA,OAAA,CAAQ;AAAA,YACJ,EAAA;AAAA,YACA,GAAI,MAAA,GAAS,EAAE,MAAA,KAAW,EAAC;AAAA,YAC3B,SAAA,EAAW;AAAA,WACd,CAAA;AAAA,QACL;AAAA,MACJ,CAAA;AACA,MAAA,EAAA,CAAG,UAAU,MAAM;AACf,QAAA,IAAI,OAAA,EAAS;AACb,QAAA,OAAA,GAAU,IAAA;AACV,QAAA,YAAA,CAAa,KAAK,CAAA;AAClB,QAAA,OAAA,CAAQ,EAAE,EAAA,EAAI,KAAA,EAAO,QAAQ,iBAAA,EAAmB,SAAA,EAAW,MAAM,CAAA;AAAA,MACrE,CAAA;AACA,MAAA,EAAA,CAAG,UAAU,MAAM;AACf,QAAA,IAAI,OAAA,EAAS;AACb,QAAA,OAAA,GAAU,IAAA;AACV,QAAA,YAAA,CAAa,KAAK,CAAA;AAClB,QAAA,OAAA,CAAQ,EAAE,EAAA,EAAI,KAAA,EAAO,QAAQ,cAAA,EAAgB,SAAA,EAAW,MAAM,CAAA;AAAA,MAClE,CAAA;AAAA,IACJ,SAAS,GAAA,EAAK;AACV,MAAA,IAAI,OAAA,EAAS;AACb,MAAA,OAAA,GAAU,IAAA;AACV,MAAA,YAAA,CAAa,KAAK,CAAA;AAClB,MAAA,OAAA,CAAQ;AAAA,QACJ,EAAA,EAAI,KAAA;AAAA,QACJ,MAAA,EAAQ,GAAA,YAAe,KAAA,GAAQ,GAAA,CAAI,OAAA,GAAU,SAAA;AAAA,QAC7C,SAAA,EAAW;AAAA,OACd,CAAA;AAAA,IACL;AAAA,EACJ,CAAC,CAAA;AACL;AAMA,eAAsB,YAAA,CAClB,KAAA,EACA,MAAA,GAA4B,cAAA,EAC5B,YAAY,GAAA,EACY;AACxB,EAAA,MAAM,KAAA,GAAsB,EAAE,GAAG,aAAA,EAAe,SAAA,EAAU;AAC1D,EAAA,OAAO,OAAA,CAAQ,GAAA,CAAI,MAAA,CAAO,GAAA,CAAI,CAAC,CAAA,KAAM,UAAA,CAAW,CAAA,EAAG,KAAA,EAAO,KAAK,CAAC,CAAC,CAAA;AACrE;AAeA,eAAsB,WAAA,CAClB,MAAA,EACA,MAAA,GAA4B,cAAA,EAC5B,YAAY,IAAA,EACQ;AACpB,EAAA,MAAM,KAAA,GAAQ,OAAA,GAAU,IAAA,CAAK,MAAA,EAAO,CAAE,SAAS,EAAE,CAAA,CAAE,KAAA,CAAM,CAAA,EAAG,EAAE,CAAA;AAC9D,EAAA,MAAM,IAAA,uBAAW,GAAA,EAAwB;AACzC,EAAA,MAAM,SAAqC,EAAC;AAE5C,EAAA,MAAM,OAAA,CAAQ,GAAA;AAAA,IACV,MAAA,CAAO,GAAA;AAAA,MACH,CAAC,GAAA,KACG,IAAI,OAAA,CAAc,CAAC,OAAA,KAAY;AAC3B,QAAA,IAAI,OAAA,GAAU,KAAA;AACd,QAAA,IAAI,KAAA,GAAQ,CAAA;AACZ,QAAA,IAAI,MAAA;AACJ,QAAA,IAAI,EAAA,GAAuB,IAAA;AAC3B,QAAA,MAAM,KAAA,GAAQ,WAAW,MAAM;AAC3B,UAAA,IAAI,OAAA,EAAS;AACb,UAAA,OAAA,GAAU,IAAA;AACV,UAAA,IAAI;AACA,YAAA,EAAA,EAAI,KAAA,EAAM;AAAA,UACd,CAAA,CAAA,MAAQ;AAAA,UAAC;AACT,UAAA,MAAA,CAAO,IAAA,CAAK;AAAA,YACR,KAAA,EAAO,GAAA;AAAA,YACP,IAAI,KAAA,GAAQ,CAAA;AAAA,YACZ,QAAQ,MAAA,IAAU,SAAA;AAAA,YAClB,MAAA,EAAQ;AAAA,WACX,CAAA;AACD,UAAA,OAAA,EAAQ;AAAA,QACZ,GAAG,SAAS,CAAA;AACZ,QAAA,IAAI;AACA,UAAA,EAAA,GAAK,IAAI,UAAU,GAAG,CAAA;AACtB,UAAA,EAAA,CAAG,MAAA,GAAS,MAAM,EAAA,EAAI,IAAA,CAAK,IAAA,CAAK,SAAA,CAAU,CAAC,KAAA,EAAO,KAAA,EAAO,MAAM,CAAC,CAAC,CAAA;AACjE,UAAA,EAAA,CAAG,SAAA,GAAY,CAAC,GAAA,KAAQ;AACpB,YAAA,MAAM,KAAA,GAAQ,UAAA,CAAW,GAAA,CAAI,IAAc,CAAA;AAC3C,YAAA,IAAI,CAAC,KAAA,EAAO;AACZ,YAAA,IAAI,MAAM,IAAA,KAAS,OAAA,IAAW,MAAM,OAAA,CAAQ,CAAC,MAAM,KAAA,EAAO;AACtD,cAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,OAAA,CAAQ,CAAC,CAAA;AAC7B,cAAA,IAAI,KAAA,IAAS,MAAM,EAAA,EAAI;AACnB,gBAAA,IAAA,CAAK,GAAA,CAAI,KAAA,CAAM,EAAA,EAAI,KAAK,CAAA;AACxB,gBAAA,KAAA,EAAA;AAAA,cACJ;AAAA,YACJ,CAAA,MAAA,IAAW,MAAM,IAAA,KAAS,MAAA,IAAU,MAAM,OAAA,CAAQ,CAAC,MAAM,KAAA,EAAO;AAC5D,cAAA,IAAI,OAAA,EAAS;AACb,cAAA,OAAA,GAAU,IAAA;AACV,cAAA,YAAA,CAAa,KAAK,CAAA;AAClB,cAAA,IAAI;AACA,gBAAA,EAAA,EAAI,KAAK,IAAA,CAAK,SAAA,CAAU,CAAC,OAAA,EAAS,KAAK,CAAC,CAAC,CAAA;AACzC,gBAAA,EAAA,EAAI,KAAA,EAAM;AAAA,cACd,CAAA,CAAA,MAAQ;AAAA,cAAC;AACT,cAAA,MAAA,CAAO,IAAA,CAAK,EAAE,KAAA,EAAO,GAAA,EAAK,IAAI,IAAA,EAAM,MAAA,EAAQ,OAAO,CAAA;AACnD,cAAA,OAAA,EAAQ;AAAA,YACZ,CAAA,MAAA,IAAW,KAAA,CAAM,IAAA,KAAS,QAAA,EAAU;AAChC,cAAA,MAAA,GAAS,MAAA,CAAO,KAAA,CAAM,OAAA,CAAQ,CAAC,KAAK,QAAQ,CAAA;AAAA,YAChD;AAAA,UACJ,CAAA;AACA,UAAA,EAAA,CAAG,UAAU,MAAM;AACf,YAAA,IAAI,OAAA,EAAS;AACb,YAAA,OAAA,GAAU,IAAA;AACV,YAAA,YAAA,CAAa,KAAK,CAAA;AAClB,YAAA,MAAA,CAAO,IAAA,CAAK;AAAA,cACR,KAAA,EAAO,GAAA;AAAA,cACP,EAAA,EAAI,KAAA;AAAA,cACJ,MAAA,EAAQ,UAAA;AAAA,cACR,MAAA,EAAQ;AAAA,aACX,CAAA;AACD,YAAA,OAAA,EAAQ;AAAA,UACZ,CAAA;AACA,UAAA,EAAA,CAAG,UAAU,MAAM;AACf,YAAA,IAAI,OAAA,EAAS;AACb,YAAA,OAAA,GAAU,IAAA;AACV,YAAA,YAAA,CAAa,KAAK,CAAA;AAClB,YAAA,MAAA,CAAO,IAAA,CAAK;AAAA,cACR,KAAA,EAAO,GAAA;AAAA,cACP,IAAI,KAAA,GAAQ,CAAA;AAAA,cACZ,QAAQ,MAAA,IAAU,cAAA;AAAA,cAClB,MAAA,EAAQ;AAAA,aACX,CAAA;AACD,YAAA,OAAA,EAAQ;AAAA,UACZ,CAAA;AAAA,QACJ,SAAS,GAAA,EAAK;AACV,UAAA,IAAI,OAAA,EAAS;AACb,UAAA,OAAA,GAAU,IAAA;AACV,UAAA,YAAA,CAAa,KAAK,CAAA;AAClB,UAAA,MAAA,CAAO,IAAA,CAAK;AAAA,YACR,KAAA,EAAO,GAAA;AAAA,YACP,EAAA,EAAI,KAAA;AAAA,YACJ,MAAA,EAAQ,GAAA,YAAe,KAAA,GAAQ,GAAA,CAAI,OAAA,GAAU,SAAA;AAAA,YAC7C,MAAA,EAAQ;AAAA,WACX,CAAA;AACD,UAAA,OAAA,EAAQ;AAAA,QACZ;AAAA,MACJ,CAAC;AAAA;AACT,GACJ;AAEA,EAAA,OAAO;AAAA,IACH,MAAA,EAAQ,KAAA,CAAM,IAAA,CAAK,IAAA,CAAK,QAAQ,CAAA,CAAE,IAAA,CAAK,CAAC,CAAA,EAAG,CAAA,KAAM,CAAA,CAAE,UAAA,GAAa,EAAE,UAAU,CAAA;AAAA,IAC5E,WAAA,EAAa;AAAA,GACjB;AACJ","file":"index.js","sourcesContent":["/**\n * @orangecheck/nostr-core\n *\n * Browser-compatible Nostr client used by every OrangeCheck family web\n * app. Raw NIP-01 over WebSocket against a list of relays. Every operation\n * races all relays in parallel and reports per-relay status so the caller\n * can distinguish \"nobody replied\" from \"one relay rejected.\" Retries with\n * exponential backoff on transport errors only.\n *\n * No dependencies — uses the platform `WebSocket` global. Works in any\n * runtime that ships a WHATWG WebSocket (browser, Node 22+, Deno, Bun,\n * Cloudflare Workers).\n *\n * Source-of-truth `DEFAULT_RELAYS` for the OC family. Co-publishes to four\n * public relays plus `wss://relay.ochk.io` (the family's first-party\n * kind-allowlisted relay — see https://github.com/orangecheck/oc-relay-infra).\n *\n * **Hard invariant:** `DEFAULT_RELAYS` MUST contain at least two entries,\n * and MUST NOT be `relay.ochk.io` alone. Enforced at the type level — a\n * future engineer simplifying to ours-only fails `tsc`. See `_validate`\n * below.\n */\n\n// ─────────────────────────────────────────────────────────────────────────\n// Build-time invariants + default relay set.\n// ─────────────────────────────────────────────────────────────────────────\n\n/**\n * Family-relay invariants applied to `DEFAULT_RELAYS`. The relay set must:\n * 1. Contain at least two relays — single-relay defaults are always wrong\n * because the family's BYPASS principle requires public-relay co-publish.\n * 2. Not be `wss://relay.ochk.io` alone — relay.ochk.io is additive, never\n * a single point of failure. See oc-relay-infra/BYPASS.md.\n *\n * If `T` violates either rule, this resolves to `never` and the assignment\n * below fails at `tsc` time.\n */\ntype ValidRelaySet<T extends readonly string[]> =\n T['length'] extends 0 | 1 ? never :\n T extends readonly ['wss://relay.ochk.io'] ? never :\n T;\n\nconst _RELAYS: ValidRelaySet<readonly [\n 'wss://relay.nostr.band',\n 'wss://nos.lol',\n 'wss://relay.primal.net',\n 'wss://offchain.pub',\n 'wss://relay.ochk.io',\n]> = [\n 'wss://relay.nostr.band',\n 'wss://nos.lol',\n 'wss://relay.primal.net',\n 'wss://offchain.pub',\n // First-party family relay — kind allowlist 30078–30086 + canonical\n // OC d-tag prefixes. Always co-published with public relays; never\n // the only copy. See https://github.com/orangecheck/oc-relay-infra.\n 'wss://relay.ochk.io',\n] as const;\n\n/**\n * Default relay set for OrangeCheck family Nostr publishes + queries.\n *\n * Frozen at runtime; consumers MAY pass an explicit `relays` arg to any\n * function in this package to override.\n */\nexport const DEFAULT_RELAYS: readonly string[] = Object.freeze([..._RELAYS]);\n\n// ─────────────────────────────────────────────────────────────────────────\n// Wire types — NIP-01 event + filter + result shapes.\n// ─────────────────────────────────────────────────────────────────────────\n\nexport interface NostrEvent {\n id: string;\n kind: number;\n pubkey: string;\n created_at: number;\n content: string;\n tags: string[][];\n sig: string;\n}\n\nexport interface PublishResult {\n relay: string;\n ok: boolean;\n reason?: string;\n attempts: number;\n}\n\nexport interface Filter {\n kinds?: number[];\n authors?: string[];\n ids?: string[];\n limit?: number;\n since?: number;\n until?: number;\n /** NIP-12 indexable `d`-tag filter. */\n '#d'?: string[];\n /** NIP-12 indexable single-letter tag filter. */\n '#t'?: string[];\n /** Used by OC Vote (kind 30081 ballots). */\n '#poll_id'?: string[];\n /** Used by OC Vote (kind 30081 ballots). */\n '#voter'?: string[];\n /** Used by OC Vote (kind 30080 polls). */\n '#creator'?: string[];\n /** Other indexable single-letter tags clients may filter on. */\n [key: `#${string}`]: string[] | undefined;\n}\n\nexport interface QueryResult {\n events: NostrEvent[];\n relayStatus: { relay: string; ok: boolean; reason?: string; events: number }[];\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Internal — frame parsing + retry config.\n// ─────────────────────────────────────────────────────────────────────────\n\ntype FrameType = 'OK' | 'EVENT' | 'EOSE' | 'NOTICE' | 'CLOSED';\ninterface RelayFrame {\n type: FrameType;\n payload: unknown[];\n}\n\nfunction parseFrame(raw: string): RelayFrame | null {\n try {\n const arr = JSON.parse(raw) as unknown[];\n if (!Array.isArray(arr) || arr.length === 0) return null;\n const type = arr[0];\n if (\n type === 'OK' ||\n type === 'EVENT' ||\n type === 'EOSE' ||\n type === 'NOTICE' ||\n type === 'CLOSED'\n ) {\n return { type: type as FrameType, payload: arr.slice(1) };\n }\n return null;\n } catch {\n return null;\n }\n}\n\ninterface RetryOptions {\n attempts: number;\n timeoutMs: number;\n initialBackoffMs: number;\n maxBackoffMs: number;\n}\n\nconst DEFAULT_RETRY: RetryOptions = {\n attempts: 3,\n timeoutMs: 5000,\n initialBackoffMs: 500,\n maxBackoffMs: 4000,\n};\n\nfunction delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Publish — write an event to one or more relays in parallel, with retry.\n// ─────────────────────────────────────────────────────────────────────────\n\nasync function publishOne(\n url: string,\n event: NostrEvent,\n retry: RetryOptions\n): Promise<PublishResult> {\n let attempts = 0;\n let backoff = retry.initialBackoffMs;\n let lastReason: string | undefined;\n\n while (attempts < retry.attempts) {\n attempts++;\n const attempt = await attemptPublish(url, event, retry.timeoutMs);\n if (attempt.ok) {\n return {\n relay: url,\n ok: true,\n attempts,\n ...(attempt.reason ? { reason: attempt.reason } : {}),\n };\n }\n lastReason = attempt.reason;\n if (attempt.retryable && attempts < retry.attempts) {\n await delay(backoff);\n backoff = Math.min(backoff * 2, retry.maxBackoffMs);\n continue;\n }\n break;\n }\n return {\n relay: url,\n ok: false,\n attempts,\n ...(lastReason ? { reason: lastReason } : {}),\n };\n}\n\nfunction attemptPublish(\n url: string,\n event: NostrEvent,\n timeoutMs: number\n): Promise<{ ok: boolean; reason?: string; retryable: boolean }> {\n return new Promise((resolve) => {\n let settled = false;\n let ws: WebSocket | null = null;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try {\n ws?.close();\n } catch {}\n resolve({ ok: false, reason: 'timeout', retryable: true });\n }, timeoutMs);\n try {\n ws = new WebSocket(url);\n ws.onopen = () => ws?.send(JSON.stringify(['EVENT', event]));\n ws.onmessage = (msg) => {\n const frame = parseFrame(msg.data as string);\n if (!frame) return;\n if (frame.type === 'OK' && frame.payload[0] === event.id) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n try {\n ws?.close();\n } catch {}\n const ok = frame.payload[1] === true;\n const reason = frame.payload[2] as string | undefined;\n resolve({\n ok,\n ...(reason ? { reason } : {}),\n retryable: false,\n });\n }\n };\n ws.onerror = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n resolve({ ok: false, reason: 'websocket_error', retryable: true });\n };\n ws.onclose = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n resolve({ ok: false, reason: 'closed_early', retryable: true });\n };\n } catch (err) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n resolve({\n ok: false,\n reason: err instanceof Error ? err.message : 'unknown',\n retryable: true,\n });\n }\n });\n}\n\n/**\n * Publish a NIP-01 event to all `relays` in parallel. Returns one\n * `PublishResult` per relay. Default timeout 5000ms.\n */\nexport async function publishEvent(\n event: NostrEvent,\n relays: readonly string[] = DEFAULT_RELAYS,\n timeoutMs = 5000\n): Promise<PublishResult[]> {\n const retry: RetryOptions = { ...DEFAULT_RETRY, timeoutMs };\n return Promise.all(relays.map((r) => publishOne(r, event, retry)));\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Query — REQ → EOSE → close. Races all relays; first to EOSE wins.\n// ─────────────────────────────────────────────────────────────────────────\n\n/**\n * Issue a NIP-01 REQ across all `relays` in parallel. Returns deduplicated\n * events sorted newest-first plus per-relay status.\n *\n * Default timeout 1500ms — short enough that a momentary blip on any one\n * relay (including relay.ochk.io) never holds up the racing reads. Pass an\n * explicit `timeoutMs` for slow filters or for use cases where waiting on\n * the slowest relay matters.\n */\nexport async function queryEvents(\n filter: Filter,\n relays: readonly string[] = DEFAULT_RELAYS,\n timeoutMs = 1500\n): Promise<QueryResult> {\n const subId = 'ocnc-' + Math.random().toString(36).slice(2, 10);\n const byId = new Map<string, NostrEvent>();\n const status: QueryResult['relayStatus'] = [];\n\n await Promise.all(\n relays.map(\n (url) =>\n new Promise<void>((resolve) => {\n let settled = false;\n let count = 0;\n let reason: string | undefined;\n let ws: WebSocket | null = null;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try {\n ws?.close();\n } catch {}\n status.push({\n relay: url,\n ok: count > 0,\n reason: reason ?? 'timeout',\n events: count,\n });\n resolve();\n }, timeoutMs);\n try {\n ws = new WebSocket(url);\n ws.onopen = () => ws?.send(JSON.stringify(['REQ', subId, filter]));\n ws.onmessage = (msg) => {\n const frame = parseFrame(msg.data as string);\n if (!frame) return;\n if (frame.type === 'EVENT' && frame.payload[0] === subId) {\n const event = frame.payload[1] as NostrEvent | undefined;\n if (event && event.id) {\n byId.set(event.id, event);\n count++;\n }\n } else if (frame.type === 'EOSE' && frame.payload[0] === subId) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n try {\n ws?.send(JSON.stringify(['CLOSE', subId]));\n ws?.close();\n } catch {}\n status.push({ relay: url, ok: true, events: count });\n resolve();\n } else if (frame.type === 'NOTICE') {\n reason = String(frame.payload[0] ?? 'notice');\n }\n };\n ws.onerror = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n status.push({\n relay: url,\n ok: false,\n reason: 'ws_error',\n events: count,\n });\n resolve();\n };\n ws.onclose = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n status.push({\n relay: url,\n ok: count > 0,\n reason: reason ?? 'closed_early',\n events: count,\n });\n resolve();\n };\n } catch (err) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n status.push({\n relay: url,\n ok: false,\n reason: err instanceof Error ? err.message : 'unknown',\n events: count,\n });\n resolve();\n }\n })\n )\n );\n\n return {\n events: Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at),\n relayStatus: status,\n };\n}\n"]}
package/dist/index.mjs ADDED
@@ -0,0 +1,229 @@
1
+ // src/index.ts
2
+ var _RELAYS = [
3
+ "wss://relay.nostr.band",
4
+ "wss://nos.lol",
5
+ "wss://relay.primal.net",
6
+ "wss://offchain.pub",
7
+ // First-party family relay — kind allowlist 30078–30086 + canonical
8
+ // OC d-tag prefixes. Always co-published with public relays; never
9
+ // the only copy. See https://github.com/orangecheck/oc-relay-infra.
10
+ "wss://relay.ochk.io"
11
+ ];
12
+ var DEFAULT_RELAYS = Object.freeze([..._RELAYS]);
13
+ function parseFrame(raw) {
14
+ try {
15
+ const arr = JSON.parse(raw);
16
+ if (!Array.isArray(arr) || arr.length === 0) return null;
17
+ const type = arr[0];
18
+ if (type === "OK" || type === "EVENT" || type === "EOSE" || type === "NOTICE" || type === "CLOSED") {
19
+ return { type, payload: arr.slice(1) };
20
+ }
21
+ return null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+ var DEFAULT_RETRY = {
27
+ attempts: 3,
28
+ timeoutMs: 5e3,
29
+ initialBackoffMs: 500,
30
+ maxBackoffMs: 4e3
31
+ };
32
+ function delay(ms) {
33
+ return new Promise((resolve) => setTimeout(resolve, ms));
34
+ }
35
+ async function publishOne(url, event, retry) {
36
+ let attempts = 0;
37
+ let backoff = retry.initialBackoffMs;
38
+ let lastReason;
39
+ while (attempts < retry.attempts) {
40
+ attempts++;
41
+ const attempt = await attemptPublish(url, event, retry.timeoutMs);
42
+ if (attempt.ok) {
43
+ return {
44
+ relay: url,
45
+ ok: true,
46
+ attempts,
47
+ ...attempt.reason ? { reason: attempt.reason } : {}
48
+ };
49
+ }
50
+ lastReason = attempt.reason;
51
+ if (attempt.retryable && attempts < retry.attempts) {
52
+ await delay(backoff);
53
+ backoff = Math.min(backoff * 2, retry.maxBackoffMs);
54
+ continue;
55
+ }
56
+ break;
57
+ }
58
+ return {
59
+ relay: url,
60
+ ok: false,
61
+ attempts,
62
+ ...lastReason ? { reason: lastReason } : {}
63
+ };
64
+ }
65
+ function attemptPublish(url, event, timeoutMs) {
66
+ return new Promise((resolve) => {
67
+ let settled = false;
68
+ let ws = null;
69
+ const timer = setTimeout(() => {
70
+ if (settled) return;
71
+ settled = true;
72
+ try {
73
+ ws?.close();
74
+ } catch {
75
+ }
76
+ resolve({ ok: false, reason: "timeout", retryable: true });
77
+ }, timeoutMs);
78
+ try {
79
+ ws = new WebSocket(url);
80
+ ws.onopen = () => ws?.send(JSON.stringify(["EVENT", event]));
81
+ ws.onmessage = (msg) => {
82
+ const frame = parseFrame(msg.data);
83
+ if (!frame) return;
84
+ if (frame.type === "OK" && frame.payload[0] === event.id) {
85
+ if (settled) return;
86
+ settled = true;
87
+ clearTimeout(timer);
88
+ try {
89
+ ws?.close();
90
+ } catch {
91
+ }
92
+ const ok = frame.payload[1] === true;
93
+ const reason = frame.payload[2];
94
+ resolve({
95
+ ok,
96
+ ...reason ? { reason } : {},
97
+ retryable: false
98
+ });
99
+ }
100
+ };
101
+ ws.onerror = () => {
102
+ if (settled) return;
103
+ settled = true;
104
+ clearTimeout(timer);
105
+ resolve({ ok: false, reason: "websocket_error", retryable: true });
106
+ };
107
+ ws.onclose = () => {
108
+ if (settled) return;
109
+ settled = true;
110
+ clearTimeout(timer);
111
+ resolve({ ok: false, reason: "closed_early", retryable: true });
112
+ };
113
+ } catch (err) {
114
+ if (settled) return;
115
+ settled = true;
116
+ clearTimeout(timer);
117
+ resolve({
118
+ ok: false,
119
+ reason: err instanceof Error ? err.message : "unknown",
120
+ retryable: true
121
+ });
122
+ }
123
+ });
124
+ }
125
+ async function publishEvent(event, relays = DEFAULT_RELAYS, timeoutMs = 5e3) {
126
+ const retry = { ...DEFAULT_RETRY, timeoutMs };
127
+ return Promise.all(relays.map((r) => publishOne(r, event, retry)));
128
+ }
129
+ async function queryEvents(filter, relays = DEFAULT_RELAYS, timeoutMs = 1500) {
130
+ const subId = "ocnc-" + Math.random().toString(36).slice(2, 10);
131
+ const byId = /* @__PURE__ */ new Map();
132
+ const status = [];
133
+ await Promise.all(
134
+ relays.map(
135
+ (url) => new Promise((resolve) => {
136
+ let settled = false;
137
+ let count = 0;
138
+ let reason;
139
+ let ws = null;
140
+ const timer = setTimeout(() => {
141
+ if (settled) return;
142
+ settled = true;
143
+ try {
144
+ ws?.close();
145
+ } catch {
146
+ }
147
+ status.push({
148
+ relay: url,
149
+ ok: count > 0,
150
+ reason: reason ?? "timeout",
151
+ events: count
152
+ });
153
+ resolve();
154
+ }, timeoutMs);
155
+ try {
156
+ ws = new WebSocket(url);
157
+ ws.onopen = () => ws?.send(JSON.stringify(["REQ", subId, filter]));
158
+ ws.onmessage = (msg) => {
159
+ const frame = parseFrame(msg.data);
160
+ if (!frame) return;
161
+ if (frame.type === "EVENT" && frame.payload[0] === subId) {
162
+ const event = frame.payload[1];
163
+ if (event && event.id) {
164
+ byId.set(event.id, event);
165
+ count++;
166
+ }
167
+ } else if (frame.type === "EOSE" && frame.payload[0] === subId) {
168
+ if (settled) return;
169
+ settled = true;
170
+ clearTimeout(timer);
171
+ try {
172
+ ws?.send(JSON.stringify(["CLOSE", subId]));
173
+ ws?.close();
174
+ } catch {
175
+ }
176
+ status.push({ relay: url, ok: true, events: count });
177
+ resolve();
178
+ } else if (frame.type === "NOTICE") {
179
+ reason = String(frame.payload[0] ?? "notice");
180
+ }
181
+ };
182
+ ws.onerror = () => {
183
+ if (settled) return;
184
+ settled = true;
185
+ clearTimeout(timer);
186
+ status.push({
187
+ relay: url,
188
+ ok: false,
189
+ reason: "ws_error",
190
+ events: count
191
+ });
192
+ resolve();
193
+ };
194
+ ws.onclose = () => {
195
+ if (settled) return;
196
+ settled = true;
197
+ clearTimeout(timer);
198
+ status.push({
199
+ relay: url,
200
+ ok: count > 0,
201
+ reason: reason ?? "closed_early",
202
+ events: count
203
+ });
204
+ resolve();
205
+ };
206
+ } catch (err) {
207
+ if (settled) return;
208
+ settled = true;
209
+ clearTimeout(timer);
210
+ status.push({
211
+ relay: url,
212
+ ok: false,
213
+ reason: err instanceof Error ? err.message : "unknown",
214
+ events: count
215
+ });
216
+ resolve();
217
+ }
218
+ })
219
+ )
220
+ );
221
+ return {
222
+ events: Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at),
223
+ relayStatus: status
224
+ };
225
+ }
226
+
227
+ export { DEFAULT_RELAYS, publishEvent, queryEvents };
228
+ //# sourceMappingURL=index.mjs.map
229
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["/**\n * @orangecheck/nostr-core\n *\n * Browser-compatible Nostr client used by every OrangeCheck family web\n * app. Raw NIP-01 over WebSocket against a list of relays. Every operation\n * races all relays in parallel and reports per-relay status so the caller\n * can distinguish \"nobody replied\" from \"one relay rejected.\" Retries with\n * exponential backoff on transport errors only.\n *\n * No dependencies — uses the platform `WebSocket` global. Works in any\n * runtime that ships a WHATWG WebSocket (browser, Node 22+, Deno, Bun,\n * Cloudflare Workers).\n *\n * Source-of-truth `DEFAULT_RELAYS` for the OC family. Co-publishes to four\n * public relays plus `wss://relay.ochk.io` (the family's first-party\n * kind-allowlisted relay — see https://github.com/orangecheck/oc-relay-infra).\n *\n * **Hard invariant:** `DEFAULT_RELAYS` MUST contain at least two entries,\n * and MUST NOT be `relay.ochk.io` alone. Enforced at the type level — a\n * future engineer simplifying to ours-only fails `tsc`. See `_validate`\n * below.\n */\n\n// ─────────────────────────────────────────────────────────────────────────\n// Build-time invariants + default relay set.\n// ─────────────────────────────────────────────────────────────────────────\n\n/**\n * Family-relay invariants applied to `DEFAULT_RELAYS`. The relay set must:\n * 1. Contain at least two relays — single-relay defaults are always wrong\n * because the family's BYPASS principle requires public-relay co-publish.\n * 2. Not be `wss://relay.ochk.io` alone — relay.ochk.io is additive, never\n * a single point of failure. See oc-relay-infra/BYPASS.md.\n *\n * If `T` violates either rule, this resolves to `never` and the assignment\n * below fails at `tsc` time.\n */\ntype ValidRelaySet<T extends readonly string[]> =\n T['length'] extends 0 | 1 ? never :\n T extends readonly ['wss://relay.ochk.io'] ? never :\n T;\n\nconst _RELAYS: ValidRelaySet<readonly [\n 'wss://relay.nostr.band',\n 'wss://nos.lol',\n 'wss://relay.primal.net',\n 'wss://offchain.pub',\n 'wss://relay.ochk.io',\n]> = [\n 'wss://relay.nostr.band',\n 'wss://nos.lol',\n 'wss://relay.primal.net',\n 'wss://offchain.pub',\n // First-party family relay — kind allowlist 30078–30086 + canonical\n // OC d-tag prefixes. Always co-published with public relays; never\n // the only copy. See https://github.com/orangecheck/oc-relay-infra.\n 'wss://relay.ochk.io',\n] as const;\n\n/**\n * Default relay set for OrangeCheck family Nostr publishes + queries.\n *\n * Frozen at runtime; consumers MAY pass an explicit `relays` arg to any\n * function in this package to override.\n */\nexport const DEFAULT_RELAYS: readonly string[] = Object.freeze([..._RELAYS]);\n\n// ─────────────────────────────────────────────────────────────────────────\n// Wire types — NIP-01 event + filter + result shapes.\n// ─────────────────────────────────────────────────────────────────────────\n\nexport interface NostrEvent {\n id: string;\n kind: number;\n pubkey: string;\n created_at: number;\n content: string;\n tags: string[][];\n sig: string;\n}\n\nexport interface PublishResult {\n relay: string;\n ok: boolean;\n reason?: string;\n attempts: number;\n}\n\nexport interface Filter {\n kinds?: number[];\n authors?: string[];\n ids?: string[];\n limit?: number;\n since?: number;\n until?: number;\n /** NIP-12 indexable `d`-tag filter. */\n '#d'?: string[];\n /** NIP-12 indexable single-letter tag filter. */\n '#t'?: string[];\n /** Used by OC Vote (kind 30081 ballots). */\n '#poll_id'?: string[];\n /** Used by OC Vote (kind 30081 ballots). */\n '#voter'?: string[];\n /** Used by OC Vote (kind 30080 polls). */\n '#creator'?: string[];\n /** Other indexable single-letter tags clients may filter on. */\n [key: `#${string}`]: string[] | undefined;\n}\n\nexport interface QueryResult {\n events: NostrEvent[];\n relayStatus: { relay: string; ok: boolean; reason?: string; events: number }[];\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Internal — frame parsing + retry config.\n// ─────────────────────────────────────────────────────────────────────────\n\ntype FrameType = 'OK' | 'EVENT' | 'EOSE' | 'NOTICE' | 'CLOSED';\ninterface RelayFrame {\n type: FrameType;\n payload: unknown[];\n}\n\nfunction parseFrame(raw: string): RelayFrame | null {\n try {\n const arr = JSON.parse(raw) as unknown[];\n if (!Array.isArray(arr) || arr.length === 0) return null;\n const type = arr[0];\n if (\n type === 'OK' ||\n type === 'EVENT' ||\n type === 'EOSE' ||\n type === 'NOTICE' ||\n type === 'CLOSED'\n ) {\n return { type: type as FrameType, payload: arr.slice(1) };\n }\n return null;\n } catch {\n return null;\n }\n}\n\ninterface RetryOptions {\n attempts: number;\n timeoutMs: number;\n initialBackoffMs: number;\n maxBackoffMs: number;\n}\n\nconst DEFAULT_RETRY: RetryOptions = {\n attempts: 3,\n timeoutMs: 5000,\n initialBackoffMs: 500,\n maxBackoffMs: 4000,\n};\n\nfunction delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Publish — write an event to one or more relays in parallel, with retry.\n// ─────────────────────────────────────────────────────────────────────────\n\nasync function publishOne(\n url: string,\n event: NostrEvent,\n retry: RetryOptions\n): Promise<PublishResult> {\n let attempts = 0;\n let backoff = retry.initialBackoffMs;\n let lastReason: string | undefined;\n\n while (attempts < retry.attempts) {\n attempts++;\n const attempt = await attemptPublish(url, event, retry.timeoutMs);\n if (attempt.ok) {\n return {\n relay: url,\n ok: true,\n attempts,\n ...(attempt.reason ? { reason: attempt.reason } : {}),\n };\n }\n lastReason = attempt.reason;\n if (attempt.retryable && attempts < retry.attempts) {\n await delay(backoff);\n backoff = Math.min(backoff * 2, retry.maxBackoffMs);\n continue;\n }\n break;\n }\n return {\n relay: url,\n ok: false,\n attempts,\n ...(lastReason ? { reason: lastReason } : {}),\n };\n}\n\nfunction attemptPublish(\n url: string,\n event: NostrEvent,\n timeoutMs: number\n): Promise<{ ok: boolean; reason?: string; retryable: boolean }> {\n return new Promise((resolve) => {\n let settled = false;\n let ws: WebSocket | null = null;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try {\n ws?.close();\n } catch {}\n resolve({ ok: false, reason: 'timeout', retryable: true });\n }, timeoutMs);\n try {\n ws = new WebSocket(url);\n ws.onopen = () => ws?.send(JSON.stringify(['EVENT', event]));\n ws.onmessage = (msg) => {\n const frame = parseFrame(msg.data as string);\n if (!frame) return;\n if (frame.type === 'OK' && frame.payload[0] === event.id) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n try {\n ws?.close();\n } catch {}\n const ok = frame.payload[1] === true;\n const reason = frame.payload[2] as string | undefined;\n resolve({\n ok,\n ...(reason ? { reason } : {}),\n retryable: false,\n });\n }\n };\n ws.onerror = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n resolve({ ok: false, reason: 'websocket_error', retryable: true });\n };\n ws.onclose = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n resolve({ ok: false, reason: 'closed_early', retryable: true });\n };\n } catch (err) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n resolve({\n ok: false,\n reason: err instanceof Error ? err.message : 'unknown',\n retryable: true,\n });\n }\n });\n}\n\n/**\n * Publish a NIP-01 event to all `relays` in parallel. Returns one\n * `PublishResult` per relay. Default timeout 5000ms.\n */\nexport async function publishEvent(\n event: NostrEvent,\n relays: readonly string[] = DEFAULT_RELAYS,\n timeoutMs = 5000\n): Promise<PublishResult[]> {\n const retry: RetryOptions = { ...DEFAULT_RETRY, timeoutMs };\n return Promise.all(relays.map((r) => publishOne(r, event, retry)));\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Query — REQ → EOSE → close. Races all relays; first to EOSE wins.\n// ─────────────────────────────────────────────────────────────────────────\n\n/**\n * Issue a NIP-01 REQ across all `relays` in parallel. Returns deduplicated\n * events sorted newest-first plus per-relay status.\n *\n * Default timeout 1500ms — short enough that a momentary blip on any one\n * relay (including relay.ochk.io) never holds up the racing reads. Pass an\n * explicit `timeoutMs` for slow filters or for use cases where waiting on\n * the slowest relay matters.\n */\nexport async function queryEvents(\n filter: Filter,\n relays: readonly string[] = DEFAULT_RELAYS,\n timeoutMs = 1500\n): Promise<QueryResult> {\n const subId = 'ocnc-' + Math.random().toString(36).slice(2, 10);\n const byId = new Map<string, NostrEvent>();\n const status: QueryResult['relayStatus'] = [];\n\n await Promise.all(\n relays.map(\n (url) =>\n new Promise<void>((resolve) => {\n let settled = false;\n let count = 0;\n let reason: string | undefined;\n let ws: WebSocket | null = null;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try {\n ws?.close();\n } catch {}\n status.push({\n relay: url,\n ok: count > 0,\n reason: reason ?? 'timeout',\n events: count,\n });\n resolve();\n }, timeoutMs);\n try {\n ws = new WebSocket(url);\n ws.onopen = () => ws?.send(JSON.stringify(['REQ', subId, filter]));\n ws.onmessage = (msg) => {\n const frame = parseFrame(msg.data as string);\n if (!frame) return;\n if (frame.type === 'EVENT' && frame.payload[0] === subId) {\n const event = frame.payload[1] as NostrEvent | undefined;\n if (event && event.id) {\n byId.set(event.id, event);\n count++;\n }\n } else if (frame.type === 'EOSE' && frame.payload[0] === subId) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n try {\n ws?.send(JSON.stringify(['CLOSE', subId]));\n ws?.close();\n } catch {}\n status.push({ relay: url, ok: true, events: count });\n resolve();\n } else if (frame.type === 'NOTICE') {\n reason = String(frame.payload[0] ?? 'notice');\n }\n };\n ws.onerror = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n status.push({\n relay: url,\n ok: false,\n reason: 'ws_error',\n events: count,\n });\n resolve();\n };\n ws.onclose = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n status.push({\n relay: url,\n ok: count > 0,\n reason: reason ?? 'closed_early',\n events: count,\n });\n resolve();\n };\n } catch (err) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n status.push({\n relay: url,\n ok: false,\n reason: err instanceof Error ? err.message : 'unknown',\n events: count,\n });\n resolve();\n }\n })\n )\n );\n\n return {\n events: Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at),\n relayStatus: status,\n };\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@orangecheck/nostr-core",
3
+ "version": "0.1.0",
4
+ "description": "Browser-compatible Nostr client used by every OrangeCheck family web app. Raw NIP-01 over WebSocket against a list of relays. publishEvent / queryEvents / DEFAULT_RELAYS.",
5
+ "keywords": [
6
+ "nostr",
7
+ "nip-01",
8
+ "websocket",
9
+ "relay",
10
+ "orangecheck"
11
+ ],
12
+ "author": "OrangeCheck",
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/orangecheck/oc-packages.git",
17
+ "directory": "nostr-core"
18
+ },
19
+ "homepage": "https://github.com/orangecheck/oc-packages/tree/main/nostr-core",
20
+ "bugs": {
21
+ "url": "https://github.com/orangecheck/oc-packages/issues"
22
+ },
23
+ "main": "./dist/index.js",
24
+ "module": "./dist/index.mjs",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.mjs",
30
+ "require": "./dist/index.js"
31
+ }
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "src",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "scripts": {
40
+ "build": "tsup",
41
+ "dev": "tsup --watch",
42
+ "type-check": "tsc --noEmit",
43
+ "test": "vitest run --passWithNoTests",
44
+ "test:watch": "vitest",
45
+ "clean": "rm -rf dist",
46
+ "prepublishOnly": "npm run clean && npm run build"
47
+ },
48
+ "dependencies": {},
49
+ "devDependencies": {
50
+ "@types/node": "^22.10.2",
51
+ "tsup": "^8.3.5",
52
+ "typescript": "^5.7.2",
53
+ "vitest": "^3.2.4"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ }
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,393 @@
1
+ /**
2
+ * @orangecheck/nostr-core
3
+ *
4
+ * Browser-compatible Nostr client used by every OrangeCheck family web
5
+ * app. Raw NIP-01 over WebSocket against a list of relays. Every operation
6
+ * races all relays in parallel and reports per-relay status so the caller
7
+ * can distinguish "nobody replied" from "one relay rejected." Retries with
8
+ * exponential backoff on transport errors only.
9
+ *
10
+ * No dependencies — uses the platform `WebSocket` global. Works in any
11
+ * runtime that ships a WHATWG WebSocket (browser, Node 22+, Deno, Bun,
12
+ * Cloudflare Workers).
13
+ *
14
+ * Source-of-truth `DEFAULT_RELAYS` for the OC family. Co-publishes to four
15
+ * public relays plus `wss://relay.ochk.io` (the family's first-party
16
+ * kind-allowlisted relay — see https://github.com/orangecheck/oc-relay-infra).
17
+ *
18
+ * **Hard invariant:** `DEFAULT_RELAYS` MUST contain at least two entries,
19
+ * and MUST NOT be `relay.ochk.io` alone. Enforced at the type level — a
20
+ * future engineer simplifying to ours-only fails `tsc`. See `_validate`
21
+ * below.
22
+ */
23
+
24
+ // ─────────────────────────────────────────────────────────────────────────
25
+ // Build-time invariants + default relay set.
26
+ // ─────────────────────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Family-relay invariants applied to `DEFAULT_RELAYS`. The relay set must:
30
+ * 1. Contain at least two relays — single-relay defaults are always wrong
31
+ * because the family's BYPASS principle requires public-relay co-publish.
32
+ * 2. Not be `wss://relay.ochk.io` alone — relay.ochk.io is additive, never
33
+ * a single point of failure. See oc-relay-infra/BYPASS.md.
34
+ *
35
+ * If `T` violates either rule, this resolves to `never` and the assignment
36
+ * below fails at `tsc` time.
37
+ */
38
+ type ValidRelaySet<T extends readonly string[]> =
39
+ T['length'] extends 0 | 1 ? never :
40
+ T extends readonly ['wss://relay.ochk.io'] ? never :
41
+ T;
42
+
43
+ const _RELAYS: ValidRelaySet<readonly [
44
+ 'wss://relay.nostr.band',
45
+ 'wss://nos.lol',
46
+ 'wss://relay.primal.net',
47
+ 'wss://offchain.pub',
48
+ 'wss://relay.ochk.io',
49
+ ]> = [
50
+ 'wss://relay.nostr.band',
51
+ 'wss://nos.lol',
52
+ 'wss://relay.primal.net',
53
+ 'wss://offchain.pub',
54
+ // First-party family relay — kind allowlist 30078–30086 + canonical
55
+ // OC d-tag prefixes. Always co-published with public relays; never
56
+ // the only copy. See https://github.com/orangecheck/oc-relay-infra.
57
+ 'wss://relay.ochk.io',
58
+ ] as const;
59
+
60
+ /**
61
+ * Default relay set for OrangeCheck family Nostr publishes + queries.
62
+ *
63
+ * Frozen at runtime; consumers MAY pass an explicit `relays` arg to any
64
+ * function in this package to override.
65
+ */
66
+ export const DEFAULT_RELAYS: readonly string[] = Object.freeze([..._RELAYS]);
67
+
68
+ // ─────────────────────────────────────────────────────────────────────────
69
+ // Wire types — NIP-01 event + filter + result shapes.
70
+ // ─────────────────────────────────────────────────────────────────────────
71
+
72
+ export interface NostrEvent {
73
+ id: string;
74
+ kind: number;
75
+ pubkey: string;
76
+ created_at: number;
77
+ content: string;
78
+ tags: string[][];
79
+ sig: string;
80
+ }
81
+
82
+ export interface PublishResult {
83
+ relay: string;
84
+ ok: boolean;
85
+ reason?: string;
86
+ attempts: number;
87
+ }
88
+
89
+ export interface Filter {
90
+ kinds?: number[];
91
+ authors?: string[];
92
+ ids?: string[];
93
+ limit?: number;
94
+ since?: number;
95
+ until?: number;
96
+ /** NIP-12 indexable `d`-tag filter. */
97
+ '#d'?: string[];
98
+ /** NIP-12 indexable single-letter tag filter. */
99
+ '#t'?: string[];
100
+ /** Used by OC Vote (kind 30081 ballots). */
101
+ '#poll_id'?: string[];
102
+ /** Used by OC Vote (kind 30081 ballots). */
103
+ '#voter'?: string[];
104
+ /** Used by OC Vote (kind 30080 polls). */
105
+ '#creator'?: string[];
106
+ /** Other indexable single-letter tags clients may filter on. */
107
+ [key: `#${string}`]: string[] | undefined;
108
+ }
109
+
110
+ export interface QueryResult {
111
+ events: NostrEvent[];
112
+ relayStatus: { relay: string; ok: boolean; reason?: string; events: number }[];
113
+ }
114
+
115
+ // ─────────────────────────────────────────────────────────────────────────
116
+ // Internal — frame parsing + retry config.
117
+ // ─────────────────────────────────────────────────────────────────────────
118
+
119
+ type FrameType = 'OK' | 'EVENT' | 'EOSE' | 'NOTICE' | 'CLOSED';
120
+ interface RelayFrame {
121
+ type: FrameType;
122
+ payload: unknown[];
123
+ }
124
+
125
+ function parseFrame(raw: string): RelayFrame | null {
126
+ try {
127
+ const arr = JSON.parse(raw) as unknown[];
128
+ if (!Array.isArray(arr) || arr.length === 0) return null;
129
+ const type = arr[0];
130
+ if (
131
+ type === 'OK' ||
132
+ type === 'EVENT' ||
133
+ type === 'EOSE' ||
134
+ type === 'NOTICE' ||
135
+ type === 'CLOSED'
136
+ ) {
137
+ return { type: type as FrameType, payload: arr.slice(1) };
138
+ }
139
+ return null;
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ interface RetryOptions {
146
+ attempts: number;
147
+ timeoutMs: number;
148
+ initialBackoffMs: number;
149
+ maxBackoffMs: number;
150
+ }
151
+
152
+ const DEFAULT_RETRY: RetryOptions = {
153
+ attempts: 3,
154
+ timeoutMs: 5000,
155
+ initialBackoffMs: 500,
156
+ maxBackoffMs: 4000,
157
+ };
158
+
159
+ function delay(ms: number): Promise<void> {
160
+ return new Promise((resolve) => setTimeout(resolve, ms));
161
+ }
162
+
163
+ // ─────────────────────────────────────────────────────────────────────────
164
+ // Publish — write an event to one or more relays in parallel, with retry.
165
+ // ─────────────────────────────────────────────────────────────────────────
166
+
167
+ async function publishOne(
168
+ url: string,
169
+ event: NostrEvent,
170
+ retry: RetryOptions
171
+ ): Promise<PublishResult> {
172
+ let attempts = 0;
173
+ let backoff = retry.initialBackoffMs;
174
+ let lastReason: string | undefined;
175
+
176
+ while (attempts < retry.attempts) {
177
+ attempts++;
178
+ const attempt = await attemptPublish(url, event, retry.timeoutMs);
179
+ if (attempt.ok) {
180
+ return {
181
+ relay: url,
182
+ ok: true,
183
+ attempts,
184
+ ...(attempt.reason ? { reason: attempt.reason } : {}),
185
+ };
186
+ }
187
+ lastReason = attempt.reason;
188
+ if (attempt.retryable && attempts < retry.attempts) {
189
+ await delay(backoff);
190
+ backoff = Math.min(backoff * 2, retry.maxBackoffMs);
191
+ continue;
192
+ }
193
+ break;
194
+ }
195
+ return {
196
+ relay: url,
197
+ ok: false,
198
+ attempts,
199
+ ...(lastReason ? { reason: lastReason } : {}),
200
+ };
201
+ }
202
+
203
+ function attemptPublish(
204
+ url: string,
205
+ event: NostrEvent,
206
+ timeoutMs: number
207
+ ): Promise<{ ok: boolean; reason?: string; retryable: boolean }> {
208
+ return new Promise((resolve) => {
209
+ let settled = false;
210
+ let ws: WebSocket | null = null;
211
+ const timer = setTimeout(() => {
212
+ if (settled) return;
213
+ settled = true;
214
+ try {
215
+ ws?.close();
216
+ } catch {}
217
+ resolve({ ok: false, reason: 'timeout', retryable: true });
218
+ }, timeoutMs);
219
+ try {
220
+ ws = new WebSocket(url);
221
+ ws.onopen = () => ws?.send(JSON.stringify(['EVENT', event]));
222
+ ws.onmessage = (msg) => {
223
+ const frame = parseFrame(msg.data as string);
224
+ if (!frame) return;
225
+ if (frame.type === 'OK' && frame.payload[0] === event.id) {
226
+ if (settled) return;
227
+ settled = true;
228
+ clearTimeout(timer);
229
+ try {
230
+ ws?.close();
231
+ } catch {}
232
+ const ok = frame.payload[1] === true;
233
+ const reason = frame.payload[2] as string | undefined;
234
+ resolve({
235
+ ok,
236
+ ...(reason ? { reason } : {}),
237
+ retryable: false,
238
+ });
239
+ }
240
+ };
241
+ ws.onerror = () => {
242
+ if (settled) return;
243
+ settled = true;
244
+ clearTimeout(timer);
245
+ resolve({ ok: false, reason: 'websocket_error', retryable: true });
246
+ };
247
+ ws.onclose = () => {
248
+ if (settled) return;
249
+ settled = true;
250
+ clearTimeout(timer);
251
+ resolve({ ok: false, reason: 'closed_early', retryable: true });
252
+ };
253
+ } catch (err) {
254
+ if (settled) return;
255
+ settled = true;
256
+ clearTimeout(timer);
257
+ resolve({
258
+ ok: false,
259
+ reason: err instanceof Error ? err.message : 'unknown',
260
+ retryable: true,
261
+ });
262
+ }
263
+ });
264
+ }
265
+
266
+ /**
267
+ * Publish a NIP-01 event to all `relays` in parallel. Returns one
268
+ * `PublishResult` per relay. Default timeout 5000ms.
269
+ */
270
+ export async function publishEvent(
271
+ event: NostrEvent,
272
+ relays: readonly string[] = DEFAULT_RELAYS,
273
+ timeoutMs = 5000
274
+ ): Promise<PublishResult[]> {
275
+ const retry: RetryOptions = { ...DEFAULT_RETRY, timeoutMs };
276
+ return Promise.all(relays.map((r) => publishOne(r, event, retry)));
277
+ }
278
+
279
+ // ─────────────────────────────────────────────────────────────────────────
280
+ // Query — REQ → EOSE → close. Races all relays; first to EOSE wins.
281
+ // ─────────────────────────────────────────────────────────────────────────
282
+
283
+ /**
284
+ * Issue a NIP-01 REQ across all `relays` in parallel. Returns deduplicated
285
+ * events sorted newest-first plus per-relay status.
286
+ *
287
+ * Default timeout 1500ms — short enough that a momentary blip on any one
288
+ * relay (including relay.ochk.io) never holds up the racing reads. Pass an
289
+ * explicit `timeoutMs` for slow filters or for use cases where waiting on
290
+ * the slowest relay matters.
291
+ */
292
+ export async function queryEvents(
293
+ filter: Filter,
294
+ relays: readonly string[] = DEFAULT_RELAYS,
295
+ timeoutMs = 1500
296
+ ): Promise<QueryResult> {
297
+ const subId = 'ocnc-' + Math.random().toString(36).slice(2, 10);
298
+ const byId = new Map<string, NostrEvent>();
299
+ const status: QueryResult['relayStatus'] = [];
300
+
301
+ await Promise.all(
302
+ relays.map(
303
+ (url) =>
304
+ new Promise<void>((resolve) => {
305
+ let settled = false;
306
+ let count = 0;
307
+ let reason: string | undefined;
308
+ let ws: WebSocket | null = null;
309
+ const timer = setTimeout(() => {
310
+ if (settled) return;
311
+ settled = true;
312
+ try {
313
+ ws?.close();
314
+ } catch {}
315
+ status.push({
316
+ relay: url,
317
+ ok: count > 0,
318
+ reason: reason ?? 'timeout',
319
+ events: count,
320
+ });
321
+ resolve();
322
+ }, timeoutMs);
323
+ try {
324
+ ws = new WebSocket(url);
325
+ ws.onopen = () => ws?.send(JSON.stringify(['REQ', subId, filter]));
326
+ ws.onmessage = (msg) => {
327
+ const frame = parseFrame(msg.data as string);
328
+ if (!frame) return;
329
+ if (frame.type === 'EVENT' && frame.payload[0] === subId) {
330
+ const event = frame.payload[1] as NostrEvent | undefined;
331
+ if (event && event.id) {
332
+ byId.set(event.id, event);
333
+ count++;
334
+ }
335
+ } else if (frame.type === 'EOSE' && frame.payload[0] === subId) {
336
+ if (settled) return;
337
+ settled = true;
338
+ clearTimeout(timer);
339
+ try {
340
+ ws?.send(JSON.stringify(['CLOSE', subId]));
341
+ ws?.close();
342
+ } catch {}
343
+ status.push({ relay: url, ok: true, events: count });
344
+ resolve();
345
+ } else if (frame.type === 'NOTICE') {
346
+ reason = String(frame.payload[0] ?? 'notice');
347
+ }
348
+ };
349
+ ws.onerror = () => {
350
+ if (settled) return;
351
+ settled = true;
352
+ clearTimeout(timer);
353
+ status.push({
354
+ relay: url,
355
+ ok: false,
356
+ reason: 'ws_error',
357
+ events: count,
358
+ });
359
+ resolve();
360
+ };
361
+ ws.onclose = () => {
362
+ if (settled) return;
363
+ settled = true;
364
+ clearTimeout(timer);
365
+ status.push({
366
+ relay: url,
367
+ ok: count > 0,
368
+ reason: reason ?? 'closed_early',
369
+ events: count,
370
+ });
371
+ resolve();
372
+ };
373
+ } catch (err) {
374
+ if (settled) return;
375
+ settled = true;
376
+ clearTimeout(timer);
377
+ status.push({
378
+ relay: url,
379
+ ok: false,
380
+ reason: err instanceof Error ? err.message : 'unknown',
381
+ events: count,
382
+ });
383
+ resolve();
384
+ }
385
+ })
386
+ )
387
+ );
388
+
389
+ return {
390
+ events: Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at),
391
+ relayStatus: status,
392
+ };
393
+ }