@venturewild/workspace 0.6.28 → 0.6.30

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.30",
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,17 @@ 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
+ pickPreviewSlug,
69
+ PREVIEW_ENDED_PAGE,
70
+ } from './preview-proxy.mjs';
71
+ import { createPreviewRails } from './preview-rails.mjs';
61
72
  import { createBazaar } from './bazaar/core.mjs';
62
73
  import { createCanvas } from './canvas/core.mjs';
63
74
  import { createCanvasRails } from './canvas-rails.mjs';
@@ -434,6 +445,9 @@ export async function createServer(overrides = {}) {
434
445
  // Cross-host shared chat sessions client (Phase 2). Inert without an account /
435
446
  // bmo-sync URL; only used for SHARED workspaces (local ones stay per-host).
436
447
  const sessionRails = overrides.sessionRails || createSessionRails(config, config.account);
448
+ // Shared-preview registry client (ticket tk-5d09bf04-5). Inert until login;
449
+ // the dev-port watcher (below) publishes/heartbeats/retires live previews here.
450
+ const previewRails = overrides.previewRails || createPreviewRails(config);
437
451
  // Shared-workspace membership client (sharing slice — design §4). The account
438
452
  // token is kept top-level on config (out of the broadcast `config.account`), so
439
453
  // pass it explicitly, mirroring the CLI `wild-workspace workspace …`. Inert (and
@@ -1081,6 +1095,38 @@ export async function createServer(overrides = {}) {
1081
1095
  );
1082
1096
  }
1083
1097
 
1098
+ // --- shared-preview reverse-proxy (ticket tk-5d09bf04-5) ------------------
1099
+ // MUST be the FIRST middleware. A `pv-<token>` Host means a viewer is looking
1100
+ // at a dev server running on THIS machine (routed here through the tunnel:
1101
+ // vw-proxy → bmo-sync preview-registry → this account's daemon → :5173). We
1102
+ // reverse-proxy it straight to the local dev port and return — deliberately
1103
+ // BEFORE the auth wall + the security-headers/CSP middleware, because the dev
1104
+ // server is the user's OWN product on its OWN origin: the app's
1105
+ // X-Frame-Options/CSP would break it, and access is anyone-with-link (the
1106
+ // unguessable token IS the capability — decision D3). SSRF-safe: the only
1107
+ // reachable target is a loopback port THIS server itself registered, and the
1108
+ // relay strips any client-forged X-Forwarded-* so the host can't be spoofed
1109
+ // from off-box. Populated by the dev-port watcher (Part B).
1110
+ const localPreviews = overrides.localPreviews || new LocalPreviewRegistry();
1111
+ // Most-recently-viewed workspace id (set by the auth middleware from
1112
+ // X-Workspace-Id) — the shared-preview watcher tags previews with the workspace
1113
+ // the user is actively collaborating in, so teammates can discover them.
1114
+ let lastActiveWorkspaceId = null;
1115
+ app.use('*', async (c, next) => {
1116
+ const token = previewTokenFromHeaders((n) => c.req.header(n));
1117
+ if (!token) return next();
1118
+ const port = localPreviews.portForToken(token);
1119
+ if (!port) {
1120
+ // Unknown / expired token → a graceful "ended" page, never the SPA shell.
1121
+ return c.body(PREVIEW_ENDED_PAGE, 404, {
1122
+ 'content-type': 'text/html',
1123
+ 'cache-control': 'no-store',
1124
+ });
1125
+ }
1126
+ localPreviews.heartbeat(token);
1127
+ return proxyHttpToPort(port, c.req.raw);
1128
+ });
1129
+
1084
1130
  // Security headers on every response (SECURITY.md S7). Set AFTER next() so they
1085
1131
  // land on static-asset + SPA-fallback responses too. Referrer-Policy: no-referrer
1086
1132
  // also backstops S1 — a stale `?t=` in the URL can't leak via the Referer header.
