Skip to content
Source
Sign inSign up

dev-runner.ts

1,031 lines36.0 KB

1#!/usr/bin/env node
2​
3import * as NodeOS from "node:os";
4​
5import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import * as NetService from "@t3tools/shared/Net";
8import { resolveGitWorktreePath, resolveWorktreeT3Home } from "@t3tools/shared/devHome";
9import * as HostProcess from "@t3tools/shared/HostProcess";
10import { resolveSpawnCommand } from "@t3tools/shared/shell";
11import * as Config from "effect/Config";
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Hash from "effect/Hash";
15import * as Layer from "effect/Layer";
16import * as Logger from "effect/Logger";
17import * as Option from "effect/Option";
18import * as Path from "effect/Path";
19import * as Schema from "effect/Schema";
20import { Argument, Command, Flag } from "effect/cli";
21import { ChildProcess } from "effect/process";
22​
23import { defaultDevMemoryLimitBytes, enforceDevMemoryLimit } from "./lib/dev-memory-limit.ts";
24import { type DevShareError, shareDevServer, unshareDevServer } from "./lib/dev-share.ts";
25import { loadRepoEnv } from "./lib/public-config.ts";
26​
27Object.assign(process.env, loadRepoEnv());
28​
29const BASE_SERVER_PORT = 13773;
30const BASE_WEB_PORT = 5733;
31const MAX_HASH_OFFSET = 3000;
32const MAX_PORT = 65535;
33const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1";
34// Browsers block these ports before networking even when curl accepts them.
35// https://fetch.spec.whatwg.org/#port-blocking
36const FETCH_BAD_PORTS = new Set([
37 0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102,
38 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465,
39 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993,
40 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668,
41 6669, 6679, 6697, 10080,
42]);
43// Dev binds loopback; other interfaces cannot establish port availability.
44const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "::1"] as const;
45// Migration prunes after copying, so large sources also need an automatic-seeding limit.
46const LARGE_DEV_DATABASE_BYTES = 500 * 1024 * 1024;
47​
48/** Only loopback and wildcard binds keep the loopback proxy target reachable. */
49export function isProxiableBindHost(host: string): boolean {
50 const normalized = host.trim();
51 return (
52 normalized === "" ||
53 normalized === "localhost" ||
54 normalized === "127.0.0.1" ||
55 normalized === "::1" ||
56 normalized === "[::1]" ||
57 normalized === "0.0.0.0" ||
58 normalized === "::" ||
59 normalized === "[::]"
60 );
62​
63function sharedBackendTargetHost(host: string | undefined): string {
64 const normalized = host?.trim() ?? "";
65 if (normalized === "::1" || normalized === "[::1]") return "[::1]";
66 if (normalized === "localhost") return "localhost";
67 return "127.0.0.1";
69​
70export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) =>
71 path.join(NodeOS.homedir(), ".t3"),
72);
73​
74const MODE_ARGS = {
75 dev: [
76 "run",
77 "--filter=@t3tools/contracts",
78 "--filter=@t3tools/web",
79 "--filter=t3",
80 "--parallel",
81 "dev",
82 ],
83 "dev:server": ["run", "--filter=t3", "dev"],
84 "dev:web": ["run", "--filter=@t3tools/web", "dev"],
85 "dev:desktop": ["run", "--filter=@t3tools/desktop", "--filter=@t3tools/web", "dev"],
86} as const satisfies Record<string, ReadonlyArray<string>>;
87​
88// `--built`: the web package rebuilds into apps/web/dist and the backend serves
89// it, so no Vite dev server runs.
90const BUILT_DEV_ARGS = [
91 "run",
92 "--filter=@t3tools/web",
93 "--filter=t3",
94 "--parallel",
95 "dev:built",
96] as const;
97​
98type DevMode = keyof typeof MODE_ARGS;
99// Only backend ports honor --host/T3CODE_HOST; web ports stay loopback.
100type PortAvailabilityCheck<R = never> = (
101 port: number,
102 role?: "server" | "web",
103) => Effect.Effect<boolean, never, R>;
104​
105const DEV_RUNNER_MODES = Object.keys(MODE_ARGS) as Array<DevMode>;
106​
107export function isBrowserAllowedPort(port: number): boolean {
108 return !FETCH_BAD_PORTS.has(port);
110​
111export class DevRunnerConfigurationError extends Schema.TaggedError<DevRunnerConfigurationError>()(
112 "DevRunnerConfigurationError",
113 {
114 configKeys: Schema.Array(Schema.String),
115 cause: Schema.Defect(),
116 },
117) {
118 override get message(): string {
119 return `Failed to read dev-runner configuration: ${this.configKeys.join(", ")}.`;
120 }
122​
123export class DevRunnerInvalidPortOffsetError extends Schema.TaggedError<DevRunnerInvalidPortOffsetError>()(
124 "DevRunnerInvalidPortOffsetError",
125 {
126 configKey: Schema.Literal("T3CODE_PORT_OFFSET"),
127 portOffset: Schema.Number,
128 minimum: Schema.Number,
129 },
130) {
131 override get message(): string {
132 return `${this.configKey} must be at least ${this.minimum}; received ${this.portOffset}.`;
133 }
135​
136export class DevRunnerPortExhaustedError extends Schema.TaggedError<DevRunnerPortExhaustedError>()(
137 "DevRunnerPortExhaustedError",
138 {
139 startOffset: Schema.Number,
140 requireServerPort: Schema.Boolean,
141 requireWebPort: Schema.Boolean,
142 baseServerPort: Schema.Number,
143 baseWebPort: Schema.Number,
144 maximumPort: Schema.Number,
145 },
146) {
147 override get message(): string {
148 return `No required dev ports were available from offset ${this.startOffset} through maximum port ${this.maximumPort}.`;
149 }
151​
152export class DevRunnerProcessError extends Schema.TaggedError<DevRunnerProcessError>()(
153 "DevRunnerProcessError",
154 {
155 operation: Schema.Literals(["spawn", "wait-for-exit"]),
156 mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]),
157 executable: Schema.Literal("vp"),
158 argumentCount: Schema.Number,
159 shell: Schema.Boolean,
160 cause: Schema.Defect(),
161 },
162) {
163 override get message(): string {
164 return `Dev-runner process operation "${this.operation}" failed for mode "${this.mode}".`;
165 }
167​
168export class DevRunnerProcessExitError extends Schema.TaggedError<DevRunnerProcessExitError>()(
169 "DevRunnerProcessExitError",
170 {
171 mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]),
172 executable: Schema.Literal("vp"),
173 argumentCount: Schema.Number,
174 shell: Schema.Boolean,
175 exitCode: Schema.Number,
176 },
177) {
178 override get message(): string {
179 return `Dev-runner process exited with code ${this.exitCode} in mode "${this.mode}".`;
180 }
182​
183export class DevRunnerHostNotProxiableError extends Schema.TaggedError<DevRunnerHostNotProxiableError>()(
184 "DevRunnerHostNotProxiableError",
185 {
186 mode: Schema.Literals(["dev", "dev:web"]),
187 host: Schema.String,
188 },
189) {
190 override get message(): string {
191 return `--host ${this.host} cannot be combined with ${this.mode}: single-origin browser dev proxies the backend at localhost, and a backend bound only to ${this.host} leaves localhost unanswered, so every proxied request fails. Use a wildcard (0.0.0.0 or ::) to serve that interface and loopback together, or --share for remote access.`;
192 }
194​
195export class DevRunnerBuiltNeedsShareError extends Schema.TaggedError<DevRunnerBuiltNeedsShareError>()(
196 "DevRunnerBuiltNeedsShareError",
197 {
198 mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]),
199 },
200) {
201 override get message(): string {
202 return `--built only works with \`dev --share\` (got ${this.mode}${this.mode === "dev" ? " without --share" : ""}): the backend serves the built client on the tailnet URL.`;
203 }
205​
206const optionalStringConfig = (name: string): Config.Config<string | undefined> =>
207 Config.String(name).pipe(
208 Config.option,
209 Config.map((value) => Option.getOrUndefined(value)),
210 );
211const optionalBooleanConfig = (name: string): Config.Config<boolean | undefined> =>
212 Config.Boolean(name).pipe(
213 Config.option,
214 Config.map((value) => Option.getOrUndefined(value)),
215 );
216const optionalPortConfig = (name: string): Config.Config<number | undefined> =>
217 Config.Port(name).pipe(
218 Config.option,
219 Config.map((value) => Option.getOrUndefined(value)),
220 );
221const optionalIntegerConfig = (name: string): Config.Config<number | undefined> =>
222 Config.Int(name).pipe(
223 Config.option,
224 Config.map((value) => Option.getOrUndefined(value)),
225 );
226const OffsetConfig = Config.all({
227 portOffset: optionalIntegerConfig("T3CODE_PORT_OFFSET"),
228 devInstance: optionalStringConfig("T3CODE_DEV_INSTANCE"),
229});
230​
231export function resolveOffset(config: {
232 readonly portOffset: number | undefined;
233 readonly devInstance: string | undefined;
234 readonly worktreePath?: string | undefined;
235}): Effect.Effect<
236 { readonly offset: number; readonly source: string },
237 DevRunnerInvalidPortOffsetError
238> {
239 if (config.portOffset !== undefined) {
240 if (config.portOffset < 0) {
241 return Effect.fail(
242 new DevRunnerInvalidPortOffsetError({
243 configKey: "T3CODE_PORT_OFFSET",
244 portOffset: config.portOffset,
245 minimum: 0,
246 }),
247 );
248 }
249 return Effect.succeed({
250 offset: config.portOffset,
251 source: `T3CODE_PORT_OFFSET=${config.portOffset}`,
252 });
253 }
254​
255 const seed = config.devInstance?.trim();
256 if (seed) {
257 if (/^\d+$/.test(seed)) {
258 return Effect.succeed({
259 offset: Number(seed),
260 source: `numeric T3CODE_DEV_INSTANCE=${seed}`,
261 });
262 }
263​
264 const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1;
265 return Effect.succeed({ offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` });
266 }
267​
268 const worktreePath = config.worktreePath?.trim();
269 if (worktreePath) {
270 const offset = ((Hash.string(worktreePath) >>> 0) % MAX_HASH_OFFSET) + 1;
271 return Effect.succeed({ offset, source: `worktree ${worktreePath}` });
272 }
273​
274 return Effect.succeed({ offset: 0, source: "default ports" });
276​
277const resolveBaseDir = Effect.fn("dev-runner.resolveBaseDir")(function* (
278 baseDir: string | undefined,
279): Effect.fn.Return<string, never, Path.Path> {
280 const path = yield* Path.Path;
281 const configured = baseDir?.trim();
282​
283 if (configured === "~") {
284 return yield* HostProcess.HomeDirectory;
285 }
286 if (configured?.startsWith("~/") || configured?.startsWith("~\\")) {
287 return path.join(yield* HostProcess.HomeDirectory, configured.slice(2));
288 }
289 if (configured) {
290 return path.resolve(configured);
291 }
292​
293 return yield* DEFAULT_T3_HOME;
294});
295​
296interface CreateDevRunnerEnvInput {
297 readonly mode: DevMode;
298 readonly baseEnv: NodeJS.ProcessEnv;
299 readonly serverOffset: number;
300 readonly webOffset: number;
301 readonly t3Home: string | undefined;
302 readonly browser: boolean | undefined;
303 readonly autoBootstrapProjectFromCwd: boolean | undefined;
304 readonly logWebSocketEvents: boolean | undefined;
305 readonly host: string | undefined;
306 readonly port: number | undefined;
307 readonly devUrl: URL | undefined;
309​
310export const createDevRunnerEnv = Effect.fn("dev-runner.createDevRunnerEnv")(function* ({
311 mode,
312 baseEnv,
313 serverOffset,
314 webOffset,
315 t3Home,
316 browser,
317 autoBootstrapProjectFromCwd,
318 logWebSocketEvents,
319 host,
320 port,
321 devUrl,
322}: CreateDevRunnerEnvInput): Effect.fn.Return<NodeJS.ProcessEnv, never, Path.Path> {
323 const serverPort = port ?? BASE_SERVER_PORT + serverOffset;
324 const webPort = BASE_WEB_PORT + webOffset;
325 const configuredBaseDir = t3Home?.trim() || undefined;
326 const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir);
327 const isDesktopMode = mode === "dev:desktop";
328​
329 const output: NodeJS.ProcessEnv = {
330 ...baseEnv,
331 PORT: String(webPort),
332 VITE_DEV_SERVER_URL:
333 devUrl?.toString() ??
334 `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`,
335 };
336​
337 if (configuredBaseDir !== undefined) {
338 output.T3CODE_HOME = resolvedBaseDir;
339 } else {
340 delete output.T3CODE_HOME;
341 }
342​
343 // Managed T3 service shells leak launcher variables that make dev servers fail version checks.
344 // Dev-runner servers are never launcher-managed (serviceLauncherClient.ts resolveStartup).
345 delete output.T3_SERVICE_LAUNCHER_CONTEXT;
346 delete output.T3_BOOT_SERVICE_UNIT;
347​
348 // Each worktree has its own state, so a dev server would count as a new
349 // install of the developer's real accounts. Set it explicitly to opt in.
350 output.T3CODE_TELEMETRY_ENABLED = baseEnv.T3CODE_TELEMETRY_ENABLED ?? "false";
351​
352 if (!isDesktopMode) {
353 output.T3CODE_PORT = String(serverPort);
354 // An inherited HOST pins Vite's HMR socket via explicitHost in apps/web/vite.config.ts.
355 // Only desktop mode should set it.
356 delete output.HOST;
357 if (mode === "dev" || mode === "dev:web") {
358 // Browser dev is single-origin: everything (including /ws) is proxied
359 // through Vite, so the client must resolve its backend from
360 // window.location.origin rather than a baked-in localhost URL.
361 delete output.VITE_HTTP_URL;
362 delete output.VITE_WS_URL;
363 // Deleting is not enough on its own: vite.config.ts calls loadRepoEnv,
364 // which merges `.env`/`.env.local` *under* this env, so a developer
365 // with either URL in their `.env` would get it back and silently lose
366 // single-origin mode.
367 output.T3CODE_SINGLE_ORIGIN_DEV = "1";
368 } else {
369 output.VITE_HTTP_URL = `http://localhost:${serverPort}`;
370 output.VITE_WS_URL = `ws://localhost:${serverPort}`;
371 delete output.T3CODE_SINGLE_ORIGIN_DEV;
372 }
373 } else {
374 output.T3CODE_PORT = String(serverPort);
375 output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`;
376 output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`;
377 // Desktop pins the renderer to loopback on purpose; an ambient marker
378 // must not make Vite drop those URLs.
379 delete output.T3CODE_SINGLE_ORIGIN_DEV;
380 delete output.T3CODE_MODE;
381 delete output.T3CODE_NO_BROWSER;
382 delete output.T3CODE_HOST;
383 delete output.T3CODE_DEV_AUTH_TOKEN;
384 }
385​
386 if (!isDesktopMode && host !== undefined) {
387 output.T3CODE_HOST = host;
388 }
389​
390 if (!isDesktopMode) {
391 output.T3CODE_NO_BROWSER = browser === true ? "0" : "1";
392 }
393​
394 if (autoBootstrapProjectFromCwd !== undefined) {
395 output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD = autoBootstrapProjectFromCwd ? "1" : "0";
396 } else {
397 delete output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD;
398 }
399​
400 if (logWebSocketEvents !== undefined) {
401 output.T3CODE_LOG_WS_EVENTS = logWebSocketEvents ? "1" : "0";
402 } else {
403 delete output.T3CODE_LOG_WS_EVENTS;
404 }
405​
406 if (mode === "dev") {
407 output.T3CODE_MODE = "web";
408 delete output.T3CODE_DESKTOP_WS_URL;
409 }
410​
411 if (mode === "dev:server" || mode === "dev:web") {
412 output.T3CODE_MODE = "web";
413 delete output.T3CODE_DESKTOP_WS_URL;
414 }
415​
416 if (isDesktopMode) {
417 output.HOST = DESKTOP_DEV_LOOPBACK_HOST;
418 delete output.T3CODE_DESKTOP_WS_URL;
419 }
420​
421 return output;
422});
423​
424function portPairForOffset(offset: number): {
425 readonly serverPort: number;
426 readonly webPort: number;
427} {
428 return {
429 serverPort: BASE_SERVER_PORT + offset,
430 webPort: BASE_WEB_PORT + offset,
431 };
433​
434export const checkPortAvailabilityOnHosts = Effect.fn("dev-runner.checkPortAvailabilityOnHosts")(
435 function* <R>(
436 port: number,
437 hosts: ReadonlyArray<string>,
438 canListenOnHost: (port: number, host: string) => Effect.Effect<boolean, never, R>,
439 ): Effect.fn.Return<boolean, never, R> {
440 for (const host of hosts) {
441 if (!(yield* canListenOnHost(port, host))) {
442 return false;
443 }
444 }
445​
446 return true;
447 },
448);
449​
450export function devPortProbeHosts(configuredHost: string | undefined): ReadonlyArray<string> {
451 const host = configuredHost?.trim();
452 if (!host || DEV_PORT_PROBE_HOSTS.includes(host as (typeof DEV_PORT_PROBE_HOSTS)[number])) {
453 return DEV_PORT_PROBE_HOSTS;
454 }
455 return [...DEV_PORT_PROBE_HOSTS, host];
457​
458const makeDefaultCheckPortAvailability =
459 (configuredHost: string | undefined): PortAvailabilityCheck<NetService.NetService> =>
460 (port, role) =>
461 Effect.gen(function* () {
462 const net = yield* NetService.NetService;
463 const hosts = role === "web" ? DEV_PORT_PROBE_HOSTS : devPortProbeHosts(configuredHost);
464 return yield* checkPortAvailabilityOnHosts(port, hosts, (candidatePort, host) =>
465 net.canListenOnHost(candidatePort, host),
466 );
467 });
468​
469const defaultCheckPortAvailability = makeDefaultCheckPortAvailability(undefined);
470​
471interface FindFirstAvailableOffsetInput<R = NetService.NetService> {
472 readonly startOffset: number;
473 readonly requireServerPort: boolean;
474 readonly requireWebPort: boolean;
475 readonly checkPortAvailability?: PortAvailabilityCheck<R>;
477​
478export const findFirstAvailableOffset = Effect.fn("dev-runner.findFirstAvailableOffset")(function* <
479 R = NetService.NetService,
480>({
481 startOffset,
482 requireServerPort,
483 requireWebPort,
484 checkPortAvailability,
485}: FindFirstAvailableOffsetInput<R>): Effect.fn.Return<number, DevRunnerPortExhaustedError, R> {
486 const checkPort = (checkPortAvailability ??
487 defaultCheckPortAvailability) as PortAvailabilityCheck<R>;
488​
489 for (let candidate = startOffset; ; candidate += 1) {
490 const { serverPort, webPort } = portPairForOffset(candidate);
491 const serverPortOutOfRange = serverPort > MAX_PORT;
492 const webPortOutOfRange = webPort > MAX_PORT;
493​
494 if (
495 (requireServerPort && serverPortOutOfRange) ||
496 (requireWebPort && webPortOutOfRange) ||
497 (!requireServerPort && !requireWebPort && (serverPortOutOfRange || webPortOutOfRange))
498 ) {
499 break;
500 }
501​
502 if (requireWebPort && !isBrowserAllowedPort(webPort)) {
503 continue;
504 }
505​
506 const checks: Array<Effect.Effect<boolean, never, R>> = [];
507 if (requireServerPort) {
508 checks.push(checkPort(serverPort, "server"));
509 }
510 if (requireWebPort) {
511 checks.push(checkPort(webPort, "web"));
512 }
513​
514 if (checks.length === 0) {
515 return candidate;
516 }
517​
518 const availability = yield* Effect.all(checks);
519 if (availability.every(Boolean)) {
520 return candidate;
521 }
522 }
523​
524 return yield* new DevRunnerPortExhaustedError({
525 startOffset,
526 requireServerPort,
527 requireWebPort,
528 baseServerPort: BASE_SERVER_PORT,
529 baseWebPort: BASE_WEB_PORT,
530 maximumPort: MAX_PORT,
531 });
532});
533​
534interface ResolveModePortOffsetsInput<R = NetService.NetService> {
535 readonly mode: DevMode;
536 readonly startOffset: number;
537 readonly hasExplicitServerPort: boolean;
538 readonly hasExplicitDevUrl: boolean;
539 readonly checkPortAvailability?: PortAvailabilityCheck<R>;
541​
542export const resolveModePortOffsets = Effect.fn("dev-runner.resolveModePortOffsets")(function* <
543 R = NetService.NetService,
544>({
545 mode,
546 startOffset,
547 hasExplicitServerPort,
548 hasExplicitDevUrl,
549 checkPortAvailability,
550}: ResolveModePortOffsetsInput<R>): Effect.fn.Return<
551 { readonly serverOffset: number; readonly webOffset: number },
552 DevRunnerPortExhaustedError,
553 R
554> {
555 const checkPort = (checkPortAvailability ??
556 defaultCheckPortAvailability) as PortAvailabilityCheck<R>;
557​
558 if (mode === "dev:web") {
559 if (hasExplicitDevUrl) {
560 return { serverOffset: startOffset, webOffset: startOffset };
561 }
562​
563 const webOffset = yield* findFirstAvailableOffset({
564 startOffset,
565 requireServerPort: false,
566 requireWebPort: true,
567 checkPortAvailability: checkPort,
568 });
569 return { serverOffset: startOffset, webOffset };
570 }
571​
572 if (mode === "dev:server") {
573 if (hasExplicitServerPort) {
574 return { serverOffset: startOffset, webOffset: startOffset };
575 }
576​
577 const serverOffset = yield* findFirstAvailableOffset({
578 startOffset,
579 requireServerPort: true,
580 requireWebPort: false,
581 checkPortAvailability: checkPort,
582 });
583 return { serverOffset, webOffset: serverOffset };
584 }
585​
586 const sharedOffset = yield* findFirstAvailableOffset({
587 startOffset,
588 requireServerPort: !hasExplicitServerPort,
589 requireWebPort: !hasExplicitDevUrl,
590 checkPortAvailability: checkPort,
591 });
592​
593 return { serverOffset: sharedOffset, webOffset: sharedOffset };
594});
595​
596/** Best effort: a failed first-start step warns, and dev still starts. */
597const runRepoScript = Effect.fn("dev-runner.runRepoScript")(function* (
598 script: string,
599 args: ReadonlyArray<string>,
600 env: NodeJS.ProcessEnv,
601) {
602 const path = yield* Path.Path;
603 const repoRoot = path.dirname(import.meta.dirname);
604 const exitCode = yield* ChildProcess.make(
605 yield* HostProcess.ExecutablePath,
606 [path.join(repoRoot, script), ...args],
607 { cwd: repoRoot, stdin: "ignore", stdout: "inherit", stderr: "inherit", env, extendEnv: false },
608 ).pipe(
609 Effect.flatMap((child) => child.exitCode),
610 Effect.scoped,
611 Effect.orElseSucceed(() => -1),
612 );
613 if (exitCode !== 0) {
614 yield* Effect.logWarning(`[dev-runner] ${script} failed; starting anyway.`);
615 }
616});
617​
618/**
619 * The first start in a worktree seeds its empty database from a small live database
620 * and warms the web dependency cache. Each step checks for its own output, so
621 * later starts skip it.
622 */
623const prepareFirstStart = Effect.fn("dev-runner.prepareFirstStart")(function* (input: {
624 readonly mode: DevMode;
625 readonly seed: boolean;
626 readonly worktreeHome: string | undefined;
627 readonly env: NodeJS.ProcessEnv;
628}) {
629 const fs = yield* FileSystem.FileSystem;
630 const path = yield* Path.Path;
631 const exists = (file: string) => fs.exists(file).pipe(Effect.orElseSucceed(() => false));
632​
633 if (input.worktreeHome !== undefined && input.mode !== "dev:web") {
634 const stateDir = path.join(input.worktreeHome, "userdata");
635 for (const name of ["statev2.sqlite", "state.sqlite"]) {
636 const file = path.join(stateDir, name);
637 const info = yield* fs.stat(file).pipe(Effect.option);
638 if (Option.isSome(info) && Number(info.value.size) > LARGE_DEV_DATABASE_BYTES) {
639 yield* Effect.logWarning(
640 `[dev-runner] ${file} is ${Math.round(Number(info.value.size) / 1024 / 1024)} MB. With the dev server stopped, \`vp run migrate-dev-db\` rebuilds statev2.sqlite as a small copy of recent data.`,
641 );
642 }
643 }
644 const source = path.join(yield* HostProcess.HomeDirectory, ".t3", "userdata", "statev2.sqlite");
645 // A V1 state.sqlite counts as data too: the server imports it only while
646 // statev2.sqlite is missing, so seeding would hide it.
647 if (
648 input.seed &&
649 !(yield* exists(path.join(stateDir, "statev2.sqlite"))) &&
650 !(yield* exists(path.join(stateDir, "state.sqlite")))
651 ) {
652 const info = yield* fs.stat(source).pipe(Effect.option);
653 if (Option.isSome(info)) {
654 if (Number(info.value.size) > LARGE_DEV_DATABASE_BYTES) {
655 yield* Effect.logWarning(
656 `[dev-runner] ${source} exceeds 500 MiB; skipping automatic database seeding and starting with an empty database. Run \`vp run migrate-dev-db\` manually with dev stopped if needed; it may use substantial disk space before pruning.`,
657 );
658 } else {
659 yield* Effect.logInfo(
660 `[dev-runner] seeding ${input.worktreeHome} from ${source} (read-only). --no-seed or T3CODE_DEV_SEED=0 skips this.`,
661 );
662 yield* runRepoScript(
663 "apps/server/scripts/migrate-dev-db.ts",
664 ["--base-dir", input.worktreeHome, "--source", source],
665 input.env,
666 );
667 }
668 }
669 }
670 }
671​
672 const repoRoot = path.dirname(import.meta.dirname);
673 if (
674 input.mode !== "dev:server" &&
675 !(yield* exists(path.join(repoRoot, "apps/web/node_modules/.vite/deps/_metadata.json")))
676 ) {
677 yield* runRepoScript("apps/web/scripts/warm-dep-cache.ts", [], input.env);
678 }
679});
680​
681interface DevRunnerCliInput {
682 readonly mode: DevMode;
683 readonly t3Home: string | undefined;
684 readonly browser: boolean | undefined;
685 readonly autoBootstrapProjectFromCwd: boolean | undefined;
686 readonly logWebSocketEvents: boolean | undefined;
687 readonly host: string | undefined;
688 readonly port: number | undefined;
689 readonly devUrl: URL | undefined;
690 readonly dryRun: boolean;
691 readonly share: boolean;
692 readonly seed?: boolean | undefined;
693 readonly built: boolean;
694 readonly memoryLimitGb?: number | undefined;
695 readonly runArgs: ReadonlyArray<string>;
697​
698export const runDevRunnerWithInput = Effect.fn("dev-runner.runDevRunnerWithInput")(function* (
699 input: DevRunnerCliInput,
700) {
701 const { portOffset, devInstance } = yield* OffsetConfig.pipe(
702 Effect.mapError(
703 (cause) =>
704 new DevRunnerConfigurationError({
705 configKeys: ["T3CODE_PORT_OFFSET", "T3CODE_DEV_INSTANCE"],
706 cause,
707 }),
708 ),
709 );
710​
711 if (
712 (input.mode === "dev" || input.mode === "dev:web") &&
713 input.host !== undefined &&
714 !isProxiableBindHost(input.host)
715 ) {
716 return yield* new DevRunnerHostNotProxiableError({ mode: input.mode, host: input.host });
717 }
718​
719 if (input.built && (input.mode !== "dev" || !input.share)) {
720 return yield* new DevRunnerBuiltNeedsShareError({ mode: input.mode });
721 }
722​
723 const worktreePath = yield* resolveGitWorktreePath(yield* HostProcess.WorkingDirectory);
724​
725 const { offset, source } = yield* resolveOffset({
726 portOffset,
727 devInstance,
728 worktreePath,
729 });
730​
731 const { serverOffset, webOffset } = yield* resolveModePortOffsets({
732 mode: input.mode,
733 startOffset: offset,
734 hasExplicitServerPort: input.port !== undefined,
735 hasExplicitDevUrl: input.devUrl !== undefined,
736 checkPortAvailability: makeDefaultCheckPortAvailability(input.host),
737 });
738​
739 const hostEnvironment = yield* HostProcess.Environment;
740 // A dev server started inside a worktree defaults to that worktree's own
741 // (gitignored) `.t3` — see @t3tools/shared/devHome for why this must
742 // outrank an ambient T3CODE_HOME. `--home-dir` still wins.
743 const worktreeHome = yield* resolveWorktreeT3Home(yield* HostProcess.WorkingDirectory);
744 // Trim before choosing: `--home-dir ""` is not a selection, and treating it
745 // as one would skip the worktree default and land on the shared home —
746 // exactly the outcome this precedence exists to prevent.
747 const resolvedT3Home =
748 (input.t3Home?.trim() || undefined) ??
749 worktreeHome ??
750 (hostEnvironment.T3CODE_HOME?.trim() || undefined);
751 const env = yield* createDevRunnerEnv({
752 mode: input.mode,
753 baseEnv: hostEnvironment,
754 serverOffset,
755 webOffset,
756 t3Home: resolvedT3Home,
757 browser: input.browser,
758 autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd,
759 logWebSocketEvents: input.logWebSocketEvents,
760 host: input.host,
761 port: input.port,
762 devUrl: input.devUrl,
763 });
764​
765 const selectionSuffix =
766 serverOffset !== offset || webOffset !== offset
767 ? ` selectedOffset(server=${serverOffset},web=${webOffset})`
768 : "";
769 const baseDir = env.T3CODE_HOME ?? (yield* DEFAULT_T3_HOME);
770​
771 yield* Effect.logInfo(
772 `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`,
773 );
774​
775 // Before the share block: --dry-run only resolves and prints. Sharing would
776 // replace, then tear down, whatever mapping the port already had — a
777 // surprising side effect from a command documented as inert.
778 if (input.dryRun) {
779 return;
780 }
781​
782 const sharedWebPort = BASE_WEB_PORT + webOffset;
783 let built = false;
784 if (input.share) {
785 if (input.mode === "dev:server") {
786 yield* Effect.logInfo("[dev-runner] --share has no effect for dev:server (no web server).");
787 } else if (input.mode === "dev:desktop") {
788 yield* Effect.logWarning(
789 "[dev-runner] --share is not supported for dev:desktop (the renderer is pinned to loopback). Use `dev`, which runs the whole browser stack.",
790 );
791 } else {
792 // acquireRelease, not share-then-addFinalizer: the mapping outlives this
793 // process (and reboots), so the cleanup has to be registered atomically
794 // with creating it. An interrupt landing in between would otherwise
795 // leave a mapping pointing at a port nothing is listening on.
796 const shared = yield* Effect.acquireRelease(
797 shareDevServer({
798 webPort: sharedWebPort,
799 // The backend binds 127.0.0.1 unless --host changes it. A wildcard
800 // still answers there; `--host ::1` binds only the IPv6 loopback, and
801 // `localhost` binds whichever loopback Node resolves first.
802 ...(input.built
803 ? {
804 localTarget: {
805 port: Number(env.T3CODE_PORT),
806 host: sharedBackendTargetHost(input.host),
807 },
808 }
809 : {}),
810 }),
811 () =>
812 unshareDevServer(sharedWebPort).pipe(
813 Effect.flatMap((result) =>
814 result.cleared
815 ? Effect.void
816 : Effect.logWarning(
817 `[dev-runner] could not remove the tailnet mapping for port ${String(sharedWebPort)}${
818 result.explanation ? `: ${result.explanation}` : ""
819 }. Remove it with \`tailscale serve --https=${String(sharedWebPort)} --set-path=/ off\`.`,
820 ),
821 ),
822 ),
823 ).pipe(
824 Effect.tapError((error: DevShareError) =>
825 Effect.logWarning(
826 `[dev-runner] could not share on the tailnet: ${error.message}${
827 error.hint ? ` — ${error.hint}` : ""
828 }`,
829 ),
830 ),
831 Effect.option,
832 Effect.map(Option.getOrUndefined),
833 );
834​
835 if (shared) {
836 // The app is reached from the tailnet origin. Vite already allows
837 // *.ts.net hosts; the backend needs the origin for credentialed
838 // requests that bypass the proxy (desktop renderer, direct calls).
839 env.T3CODE_DEV_ALLOWED_ORIGINS = [
840 env.T3CODE_DEV_ALLOWED_ORIGINS,
841 new URL(shared.url).origin,
842 ]
843 .filter((entry) => entry && entry.length > 0)
844 .join(",");
845 if (input.devUrl === undefined) {
846 env.VITE_DEV_SERVER_URL = shared.url;
847 }
848 if (input.built) {
849 built = true;
850 env.T3CODE_BUILT_DEV = "1";
851 } else if (env.T3CODE_BUNDLED_DEV === undefined) {
852 // A shared origin serves a remote browser, where unbundled dev's
853 // per-module requests each pay a tailnet round trip — a cold module
854 // graph takes minutes to first paint. Bundled dev collapses that to a
855 // few chunk requests.
856 env.T3CODE_BUNDLED_DEV = "1";
857 }
858 yield* Effect.logInfo(`[dev-runner] shared on tailnet: ${shared.url}`);
859 } else if (input.built) {
860 yield* Effect.logWarning(
861 "[dev-runner] --built needs the tailnet share; running the Vite dev server instead.",
862 );
863 }
864 }
865 }
866​
867 // After sharing, so the warm step sees the same bundled-dev env as Vite.
868 yield* prepareFirstStart({
869 mode: input.mode,
870 seed: input.seed !== false,
871 worktreeHome: baseDir === worktreeHome ? worktreeHome : undefined,
872 env,
873 });
874​
875 const args = [...(built ? BUILT_DEV_ARGS : MODE_ARGS[input.mode]), ...input.runArgs];
876 const spawnCommand = yield* resolveSpawnCommand("vp", args, { env });
877 const processContext = {
878 mode: input.mode,
879 executable: "vp" as const,
880 argumentCount: args.length,
881 shell: spawnCommand.shell,
882 } as const;
883 const child = yield* ChildProcess.make(spawnCommand.command, spawnCommand.args, {
884 stdin: "inherit",
885 stdout: "inherit",
886 stderr: "inherit",
887 env,
888 extendEnv: false,
889 shell: spawnCommand.shell,
890 // Its own process group: teardown signals the whole group, because vp
891 // does not pass SIGTERM on to Vite and the server.
892 detached: true,
893 forceKillAfter: "1500 millis",
894 }).pipe(
895 Effect.mapError(
896 (cause) =>
897 new DevRunnerProcessError({
898 ...processContext,
899 operation: "spawn",
900 cause,
901 }),
902 ),
903 );
904​
905 const memoryLimitBytes =
906 input.memoryLimitGb === undefined
907 ? defaultDevMemoryLimitBytes()
908 : input.memoryLimitGb * 1024 ** 3;
909 const exitCode = yield* child.exitCode.pipe(
910 Effect.mapError(
911 (cause) =>
912 new DevRunnerProcessError({
913 ...processContext,
914 operation: "wait-for-exit",
915 cause,
916 }),
917 ),
918 Effect.raceFirst(enforceDevMemoryLimit(memoryLimitBytes)),
919 );
920 if (exitCode !== 0) {
921 return yield* new DevRunnerProcessExitError({
922 ...processContext,
923 exitCode,
924 });
925 }
926});
927​
928const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
929​
930const devRunnerCli = Command.make("dev-runner", {
931 mode: Argument.Literals("mode", DEV_RUNNER_MODES).pipe(
932 Argument.withDescription("Development mode to run."),
933 ),
934 t3Home: Flag.String("home-dir").pipe(
935 Flag.withDescription(
936 "Explicit T3 Code data directory; runtime state is stored under userdata (equivalent to T3CODE_HOME). Inside a git worktree this defaults to that worktree's own .t3 so dev state stays off the shared home.",
937 ),
938 Flag.optional,
939 Flag.map(Option.getOrUndefined),
940 ),
941 browser: Flag.Boolean("browser").pipe(
942 Flag.withDescription("Open a browser automatically (disabled by default for web dev)."),
943 Flag.withDefault(false),
944 ),
945 autoBootstrapProjectFromCwd: Flag.Boolean("auto-bootstrap-project-from-cwd").pipe(
946 Flag.withDescription(
947 "Auto-bootstrap toggle (equivalent to T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD).",
948 ),
949 Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD")),
950 ),
951 logWebSocketEvents: Flag.Boolean("log-websocket-events").pipe(
952 Flag.withDescription("WebSocket event logging toggle (equivalent to T3CODE_LOG_WS_EVENTS)."),
953 Flag.withAlias("log-ws-events"),
954 Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_LOG_WS_EVENTS")),
955 ),
956 host: Flag.String("host").pipe(
957 Flag.withDescription("Server host/interface override (forwards to T3CODE_HOST)."),
958 Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOST")),
959 ),
960 port: Flag.Int("port").pipe(
961 Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
962 Flag.withDescription("Server port override (forwards to T3CODE_PORT)."),
963 Flag.withFallbackConfig(optionalPortConfig("T3CODE_PORT")),
964 ),
965 devUrl: Flag.String("dev-url").pipe(
966 Flag.withSchema(Schema.URLFromString),
967 Flag.withDescription(
968 "Explicit web dev URL override (forwards to VITE_DEV_SERVER_URL). Ambient VITE_DEV_SERVER_URL values are ignored so a parent dev app cannot redirect the child runner.",
969 ),
970 Flag.optional,
971 Flag.map(Option.getOrUndefined),
972 ),
973 dryRun: Flag.Boolean("dry-run").pipe(
974 Flag.withDescription("Resolve mode/ports/env and print, but do not spawn Vite+."),
975 Flag.withDefault(false),
976 ),
977 share: Flag.Boolean("share").pipe(
978 Flag.withDescription(
979 "Publish the web dev server on this machine's tailnet over HTTPS (via `tailscale serve`) and print the pairing URL for it. Removed again on exit.",
980 ),
981 Flag.withDefault(false),
982 ),
983 seed: Flag.Boolean("seed").pipe(
984 Flag.withDescription(
985 "Seed an empty worktree database from ~/.t3 on first start (read-only, via migrate-dev-db) if the source is at most 500 MiB. On by default; --no-seed or T3CODE_DEV_SEED=0 skips it.",
986 ),
987 Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_DEV_SEED")),
988 ),
989 built: Flag.Boolean("built").pipe(
990 Flag.withDescription(
991 "With --share: serve a production web build from the backend instead of the Vite dev server. Rebuilds on change, no HMR, much smaller remote loads.",
992 ),
993 Flag.withDefault(false),
994 ),
995 memoryLimitGb: Flag.Int("memory-limit-gb").pipe(
996 Flag.withSchema(NonNegativeInt),
997 Flag.withDescription(
998 "Stop the dev server when it and its child processes use more than this many GiB of RAM plus swap (equivalent to T3CODE_DEV_MEMORY_LIMIT_GB). Defaults to half of RAM, at most 16. 0 turns it off. Linux only.",
999 ),
1000 Flag.withFallbackConfig(
1001 Config.schema(NonNegativeInt, "T3CODE_DEV_MEMORY_LIMIT_GB").pipe(
1002 Config.option,
1003 Config.map(Option.getOrUndefined),
1004 ),
1005 ),
1006 ),
1007 runArgs: Argument.String("run-arg").pipe(
1008 Argument.withDescription("Additional Vite+ run args (pass after `--`)."),
1009 Argument.variadic(),
1010 ),
1011}).pipe(
1012 Command.withDescription("Run monorepo development modes with deterministic port/env wiring."),
1013 Command.withHandler((input) => runDevRunnerWithInput(input)),
1015​
1016const cliRuntimeLayer = Layer.mergeAll(
1017 Logger.layer([Logger.consolePretty()]),
1018 NodeServices.layer,
1019 NetService.layer,
1021​
1022if (import.meta.main) {
1023 // runMain interrupts (running finalizers such as the --share unmapping) only
1024 // on SIGINT/SIGTERM; a closed terminal or dropped SSH session sends SIGHUP.
1025 process.on("SIGHUP", () => process.kill(process.pid, "SIGTERM"));
1026 Command.run(devRunnerCli, { version: "0.0.0" }).pipe(
1027 Effect.scoped,
1028 Effect.provide(cliRuntimeLayer),
1029 NodeRuntime.runMain,
1030 );