Browse Source

Move bunker service to Web Worker for persistence (v0.44.6)

- 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
woikos 2 weeks ago
parent
commit
ac61e56b61
No known key found for this signature in database
  1. 40
      app/web/dist/bundle.js
  2. 2
      app/web/dist/bundle.js.map
  3. 23
      app/web/rollup.config.js
  4. 126
      app/web/src/BunkerView.svelte
  5. 423
      app/web/src/bunker-worker.js
  6. 112
      app/web/src/stores.js
  7. 2
      pkg/version/version

40
app/web/dist/bundle.js vendored

File diff suppressed because one or more lines are too long

2
app/web/dist/bundle.js.map vendored

File diff suppressed because one or more lines are too long

23
app/web/rollup.config.js

@ -34,7 +34,8 @@ function serve() { @@ -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 { @@ -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];

126
app/web/src/BunkerView.svelte

@ -1,20 +1,32 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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
);
// 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 = [];
}
};
userPrivkey: privkeyHex,
relayUrl: bunkerInfo.relay_url,
catTokenEncoded: serviceCatTokenEncoded,
secrets: bunkerSecret ? [bunkerSecret] : []
});
// 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();
}

423
app/web/src/bunker-worker.js

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

112
app/web/src/stores.js

@ -43,6 +43,118 @@ export const searchQuery = writable(""); @@ -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 ====================
/**

2
pkg/version/version

@ -1 +1 @@ @@ -1 +1 @@
v0.44.5
v0.44.6

Loading…
Cancel
Save