aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-29 07:22:30 -0700
committerAdam Soltys <[email protected]>2026-06-29 07:22:30 -0700
commit729690e58a2601ec9c96f5b4431944c1d8f999c7 (patch)
treeb6270e661f914c136c5db08e1b71254c01080b03
parent0baf4cf55daa1ed33c2b276f40a6fd8aa922ee34 (diff)
downloadcoinosv3-729690e58a2601ec9c96f5b4431944c1d8f999c7.tar.gz
coinosv3-729690e58a2601ec9c96f5b4431944c1d8f999c7.zip
Dev: proxy all regtest backends through the dev server for LAN/phone testing
The dev server already binds 0.0.0.0, but the app's regtest data source + SP indexer pointed at localhost, which on a phone means the phone's own localhost. Proxy them all through the dev server (same-origin), like /boltz already was: HTTP by prefix (/boltz, /esplora, /sp) and WebSocket by exact path (/electrum → Fulcrum, /sp/ws → SP indexer), with a Bun websocket relay. The regtest presets become relative paths, and a small absWsUrl() resolves '/path' WS URLs to ws(s)://<page-host>/path so they work from any device hitting the dev server. No per-device config, no CORS. Verified: /electrum relays Fulcrum 2.1.1, /esplora returns the tip height, and the desktop wallet still loads (balance 98,444) and connects through the proxy with no errors. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
-rw-r--r--dev.js69
-rw-r--r--src/api.js6
-rw-r--r--src/wallet.js15
3 files changed, 67 insertions, 23 deletions
diff --git a/dev.js b/dev.js
index 02aa772..54a88a2 100644
--- a/dev.js
+++ b/dev.js
@@ -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)`);
diff --git a/src/api.js b/src/api.js
index a12ddc6..186a0ca 100644
--- a/src/api.js
+++ b/src/api.js
@@ -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;
}