@jami-studio/core 0.92.11 → 0.92.13

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.
@@ -1,1557 +1,1816 @@
1
- #!/usr/bin/env tsx
2
- import { execFileSync, spawn, type ChildProcess } from "node:child_process";
3
- import fs from "node:fs";
4
- import http from "node:http";
5
- import net from "node:net";
6
- import path from "node:path";
7
- import type { Duplex } from "node:stream";
8
- import { fileURLToPath } from "node:url";
9
-
10
- import * as Sentry from "@sentry/node";
11
-
12
- import { extractOAuthStateAppId } from "../shared/oauth-state.js";
13
- import {
14
- DEFAULT_WORKSPACE_APP_AUDIENCE,
15
- workspaceAppAudienceFromPackageJson,
16
- workspaceAppRouteAccessFromPackageJson,
17
- type WorkspaceAppAudience,
18
- } from "../shared/workspace-app-audience.js";
19
- import {
20
- attachGatewaySocketErrorSink,
21
- normalizeOrigin,
22
- rewriteRedirectLocation,
23
- escapeHtml,
24
- } from "./gateway-helpers.js";
25
-
26
- export interface WorkspaceApp {
27
- id: string;
28
- name: string;
29
- description: string;
30
- audience: WorkspaceAppAudience;
31
- publicPaths: string[];
32
- protectedPaths: string[];
33
- dir: string;
34
- port: number;
35
- process?: ChildProcess;
36
- restartTimer?: NodeJS.Timeout;
37
- restartAttempts?: number;
38
- lastFailure?: {
39
- code: number | null;
40
- signal: NodeJS.Signals | null;
41
- at: number;
42
- installing: boolean;
43
- output: string;
44
- nextRetryAt: number;
45
- };
46
- outputTail?: string;
47
- installing?: boolean;
48
- installAttempted?: boolean;
49
- /**
50
- * Set true once we've successfully connected to the upstream. After that we
51
- * skip the readiness probe on every request; the child server stays
52
- * listening for the rest of the dev session.
53
- */
54
- ready?: boolean;
55
- readinessProbe?: Promise<void>;
56
- }
57
-
58
- export interface WorkspaceDevOptions {
59
- args?: string[];
60
- env?: NodeJS.ProcessEnv;
61
- root?: string;
62
- spawnProcess?: typeof spawn;
63
- openBrowser?: boolean;
64
- stdout?: Pick<NodeJS.WriteStream, "write">;
65
- stderr?: Pick<NodeJS.WriteStream, "write">;
66
- }
67
-
68
- export interface WorkspaceDevHandle {
69
- apps: WorkspaceApp[];
70
- defaultApp: string;
71
- gatewayUrl: () => string;
72
- ready: Promise<{ port: number; url: string }>;
73
- server: http.Server;
74
- shutdown: () => void;
75
- }
76
-
77
- const DEFAULT_GATEWAY_HOST = "127.0.0.1";
78
- const DEFAULT_GATEWAY_PORT = 8080;
79
- const DEFAULT_APP_PORT_START = 8100;
80
- const PROXY_READY_RETRY_DELAY_MS = 250;
81
- const APP_RESTART_MAX_DELAY_MS = 10_000;
82
- const DEFAULT_PROXY_RESPONSE_TIMEOUT_MS = 5_000;
83
- const DEFAULT_PROXY_NON_HTML_RESPONSE_TIMEOUT_MS = 120_000;
84
- const APP_OUTPUT_TAIL_BYTES = 8_000;
85
- const POLLING_WATCH_INTERVAL_MS = "1000";
86
- const reportedDiscoverAppsReadFailures = new Set<string>();
87
- const STARTING_APP_RESPONSE_HEADERS: http.OutgoingHttpHeaders = {
88
- "content-type": "text/html; charset=utf-8",
89
- "cache-control": "no-store, no-cache, max-age=0, must-revalidate",
90
- pragma: "no-cache",
91
- expires: "0",
92
- };
93
-
94
- function workspaceOAuthOrigin(
95
- env: NodeJS.ProcessEnv,
96
- gatewayUrl: string,
97
- ): string | undefined {
98
- return (
99
- normalizeOrigin(env.VITE_WORKSPACE_OAUTH_ORIGIN) ||
100
- normalizeOrigin(env.WORKSPACE_OAUTH_ORIGIN) ||
101
- normalizeOrigin(env.APP_URL) ||
102
- normalizeOrigin(env.BETTER_AUTH_URL) ||
103
- normalizeOrigin(gatewayUrl)
104
- );
105
- }
106
-
107
- export function isWorkspaceWatcherLimitError(
108
- err: Pick<NodeJS.ErrnoException, "code">,
109
- ): boolean {
110
- return err.code === "ENOSPC" || err.code === "EMFILE";
111
- }
112
-
113
- export function shouldEagerStartWorkspaceApps(
114
- args: string[] = [],
115
- env: NodeJS.ProcessEnv = process.env,
116
- ): boolean {
117
- return (
118
- args.includes("--eager") ||
119
- env.WORKSPACE_EAGER === "1" ||
120
- env.WORKSPACE_EAGER === "true"
121
- );
122
- }
123
-
124
- /**
125
- * Whether the gateway should spawn the rest of the apps in the background
126
- * after the default app boots. Lazy spawn already covers correctness — this
127
- * is purely a UX optimization so the second/third/Nth app the user clicks
128
- * into is already warm instead of paying the Vite + esbuild prebundle cost
129
- * on demand.
130
- *
131
- * Defaults to ON in lazy mode. Off when the user passed --no-prewarm /
132
- * WORKSPACE_NO_PREWARM=1, or when eager mode is already starting every app
133
- * up front (in which case prewarm would be redundant).
134
- */
135
- export function shouldPrewarmWorkspaceApps(
136
- args: string[] = [],
137
- env: NodeJS.ProcessEnv = process.env,
138
- ): boolean {
139
- if (args.includes("--no-prewarm")) return false;
140
- if (env.WORKSPACE_NO_PREWARM === "1" || env.WORKSPACE_NO_PREWARM === "true") {
141
- return false;
142
- }
143
- // Eager mode starts every app immediately; prewarm has nothing to do.
144
- if (shouldEagerStartWorkspaceApps(args, env)) return false;
145
- return true;
146
- }
147
-
148
- /**
149
- * How many apps to prewarm in parallel. Each Vite spawn briefly maxes out a
150
- * CPU core during esbuild prebundling, so booting all 9 templates at once on
151
- * a 4-core laptop just produces a thundering herd. Default 2 — gentle on
152
- * laptops, fast enough that all apps finish within a few cold-spawn windows.
153
- * Override via --prewarm-concurrency=N or WORKSPACE_PREWARM_CONCURRENCY=N.
154
- */
155
- export function workspacePrewarmConcurrency(
156
- args: string[] = [],
157
- env: NodeJS.ProcessEnv = process.env,
158
- ): number {
159
- const flag = args.find((arg) => arg.startsWith("--prewarm-concurrency="));
160
- const raw = flag
161
- ? flag.slice("--prewarm-concurrency=".length)
162
- : env.WORKSPACE_PREWARM_CONCURRENCY;
163
- if (!raw) return 2;
164
- const parsed = Number(raw);
165
- if (!Number.isFinite(parsed) || parsed < 1) return 2;
166
- return Math.floor(parsed);
167
- }
168
-
169
- /**
170
- * How long the prewarm queue waits after the gateway is ready before kicking
171
- * off background spawns. Lets the default app's prebundle get first dibs on
172
- * CPU. Override via WORKSPACE_PREWARM_DELAY_MS (mostly for tests).
173
- */
174
- export function workspacePrewarmDelayMs(
175
- env: NodeJS.ProcessEnv = process.env,
176
- ): number {
177
- const raw = env.WORKSPACE_PREWARM_DELAY_MS;
178
- if (raw === undefined) return 1_000;
179
- const parsed = Number(raw);
180
- if (!Number.isFinite(parsed) || parsed < 0) return 1_000;
181
- return Math.floor(parsed);
182
- }
183
-
184
- function readBooleanEnv(value: string | undefined): boolean | undefined {
185
- if (value === undefined) return undefined;
186
- const normalized = value.trim().toLowerCase();
187
- if (["1", "true", "yes", "on"].includes(normalized)) return true;
188
- if (["0", "false", "no", "off"].includes(normalized)) return false;
189
- return undefined;
190
- }
191
-
192
- export type PollingFileWatcherMode =
193
- | "enable"
194
- | "disable-explicit"
195
- | "disable-default";
196
-
197
- /**
198
- * Three-way classification of the polling-watcher decision so callers can
199
- * tell apart "the user explicitly turned this off" (where we want to override
200
- * any inherited chokidar/TSC env vars from the parent shell) from "we just
201
- * didn't auto-detect a Builder/Codespaces/Gitpod container" (where the user's
202
- * own watcher vars should pass through untouched).
203
- */
204
- export function pollingFileWatcherMode(
205
- env: NodeJS.ProcessEnv = process.env,
206
- root = process.cwd(),
207
- ): PollingFileWatcherMode {
208
- const explicit =
209
- readBooleanEnv(env.AGENT_NATIVE_DEV_USE_POLLING) ??
210
- readBooleanEnv(env.WORKSPACE_USE_POLLING_WATCHER);
211
- if (explicit === true) return "enable";
212
- if (explicit === false) return "disable-explicit";
213
-
214
- const chokidarExplicit = readBooleanEnv(env.CHOKIDAR_USEPOLLING);
215
- if (chokidarExplicit === true) return "enable";
216
- if (chokidarExplicit === false) return "disable-explicit";
217
-
218
- const autoEnable = Boolean(
219
- env.BUILDER_IO_DEV_SERVER ||
220
- env.BUILDER_PROJECT_ID ||
221
- env.BUILDER_WORKSPACE_ID ||
222
- env.CODESPACES ||
223
- env.GITPOD_WORKSPACE_ID ||
224
- env.REMOTE_CONTAINERS ||
225
- env.DEVCONTAINER ||
226
- root.startsWith("/root/app/"),
227
- );
228
- return autoEnable ? "enable" : "disable-default";
229
- }
230
-
231
- export function shouldUsePollingFileWatcher(
232
- env: NodeJS.ProcessEnv = process.env,
233
- root = process.cwd(),
234
- ): boolean {
235
- return pollingFileWatcherMode(env, root) === "enable";
236
- }
237
-
238
- /**
239
- * Default child spawner. On Windows, Node >= 20.12 refuses to spawn the
240
- * `pnpm` .cmd/.ps1 shims without a shell — the spawn fails ENOENT silently
241
- * (no `error` listener existed), the app port never binds, and only the
242
- * readiness timeout surfaced. When the gateway itself was launched by pnpm,
243
- * `npm_execpath` points at pnpm's JS entry, so run that through the current
244
- * Node executable instead. Everything else spawns as-is.
245
- */
246
- const windowsSafePnpmSpawn: typeof spawn = ((
247
- command: string,
248
- args: readonly string[],
249
- spawnOptions: object,
250
- ) => {
251
- if (
252
- process.platform === "win32" &&
253
- command === "pnpm" &&
254
- Array.isArray(args)
255
- ) {
256
- const execPath = process.env.npm_execpath;
257
- if (execPath && /\.[cm]?js$/i.test(execPath)) {
258
- return spawn(process.execPath, [execPath, ...args], spawnOptions);
259
- }
260
- }
261
- return spawn(command, args as string[], spawnOptions);
262
- }) as typeof spawn;
263
-
264
- /**
265
- * Kill an app child and its WHOLE process tree. `child.kill("SIGTERM")`
266
- * reaches only the direct pnpm child on Windows; the vite grandchild keeps
267
- * the app port bound, so the gateway respawned forever into
268
- * "Port 810x is already in use" (blank apps, dropped sessions). taskkill /T
269
- * fells the tree; POSIX keeps the plain signal.
270
- */
271
- function killAppProcessTree(child: ChildProcess | undefined): void {
272
- if (!child) return;
273
- if (process.platform === "win32" && child.pid) {
274
- try {
275
- execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
276
- stdio: "ignore",
277
- });
278
- return;
279
- } catch {
280
- // taskkill unavailable or already exited — fall through to the signal.
281
- }
282
- }
283
- child.kill("SIGTERM");
284
- }
285
-
286
- function devWatcherEnv(
287
- env: NodeJS.ProcessEnv,
288
- mode: PollingFileWatcherMode,
289
- ): NodeJS.ProcessEnv {
290
- if (mode === "enable") {
291
- return {
292
- ...env,
293
- CHOKIDAR_USEPOLLING: "1",
294
- CHOKIDAR_INTERVAL: env.CHOKIDAR_INTERVAL ?? POLLING_WATCH_INTERVAL_MS,
295
- TSC_WATCHFILE: env.TSC_WATCHFILE ?? "DynamicPriorityPolling",
296
- TSC_WATCHDIRECTORY: env.TSC_WATCHDIRECTORY ?? "DynamicPriorityPolling",
297
- };
298
- }
299
- if (mode === "disable-explicit") {
300
- // The user explicitly turned polling off (AGENT_NATIVE_DEV_USE_POLLING=0
301
- // / WORKSPACE_USE_POLLING_WATCHER=0 / CHOKIDAR_USEPOLLING=0). Strip the
302
- // watcher vars from the child env so an inherited parent-shell
303
- // CHOKIDAR_USEPOLLING=1 (or stale TSC_WATCH* override) can't silently
304
- // re-enable polling against the user's explicit wish.
305
- const {
306
- CHOKIDAR_USEPOLLING: _polling,
307
- CHOKIDAR_INTERVAL: _interval,
308
- TSC_WATCHFILE: _watchFile,
309
- TSC_WATCHDIRECTORY: _watchDir,
310
- ...rest
311
- } = env;
312
- return rest;
313
- }
314
- // mode === "disable-default": no explicit signal either way. Pass the env
315
- // through unchanged so legitimate user overrides like
316
- // TSC_WATCHFILE=UseFsEventsWithFallbackDynamicPolling survive.
317
- return env;
318
- }
319
-
320
- export function initialWorkspaceAppIds(
321
- apps: Array<Pick<WorkspaceApp, "id">>,
322
- defaultApp: string,
323
- eager: boolean,
324
- startDefault = true,
325
- ): string[] {
326
- if (eager) return apps.map((app) => app.id);
327
- if (!startDefault) return [];
328
- return apps.some((app) => app.id === defaultApp) ? [defaultApp] : [];
329
- }
330
-
331
- function readJson(file: string): any {
332
- try {
333
- return JSON.parse(fs.readFileSync(file, "utf8"));
334
- } catch {
335
- return null;
336
- }
337
- }
338
-
339
- function shouldReportDiscoverAppsReadFailure(
340
- appsDir: string,
341
- code: string | undefined,
342
- ): boolean {
343
- if (code === "ENOENT") return false;
344
- const key = `${appsDir}:${code ?? "unknown"}`;
345
- if (reportedDiscoverAppsReadFailures.has(key)) return false;
346
- reportedDiscoverAppsReadFailures.add(key);
347
- return true;
348
- }
349
-
350
- function shouldCaptureDiscoverAppsReadFailure(
351
- code: string | undefined,
352
- ): boolean {
353
- return code !== "EACCES" && code !== "EPERM";
354
- }
355
-
356
- function discoverApps(appsDir: string, appPortStart: number): WorkspaceApp[] {
357
- if (!fs.existsSync(appsDir)) return [];
358
- // existsSync -> readdirSync is a TOCTOU race. Treat ENOENT as "no apps
359
- // right now" and let the polling sync recover.
360
- let entries: fs.Dirent[];
361
- try {
362
- entries = fs.readdirSync(appsDir, { withFileTypes: true });
363
- } catch (err) {
364
- const code = (err as NodeJS.ErrnoException).code;
365
- if (shouldReportDiscoverAppsReadFailure(appsDir, code)) {
366
- console.warn(
367
- `[workspace] Could not read ${appsDir} (${code ?? "unknown"}): ` +
368
- `${(err as Error).message}`,
369
- );
370
- if (shouldCaptureDiscoverAppsReadFailure(code)) {
371
- Sentry.captureException(err, {
372
- tags: { handled: "dev-discover-readdir" },
373
- level: "warning",
374
- });
375
- }
376
- }
377
- return [];
378
- }
379
- return entries
380
- .filter((entry) => entry.isDirectory())
381
- .map((entry) => {
382
- const dir = path.join(appsDir, entry.name);
383
- const pkg = readJson(path.join(dir, "package.json"));
384
- if (!pkg) return null;
385
- const routeAccess = workspaceAppRouteAccessFromPackageJson(pkg);
386
- return {
387
- id: entry.name,
388
- name: pkg.displayName || pkg.name || entry.name,
389
- description: typeof pkg.description === "string" ? pkg.description : "",
390
- audience:
391
- workspaceAppAudienceFromPackageJson(pkg) ??
392
- DEFAULT_WORKSPACE_APP_AUDIENCE,
393
- publicPaths: routeAccess.publicPaths ?? [],
394
- protectedPaths: routeAccess.protectedPaths ?? [],
395
- dir,
396
- port: appPortStart,
397
- } satisfies WorkspaceApp;
398
- })
399
- .filter((app): app is WorkspaceApp => !!app)
400
- .sort(compareApps)
401
- .map((app, index) => ({ ...app, port: appPortStart + index }));
402
- }
403
-
404
- function compareApps(a: Pick<WorkspaceApp, "id">, b: Pick<WorkspaceApp, "id">) {
405
- if (a.id === "dispatch") return -1;
406
- if (b.id === "dispatch") return 1;
407
- return a.id.localeCompare(b.id);
408
- }
409
-
410
- function isChildDevServerUrlLine(line: string): boolean {
411
- return /^\s*->\s+(?:Local|Network):\s+https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):\d+(?:\/\S*)?\s*$/i.test(
412
- line.replace(/\u279c/g, "->"),
413
- );
414
- }
415
-
416
- function formatAppOutput(chunk: unknown): string {
417
- const lines = String(chunk)
418
- .split(/\r?\n/)
419
- .filter(Boolean)
420
- .filter((line) => !isChildDevServerUrlLine(line));
421
- return lines.length === 0 ? "" : lines.join("\n") + "\n";
422
- }
423
-
424
- function appendAppOutputTail(app: WorkspaceApp, output: string): void {
425
- if (!output) return;
426
- const next = `${app.outputTail ?? ""}${output}`;
427
- app.outputTail =
428
- next.length > APP_OUTPUT_TAIL_BYTES
429
- ? next.slice(-APP_OUTPUT_TAIL_BYTES)
430
- : next;
431
- }
432
-
433
- function pipeAppOutput(
434
- prefix: string,
435
- chunk: unknown,
436
- write: (value: string) => void,
437
- ): string {
438
- const output = formatAppOutput(chunk);
439
- if (!output) return "";
440
- const prefixed = output
441
- .trimEnd()
442
- .split(/\n/)
443
- .map((line) => `${prefix} ${line}`)
444
- .join("\n");
445
- write(`${prefixed}\n`);
446
- return output;
447
- }
448
-
449
- function firstPathSegment(url: string | undefined): string | null {
450
- if (!url) return null;
451
- try {
452
- const parsed = new URL(url, "http://workspace.local");
453
- const [segment] = parsed.pathname.split("/").filter(Boolean);
454
- return segment || null;
455
- } catch {
456
- return null;
457
- }
458
- }
459
-
460
- function appRestartDelay(attempts: number): number {
461
- return Math.min(
462
- 1_000 * 2 ** Math.max(0, attempts - 1),
463
- APP_RESTART_MAX_DELAY_MS,
464
- );
465
- }
466
-
467
- function formatProxyReadyTimeout(timeoutMs: number): string {
468
- const seconds = timeoutMs / 1_000;
469
- return Number.isInteger(seconds) ? `${seconds}s` : `${timeoutMs}ms`;
470
- }
471
-
472
- function probePort(port: number, timeoutMs = 1_000): Promise<boolean> {
473
- return new Promise((resolve) => {
474
- const socket = new net.Socket();
475
- let settled = false;
476
- const finish = (ok: boolean) => {
477
- if (settled) return;
478
- settled = true;
479
- socket.destroy();
480
- resolve(ok);
481
- };
482
- socket.setTimeout(timeoutMs);
483
- socket.once("connect", () => finish(true));
484
- socket.once("error", () => finish(false));
485
- socket.once("timeout", () => finish(false));
486
- socket.connect(port, "127.0.0.1");
487
- });
488
- }
489
-
490
- function probeHttpReady(
491
- app: Pick<WorkspaceApp, "id" | "port">,
492
- timeoutMs = 1_000,
493
- ): Promise<boolean> {
494
- return new Promise((resolve) => {
495
- let settled = false;
496
- let req: http.ClientRequest;
497
- const finish = (ok: boolean) => {
498
- if (settled) return;
499
- settled = true;
500
- req.destroy();
501
- resolve(ok);
502
- };
503
- req = http.request(
504
- {
505
- hostname: "127.0.0.1",
506
- port: app.port,
507
- method: "GET",
508
- path: `/${app.id}`,
509
- headers: {
510
- accept: "text/html",
511
- host: `127.0.0.1:${app.port}`,
512
- },
513
- },
514
- (res) => {
515
- res.resume();
516
- finish(true);
517
- },
518
- );
519
- req.setTimeout(timeoutMs, () => finish(false));
520
- req.once("error", () => finish(false));
521
- req.end();
522
- });
523
- }
524
-
525
- function firstHeaderValue(
526
- value: string | string[] | number | undefined,
527
- ): string | undefined {
528
- if (Array.isArray(value)) return value[0];
529
- if (value === undefined) return undefined;
530
- return String(value);
531
- }
532
-
533
- function wantsHtml(req: http.IncomingMessage): boolean {
534
- if (req.method !== "GET" && req.method !== "HEAD") return false;
535
- const accept = firstHeaderValue(req.headers.accept);
536
- if (!accept) return false;
537
- return accept.includes("text/html");
538
- }
539
-
540
- function renderStartingApp(app: WorkspaceApp): string {
541
- const escapedName = escapeHtml(app.name || app.id);
542
- const failure = app.lastFailure;
543
- const retryDelayMs = failure
544
- ? Math.max(1_000, failure.nextRetryAt - Date.now() + 250)
545
- : 900;
546
- const refreshSeconds = failure
547
- ? Math.max(1, Math.ceil(retryDelayMs / 1_000))
548
- : 1;
549
- const refreshScriptDelay = failure ? retryDelayMs : 900;
550
- const title = failure
551
- ? `${failure.installing ? "Install failed" : "App failed to start"}: ${escapedName}`
552
- : `Starting ${escapedName}`;
553
- const message = failure
554
- ? `The workspace gateway will retry in ${Math.max(
555
- 1,
556
- Math.ceil((failure.nextRetryAt - Date.now()) / 1_000),
557
- )}s. Fix the error below or stop the server with Ctrl+C.`
558
- : app.installing
559
- ? "The workspace gateway is installing this app's dependencies before starting it."
560
- : "The workspace gateway is waking this app's dev server.";
561
- const failureOutput = failure?.output.trim();
562
- return `<!doctype html>
563
- <html>
564
- <head>
565
- <meta charset="utf-8" />
566
- <meta name="viewport" content="width=device-width, initial-scale=1" />
567
- <meta http-equiv="refresh" content="${refreshSeconds}" />
568
- <title>${title}</title>
569
- <meta name="color-scheme" content="light dark" />
570
- <style>
571
- :root { --bg: #fafafa; --fg: #171717; --muted: #737373; --bar-bg: #e5e5e5; --bar-fill: #171717; --danger: #dc2626; --code-bg: #171717; --code-fg: #fafafa; }
572
- @media (prefers-color-scheme: dark) { :root { --bg: #0a0a0a; --fg: #fafafa; --muted: #a3a3a3; --bar-bg: #262626; --bar-fill: #fafafa; --danger: #f87171; --code-bg: #171717; --code-fg: #f5f5f5; } }
573
- body { min-height: 100vh; margin: 0; display: grid; place-items: center; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--fg); }
574
- main { width: min(680px, calc(100vw - 48px)); }
575
- .bar { height: 3px; overflow: hidden; border-radius: 999px; background: var(--bar-bg); }
576
- .bar::before { content: ""; display: block; height: 100%; width: 42%; border-radius: inherit; background: var(--bar-fill); animation: load 1s ease-in-out infinite; }
577
- main.failed .bar::before { width: 100%; background: var(--danger); animation: none; }
578
- p { color: var(--muted); }
579
- pre { max-height: min(46vh, 360px); overflow: auto; margin-top: 20px; padding: 14px 16px; border-radius: 8px; background: var(--code-bg); color: var(--code-fg); font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; white-space: pre-wrap; word-break: break-word; }
580
- @keyframes load { 0% { transform: translateX(-105%); } 100% { transform: translateX(245%); } }
581
- </style>
582
- <script>
583
- (() => {
584
- const reload = () => window.location.reload();
585
- setTimeout(reload, ${JSON.stringify(refreshScriptDelay)});
586
- setInterval(reload, ${JSON.stringify(Math.max(refreshScriptDelay, 3_000))});
587
- })();
588
- </script>
589
- </head>
590
- <body>
591
- <main class="${failure ? "failed" : ""}">
592
- <div class="bar"></div>
593
- <h1>${title}</h1>
594
- <p>${escapeHtml(message)}</p>
595
- ${failureOutput ? `<pre>${escapeHtml(failureOutput)}</pre>` : ""}
596
- </main>
597
- </body>
598
- </html>`;
599
- }
600
-
601
- function renderIndex(apps: WorkspaceApp[]): string {
602
- return `<!doctype html>
603
- <html>
604
- <head>
605
- <meta charset="utf-8" />
606
- <meta name="viewport" content="width=device-width, initial-scale=1" />
607
- <title>Agent-Native Workspace</title>
608
- <meta name="color-scheme" content="light dark" />
609
- <style>
610
- :root { --bg: #fafafa; --fg: #171717; --muted: #737373; --card-bg: #ffffff; --card-border: #d4d4d4; }
611
- @media (prefers-color-scheme: dark) { :root { --bg: #0a0a0a; --fg: #fafafa; --muted: #a3a3a3; --card-bg: #171717; --card-border: #262626; } }
612
- body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; padding: 32px; background: var(--bg); color: var(--fg); }
613
- main { max-width: 760px; margin: 0 auto; }
614
- a { color: inherit; text-decoration: none; }
615
- .grid { display: grid; gap: 12px; margin-top: 20px; }
616
- .card { display: flex; justify-content: space-between; border: 1px solid var(--card-border); border-radius: 8px; padding: 14px 16px; background: var(--card-bg); }
617
- .muted { color: var(--muted); }
618
- </style>
619
- </head>
620
- <body>
621
- <main>
622
- <h1>Agent-Native Workspace</h1>
623
- <p class="muted">Open an app below. Dispatch is the workspace control plane when installed.</p>
624
- <div class="grid">
625
- ${apps
626
- .map(
627
- (app) =>
628
- `<a class="card" href="/${app.id}"><strong>${escapeHtml(app.name)}</strong><span class="muted">/${escapeHtml(app.id)}</span></a>`,
629
- )
630
- .join("")}
631
- </div>
632
- </main>
633
- </body>
634
- </html>`;
635
- }
636
-
637
- function hasLocalBin(dir: string, command: string): boolean {
638
- const binDir = path.join(dir, "node_modules", ".bin");
639
- return (
640
- fs.existsSync(path.join(binDir, command)) ||
641
- fs.existsSync(path.join(binDir, `${command}.cmd`)) ||
642
- fs.existsSync(path.join(binDir, `${command}.ps1`))
643
- );
644
- }
645
-
646
- export async function runWorkspaceDev(
647
- options: WorkspaceDevOptions = {},
648
- ): Promise<WorkspaceDevHandle> {
649
- const args = options.args ?? process.argv.slice(2);
650
- const env = options.env ?? process.env;
651
- const root = options.root ?? process.cwd();
652
- const appsDir = path.join(root, "apps");
653
- const spawnProcess = options.spawnProcess ?? windowsSafePnpmSpawn;
654
- const stdout = options.stdout ?? process.stdout;
655
- const stderr = options.stderr ?? process.stderr;
656
-
657
- fs.mkdirSync(path.join(root, "data"), { recursive: true });
658
- const gatewayHost = env.WORKSPACE_HOST || DEFAULT_GATEWAY_HOST;
659
- const requestedPort = Number(
660
- env.WORKSPACE_PORT || env.PORT || DEFAULT_GATEWAY_PORT,
661
- );
662
- const appPortStart = Number(
663
- env.WORKSPACE_APP_PORT_START || DEFAULT_APP_PORT_START,
664
- );
665
- const forceVite = env.WORKSPACE_VITE_FORCE === "1";
666
- const eager = shouldEagerStartWorkspaceApps(args, env);
667
- const pollingMode = pollingFileWatcherMode(env, root);
668
- const usePollingFileWatcher = pollingMode === "enable";
669
- const proxyReadyTimeoutMs = Number(
670
- env.WORKSPACE_PROXY_READY_TIMEOUT_MS ?? 30_000,
671
- );
672
- const proxyResponseTimeoutMs = Number(
673
- env.WORKSPACE_PROXY_RESPONSE_TIMEOUT_MS ??
674
- DEFAULT_PROXY_RESPONSE_TIMEOUT_MS,
675
- );
676
- const proxyNonHtmlResponseTimeoutMs = Number(
677
- env.WORKSPACE_PROXY_NON_HTML_RESPONSE_TIMEOUT_MS ??
678
- env.WORKSPACE_PROXY_RESPONSE_TIMEOUT_MS ??
679
- DEFAULT_PROXY_NON_HTML_RESPONSE_TIMEOUT_MS,
680
- );
681
- let gatewayUrl = `http://${gatewayHost}:${requestedPort}`;
682
-
683
- const apps = discoverApps(appsDir, appPortStart);
684
- if (apps.length === 0) {
685
- throw new Error("[workspace] No apps found under ./apps");
686
- }
687
-
688
- // Probe each app's proposed port to see if something else on the host
689
- // already owns it. Vite is spawned with `--strictPort` (so the gateway can
690
- // route /<appId> to a known port), which fails hard on EADDRINUSE — without
691
- // this probe a single conflicting process on 8100/8101/... kills the
692
- // workspace before the dev server prints anything useful.
693
- function probePortAvailable(port: number): Promise<boolean> {
694
- return new Promise((resolve) => {
695
- const probe = net.createServer();
696
- probe.once("error", () => resolve(false));
697
- probe.once("listening", () => {
698
- probe.close(() => resolve(true));
699
- });
700
- probe.listen(port, gatewayHost);
701
- });
702
- }
703
-
704
- async function reserveAppPort(
705
- start: number,
706
- excluded: Set<number>,
707
- ): Promise<number> {
708
- for (let port = start; port < start + 100; port++) {
709
- if (excluded.has(port)) continue;
710
- if (port === requestedPort) continue;
711
- if (await probePortAvailable(port)) return port;
712
- }
713
- throw new Error(
714
- `[workspace] No available port found between ${start} and ${start + 100}`,
715
- );
716
- }
717
-
718
- const reservedAppPorts = new Set<number>();
719
- for (const app of apps) {
720
- const original = app.port;
721
- const port = await reserveAppPort(original, reservedAppPorts);
722
- if (port !== original) {
723
- stdout.write(
724
- `[workspace] Port ${original} unavailable for /${app.id}, using ${port}\n`,
725
- );
726
- }
727
- app.port = port;
728
- reservedAppPorts.add(port);
729
- }
730
-
731
- const appById = new Map(apps.map((app) => [app.id, app]));
732
- const explicitDefaultApp =
733
- env.WORKSPACE_DEFAULT_APP && appById.has(env.WORKSPACE_DEFAULT_APP)
734
- ? env.WORKSPACE_DEFAULT_APP
735
- : null;
736
- const hasDispatch = appById.has("dispatch");
737
- const defaultApp =
738
- explicitDefaultApp ?? (hasDispatch ? "dispatch" : apps[0].id);
739
- const redirectRootToDefault = Boolean(explicitDefaultApp || hasDispatch);
740
-
741
- let syncTimer: NodeJS.Timeout | undefined;
742
- let shuttingDown = false;
743
- let workspaceStarted = false;
744
-
745
- if (usePollingFileWatcher) {
746
- stdout.write(
747
- `[workspace] Using polling file watchers (${POLLING_WATCH_INTERVAL_MS}ms) to avoid remote-container inotify limits.\n`,
748
- );
749
- }
750
-
751
- let readyResolve: (value: { port: number; url: string }) => void;
752
- const ready = new Promise<{ port: number; url: string }>((resolve) => {
753
- readyResolve = resolve;
754
- });
755
-
756
- function workspaceAppsJson(): string {
757
- return JSON.stringify(
758
- apps.map((workspaceApp) => ({
759
- id: workspaceApp.id,
760
- name: workspaceApp.name,
761
- description: workspaceApp.description,
762
- path: `/${workspaceApp.id}`,
763
- audience: workspaceApp.audience,
764
- publicPaths: workspaceApp.publicPaths,
765
- protectedPaths: workspaceApp.protectedPaths,
766
- })),
767
- );
768
- }
769
-
770
- async function syncApps(): Promise<void> {
771
- const discovered = discoverApps(appsDir, appPortStart);
772
- for (const app of discovered) {
773
- const existing = appById.get(app.id);
774
- if (existing) {
775
- existing.name = app.name;
776
- existing.description = app.description;
777
- existing.audience = app.audience;
778
- existing.publicPaths = app.publicPaths;
779
- existing.protectedPaths = app.protectedPaths;
780
- existing.dir = app.dir;
781
- continue;
782
- }
783
- const usedPorts = new Set(apps.map((existingApp) => existingApp.port));
784
- const port = await reserveAppPort(appPortStart, usedPorts);
785
- reservedAppPorts.add(port);
786
- const next = { ...app, port };
787
- apps.push(next);
788
- apps.sort(compareApps);
789
- appById.set(next.id, next);
790
- stdout.write(`[workspace] Detected new app: /${next.id}\n`);
791
- }
792
- }
793
-
794
- function scheduleSync(): void {
795
- if (syncTimer) clearTimeout(syncTimer);
796
- syncTimer = setTimeout(() => {
797
- void syncApps().catch((err) => {
798
- stderr.write(
799
- `[workspace] App sync failed: ${err instanceof Error ? err.message : String(err)}\n`,
800
- );
801
- });
802
- }, 400);
803
- }
804
-
805
- function appForRequest(req: http.IncomingMessage): WorkspaceApp | null {
806
- const params = new URL(req.url || "/", "http://workspace.local")
807
- .searchParams;
808
- const explicit = params.get("_app");
809
- if (explicit && appById.has(explicit)) return appById.get(explicit) ?? null;
810
-
811
- const direct = firstPathSegment(req.url);
812
- if (direct && appById.has(direct)) return appById.get(direct) ?? null;
813
-
814
- const fromState = extractOAuthStateAppId(params.get("state"));
815
- if (fromState && appById.has(fromState)) {
816
- return appById.get(fromState) ?? null;
817
- }
818
-
819
- const referer = req.headers.referer;
820
- const fromReferer =
821
- typeof referer === "string" ? firstPathSegment(referer) : null;
822
- return fromReferer && appById.has(fromReferer)
823
- ? (appById.get(fromReferer) ?? null)
824
- : null;
825
- }
826
-
827
- function startApp(app: WorkspaceApp): void {
828
- if (app.process && !app.process.killed) return;
829
- if (app.restartTimer) return;
830
- app.lastFailure = undefined;
831
- app.outputTail = undefined;
832
-
833
- const basePath = `/${app.id}`;
834
- const shouldInstall =
835
- !app.installAttempted && !hasLocalBin(app.dir, "vite");
836
- const childArgs = shouldInstall
837
- ? ["--dir", root, "install", "--no-frozen-lockfile", "--prefer-offline"]
838
- : [
839
- "--dir",
840
- app.dir,
841
- "exec",
842
- "vite",
843
- "--host",
844
- "127.0.0.1",
845
- "--port",
846
- String(app.port),
847
- "--strictPort",
848
- ...(forceVite ? ["--force"] : []),
849
- ];
850
-
851
- if (shouldInstall) {
852
- stdout.write(
853
- `[workspace] Installing dependencies before starting /${app.id}\n`,
854
- );
855
- }
856
-
857
- const child = spawnProcess("pnpm", childArgs, {
858
- cwd: root,
859
- stdio: ["ignore", "pipe", "pipe"],
860
- env: devWatcherEnv(
861
- {
862
- ...env,
863
- APP_NAME: app.id,
864
- AGENT_NATIVE_WORKSPACE: "1",
865
- AGENT_NATIVE_WORKSPACE_APP_ID: app.id,
866
- AGENT_NATIVE_WORKSPACE_APPS_JSON: workspaceAppsJson(),
867
- AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: app.audience,
868
- AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: JSON.stringify(
869
- app.publicPaths,
870
- ),
871
- AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: JSON.stringify(
872
- app.protectedPaths,
873
- ),
874
- APP_BASE_PATH: basePath,
875
- VITE_AGENT_NATIVE_WORKSPACE: "1",
876
- VITE_AGENT_NATIVE_WORKSPACE_APP_ID: app.id,
877
- VITE_AGENT_NATIVE_WORKSPACE_APPS_JSON: workspaceAppsJson(),
878
- VITE_AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: app.audience,
879
- VITE_AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: JSON.stringify(
880
- app.publicPaths,
881
- ),
882
- VITE_AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: JSON.stringify(
883
- app.protectedPaths,
884
- ),
885
- VITE_APP_BASE_PATH: basePath,
886
- VITE_WORKSPACE_OAUTH_ORIGIN: workspaceOAuthOrigin(env, gatewayUrl),
887
- VITE_WORKSPACE_GATEWAY_URL: gatewayUrl,
888
- PORT: String(app.port),
889
- WORKSPACE_GATEWAY_URL: gatewayUrl,
890
- },
891
- pollingMode,
892
- ),
893
- });
894
- app.process = child;
895
- app.installing = shouldInstall;
896
-
897
- const prefix = `[${app.id}]`;
898
- const stableTimer = setTimeout(() => {
899
- app.restartAttempts = 0;
900
- }, 5_000);
901
- stableTimer.unref();
902
-
903
- child.stdout?.on("data", (chunk) => {
904
- appendAppOutputTail(
905
- app,
906
- pipeAppOutput(prefix, chunk, (value) => stdout.write(value)),
907
- );
908
- });
909
- child.stderr?.on("data", (chunk) => {
910
- appendAppOutputTail(
911
- app,
912
- pipeAppOutput(prefix, chunk, (value) => stderr.write(value)),
913
- );
914
- });
915
- child.on("error", (error) => {
916
- // Without this listener a failed spawn (e.g. the Windows pnpm-shim
917
- // ENOENT) was SILENT — no exit event fires, the port never binds, and
918
- // only the readiness timeout showed. Surface it and enter the normal
919
- // retry path.
920
- clearTimeout(stableTimer);
921
- const wasInstalling = app.installing;
922
- app.process = undefined;
923
- app.installing = false;
924
- app.ready = false;
925
- app.readinessProbe = undefined;
926
- if (app.restartTimer || shuttingDown) return;
927
- if (wasInstalling) app.installAttempted = false;
928
- scheduleAppRestart(app, {
929
- code: null,
930
- signal: null,
931
- installing: wasInstalling,
932
- output: String(error),
933
- logMessage: `failed to spawn child process: ${
934
- error instanceof Error ? error.message : String(error)
935
- }`,
936
- });
937
- });
938
- child.on("exit", (code, signal) => {
939
- clearTimeout(stableTimer);
940
- const wasInstalling = app.installing;
941
- app.process = undefined;
942
- app.installing = false;
943
- app.ready = false;
944
- app.readinessProbe = undefined;
945
- if (app.restartTimer) return;
946
- if (code === 0 || shuttingDown) {
947
- if (wasInstalling && code === 0 && !shuttingDown) {
948
- app.installAttempted = true;
949
- startApp(app);
950
- }
951
- return;
952
- }
953
- if (wasInstalling) app.installAttempted = false;
954
- scheduleAppRestart(app, {
955
- code,
956
- signal,
957
- installing: wasInstalling,
958
- output: app.outputTail ?? "",
959
- logMessage: `exited with code ${code}`,
960
- });
961
- });
962
- }
963
-
964
- function scheduleAppRestart(
965
- app: WorkspaceApp,
966
- input: {
967
- code: number | null;
968
- signal: NodeJS.Signals | null;
969
- installing: boolean;
970
- output: string;
971
- logMessage: string;
972
- },
973
- ): void {
974
- if (shuttingDown || app.restartTimer) return;
975
- if (input.installing) app.installAttempted = false;
976
- app.restartAttempts = (app.restartAttempts ?? 0) + 1;
977
- const delay = appRestartDelay(app.restartAttempts);
978
- const nextRetryAt = Date.now() + delay;
979
- app.lastFailure = {
980
- code: input.code,
981
- signal: input.signal,
982
- at: Date.now(),
983
- installing: input.installing,
984
- output: input.output,
985
- nextRetryAt,
986
- };
987
- stderr.write(
988
- `[${app.id}] ${input.logMessage}; retrying in ${Math.round(
989
- delay / 1000,
990
- )}s\n`,
991
- );
992
- app.restartTimer = setTimeout(() => {
993
- app.restartTimer = undefined;
994
- startApp(app);
995
- }, delay);
996
- app.restartTimer.unref();
997
- }
998
-
999
- function failAppStartupTimeout(app: WorkspaceApp): void {
1000
- if (app.installing || app.ready || app.restartTimer) return;
1001
- const timeout = formatProxyReadyTimeout(proxyReadyTimeoutMs);
1002
- const message =
1003
- `Timed out waiting ${timeout} for /${app.id} to return ` +
1004
- `an HTTP response on 127.0.0.1:${app.port}.`;
1005
- const output = [message, app.outputTail?.trim()]
1006
- .filter(Boolean)
1007
- .join("\n\nLast child output:\n");
1008
- app.ready = false;
1009
- app.readinessProbe = undefined;
1010
- scheduleAppRestart(app, {
1011
- code: null,
1012
- signal: null,
1013
- installing: false,
1014
- output,
1015
- logMessage: message,
1016
- });
1017
- killAppProcessTree(app.process);
1018
- }
1019
-
1020
- function forwardedProto(req: http.IncomingMessage): string {
1021
- return (
1022
- firstHeaderValue(req.headers["x-forwarded-proto"]) ||
1023
- ((req.socket as { encrypted?: boolean }).encrypted ? "https" : "http")
1024
- );
1025
- }
1026
-
1027
- function forwardedHost(req: http.IncomingMessage): string {
1028
- return (
1029
- firstHeaderValue(req.headers["x-forwarded-host"]) ||
1030
- firstHeaderValue(req.headers.host) ||
1031
- new URL(gatewayUrl).host
1032
- );
1033
- }
1034
-
1035
- function proxyHeaders(
1036
- req: http.IncomingMessage,
1037
- targetHost: string,
1038
- ): http.OutgoingHttpHeaders {
1039
- return {
1040
- ...req.headers,
1041
- "x-forwarded-host": forwardedHost(req),
1042
- "x-forwarded-proto": forwardedProto(req),
1043
- host: targetHost,
1044
- };
1045
- }
1046
-
1047
- async function waitForPort(port: number, deadline: number): Promise<boolean> {
1048
- while (Date.now() < deadline) {
1049
- if (await probePort(port)) return true;
1050
- await new Promise((r) => setTimeout(r, PROXY_READY_RETRY_DELAY_MS));
1051
- }
1052
- return false;
1053
- }
1054
-
1055
- async function waitForHttpReady(
1056
- app: WorkspaceApp,
1057
- deadline: number,
1058
- ): Promise<boolean> {
1059
- while (Date.now() < deadline) {
1060
- const timeoutMs = Math.min(1_000, Math.max(1, deadline - Date.now()));
1061
- if (await probeHttpReady(app, timeoutMs)) return true;
1062
- await new Promise((r) => setTimeout(r, PROXY_READY_RETRY_DELAY_MS));
1063
- }
1064
- return false;
1065
- }
1066
-
1067
- function ensureReadinessProbe(app: WorkspaceApp): void {
1068
- if (app.ready || app.readinessProbe || app.installing) return;
1069
- app.readinessProbe = waitForHttpReady(app, Date.now() + proxyReadyTimeoutMs)
1070
- .then((ready) => {
1071
- if (ready) {
1072
- app.ready = true;
1073
- return;
1074
- }
1075
- failAppStartupTimeout(app);
1076
- })
1077
- .finally(() => {
1078
- app.readinessProbe = undefined;
1079
- });
1080
- }
1081
-
1082
- function proxyHttp(
1083
- app: WorkspaceApp,
1084
- req: http.IncomingMessage,
1085
- res: http.ServerResponse,
1086
- ): void {
1087
- const cold = !app.process || app.process.killed;
1088
- startApp(app);
1089
-
1090
- if (!app.ready && wantsHtml(req)) {
1091
- ensureReadinessProbe(app);
1092
- res.writeHead(200, STARTING_APP_RESPONSE_HEADERS);
1093
- if (req.method === "HEAD") {
1094
- res.end();
1095
- return;
1096
- }
1097
- res.end(renderStartingApp(app));
1098
- return;
1099
- }
1100
-
1101
- const serveStartingPage = () => {
1102
- res.writeHead(200, STARTING_APP_RESPONSE_HEADERS);
1103
- if (req.method === "HEAD") {
1104
- res.end();
1105
- return;
1106
- }
1107
- res.end(renderStartingApp(app));
1108
- };
1109
-
1110
- const dispatch = () => {
1111
- const headers = proxyHeaders(req, `127.0.0.1:${app.port}`);
1112
- let settled = false;
1113
- let responseTimer: NodeJS.Timeout;
1114
- const responseTimeoutMs = wantsHtml(req)
1115
- ? proxyResponseTimeoutMs
1116
- : proxyNonHtmlResponseTimeoutMs;
1117
- const proxyReq = http.request(
1118
- {
1119
- hostname: "127.0.0.1",
1120
- port: app.port,
1121
- method: req.method,
1122
- path: req.url,
1123
- headers,
1124
- },
1125
- (proxyRes) => {
1126
- if (settled) {
1127
- proxyRes.resume();
1128
- return;
1129
- }
1130
- settled = true;
1131
- clearTimeout(responseTimer);
1132
- app.ready = true;
1133
- const statusCode = proxyRes.statusCode ?? 502;
1134
- const responseHeaders = { ...proxyRes.headers };
1135
- if (statusCode >= 300 && statusCode < 400) {
1136
- const rewritten = rewriteRedirectLocation(
1137
- app,
1138
- firstHeaderValue(responseHeaders.location),
1139
- );
1140
- if (rewritten) responseHeaders.location = rewritten;
1141
- }
1142
- res.writeHead(statusCode, responseHeaders);
1143
- proxyRes.once("error", () => {
1144
- if (!res.destroyed) res.destroy();
1145
- });
1146
- proxyRes.pipe(res);
1147
- },
1148
- );
1149
- proxyReq.once("socket", (socket) => {
1150
- attachGatewaySocketErrorSink(socket);
1151
- });
1152
- res.once("error", () => {
1153
- proxyReq.destroy();
1154
- });
1155
- responseTimer = setTimeout(() => {
1156
- if (settled) return;
1157
- settled = true;
1158
- app.ready = false;
1159
- proxyReq.destroy();
1160
- ensureReadinessProbe(app);
1161
- if (res.headersSent) {
1162
- res.end();
1163
- return;
1164
- }
1165
- if (wantsHtml(req)) {
1166
- serveStartingPage();
1167
- return;
1168
- }
1169
- res.writeHead(504, { "content-type": "text/plain" });
1170
- res.end(
1171
- `App "${app.id}" did not return response headers within ${formatProxyReadyTimeout(responseTimeoutMs)}.`,
1172
- );
1173
- }, responseTimeoutMs);
1174
- responseTimer.unref();
1175
-
1176
- proxyReq.on("error", (err) => {
1177
- clearTimeout(responseTimer);
1178
- if (settled) return;
1179
- settled = true;
1180
- if (res.headersSent) {
1181
- res.end();
1182
- return;
1183
- }
1184
- res.writeHead(502, { "content-type": "text/plain" });
1185
- res.end(`App "${app.id}" is not ready yet: ${err.message}`);
1186
- });
1187
-
1188
- req.pipe(proxyReq);
1189
- };
1190
-
1191
- // Fast path: the upstream has accepted at least one request before, so
1192
- // it's listening. Skip the probe so steady-state requests stay zero-latency.
1193
- if (app.ready && !cold) {
1194
- dispatch();
1195
- return;
1196
- }
1197
-
1198
- // Cold path: hold non-HTML requests open while the child server boots.
1199
- // Node keeps the request body paused until pipe() attaches.
1200
- void waitForHttpReady(app, Date.now() + proxyReadyTimeoutMs).then(
1201
- (ready) => {
1202
- if (!ready) {
1203
- failAppStartupTimeout(app);
1204
- if (!res.headersSent) {
1205
- res.writeHead(502, { "content-type": "text/plain" });
1206
- res.end(
1207
- `App "${app.id}" is not ready yet: no HTTP response from 127.0.0.1:${app.port}`,
1208
- );
1209
- } else {
1210
- res.end();
1211
- }
1212
- return;
1213
- }
1214
- app.ready = true;
1215
- dispatch();
1216
- },
1217
- );
1218
- }
1219
-
1220
- function proxyUpgrade(
1221
- app: WorkspaceApp,
1222
- req: http.IncomingMessage,
1223
- socket: Duplex,
1224
- head: Buffer,
1225
- ): void {
1226
- startApp(app);
1227
- let target: net.Socket | undefined;
1228
- attachGatewaySocketErrorSink(socket, () => {
1229
- target?.destroy();
1230
- });
1231
- socket.once("close", () => {
1232
- target?.destroy();
1233
- });
1234
- void waitForPort(app.port, Date.now() + proxyReadyTimeoutMs).then(
1235
- (ready) => {
1236
- if (!ready) {
1237
- failAppStartupTimeout(app);
1238
- socket.destroy();
1239
- return;
1240
- }
1241
- if (socket.destroyed) return;
1242
- app.ready = true;
1243
- const upstream = net.connect(app.port, "127.0.0.1", () => {
1244
- const headers = Object.entries(
1245
- proxyHeaders(req, `127.0.0.1:${app.port}`),
1246
- )
1247
- .flatMap(([key, value]) =>
1248
- Array.isArray(value)
1249
- ? value.map((item) => `${key}: ${item}`)
1250
- : [`${key}: ${value ?? ""}`],
1251
- )
1252
- .join("\r\n");
1253
- upstream.write(
1254
- `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n${headers}\r\n\r\n`,
1255
- );
1256
- if (head.length) upstream.write(head);
1257
- socket.pipe(upstream).pipe(socket);
1258
- });
1259
- target = upstream;
1260
-
1261
- attachGatewaySocketErrorSink(upstream, () => {
1262
- if (!socket.destroyed) socket.destroy();
1263
- });
1264
- upstream.once("close", () => {
1265
- if (!socket.destroyed) socket.destroy();
1266
- });
1267
- },
1268
- );
1269
- }
1270
-
1271
- function handleWatcherError(err: NodeJS.ErrnoException): void {
1272
- if (isWorkspaceWatcherLimitError(err)) {
1273
- stderr.write(
1274
- `[workspace] Recursive file watcher hit the system limit (${err.code}). ` +
1275
- `New apps will still be detected via polling every ~2s. ` +
1276
- (err.code === "ENOSPC"
1277
- ? `On Linux you can raise the limit with ` +
1278
- `\`sudo sysctl fs.inotify.max_user_watches=524288\` ` +
1279
- `(persist via /etc/sysctl.d/*.conf). `
1280
- : `Try closing other dev servers or raising your open-file limit. `) +
1281
- `On macOS/Windows this usually ` +
1282
- `means too many other watchers are running.\n`,
1283
- );
1284
- return;
1285
- }
1286
- if (err.code === "ENOENT") {
1287
- return;
1288
- }
1289
- stderr.write(
1290
- `[workspace] Recursive file watcher failed (${err.code ?? "unknown"}): ${err.message}. ` +
1291
- `Falling back to polling.\n`,
1292
- );
1293
- Sentry.captureException(err, {
1294
- tags: { handled: "dev-watch-unknown" },
1295
- level: "warning",
1296
- });
1297
- }
1298
-
1299
- function startWorkspaceProcesses(): void {
1300
- if (workspaceStarted) return;
1301
- workspaceStarted = true;
1302
- for (const id of initialWorkspaceAppIds(
1303
- apps,
1304
- defaultApp,
1305
- eager,
1306
- redirectRootToDefault,
1307
- )) {
1308
- const app = appById.get(id);
1309
- if (app) startApp(app);
1310
- }
1311
- try {
1312
- const watcher = fs.watch(appsDir, { recursive: true }, scheduleSync);
1313
- watcher.on("error", (err) => {
1314
- handleWatcherError(err as NodeJS.ErrnoException);
1315
- });
1316
- } catch (err) {
1317
- handleWatcherError(err as NodeJS.ErrnoException);
1318
- }
1319
- setInterval(() => {
1320
- void syncApps().catch(() => {});
1321
- }, 2_000).unref();
1322
- }
1323
-
1324
- /**
1325
- * Background-spawn every app that wasn't started by `startWorkspaceProcesses`.
1326
- * The lazy proxy still handles correctness (an on-demand request always
1327
- * starts its target app); this is purely so the first navigation into a
1328
- * non-default app doesn't pay the cold Vite + esbuild prebundle cost.
1329
- *
1330
- * Fires after a short delay so the default app's prebundle gets first dibs
1331
- * on CPU. Concurrency-limited to avoid hammering a small dev machine —
1332
- * each Vite spawn briefly maxes out a core during prebundling.
1333
- */
1334
- async function prewarmRemainingApps(): Promise<void> {
1335
- const concurrency = workspacePrewarmConcurrency(args, env);
1336
- const delayMs = workspacePrewarmDelayMs(env);
1337
-
1338
- const queue = apps
1339
- .filter((app) => app.id !== defaultApp)
1340
- .filter((app) => !(app.process && !app.process.killed))
1341
- .map((app) => app.id);
1342
-
1343
- if (queue.length === 0) return;
1344
-
1345
- stdout.write(
1346
- `[workspace] Prewarming ${queue.length} app(s) in the background ` +
1347
- `(concurrency ${concurrency}; pass --no-prewarm to disable)\n`,
1348
- );
1349
-
1350
- if (delayMs > 0) {
1351
- await new Promise((resolve) => setTimeout(resolve, delayMs));
1352
- }
1353
- if (shuttingDown) return;
1354
-
1355
- let next = 0;
1356
- async function worker(): Promise<void> {
1357
- while (!shuttingDown && next < queue.length) {
1358
- const id = queue[next++];
1359
- const app = appById.get(id);
1360
- if (!app) continue;
1361
- // Another path (a real request, a restart, etc.) may have started
1362
- // this app already — skip without consuming a worker slot needlessly.
1363
- if (app.process && !app.process.killed) continue;
1364
- startApp(app);
1365
- ensureReadinessProbe(app);
1366
- // Wait for the upstream to answer HTTP before pulling the next
1367
- // app off the queue. This is what actually limits *concurrent
1368
- // prebundling* (not just concurrent spawning) and keeps CPU pressure
1369
- // sane. proxyReadyTimeoutMs caps any single stuck app.
1370
- await waitForHttpReady(app, Date.now() + proxyReadyTimeoutMs).catch(
1371
- () => false,
1372
- );
1373
- }
1374
- }
1375
-
1376
- const workerCount = Math.max(1, Math.min(concurrency, queue.length));
1377
- await Promise.all(Array.from({ length: workerCount }, () => worker()));
1378
- }
1379
-
1380
- function openBrowser(url: string): void {
1381
- if (options.openBrowser === false || env.WORKSPACE_NO_OPEN === "1") return;
1382
- const command =
1383
- process.platform === "darwin"
1384
- ? "open"
1385
- : process.platform === "win32"
1386
- ? "cmd"
1387
- : "xdg-open";
1388
- const openArgs =
1389
- process.platform === "win32" ? ["/c", "start", "", url] : [url];
1390
- const child = spawnProcess(command, openArgs, {
1391
- stdio: "ignore",
1392
- detached: true,
1393
- });
1394
- child.unref();
1395
- }
1396
-
1397
- const server = http.createServer(async (req, res) => {
1398
- const parsedUrl = new URL(req.url || "/", "http://workspace.local");
1399
- const pathname = parsedUrl.pathname;
1400
-
1401
- if (pathname === "/" || pathname === "/index.html") {
1402
- await syncApps().catch(() => {});
1403
- const currentDefaultApp =
1404
- explicitDefaultApp && appById.has(explicitDefaultApp)
1405
- ? explicitDefaultApp
1406
- : appById.has("dispatch")
1407
- ? "dispatch"
1408
- : defaultApp;
1409
- const shouldRedirectRoot =
1410
- Boolean(explicitDefaultApp && appById.has(explicitDefaultApp)) ||
1411
- appById.has("dispatch");
1412
- if (shouldRedirectRoot) {
1413
- res.writeHead(302, {
1414
- location: `/${currentDefaultApp}${parsedUrl.search}`,
1415
- });
1416
- res.end();
1417
- return;
1418
- }
1419
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
1420
- res.end(renderIndex(apps));
1421
- return;
1422
- }
1423
-
1424
- if (pathname === "/_workspace/apps") {
1425
- await syncApps().catch(() => {});
1426
- res.writeHead(200, { "content-type": "application/json" });
1427
- res.end(
1428
- JSON.stringify(
1429
- apps.map((app) => ({
1430
- id: app.id,
1431
- name: app.name,
1432
- description: app.description,
1433
- path: `/${app.id}`,
1434
- audience: app.audience,
1435
- publicPaths: app.publicPaths,
1436
- protectedPaths: app.protectedPaths,
1437
- port: app.port,
1438
- running: Boolean(app.process && !app.process.killed),
1439
- })),
1440
- ),
1441
- );
1442
- return;
1443
- }
1444
-
1445
- let app = appForRequest(req);
1446
- if (!app) {
1447
- await syncApps().catch(() => {});
1448
- app = appForRequest(req);
1449
- }
1450
- if (!app) {
1451
- res.writeHead(404, { "content-type": "text/html; charset=utf-8" });
1452
- res.end(renderIndex(apps));
1453
- return;
1454
- }
1455
- proxyHttp(app, req, res);
1456
- });
1457
-
1458
- server.on("upgrade", (req, socket, head) => {
1459
- const app = appForRequest(req);
1460
- if (!app) {
1461
- socket.destroy();
1462
- return;
1463
- }
1464
- proxyUpgrade(app, req, socket, head);
1465
- });
1466
-
1467
- function listen(port: number, attempts = 20): void {
1468
- server.once("error", (err: NodeJS.ErrnoException) => {
1469
- if (err.code === "EADDRINUSE" && attempts > 0) {
1470
- listen(port + 1, attempts - 1);
1471
- return;
1472
- }
1473
- stderr.write(`[workspace] Could not start gateway: ${err.message}\n`);
1474
- throw err;
1475
- });
1476
- server.listen(port, gatewayHost, () => {
1477
- const address = server.address();
1478
- const actualPort =
1479
- typeof address === "object" && address ? address.port : port;
1480
- gatewayUrl = `http://${gatewayHost}:${actualPort}`;
1481
- stdout.write(
1482
- `[workspace] Default: ${redirectRootToDefault ? `${gatewayUrl}/${defaultApp}` : gatewayUrl}\n`,
1483
- );
1484
- stdout.write(`[workspace] Gateway: ${gatewayUrl}\n`);
1485
- const prewarming = shouldPrewarmWorkspaceApps(args, env);
1486
- stdout.write(
1487
- `[workspace] Mode: ${
1488
- eager ? "eager" : prewarming ? "lazy+prewarm" : "lazy"
1489
- }\n`,
1490
- );
1491
- for (const app of apps) {
1492
- stdout.write(
1493
- `[workspace] ${app.id}: /${app.id} -> 127.0.0.1:${app.port}\n`,
1494
- );
1495
- }
1496
- startWorkspaceProcesses();
1497
- if (prewarming) {
1498
- void prewarmRemainingApps().catch((err) => {
1499
- stderr.write(
1500
- `[workspace] Prewarm error: ${
1501
- err instanceof Error ? err.message : String(err)
1502
- }\n`,
1503
- );
1504
- });
1505
- }
1506
- openBrowser(
1507
- redirectRootToDefault ? `${gatewayUrl}/${defaultApp}` : gatewayUrl,
1508
- );
1509
- readyResolve({ port: actualPort, url: gatewayUrl });
1510
- });
1511
- }
1512
-
1513
- function shutdown(): void {
1514
- if (shuttingDown) return;
1515
- shuttingDown = true;
1516
- server.close();
1517
- for (const app of apps) {
1518
- killAppProcessTree(app.process);
1519
- }
1520
- if (syncTimer) clearTimeout(syncTimer);
1521
- process.off("SIGINT", handleSigint);
1522
- process.off("SIGTERM", handleSigterm);
1523
- }
1524
-
1525
- const handleSigint = () => shutdown();
1526
- const handleSigterm = () => shutdown();
1527
- process.once("SIGINT", handleSigint);
1528
- process.once("SIGTERM", handleSigterm);
1529
-
1530
- listen(requestedPort);
1531
-
1532
- return {
1533
- apps,
1534
- defaultApp,
1535
- gatewayUrl: () => gatewayUrl,
1536
- ready,
1537
- server,
1538
- shutdown,
1539
- };
1540
- }
1541
-
1542
- function isDirectRun(): boolean {
1543
- const entry = process.argv[1];
1544
- if (!entry) return false;
1545
- try {
1546
- return path.resolve(entry) === fileURLToPath(import.meta.url);
1547
- } catch {
1548
- return false;
1549
- }
1550
- }
1551
-
1552
- if (isDirectRun()) {
1553
- runWorkspaceDev({ args: process.argv.slice(2) }).catch((err) => {
1554
- console.error(err instanceof Error ? err.message : String(err));
1555
- process.exit(1);
1556
- });
1557
- }
1
+ #!/usr/bin/env tsx
2
+ import { execFileSync, spawn, type ChildProcess } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import http from "node:http";
5
+ import net from "node:net";
6
+ import path from "node:path";
7
+ import type { Duplex } from "node:stream";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ import * as Sentry from "@sentry/node";
11
+
12
+ import { extractOAuthStateAppId } from "../shared/oauth-state.js";
13
+ import {
14
+ DEFAULT_WORKSPACE_APP_AUDIENCE,
15
+ workspaceAppAudienceFromPackageJson,
16
+ workspaceAppRouteAccessFromPackageJson,
17
+ type WorkspaceAppAudience,
18
+ } from "../shared/workspace-app-audience.js";
19
+ import {
20
+ attachGatewaySocketErrorSink,
21
+ normalizeOrigin,
22
+ rewriteRedirectLocation,
23
+ escapeHtml,
24
+ } from "./gateway-helpers.js";
25
+
26
+ export interface WorkspaceApp {
27
+ id: string;
28
+ name: string;
29
+ description: string;
30
+ audience: WorkspaceAppAudience;
31
+ publicPaths: string[];
32
+ protectedPaths: string[];
33
+ dir: string;
34
+ port: number;
35
+ process?: ChildProcess;
36
+ restartTimer?: NodeJS.Timeout;
37
+ restartAttempts?: number;
38
+ lastFailure?: {
39
+ code: number | null;
40
+ signal: NodeJS.Signals | null;
41
+ at: number;
42
+ installing: boolean;
43
+ output: string;
44
+ nextRetryAt: number;
45
+ };
46
+ outputTail?: string;
47
+ installing?: boolean;
48
+ installAttempted?: boolean;
49
+ /**
50
+ * Built-mode lifecycle. `devMode` marks an app that has been promoted to
51
+ * the vite dev server for the rest of the session (source changed, build
52
+ * failed, or no build could be produced). `building` marks a production
53
+ * build in flight as the app's prep child. `buildAttempted` prevents
54
+ * rebuild loops; `buildChecked` caches the once-per-session staleness scan.
55
+ */
56
+ devMode?: boolean;
57
+ building?: boolean;
58
+ buildAttempted?: boolean;
59
+ buildChecked?: boolean;
60
+ /**
61
+ * Set before an intentional kill (promotion to dev mode) so the exit
62
+ * handler respawns immediately instead of treating it as a crash.
63
+ */
64
+ expectedExit?: boolean;
65
+ promoteWatcher?: fs.FSWatcher;
66
+ /**
67
+ * Set true once we've successfully connected to the upstream. After that we
68
+ * skip the readiness probe on every request; the child server stays
69
+ * listening for the rest of the dev session.
70
+ */
71
+ ready?: boolean;
72
+ readinessProbe?: Promise<void>;
73
+ }
74
+
75
+ export interface WorkspaceDevOptions {
76
+ args?: string[];
77
+ env?: NodeJS.ProcessEnv;
78
+ root?: string;
79
+ spawnProcess?: typeof spawn;
80
+ openBrowser?: boolean;
81
+ stdout?: Pick<NodeJS.WriteStream, "write">;
82
+ stderr?: Pick<NodeJS.WriteStream, "write">;
83
+ }
84
+
85
+ export interface WorkspaceDevHandle {
86
+ apps: WorkspaceApp[];
87
+ defaultApp: string;
88
+ gatewayUrl: () => string;
89
+ ready: Promise<{ port: number; url: string }>;
90
+ server: http.Server;
91
+ shutdown: () => void;
92
+ }
93
+
94
+ const DEFAULT_GATEWAY_HOST = "127.0.0.1";
95
+ const DEFAULT_GATEWAY_PORT = 8080;
96
+ const DEFAULT_APP_PORT_START = 8100;
97
+ const PROXY_READY_RETRY_DELAY_MS = 250;
98
+ const APP_RESTART_MAX_DELAY_MS = 10_000;
99
+ const DEFAULT_PROXY_RESPONSE_TIMEOUT_MS = 5_000;
100
+ const DEFAULT_PROXY_NON_HTML_RESPONSE_TIMEOUT_MS = 120_000;
101
+ const APP_OUTPUT_TAIL_BYTES = 8_000;
102
+ const POLLING_WATCH_INTERVAL_MS = "1000";
103
+ const reportedDiscoverAppsReadFailures = new Set<string>();
104
+ const STARTING_APP_RESPONSE_HEADERS: http.OutgoingHttpHeaders = {
105
+ "content-type": "text/html; charset=utf-8",
106
+ "cache-control": "no-store, no-cache, max-age=0, must-revalidate",
107
+ pragma: "no-cache",
108
+ expires: "0",
109
+ };
110
+
111
+ function workspaceOAuthOrigin(
112
+ env: NodeJS.ProcessEnv,
113
+ gatewayUrl: string,
114
+ ): string | undefined {
115
+ return (
116
+ normalizeOrigin(env.VITE_WORKSPACE_OAUTH_ORIGIN) ||
117
+ normalizeOrigin(env.WORKSPACE_OAUTH_ORIGIN) ||
118
+ normalizeOrigin(env.APP_URL) ||
119
+ normalizeOrigin(env.BETTER_AUTH_URL) ||
120
+ normalizeOrigin(gatewayUrl)
121
+ );
122
+ }
123
+
124
+ export function isWorkspaceWatcherLimitError(
125
+ err: Pick<NodeJS.ErrnoException, "code">,
126
+ ): boolean {
127
+ return err.code === "ENOSPC" || err.code === "EMFILE";
128
+ }
129
+
130
+ export function shouldEagerStartWorkspaceApps(
131
+ args: string[] = [],
132
+ env: NodeJS.ProcessEnv = process.env,
133
+ ): boolean {
134
+ return (
135
+ args.includes("--eager") ||
136
+ env.WORKSPACE_EAGER === "1" ||
137
+ env.WORKSPACE_EAGER === "true"
138
+ );
139
+ }
140
+
141
+ /**
142
+ * Mixed-mode gateway: when enabled (--built / WORKSPACE_BUILT=1), each app
143
+ * boots from its prebuilt production server (`.output/server/index.mjs`,
144
+ * instant, prod-shaped) instead of a vite dev server. The gateway builds an
145
+ * app first when no fresh build exists, and automatically promotes an app to
146
+ * the vite dev server the moment one of its source files changes — so the
147
+ * app being edited gets hot reload while every other app stays on built
148
+ * output. Nothing is manual: editing is the switch.
149
+ */
150
+ export function shouldRunBuiltWorkspaceApps(
151
+ args: string[] = [],
152
+ env: NodeJS.ProcessEnv = process.env,
153
+ ): boolean {
154
+ if (args.includes("--no-built")) return false;
155
+ if (env.WORKSPACE_BUILT === "0" || env.WORKSPACE_BUILT === "false") {
156
+ return false;
157
+ }
158
+ return (
159
+ args.includes("--built") ||
160
+ env.WORKSPACE_BUILT === "1" ||
161
+ env.WORKSPACE_BUILT === "true"
162
+ );
163
+ }
164
+
165
+ const BUILT_MODE_IGNORED_SEGMENTS = new Set([
166
+ "node_modules",
167
+ "build",
168
+ "dist",
169
+ "data",
170
+ "media",
171
+ "coverage",
172
+ "test-results",
173
+ "playwright-report",
174
+ ]);
175
+ const BUILT_MODE_IGNORED_FILE_PATTERN =
176
+ /\.(log|tmp|db|db-wal|db-shm|sqlite|sqlite-wal|sqlite-shm)$/i;
177
+
178
+ /**
179
+ * Whether a path relative to an app dir counts as "source" for built-mode
180
+ * staleness checks and dev-server promotion. Excludes build outputs, data,
181
+ * dependency trees, and every dot-directory/dot-file (`.output`,
182
+ * `.deploy-tmp`, `.react-router`, `.env`, ...) — an `.env` edit needs a
183
+ * restart, not a permanent promotion to vite.
184
+ */
185
+ export function isBuiltModeSourcePath(relPath: string): boolean {
186
+ if (!relPath) return false;
187
+ const segments = relPath.split(/[\\/]/).filter(Boolean);
188
+ if (segments.length === 0) return false;
189
+ for (const segment of segments) {
190
+ if (segment.startsWith(".")) return false;
191
+ if (BUILT_MODE_IGNORED_SEGMENTS.has(segment)) return false;
192
+ }
193
+ return !BUILT_MODE_IGNORED_FILE_PATTERN.test(relPath);
194
+ }
195
+
196
+ export function builtWorkspaceAppServerEntry(appDir: string): string {
197
+ return path.join(appDir, ".output", "server", "index.mjs");
198
+ }
199
+
200
+ /**
201
+ * Newest mtime across an app's source files (build outputs, data, and
202
+ * node_modules excluded). Used once per session per app to decide whether
203
+ * the prebuilt server is stale and needs a rebuild before serving.
204
+ */
205
+ export function newestBuiltModeSourceMtimeMs(
206
+ dir: string,
207
+ relBase = "",
208
+ ): number {
209
+ let entries: fs.Dirent[];
210
+ try {
211
+ entries = fs.readdirSync(dir, { withFileTypes: true });
212
+ } catch {
213
+ return 0;
214
+ }
215
+ let newest = 0;
216
+ for (const entry of entries) {
217
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
218
+ if (entry.isDirectory()) {
219
+ if (
220
+ entry.name.startsWith(".") ||
221
+ BUILT_MODE_IGNORED_SEGMENTS.has(entry.name)
222
+ ) {
223
+ continue;
224
+ }
225
+ newest = Math.max(
226
+ newest,
227
+ newestBuiltModeSourceMtimeMs(path.join(dir, entry.name), rel),
228
+ );
229
+ continue;
230
+ }
231
+ if (!entry.isFile() || !isBuiltModeSourcePath(rel)) continue;
232
+ try {
233
+ newest = Math.max(
234
+ newest,
235
+ fs.statSync(path.join(dir, entry.name)).mtimeMs,
236
+ );
237
+ } catch {
238
+ // File vanished mid-scan — ignore.
239
+ }
240
+ }
241
+ return newest;
242
+ }
243
+
244
+ /**
245
+ * Whether the gateway should spawn the rest of the apps in the background
246
+ * after the default app boots. Lazy spawn already covers correctness — this
247
+ * is purely a UX optimization so the second/third/Nth app the user clicks
248
+ * into is already warm instead of paying the Vite + esbuild prebundle cost
249
+ * on demand.
250
+ *
251
+ * Defaults to ON in lazy mode. Off when the user passed --no-prewarm /
252
+ * WORKSPACE_NO_PREWARM=1, or when eager mode is already starting every app
253
+ * up front (in which case prewarm would be redundant).
254
+ */
255
+ export function shouldPrewarmWorkspaceApps(
256
+ args: string[] = [],
257
+ env: NodeJS.ProcessEnv = process.env,
258
+ ): boolean {
259
+ if (args.includes("--no-prewarm")) return false;
260
+ if (env.WORKSPACE_NO_PREWARM === "1" || env.WORKSPACE_NO_PREWARM === "true") {
261
+ return false;
262
+ }
263
+ // Eager mode starts every app immediately; prewarm has nothing to do.
264
+ if (shouldEagerStartWorkspaceApps(args, env)) return false;
265
+ return true;
266
+ }
267
+
268
+ /**
269
+ * How many apps to prewarm in parallel. Each Vite spawn briefly maxes out a
270
+ * CPU core during esbuild prebundling, so booting all 9 templates at once on
271
+ * a 4-core laptop just produces a thundering herd. Default 2 — gentle on
272
+ * laptops, fast enough that all apps finish within a few cold-spawn windows.
273
+ * Override via --prewarm-concurrency=N or WORKSPACE_PREWARM_CONCURRENCY=N.
274
+ */
275
+ export function workspacePrewarmConcurrency(
276
+ args: string[] = [],
277
+ env: NodeJS.ProcessEnv = process.env,
278
+ ): number {
279
+ const flag = args.find((arg) => arg.startsWith("--prewarm-concurrency="));
280
+ const raw = flag
281
+ ? flag.slice("--prewarm-concurrency=".length)
282
+ : env.WORKSPACE_PREWARM_CONCURRENCY;
283
+ if (!raw) return 2;
284
+ const parsed = Number(raw);
285
+ if (!Number.isFinite(parsed) || parsed < 1) return 2;
286
+ return Math.floor(parsed);
287
+ }
288
+
289
+ /**
290
+ * How long the prewarm queue waits after the gateway is ready before kicking
291
+ * off background spawns. Lets the default app's prebundle get first dibs on
292
+ * CPU. Override via WORKSPACE_PREWARM_DELAY_MS (mostly for tests).
293
+ */
294
+ export function workspacePrewarmDelayMs(
295
+ env: NodeJS.ProcessEnv = process.env,
296
+ ): number {
297
+ const raw = env.WORKSPACE_PREWARM_DELAY_MS;
298
+ if (raw === undefined) return 1_000;
299
+ const parsed = Number(raw);
300
+ if (!Number.isFinite(parsed) || parsed < 0) return 1_000;
301
+ return Math.floor(parsed);
302
+ }
303
+
304
+ function readBooleanEnv(value: string | undefined): boolean | undefined {
305
+ if (value === undefined) return undefined;
306
+ const normalized = value.trim().toLowerCase();
307
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
308
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
309
+ return undefined;
310
+ }
311
+
312
+ export type PollingFileWatcherMode =
313
+ | "enable"
314
+ | "disable-explicit"
315
+ | "disable-default";
316
+
317
+ /**
318
+ * Three-way classification of the polling-watcher decision so callers can
319
+ * tell apart "the user explicitly turned this off" (where we want to override
320
+ * any inherited chokidar/TSC env vars from the parent shell) from "we just
321
+ * didn't auto-detect a Builder/Codespaces/Gitpod container" (where the user's
322
+ * own watcher vars should pass through untouched).
323
+ */
324
+ export function pollingFileWatcherMode(
325
+ env: NodeJS.ProcessEnv = process.env,
326
+ root = process.cwd(),
327
+ ): PollingFileWatcherMode {
328
+ const explicit =
329
+ readBooleanEnv(env.AGENT_NATIVE_DEV_USE_POLLING) ??
330
+ readBooleanEnv(env.WORKSPACE_USE_POLLING_WATCHER);
331
+ if (explicit === true) return "enable";
332
+ if (explicit === false) return "disable-explicit";
333
+
334
+ const chokidarExplicit = readBooleanEnv(env.CHOKIDAR_USEPOLLING);
335
+ if (chokidarExplicit === true) return "enable";
336
+ if (chokidarExplicit === false) return "disable-explicit";
337
+
338
+ const autoEnable = Boolean(
339
+ env.BUILDER_IO_DEV_SERVER ||
340
+ env.BUILDER_PROJECT_ID ||
341
+ env.BUILDER_WORKSPACE_ID ||
342
+ env.CODESPACES ||
343
+ env.GITPOD_WORKSPACE_ID ||
344
+ env.REMOTE_CONTAINERS ||
345
+ env.DEVCONTAINER ||
346
+ root.startsWith("/root/app/"),
347
+ );
348
+ return autoEnable ? "enable" : "disable-default";
349
+ }
350
+
351
+ export function shouldUsePollingFileWatcher(
352
+ env: NodeJS.ProcessEnv = process.env,
353
+ root = process.cwd(),
354
+ ): boolean {
355
+ return pollingFileWatcherMode(env, root) === "enable";
356
+ }
357
+
358
+ /**
359
+ * Default child spawner. On Windows, Node >= 20.12 refuses to spawn the
360
+ * `pnpm` .cmd/.ps1 shims without a shell — the spawn fails ENOENT silently
361
+ * (no `error` listener existed), the app port never binds, and only the
362
+ * readiness timeout surfaced. When the gateway itself was launched by pnpm,
363
+ * `npm_execpath` points at pnpm's JS entry, so run that through the current
364
+ * Node executable instead. Everything else spawns as-is.
365
+ */
366
+ const windowsSafePnpmSpawn: typeof spawn = ((
367
+ command: string,
368
+ args: readonly string[],
369
+ spawnOptions: object,
370
+ ) => {
371
+ if (
372
+ process.platform === "win32" &&
373
+ command === "pnpm" &&
374
+ Array.isArray(args)
375
+ ) {
376
+ const execPath = process.env.npm_execpath;
377
+ if (execPath && /\.[cm]?js$/i.test(execPath)) {
378
+ return spawn(process.execPath, [execPath, ...args], spawnOptions);
379
+ }
380
+ }
381
+ return spawn(command, args as string[], spawnOptions);
382
+ }) as typeof spawn;
383
+
384
+ /**
385
+ * Kill an app child and its WHOLE process tree. `child.kill("SIGTERM")`
386
+ * reaches only the direct pnpm child on Windows; the vite grandchild keeps
387
+ * the app port bound, so the gateway respawned forever into
388
+ * "Port 810x is already in use" (blank apps, dropped sessions). taskkill /T
389
+ * fells the tree; POSIX keeps the plain signal.
390
+ */
391
+ function killAppProcessTree(child: ChildProcess | undefined): void {
392
+ if (!child) return;
393
+ if (process.platform === "win32" && child.pid) {
394
+ try {
395
+ execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
396
+ stdio: "ignore",
397
+ });
398
+ return;
399
+ } catch {
400
+ // taskkill unavailable or already exited — fall through to the signal.
401
+ }
402
+ }
403
+ child.kill("SIGTERM");
404
+ }
405
+
406
+ function devWatcherEnv(
407
+ env: NodeJS.ProcessEnv,
408
+ mode: PollingFileWatcherMode,
409
+ ): NodeJS.ProcessEnv {
410
+ if (mode === "enable") {
411
+ return {
412
+ ...env,
413
+ CHOKIDAR_USEPOLLING: "1",
414
+ CHOKIDAR_INTERVAL: env.CHOKIDAR_INTERVAL ?? POLLING_WATCH_INTERVAL_MS,
415
+ TSC_WATCHFILE: env.TSC_WATCHFILE ?? "DynamicPriorityPolling",
416
+ TSC_WATCHDIRECTORY: env.TSC_WATCHDIRECTORY ?? "DynamicPriorityPolling",
417
+ };
418
+ }
419
+ if (mode === "disable-explicit") {
420
+ // The user explicitly turned polling off (AGENT_NATIVE_DEV_USE_POLLING=0
421
+ // / WORKSPACE_USE_POLLING_WATCHER=0 / CHOKIDAR_USEPOLLING=0). Strip the
422
+ // watcher vars from the child env so an inherited parent-shell
423
+ // CHOKIDAR_USEPOLLING=1 (or stale TSC_WATCH* override) can't silently
424
+ // re-enable polling against the user's explicit wish.
425
+ const {
426
+ CHOKIDAR_USEPOLLING: _polling,
427
+ CHOKIDAR_INTERVAL: _interval,
428
+ TSC_WATCHFILE: _watchFile,
429
+ TSC_WATCHDIRECTORY: _watchDir,
430
+ ...rest
431
+ } = env;
432
+ return rest;
433
+ }
434
+ // mode === "disable-default": no explicit signal either way. Pass the env
435
+ // through unchanged so legitimate user overrides like
436
+ // TSC_WATCHFILE=UseFsEventsWithFallbackDynamicPolling survive.
437
+ return env;
438
+ }
439
+
440
+ export function initialWorkspaceAppIds(
441
+ apps: Array<Pick<WorkspaceApp, "id">>,
442
+ defaultApp: string,
443
+ eager: boolean,
444
+ startDefault = true,
445
+ ): string[] {
446
+ if (eager) return apps.map((app) => app.id);
447
+ if (!startDefault) return [];
448
+ return apps.some((app) => app.id === defaultApp) ? [defaultApp] : [];
449
+ }
450
+
451
+ function readJson(file: string): any {
452
+ try {
453
+ return JSON.parse(fs.readFileSync(file, "utf8"));
454
+ } catch {
455
+ return null;
456
+ }
457
+ }
458
+
459
+ function shouldReportDiscoverAppsReadFailure(
460
+ appsDir: string,
461
+ code: string | undefined,
462
+ ): boolean {
463
+ if (code === "ENOENT") return false;
464
+ const key = `${appsDir}:${code ?? "unknown"}`;
465
+ if (reportedDiscoverAppsReadFailures.has(key)) return false;
466
+ reportedDiscoverAppsReadFailures.add(key);
467
+ return true;
468
+ }
469
+
470
+ function shouldCaptureDiscoverAppsReadFailure(
471
+ code: string | undefined,
472
+ ): boolean {
473
+ return code !== "EACCES" && code !== "EPERM";
474
+ }
475
+
476
+ function discoverApps(appsDir: string, appPortStart: number): WorkspaceApp[] {
477
+ if (!fs.existsSync(appsDir)) return [];
478
+ // existsSync -> readdirSync is a TOCTOU race. Treat ENOENT as "no apps
479
+ // right now" and let the polling sync recover.
480
+ let entries: fs.Dirent[];
481
+ try {
482
+ entries = fs.readdirSync(appsDir, { withFileTypes: true });
483
+ } catch (err) {
484
+ const code = (err as NodeJS.ErrnoException).code;
485
+ if (shouldReportDiscoverAppsReadFailure(appsDir, code)) {
486
+ console.warn(
487
+ `[workspace] Could not read ${appsDir} (${code ?? "unknown"}): ` +
488
+ `${(err as Error).message}`,
489
+ );
490
+ if (shouldCaptureDiscoverAppsReadFailure(code)) {
491
+ Sentry.captureException(err, {
492
+ tags: { handled: "dev-discover-readdir" },
493
+ level: "warning",
494
+ });
495
+ }
496
+ }
497
+ return [];
498
+ }
499
+ return entries
500
+ .filter((entry) => entry.isDirectory())
501
+ .map((entry) => {
502
+ const dir = path.join(appsDir, entry.name);
503
+ const pkg = readJson(path.join(dir, "package.json"));
504
+ if (!pkg) return null;
505
+ const routeAccess = workspaceAppRouteAccessFromPackageJson(pkg);
506
+ return {
507
+ id: entry.name,
508
+ name: pkg.displayName || pkg.name || entry.name,
509
+ description: typeof pkg.description === "string" ? pkg.description : "",
510
+ audience:
511
+ workspaceAppAudienceFromPackageJson(pkg) ??
512
+ DEFAULT_WORKSPACE_APP_AUDIENCE,
513
+ publicPaths: routeAccess.publicPaths ?? [],
514
+ protectedPaths: routeAccess.protectedPaths ?? [],
515
+ dir,
516
+ port: appPortStart,
517
+ } satisfies WorkspaceApp;
518
+ })
519
+ .filter((app): app is WorkspaceApp => !!app)
520
+ .sort(compareApps)
521
+ .map((app, index) => ({ ...app, port: appPortStart + index }));
522
+ }
523
+
524
+ function compareApps(a: Pick<WorkspaceApp, "id">, b: Pick<WorkspaceApp, "id">) {
525
+ if (a.id === "dispatch") return -1;
526
+ if (b.id === "dispatch") return 1;
527
+ return a.id.localeCompare(b.id);
528
+ }
529
+
530
+ function isChildDevServerUrlLine(line: string): boolean {
531
+ return /^\s*->\s+(?:Local|Network):\s+https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):\d+(?:\/\S*)?\s*$/i.test(
532
+ line.replace(/\u279c/g, "->"),
533
+ );
534
+ }
535
+
536
+ function formatAppOutput(chunk: unknown): string {
537
+ const lines = String(chunk)
538
+ .split(/\r?\n/)
539
+ .filter(Boolean)
540
+ .filter((line) => !isChildDevServerUrlLine(line));
541
+ return lines.length === 0 ? "" : lines.join("\n") + "\n";
542
+ }
543
+
544
+ function appendAppOutputTail(app: WorkspaceApp, output: string): void {
545
+ if (!output) return;
546
+ const next = `${app.outputTail ?? ""}${output}`;
547
+ app.outputTail =
548
+ next.length > APP_OUTPUT_TAIL_BYTES
549
+ ? next.slice(-APP_OUTPUT_TAIL_BYTES)
550
+ : next;
551
+ }
552
+
553
+ function pipeAppOutput(
554
+ prefix: string,
555
+ chunk: unknown,
556
+ write: (value: string) => void,
557
+ ): string {
558
+ const output = formatAppOutput(chunk);
559
+ if (!output) return "";
560
+ const prefixed = output
561
+ .trimEnd()
562
+ .split(/\n/)
563
+ .map((line) => `${prefix} ${line}`)
564
+ .join("\n");
565
+ write(`${prefixed}\n`);
566
+ return output;
567
+ }
568
+
569
+ function firstPathSegment(url: string | undefined): string | null {
570
+ if (!url) return null;
571
+ try {
572
+ const parsed = new URL(url, "http://workspace.local");
573
+ const [segment] = parsed.pathname.split("/").filter(Boolean);
574
+ return segment || null;
575
+ } catch {
576
+ return null;
577
+ }
578
+ }
579
+
580
+ function appRestartDelay(attempts: number): number {
581
+ return Math.min(
582
+ 1_000 * 2 ** Math.max(0, attempts - 1),
583
+ APP_RESTART_MAX_DELAY_MS,
584
+ );
585
+ }
586
+
587
+ function formatProxyReadyTimeout(timeoutMs: number): string {
588
+ const seconds = timeoutMs / 1_000;
589
+ return Number.isInteger(seconds) ? `${seconds}s` : `${timeoutMs}ms`;
590
+ }
591
+
592
+ function probePort(port: number, timeoutMs = 1_000): Promise<boolean> {
593
+ return new Promise((resolve) => {
594
+ const socket = new net.Socket();
595
+ let settled = false;
596
+ const finish = (ok: boolean) => {
597
+ if (settled) return;
598
+ settled = true;
599
+ socket.destroy();
600
+ resolve(ok);
601
+ };
602
+ socket.setTimeout(timeoutMs);
603
+ socket.once("connect", () => finish(true));
604
+ socket.once("error", () => finish(false));
605
+ socket.once("timeout", () => finish(false));
606
+ socket.connect(port, "127.0.0.1");
607
+ });
608
+ }
609
+
610
+ function probeHttpReady(
611
+ app: Pick<WorkspaceApp, "id" | "port">,
612
+ timeoutMs = 1_000,
613
+ ): Promise<boolean> {
614
+ return new Promise((resolve) => {
615
+ let settled = false;
616
+ let req: http.ClientRequest;
617
+ const finish = (ok: boolean) => {
618
+ if (settled) return;
619
+ settled = true;
620
+ req.destroy();
621
+ resolve(ok);
622
+ };
623
+ req = http.request(
624
+ {
625
+ hostname: "127.0.0.1",
626
+ port: app.port,
627
+ method: "GET",
628
+ path: `/${app.id}`,
629
+ headers: {
630
+ accept: "text/html",
631
+ host: `127.0.0.1:${app.port}`,
632
+ },
633
+ },
634
+ (res) => {
635
+ res.resume();
636
+ finish(true);
637
+ },
638
+ );
639
+ req.setTimeout(timeoutMs, () => finish(false));
640
+ req.once("error", () => finish(false));
641
+ req.end();
642
+ });
643
+ }
644
+
645
+ function firstHeaderValue(
646
+ value: string | string[] | number | undefined,
647
+ ): string | undefined {
648
+ if (Array.isArray(value)) return value[0];
649
+ if (value === undefined) return undefined;
650
+ return String(value);
651
+ }
652
+
653
+ function wantsHtml(req: http.IncomingMessage): boolean {
654
+ if (req.method !== "GET" && req.method !== "HEAD") return false;
655
+ const accept = firstHeaderValue(req.headers.accept);
656
+ if (!accept) return false;
657
+ return accept.includes("text/html");
658
+ }
659
+
660
+ function renderStartingApp(app: WorkspaceApp): string {
661
+ const escapedName = escapeHtml(app.name || app.id);
662
+ const failure = app.lastFailure;
663
+ const retryDelayMs = failure
664
+ ? Math.max(1_000, failure.nextRetryAt - Date.now() + 250)
665
+ : 900;
666
+ const refreshSeconds = failure
667
+ ? Math.max(1, Math.ceil(retryDelayMs / 1_000))
668
+ : 1;
669
+ const refreshScriptDelay = failure ? retryDelayMs : 900;
670
+ const title = failure
671
+ ? `${failure.installing ? "Install failed" : "App failed to start"}: ${escapedName}`
672
+ : `Starting ${escapedName}`;
673
+ const message = failure
674
+ ? `The workspace gateway will retry in ${Math.max(
675
+ 1,
676
+ Math.ceil((failure.nextRetryAt - Date.now()) / 1_000),
677
+ )}s. Fix the error below or stop the server with Ctrl+C.`
678
+ : app.installing
679
+ ? "The workspace gateway is installing this app's dependencies before starting it."
680
+ : app.building
681
+ ? "The workspace gateway is building this app's production server before serving it."
682
+ : "The workspace gateway is waking this app's dev server.";
683
+ const failureOutput = failure?.output.trim();
684
+ return `<!doctype html>
685
+ <html>
686
+ <head>
687
+ <meta charset="utf-8" />
688
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
689
+ <meta http-equiv="refresh" content="${refreshSeconds}" />
690
+ <title>${title}</title>
691
+ <meta name="color-scheme" content="light dark" />
692
+ <style>
693
+ :root { --bg: #fafafa; --fg: #171717; --muted: #737373; --bar-bg: #e5e5e5; --bar-fill: #171717; --danger: #dc2626; --code-bg: #171717; --code-fg: #fafafa; }
694
+ @media (prefers-color-scheme: dark) { :root { --bg: #0a0a0a; --fg: #fafafa; --muted: #a3a3a3; --bar-bg: #262626; --bar-fill: #fafafa; --danger: #f87171; --code-bg: #171717; --code-fg: #f5f5f5; } }
695
+ body { min-height: 100vh; margin: 0; display: grid; place-items: center; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--fg); }
696
+ main { width: min(680px, calc(100vw - 48px)); }
697
+ .bar { height: 3px; overflow: hidden; border-radius: 999px; background: var(--bar-bg); }
698
+ .bar::before { content: ""; display: block; height: 100%; width: 42%; border-radius: inherit; background: var(--bar-fill); animation: load 1s ease-in-out infinite; }
699
+ main.failed .bar::before { width: 100%; background: var(--danger); animation: none; }
700
+ p { color: var(--muted); }
701
+ pre { max-height: min(46vh, 360px); overflow: auto; margin-top: 20px; padding: 14px 16px; border-radius: 8px; background: var(--code-bg); color: var(--code-fg); font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; white-space: pre-wrap; word-break: break-word; }
702
+ @keyframes load { 0% { transform: translateX(-105%); } 100% { transform: translateX(245%); } }
703
+ </style>
704
+ <script>
705
+ (() => {
706
+ const reload = () => window.location.reload();
707
+ setTimeout(reload, ${JSON.stringify(refreshScriptDelay)});
708
+ setInterval(reload, ${JSON.stringify(Math.max(refreshScriptDelay, 3_000))});
709
+ })();
710
+ </script>
711
+ </head>
712
+ <body>
713
+ <main class="${failure ? "failed" : ""}">
714
+ <div class="bar"></div>
715
+ <h1>${title}</h1>
716
+ <p>${escapeHtml(message)}</p>
717
+ ${failureOutput ? `<pre>${escapeHtml(failureOutput)}</pre>` : ""}
718
+ </main>
719
+ </body>
720
+ </html>`;
721
+ }
722
+
723
+ function renderIndex(apps: WorkspaceApp[]): string {
724
+ return `<!doctype html>
725
+ <html>
726
+ <head>
727
+ <meta charset="utf-8" />
728
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
729
+ <title>Agent-Native Workspace</title>
730
+ <meta name="color-scheme" content="light dark" />
731
+ <style>
732
+ :root { --bg: #fafafa; --fg: #171717; --muted: #737373; --card-bg: #ffffff; --card-border: #d4d4d4; }
733
+ @media (prefers-color-scheme: dark) { :root { --bg: #0a0a0a; --fg: #fafafa; --muted: #a3a3a3; --card-bg: #171717; --card-border: #262626; } }
734
+ body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; padding: 32px; background: var(--bg); color: var(--fg); }
735
+ main { max-width: 760px; margin: 0 auto; }
736
+ a { color: inherit; text-decoration: none; }
737
+ .grid { display: grid; gap: 12px; margin-top: 20px; }
738
+ .card { display: flex; justify-content: space-between; border: 1px solid var(--card-border); border-radius: 8px; padding: 14px 16px; background: var(--card-bg); }
739
+ .muted { color: var(--muted); }
740
+ </style>
741
+ </head>
742
+ <body>
743
+ <main>
744
+ <h1>Agent-Native Workspace</h1>
745
+ <p class="muted">Open an app below. Dispatch is the workspace control plane when installed.</p>
746
+ <div class="grid">
747
+ ${apps
748
+ .map(
749
+ (app) =>
750
+ `<a class="card" href="/${app.id}"><strong>${escapeHtml(app.name)}</strong><span class="muted">/${escapeHtml(app.id)}</span></a>`,
751
+ )
752
+ .join("")}
753
+ </div>
754
+ </main>
755
+ </body>
756
+ </html>`;
757
+ }
758
+
759
+ function hasLocalBin(dir: string, command: string): boolean {
760
+ const binDir = path.join(dir, "node_modules", ".bin");
761
+ return (
762
+ fs.existsSync(path.join(binDir, command)) ||
763
+ fs.existsSync(path.join(binDir, `${command}.cmd`)) ||
764
+ fs.existsSync(path.join(binDir, `${command}.ps1`))
765
+ );
766
+ }
767
+
768
+ export async function runWorkspaceDev(
769
+ options: WorkspaceDevOptions = {},
770
+ ): Promise<WorkspaceDevHandle> {
771
+ const args = options.args ?? process.argv.slice(2);
772
+ const env = options.env ?? process.env;
773
+ const root = options.root ?? process.cwd();
774
+ const appsDir = path.join(root, "apps");
775
+ const spawnProcess = options.spawnProcess ?? windowsSafePnpmSpawn;
776
+ const stdout = options.stdout ?? process.stdout;
777
+ const stderr = options.stderr ?? process.stderr;
778
+
779
+ fs.mkdirSync(path.join(root, "data"), { recursive: true });
780
+ const gatewayHost = env.WORKSPACE_HOST || DEFAULT_GATEWAY_HOST;
781
+ const requestedPort = Number(
782
+ env.WORKSPACE_PORT || env.PORT || DEFAULT_GATEWAY_PORT,
783
+ );
784
+ const appPortStart = Number(
785
+ env.WORKSPACE_APP_PORT_START || DEFAULT_APP_PORT_START,
786
+ );
787
+ const forceVite = env.WORKSPACE_VITE_FORCE === "1";
788
+ const eager = shouldEagerStartWorkspaceApps(args, env);
789
+ const builtMode = shouldRunBuiltWorkspaceApps(args, env);
790
+ const pollingMode = pollingFileWatcherMode(env, root);
791
+ const usePollingFileWatcher = pollingMode === "enable";
792
+ const proxyReadyTimeoutMs = Number(
793
+ env.WORKSPACE_PROXY_READY_TIMEOUT_MS ?? 30_000,
794
+ );
795
+ const proxyResponseTimeoutMs = Number(
796
+ env.WORKSPACE_PROXY_RESPONSE_TIMEOUT_MS ??
797
+ DEFAULT_PROXY_RESPONSE_TIMEOUT_MS,
798
+ );
799
+ const proxyNonHtmlResponseTimeoutMs = Number(
800
+ env.WORKSPACE_PROXY_NON_HTML_RESPONSE_TIMEOUT_MS ??
801
+ env.WORKSPACE_PROXY_RESPONSE_TIMEOUT_MS ??
802
+ DEFAULT_PROXY_NON_HTML_RESPONSE_TIMEOUT_MS,
803
+ );
804
+ let gatewayUrl = `http://${gatewayHost}:${requestedPort}`;
805
+
806
+ const apps = discoverApps(appsDir, appPortStart);
807
+ if (apps.length === 0) {
808
+ throw new Error("[workspace] No apps found under ./apps");
809
+ }
810
+
811
+ // Probe each app's proposed port to see if something else on the host
812
+ // already owns it. Vite is spawned with `--strictPort` (so the gateway can
813
+ // route /<appId> to a known port), which fails hard on EADDRINUSE — without
814
+ // this probe a single conflicting process on 8100/8101/... kills the
815
+ // workspace before the dev server prints anything useful.
816
+ function probePortAvailable(port: number): Promise<boolean> {
817
+ return new Promise((resolve) => {
818
+ const probe = net.createServer();
819
+ probe.once("error", () => resolve(false));
820
+ probe.once("listening", () => {
821
+ probe.close(() => resolve(true));
822
+ });
823
+ probe.listen(port, gatewayHost);
824
+ });
825
+ }
826
+
827
+ async function reserveAppPort(
828
+ start: number,
829
+ excluded: Set<number>,
830
+ ): Promise<number> {
831
+ for (let port = start; port < start + 100; port++) {
832
+ if (excluded.has(port)) continue;
833
+ if (port === requestedPort) continue;
834
+ if (await probePortAvailable(port)) return port;
835
+ }
836
+ throw new Error(
837
+ `[workspace] No available port found between ${start} and ${start + 100}`,
838
+ );
839
+ }
840
+
841
+ const reservedAppPorts = new Set<number>();
842
+ for (const app of apps) {
843
+ const original = app.port;
844
+ const port = await reserveAppPort(original, reservedAppPorts);
845
+ if (port !== original) {
846
+ stdout.write(
847
+ `[workspace] Port ${original} unavailable for /${app.id}, using ${port}\n`,
848
+ );
849
+ }
850
+ app.port = port;
851
+ reservedAppPorts.add(port);
852
+ }
853
+
854
+ const appById = new Map(apps.map((app) => [app.id, app]));
855
+ const explicitDefaultApp =
856
+ env.WORKSPACE_DEFAULT_APP && appById.has(env.WORKSPACE_DEFAULT_APP)
857
+ ? env.WORKSPACE_DEFAULT_APP
858
+ : null;
859
+ const hasDispatch = appById.has("dispatch");
860
+ const defaultApp =
861
+ explicitDefaultApp ?? (hasDispatch ? "dispatch" : apps[0].id);
862
+ const redirectRootToDefault = Boolean(explicitDefaultApp || hasDispatch);
863
+
864
+ let syncTimer: NodeJS.Timeout | undefined;
865
+ let shuttingDown = false;
866
+ let workspaceStarted = false;
867
+
868
+ if (usePollingFileWatcher) {
869
+ stdout.write(
870
+ `[workspace] Using polling file watchers (${POLLING_WATCH_INTERVAL_MS}ms) to avoid remote-container inotify limits.\n`,
871
+ );
872
+ }
873
+
874
+ let readyResolve: (value: { port: number; url: string }) => void;
875
+ const ready = new Promise<{ port: number; url: string }>((resolve) => {
876
+ readyResolve = resolve;
877
+ });
878
+
879
+ function workspaceAppsJson(): string {
880
+ return JSON.stringify(
881
+ apps.map((workspaceApp) => ({
882
+ id: workspaceApp.id,
883
+ name: workspaceApp.name,
884
+ description: workspaceApp.description,
885
+ path: `/${workspaceApp.id}`,
886
+ audience: workspaceApp.audience,
887
+ publicPaths: workspaceApp.publicPaths,
888
+ protectedPaths: workspaceApp.protectedPaths,
889
+ })),
890
+ );
891
+ }
892
+
893
+ async function syncApps(): Promise<void> {
894
+ const discovered = discoverApps(appsDir, appPortStart);
895
+ for (const app of discovered) {
896
+ const existing = appById.get(app.id);
897
+ if (existing) {
898
+ existing.name = app.name;
899
+ existing.description = app.description;
900
+ existing.audience = app.audience;
901
+ existing.publicPaths = app.publicPaths;
902
+ existing.protectedPaths = app.protectedPaths;
903
+ existing.dir = app.dir;
904
+ continue;
905
+ }
906
+ const usedPorts = new Set(apps.map((existingApp) => existingApp.port));
907
+ const port = await reserveAppPort(appPortStart, usedPorts);
908
+ reservedAppPorts.add(port);
909
+ const next = { ...app, port };
910
+ apps.push(next);
911
+ apps.sort(compareApps);
912
+ appById.set(next.id, next);
913
+ stdout.write(`[workspace] Detected new app: /${next.id}\n`);
914
+ }
915
+ }
916
+
917
+ function scheduleSync(): void {
918
+ if (syncTimer) clearTimeout(syncTimer);
919
+ syncTimer = setTimeout(() => {
920
+ void syncApps().catch((err) => {
921
+ stderr.write(
922
+ `[workspace] App sync failed: ${err instanceof Error ? err.message : String(err)}\n`,
923
+ );
924
+ });
925
+ }, 400);
926
+ }
927
+
928
+ function appForRequest(req: http.IncomingMessage): WorkspaceApp | null {
929
+ const params = new URL(req.url || "/", "http://workspace.local")
930
+ .searchParams;
931
+ const explicit = params.get("_app");
932
+ if (explicit && appById.has(explicit)) return appById.get(explicit) ?? null;
933
+
934
+ const direct = firstPathSegment(req.url);
935
+ if (direct && appById.has(direct)) return appById.get(direct) ?? null;
936
+
937
+ const fromState = extractOAuthStateAppId(params.get("state"));
938
+ if (fromState && appById.has(fromState)) {
939
+ return appById.get(fromState) ?? null;
940
+ }
941
+
942
+ const referer = req.headers.referer;
943
+ const fromReferer =
944
+ typeof referer === "string" ? firstPathSegment(referer) : null;
945
+ return fromReferer && appById.has(fromReferer)
946
+ ? (appById.get(fromReferer) ?? null)
947
+ : null;
948
+ }
949
+
950
+ function startApp(app: WorkspaceApp): void {
951
+ if (app.process && !app.process.killed) return;
952
+ if (app.restartTimer) return;
953
+ app.lastFailure = undefined;
954
+ app.outputTail = undefined;
955
+
956
+ const basePath = `/${app.id}`;
957
+ const shouldInstall =
958
+ !app.installAttempted && !hasLocalBin(app.dir, "vite");
959
+ const builtEntry = builtWorkspaceAppServerEntry(app.dir);
960
+ let shouldBuild = false;
961
+ let runBuilt = false;
962
+ if (builtMode && !shouldInstall && !app.devMode) {
963
+ let hasFreshBuild = false;
964
+ if (fs.existsSync(builtEntry)) {
965
+ if (app.buildChecked) {
966
+ hasFreshBuild = true;
967
+ } else {
968
+ app.buildChecked = true;
969
+ try {
970
+ hasFreshBuild =
971
+ newestBuiltModeSourceMtimeMs(app.dir) <=
972
+ fs.statSync(builtEntry).mtimeMs;
973
+ } catch {
974
+ hasFreshBuild = false;
975
+ }
976
+ if (!hasFreshBuild) {
977
+ stdout.write(
978
+ `[workspace] /${app.id}: production build is stale \u2014 rebuilding before serving\n`,
979
+ );
980
+ }
981
+ }
982
+ }
983
+ if (hasFreshBuild) {
984
+ runBuilt = true;
985
+ } else if (!app.buildAttempted) {
986
+ shouldBuild = true;
987
+ } else {
988
+ app.devMode = true;
989
+ stdout.write(
990
+ `[workspace] /${app.id}: no usable production build \u2014 falling back to the vite dev server\n`,
991
+ );
992
+ }
993
+ }
994
+
995
+ let spawnCommand = "pnpm";
996
+ let childArgs: string[];
997
+ if (shouldInstall) {
998
+ childArgs = [
999
+ "--dir",
1000
+ root,
1001
+ "install",
1002
+ "--no-frozen-lockfile",
1003
+ "--prefer-offline",
1004
+ ];
1005
+ } else if (shouldBuild) {
1006
+ childArgs = ["--dir", app.dir, "run", "build"];
1007
+ } else if (runBuilt) {
1008
+ spawnCommand = process.execPath;
1009
+ childArgs = [builtEntry];
1010
+ } else {
1011
+ childArgs = [
1012
+ "--dir",
1013
+ app.dir,
1014
+ "exec",
1015
+ "vite",
1016
+ "--host",
1017
+ "127.0.0.1",
1018
+ "--port",
1019
+ String(app.port),
1020
+ "--strictPort",
1021
+ ...(forceVite ? ["--force"] : []),
1022
+ ];
1023
+ }
1024
+
1025
+ if (shouldInstall) {
1026
+ stdout.write(
1027
+ `[workspace] Installing dependencies before starting /${app.id}\n`,
1028
+ );
1029
+ } else if (shouldBuild) {
1030
+ stdout.write(
1031
+ `[workspace] Building /${app.id} for built-mode serving (one-time; subsequent boots are instant)\n`,
1032
+ );
1033
+ } else if (runBuilt) {
1034
+ stdout.write(`[workspace] /${app.id}: serving prebuilt server\n`);
1035
+ }
1036
+
1037
+ const child = spawnProcess(spawnCommand, childArgs, {
1038
+ cwd: root,
1039
+ stdio: ["ignore", "pipe", "pipe"],
1040
+ env: devWatcherEnv(
1041
+ {
1042
+ ...env,
1043
+ APP_NAME: app.id,
1044
+ AGENT_NATIVE_WORKSPACE: "1",
1045
+ AGENT_NATIVE_WORKSPACE_APP_ID: app.id,
1046
+ AGENT_NATIVE_WORKSPACE_APPS_JSON: workspaceAppsJson(),
1047
+ AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: app.audience,
1048
+ AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: JSON.stringify(
1049
+ app.publicPaths,
1050
+ ),
1051
+ AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: JSON.stringify(
1052
+ app.protectedPaths,
1053
+ ),
1054
+ APP_BASE_PATH: basePath,
1055
+ VITE_AGENT_NATIVE_WORKSPACE: "1",
1056
+ VITE_AGENT_NATIVE_WORKSPACE_APP_ID: app.id,
1057
+ VITE_AGENT_NATIVE_WORKSPACE_APPS_JSON: workspaceAppsJson(),
1058
+ VITE_AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: app.audience,
1059
+ VITE_AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: JSON.stringify(
1060
+ app.publicPaths,
1061
+ ),
1062
+ VITE_AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: JSON.stringify(
1063
+ app.protectedPaths,
1064
+ ),
1065
+ VITE_APP_BASE_PATH: basePath,
1066
+ VITE_WORKSPACE_OAUTH_ORIGIN: workspaceOAuthOrigin(env, gatewayUrl),
1067
+ VITE_WORKSPACE_GATEWAY_URL: gatewayUrl,
1068
+ PORT: String(app.port),
1069
+ WORKSPACE_GATEWAY_URL: gatewayUrl,
1070
+ ...(runBuilt ? { HOST: "127.0.0.1" } : {}),
1071
+ },
1072
+ pollingMode,
1073
+ ),
1074
+ });
1075
+ app.process = child;
1076
+ app.installing = shouldInstall;
1077
+ app.building = shouldBuild;
1078
+ if (runBuilt) attachPromoteWatcher(app);
1079
+
1080
+ const prefix = `[${app.id}]`;
1081
+ const stableTimer = setTimeout(() => {
1082
+ app.restartAttempts = 0;
1083
+ }, 5_000);
1084
+ stableTimer.unref();
1085
+
1086
+ child.stdout?.on("data", (chunk) => {
1087
+ appendAppOutputTail(
1088
+ app,
1089
+ pipeAppOutput(prefix, chunk, (value) => stdout.write(value)),
1090
+ );
1091
+ });
1092
+ child.stderr?.on("data", (chunk) => {
1093
+ appendAppOutputTail(
1094
+ app,
1095
+ pipeAppOutput(prefix, chunk, (value) => stderr.write(value)),
1096
+ );
1097
+ });
1098
+ child.on("error", (error) => {
1099
+ // Without this listener a failed spawn (e.g. the Windows pnpm-shim
1100
+ // ENOENT) was SILENT — no exit event fires, the port never binds, and
1101
+ // only the readiness timeout showed. Surface it and enter the normal
1102
+ // retry path.
1103
+ clearTimeout(stableTimer);
1104
+ const wasInstalling = app.installing;
1105
+ app.process = undefined;
1106
+ app.installing = false;
1107
+ app.building = false;
1108
+ app.ready = false;
1109
+ app.readinessProbe = undefined;
1110
+ if (app.restartTimer || shuttingDown) return;
1111
+ if (wasInstalling) app.installAttempted = false;
1112
+ scheduleAppRestart(app, {
1113
+ code: null,
1114
+ signal: null,
1115
+ installing: wasInstalling,
1116
+ output: String(error),
1117
+ logMessage: `failed to spawn child process: ${
1118
+ error instanceof Error ? error.message : String(error)
1119
+ }`,
1120
+ });
1121
+ });
1122
+ child.on("exit", (code, signal) => {
1123
+ clearTimeout(stableTimer);
1124
+ const wasInstalling = app.installing;
1125
+ const wasBuilding = app.building;
1126
+ app.process = undefined;
1127
+ app.installing = false;
1128
+ app.building = false;
1129
+ app.ready = false;
1130
+ app.readinessProbe = undefined;
1131
+ if (app.expectedExit) {
1132
+ // Intentional kill (promotion to the vite dev server). Respawn
1133
+ // immediately instead of treating it as a crash.
1134
+ app.expectedExit = false;
1135
+ if (!shuttingDown && !app.restartTimer) startApp(app);
1136
+ return;
1137
+ }
1138
+ if (app.restartTimer) return;
1139
+ if (code === 0 || shuttingDown) {
1140
+ if (code === 0 && !shuttingDown) {
1141
+ if (wasInstalling) {
1142
+ app.installAttempted = true;
1143
+ startApp(app);
1144
+ } else if (wasBuilding) {
1145
+ app.buildAttempted = true;
1146
+ startApp(app);
1147
+ }
1148
+ }
1149
+ return;
1150
+ }
1151
+ if (wasInstalling) app.installAttempted = false;
1152
+ if (wasBuilding) {
1153
+ // A failed production build must never brick the app — fall back to
1154
+ // the vite dev server, which surfaces the error with hot reload.
1155
+ app.devMode = true;
1156
+ stderr.write(
1157
+ `[${app.id}] production build failed (exit ${code}); falling back to the vite dev server\n`,
1158
+ );
1159
+ startApp(app);
1160
+ return;
1161
+ }
1162
+ scheduleAppRestart(app, {
1163
+ code,
1164
+ signal,
1165
+ installing: wasInstalling,
1166
+ output: app.outputTail ?? "",
1167
+ logMessage: `exited with code ${code}`,
1168
+ });
1169
+ });
1170
+ }
1171
+
1172
+ /**
1173
+ * Built-mode promotion: the moment one of an app's source files changes,
1174
+ * swap its prebuilt server for the vite dev server on the same port. The
1175
+ * proxy doesn't care what listens upstream, so the swap is invisible apart
1176
+ * from a brief wake page. Sticky for the rest of the session.
1177
+ */
1178
+ function promoteAppToDevServer(app: WorkspaceApp, changedPath: string): void {
1179
+ if (app.devMode || shuttingDown) return;
1180
+ app.devMode = true;
1181
+ app.promoteWatcher?.close();
1182
+ app.promoteWatcher = undefined;
1183
+ stdout.write(
1184
+ `[workspace] /${app.id}: source changed (${changedPath}) \u2014 promoting to the vite dev server with hot reload\n`,
1185
+ );
1186
+ app.ready = false;
1187
+ app.readinessProbe = undefined;
1188
+ if (app.process && !app.process.killed) {
1189
+ app.expectedExit = true;
1190
+ killAppProcessTree(app.process);
1191
+ return;
1192
+ }
1193
+ startApp(app);
1194
+ }
1195
+
1196
+ function attachPromoteWatcher(app: WorkspaceApp): void {
1197
+ if (app.promoteWatcher || app.devMode) return;
1198
+ try {
1199
+ const watcher = fs.watch(
1200
+ app.dir,
1201
+ { recursive: true },
1202
+ (_event, filename) => {
1203
+ const rel = filename ? String(filename) : "";
1204
+ if (!isBuiltModeSourcePath(rel)) return;
1205
+ promoteAppToDevServer(app, rel);
1206
+ },
1207
+ );
1208
+ watcher.on("error", (err) => {
1209
+ handleWatcherError(err as NodeJS.ErrnoException);
1210
+ });
1211
+ app.promoteWatcher = watcher;
1212
+ } catch (err) {
1213
+ handleWatcherError(err as NodeJS.ErrnoException);
1214
+ }
1215
+ }
1216
+
1217
+ function scheduleAppRestart(
1218
+ app: WorkspaceApp,
1219
+ input: {
1220
+ code: number | null;
1221
+ signal: NodeJS.Signals | null;
1222
+ installing: boolean;
1223
+ output: string;
1224
+ logMessage: string;
1225
+ },
1226
+ ): void {
1227
+ if (shuttingDown || app.restartTimer) return;
1228
+ if (input.installing) app.installAttempted = false;
1229
+ app.restartAttempts = (app.restartAttempts ?? 0) + 1;
1230
+ const delay = appRestartDelay(app.restartAttempts);
1231
+ const nextRetryAt = Date.now() + delay;
1232
+ app.lastFailure = {
1233
+ code: input.code,
1234
+ signal: input.signal,
1235
+ at: Date.now(),
1236
+ installing: input.installing,
1237
+ output: input.output,
1238
+ nextRetryAt,
1239
+ };
1240
+ stderr.write(
1241
+ `[${app.id}] ${input.logMessage}; retrying in ${Math.round(
1242
+ delay / 1000,
1243
+ )}s\n`,
1244
+ );
1245
+ app.restartTimer = setTimeout(() => {
1246
+ app.restartTimer = undefined;
1247
+ startApp(app);
1248
+ }, delay);
1249
+ app.restartTimer.unref();
1250
+ }
1251
+
1252
+ function failAppStartupTimeout(app: WorkspaceApp): void {
1253
+ if (app.installing || app.building || app.ready || app.restartTimer) {
1254
+ return;
1255
+ }
1256
+ const timeout = formatProxyReadyTimeout(proxyReadyTimeoutMs);
1257
+ const message =
1258
+ `Timed out waiting ${timeout} for /${app.id} to return ` +
1259
+ `an HTTP response on 127.0.0.1:${app.port}.`;
1260
+ const output = [message, app.outputTail?.trim()]
1261
+ .filter(Boolean)
1262
+ .join("\n\nLast child output:\n");
1263
+ app.ready = false;
1264
+ app.readinessProbe = undefined;
1265
+ scheduleAppRestart(app, {
1266
+ code: null,
1267
+ signal: null,
1268
+ installing: false,
1269
+ output,
1270
+ logMessage: message,
1271
+ });
1272
+ killAppProcessTree(app.process);
1273
+ }
1274
+
1275
+ function forwardedProto(req: http.IncomingMessage): string {
1276
+ return (
1277
+ firstHeaderValue(req.headers["x-forwarded-proto"]) ||
1278
+ ((req.socket as { encrypted?: boolean }).encrypted ? "https" : "http")
1279
+ );
1280
+ }
1281
+
1282
+ function forwardedHost(req: http.IncomingMessage): string {
1283
+ return (
1284
+ firstHeaderValue(req.headers["x-forwarded-host"]) ||
1285
+ firstHeaderValue(req.headers.host) ||
1286
+ new URL(gatewayUrl).host
1287
+ );
1288
+ }
1289
+
1290
+ function proxyHeaders(
1291
+ req: http.IncomingMessage,
1292
+ targetHost: string,
1293
+ ): http.OutgoingHttpHeaders {
1294
+ return {
1295
+ ...req.headers,
1296
+ "x-forwarded-host": forwardedHost(req),
1297
+ "x-forwarded-proto": forwardedProto(req),
1298
+ host: targetHost,
1299
+ };
1300
+ }
1301
+
1302
+ async function waitForPort(port: number, deadline: number): Promise<boolean> {
1303
+ while (Date.now() < deadline) {
1304
+ if (await probePort(port)) return true;
1305
+ await new Promise((r) => setTimeout(r, PROXY_READY_RETRY_DELAY_MS));
1306
+ }
1307
+ return false;
1308
+ }
1309
+
1310
+ async function waitForHttpReady(
1311
+ app: WorkspaceApp,
1312
+ deadline: number,
1313
+ ): Promise<boolean> {
1314
+ while (Date.now() < deadline) {
1315
+ const timeoutMs = Math.min(1_000, Math.max(1, deadline - Date.now()));
1316
+ if (await probeHttpReady(app, timeoutMs)) return true;
1317
+ await new Promise((r) => setTimeout(r, PROXY_READY_RETRY_DELAY_MS));
1318
+ }
1319
+ return false;
1320
+ }
1321
+
1322
+ function ensureReadinessProbe(app: WorkspaceApp): void {
1323
+ if (app.ready || app.readinessProbe || app.installing || app.building) {
1324
+ return;
1325
+ }
1326
+ app.readinessProbe = waitForHttpReady(app, Date.now() + proxyReadyTimeoutMs)
1327
+ .then((ready) => {
1328
+ if (ready) {
1329
+ app.ready = true;
1330
+ return;
1331
+ }
1332
+ failAppStartupTimeout(app);
1333
+ })
1334
+ .finally(() => {
1335
+ app.readinessProbe = undefined;
1336
+ });
1337
+ }
1338
+
1339
+ function proxyHttp(
1340
+ app: WorkspaceApp,
1341
+ req: http.IncomingMessage,
1342
+ res: http.ServerResponse,
1343
+ ): void {
1344
+ const cold = !app.process || app.process.killed;
1345
+ startApp(app);
1346
+
1347
+ if (!app.ready && wantsHtml(req)) {
1348
+ ensureReadinessProbe(app);
1349
+ res.writeHead(200, STARTING_APP_RESPONSE_HEADERS);
1350
+ if (req.method === "HEAD") {
1351
+ res.end();
1352
+ return;
1353
+ }
1354
+ res.end(renderStartingApp(app));
1355
+ return;
1356
+ }
1357
+
1358
+ const serveStartingPage = () => {
1359
+ res.writeHead(200, STARTING_APP_RESPONSE_HEADERS);
1360
+ if (req.method === "HEAD") {
1361
+ res.end();
1362
+ return;
1363
+ }
1364
+ res.end(renderStartingApp(app));
1365
+ };
1366
+
1367
+ const dispatch = () => {
1368
+ const headers = proxyHeaders(req, `127.0.0.1:${app.port}`);
1369
+ let settled = false;
1370
+ let responseTimer: NodeJS.Timeout;
1371
+ const responseTimeoutMs = wantsHtml(req)
1372
+ ? proxyResponseTimeoutMs
1373
+ : proxyNonHtmlResponseTimeoutMs;
1374
+ const proxyReq = http.request(
1375
+ {
1376
+ hostname: "127.0.0.1",
1377
+ port: app.port,
1378
+ method: req.method,
1379
+ path: req.url,
1380
+ headers,
1381
+ },
1382
+ (proxyRes) => {
1383
+ if (settled) {
1384
+ proxyRes.resume();
1385
+ return;
1386
+ }
1387
+ settled = true;
1388
+ clearTimeout(responseTimer);
1389
+ app.ready = true;
1390
+ const statusCode = proxyRes.statusCode ?? 502;
1391
+ const responseHeaders = { ...proxyRes.headers };
1392
+ if (statusCode >= 300 && statusCode < 400) {
1393
+ const rewritten = rewriteRedirectLocation(
1394
+ app,
1395
+ firstHeaderValue(responseHeaders.location),
1396
+ );
1397
+ if (rewritten) responseHeaders.location = rewritten;
1398
+ }
1399
+ res.writeHead(statusCode, responseHeaders);
1400
+ proxyRes.once("error", () => {
1401
+ if (!res.destroyed) res.destroy();
1402
+ });
1403
+ proxyRes.pipe(res);
1404
+ },
1405
+ );
1406
+ proxyReq.once("socket", (socket) => {
1407
+ attachGatewaySocketErrorSink(socket);
1408
+ });
1409
+ res.once("error", () => {
1410
+ proxyReq.destroy();
1411
+ });
1412
+ responseTimer = setTimeout(() => {
1413
+ if (settled) return;
1414
+ settled = true;
1415
+ app.ready = false;
1416
+ proxyReq.destroy();
1417
+ ensureReadinessProbe(app);
1418
+ if (res.headersSent) {
1419
+ res.end();
1420
+ return;
1421
+ }
1422
+ if (wantsHtml(req)) {
1423
+ serveStartingPage();
1424
+ return;
1425
+ }
1426
+ res.writeHead(504, { "content-type": "text/plain" });
1427
+ res.end(
1428
+ `App "${app.id}" did not return response headers within ${formatProxyReadyTimeout(responseTimeoutMs)}.`,
1429
+ );
1430
+ }, responseTimeoutMs);
1431
+ responseTimer.unref();
1432
+
1433
+ proxyReq.on("error", (err) => {
1434
+ clearTimeout(responseTimer);
1435
+ if (settled) return;
1436
+ settled = true;
1437
+ if (res.headersSent) {
1438
+ res.end();
1439
+ return;
1440
+ }
1441
+ res.writeHead(502, { "content-type": "text/plain" });
1442
+ res.end(`App "${app.id}" is not ready yet: ${err.message}`);
1443
+ });
1444
+
1445
+ req.pipe(proxyReq);
1446
+ };
1447
+
1448
+ // Fast path: the upstream has accepted at least one request before, so
1449
+ // it's listening. Skip the probe so steady-state requests stay zero-latency.
1450
+ if (app.ready && !cold) {
1451
+ dispatch();
1452
+ return;
1453
+ }
1454
+
1455
+ // Cold path: hold non-HTML requests open while the child server boots.
1456
+ // Node keeps the request body paused until pipe() attaches.
1457
+ void waitForHttpReady(app, Date.now() + proxyReadyTimeoutMs).then(
1458
+ (ready) => {
1459
+ if (!ready) {
1460
+ failAppStartupTimeout(app);
1461
+ if (!res.headersSent) {
1462
+ res.writeHead(502, { "content-type": "text/plain" });
1463
+ res.end(
1464
+ `App "${app.id}" is not ready yet: no HTTP response from 127.0.0.1:${app.port}`,
1465
+ );
1466
+ } else {
1467
+ res.end();
1468
+ }
1469
+ return;
1470
+ }
1471
+ app.ready = true;
1472
+ dispatch();
1473
+ },
1474
+ );
1475
+ }
1476
+
1477
+ function proxyUpgrade(
1478
+ app: WorkspaceApp,
1479
+ req: http.IncomingMessage,
1480
+ socket: Duplex,
1481
+ head: Buffer,
1482
+ ): void {
1483
+ startApp(app);
1484
+ let target: net.Socket | undefined;
1485
+ attachGatewaySocketErrorSink(socket, () => {
1486
+ target?.destroy();
1487
+ });
1488
+ socket.once("close", () => {
1489
+ target?.destroy();
1490
+ });
1491
+ void waitForPort(app.port, Date.now() + proxyReadyTimeoutMs).then(
1492
+ (ready) => {
1493
+ if (!ready) {
1494
+ failAppStartupTimeout(app);
1495
+ socket.destroy();
1496
+ return;
1497
+ }
1498
+ if (socket.destroyed) return;
1499
+ app.ready = true;
1500
+ const upstream = net.connect(app.port, "127.0.0.1", () => {
1501
+ const headers = Object.entries(
1502
+ proxyHeaders(req, `127.0.0.1:${app.port}`),
1503
+ )
1504
+ .flatMap(([key, value]) =>
1505
+ Array.isArray(value)
1506
+ ? value.map((item) => `${key}: ${item}`)
1507
+ : [`${key}: ${value ?? ""}`],
1508
+ )
1509
+ .join("\r\n");
1510
+ upstream.write(
1511
+ `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n${headers}\r\n\r\n`,
1512
+ );
1513
+ if (head.length) upstream.write(head);
1514
+ socket.pipe(upstream).pipe(socket);
1515
+ });
1516
+ target = upstream;
1517
+
1518
+ attachGatewaySocketErrorSink(upstream, () => {
1519
+ if (!socket.destroyed) socket.destroy();
1520
+ });
1521
+ upstream.once("close", () => {
1522
+ if (!socket.destroyed) socket.destroy();
1523
+ });
1524
+ },
1525
+ );
1526
+ }
1527
+
1528
+ function handleWatcherError(err: NodeJS.ErrnoException): void {
1529
+ if (isWorkspaceWatcherLimitError(err)) {
1530
+ stderr.write(
1531
+ `[workspace] Recursive file watcher hit the system limit (${err.code}). ` +
1532
+ `New apps will still be detected via polling every ~2s. ` +
1533
+ (err.code === "ENOSPC"
1534
+ ? `On Linux you can raise the limit with ` +
1535
+ `\`sudo sysctl fs.inotify.max_user_watches=524288\` ` +
1536
+ `(persist via /etc/sysctl.d/*.conf). `
1537
+ : `Try closing other dev servers or raising your open-file limit. `) +
1538
+ `On macOS/Windows this usually ` +
1539
+ `means too many other watchers are running.\n`,
1540
+ );
1541
+ return;
1542
+ }
1543
+ if (err.code === "ENOENT") {
1544
+ return;
1545
+ }
1546
+ stderr.write(
1547
+ `[workspace] Recursive file watcher failed (${err.code ?? "unknown"}): ${err.message}. ` +
1548
+ `Falling back to polling.\n`,
1549
+ );
1550
+ Sentry.captureException(err, {
1551
+ tags: { handled: "dev-watch-unknown" },
1552
+ level: "warning",
1553
+ });
1554
+ }
1555
+
1556
+ function startWorkspaceProcesses(): void {
1557
+ if (workspaceStarted) return;
1558
+ workspaceStarted = true;
1559
+ for (const id of initialWorkspaceAppIds(
1560
+ apps,
1561
+ defaultApp,
1562
+ eager,
1563
+ redirectRootToDefault,
1564
+ )) {
1565
+ const app = appById.get(id);
1566
+ if (app) startApp(app);
1567
+ }
1568
+ try {
1569
+ const watcher = fs.watch(appsDir, { recursive: true }, scheduleSync);
1570
+ watcher.on("error", (err) => {
1571
+ handleWatcherError(err as NodeJS.ErrnoException);
1572
+ });
1573
+ } catch (err) {
1574
+ handleWatcherError(err as NodeJS.ErrnoException);
1575
+ }
1576
+ setInterval(() => {
1577
+ void syncApps().catch(() => {});
1578
+ }, 2_000).unref();
1579
+ }
1580
+
1581
+ /**
1582
+ * Background-spawn every app that wasn't started by `startWorkspaceProcesses`.
1583
+ * The lazy proxy still handles correctness (an on-demand request always
1584
+ * starts its target app); this is purely so the first navigation into a
1585
+ * non-default app doesn't pay the cold Vite + esbuild prebundle cost.
1586
+ *
1587
+ * Fires after a short delay so the default app's prebundle gets first dibs
1588
+ * on CPU. Concurrency-limited to avoid hammering a small dev machine —
1589
+ * each Vite spawn briefly maxes out a core during prebundling.
1590
+ */
1591
+ async function prewarmRemainingApps(): Promise<void> {
1592
+ const concurrency = workspacePrewarmConcurrency(args, env);
1593
+ const delayMs = workspacePrewarmDelayMs(env);
1594
+
1595
+ const queue = apps
1596
+ .filter((app) => app.id !== defaultApp)
1597
+ .filter((app) => !(app.process && !app.process.killed))
1598
+ .map((app) => app.id);
1599
+
1600
+ if (queue.length === 0) return;
1601
+
1602
+ stdout.write(
1603
+ `[workspace] Prewarming ${queue.length} app(s) in the background ` +
1604
+ `(concurrency ${concurrency}; pass --no-prewarm to disable)\n`,
1605
+ );
1606
+
1607
+ if (delayMs > 0) {
1608
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
1609
+ }
1610
+ if (shuttingDown) return;
1611
+
1612
+ let next = 0;
1613
+ async function worker(): Promise<void> {
1614
+ while (!shuttingDown && next < queue.length) {
1615
+ const id = queue[next++];
1616
+ const app = appById.get(id);
1617
+ if (!app) continue;
1618
+ // Another path (a real request, a restart, etc.) may have started
1619
+ // this app already — skip without consuming a worker slot needlessly.
1620
+ if (app.process && !app.process.killed) continue;
1621
+ startApp(app);
1622
+ ensureReadinessProbe(app);
1623
+ // Wait for the upstream to answer HTTP before pulling the next
1624
+ // app off the queue. This is what actually limits *concurrent
1625
+ // prebundling* (not just concurrent spawning) and keeps CPU pressure
1626
+ // sane. proxyReadyTimeoutMs caps any single stuck app.
1627
+ await waitForHttpReady(app, Date.now() + proxyReadyTimeoutMs).catch(
1628
+ () => false,
1629
+ );
1630
+ }
1631
+ }
1632
+
1633
+ const workerCount = Math.max(1, Math.min(concurrency, queue.length));
1634
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
1635
+ }
1636
+
1637
+ function openBrowser(url: string): void {
1638
+ if (options.openBrowser === false || env.WORKSPACE_NO_OPEN === "1") return;
1639
+ const command =
1640
+ process.platform === "darwin"
1641
+ ? "open"
1642
+ : process.platform === "win32"
1643
+ ? "cmd"
1644
+ : "xdg-open";
1645
+ const openArgs =
1646
+ process.platform === "win32" ? ["/c", "start", "", url] : [url];
1647
+ const child = spawnProcess(command, openArgs, {
1648
+ stdio: "ignore",
1649
+ detached: true,
1650
+ });
1651
+ child.unref();
1652
+ }
1653
+
1654
+ const server = http.createServer(async (req, res) => {
1655
+ const parsedUrl = new URL(req.url || "/", "http://workspace.local");
1656
+ const pathname = parsedUrl.pathname;
1657
+
1658
+ if (pathname === "/" || pathname === "/index.html") {
1659
+ await syncApps().catch(() => {});
1660
+ const currentDefaultApp =
1661
+ explicitDefaultApp && appById.has(explicitDefaultApp)
1662
+ ? explicitDefaultApp
1663
+ : appById.has("dispatch")
1664
+ ? "dispatch"
1665
+ : defaultApp;
1666
+ const shouldRedirectRoot =
1667
+ Boolean(explicitDefaultApp && appById.has(explicitDefaultApp)) ||
1668
+ appById.has("dispatch");
1669
+ if (shouldRedirectRoot) {
1670
+ res.writeHead(302, {
1671
+ location: `/${currentDefaultApp}${parsedUrl.search}`,
1672
+ });
1673
+ res.end();
1674
+ return;
1675
+ }
1676
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
1677
+ res.end(renderIndex(apps));
1678
+ return;
1679
+ }
1680
+
1681
+ if (pathname === "/_workspace/apps") {
1682
+ await syncApps().catch(() => {});
1683
+ res.writeHead(200, { "content-type": "application/json" });
1684
+ res.end(
1685
+ JSON.stringify(
1686
+ apps.map((app) => ({
1687
+ id: app.id,
1688
+ name: app.name,
1689
+ description: app.description,
1690
+ path: `/${app.id}`,
1691
+ audience: app.audience,
1692
+ publicPaths: app.publicPaths,
1693
+ protectedPaths: app.protectedPaths,
1694
+ port: app.port,
1695
+ running: Boolean(app.process && !app.process.killed),
1696
+ })),
1697
+ ),
1698
+ );
1699
+ return;
1700
+ }
1701
+
1702
+ let app = appForRequest(req);
1703
+ if (!app) {
1704
+ await syncApps().catch(() => {});
1705
+ app = appForRequest(req);
1706
+ }
1707
+ if (!app) {
1708
+ res.writeHead(404, { "content-type": "text/html; charset=utf-8" });
1709
+ res.end(renderIndex(apps));
1710
+ return;
1711
+ }
1712
+ proxyHttp(app, req, res);
1713
+ });
1714
+
1715
+ server.on("upgrade", (req, socket, head) => {
1716
+ const app = appForRequest(req);
1717
+ if (!app) {
1718
+ socket.destroy();
1719
+ return;
1720
+ }
1721
+ proxyUpgrade(app, req, socket, head);
1722
+ });
1723
+
1724
+ function listen(port: number, attempts = 20): void {
1725
+ server.once("error", (err: NodeJS.ErrnoException) => {
1726
+ if (err.code === "EADDRINUSE" && attempts > 0) {
1727
+ listen(port + 1, attempts - 1);
1728
+ return;
1729
+ }
1730
+ stderr.write(`[workspace] Could not start gateway: ${err.message}\n`);
1731
+ throw err;
1732
+ });
1733
+ server.listen(port, gatewayHost, () => {
1734
+ const address = server.address();
1735
+ const actualPort =
1736
+ typeof address === "object" && address ? address.port : port;
1737
+ gatewayUrl = `http://${gatewayHost}:${actualPort}`;
1738
+ stdout.write(
1739
+ `[workspace] Default: ${redirectRootToDefault ? `${gatewayUrl}/${defaultApp}` : gatewayUrl}\n`,
1740
+ );
1741
+ stdout.write(`[workspace] Gateway: ${gatewayUrl}\n`);
1742
+ const prewarming = shouldPrewarmWorkspaceApps(args, env);
1743
+ stdout.write(
1744
+ `[workspace] Mode: ${
1745
+ eager ? "eager" : prewarming ? "lazy+prewarm" : "lazy"
1746
+ }${builtMode ? "+built (prebuilt servers; edits promote to vite)" : ""}\n`,
1747
+ );
1748
+ for (const app of apps) {
1749
+ stdout.write(
1750
+ `[workspace] ${app.id}: /${app.id} -> 127.0.0.1:${app.port}\n`,
1751
+ );
1752
+ }
1753
+ startWorkspaceProcesses();
1754
+ if (prewarming) {
1755
+ void prewarmRemainingApps().catch((err) => {
1756
+ stderr.write(
1757
+ `[workspace] Prewarm error: ${
1758
+ err instanceof Error ? err.message : String(err)
1759
+ }\n`,
1760
+ );
1761
+ });
1762
+ }
1763
+ openBrowser(
1764
+ redirectRootToDefault ? `${gatewayUrl}/${defaultApp}` : gatewayUrl,
1765
+ );
1766
+ readyResolve({ port: actualPort, url: gatewayUrl });
1767
+ });
1768
+ }
1769
+
1770
+ function shutdown(): void {
1771
+ if (shuttingDown) return;
1772
+ shuttingDown = true;
1773
+ server.close();
1774
+ for (const app of apps) {
1775
+ app.promoteWatcher?.close();
1776
+ app.promoteWatcher = undefined;
1777
+ killAppProcessTree(app.process);
1778
+ }
1779
+ if (syncTimer) clearTimeout(syncTimer);
1780
+ process.off("SIGINT", handleSigint);
1781
+ process.off("SIGTERM", handleSigterm);
1782
+ }
1783
+
1784
+ const handleSigint = () => shutdown();
1785
+ const handleSigterm = () => shutdown();
1786
+ process.once("SIGINT", handleSigint);
1787
+ process.once("SIGTERM", handleSigterm);
1788
+
1789
+ listen(requestedPort);
1790
+
1791
+ return {
1792
+ apps,
1793
+ defaultApp,
1794
+ gatewayUrl: () => gatewayUrl,
1795
+ ready,
1796
+ server,
1797
+ shutdown,
1798
+ };
1799
+ }
1800
+
1801
+ function isDirectRun(): boolean {
1802
+ const entry = process.argv[1];
1803
+ if (!entry) return false;
1804
+ try {
1805
+ return path.resolve(entry) === fileURLToPath(import.meta.url);
1806
+ } catch {
1807
+ return false;
1808
+ }
1809
+ }
1810
+
1811
+ if (isDirectRun()) {
1812
+ runWorkspaceDev({ args: process.argv.slice(2) }).catch((err) => {
1813
+ console.error(err instanceof Error ? err.message : String(err));
1814
+ process.exit(1);
1815
+ });
1816
+ }