@@ -1139,7 +1185,13 @@ export async function createServer(overrides = {}) {
1139
1185
  c.set('session', session);
1140
1186
  // Resolve the active workspace for this request (lobby M1). Per-tab via the
1141
1187
  // X-Workspace-Id header; absent/unknown → the boot default.
1142
- c.set('workspace', resolveWorkspace(c.req.header('x-workspace-id')));
1188
+ const xwid = c.req.header('x-workspace-id');
1189
+ const activeWs = resolveWorkspace(xwid);
1190
+ c.set('workspace', activeWs);
1191
+ // Track the most-recently-viewed workspace so the shared-preview watcher can
1192
+ // tag previews with the workspace the user is actually collaborating in
1193
+ // (not the boot-default launch folder). (tk-5d09bf04-5.)
1194
+ if (xwid && activeWs?.id) lastActiveWorkspaceId = activeWs.id;
1143
1195
  // Block the API for denied (non-localhost, unauthenticated) requests, but
1144
1196
  // let static assets + the public endpoints through so the SPA can still
1145
1197
  // load and prompt for sign-in. (Concern C1.)
@@ -2909,6 +2961,46 @@ export async function createServer(overrides = {}) {
2909
2961
  return c.json({ port, host, listening: await checkPort(port, host) });
2910
2962
  });
2911
2963
 
2964
+ // The live previews to show in this workspace's Live view (ticket tk-5d09bf04-5,
2965
+ // Part C). Combines the previews THIS server hosts (`mine`) with members'
2966
+ // previews on the rails (membership-gated by the shared slug). Each carries the
2967
+ // public `pv-` URL the iframe loads; `mine` entries also carry the local port so
2968
+ // the owner viewing on THIS machine can load localhost directly (no tunnel hop).
2969
+ // A non-member viewing a raw `pv-` link never calls this — they load the link.
2970
+ app.get('/api/preview/live', async (c) => {
2971
+ const forbidden = require(c, 'preview');
2972
+ if (forbidden) return forbidden;
2973
+ const byToken = new Map();
2974
+ for (const e of localPreviews.list()) {
2975
+ byToken.set(e.token, {
2976
+ token: e.token,
2977
+ url: previewUrlFor(config.shareBaseUrl, e.token),
2978
+ port: e.port, // owner-only convenience (local fast path); harmless to expose to the owner
2979
+ label: e.initiator || config.account?.email || 'You',
2980
+ mine: true,
2981
+ startedAt: e.startedAt,
2982
+ });
2983
+ }
2984
+ const slug = sharedSlugFor(c);
2985
+ if (slug && previewRails.capable) {
2986
+ const r = await previewRails.listWorkspace(slug);
2987
+ if (r.ok) {
2988
+ for (const p of r.previews) {
2989
+ if (byToken.has(p.token)) continue; // a local (mine) entry always wins
2990
+ byToken.set(p.token, {
2991
+ token: p.token,
2992
+ url: p.url || previewUrlFor(config.shareBaseUrl, p.token),
2993
+ label: p.label || p.owner_email || 'A teammate',
2994
+ mine: false,
2995
+ startedAt: p.created_at ? p.created_at * 1000 : Date.now(),
2996
+ });
2997
+ }
2998
+ }
2999
+ }
3000
+ const previews = [...byToken.values()].sort((a, b) => b.startedAt - a.startedAt);
3001
+ return c.json({ previews, shared: Boolean(slug) });
3002
+ });
3003
+
2912
3004
  // --- bazaar (producer/remix marketplace — §3.7) ---------------------------
2913
3005
  // The shelf + ledger state for the UI. Live updates ride the chat chunk stream
2914
3006
  // (agent tool results) and the /preview/match broadcast below — never a watcher.
@@ -3615,12 +3707,82 @@ export async function createServer(overrides = {}) {
3615
3707
  } catch { /* best-effort — restart-server falls back to a health probe */ }
3616
3708
  }
3617
3709
 
