Browse Source
- Add bunker-worker.js Web Worker for NIP-46 signing - Update rollup to build worker as separate bundle - Move bunker state to stores.js for persistence across tab switches - Worker maintains WebSocket connection independently of UI lifecycle Files modified: - app/web/src/bunker-worker.js: New Web Worker implementation - app/web/src/stores.js: Added bunker worker state management - app/web/src/BunkerView.svelte: Use worker instead of inline service - app/web/rollup.config.js: Build worker bundle separately 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>main
7 changed files with 628 additions and 100 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,423 @@
@@ -0,0 +1,423 @@
|
||||
/** |
||||
* BunkerWorker - Web Worker for persistent NIP-46 bunker service |
||||
* |
||||
* Runs in a separate thread to maintain WebSocket connection |
||||
* regardless of UI component lifecycle. |
||||
*/ |
||||
|
||||
import { nip04 } from 'nostr-tools'; |
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; |
||||
import { secp256k1 } from '@noble/curves/secp256k1'; |
||||
|
||||
// State
|
||||
let ws = null; |
||||
let connected = false; |
||||
let userPubkey = null; |
||||
let userPrivkey = null; |
||||
let relayUrl = null; |
||||
let catTokenEncoded = null; |
||||
let subscriptionId = null; |
||||
let heartbeatInterval = null; |
||||
let allowedSecrets = new Set(); |
||||
let connectedClients = new Map(); |
||||
|
||||
// NIP-46 methods
|
||||
const NIP46_METHOD = { |
||||
CONNECT: 'connect', |
||||
GET_PUBLIC_KEY: 'get_public_key', |
||||
SIGN_EVENT: 'sign_event', |
||||
NIP04_ENCRYPT: 'nip04_encrypt', |
||||
NIP04_DECRYPT: 'nip04_decrypt', |
||||
PING: 'ping' |
||||
}; |
||||
|
||||
function generateRandomHex(bytes = 16) { |
||||
const arr = new Uint8Array(bytes); |
||||
crypto.getRandomValues(arr); |
||||
return bytesToHex(arr); |
||||
} |
||||
|
||||
function postStatus(status, data = {}) { |
||||
self.postMessage({ type: 'status', status, ...data }); |
||||
} |
||||
|
||||
function postError(error) { |
||||
self.postMessage({ type: 'error', error }); |
||||
} |
||||
|
||||
function postClientsUpdate() { |
||||
const clients = Array.from(connectedClients.entries()).map(([pubkey, info]) => ({ |
||||
pubkey, |
||||
...info |
||||
})); |
||||
self.postMessage({ type: 'clients', clients }); |
||||
} |
||||
|
||||
async function connect() { |
||||
if (connected || !relayUrl || !userPubkey || !userPrivkey) { |
||||
postError('Missing configuration or already connected'); |
||||
return; |
||||
} |
||||
|
||||
return new Promise((resolve, reject) => { |
||||
let wsUrl = relayUrl; |
||||
if (wsUrl.startsWith('http://')) { |
||||
wsUrl = 'ws://' + wsUrl.slice(7); |
||||
} else if (wsUrl.startsWith('https://')) { |
||||
wsUrl = 'wss://' + wsUrl.slice(8); |
||||
} else if (!wsUrl.startsWith('ws://') && !wsUrl.startsWith('wss://')) { |
||||
wsUrl = 'wss://' + wsUrl; |
||||
} |
||||
|
||||
// Add CAT token if available
|
||||
if (catTokenEncoded) { |
||||
const url = new URL(wsUrl); |
||||
url.searchParams.set('token', catTokenEncoded); |
||||
wsUrl = url.toString(); |
||||
} |
||||
|
||||
console.log('[BunkerWorker] Connecting to:', wsUrl.split('?')[0]); |
||||
|
||||
ws = new WebSocket(wsUrl); |
||||
|
||||
const timeout = setTimeout(() => { |
||||
ws.close(); |
||||
postError('Connection timeout'); |
||||
reject(new Error('Connection timeout')); |
||||
}, 10000); |
||||
|
||||
ws.onopen = () => { |
||||
clearTimeout(timeout); |
||||
connected = true; |
||||
console.log('[BunkerWorker] Connected to relay'); |
||||
|
||||
// Subscribe to NIP-46 events
|
||||
subscriptionId = generateRandomHex(8); |
||||
const sub = JSON.stringify([ |
||||
'REQ', |
||||
subscriptionId, |
||||
{ |
||||
kinds: [24133], |
||||
'#p': [userPubkey], |
||||
since: Math.floor(Date.now() / 1000) - 60 |
||||
} |
||||
]); |
||||
ws.send(sub); |
||||
|
||||
startHeartbeat(); |
||||
postStatus('connected'); |
||||
resolve(); |
||||
}; |
||||
|
||||
ws.onerror = (error) => { |
||||
clearTimeout(timeout); |
||||
console.error('[BunkerWorker] WebSocket error:', error); |
||||
postError('WebSocket error'); |
||||
reject(new Error('WebSocket error')); |
||||
}; |
||||
|
||||
ws.onclose = () => { |
||||
connected = false; |
||||
ws = null; |
||||
stopHeartbeat(); |
||||
console.log('[BunkerWorker] Disconnected from relay'); |
||||
postStatus('disconnected'); |
||||
}; |
||||
|
||||
ws.onmessage = (event) => { |
||||
handleMessage(event.data); |
||||
}; |
||||
}); |
||||
} |
||||
|
||||
function disconnect() { |
||||
stopHeartbeat(); |
||||
if (ws) { |
||||
if (subscriptionId) { |
||||
ws.send(JSON.stringify(['CLOSE', subscriptionId])); |
||||
} |
||||
ws.close(); |
||||
ws = null; |
||||
} |
||||
connected = false; |
||||
connectedClients.clear(); |
||||
postStatus('disconnected'); |
||||
postClientsUpdate(); |
||||
} |
||||
|
||||
function startHeartbeat(intervalMs = 30000) { |
||||
stopHeartbeat(); |
||||
heartbeatInterval = setInterval(() => { |
||||
if (ws && ws.readyState === WebSocket.OPEN) { |
||||
const sub = JSON.stringify([ |
||||
'REQ', |
||||
subscriptionId, |
||||
{ |
||||
kinds: [24133], |
||||
'#p': [userPubkey], |
||||
since: Math.floor(Date.now() / 1000) - 60 |
||||
} |
||||
]); |
||||
ws.send(sub); |
||||
} |
||||
}, intervalMs); |
||||
} |
||||
|
||||
function stopHeartbeat() { |
||||
if (heartbeatInterval) { |
||||
clearInterval(heartbeatInterval); |
||||
heartbeatInterval = null; |
||||
} |
||||
} |
||||
|
||||
async function handleMessage(data) { |
||||
try { |
||||
const msg = JSON.parse(data); |
||||
if (!Array.isArray(msg)) return; |
||||
|
||||
const [type, ...rest] = msg; |
||||
|
||||
if (type === 'EVENT') { |
||||
const [, event] = rest; |
||||
if (event.kind === 24133) { |
||||
await handleNIP46Request(event); |
||||
} |
||||
} else if (type === 'OK') { |
||||
console.log('[BunkerWorker] Event published:', rest[0]?.substring(0, 8)); |
||||
} else if (type === 'NOTICE') { |
||||
console.warn('[BunkerWorker] Relay notice:', rest[0]); |
||||
} |
||||
} catch (err) { |
||||
console.error('[BunkerWorker] Failed to parse message:', err); |
||||
} |
||||
} |
||||
|
||||
async function handleNIP46Request(event) { |
||||
try { |
||||
const privkeyHex = bytesToHex(userPrivkey); |
||||
const decrypted = await nip04.decrypt(privkeyHex, event.pubkey, event.content); |
||||
const request = JSON.parse(decrypted); |
||||
|
||||
console.log('[BunkerWorker] Received request:', request.method, 'from:', event.pubkey.substring(0, 8)); |
||||
|
||||
// Log to main thread
|
||||
self.postMessage({ |
||||
type: 'request', |
||||
method: request.method, |
||||
from: event.pubkey, |
||||
timestamp: Date.now() |
||||
}); |
||||
|
||||
let result = null; |
||||
let error = null; |
||||
|
||||
try { |
||||
switch (request.method) { |
||||
case NIP46_METHOD.CONNECT: |
||||
result = await handleConnect(request, event.pubkey); |
||||
break; |
||||
case NIP46_METHOD.GET_PUBLIC_KEY: |
||||
result = handleGetPublicKey(event.pubkey); |
||||
break; |
||||
case NIP46_METHOD.SIGN_EVENT: |
||||
result = await handleSignEvent(request, event.pubkey); |
||||
break; |
||||
case NIP46_METHOD.NIP04_ENCRYPT: |
||||
result = await handleNip04Encrypt(request, event.pubkey); |
||||
break; |
||||
case NIP46_METHOD.NIP04_DECRYPT: |
||||
result = await handleNip04Decrypt(request, event.pubkey); |
||||
break; |
||||
case NIP46_METHOD.PING: |
||||
result = 'pong'; |
||||
break; |
||||
default: |
||||
error = `Unknown method: ${request.method}`; |
||||
} |
||||
} catch (err) { |
||||
console.error('[BunkerWorker] Error handling request:', err); |
||||
error = err.message; |
||||
} |
||||
|
||||
await sendResponse(request.id, result, error, event.pubkey); |
||||
|
||||
} catch (err) { |
||||
console.error('[BunkerWorker] Failed to handle NIP-46 request:', err); |
||||
} |
||||
} |
||||
|
||||
async function handleConnect(request, senderPubkey) { |
||||
const [clientPubkey, secret] = request.params; |
||||
|
||||
if (allowedSecrets.size > 0) { |
||||
if (!secret || !allowedSecrets.has(secret)) { |
||||
throw new Error('Invalid or missing connection secret'); |
||||
} |
||||
} |
||||
|
||||
connectedClients.set(senderPubkey, { |
||||
clientPubkey: clientPubkey || senderPubkey, |
||||
connectedAt: Date.now(), |
||||
lastActivity: Date.now() |
||||
}); |
||||
|
||||
console.log('[BunkerWorker] Client connected:', senderPubkey.substring(0, 8)); |
||||
postClientsUpdate(); |
||||
|
||||
return 'ack'; |
||||
} |
||||
|
||||
function handleGetPublicKey(senderPubkey) { |
||||
if (connectedClients.has(senderPubkey)) { |
||||
connectedClients.get(senderPubkey).lastActivity = Date.now(); |
||||
} |
||||
return userPubkey; |
||||
} |
||||
|
||||
async function handleSignEvent(request, senderPubkey) { |
||||
if (!connectedClients.has(senderPubkey)) { |
||||
throw new Error('Not connected'); |
||||
} |
||||
|
||||
connectedClients.get(senderPubkey).lastActivity = Date.now(); |
||||
|
||||
const [eventJson] = request.params; |
||||
const event = JSON.parse(eventJson); |
||||
|
||||
if (event.pubkey && event.pubkey !== userPubkey) { |
||||
throw new Error('Event pubkey does not match signer pubkey'); |
||||
} |
||||
|
||||
event.pubkey = userPubkey; |
||||
|
||||
const serialized = JSON.stringify([ |
||||
0, |
||||
event.pubkey, |
||||
event.created_at, |
||||
event.kind, |
||||
event.tags, |
||||
event.content |
||||
]); |
||||
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialized)); |
||||
event.id = bytesToHex(new Uint8Array(hash)); |
||||
|
||||
const sig = secp256k1.sign(hexToBytes(event.id), userPrivkey); |
||||
event.sig = sig.toCompactHex(); |
||||
|
||||
console.log('[BunkerWorker] Signed event:', event.id.substring(0, 8), 'kind:', event.kind); |
||||
|
||||
return JSON.stringify(event); |
||||
} |
||||
|
||||
async function handleNip04Encrypt(request, senderPubkey) { |
||||
if (!connectedClients.has(senderPubkey)) { |
||||
throw new Error('Not connected'); |
||||
} |
||||
|
||||
connectedClients.get(senderPubkey).lastActivity = Date.now(); |
||||
|
||||
const [pubkey, plaintext] = request.params; |
||||
const privkeyHex = bytesToHex(userPrivkey); |
||||
return await nip04.encrypt(privkeyHex, pubkey, plaintext); |
||||
} |
||||
|
||||
async function handleNip04Decrypt(request, senderPubkey) { |
||||
if (!connectedClients.has(senderPubkey)) { |
||||
throw new Error('Not connected'); |
||||
} |
||||
|
||||
connectedClients.get(senderPubkey).lastActivity = Date.now(); |
||||
|
||||
const [pubkey, ciphertext] = request.params; |
||||
const privkeyHex = bytesToHex(userPrivkey); |
||||
return await nip04.decrypt(privkeyHex, pubkey, ciphertext); |
||||
} |
||||
|
||||
async function sendResponse(requestId, result, error, recipientPubkey) { |
||||
if (!ws || !connected) { |
||||
console.error('[BunkerWorker] Cannot send response: not connected'); |
||||
return; |
||||
} |
||||
|
||||
const response = { |
||||
id: requestId, |
||||
result: result !== null ? result : undefined, |
||||
error: error !== null ? error : undefined |
||||
}; |
||||
|
||||
const privkeyHex = bytesToHex(userPrivkey); |
||||
const encrypted = await nip04.encrypt(privkeyHex, recipientPubkey, JSON.stringify(response)); |
||||
|
||||
const event = { |
||||
kind: 24133, |
||||
pubkey: userPubkey, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
content: encrypted, |
||||
tags: [['p', recipientPubkey]] |
||||
}; |
||||
|
||||
const serialized = JSON.stringify([ |
||||
0, |
||||
event.pubkey, |
||||
event.created_at, |
||||
event.kind, |
||||
event.tags, |
||||
event.content |
||||
]); |
||||
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialized)); |
||||
event.id = bytesToHex(new Uint8Array(hash)); |
||||
|
||||
const sig = secp256k1.sign(hexToBytes(event.id), userPrivkey); |
||||
event.sig = sig.toCompactHex(); |
||||
|
||||
ws.send(JSON.stringify(['EVENT', event])); |
||||
console.log('[BunkerWorker] Sent response for:', requestId); |
||||
} |
||||
|
||||
// Message handler from main thread
|
||||
self.onmessage = async (event) => { |
||||
const { type, ...data } = event.data; |
||||
|
||||
switch (type) { |
||||
case 'configure': |
||||
userPubkey = data.userPubkey; |
||||
userPrivkey = data.userPrivkey ? hexToBytes(data.userPrivkey) : null; |
||||
relayUrl = data.relayUrl; |
||||
catTokenEncoded = data.catTokenEncoded; |
||||
if (data.secrets) { |
||||
allowedSecrets = new Set(data.secrets); |
||||
} |
||||
console.log('[BunkerWorker] Configured for pubkey:', userPubkey?.substring(0, 8)); |
||||
break; |
||||
|
||||
case 'connect': |
||||
try { |
||||
await connect(); |
||||
} catch (err) { |
||||
postError(err.message); |
||||
} |
||||
break; |
||||
|
||||
case 'disconnect': |
||||
disconnect(); |
||||
break; |
||||
|
||||
case 'addSecret': |
||||
allowedSecrets.add(data.secret); |
||||
break; |
||||
|
||||
case 'removeSecret': |
||||
allowedSecrets.delete(data.secret); |
||||
break; |
||||
|
||||
case 'getStatus': |
||||
postStatus(connected ? 'connected' : 'disconnected'); |
||||
postClientsUpdate(); |
||||
break; |
||||
|
||||
default: |
||||
console.warn('[BunkerWorker] Unknown message type:', type); |
||||
} |
||||
}; |
||||
|
||||
console.log('[BunkerWorker] Worker initialized'); |
||||
Loading…
Reference in new issue