Move bunker service to Web Worker for persistence (v0.44.6)
Some checks failed
Go / build-and-release (push) Has been cancelled

- 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>
This commit is contained in:
woikos
2025-12-29 16:24:56 +01:00
parent ae024cc784
commit ac61e56b61
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

View File

@@ -34,7 +34,8 @@ function serve() {
};
}
export default {
// Main app bundle
const mainConfig = {
input: "src/main.js",
output: {
sourcemap: true,
@@ -95,3 +96,23 @@ export default {
clearScreen: false,
},
};
// Bunker worker bundle (runs in Web Worker context)
const workerConfig = {
input: "src/bunker-worker.js",
output: {
sourcemap: true,
format: "iife",
name: "bunkerWorker",
file: `${outputDir}/bunker-worker.js`,
},
plugins: [
resolve({
browser: true,
}),
commonjs(),
production && terser(),
],
};
export default [mainConfig, workerConfig];

View File

@@ -1,20 +1,32 @@
<script>
import { createEventDispatcher, onMount, onDestroy } from "svelte";
import { createEventDispatcher, onMount } from "svelte";
import QRCode from "qrcode";
import { getBunkerInfo, createNIP98Auth } from "./api.js";
import { BunkerService } from "./bunker-service.js";
import { requestToken, encodeToken, TokenScope, getMintInfo } from "./cashu-client.js";
import { hexToBytes } from "@noble/hashes/utils";
import { hexToBytes, bytesToHex } from "@noble/hashes/utils";
import {
bunkerServiceActive,
bunkerServiceCatToken,
bunkerClientTokens,
bunkerSelectedTokenId,
bunkerConnectedClients,
configureBunkerWorker,
connectBunkerWorker,
disconnectBunkerWorker,
addBunkerSecret,
requestBunkerStatus,
resetBunkerState
} from "./stores.js";
export let isLoggedIn = false;
export let userPubkey = "";
export let userSigner = null;
export let userPrivkey = null; // User's private key for signing
export let userPrivkey = null; // User's private key for signing (Uint8Array)
export let currentEffectiveRole = "";
const dispatch = createEventDispatcher();
// State
// Local UI state
let bunkerInfo = null;
let isLoading = false;
let error = "";
@@ -22,17 +34,13 @@
let signerQrDataUrl = "";
let copiedItem = "";
let bunkerSecret = "";
// Bunker service state
let bunkerService = null;
let isServiceActive = false;
let isStartingService = false;
let connectedClients = [];
let serviceCatToken = null; // Token for ORLY's own relay connection
// Client tokens list - each device gets its own token
let clientTokens = []; // [{id, name, token, encoded, createdAt, isEditing}]
let selectedTokenId = null; // Currently selected token for the QR code
// Subscribe to global bunker stores
$: isServiceActive = $bunkerServiceActive;
$: clientTokens = $bunkerClientTokens;
$: selectedTokenId = $bunkerSelectedTokenId;
$: connectedClients = $bunkerConnectedClients;
// Two-word name generator
const adjectives = ["brave", "calm", "clever", "cosmic", "cozy", "daring", "eager", "fancy", "gentle", "happy", "jolly", "keen", "lively", "merry", "nimble", "peppy", "quick", "rustic", "shiny", "swift", "tender", "vivid", "witty", "zesty"];
@@ -67,10 +75,10 @@
createdAt: Date.now(),
isExpanded: false
};
clientTokens = [...clientTokens, newToken];
bunkerClientTokens.update(tokens => [...tokens, newToken]);
// Select the new token if none selected
if (!selectedTokenId) {
selectedTokenId = id;
if (!$bunkerSelectedTokenId) {
bunkerSelectedTokenId.set(id);
}
console.log(`Client token "${newToken.name}" created, expires:`, new Date(token.expiry * 1000).toISOString());
return newToken;
@@ -150,18 +158,13 @@
onMount(async () => {
await loadBunkerInfo();
// Request current status from worker (in case it's already running)
requestBunkerStatus();
});
onDestroy(() => {
// Stop bunker service on component unmount
if (bunkerService) {
bunkerService.disconnect();
bunkerService = null;
isServiceActive = false;
}
});
// Note: No onDestroy cleanup - worker persists across component mounts
// Start the bunker service
// Start the bunker service (via Web Worker)
async function startBunkerService() {
// Prevent starting if already active or starting
if (isServiceActive || isStartingService) {
@@ -178,6 +181,8 @@
error = "";
try {
let serviceCatTokenEncoded = null;
// Check if CAT is required and mint tokens
if (bunkerInfo.cashu_enabled) {
console.log("CAT required, minting tokens...");
@@ -189,14 +194,16 @@
return `Nostr ${header}`;
};
// 1. Token for ORLY's BunkerService relay connection
serviceCatToken = await requestToken(
// 1. Token for worker's relay connection
const serviceCatToken = await requestToken(
mintInfo.mintUrl,
TokenScope.NIP46,
hexToBytes(userPubkey),
signHttpAuth,
[24133]
);
serviceCatTokenEncoded = encodeToken(serviceCatToken);
bunkerServiceCatToken.set(serviceCatToken);
console.log("Service CAT token acquired, expires:", new Date(serviceCatToken.expiry * 1000).toISOString());
// 2. Create first client token
@@ -204,70 +211,35 @@
}
}
// Create and start bunker service
bunkerService = new BunkerService(
bunkerInfo.relay_url,
// Configure the worker with user credentials
const privkeyHex = userPrivkey instanceof Uint8Array ? bytesToHex(userPrivkey) : userPrivkey;
configureBunkerWorker({
userPubkey,
userPrivkey
);
userPrivkey: privkeyHex,
relayUrl: bunkerInfo.relay_url,
catTokenEncoded: serviceCatTokenEncoded,
secrets: bunkerSecret ? [bunkerSecret] : []
});
// Add the current secret
if (bunkerSecret) {
bunkerService.addAllowedSecret(bunkerSecret);
}
// Set CAT token for service connection
if (serviceCatToken) {
bunkerService.setCatToken(serviceCatToken);
}
// Set up callbacks
bunkerService.onClientConnected = (pubkey) => {
connectedClients = bunkerService.getConnectedClients();
};
bunkerService.onStatusChange = (status) => {
console.log("[BunkerView] Service status changed:", status);
isServiceActive = status === 'connected';
// Don't clear tokens on disconnect - they're still valid
// Just clear the connected clients list
if (status === 'disconnected') {
connectedClients = [];
}
};
// Connect to relay
await bunkerService.connect();
isServiceActive = true;
// Connect the worker
connectBunkerWorker();
// Regenerate QR codes with CAT token
await generateQRCodes();
console.log("Bunker service started successfully");
console.log("Bunker worker started successfully");
} catch (err) {
console.error("Failed to start bunker service:", err);
error = err.message || "Failed to start bunker service";
bunkerService = null;
isServiceActive = false;
serviceCatToken = null;
clientTokens = [];
selectedTokenId = null;
resetBunkerState();
} finally {
isStartingService = false;
}
}
// Stop the bunker service
// Stop the bunker service (via Web Worker)
function stopBunkerService() {
if (bunkerService) {
bunkerService.disconnect();
bunkerService = null;
}
isServiceActive = false;
connectedClients = [];
serviceCatToken = null;
clientTokens = [];
selectedTokenId = null;
resetBunkerState();
// Regenerate QR codes without CAT token
generateQRCodes();
}

View File

@@ -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');

View File

@@ -43,6 +43,118 @@ export const searchQuery = writable("");
export const searchTabs = writable([]);
export const searchResults = writable(new Map());
// ==================== Bunker Worker State ====================
// Persists across component mounts/unmounts using Web Worker
export const bunkerWorker = writable(null);
export const bunkerServiceActive = writable(false);
export const bunkerServiceCatToken = writable(null);
export const bunkerClientTokens = writable([]); // [{id, name, token, encoded, createdAt, isExpanded}]
export const bunkerSelectedTokenId = writable(null);
export const bunkerConnectedClients = writable([]);
// Internal worker reference (not reactive)
let _bunkerWorker = null;
/**
* Initialize the bunker worker
*/
export function initBunkerWorker() {
if (_bunkerWorker) {
return _bunkerWorker;
}
_bunkerWorker = new Worker('/bunker-worker.js');
_bunkerWorker.onmessage = (event) => {
const { type, ...data } = event.data;
switch (type) {
case 'status':
bunkerServiceActive.set(data.status === 'connected');
break;
case 'clients':
bunkerConnectedClients.set(data.clients || []);
break;
case 'error':
console.error('[BunkerStore] Worker error:', data.error);
break;
case 'request':
console.log('[BunkerStore] NIP-46 request:', data.method, 'from:', data.from?.substring(0, 8));
break;
}
};
_bunkerWorker.onerror = (error) => {
console.error('[BunkerStore] Worker error:', error);
bunkerServiceActive.set(false);
};
bunkerWorker.set(_bunkerWorker);
return _bunkerWorker;
}
/**
* Get or create the bunker worker
*/
export function getBunkerWorker() {
return _bunkerWorker || initBunkerWorker();
}
/**
* Configure the bunker worker
*/
export function configureBunkerWorker(config) {
const worker = getBunkerWorker();
worker.postMessage({ type: 'configure', ...config });
}
/**
* Start the bunker worker connection
*/
export function connectBunkerWorker() {
const worker = getBunkerWorker();
worker.postMessage({ type: 'connect' });
}
/**
* Stop the bunker worker connection
*/
export function disconnectBunkerWorker() {
if (_bunkerWorker) {
_bunkerWorker.postMessage({ type: 'disconnect' });
}
}
/**
* Add a secret to the worker
*/
export function addBunkerSecret(secret) {
const worker = getBunkerWorker();
worker.postMessage({ type: 'addSecret', secret });
}
/**
* Request status from worker
*/
export function requestBunkerStatus() {
if (_bunkerWorker) {
_bunkerWorker.postMessage({ type: 'getStatus' });
}
}
/**
* Reset bunker state (call on logout or stop)
*/
export function resetBunkerState() {
disconnectBunkerWorker();
bunkerServiceActive.set(false);
bunkerServiceCatToken.set(null);
bunkerClientTokens.set([]);
bunkerSelectedTokenId.set(null);
bunkerConnectedClients.set([]);
}
// ==================== Helper Functions ====================
/**