@venturewild/workspace 0.6.28 → 0.6.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venturewild/workspace",
3
- "version": "0.6.28",
3
+ "version": "0.6.29",
4
4
  "description": "Claude Code Web — Replit/Lovable-style chat-first browser UI that wraps the AI agent already installed on your machine.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -58,6 +58,16 @@ import { DaemonSupervisor } from './daemon-supervisor.mjs';
58
58
  import { TunnelWatchdog } from './tunnel-watchdog.mjs';
59
59
  import { SyncControl } from './sync.mjs';
60
60
  import { detectPreviewPorts, checkPort } from './preview.mjs';
61
+ import {
62
+ LocalPreviewRegistry,
63
+ previewTokenFromHeaders,
64
+ proxyHttpToPort,
65
+ proxyWsUpgrade,
66
+ reconcilePreviews,
67
+ previewUrlFor,
68
+ PREVIEW_ENDED_PAGE,
69
+ } from './preview-proxy.mjs';
70
+ import { createPreviewRails } from './preview-rails.mjs';
61
71
  import { createBazaar } from './bazaar/core.mjs';
62
72
  import { createCanvas } from './canvas/core.mjs';
63
73
  import { createCanvasRails } from './canvas-rails.mjs';
@@ -434,6 +444,9 @@ export async function createServer(overrides = {}) {
434
444
  // Cross-host shared chat sessions client (Phase 2). Inert without an account /
435
445
  // bmo-sync URL; only used for SHARED workspaces (local ones stay per-host).
436
446
  const sessionRails = overrides.sessionRails || createSessionRails(config, config.account);
447
+ // Shared-preview registry client (ticket tk-5d09bf04-5). Inert until login;
448
+ // the dev-port watcher (below) publishes/heartbeats/retires live previews here.
449
+ const previewRails = overrides.previewRails || createPreviewRails(config);
437
450
  // Shared-workspace membership client (sharing slice — design §4). The account
438
451
  // token is kept top-level on config (out of the broadcast `config.account`), so
439
452
  // pass it explicitly, mirroring the CLI `wild-workspace workspace …`. Inert (and
@@ -1081,6 +1094,34 @@ export async function createServer(overrides = {}) {
1081
1094
  );
1082
1095
  }
1083
1096
 
1097
+ // --- shared-preview reverse-proxy (ticket tk-5d09bf04-5) ------------------
1098
+ // MUST be the FIRST middleware. A `pv-<token>` Host means a viewer is looking
1099
+ // at a dev server running on THIS machine (routed here through the tunnel:
1100
+ // vw-proxy → bmo-sync preview-registry → this account's daemon → :5173). We
1101
+ // reverse-proxy it straight to the local dev port and return — deliberately
1102
+ // BEFORE the auth wall + the security-headers/CSP middleware, because the dev
1103
+ // server is the user's OWN product on its OWN origin: the app's
1104
+ // X-Frame-Options/CSP would break it, and access is anyone-with-link (the
1105
+ // unguessable token IS the capability — decision D3). SSRF-safe: the only
1106
+ // reachable target is a loopback port THIS server itself registered, and the
1107
+ // relay strips any client-forged X-Forwarded-* so the host can't be spoofed
1108
+ // from off-box. Populated by the dev-port watcher (Part B).
1109
+ const localPreviews = overrides.localPreviews || new LocalPreviewRegistry();
1110
+ app.use('*', async (c, next) => {
1111
+ const token = previewTokenFromHeaders((n) => c.req.header(n));
1112
+ if (!token) return next();
1113
+ const port = localPreviews.portForToken(token);
1114
+ if (!port) {
1115
+ // Unknown / expired token → a graceful "ended" page, never the SPA shell.
1116
+ return c.body(PREVIEW_ENDED_PAGE, 404, {
1117
+ 'content-type': 'text/html',
1118
+ 'cache-control': 'no-store',
1119
+ });
1120
+ }
1121
+ localPreviews.heartbeat(token);
1122
+ return proxyHttpToPort(port, c.req.raw);
1123
+ });
1124
+
1084
1125
  // Security headers on every response (SECURITY.md S7). Set AFTER next() so they
