diff options
| -rw-r--r-- | dev.js | 69 | ||||
| -rw-r--r-- | src/api.js | 6 | ||||
| -rw-r--r-- | src/wallet.js | 15 |
3 files changed, 67 insertions, 23 deletions
@@ -1,31 +1,44 @@ // Dev server: rebuilds the inlined bundle on every request so you just refresh // the browser to see changes. Same output as `bun run build`, unminified. +// +// It also proxies the regtest backends (which bind to localhost on this machine) +// through the same origin, so the app works unchanged whether you load it on +// localhost or from a phone over the LAN/Tailscale — no per-device config, no +// CORS. HTTP backends by path prefix; WebSocket backends by exact path. import { buildHtml, buildJsQr } from './build.js'; const port = Number(process.env.PORT || 5173); +const HTTP_PROXY = { '/boltz': 'http://localhost:9001', '/esplora': 'http://localhost:3000', '/sp': 'http://localhost:8888' }; +const WS_PROXY = { '/electrum': 'ws://localhost:50003', '/sp/ws': 'ws://localhost:8888/ws' }; +const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET,POST,OPTIONS', 'access-control-allow-headers': 'content-type' }; + Bun.serve({ port, - async fetch(req) { - // Never let the browser cache (or bfcache) dev assets — every load rebuilds - // from source, so a stale HTTP cache or restored tab can't pin old code. + hostname: '0.0.0.0', // listen on all interfaces so the LAN/Tailscale can reach it + async fetch(req, server) { const NO_STORE = 'no-store, must-revalidate'; try { - const path = new URL(req.url).pathname; - // The regtest boltz API (localhost:9001) sends no CORS headers, so the - // browser can't fetch it cross-origin. Proxy /boltz/* to it (adding CORS) - // so Lightning swaps work same-origin in dev. - if (path === '/boltz' || path.startsWith('/boltz/')) { - const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET,POST,OPTIONS', 'access-control-allow-headers': 'content-type' }; - if (req.method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS }); - const target = 'http://localhost:9001' + path.replace(/^\/boltz/, '') + new URL(req.url).search; - const r = await fetch(target, { - method: req.method, - headers: req.headers.get('content-type') ? { 'content-type': req.headers.get('content-type') } : {}, - body: ['GET', 'HEAD'].includes(req.method) ? undefined : await req.text(), - }); - return new Response(await r.text(), { status: r.status, headers: { 'content-type': r.headers.get('content-type') || 'application/json', ...CORS } }); + const url = new URL(req.url); + const path = url.pathname; + // WebSocket proxies (Fulcrum electrum, SP-indexer push) — upgrade + relay. + if (WS_PROXY[path]) { + if (server.upgrade(req, { data: { target: WS_PROXY[path] } })) return; + return new Response('expected websocket', { status: 426 }); + } + // HTTP proxies (boltz, esplora, SP indexer) — same-origin, CORS for cross-origin loads. + for (const prefix in HTTP_PROXY) { + if (path === prefix || path.startsWith(prefix + '/')) { + if (req.method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS }); + const target = HTTP_PROXY[prefix] + path.slice(prefix.length) + url.search; + const r = await fetch(target, { + method: req.method, + headers: req.headers.get('content-type') ? { 'content-type': req.headers.get('content-type') } : {}, + body: ['GET', 'HEAD'].includes(req.method) ? undefined : await req.arrayBuffer(), + }); + return new Response(await r.arrayBuffer(), { status: r.status, headers: { 'content-type': r.headers.get('content-type') || 'application/json', ...CORS } }); + } } if (path === '/jsqr.js') { return new Response(await buildJsQr({ minify: false }), { @@ -50,6 +63,26 @@ Bun.serve({ }); } }, + // Relay each browser WebSocket to its localhost backend, buffering until the + // upstream connection opens. + websocket: { + open(ws) { + const up = new WebSocket(ws.data.target); + ws.data.up = up; + ws.data.q = []; + up.onopen = () => { const q = ws.data.q; ws.data.q = null; for (const m of q) up.send(m); }; + up.onmessage = (e) => { try { ws.send(e.data); } catch {} }; + up.onclose = () => { try { ws.close(); } catch {} }; + up.onerror = () => { try { ws.close(); } catch {} }; + }, + message(ws, msg) { + const up = ws.data.up; + if (!up) return; + if (up.readyState === 1) up.send(msg); + else if (ws.data.q) ws.data.q.push(msg); + }, + close(ws) { try { ws.data.up && ws.data.up.close(); } catch {} }, + }, }); -console.log(`dev server → http://localhost:${port}`); +console.log(`dev server → http://localhost:${port} (also on 0.0.0.0:${port} for LAN/Tailscale)`); @@ -76,8 +76,8 @@ const DATA_SOURCES_BY_NET = { { id: 'custom', label: 'Custom', type: 'auto', url: '' }, ], regtest: [ - { id: 'local', label: 'Local Fulcrum', type: 'electrum', url: 'ws://localhost:50003' }, - { id: 'localesplora', label: 'Local Esplora', type: 'esplora', base: 'http://localhost:3000', web: 'http://localhost:3000', kind: 'esplora' }, + { id: 'local', label: 'Local Fulcrum', type: 'electrum', url: '/electrum' }, + { id: 'localesplora', label: 'Local Esplora', type: 'esplora', base: '/esplora', web: 'http://localhost:3000', kind: 'esplora' }, { id: 'custom', label: 'Custom', type: 'auto', url: '' }, ], }; @@ -185,7 +185,7 @@ const SP_INDEXER_PRESETS_BY_NET = { mainnet: [{ id: 'coinos', label: 'coinos', url: 'https://sp.coinos.io' }, { id: 'custom', label: 'Custom', url: '' }], testnet: [{ id: 'custom', label: 'Custom', url: '' }], mutinynet: [{ id: 'custom', label: 'Custom', url: '' }], - regtest: [{ id: 'local', label: 'Local (localhost:8888)', url: 'http://localhost:8888' }, { id: 'custom', label: 'Custom', url: '' }], + regtest: [{ id: 'local', label: 'Local (proxied)', url: '/sp' }, { id: 'custom', label: 'Custom', url: '' }], }; const SP_INDEXER_DEFAULT = { mainnet: 'coinos', testnet: 'custom', mutinynet: 'custom', regtest: 'local' }; const SP_INDEXER_KEY = 'btc-wallet-sp-indexer'; diff --git a/src/wallet.js b/src/wallet.js index bc70561..519e3e1 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -27,6 +27,17 @@ import { NostrSync, getSyncConfig, encryptGiftPayload, npubOf } from './nostr.js import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan, silentPaymentOutputPrivKey, silentPaymentCandidate, bloomHas } from './silentpay.js'; import { schnorr } from '@noble/curves/secp256k1'; +// Resolve a same-origin '/path' WebSocket URL to an absolute ws(s):// URL against +// the page. The regtest backends are proxied through the dev server as relative +// paths (e.g. '/electrum') so the app works from a phone over the LAN/Tailscale; +// absolute URLs (real deployments, tests) pass through untouched. +function absWsUrl(u) { + if (u && u[0] === '/' && typeof location !== 'undefined') { + return (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + u; + } + return u; +} + // No look-ahead: stop scanning a chain at the first unused address. This wallet // only ever exposes ONE unused address at a time (freshReceive = first unused; // there is no "generate another address" button), so used addresses stay @@ -528,7 +539,7 @@ export class Wallet { this._spDisconnect(); this._spWsUrl = url; let ws; - try { ws = new WebSocket(url); } catch { return; } + try { ws = new WebSocket(absWsUrl(url)); } catch { return; } this._spWs = ws; ws.onopen = () => { this.spLive = true; @@ -1772,7 +1783,7 @@ export class Wallet { if (getBackend() === 'electrum') { if (!this._wsCandidates || !this._wsCandidates.length) { this._wsCandidates = electrumCandidates(this.netName); this._wsCandIdx = 0; } const list = this._wsCandidates; - return list.length ? list[(this._wsCandIdx || 0) % list.length] : null; + return list.length ? absWsUrl(list[(this._wsCandIdx || 0) % list.length]) : null; } return null; } |
