aboutsummaryrefslogtreecommitdiffstats
path: root/dev.js
blob: fb46a726c51f5e4b8c442fc6088097c3a2a96964 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
// 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 { startFeedCache } from './feed/service.js';
import { snapshotResponse } from './feed/cache.js';

const publicFeedCache = await startFeedCache();

import { buildHtml, buildJsQr, buildNip46, buildVerifyWorker } 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' };
const JS_ASSETS = { '/jsqr.js': buildJsQr, '/nip46.js': buildNip46, '/verify-worker.js': buildVerifyWorker };
const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET,POST,OPTIONS', 'access-control-allow-headers': 'content-type' };

Bun.serve({
  port,
  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 url = new URL(req.url);
      const path = url.pathname;
      if (path === '/api/feed') return snapshotResponse(publicFeedCache, req);
      // 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 (Object.hasOwn(JS_ASSETS, path)) {
        return new Response(await JS_ASSETS[path]({ minify: false }), {
          headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': NO_STORE },
        });
      }
      if (/^\/(punks|punks-sm)\//.test(path)) {
        // These are bundled images, not app routes. Returning index.html here
        // produces broken avatars and rebuilds the app for every face.
        if (!/^\/(punks|punks-sm)\/([1-9]|[1-5][0-9]|6[0-4])\.webp$/.test(path)) {
          return new Response('Not found', { status: 404 });
        }
        const file = Bun.file('static' + path);
        return await file.exists()
          ? new Response(file, { headers: { 'content-type': 'image/webp', 'cache-control': NO_STORE } })
          : new Response('Not found', { status: 404 });
      }
      if (/^\/icon(-maskable)?(-\d+)?\.(png|svg)$/.test(path)) {
        const file = Bun.file('static' + path);
        if (await file.exists()) return new Response(file, { headers: { 'cache-control': NO_STORE } });
      }
      const loc = path.match(/^\/locales\/([a-z]{2})\.json$/);
      if (loc) {
        const f = Bun.file('src/locales/' + loc[1] + '.json');
        return (await f.exists())
          ? new Response(f, { headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': NO_STORE } })
          : new Response('{}', { status: 404, headers: { 'content-type': 'application/json', 'cache-control': NO_STORE } });
      }
      const html = await buildHtml({ minify: false });
      return new Response(html, {
        headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': NO_STORE },
      });
    } catch (e) {
      return new Response(`Build error:\n\n${e.stack || e}`, {
        status: 500,
        headers: { 'content-type': 'text/plain; charset=utf-8' },
      });
    }
  },
  // 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}  (also on 0.0.0.0:${port} for LAN/Tailscale)`);