1085
1126
  // land on static-asset + SPA-fallback responses too. Referrer-Policy: no-referrer
1086
1127
  // also backstops S1 — a stale `?t=` in the URL can't leak via the Referer header.
@@ -2909,6 +2950,46 @@ export async function createServer(overrides = {}) {
2909
2950
  return c.json({ port, host, listening: await checkPort(port, host) });
2910
2951
  });
2911
2952
 
2953
+ // The live previews to show in this workspace's Live view (ticket tk-5d09bf04-5,
2954
+ // Part C). Combines the previews THIS server hosts (`mine`) with members'
2955
+ // previews on the rails (membership-gated by the shared slug). Each carries the
2956
+ // public `pv-` URL the iframe loads; `mine` entries also carry the local port so
2957
+ // the owner viewing on THIS machine can load localhost directly (no tunnel hop).
2958
+ // A non-member viewing a raw `pv-` link never calls this — they load the link.
2959
+ app.get('/api/preview/live', async (c) => {
2960
+ const forbidden = require(c, 'preview');
2961
+ if (forbidden) return forbidden;
2962
+ const byToken = new Map();
2963
+ for (const e of localPreviews.list()) {
2964
+ byToken.set(e.token, {
2965
+ token: e.token,
2966
+ url: previewUrlFor(config.shareBaseUrl, e.token),
2967
+ port: e.port, // owner-only convenience (local fast path); harmless to expose to the owner
2968
+ label: e.initiator || config.account?.email || 'You',
2969
+ mine: true,
2970
+ startedAt: e.startedAt,
2971
+ });
2972
+ }
2973
+ const slug = sharedSlugFor(c);
2974
+ if (slug && previewRails.capable) {
2975
+ const r = await previewRails.listWorkspace(slug);
2976
+ if (r.ok) {
2977
+ for (const p of r.previews) {
2978
+ if (byToken.has(p.token)) continue; // a local (mine) entry always wins
2979
+ byToken.set(p.token, {
2980
+ token: p.token,
2981
+ url: p.url || previewUrlFor(config.shareBaseUrl, p.token),
2982
+ label: p.label || p.owner_email || 'A teammate',
2983
+ mine: false,
2984
+ startedAt: p.created_at ? p.created_at * 1000 : Date.now(),
2985
+ });
2986
+ }
2987
+ }
2988
+ }
2989
+ const previews = [...byToken.values()].sort((a, b) => b.startedAt - a.startedAt);
2990
+ return c.json({ previews, shared: Boolean(slug) });
2991
+ });
2992
+
2912
2993
  // --- bazaar (producer/remix marketplace — §3.7) ---------------------------
2913
2994
  // The shelf + ledger state for the UI. Live updates ride the chat chunk stream
2914
2995
  // (agent tool results) and the /preview/match broadcast below — never a watcher.
@@ -3615,12 +3696,74 @@ export async function createServer(overrides = {}) {
3615
3696
  } catch { /* best-effort — restart-server falls back to a health probe */ }
3616
3697
  }
3617
3698
 
