Skip to content
Source
Sign inSign up

dev-runner.ts

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