@venturewild/workspace 0.6.27 → 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 +1 -1
- package/server/src/agent.mjs +29 -3
- package/server/src/index.mjs +146 -0
- package/server/src/preview-proxy.mjs +417 -0
- package/server/src/preview-rails.mjs +103 -0
- package/web/dist/assets/{index-CUcUGKJC.css → index-BcKqZzsq.css} +1 -1
- package/web/dist/assets/{index-Ccr8fqed.js → index-k6aQ8k4a.js} +26 -26
- package/web/dist/index.html +2 -2
package/package.json
CHANGED
package/server/src/agent.mjs
CHANGED
|
@@ -46,13 +46,32 @@ export function ensureToolPath(env = process.env, { platform = process.platform,
|
|
|
46
46
|
return env.PATH;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// On Windows, `where.exe <binary>` lists the extensionless POSIX npm shim
|
|
50
|
+
// (e.g. `…\AppData\Roaming\npm\claude`) BEFORE claude.cmd. spawn() with
|
|
51
|
+
// shell:false can't run that shim — it's a sh script — so a npm-installed
|
|
52
|
+
// Claude resolved to ENOENT (caught live 2026-06-26). Prefer a real Windows
|
|
53
|
+
// launcher (.cmd > .exe > .bat); fall back to the first hit if none. Exported
|
|
54
|
+
// for unit testing.
|
|
55
|
+
export function pickWindowsBinary(lines) {
|
|
56
|
+
const list = Array.isArray(lines) ? lines : [];
|
|
57
|
+
for (const ext of ['.exe', '.cmd', '.bat']) {
|
|
58
|
+
const re = new RegExp(ext.replace('.', '\\.') + '$', 'i');
|
|
59
|
+
const hit = list.find((l) => re.test(l.trim()));
|
|
60
|
+
if (hit) return hit.trim();
|
|
61
|
+
}
|
|
62
|
+
return list[0]?.trim() || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
async function isOnPath(binary) {
|
|
50
66
|
ensureToolPath(); // make sure the tool dirs are on PATH before we look
|
|
51
67
|
const probe = process.platform === 'win32' ? 'where.exe' : 'which';
|
|
52
68
|
try {
|
|
53
69
|
const { stdout } = await execFile(probe, [binary], { timeout: PATH_LOOKUP_TIMEOUT_MS });
|
|
54
70
|
const lines = stdout.split(/\r?\n/).filter(Boolean);
|
|
55
|
-
if (lines.length > 0)
|
|
71
|
+
if (lines.length > 0) {
|
|
72
|
+
// Skip the extensionless npm shim on Windows (see pickWindowsBinary).
|
|
73
|
+
return process.platform === 'win32' ? pickWindowsBinary(lines) : lines[0].trim();
|
|
74
|
+
}
|
|
56
75
|
} catch { /* fall through to a direct probe */ }
|
|
57
76
|
// which/where can still miss a freshly-installed binary in a stripped launchd
|
|
58
77
|
// environment — probe the known install dirs directly and return an ABSOLUTE
|
|
@@ -180,12 +199,19 @@ export class AgentSession extends EventEmitter {
|
|
|
180
199
|
? buildClaudeArgs(this.agent.args, ctx)
|
|
181
200
|
: [...this.agent.args];
|
|
182
201
|
// Prefer the resolved absolute path from detection; fall back to the bare
|
|
183
|
-
// name.
|
|
202
|
+
// name. On Windows a npm-installed Claude resolves to claude.cmd — which
|
|
203
|
+
// Node refuses to spawn with shell:false (CVE-2024-27980) — so a .cmd/.bat
|
|
204
|
+
// shim (or the extensionless npm shim, which cmd rescues via PATHEXT) must
|
|
205
|
+
// go through cmd.exe. A real .exe (node, native claude.exe) spawns
|
|
206
|
+
// directly with shell:false, so tests + native installs are untouched.
|
|
207
|
+
// POSIX keeps shell:false (the native binary there spawns directly).
|
|
184
208
|
const command = this.agent.resolvedPath || this.agent.binary;
|
|
209
|
+
const isWin = process.platform === 'win32';
|
|
210
|
+
const needsShell = isWin && !/\.exe$/i.test(command);
|
|
185
211
|
this.proc = spawn(command, args, {
|
|
186
212
|
cwd: ctx.cwd || this.opts.cwd || process.cwd(),
|
|
187
213
|
env: { ...process.env, ...(ctx.env || {}) },
|
|
188
|
-
shell:
|
|
214
|
+
shell: needsShell,
|
|
189
215
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
190
216
|
windowsHide: true,
|
|
191
217
|
});
|
package/server/src/index.mjs
CHANGED
|
@@ -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
|
+
}
|