3710
+ // --- shared-preview dev-port watcher (ticket tk-5d09bf04-5, Part B) --------
3711
+ // Proactively detects the user's local dev servers and keeps each one published
3712
+ // as a live `pv-` preview (so a collaborator's Live view shows it even when the
3713
+ // owner isn't looking). Only runs when logged in (a `pv-` link needs the
3714
+ // tunnel); degrade-never below it. Tagged with the boot/default workspace's
3715
+ // shared slug for member discovery (the picker covers the multi-server edge).
3716
+ // Skipped under the test runner; tests drive reconcilePreviews directly.
3717
+ let previewTimer = null;
3718
+ // Tag previews with the workspace the user is actively collaborating in (active
3719
+ // → sole shared workspace → boot-default), so a teammate's Live view discovers
3720
+ // them. Tagging only with the boot-default (a local launch folder) meant
3721
+ // cross-member discovery never fired. (tk-5d09bf04-5.)
3722
+ function previewSlug() {
3723
+ try {
3724
+ return pickPreviewSlug({
3725
+ activeId: lastActiveWorkspaceId,
3726
+ bootDefaultId: config.workspaceId,
3727
+ resolve: (id) => getWorkspace(id, registryEnv),
3728
+ all: () => listWorkspaces(registryEnv),
3729
+ });
3730
+ } catch {
3731
+ return null;
3732
+ }
3733
+ }
3734
+ async function previewWatcherTick() {
3735
+ if (!previewRails.capable) {
3736
+ localPreviews.sweep();
3737
+ return;
3738
+ }
3739
+ await reconcilePreviews({
3740
+ detect: () => detectPreviewPorts(),
3741
+ registry: localPreviews,
3742
+ rails: previewRails,
3743
+ slug: previewSlug(),
3744
+ initiator: config.account?.email || config.account?.slug || null,
3745
+ selfPort: config.port,
3746
+ });
3747
+ }
3748
+ if (
3749
+ process.env.VITEST !== 'true' &&
3750
+ config.nodeEnv !== 'test' &&
3751
+ !overrides.previewWatcherDisabled
3752
+ ) {
3753
+ previewWatcherTick().catch((e) => log('[preview]', `watcher tick failed: ${e?.message || e}`));
3754
+ previewTimer = setInterval(() => {
3755
+ previewWatcherTick().catch((e) => log('[preview]', `watcher tick failed: ${e?.message || e}`));
3756
+ }, 20_000);
3757
+ if (previewTimer.unref) previewTimer.unref();
3758
+ }
3759
+
3618
3760
  // --- websocket bridge ---
3619
3761
  // The power-user terminal (T1) loads node-pty lazily + optionally — same loader as
3620
3762
  // in-app sign-in. Overridable so tests can inject a deterministic fake PTY.
3621
3763
  const ptyLoader = overrides.ptyLoader || defaultPtyLoader;
3622
3764
  const wss = new WebSocketServer({ noServer: true });