3699
+ // --- shared-preview dev-port watcher (ticket tk-5d09bf04-5, Part B) --------
3700
+ // Proactively detects the user's local dev servers and keeps each one published
3701
+ // as a live `pv-` preview (so a collaborator's Live view shows it even when the
3702
+ // owner isn't looking). Only runs when logged in (a `pv-` link needs the
3703
+ // tunnel); degrade-never below it. Tagged with the boot/default workspace's
3704
+ // shared slug for member discovery (the picker covers the multi-server edge).
3705
+ // Skipped under the test runner; tests drive reconcilePreviews directly.
3706
+ let previewTimer = null;
3707
+ function defaultSharedSlug() {
3708
+ try {
3709
+ const entry = getWorkspace(config.workspaceId, registryEnv);
3710
+ return entry?.kind === 'shared' && entry.shared?.slug ? entry.shared.slug : null;
3711
+ } catch {
3712
+ return null;
3713
+ }
3714
+ }
3715
+ async function previewWatcherTick() {
3716
+ if (!previewRails.capable) {
3717
+ localPreviews.sweep();
3718
+ return;
3719
+ }
3720
+ await reconcilePreviews({
3721
+ detect: () => detectPreviewPorts(),
3722
+ registry: localPreviews,
3723
+ rails: previewRails,
3724
+ slug: defaultSharedSlug(),
3725
+ initiator: config.account?.email || config.account?.slug || null,
3726
+ selfPort: config.port,
3727
+ });
3728
+ }
3729
+ if (
3730
+ process.env.VITEST !== 'true' &&
3731
+ config.nodeEnv !== 'test' &&
3732
+ !overrides.previewWatcherDisabled
3733
+ ) {
3734
+ previewWatcherTick().catch((e) => log('[preview]', `watcher tick failed: ${e?.message || e}`));
3735
+ previewTimer = setInterval(() => {
3736
+ previewWatcherTick().catch((e) => log('[preview]', `watcher tick failed: ${e?.message || e}`));
3737
+ }, 20_000);
3738
+ if (previewTimer.unref) previewTimer.unref();
3739
+ }
3740
+
3618
3741
  // --- websocket bridge ---
3619
3742
  // The power-user terminal (T1) loads node-pty lazily + optionally — same loader as
3620
3743
  // in-app sign-in. Overridable so tests can inject a deterministic fake PTY.
3621
3744
  const ptyLoader = overrides.ptyLoader || defaultPtyLoader;
3622
3745
  const wss = new WebSocketServer({ noServer: true });
