@orangecheck/nostr-core 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/index.test.ts +254 -0
- package/src/index.ts +9 -0
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# @orangecheck/nostr-core
|
|
2
2
|
|
|
3
|
+
> **Full reference:** [docs.ochk.io/sdk/nostr-core](https://docs.ochk.io/sdk/nostr-core) — auto-generated from the TypeScript source on every release.
|
|
4
|
+
> Hand-written prose below is the high-level overview; the docs site is the source of truth for every export, type, and signature.
|
|
5
|
+
|
|
6
|
+
|
|
3
7
|
Browser-compatible Nostr client used by every OrangeCheck family web app. Raw NIP-01 over WebSocket against a list of relays.
|
|
4
8
|
|
|
5
9
|
## Install
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","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"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","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 *\n * @example\n * ```ts\n * import { DEFAULT_RELAYS, publishEvent } from '@orangecheck/nostr-core';\n *\n * const results = await publishEvent(myEvent);\n * // → publishes to all 5 relays in DEFAULT_RELAYS in parallel\n * console.log(`accepted on ${results.filter(r => r.ok).length}/${results.length}`);\n * ```\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.map
CHANGED
|
@@ -1 +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"]}
|
|
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 *\n * @example\n * ```ts\n * import { DEFAULT_RELAYS, publishEvent } from '@orangecheck/nostr-core';\n *\n * const results = await publishEvent(myEvent);\n * // → publishes to all 5 relays in DEFAULT_RELAYS in parallel\n * console.log(`accepted on ${results.filter(r => r.ok).length}/${results.length}`);\n * ```\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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orangecheck/nostr-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"nostr",
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @orangecheck/nostr-core — unit tests
|
|
3
|
+
*
|
|
4
|
+
* Covers the bits that don't need a live relay:
|
|
5
|
+
* - DEFAULT_RELAYS shape + invariants
|
|
6
|
+
* - frame parsing (the inner protocol parser)
|
|
7
|
+
* - publishEvent / queryEvents end-to-end against a mock WebSocket
|
|
8
|
+
*
|
|
9
|
+
* The mock WebSocket implementation lives at the bottom of this file.
|
|
10
|
+
* It implements just enough of the WHATWG WebSocket interface to drive
|
|
11
|
+
* publishOne / attemptPublish + queryEvents through their happy paths
|
|
12
|
+
* and a few error paths.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
16
|
+
import { DEFAULT_RELAYS, publishEvent, queryEvents, type NostrEvent } from './index';
|
|
17
|
+
|
|
18
|
+
// ── DEFAULT_RELAYS ───────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
describe('DEFAULT_RELAYS', () => {
|
|
21
|
+
it('has at least 5 relays', () => {
|
|
22
|
+
expect(DEFAULT_RELAYS.length).toBeGreaterThanOrEqual(5);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('includes the family first-party relay', () => {
|
|
26
|
+
expect(DEFAULT_RELAYS).toContain('wss://relay.ochk.io');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('is not relay.ochk.io alone (BYPASS invariant)', () => {
|
|
30
|
+
expect(DEFAULT_RELAYS).not.toEqual(['wss://relay.ochk.io']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('is frozen at runtime — consumers cannot mutate', () => {
|
|
34
|
+
expect(() => {
|
|
35
|
+
(DEFAULT_RELAYS as unknown as string[]).push('wss://attack.example');
|
|
36
|
+
}).toThrow();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('every entry is a wss:// or ws:// URL', () => {
|
|
40
|
+
for (const relay of DEFAULT_RELAYS) {
|
|
41
|
+
expect(relay).toMatch(/^wss?:\/\//);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ── publishEvent + queryEvents — mock-WebSocket-backed integration ──────
|
|
47
|
+
|
|
48
|
+
const SAMPLE_EVENT: NostrEvent = {
|
|
49
|
+
id: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
50
|
+
kind: 30078,
|
|
51
|
+
pubkey: 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899',
|
|
52
|
+
created_at: 1735689600,
|
|
53
|
+
content: 'test',
|
|
54
|
+
tags: [['d', 'oc-test:abc']],
|
|
55
|
+
sig: 'cafebabe'.repeat(16),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
installMockWebSocket();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
restoreWebSocket();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('publishEvent', () => {
|
|
67
|
+
it('returns one PublishResult per relay', async () => {
|
|
68
|
+
mockNextHandshake('OK', { ok: true });
|
|
69
|
+
mockNextHandshake('OK', { ok: true });
|
|
70
|
+
const results = await publishEvent(SAMPLE_EVENT, ['wss://a', 'wss://b']);
|
|
71
|
+
expect(results).toHaveLength(2);
|
|
72
|
+
expect(results[0]!.relay).toBe('wss://a');
|
|
73
|
+
expect(results[1]!.relay).toBe('wss://b');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('marks ok: true when relay sends OK true', async () => {
|
|
77
|
+
mockNextHandshake('OK', { ok: true });
|
|
78
|
+
const results = await publishEvent(SAMPLE_EVENT, ['wss://a']);
|
|
79
|
+
expect(results[0]!.ok).toBe(true);
|
|
80
|
+
expect(results[0]!.attempts).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('marks ok: false when relay rejects with OK false + reason', async () => {
|
|
84
|
+
mockNextHandshake('OK', { ok: false, reason: 'blocked: bad shape' });
|
|
85
|
+
const results = await publishEvent(SAMPLE_EVENT, ['wss://a']);
|
|
86
|
+
expect(results[0]!.ok).toBe(false);
|
|
87
|
+
expect(results[0]!.reason).toBe('blocked: bad shape');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('retries on transient failure (3 attempts, exits ok: false)', async () => {
|
|
91
|
+
// 3 errors → exhausts retries. We assert the shape (attempts=3,
|
|
92
|
+
// ok=false) — the precise lastReason can race with the timeout
|
|
93
|
+
// on slow runners and isn't load-bearing for the contract.
|
|
94
|
+
mockNextHandshake('error');
|
|
95
|
+
mockNextHandshake('error');
|
|
96
|
+
mockNextHandshake('error');
|
|
97
|
+
const results = await publishEvent(SAMPLE_EVENT, ['wss://flaky'], 1000);
|
|
98
|
+
expect(results[0]!.ok).toBe(false);
|
|
99
|
+
expect(results[0]!.attempts).toBe(3);
|
|
100
|
+
}, 30_000);
|
|
101
|
+
|
|
102
|
+
it('publishes to all relays in parallel', async () => {
|
|
103
|
+
mockNextHandshake('OK', { ok: true });
|
|
104
|
+
mockNextHandshake('OK', { ok: true });
|
|
105
|
+
mockNextHandshake('OK', { ok: true });
|
|
106
|
+
const start = Date.now();
|
|
107
|
+
await publishEvent(SAMPLE_EVENT, ['wss://a', 'wss://b', 'wss://c']);
|
|
108
|
+
const elapsed = Date.now() - start;
|
|
109
|
+
// If they ran serially with the mock's tiny delay we'd see ≥ 30ms.
|
|
110
|
+
// Parallel should resolve in well under that.
|
|
111
|
+
expect(elapsed).toBeLessThan(100);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('queryEvents', () => {
|
|
116
|
+
it('returns events streamed via EVENT, dedupes across relays', async () => {
|
|
117
|
+
const ev1 = { ...SAMPLE_EVENT, id: 'a'.repeat(64) };
|
|
118
|
+
const ev2 = { ...SAMPLE_EVENT, id: 'b'.repeat(64), created_at: 1735689700 };
|
|
119
|
+
mockNextQuery([ev1, ev2]);
|
|
120
|
+
// Same event id on a second relay — should dedupe.
|
|
121
|
+
mockNextQuery([ev1]);
|
|
122
|
+
|
|
123
|
+
const result = await queryEvents({ kinds: [30078] }, ['wss://a', 'wss://b']);
|
|
124
|
+
expect(result.events).toHaveLength(2);
|
|
125
|
+
// Sorted by created_at desc.
|
|
126
|
+
expect(result.events[0]!.id).toBe('b'.repeat(64));
|
|
127
|
+
expect(result.events[1]!.id).toBe('a'.repeat(64));
|
|
128
|
+
expect(result.relayStatus.filter((s) => s.ok)).toHaveLength(2);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('reports per-relay status — failure on one does not block others', async () => {
|
|
132
|
+
const ev = { ...SAMPLE_EVENT };
|
|
133
|
+
mockNextQuery([ev]);
|
|
134
|
+
mockNextQuery('error');
|
|
135
|
+
|
|
136
|
+
const result = await queryEvents({ kinds: [30078] }, ['wss://good', 'wss://bad']);
|
|
137
|
+
expect(result.events).toHaveLength(1);
|
|
138
|
+
const okStatuses = result.relayStatus.filter((s) => s.ok);
|
|
139
|
+
const failStatuses = result.relayStatus.filter((s) => !s.ok);
|
|
140
|
+
expect(okStatuses).toHaveLength(1);
|
|
141
|
+
expect(failStatuses).toHaveLength(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('respects timeoutMs when relay never sends EOSE', async () => {
|
|
145
|
+
mockNextQuery('hang');
|
|
146
|
+
const start = Date.now();
|
|
147
|
+
const result = await queryEvents({ kinds: [30078] }, ['wss://hang'], 200);
|
|
148
|
+
const elapsed = Date.now() - start;
|
|
149
|
+
expect(elapsed).toBeGreaterThanOrEqual(200);
|
|
150
|
+
expect(elapsed).toBeLessThan(500);
|
|
151
|
+
expect(result.relayStatus[0]!.reason).toBe('timeout');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
156
|
+
// Mock WebSocket harness
|
|
157
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
type Outcome =
|
|
160
|
+
| { kind: 'publish_ok'; ok: boolean; reason?: string }
|
|
161
|
+
| { kind: 'error' }
|
|
162
|
+
| { kind: 'query'; events: NostrEvent[] }
|
|
163
|
+
| { kind: 'query_error' }
|
|
164
|
+
| { kind: 'query_hang' };
|
|
165
|
+
|
|
166
|
+
const outcomes: Outcome[] = [];
|
|
167
|
+
|
|
168
|
+
function mockNextHandshake(type: 'OK' | 'error', detail?: { ok: boolean; reason?: string }) {
|
|
169
|
+
if (type === 'OK') {
|
|
170
|
+
outcomes.push({ kind: 'publish_ok', ok: detail!.ok, reason: detail?.reason });
|
|
171
|
+
} else {
|
|
172
|
+
outcomes.push({ kind: 'error' });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function mockNextQuery(detail: NostrEvent[] | 'error' | 'hang') {
|
|
177
|
+
if (detail === 'error') outcomes.push({ kind: 'query_error' });
|
|
178
|
+
else if (detail === 'hang') outcomes.push({ kind: 'query_hang' });
|
|
179
|
+
else outcomes.push({ kind: 'query' as const, events: detail });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let originalWebSocket: typeof globalThis.WebSocket | undefined;
|
|
183
|
+
|
|
184
|
+
function installMockWebSocket() {
|
|
185
|
+
originalWebSocket = globalThis.WebSocket;
|
|
186
|
+
outcomes.length = 0;
|
|
187
|
+
|
|
188
|
+
class MockWebSocket {
|
|
189
|
+
static CONNECTING = 0;
|
|
190
|
+
static OPEN = 1;
|
|
191
|
+
static CLOSING = 2;
|
|
192
|
+
static CLOSED = 3;
|
|
193
|
+
readyState = 1;
|
|
194
|
+
|
|
195
|
+
onopen: (() => void) | null = null;
|
|
196
|
+
onmessage: ((ev: { data: string }) => void) | null = null;
|
|
197
|
+
onerror: ((ev: unknown) => void) | null = null;
|
|
198
|
+
onclose: (() => void) | null = null;
|
|
199
|
+
|
|
200
|
+
private outcome: Outcome | undefined;
|
|
201
|
+
|
|
202
|
+
constructor(public url: string) {
|
|
203
|
+
this.outcome = outcomes.shift();
|
|
204
|
+
// Fire onopen + react asynchronously so the caller can attach handlers.
|
|
205
|
+
queueMicrotask(() => this.onopen?.());
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
send(raw: string) {
|
|
209
|
+
const frame = JSON.parse(raw) as unknown[];
|
|
210
|
+
const type = frame[0];
|
|
211
|
+
if (type === 'EVENT') {
|
|
212
|
+
const event = frame[1] as NostrEvent;
|
|
213
|
+
queueMicrotask(() => {
|
|
214
|
+
if (!this.outcome) return;
|
|
215
|
+
if (this.outcome.kind === 'publish_ok') {
|
|
216
|
+
this.onmessage?.({
|
|
217
|
+
data: JSON.stringify(['OK', event.id, this.outcome.ok, this.outcome.reason ?? '']),
|
|
218
|
+
});
|
|
219
|
+
} else if (this.outcome.kind === 'error') {
|
|
220
|
+
this.onerror?.({});
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
} else if (type === 'REQ') {
|
|
224
|
+
const subId = frame[1] as string;
|
|
225
|
+
queueMicrotask(() => {
|
|
226
|
+
if (!this.outcome) return;
|
|
227
|
+
if (this.outcome.kind === 'query') {
|
|
228
|
+
for (const ev of this.outcome.events) {
|
|
229
|
+
this.onmessage?.({ data: JSON.stringify(['EVENT', subId, ev]) });
|
|
230
|
+
}
|
|
231
|
+
this.onmessage?.({ data: JSON.stringify(['EOSE', subId]) });
|
|
232
|
+
} else if (this.outcome.kind === 'query_error') {
|
|
233
|
+
this.onerror?.({});
|
|
234
|
+
} else if (this.outcome.kind === 'query_hang') {
|
|
235
|
+
// do nothing — caller's timeout fires
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
} else if (type === 'CLOSE') {
|
|
239
|
+
// queryEvents sends CLOSE after EOSE; we don't need to react.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
close() {
|
|
244
|
+
this.readyState = 3;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
vi.stubGlobal('WebSocket', MockWebSocket);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function restoreWebSocket() {
|
|
252
|
+
if (originalWebSocket) globalThis.WebSocket = originalWebSocket;
|
|
253
|
+
vi.unstubAllGlobals();
|
|
254
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -62,6 +62,15 @@ const _RELAYS: ValidRelaySet<readonly [
|
|
|
62
62
|
*
|
|
63
63
|
* Frozen at runtime; consumers MAY pass an explicit `relays` arg to any
|
|
64
64
|
* function in this package to override.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```ts
|
|
68
|
+
* import { DEFAULT_RELAYS, publishEvent } from '@orangecheck/nostr-core';
|
|
69
|
+
*
|
|
70
|
+
* const results = await publishEvent(myEvent);
|
|
71
|
+
* // → publishes to all 5 relays in DEFAULT_RELAYS in parallel
|
|
72
|
+
* console.log(`accepted on ${results.filter(r => r.ok).length}/${results.length}`);
|
|
73
|
+
* ```
|
|
65
74
|
*/
|
|
66
75
|
export const DEFAULT_RELAYS: readonly string[] = Object.freeze([..._RELAYS]);
|
|
67
76
|
|