3765
+ // Dedicated upgrader for shared-preview HMR websockets (ticket tk-5d09bf04-5).
3766
+ // Kept separate from `wss` so its connections never reach the chat/activity/pty
3767
+ // 'connection' wiring — a preview ws is bridged straight to the dev server.
3768
+ const previewWss = new WebSocketServer({ noServer: true });
3623
3769
  httpServer.on('upgrade', async (req, socket, head) => {
3770
+ // Shared-preview WS first: a `pv-<token>` Host upgrading ANY path is the dev
3771
+ // server's HMR (or any ws it exposes). Bridge it to the local dev port,
3772
+ // bypassing the app's WS auth (the dev server is the user's own product, and
3773
+ // the token is the capability). Same SSRF guard as the HTTP path: only a
3774
+ // port THIS server registered is reachable.
3775
+ const previewToken = previewTokenFromHeaders((n) => req.headers[n]);
3776
+ if (previewToken) {
3777
+ const port = localPreviews.portForToken(previewToken);
3778
+ if (!port) {
3779
+ socket.destroy();
3780
+ return;
3781
+ }
3782
+ localPreviews.heartbeat(previewToken);
3783
+ proxyWsUpgrade({ previewWss, req, socket, head, port });
3784
+ return;
3785
+ }
3624
3786
  const reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
3625
3787
  const supported = ['/ws/chat', '/ws/activity', '/ws/pty'];
3626
3788
  if (!supported.includes(reqUrl.pathname)) {
@@ -3865,6 +4027,8 @@ export async function createServer(overrides = {}) {
3865
4027
  app,
3866
4028
  httpServer,
3867
4029
  wss,
4030
+ localPreviews,
4031
+ previewRails,
3868
4032
  activityBus,
3869
4033
  inboxWatcher,
3870
4034
  tokenRegistry,
@@ -3879,6 +4043,7 @@ export async function createServer(overrides = {}) {
3879
4043
  async stop() {
3880
4044
  try { clearTimeout(autoWakeTimer); } catch {}
3881
4045
  try { if (bazaarSyncTimer) clearInterval(bazaarSyncTimer); } catch {}
4046
+ try { if (previewTimer) clearInterval(previewTimer); } catch {}
3882
4047
  for (const t of currentTurns.values()) {
3883
4048
  try { t.session.close(); } catch {}
3884
4049
  }
@@ -0,0 +1,443 @@
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
+ // Choose which shared-workspace slug to TAG a published preview with, so
208
+ // collaborators can discover it (the membership-gated `/api/preview/list` filters
209
+ // by this tag). A raw dev port can't be perfectly attributed to a workspace, so:
210
+ // 1. the workspace the user is actively in (last X-Workspace-Id) — strongest;
211
+ // 2. else the SOLE shared workspace, if the install has exactly one — the
212
+ // unambiguous common case (a small team in one shared workspace);
213
+ // 3. else the boot-default workspace (original behaviour; usually local → null).
214
+ // `resolve(id)` → a registry entry (with `kind`/`shared`) or null; `all()` → all
215
+ // entries. Pure (deps injected) so it's unit-testable without a live registry.
216
+ //
217
+ // WHY this exists: tagging with the boot-default alone (the install's launch
218
+ // folder, almost always a LOCAL workspace) meant cross-member discovery never
219
+ // fired — a teammate's preview never appeared in your picker. (tk-5d09bf04-5.)
220
+ export function pickPreviewSlug({ activeId, bootDefaultId, resolve, all }) {
221
+ const sharedSlugOf = (id) => {
222
+ if (!id) return null;
223
+ const e = resolve(id);
224
+ return e && e.kind === 'shared' && e.shared && e.shared.slug ? e.shared.slug : null;
225
+ };
226
+ const active = sharedSlugOf(activeId);
227
+ if (active) return active;
228
+ const shared = (all() || []).filter((w) => w && w.kind === 'shared' && w.shared && w.shared.slug);
229
+ if (shared.length === 1) return shared[0].shared.slug;
230
+ return sharedSlugOf(bootDefaultId);
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Dev-port reconcile (Part B) — one detection pass, kept pure for unit tests.
235
+ // ---------------------------------------------------------------------------
236
+
237
+ /**
238
+ * Reconcile the LocalPreviewRegistry against the dev ports currently listening:
239
+ * - a newly-seen port → register (mint token) + rails.publish
240
+ * - a port that vanished → remove locally + rails.retire
241
+ * - a surviving port → heartbeat locally + on the rails (keeps it live)
242
+ * - a TTL-expired straggler (crash/sleep) → swept + rails.retire
243
+ *
244
+ * `rails` is a degrade-never client (its calls no-op when not logged in / the
245
+ * endpoint is absent), so this is safe to run before bmo-sync ships Part D.
246
+ * Injected `detect` is `detectPreviewPorts`. `selfPort` excludes the
247
+ * wild-workspace server's own port from ever being treated as a preview.
248
+ */
249
+ export async function reconcilePreviews({
250
+ detect,
251
+ registry,
252
+ rails,
253
+ slug = null,
254
+ initiator = null,
255
+ selfPort = null,
256
+ }) {
257
+ const found = (await detect()) || [];
258
+ const livePorts = new Set(found.map((d) => d.port).filter((p) => p !== selfPort));
259
+
260
+ for (const port of livePorts) {
261
+ const existing = registry.getByPort(port);
262
+ if (existing) {
263
+ registry.heartbeat(existing.token);
264
+ await rails.heartbeat(existing.token);
265
+ } else {
266
+ const entry = registry.register({ port, workspaceId: slug, initiator, label: initiator });
267
+ await rails.publish({ token: entry.token, slug, label: initiator });
268
+ }
269
+ }
270
+ for (const entry of registry.list()) {
271
+ if (!livePorts.has(entry.port)) {
272
+ registry.remove(entry.token);
273
+ await rails.retire(entry.token);
274
+ }
275
+ }
276
+ for (const dead of registry.sweep()) {
277
+ await rails.retire(dead.token);
278
+ }
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // HTTP reverse-proxy
283
+ // ---------------------------------------------------------------------------
284
+
285
+ // Headers we never forward to / from the local dev server.
286
+ const REQ_STRIP = new Set([
287
+ 'host', // we set our own (127.0.0.1:port) so a Host-checking dev server (Vite
288
+ // allowedHosts / DNS-rebind guard) accepts the request
289
+ 'connection',
290
+ 'keep-alive',
291
+ 'proxy-authorization',
292
+ 'proxy-authenticate',
293
+ 'te',
294
+ 'trailer',
295
+ 'transfer-encoding',
296
+ 'upgrade',
297
+ // The relay's forwarding headers describe the public hop, not this local one.
298
+ 'x-forwarded-host',
299
+ 'x-forwarded-proto',
300
+ ]);
301
+ // NOTE on compression: we let undici (global fetch) own content negotiation — it
302
+ // sets its own `accept-encoding` and transparently DECODES the response body, so
303
+ // the body stream we forward is already plaintext. We therefore strip the now-
304
+ // stale `content-encoding`/`content-length` from the response (see RES_STRIP)
305
+ // rather than trying to suppress compression on the request.
306
+
307
+ const RES_STRIP = new Set([
308
+ 'connection',
309
+ 'keep-alive',
310
+ 'transfer-encoding',
311
+ 'content-encoding', // body is already decoded by undici fetch
312
+ 'content-length', // length changed with the encoding
313
+ 'x-frame-options', // must NOT block the Live-view iframe framing this origin
314
+ ]);
315
+
316
+ /**
317
+ * Reverse-proxy one web `Request` to `http://127.0.0.1:<port>` and return the
318
+ * web `Response` (suitable for returning straight from a Hono handler). The
319
+ * dev server is the user's own trusted code; we transport it faithfully but
320
+ * strip framing/encoding headers that would break the iframe or the body.
321
+ *
322
+ * On upstream failure (dev server stopped) returns a small 502 page rather than
323
+ * throwing, so the iframe shows a graceful "ended" state.
324
+ */
325
+ export async function proxyHttpToPort(port, request, { fetchImpl = (...a) => globalThis.fetch(...a) } = {}) {
326
+ const p = Number(port);
327
+ if (!Number.isInteger(p) || p < 1 || p > 65535) {
328
+ return new Response('bad preview port', { status: 502 });
329
+ }
330
+ const inUrl = new URL(request.url);
331
+ const target = `http://127.0.0.1:${p}${inUrl.pathname}${inUrl.search}`;
332
+
333
+ const headers = new Headers();
334
+ for (const [name, value] of request.headers) {
335
+ if (!REQ_STRIP.has(name.toLowerCase())) headers.set(name, value);
336
+ }
337
+ headers.set('host', `127.0.0.1:${p}`);
338
+
339
+ const method = request.method.toUpperCase();
340
+ const hasBody = method !== 'GET' && method !== 'HEAD';
341
+ let upstream;
342
+ try {
343
+ upstream = await fetchImpl(target, {
344
+ method,
345
+ headers,
346
+ body: hasBody ? request.body : undefined,
347
+ redirect: 'manual', // let the browser see the dev server's redirects verbatim
348
+ ...(hasBody ? { duplex: 'half' } : {}),
349
+ });
350
+ } catch {
351
+ return new Response(PREVIEW_ENDED_PAGE, {
352
+ status: 502,
353
+ headers: { 'content-type': 'text/html', 'cache-control': 'no-store' },
354
+ });
355
+ }
356
+
357
+ const outHeaders = new Headers();
358
+ for (const [name, value] of upstream.headers) {
359
+ if (!RES_STRIP.has(name.toLowerCase())) outHeaders.append(name, value);
360
+ }
361
+ outHeaders.set('cache-control', 'no-store');
362
+ return new Response(upstream.body, { status: upstream.status, headers: outHeaders });
363
+ }
364
+
365
+ export const PREVIEW_ENDED_PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Preview</title>
366
+ <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>
367
+ </head><body><div><p>This preview has ended — the dev server that was running it is no longer live.</p></div></body></html>`;
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // WebSocket reverse-proxy (HMR / any ws the dev server exposes)
371
+ // ---------------------------------------------------------------------------
372
+
373
+ /**
374
+ * Bridge an already-accepted browser WebSocket to the dev server's ws at
375
+ * `upstreamUrl`. Pipes messages both ways and mirrors close/error so neither
376
+ * side hangs. Browser→upstream messages that arrive before upstream is open are
377
+ * queued and flushed on connect. `WS` is injectable for tests.
378
+ *
379
+ * Returns the upstream socket (so callers/tests can observe it).
380
+ */
381
+ export function bridgeWebSocket(browserWs, upstreamUrl, { protocols, headers } = {}, WS = WebSocket) {
382
+ const upstream = protocols
383
+ ? new WS(upstreamUrl, protocols, { headers })
384
+ : new WS(upstreamUrl, { headers });
385
+
386
+ const pending = [];
387
+ let upstreamOpen = false;
388
+ const safeClose = (sock, code, reason) => {
389
+ try {
390
+ // Only application/normal close codes are valid to send.
391
+ const c = Number.isInteger(code) && code >= 1000 && code <= 4999 ? code : 1000;
392
+ sock.close(c, reason);
393
+ } catch {
394
+ try { sock.terminate?.(); } catch { /* ignore */ }
395
+ }
396
+ };
397
+
398
+ upstream.on('open', () => {
399
+ upstreamOpen = true;
400
+ for (const [data, isBinary] of pending) upstream.send(data, { binary: isBinary });
401
+ pending.length = 0;
402
+ });
403
+ upstream.on('message', (data, isBinary) => {
404
+ if (browserWs.readyState === browserWs.OPEN) browserWs.send(data, { binary: isBinary });
405
+ });
406
+ upstream.on('close', (code, reason) => safeClose(browserWs, code, reason));
407
+ upstream.on('error', () => safeClose(browserWs, 1011, 'upstream error'));
408
+
409
+ browserWs.on('message', (data, isBinary) => {
410
+ if (upstreamOpen && upstream.readyState === upstream.OPEN) {
411
+ upstream.send(data, { binary: isBinary });
412
+ } else {
413
+ pending.push([data, isBinary]);
414
+ }
415
+ });
416
+ browserWs.on('close', (code, reason) => safeClose(upstream, code, reason));
417
+ browserWs.on('error', () => safeClose(upstream, 1011, 'client error'));
418
+
419
+ return upstream;
420
+ }
421
+
422
+ /**
423
+ * Complete a raw HTTP upgrade for a preview host: accept the browser side via a
424
+ * `noServer` WebSocketServer, then bridge it to `ws://127.0.0.1:<port><path>`.
425
+ * Forwards the requested subprotocol. Caller has already resolved `port` from
426
+ * the LocalPreviewRegistry (so the target is guaranteed loopback + registered).
427
+ */
428
+ export function proxyWsUpgrade({ previewWss, req, socket, head, port, WS = WebSocket }) {
429
+ const p = Number(port);
430
+ if (!Number.isInteger(p) || p < 1 || p > 65535) {
431
+ try { socket.destroy(); } catch { /* ignore */ }
432
+ return;
433
+ }
434
+ const reqUrl = new URL(req.url, `http://127.0.0.1:${p}`);
435
+ const upstreamUrl = `ws://127.0.0.1:${p}${reqUrl.pathname}${reqUrl.search}`;
436
+ const subprotocol = req.headers['sec-websocket-protocol'];
437
+ const protocols = subprotocol
438
+ ? subprotocol.split(',').map((s) => s.trim()).filter(Boolean)
439
+ : undefined;
440
+ previewWss.handleUpgrade(req, socket, head, (browserWs) => {
441
+ bridgeWebSocket(browserWs, upstreamUrl, { protocols }, WS);
442
+ });
443
+ }