Compare commits

...

1 Commits

Author SHA1 Message Date
woikos
ac61e56b61 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>
2025-12-29 16:24:56 +01:00
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", input: "src/main.js",
output: { output: {
sourcemap: true, sourcemap: true,
@@ -95,3 +96,23 @@ export default {
clearScreen: false, 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> <script>
import { createEventDispatcher, onMount, onDestroy } from "svelte"; import { createEventDispatcher, onMount } from "svelte";
import QRCode from "qrcode"; import QRCode from "qrcode";
import { getBunkerInfo, createNIP98Auth } from "./api.js"; import { getBunkerInfo, createNIP98Auth } from "./api.js";
import { BunkerService } from "./bunker-service.js";
import { requestToken, encodeToken, TokenScope, getMintInfo } from "./cashu-client.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 isLoggedIn = false;
export let userPubkey = ""; export let userPubkey = "";
export let userSigner = null; 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 = ""; export let currentEffectiveRole = "";
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
// State // Local UI state
let bunkerInfo = null; let bunkerInfo = null;
let isLoading = false; let isLoading = false;
let error = ""; let error = "";
@@ -22,17 +34,13 @@
let signerQrDataUrl = ""; let signerQrDataUrl = "";
let copiedItem = ""; let copiedItem = "";
let bunkerSecret = ""; let bunkerSecret = "";
// Bunker service state
let bunkerService = null;
let isServiceActive = false;
let isStartingService = 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 // Subscribe to global bunker stores
let clientTokens = []; // [{id, name, token, encoded, createdAt, isEditing}] $: isServiceActive = $bunkerServiceActive;
let selectedTokenId = null; // Currently selected token for the QR code $: clientTokens = $bunkerClientTokens;
$: selectedTokenId = $bunkerSelectedTokenId;
$: connectedClients = $bunkerConnectedClients;
// Two-word name generator // 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"]; 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(), createdAt: Date.now(),
isExpanded: false isExpanded: false
}; };
clientTokens = [...clientTokens, newToken]; bunkerClientTokens.update(tokens => [...tokens, newToken]);
// Select the new token if none selected // Select the new token if none selected
if (!selectedTokenId) { if (!$bunkerSelectedTokenId) {
selectedTokenId = id; bunkerSelectedTokenId.set(id);
} }
console.log(`Client token "${newToken.name}" created, expires:`, new Date(token.expiry * 1000).toISOString()); console.log(`Client token "${newToken.name}" created, expires:`, new Date(token.expiry * 1000).toISOString());
return newToken; return newToken;
@@ -150,18 +158,13 @@
onMount(async () => { onMount(async () => {
await loadBunkerInfo(); await loadBunkerInfo();
// Request current status from worker (in case it's already running)
requestBunkerStatus();
}); });
onDestroy(() => { // Note: No onDestroy cleanup - worker persists across component mounts
// Stop bunker service on component unmount
if (bunkerService) {
bunkerService.disconnect();
bunkerService = null;
isServiceActive = false;
}
});
// Start the bunker service // Start the bunker service (via Web Worker)
async function startBunkerService() { async function startBunkerService() {
// Prevent starting if already active or starting // Prevent starting if already active or starting
if (isServiceActive || isStartingService) { if (isServiceActive || isStartingService) {
@@ -178,6 +181,8 @@
error = ""; error = "";
try { try {
let serviceCatTokenEncoded = null;
// Check if CAT is required and mint tokens // Check if CAT is required and mint tokens
if (bunkerInfo.cashu_enabled) { if (bunkerInfo.cashu_enabled) {
console.log("CAT required, minting tokens..."); console.log("CAT required, minting tokens...");
@@ -189,14 +194,16 @@
return `Nostr ${header}`; return `Nostr ${header}`;
}; };
// 1. Token for ORLY's BunkerService relay connection // 1. Token for worker's relay connection
serviceCatToken = await requestToken( const serviceCatToken = await requestToken(
mintInfo.mintUrl, mintInfo.mintUrl,
TokenScope.NIP46, TokenScope.NIP46,
hexToBytes(userPubkey), hexToBytes(userPubkey),
signHttpAuth, signHttpAuth,
[24133] [24133]
); );
serviceCatTokenEncoded = encodeToken(serviceCatToken);
bunkerServiceCatToken.set(serviceCatToken);
console.log("Service CAT token acquired, expires:", new Date(serviceCatToken.expiry * 1000).toISOString()); console.log("Service CAT token acquired, expires:", new Date(serviceCatToken.expiry * 1000).toISOString());
// 2. Create first client token // 2. Create first client token
@@ -204,70 +211,35 @@
} }
} }
// Create and start bunker service // Configure the worker with user credentials
bunkerService = new BunkerService( const privkeyHex = userPrivkey instanceof Uint8Array ? bytesToHex(userPrivkey) : userPrivkey;
bunkerInfo.relay_url, configureBunkerWorker({
userPubkey, userPubkey,
userPrivkey userPrivkey: privkeyHex,
); relayUrl: bunkerInfo.relay_url,
catTokenEncoded: serviceCatTokenEncoded,
secrets: bunkerSecret ? [bunkerSecret] : []
});
// Add the current secret // Connect the worker
if (bunkerSecret) { connectBunkerWorker();
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;
// Regenerate QR codes with CAT token // Regenerate QR codes with CAT token
await generateQRCodes(); await generateQRCodes();
console.log("Bunker service started successfully"); console.log("Bunker worker started successfully");
} catch (err) { } catch (err) {
console.error("Failed to start bunker service:", err); console.error("Failed to start bunker service:", err);
error = err.message || "Failed to start bunker service"; error = err.message || "Failed to start bunker service";
bunkerService = null; resetBunkerState();
isServiceActive = false;
serviceCatToken = null;
clientTokens = [];
selectedTokenId = null;
} finally { } finally {
isStartingService = false; isStartingService = false;
} }
} }
// Stop the bunker service // Stop the bunker service (via Web Worker)
function stopBunkerService() { function stopBunkerService() {
if (bunkerService) { resetBunkerState();
bunkerService.disconnect();
bunkerService = null;
}
isServiceActive = false;
connectedClients = [];
serviceCatToken = null;
clientTokens = [];
selectedTokenId = null;
// Regenerate QR codes without CAT token // Regenerate QR codes without CAT token
generateQRCodes(); 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 searchTabs = writable([]);
export const searchResults = writable(new Map()); 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 ==================== // ==================== Helper Functions ====================
/** /**

View File

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