3746
+ // Dedicated upgrader for shared-preview HMR websockets (ticket tk-5d09bf04-5).
3747
+ // Kept separate from `wss` so its connections never reach the chat/activity/pty
3748
+ // 'connection' wiring — a preview ws is bridged straight to the dev server.
3749
+ const previewWss = new WebSocketServer({ noServer: true });
3623
3750
  httpServer.on('upgrade', async (req, socket, head) => {
3751
+ // Shared-preview WS first: a `pv-<token>` Host upgrading ANY path is the dev
3752
+ // server's HMR (or any ws it exposes). Bridge it to the local dev port,
3753
+ // bypassing the app's WS auth (the dev server is the user's own product, and
3754
+ // the token is the capability). Same SSRF guard as the HTTP path: only a
3755
+ // port THIS server registered is reachable.
3756
+ const previewToken = previewTokenFromHeaders((n) => req.headers[n]);
3757
+ if (previewToken) {
3758
+ const port = localPreviews.portForToken(previewToken);
3759
+ if (!port) {
3760
+ socket.destroy();
3761
+ return;
3762
+ }
3763
+ localPreviews.heartbeat(previewToken);
3764
+ proxyWsUpgrade({ previewWss, req, socket, head, port });
3765
+ return;
3766
+ }
3624
3767
  const reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
3625
3768
  const supported = ['/ws/chat', '/ws/activity', '/ws/pty'];
3626
3769
  if (!supported.includes(reqUrl.pathname)) {
@@ -3865,6 +4008,8 @@ export async function createServer(overrides = {}) {
3865
4008
  app,
3866
4009
  httpServer,
3867
4010
  wss,
4011
+ localPreviews,
4012
+ previewRails,
3868
4013
  activityBus,
3869
4014
  inboxWatcher,
3870
4015
  tokenRegistry,
@@ -3879,6 +4024,7 @@ export async function createServer(overrides = {}) {
3879
4024
  async stop() {
3880
4025
  try { clearTimeout(autoWakeTimer); } catch {}
3881
4026
  try { if (bazaarSyncTimer) clearInterval(bazaarSyncTimer); } catch {}
4027
+ try { if (previewTimer) clearInterval(previewTimer); } catch {}
3882
4028
  for (const t of currentTurns.values()) {
3883
4029
  try { t.session.close(); } catch {}
3884
4030
  }
@@ -0,0 +1,417 @@
1
+ // Shared-preview reverse-proxy (ticket tk-5d09bf04-5).
2
+ //
3
+ // WHY: in a shared workspace only the machine running a dev server can see its
4
+ // preview. The Live-view iframe used to load a literal `http://localhost:PORT`,
5
+ // which the browser resolves to the *viewer's own* machine — so every remote
6
+ // viewer (a collaborator, or the owner on their phone via the public URL) got a
7
+ // blank pane. A shared workspace is N peer servers; files sync, processes don't.
8
+ //
9
+ // THE FIX (decision D1/A): give each running dev server its own temporary
10
+ // capability host `pv-<rand>.venturewild.llc`, routed through the EXISTING
11
+ // tunnel back to the host actually running it:
12
+ //
13
+ // browser → vw-proxy → bmo-sync (preview registry: token→account→daemon)
14
+ // → daemon (forwards to :5173, UNCHANGED)
15
+ // → THIS server, which sees `X-Forwarded-Host: pv-<rand>.…`
16
+ // → reverse-proxies EVERYTHING to http://127.0.0.1:<devport>
17
+ //
18
+ // A separate ORIGIN (its own host) means the dev server owns the root, so its
19
+ // absolute asset paths (`/assets/*`, `/@vite/client`) and its HMR websocket all
20
+ // resolve correctly — the thing a same-origin path-proxy can't do (decision: the
21
+ // path-proxy was rejected as fragile).
22
+ //
23
+ // SECURITY: this module ONLY ever targets `127.0.0.1:<port>` for a port that is
24
+ // (a) currently in the LocalPreviewRegistry, i.e. one THIS server detected +
25
+ // registered itself. There is no caller-controlled host/port — no SSRF, no
26
+ // internal port scanner. A server only ever receives `pv-` requests for tokens
27
+ // it minted (bmo-sync routes a token to the account that registered it, whose
28
+ // daemon forwards to this same server), so the local map is authoritative.
29
+
30
+ import { customAlphabet } from 'nanoid';
31
+ import { WebSocket } from 'ws';
32
+
33
+ // `pv-` + 12 lowercase-alnum chars. Stays inside bmo-sync's slug shape
34
+ // (`^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$`, 3–30 chars) so vw-proxy forwards it with
35
+ // no change, and ~36^12 of entropy makes the link an unguessable capability.
36
+ export const PREVIEW_TOKEN_PREFIX = 'pv-';
37
+ const TOKEN_BODY_LEN = 12;
38
+ const mintBody = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', TOKEN_BODY_LEN);
39
+
40
+ // Lifetime defaults (live-only, decision D2). The owning server heartbeats every
41
+ // ~PREVIEW_HEARTBEAT_MS; an entry that misses several beats (machine crashed,
42
+ // slept, or disconnected) auto-expires so dead links never accumulate.
43
+ export const PREVIEW_HEARTBEAT_MS = 30_000;
44
+ export const PREVIEW_TTL_MS = 100_000; // ~3 missed heartbeats
45
+
46
+ /** Mint a fresh preview token (e.g. "pv-7a3f2b9c1d0e"). */
47
+ export function mintPreviewToken() {
48
+ return `${PREVIEW_TOKEN_PREFIX}${mintBody()}`;
49
+ }
50
+
51
+ // A host is a preview host iff its leftmost label is a `pv-` token. Accepts the
52
+ // public form (`pv-xxx.venturewild.llc`), a bare token, an explicit port, and is
53
+ // case-insensitive. Returns the token (lowercased) or null.
54
+ export function parsePreviewToken(host) {
55
+ if (!host || typeof host !== 'string') return null;
56
+ const bare = host.trim().toLowerCase().split(':')[0]; // strip any :port
57
+ if (!bare) return null;
58
+ const label = bare.split('.')[0]; // leftmost DNS label (or the whole bare token)
59
+ if (!label.startsWith(PREVIEW_TOKEN_PREFIX)) return null;
60
+ // Validate the full slug shape so a malformed/oversized label can't slip
61
+ // through as a "token" and key the registry with junk.
62
+ if (!/^pv-[a-z0-9]{4,40}$/.test(label)) return null;
63
+ return label;
64
+ }
65
+
66
+ // Pick the token to route on: prefer the relay-stamped X-Forwarded-Host (the
67
+ // public subdomain the visitor actually typed), falling back to Host for genuine
68
+ // local testing. `getHeader` is a (name) => value|undefined accessor.
69
+ export function previewTokenFromHeaders(getHeader) {
70
+ const fwd = getHeader('x-forwarded-host');
71
+ return parsePreviewToken(fwd) || parsePreviewToken(getHeader('host'));
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // LocalPreviewRegistry — the previews THIS server is hosting (one per dev port).
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * Tracks the dev-server previews this machine is currently serving. Keyed by
80
+ * port (a machine has at most one dev server per port). Each entry carries the
81
+ * minted token, the workspace it belongs to, and who started it, so the canvas
82
+ * can default a viewer to their own preview and list the rest in a picker (D2).
83
+ *
84
+ * `nowFn` is injectable so tests drive expiry without real clocks.
85
+ */
86
+ export class LocalPreviewRegistry {
87
+ constructor({ ttlMs = PREVIEW_TTL_MS, nowFn = () => Date.now() } = {}) {
88
+ this.ttlMs = ttlMs;
89
+ this.now = nowFn;
90
+ this.byPort = new Map(); // port -> entry
91
+ this.byToken = new Map(); // token -> entry (same object)
92
+ }
93
+
94
+ /**
95
+ * Register (idempotently) a preview for a local dev port. Re-registering the
96
+ * same (workspaceId, port) just refreshes its heartbeat and returns the
97
+ * existing entry — so the dev-port poller can call this every tick without
98
+ * minting a new token each time.
99
+ */
100
+ register({ port, workspaceId, initiator = null, label = null }) {
101
+ const p = Number(port);
102
+ if (!Number.isInteger(p) || p < 1 || p > 65535) {
103
+ throw new Error(`invalid preview port: ${port}`);
104
+ }
105
+ const existing = this.byPort.get(p);
106
+ if (existing && existing.workspaceId === workspaceId) {
107
+ existing.lastSeen = this.now();
108
+ if (initiator != null) existing.initiator = initiator;
109
+ if (label != null) existing.label = label;
110
+ return existing;
111
+ }
112
+ // Port reused for a different workspace → retire the stale entry first.
113
+ if (existing) this.remove(existing.token);
114
+ const now = this.now();
115
+ const entry = {
116
+ token: mintPreviewToken(),
117
+ port: p,
118
+ workspaceId: workspaceId ?? null,
119
+ initiator,
120
+ label,
121
+ startedAt: now,
122
+ lastSeen: now,
123
+ };
124
+ this.byPort.set(p, entry);
125
+ this.byToken.set(entry.token, entry);
126
+ return entry;
127
+ }
128
+
129
+ /** Look up the entry for a token (used by the reverse-proxy). */
130
+ get(token) {
131
+ return this.byToken.get(token) || null;
132
+ }
133
+
134
+ /** The dev port behind a token, or null. */
135
+ portForToken(token) {
136
+ const e = this.byToken.get(token);
137
+ return e ? e.port : null;
138
+ }
139
+
140
+ /** The entry currently registered for a dev port, or null. */
141
+ getByPort(port) {
142
+ return this.byPort.get(Number(port)) || null;
143
+ }
144
+
145
+ /** Refresh a token's heartbeat. Returns true if it existed. */
146
+ heartbeat(token) {
147
+ const e = this.byToken.get(token);
148
+ if (!e) return false;
149
+ e.lastSeen = this.now();
150
+ return true;
151
+ }
152
+
153
+ /** Remove a preview (dev server stopped). Returns the removed entry or null. */
154
+ remove(token) {
155
+ const e = this.byToken.get(token);
156
+ if (!e) return null;
157
+ this.byToken.delete(token);
158
+ this.byPort.delete(e.port);
159
+ return e;
160
+ }
161
+
162
+ removeByPort(port) {
163
+ const e = this.byPort.get(Number(port));
164
+ return e ? this.remove(e.token) : null;
165
+ }
166
+
167
+ /** All live previews this server hosts. */
168
+ list() {
169
+ return [...this.byToken.values()];
170
+ }
171
+
172
+ /**
173
+ * Drop entries whose heartbeat is older than the TTL (crash/sleep/disconnect).
174
+ * Returns the removed entries so the caller can also retire them on the rails.
175
+ */
176
+ sweep() {
177
+ const cutoff = this.now() - this.ttlMs;
178
+ const dead = [];
179
+ for (const e of this.byToken.values()) {
180
+ if (e.lastSeen < cutoff) dead.push(e);
181
+ }
182
+ for (const e of dead) this.remove(e.token);
183
+ return dead;
184
+ }
185
+ }
186
+
187
+ // Derive a preview's public URL from the install's share base by swapping the
188
+ // leftmost label for the token: `https://tuananh.venturewild.llc` +
189
+ // `pv-7a3f` → `https://pv-7a3f.venturewild.llc`. Returns null for a non-public
190
+ // base (localhost / bare IP / no subdomain) where a `pv-` host can't resolve.
191
+ export function previewUrlFor(shareBaseUrl, token) {
192
+ if (!shareBaseUrl || !token) return null;
193
+ let u;
194
+ try {
195
+ u = new URL(shareBaseUrl);
196
+ } catch {
197
+ return null;
198
+ }
199
+ const host = u.hostname;
200
+ if (host === 'localhost' || /^\d+\.\d+\.\d+\.\d+$/.test(host) || host.includes(':')) return null;
201
+ const parts = host.split('.');
202
+ if (parts.length < 3) return null; // need a real <slug>.<domain.tld>
203
+ parts[0] = token;
204
+ return `https://${parts.join('.')}`;
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Dev-port reconcile (Part B) — one detection pass, kept pure for unit tests.
209
+ // ---------------------------------------------------------------------------
210
+
211
+ /**
212
+ * Reconcile the LocalPreviewRegistry against the dev ports currently listening:
213
+ * - a newly-seen port → register (mint token) + rails.publish
214
+ * - a port that vanished → remove locally + rails.retire
215
+ * - a surviving port → heartbeat locally + on the rails (keeps it live)
216
+ * - a TTL-expired straggler (crash/sleep) → swept + rails.retire
217
+ *
218
+ * `rails` is a degrade-never client (its calls no-op when not logged in / the
219
+ * endpoint is absent), so this is safe to run before bmo-sync ships Part D.
220
+ * Injected `detect` is `detectPreviewPorts`. `selfPort` excludes the
221
+ * wild-workspace server's own port from ever being treated as a preview.
222
+ */
223
+ export async function reconcilePreviews({
224
+ detect,
225
+ registry,
226
+ rails,
227
+ slug = null,
228
+ initiator = null,
229
+ selfPort = null,
230
+ }) {
231
+ const found = (await detect()) || [];
232
+ const livePorts = new Set(found.map((d) => d.port).filter((p) => p !== selfPort));
233
+
234
+ for (const port of livePorts) {
235
+ const existing = registry.getByPort(port);
236
+ if (existing) {
237
+ registry.heartbeat(existing.token);
238
+ await rails.heartbeat(existing.token);
239
+ } else {
240
+ const entry = registry.register({ port, workspaceId: slug, initiator, label: initiator });
241
+ await rails.publish({ token: entry.token, slug, label: initiator });
242
+ }
243
+ }
244
+ for (const entry of registry.list()) {
245
+ if (!livePorts.has(entry.port)) {
246
+ registry.remove(entry.token);
247
+ await rails.retire(entry.token);
248
+ }
249
+ }
250
+ for (const dead of registry.sweep()) {
251
+ await rails.retire(dead.token);
252
+ }
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // HTTP reverse-proxy
257
+ // ---------------------------------------------------------------------------
258
+
259
+ // Headers we never forward to / from the local dev server.
260
+ const REQ_STRIP = new Set([
261
+ 'host', // we set our own (127.0.0.1:port) so a Host-checking dev server (Vite
262
+ // allowedHosts / DNS-rebind guard) accepts the request
263
+ 'connection',
264
+ 'keep-alive',
265
+ 'proxy-authorization',
266
+ 'proxy-authenticate',
267
+ 'te',
268
+ 'trailer',
269
+ 'transfer-encoding',
270
+ 'upgrade',
271
+ // The relay's forwarding headers describe the public hop, not this local one.
272
+ 'x-forwarded-host',
273
+ 'x-forwarded-proto',
274
+ ]);
275
+ // NOTE on compression: we let undici (global fetch) own content negotiation — it
276
+ // sets its own `accept-encoding` and transparently DECODES the response body, so
277
+ // the body stream we forward is already plaintext. We therefore strip the now-
278
+ // stale `content-encoding`/`content-length` from the response (see RES_STRIP)
279
+ // rather than trying to suppress compression on the request.
280
+
281
+ const RES_STRIP = new Set([
282
+ 'connection',
283
+ 'keep-alive',
284
+ 'transfer-encoding',
285
+ 'content-encoding', // body is already decoded by undici fetch
286
+ 'content-length', // length changed with the encoding
287
+ 'x-frame-options', // must NOT block the Live-view iframe framing this origin
288
+ ]);
289
+
290
+ /**
291
+ * Reverse-proxy one web `Request` to `http://127.0.0.1:<port>` and return the
292
+ * web `Response` (suitable for returning straight from a Hono handler). The
293
+ * dev server is the user's own trusted code; we transport it faithfully but
294
+ * strip framing/encoding headers that would break the iframe or the body.
295
+ *
296
+ * On upstream failure (dev server stopped) returns a small 502 page rather than
297
+ * throwing, so the iframe shows a graceful "ended" state.
298
+ */
299
+ export async function proxyHttpToPort(port, request, { fetchImpl = (...a) => globalThis.fetch(...a) } = {}) {
300
+ const p = Number(port);
301
+ if (!Number.isInteger(p) || p < 1 || p > 65535) {
302
+ return new Response('bad preview port', { status: 502 });
303
+ }
304
+ const inUrl = new URL(request.url);
305
+ const target = `http://127.0.0.1:${p}${inUrl.pathname}${inUrl.search}`;
306
+
307
+ const headers = new Headers();
308
+ for (const [name, value] of request.headers) {
309
+ if (!REQ_STRIP.has(name.toLowerCase())) headers.set(name, value);
310
+ }
311
+ headers.set('host', `127.0.0.1:${p}`);
312
+
313
+ const method = request.method.toUpperCase();
314
+ const hasBody = method !== 'GET' && method !== 'HEAD';
315
+ let upstream;
316
+ try {
317
+ upstream = await fetchImpl(target, {
318
+ method,
319
+ headers,
320
+ body: hasBody ? request.body : undefined,
321
+ redirect: 'manual', // let the browser see the dev server's redirects verbatim
322
+ ...(hasBody ? { duplex: 'half' } : {}),
323
+ });
324
+ } catch {
325
+ return new Response(PREVIEW_ENDED_PAGE, {
326
+ status: 502,
327
+ headers: { 'content-type': 'text/html', 'cache-control': 'no-store' },
328
+ });
329
+ }
330
+
331
+ const outHeaders = new Headers();
332
+ for (const [name, value] of upstream.headers) {
333
+ if (!RES_STRIP.has(name.toLowerCase())) outHeaders.append(name, value);
334
+ }
335
+ outHeaders.set('cache-control', 'no-store');
336
+ return new Response(upstream.body, { status: upstream.status, headers: outHeaders });
337
+ }
338
+
339
+ export const PREVIEW_ENDED_PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Preview</title>
340
+ <style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f1117;color:#9aa4b2}div{text-align:center;max-width:340px;line-height:1.6}</style>
341
+ </head><body><div><p>This preview has ended — the dev server that was running it is no longer live.</p></div></body></html>`;
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // WebSocket reverse-proxy (HMR / any ws the dev server exposes)
345
+ // ---------------------------------------------------------------------------
346
+
347
+ /**
348
+ * Bridge an already-accepted browser WebSocket to the dev server's ws at
349
+ * `upstreamUrl`. Pipes messages both ways and mirrors close/error so neither
350
+ * side hangs. Browser→upstream messages that arrive before upstream is open are
351
+ * queued and flushed on connect. `WS` is injectable for tests.
352
+ *
353
+ * Returns the upstream socket (so callers/tests can observe it).
354
+ */
355
+ export function bridgeWebSocket(browserWs, upstreamUrl, { protocols, headers } = {}, WS = WebSocket) {
356
+ const upstream = protocols
357
+ ? new WS(upstreamUrl, protocols, { headers })
358
+ : new WS(upstreamUrl, { headers });
359
+
360
+ const pending = [];
361
+ let upstreamOpen = false;
362
+ const safeClose = (sock, code, reason) => {
363
+ try {
364
+ // Only application/normal close codes are valid to send.
365
+ const c = Number.isInteger(code) && code >= 1000 && code <= 4999 ? code : 1000;
366
+ sock.close(c, reason);
367
+ } catch {
368
+ try { sock.terminate?.(); } catch { /* ignore */ }
369
+ }
370
+ };
371
+
372
+ upstream.on('open', () => {
373
+ upstreamOpen = true;
374
+ for (const [data, isBinary] of pending) upstream.send(data, { binary: isBinary });
375
+ pending.length = 0;
376
+ });
377
+ upstream.on('message', (data, isBinary) => {
378
+ if (browserWs.readyState === browserWs.OPEN) browserWs.send(data, { binary: isBinary });
379
+ });
380
+ upstream.on('close', (code, reason) => safeClose(browserWs, code, reason));
381
+ upstream.on('error', () => safeClose(browserWs, 1011, 'upstream error'));
382
+
383
+ browserWs.on('message', (data, isBinary) => {
384
+ if (upstreamOpen && upstream.readyState === upstream.OPEN) {
385
+ upstream.send(data, { binary: isBinary });
386
+ } else {
387
+ pending.push([data, isBinary]);
388
+ }
389
+ });
390
+ browserWs.on('close', (code, reason) => safeClose(upstream, code, reason));
391
+ browserWs.on('error', () => safeClose(upstream, 1011, 'client error'));
392
+
393
+ return upstream;
394
+ }
395
+
396
+ /**
397
+ * Complete a raw HTTP upgrade for a preview host: accept the browser side via a
398
+ * `noServer` WebSocketServer, then bridge it to `ws://127.0.0.1:<port><path>`.
399
+ * Forwards the requested subprotocol. Caller has already resolved `port` from
400
+ * the LocalPreviewRegistry (so the target is guaranteed loopback + registered).
401
+ */
402
+ export function proxyWsUpgrade({ previewWss, req, socket, head, port, WS = WebSocket }) {
403
+ const p = Number(port);
404
+ if (!Number.isInteger(p) || p < 1 || p > 65535) {
405
+ try { socket.destroy(); } catch { /* ignore */ }
406
+ return;
407
+ }
408
+ const reqUrl = new URL(req.url, `http://127.0.0.1:${p}`);
409
+ const upstreamUrl = `ws://127.0.0.1:${p}${reqUrl.pathname}${reqUrl.search}`;
410
+ const subprotocol = req.headers['sec-websocket-protocol'];
411
+ const protocols = subprotocol
412
+ ? subprotocol.split(',').map((s) => s.trim()).filter(Boolean)
413
+ : undefined;
414
+ previewWss.handleUpgrade(req, socket, head, (browserWs) => {
415
+ bridgeWebSocket(browserWs, upstreamUrl, { protocols }, WS);
416
+ });
417
+ }