|
|
|
|
@ -4,12 +4,23 @@
@@ -4,12 +4,23 @@
|
|
|
|
|
|
|
|
|
|
import type { NostrEvent, NostrFilter } from '../../types/nostr.js'; |
|
|
|
|
import logger from '../logger.js'; |
|
|
|
|
import { isNIP07Available, getPublicKeyWithNIP07, signEventWithNIP07 } from './nip07-signer.js'; |
|
|
|
|
|
|
|
|
|
// Polyfill WebSocket for Node.js environments (lazy initialization)
|
|
|
|
|
// Note: The 'module' import warning in browser builds is expected and harmless.
|
|
|
|
|
// This code only executes in Node.js/server environments.
|
|
|
|
|
let wsPolyfillInitialized = false; |
|
|
|
|
async function initializeWebSocketPolyfill() { |
|
|
|
|
if (wsPolyfillInitialized) return; |
|
|
|
|
if (typeof global === 'undefined' || typeof global.WebSocket !== 'undefined') { |
|
|
|
|
|
|
|
|
|
// Check if WebSocket already exists (browser or already polyfilled)
|
|
|
|
|
if (typeof WebSocket !== 'undefined') { |
|
|
|
|
wsPolyfillInitialized = true; |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Skip in browser environment - WebSocket should be native
|
|
|
|
|
if (typeof window !== 'undefined') { |
|
|
|
|
wsPolyfillInitialized = true; |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
@ -20,22 +31,25 @@ async function initializeWebSocketPolyfill() {
@@ -20,22 +31,25 @@ async function initializeWebSocketPolyfill() {
|
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Only attempt polyfill in Node.js runtime
|
|
|
|
|
// This import is only executed server-side, but Vite may still analyze it
|
|
|
|
|
try { |
|
|
|
|
// Dynamic import to avoid bundling for browser
|
|
|
|
|
const { createRequire } = await import('module'); |
|
|
|
|
const requireFunc = createRequire(import.meta.url); |
|
|
|
|
// @ts-ignore - Dynamic import that only runs in Node.js
|
|
|
|
|
const moduleModule = await import('module'); |
|
|
|
|
const requireFunc = moduleModule.createRequire(import.meta.url); |
|
|
|
|
const WebSocketImpl = requireFunc('ws'); |
|
|
|
|
global.WebSocket = WebSocketImpl as any; |
|
|
|
|
(global as any).WebSocket = WebSocketImpl; |
|
|
|
|
wsPolyfillInitialized = true; |
|
|
|
|
} catch { |
|
|
|
|
// ws package not available, will fail at runtime in Node.js
|
|
|
|
|
logger.warn('WebSocket polyfill not available. Install "ws" package for Node.js support.'); |
|
|
|
|
wsPolyfillInitialized = true; // Mark as initialized to avoid repeated warnings
|
|
|
|
|
} catch (error) { |
|
|
|
|
// ws package not available or import failed
|
|
|
|
|
// This is expected in browser builds, so we don't warn
|
|
|
|
|
wsPolyfillInitialized = true; // Mark as initialized to avoid repeated attempts
|
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Initialize on module load if in Node.js (fire and forget)
|
|
|
|
|
if (typeof process !== 'undefined' && process.versions?.node) { |
|
|
|
|
// Only in SSR/server environment - check for window to exclude browser
|
|
|
|
|
if (typeof process !== 'undefined' && process.versions?.node && typeof window === 'undefined') { |
|
|
|
|
initializeWebSocketPolyfill().catch(() => { |
|
|
|
|
// Ignore errors during initialization
|
|
|
|
|
}); |
|
|
|
|
@ -43,11 +57,72 @@ if (typeof process !== 'undefined' && process.versions?.node) {
@@ -43,11 +57,72 @@ if (typeof process !== 'undefined' && process.versions?.node) {
|
|
|
|
|
|
|
|
|
|
export class NostrClient { |
|
|
|
|
private relays: string[] = []; |
|
|
|
|
private authenticatedRelays: Set<string> = new Set(); |
|
|
|
|
|
|
|
|
|
constructor(relays: string[]) { |
|
|
|
|
this.relays = relays; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Handle AUTH challenge from relay and authenticate using NIP-42 |
|
|
|
|
*/ |
|
|
|
|
private async handleAuthChallenge(ws: WebSocket, relay: string, challenge: string): Promise<boolean> { |
|
|
|
|
// Only try to authenticate if NIP-07 is available (browser environment)
|
|
|
|
|
if (typeof window === 'undefined' || !isNIP07Available()) { |
|
|
|
|
return false; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
try { |
|
|
|
|
const pubkey = await getPublicKeyWithNIP07(); |
|
|
|
|
|
|
|
|
|
// Create auth event (kind 22242)
|
|
|
|
|
const authEvent: Omit<NostrEvent, 'sig' | 'id'> = { |
|
|
|
|
kind: 22242, |
|
|
|
|
pubkey, |
|
|
|
|
created_at: Math.floor(Date.now() / 1000), |
|
|
|
|
tags: [], |
|
|
|
|
content: challenge |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
// Sign the event (NIP-07 will calculate the ID)
|
|
|
|
|
const signedEvent = await signEventWithNIP07(authEvent); |
|
|
|
|
|
|
|
|
|
// Send AUTH response
|
|
|
|
|
ws.send(JSON.stringify(['AUTH', signedEvent])); |
|
|
|
|
|
|
|
|
|
// Wait for OK response with timeout
|
|
|
|
|
return new Promise((resolve) => { |
|
|
|
|
const timeout = setTimeout(() => { |
|
|
|
|
resolve(false); |
|
|
|
|
}, 5000); |
|
|
|
|
|
|
|
|
|
const okHandler = (event: MessageEvent) => { |
|
|
|
|
try { |
|
|
|
|
const message = JSON.parse(event.data); |
|
|
|
|
if (message[0] === 'OK' && message[1] === 'auth') { |
|
|
|
|
clearTimeout(timeout); |
|
|
|
|
ws.removeEventListener('message', okHandler); |
|
|
|
|
if (message[2] === true) { |
|
|
|
|
this.authenticatedRelays.add(relay); |
|
|
|
|
resolve(true); |
|
|
|
|
} else { |
|
|
|
|
logger.warn({ relay, reason: message[3] }, 'AUTH rejected by relay'); |
|
|
|
|
resolve(false); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} catch { |
|
|
|
|
// Ignore parse errors, continue waiting
|
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
ws.addEventListener('message', okHandler); |
|
|
|
|
}); |
|
|
|
|
} catch (error) { |
|
|
|
|
logger.error({ error, relay }, 'Failed to authenticate with relay'); |
|
|
|
|
return false; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
async fetchEvents(filters: NostrFilter[]): Promise<NostrEvent[]> { |
|
|
|
|
const events: NostrEvent[] = []; |
|
|
|
|
|
|
|
|
|
@ -76,18 +151,24 @@ export class NostrClient {
@@ -76,18 +151,24 @@ export class NostrClient {
|
|
|
|
|
// Ensure WebSocket polyfill is initialized
|
|
|
|
|
await initializeWebSocketPolyfill(); |
|
|
|
|
|
|
|
|
|
return new Promise((resolve, reject) => { |
|
|
|
|
const ws = new WebSocket(relay); |
|
|
|
|
return new Promise((resolve) => { |
|
|
|
|
let ws: WebSocket | null = null; |
|
|
|
|
const events: NostrEvent[] = []; |
|
|
|
|
let resolved = false; |
|
|
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null; |
|
|
|
|
let connectionTimeoutId: ReturnType<typeof setTimeout> | null = null; |
|
|
|
|
let authHandled = false; |
|
|
|
|
|
|
|
|
|
const cleanup = () => { |
|
|
|
|
if (timeoutId) { |
|
|
|
|
clearTimeout(timeoutId); |
|
|
|
|
timeoutId = null; |
|
|
|
|
} |
|
|
|
|
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { |
|
|
|
|
if (connectionTimeoutId) { |
|
|
|
|
clearTimeout(connectionTimeoutId); |
|
|
|
|
connectionTimeoutId = null; |
|
|
|
|
} |
|
|
|
|
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { |
|
|
|
|
try { |
|
|
|
|
ws.close(); |
|
|
|
|
} catch { |
|
|
|
|
@ -96,7 +177,7 @@ export class NostrClient {
@@ -96,7 +177,7 @@ export class NostrClient {
|
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
const resolveOnce = (value: NostrEvent[]) => { |
|
|
|
|
const resolveOnce = (value: NostrEvent[] = []) => { |
|
|
|
|
if (!resolved) { |
|
|
|
|
resolved = true; |
|
|
|
|
cleanup(); |
|
|
|
|
@ -104,26 +185,67 @@ export class NostrClient {
@@ -104,26 +185,67 @@ export class NostrClient {
|
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
const rejectOnce = (error: Error) => { |
|
|
|
|
if (!resolved) { |
|
|
|
|
resolved = true; |
|
|
|
|
cleanup(); |
|
|
|
|
reject(error); |
|
|
|
|
let authPromise: Promise<boolean> | null = null; |
|
|
|
|
|
|
|
|
|
try { |
|
|
|
|
ws = new WebSocket(relay); |
|
|
|
|
} catch (error) { |
|
|
|
|
// Connection failed immediately
|
|
|
|
|
resolveOnce([]); |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Connection timeout - if we can't connect within 3 seconds, give up
|
|
|
|
|
connectionTimeoutId = setTimeout(() => { |
|
|
|
|
if (!resolved && ws && ws.readyState !== WebSocket.OPEN) { |
|
|
|
|
resolveOnce([]); |
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
}, 3000); |
|
|
|
|
|
|
|
|
|
ws.onopen = () => { |
|
|
|
|
try { |
|
|
|
|
ws.send(JSON.stringify(['REQ', 'sub', ...filters])); |
|
|
|
|
} catch (error) { |
|
|
|
|
rejectOnce(error instanceof Error ? error : new Error(String(error))); |
|
|
|
|
if (connectionTimeoutId) { |
|
|
|
|
clearTimeout(connectionTimeoutId); |
|
|
|
|
connectionTimeoutId = null; |
|
|
|
|
} |
|
|
|
|
// Connection opened, wait for AUTH challenge or proceed
|
|
|
|
|
// If no AUTH challenge comes within 1 second, send REQ
|
|
|
|
|
setTimeout(() => { |
|
|
|
|
if (!authHandled && ws && ws.readyState === WebSocket.OPEN) { |
|
|
|
|
try { |
|
|
|
|
ws.send(JSON.stringify(['REQ', 'sub', ...filters])); |
|
|
|
|
} catch { |
|
|
|
|
// Connection might have closed
|
|
|
|
|
resolveOnce(events); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
}, 1000); |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
ws.onmessage = (event: MessageEvent) => { |
|
|
|
|
ws.onmessage = async (event: MessageEvent) => { |
|
|
|
|
try { |
|
|
|
|
const message = JSON.parse(event.data); |
|
|
|
|
|
|
|
|
|
// Handle AUTH challenge
|
|
|
|
|
if (message[0] === 'AUTH' && message[1] && !authHandled) { |
|
|
|
|
authHandled = true; |
|
|
|
|
authPromise = this.handleAuthChallenge(ws!, relay, message[1]); |
|
|
|
|
const authenticated = await authPromise; |
|
|
|
|
// After authentication, send the REQ
|
|
|
|
|
if (ws && ws.readyState === WebSocket.OPEN) { |
|
|
|
|
try { |
|
|
|
|
ws.send(JSON.stringify(['REQ', 'sub', ...filters])); |
|
|
|
|
} catch { |
|
|
|
|
resolveOnce(events); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Wait for auth to complete before processing other messages
|
|
|
|
|
if (authPromise) { |
|
|
|
|
await authPromise; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (message[0] === 'EVENT') { |
|
|
|
|
events.push(message[2]); |
|
|
|
|
} else if (message[0] === 'EOSE') { |
|
|
|
|
@ -134,8 +256,12 @@ export class NostrClient {
@@ -134,8 +256,12 @@ export class NostrClient {
|
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
ws.onerror = (error) => { |
|
|
|
|
rejectOnce(new Error(`WebSocket error for ${relay}: ${error}`)); |
|
|
|
|
ws.onerror = () => { |
|
|
|
|
// Silently handle connection errors - some relays may be down
|
|
|
|
|
// Don't log or reject, just resolve with empty results
|
|
|
|
|
if (!resolved) { |
|
|
|
|
resolveOnce([]); |
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
ws.onclose = () => { |
|
|
|
|
@ -145,9 +271,10 @@ export class NostrClient {
@@ -145,9 +271,10 @@ export class NostrClient {
|
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
// Overall timeout - resolve with what we have after 8 seconds
|
|
|
|
|
timeoutId = setTimeout(() => { |
|
|
|
|
resolveOnce(events); |
|
|
|
|
}, 5000); |
|
|
|
|
}, 8000); |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@ -175,16 +302,22 @@ export class NostrClient {
@@ -175,16 +302,22 @@ export class NostrClient {
|
|
|
|
|
await initializeWebSocketPolyfill(); |
|
|
|
|
|
|
|
|
|
return new Promise((resolve, reject) => { |
|
|
|
|
const ws = new WebSocket(relay); |
|
|
|
|
let ws: WebSocket | null = null; |
|
|
|
|
let resolved = false; |
|
|
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null; |
|
|
|
|
let connectionTimeoutId: ReturnType<typeof setTimeout> | null = null; |
|
|
|
|
let authHandled = false; |
|
|
|
|
|
|
|
|
|
const cleanup = () => { |
|
|
|
|
if (timeoutId) { |
|
|
|
|
clearTimeout(timeoutId); |
|
|
|
|
timeoutId = null; |
|
|
|
|
} |
|
|
|
|
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { |
|
|
|
|
if (connectionTimeoutId) { |
|
|
|
|
clearTimeout(connectionTimeoutId); |
|
|
|
|
connectionTimeoutId = null; |
|
|
|
|
} |
|
|
|
|
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { |
|
|
|
|
try { |
|
|
|
|
ws.close(); |
|
|
|
|
} catch { |
|
|
|
|
@ -209,18 +342,65 @@ export class NostrClient {
@@ -209,18 +342,65 @@ export class NostrClient {
|
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
try { |
|
|
|
|
ws = new WebSocket(relay); |
|
|
|
|
} catch (error) { |
|
|
|
|
rejectOnce(new Error(`Failed to create WebSocket connection to ${relay}`)); |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Connection timeout - if we can't connect within 3 seconds, reject
|
|
|
|
|
connectionTimeoutId = setTimeout(() => { |
|
|
|
|
if (!resolved && ws && ws.readyState !== WebSocket.OPEN) { |
|
|
|
|
rejectOnce(new Error(`Connection timeout for ${relay}`)); |
|
|
|
|
} |
|
|
|
|
}, 3000); |
|
|
|
|
|
|
|
|
|
let authPromise: Promise<boolean> | null = null; |
|
|
|
|
|
|
|
|
|
ws.onopen = () => { |
|
|
|
|
try { |
|
|
|
|
ws.send(JSON.stringify(['EVENT', nostrEvent])); |
|
|
|
|
} catch (error) { |
|
|
|
|
rejectOnce(error instanceof Error ? error : new Error(String(error))); |
|
|
|
|
if (connectionTimeoutId) { |
|
|
|
|
clearTimeout(connectionTimeoutId); |
|
|
|
|
connectionTimeoutId = null; |
|
|
|
|
} |
|
|
|
|
// Connection opened, wait for AUTH challenge or proceed
|
|
|
|
|
// If no AUTH challenge comes within 1 second, send EVENT
|
|
|
|
|
setTimeout(() => { |
|
|
|
|
if (!authHandled && ws && ws.readyState === WebSocket.OPEN) { |
|
|
|
|
try { |
|
|
|
|
ws.send(JSON.stringify(['EVENT', nostrEvent])); |
|
|
|
|
} catch (error) { |
|
|
|
|
rejectOnce(error instanceof Error ? error : new Error(String(error))); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
}, 1000); |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
ws.onmessage = (event: MessageEvent) => { |
|
|
|
|
ws.onmessage = async (event: MessageEvent) => { |
|
|
|
|
try { |
|
|
|
|
const message = JSON.parse(event.data); |
|
|
|
|
|
|
|
|
|
// Handle AUTH challenge
|
|
|
|
|
if (message[0] === 'AUTH' && message[1] && !authHandled) { |
|
|
|
|
authHandled = true; |
|
|
|
|
authPromise = this.handleAuthChallenge(ws!, relay, message[1]); |
|
|
|
|
await authPromise; |
|
|
|
|
// After authentication attempt, send the EVENT
|
|
|
|
|
if (ws && ws.readyState === WebSocket.OPEN) { |
|
|
|
|
try { |
|
|
|
|
ws.send(JSON.stringify(['EVENT', nostrEvent])); |
|
|
|
|
} catch (error) { |
|
|
|
|
rejectOnce(error instanceof Error ? error : new Error(String(error))); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Wait for auth to complete before processing other messages
|
|
|
|
|
if (authPromise) { |
|
|
|
|
await authPromise; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (message[0] === 'OK' && message[1] === nostrEvent.id) { |
|
|
|
|
if (message[2] === true) { |
|
|
|
|
resolveOnce(); |
|
|
|
|
@ -233,8 +413,16 @@ export class NostrClient {
@@ -233,8 +413,16 @@ export class NostrClient {
|
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
ws.onerror = (error) => { |
|
|
|
|
rejectOnce(new Error(`WebSocket error for ${relay}: ${error}`)); |
|
|
|
|
ws.onerror = () => { |
|
|
|
|
// Silently handle connection errors - reject after a short delay
|
|
|
|
|
// to allow connection to attempt
|
|
|
|
|
if (!resolved) { |
|
|
|
|
setTimeout(() => { |
|
|
|
|
if (!resolved) { |
|
|
|
|
rejectOnce(new Error(`Connection failed for ${relay}`)); |
|
|
|
|
} |
|
|
|
|
}, 100); |
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
ws.onclose = () => { |
|
|
|
|
@ -246,7 +434,7 @@ export class NostrClient {
@@ -246,7 +434,7 @@ export class NostrClient {
|
|
|
|
|
|
|
|
|
|
timeoutId = setTimeout(() => { |
|
|
|
|
rejectOnce(new Error('Publish timeout')); |
|
|
|
|
}, 5000); |
|
|
|
|
}, 10000); |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|