#!/usr/bin/env node import * as NodeOS from "node:os"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; import { resolveGitWorktreePath, resolveWorktreeT3Home } from "@t3tools/shared/devHome"; import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import * as Hash from "effect/Hash"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { Argument, Command, Flag } from "effect/unstable/cli"; import { ChildProcess } from "effect/unstable/process"; import { type DevShareError, shareDevServer, unshareDevServer } from "./lib/dev-share.ts"; import { loadRepoEnv } from "./lib/public-config.ts"; Object.assign(process.env, loadRepoEnv()); const BASE_SERVER_PORT = 13773; const BASE_WEB_PORT = 5733; const MAX_HASH_OFFSET = 3000; const MAX_PORT = 65535; const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; // HTTP(S) requests to these ports are blocked by the Fetch standard before a // browser reaches the network. Keep the complete list here so explicit or // future wider offsets cannot produce a URL that curl accepts but browsers // reject. https://fetch.spec.whatwg.org/#port-blocking const FETCH_BAD_PORTS = new Set([ 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, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080, ]); // Dev servers bind loopback, so loopback is the only interface whose // availability decides whether we can use a port. Probing wildcards too made // the runner walk away from a perfectly free port whenever something else held // the same number on another interface — `tailscale serve` does exactly that, // which silently moved the ports out from under a URL that had just been shared. const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "::1"] as const; /** * Bind hosts on which a backend still answers `http://localhost:`, which * is where single-origin browser dev proxies to. Loopback and the wildcards * qualify; a specific interface (e.g. a LAN IP) does not — the OS binds only * that address and the proxy target goes dark. */ export function isProxiableBindHost(host: string): boolean { const normalized = host.trim(); return ( normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]" || normalized === "0.0.0.0" || normalized === "::" || normalized === "[::]" ); } export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => path.join(NodeOS.homedir(), ".t3"), ); const MODE_ARGS = { dev: [ "run", "--filter=@t3tools/contracts", "--filter=@t3tools/web", "--filter=t3", "--parallel", "dev", ], "dev:server": ["run", "--filter=t3", "dev"], "dev:web": ["run", "--filter=@t3tools/web", "dev"], "dev:desktop": ["run", "--filter=@t3tools/desktop", "--filter=@t3tools/web", "dev"], } as const satisfies Record>; type DevMode = keyof typeof MODE_ARGS; /** * `role` matters because only the backend honours `--host`/`T3CODE_HOST`; the * web port is always loopback. Passed explicitly rather than inferred from the * port number, which stops distinguishing them under a large port offset. */ type PortAvailabilityCheck = ( port: number, role?: "server" | "web", ) => Effect.Effect; const DEV_RUNNER_MODES = Object.keys(MODE_ARGS) as Array; export function getDevRunnerModeArgs(mode: DevMode): ReadonlyArray { return MODE_ARGS[mode]; } export function isBrowserAllowedPort(port: number): boolean { return !FETCH_BAD_PORTS.has(port); } export class DevRunnerConfigurationError extends Schema.TaggedErrorClass()( "DevRunnerConfigurationError", { configKeys: Schema.Array(Schema.String), cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to read dev-runner configuration: ${this.configKeys.join(", ")}.`; } } export class DevRunnerInvalidPortOffsetError extends Schema.TaggedErrorClass()( "DevRunnerInvalidPortOffsetError", { configKey: Schema.Literal("T3CODE_PORT_OFFSET"), portOffset: Schema.Number, minimum: Schema.Number, }, ) { override get message(): string { return `${this.configKey} must be at least ${this.minimum}; received ${this.portOffset}.`; } } export class DevRunnerPortExhaustedError extends Schema.TaggedErrorClass()( "DevRunnerPortExhaustedError", { startOffset: Schema.Number, requireServerPort: Schema.Boolean, requireWebPort: Schema.Boolean, baseServerPort: Schema.Number, baseWebPort: Schema.Number, maximumPort: Schema.Number, }, ) { override get message(): string { return `No required dev ports were available from offset ${this.startOffset} through maximum port ${this.maximumPort}.`; } } export class DevRunnerProcessError extends Schema.TaggedErrorClass()( "DevRunnerProcessError", { operation: Schema.Literals(["spawn", "wait-for-exit"]), mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]), executable: Schema.Literal("vp"), argumentCount: Schema.Number, shell: Schema.Boolean, cause: Schema.Defect(), }, ) { override get message(): string { return `Dev-runner process operation "${this.operation}" failed for mode "${this.mode}".`; } } export class DevRunnerProcessExitError extends Schema.TaggedErrorClass()( "DevRunnerProcessExitError", { mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]), executable: Schema.Literal("vp"), argumentCount: Schema.Number, shell: Schema.Boolean, exitCode: Schema.Number, }, ) { override get message(): string { return `Dev-runner process exited with code ${this.exitCode} in mode "${this.mode}".`; } } export class DevRunnerHostNotProxiableError extends Schema.TaggedErrorClass()( "DevRunnerHostNotProxiableError", { mode: Schema.Literals(["dev", "dev:web"]), host: Schema.String, }, ) { override get message(): string { 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.`; } } export const DevRunnerError = Schema.Union([ DevRunnerConfigurationError, DevRunnerHostNotProxiableError, DevRunnerInvalidPortOffsetError, DevRunnerPortExhaustedError, DevRunnerProcessError, DevRunnerProcessExitError, ]); export type DevRunnerError = typeof DevRunnerError.Type; export const isDevRunnerError = Schema.is(DevRunnerError); const optionalStringConfig = (name: string): Config.Config => Config.string(name).pipe( Config.option, Config.map((value) => Option.getOrUndefined(value)), ); const optionalBooleanConfig = (name: string): Config.Config => Config.boolean(name).pipe( Config.option, Config.map((value) => Option.getOrUndefined(value)), ); const optionalPortConfig = (name: string): Config.Config => Config.port(name).pipe( Config.option, Config.map((value) => Option.getOrUndefined(value)), ); const optionalIntegerConfig = (name: string): Config.Config => Config.int(name).pipe( Config.option, Config.map((value) => Option.getOrUndefined(value)), ); const OffsetConfig = Config.all({ portOffset: optionalIntegerConfig("T3CODE_PORT_OFFSET"), devInstance: optionalStringConfig("T3CODE_DEV_INSTANCE"), }); export function resolveOffset(config: { readonly portOffset: number | undefined; readonly devInstance: string | undefined; readonly worktreePath?: string | undefined; }): Effect.Effect< { readonly offset: number; readonly source: string }, DevRunnerInvalidPortOffsetError > { if (config.portOffset !== undefined) { if (config.portOffset < 0) { return Effect.fail( new DevRunnerInvalidPortOffsetError({ configKey: "T3CODE_PORT_OFFSET", portOffset: config.portOffset, minimum: 0, }), ); } return Effect.succeed({ offset: config.portOffset, source: `T3CODE_PORT_OFFSET=${config.portOffset}`, }); } const seed = config.devInstance?.trim(); if (seed) { if (/^\d+$/.test(seed)) { return Effect.succeed({ offset: Number(seed), source: `numeric T3CODE_DEV_INSTANCE=${seed}`, }); } const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1; return Effect.succeed({ offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }); } // Worktrees get ports derived from their path so each one is stable across // restarts and distinct from its siblings. Without this every worktree starts // at offset 0 and scan-collides onto whatever happens to be free that minute, // so ports move under you between runs — which breaks any URL you already // shared. The main checkout keeps the documented 5733/13773. const worktreePath = config.worktreePath?.trim(); if (worktreePath) { const offset = ((Hash.string(worktreePath) >>> 0) % MAX_HASH_OFFSET) + 1; return Effect.succeed({ offset, source: `worktree ${worktreePath}` }); } return Effect.succeed({ offset: 0, source: "default ports" }); } function resolveBaseDir(baseDir: string | undefined): Effect.Effect { return Effect.gen(function* () { const path = yield* Path.Path; const configured = baseDir?.trim(); if (configured) { return path.resolve(configured); } return yield* DEFAULT_T3_HOME; }); } interface CreateDevRunnerEnvInput { readonly mode: DevMode; readonly baseEnv: NodeJS.ProcessEnv; readonly serverOffset: number; readonly webOffset: number; readonly t3Home: string | undefined; readonly browser: boolean | undefined; readonly autoBootstrapProjectFromCwd: boolean | undefined; readonly logWebSocketEvents: boolean | undefined; readonly host: string | undefined; readonly port: number | undefined; readonly devUrl: URL | undefined; } export function createDevRunnerEnv({ mode, baseEnv, serverOffset, webOffset, t3Home, browser, autoBootstrapProjectFromCwd, logWebSocketEvents, host, port, devUrl, }: CreateDevRunnerEnvInput): Effect.Effect { return Effect.gen(function* () { const serverPort = port ?? BASE_SERVER_PORT + serverOffset; const webPort = BASE_WEB_PORT + webOffset; // Precedence (--home-dir > worktree .t3 > ambient T3CODE_HOME) is resolved // by the caller; an unset t3Home here genuinely means "use the default". const configuredBaseDir = t3Home?.trim() || undefined; const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir); const isDesktopMode = mode === "dev:desktop"; const output: NodeJS.ProcessEnv = { ...baseEnv, PORT: String(webPort), VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`, }; if (configuredBaseDir !== undefined) { output.T3CODE_HOME = resolvedBaseDir; } else { delete output.T3CODE_HOME; } // A dev-runner server is never launcher-managed. When the shell that runs // this script was itself spawned by the machine's managed t3 service (an // agent working inside T3 Code), these leak through and the child server // fails startup with "The service launcher started a different t3 version" // (serviceLauncherClient.ts resolveStartup). delete output.T3_SERVICE_LAUNCHER_CONTEXT; delete output.T3_BOOT_SERVICE_UNIT; if (!isDesktopMode) { output.T3CODE_PORT = String(serverPort); // HOST is Vite's own bind address, and the desktop branch below is the // only place we set it. An inherited one (an exported HOST, a container, // a `HOST=0.0.0.0 npm start` habit) would otherwise reach Vite and pin // its HMR socket to that address — see the `explicitHost` gate in // apps/web/vite.config.ts. Over a shared origin that is invisible: the // page loads and only HMR quietly dials the wrong machine. delete output.HOST; if (mode === "dev" || mode === "dev:web") { // Browser dev is single-origin: everything (including /ws) is proxied // through Vite, so the client must resolve its backend from // window.location.origin rather than a baked-in localhost URL. See // resolveConfiguredPrimaryTarget in apps/web/src/environments/primary/target.ts // — it only defers to the origin when both of these are absent. Baking // localhost here is what breaks any non-localhost origin (tailnet, LAN, // phone): the remote browser dials its own machine. delete output.VITE_HTTP_URL; delete output.VITE_WS_URL; // Deleting is not enough on its own: vite.config.ts calls loadRepoEnv, // which merges `.env`/`.env.local` *under* this env, so a developer // with either URL in their `.env` would get it back and silently lose // single-origin mode. This states the intent positively so Vite can // ignore those values rather than infer from their absence. output.T3CODE_SINGLE_ORIGIN_DEV = "1"; } else { output.VITE_HTTP_URL = `http://localhost:${serverPort}`; output.VITE_WS_URL = `ws://localhost:${serverPort}`; delete output.T3CODE_SINGLE_ORIGIN_DEV; } } else { output.T3CODE_PORT = String(serverPort); output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; // Desktop pins the renderer to loopback on purpose; an ambient marker // must not make Vite drop those URLs. delete output.T3CODE_SINGLE_ORIGIN_DEV; delete output.T3CODE_MODE; delete output.T3CODE_NO_BROWSER; delete output.T3CODE_HOST; } if (!isDesktopMode && host !== undefined) { output.T3CODE_HOST = host; } if (!isDesktopMode) { output.T3CODE_NO_BROWSER = browser === true ? "0" : "1"; } if (autoBootstrapProjectFromCwd !== undefined) { output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD = autoBootstrapProjectFromCwd ? "1" : "0"; } else { delete output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD; } if (logWebSocketEvents !== undefined) { output.T3CODE_LOG_WS_EVENTS = logWebSocketEvents ? "1" : "0"; } else { delete output.T3CODE_LOG_WS_EVENTS; } if (mode === "dev") { output.T3CODE_MODE = "web"; delete output.T3CODE_DESKTOP_WS_URL; } if (mode === "dev:server" || mode === "dev:web") { output.T3CODE_MODE = "web"; delete output.T3CODE_DESKTOP_WS_URL; } if (isDesktopMode) { output.HOST = DESKTOP_DEV_LOOPBACK_HOST; delete output.T3CODE_DESKTOP_WS_URL; } return output; }); } function portPairForOffset(offset: number): { readonly serverPort: number; readonly webPort: number; } { return { serverPort: BASE_SERVER_PORT + offset, webPort: BASE_WEB_PORT + offset, }; } export function checkPortAvailabilityOnHosts( port: number, hosts: ReadonlyArray, canListenOnHost: (port: number, host: string) => Effect.Effect, ): Effect.Effect { return Effect.gen(function* () { for (const host of hosts) { if (!(yield* canListenOnHost(port, host))) { return false; } } return true; }); } /** * Hosts to probe for a dev server bound to `configuredHost`. * * Loopback is always checked because the web server and the desktop renderer * target reach it there. When `--host`/`T3CODE_HOST` moves the backend onto * another interface, that interface decides whether the bind actually * succeeds — probing only loopback would hand back a port that is free here * and taken there, and the server would fail to start. * * `configuredHost` applies to the *backend* only. Vite takes its bind address * from `HOST`, which the runner sets for desktop alone, so the web port stays * on loopback and must not be judged against the backend's interface — * a port free on loopback but busy on that interface would otherwise be * rejected for a server that was never going to bind there. */ export function devPortProbeHosts(configuredHost: string | undefined): ReadonlyArray { const host = configuredHost?.trim(); if (!host || DEV_PORT_PROBE_HOSTS.includes(host as (typeof DEV_PORT_PROBE_HOSTS)[number])) { return DEV_PORT_PROBE_HOSTS; } return [...DEV_PORT_PROBE_HOSTS, host]; } const makeDefaultCheckPortAvailability = (configuredHost: string | undefined): PortAvailabilityCheck => (port, role) => Effect.gen(function* () { const net = yield* NetService.NetService; const hosts = role === "web" ? DEV_PORT_PROBE_HOSTS : devPortProbeHosts(configuredHost); return yield* checkPortAvailabilityOnHosts(port, hosts, (candidatePort, host) => net.canListenOnHost(candidatePort, host), ); }); const defaultCheckPortAvailability = makeDefaultCheckPortAvailability(undefined); interface FindFirstAvailableOffsetInput { readonly startOffset: number; readonly requireServerPort: boolean; readonly requireWebPort: boolean; readonly checkPortAvailability?: PortAvailabilityCheck; } export function findFirstAvailableOffset({ startOffset, requireServerPort, requireWebPort, checkPortAvailability, }: FindFirstAvailableOffsetInput): Effect.Effect { return Effect.gen(function* () { const checkPort = (checkPortAvailability ?? defaultCheckPortAvailability) as PortAvailabilityCheck; for (let candidate = startOffset; ; candidate += 1) { const { serverPort, webPort } = portPairForOffset(candidate); const serverPortOutOfRange = serverPort > MAX_PORT; const webPortOutOfRange = webPort > MAX_PORT; if ( (requireServerPort && serverPortOutOfRange) || (requireWebPort && webPortOutOfRange) || (!requireServerPort && !requireWebPort && (serverPortOutOfRange || webPortOutOfRange)) ) { break; } if (requireWebPort && !isBrowserAllowedPort(webPort)) { continue; } const checks: Array> = []; if (requireServerPort) { checks.push(checkPort(serverPort, "server")); } if (requireWebPort) { checks.push(checkPort(webPort, "web")); } if (checks.length === 0) { return candidate; } const availability = yield* Effect.all(checks); if (availability.every(Boolean)) { return candidate; } } return yield* new DevRunnerPortExhaustedError({ startOffset, requireServerPort, requireWebPort, baseServerPort: BASE_SERVER_PORT, baseWebPort: BASE_WEB_PORT, maximumPort: MAX_PORT, }); }); } interface ResolveModePortOffsetsInput { readonly mode: DevMode; readonly startOffset: number; readonly hasExplicitServerPort: boolean; readonly hasExplicitDevUrl: boolean; readonly checkPortAvailability?: PortAvailabilityCheck; } export function resolveModePortOffsets({ mode, startOffset, hasExplicitServerPort, hasExplicitDevUrl, checkPortAvailability, }: ResolveModePortOffsetsInput): Effect.Effect< { readonly serverOffset: number; readonly webOffset: number }, DevRunnerPortExhaustedError, R > { return Effect.gen(function* () { const checkPort = (checkPortAvailability ?? defaultCheckPortAvailability) as PortAvailabilityCheck; if (mode === "dev:web") { if (hasExplicitDevUrl) { return { serverOffset: startOffset, webOffset: startOffset }; } const webOffset = yield* findFirstAvailableOffset({ startOffset, requireServerPort: false, requireWebPort: true, checkPortAvailability: checkPort, }); return { serverOffset: startOffset, webOffset }; } if (mode === "dev:server") { if (hasExplicitServerPort) { return { serverOffset: startOffset, webOffset: startOffset }; } const serverOffset = yield* findFirstAvailableOffset({ startOffset, requireServerPort: true, requireWebPort: false, checkPortAvailability: checkPort, }); return { serverOffset, webOffset: serverOffset }; } const sharedOffset = yield* findFirstAvailableOffset({ startOffset, requireServerPort: !hasExplicitServerPort, requireWebPort: !hasExplicitDevUrl, checkPortAvailability: checkPort, }); return { serverOffset: sharedOffset, webOffset: sharedOffset }; }); } interface DevRunnerCliInput { readonly mode: DevMode; readonly t3Home: string | undefined; readonly browser: boolean | undefined; readonly autoBootstrapProjectFromCwd: boolean | undefined; readonly logWebSocketEvents: boolean | undefined; readonly host: string | undefined; readonly port: number | undefined; readonly devUrl: URL | undefined; readonly dryRun: boolean; readonly share: boolean; readonly runArgs: ReadonlyArray; } export function runDevRunnerWithInput(input: DevRunnerCliInput) { return Effect.gen(function* () { const { portOffset, devInstance } = yield* OffsetConfig.pipe( Effect.mapError( (cause) => new DevRunnerConfigurationError({ configKeys: ["T3CODE_PORT_OFFSET", "T3CODE_DEV_INSTANCE"], cause, }), ), ); // Single-origin browser dev proxies the backend at localhost. A wildcard // bind still answers there; a specific non-loopback interface does not, // which breaks every proxied request in a way that reads as "server is // broken" rather than "flag combination is unsupported". Reject it up // front instead. (dev:server and dev:desktop don't proxy — untouched.) if ( (input.mode === "dev" || input.mode === "dev:web") && input.host !== undefined && !isProxiableBindHost(input.host) ) { return yield* new DevRunnerHostNotProxiableError({ mode: input.mode, host: input.host }); } const worktreePath = yield* resolveGitWorktreePath(yield* HostProcessWorkingDirectory); const { offset, source } = yield* resolveOffset({ portOffset, devInstance, worktreePath, }); const { serverOffset, webOffset } = yield* resolveModePortOffsets({ mode: input.mode, startOffset: offset, hasExplicitServerPort: input.port !== undefined, hasExplicitDevUrl: input.devUrl !== undefined, // A non-loopback bind host decides whether the backend can actually take // the port, so it has to be probed alongside loopback. checkPortAvailability: makeDefaultCheckPortAvailability(input.host), }); const hostEnvironment = yield* HostProcessEnvironment; // A dev server started inside a worktree defaults to that worktree's own // (gitignored) `.t3` — see @t3tools/shared/devHome for why this must // outrank an ambient T3CODE_HOME. `--home-dir` still wins. const worktreeHome = yield* resolveWorktreeT3Home(yield* HostProcessWorkingDirectory); // Trim before choosing: `--home-dir ""` is not a selection, and treating it // as one would skip the worktree default and land on the shared home — // exactly the outcome this precedence exists to prevent. const resolvedT3Home = (input.t3Home?.trim() || undefined) ?? worktreeHome ?? (hostEnvironment.T3CODE_HOME?.trim() || undefined); const env = yield* createDevRunnerEnv({ mode: input.mode, baseEnv: hostEnvironment, serverOffset, webOffset, t3Home: resolvedT3Home, browser: input.browser, autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd, logWebSocketEvents: input.logWebSocketEvents, host: input.host, port: input.port, devUrl: input.devUrl, }); const selectionSuffix = serverOffset !== offset || webOffset !== offset ? ` selectedOffset(server=${serverOffset},web=${webOffset})` : ""; const baseDir = env.T3CODE_HOME ?? (yield* DEFAULT_T3_HOME); yield* Effect.logInfo( `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`, ); // Before the share block: --dry-run only resolves and prints. Sharing would // replace, then tear down, whatever mapping the port already had — a // surprising side effect from a command documented as inert. if (input.dryRun) { return; } const sharedWebPort = BASE_WEB_PORT + webOffset; if (input.share) { if (input.mode === "dev:server") { yield* Effect.logInfo("[dev-runner] --share has no effect for dev:server (no web server)."); } else if (input.mode === "dev:desktop") { // Desktop is not single-origin: the renderer gets VITE_HTTP_URL and // VITE_WS_URL baked to loopback, so a tailnet visitor would load the UI // and then watch it dial its own 127.0.0.1 for the backend. Worse, // sharing would overwrite VITE_DEV_SERVER_URL, which is the origin // Electron itself loads the renderer from. Refuse rather than hand out // a URL that is broken in a way the user cannot see. yield* Effect.logWarning( "[dev-runner] --share is not supported for dev:desktop (the renderer is pinned to loopback). Use `dev`, which runs the whole browser stack.", ); } else { // acquireRelease, not share-then-addFinalizer: the mapping outlives this // process (and reboots), so the cleanup has to be registered atomically // with creating it. An interrupt landing in between would otherwise // leave a mapping pointing at a port nothing is listening on. // // Deliberately no ownership tracking beyond that: if a second runner // takes this port during a fast restart, the first's exit can briefly // tear down the new mapping — visible (the URL stops working) and fixed // by re-running --share. A lease protocol closing that window existed // and was removed as more machinery than a dev convenience warrants. // // A tailnet that isn't up shouldn't stop the dev server from starting — // warn, and carry on serving locally. const shared = yield* Effect.acquireRelease( shareDevServer({ webPort: sharedWebPort }), () => // Serve config outlives this process, so a cleanup that did not // take leaves a tailnet URL pointing at a port nothing serves. unshareDevServer(sharedWebPort).pipe( Effect.flatMap((result) => result.cleared ? Effect.void : Effect.logWarning( `[dev-runner] could not remove the tailnet mapping for port ${String(sharedWebPort)}${ result.explanation ? `: ${result.explanation}` : "" }. Remove it with \`tailscale serve --https=${String(sharedWebPort)} off\`.`, ), ), ), ).pipe( Effect.tapError((error: DevShareError) => Effect.logWarning( `[dev-runner] could not share on the tailnet: ${error.message}${ error.hint ? ` — ${error.hint}` : "" }`, ), ), Effect.option, Effect.map(Option.getOrUndefined), ); if (shared) { // The app is reached from the tailnet origin. Vite already allows // *.ts.net hosts; the backend needs the origin for credentialed // requests that bypass the proxy (desktop renderer, direct calls). env.T3CODE_DEV_ALLOWED_ORIGINS = [ env.T3CODE_DEV_ALLOWED_ORIGINS, new URL(shared.url).origin, ] .filter((entry) => entry && entry.length > 0) .join(","); // The server builds its pairing URL from this, so the URL printed at // startup is already the shareable one — no rewriting by hand. An // explicit --dev-url still wins. if (input.devUrl === undefined) { env.VITE_DEV_SERVER_URL = shared.url; } // A shared origin serves a remote browser, where unbundled dev's // per-module requests each pay a tailnet round trip — a cold module // graph takes minutes to first paint. Bundled dev collapses that to // a few chunk requests. Only defaulted, so T3CODE_BUNDLED_DEV=0 // still opts a --share run back out. if (env.T3CODE_BUNDLED_DEV === undefined) { env.T3CODE_BUNDLED_DEV = "1"; } yield* Effect.logInfo(`[dev-runner] shared on tailnet: ${shared.url}`); } } } const spawnCommand = yield* resolveSpawnCommand( "vp", [...MODE_ARGS[input.mode], ...input.runArgs], { env }, ); const processContext = { mode: input.mode, executable: "vp" as const, argumentCount: spawnCommand.args.length, shell: spawnCommand.shell, } as const; const child = yield* ChildProcess.make(spawnCommand.command, spawnCommand.args, { stdin: "inherit", stdout: "inherit", stderr: "inherit", env, extendEnv: false, shell: spawnCommand.shell, // Keep Vite+ in the same process group so terminal signals (Ctrl+C) // reach it directly. Effect defaults to detached: true on non-Windows, // which would put the runner in a new group and require manual forwarding. detached: false, forceKillAfter: "1500 millis", }).pipe( Effect.mapError( (cause) => new DevRunnerProcessError({ ...processContext, operation: "spawn", cause, }), ), ); const exitCode = yield* child.exitCode.pipe( Effect.mapError( (cause) => new DevRunnerProcessError({ ...processContext, operation: "wait-for-exit", cause, }), ), ); if (exitCode !== 0) { return yield* new DevRunnerProcessExitError({ ...processContext, exitCode, }); } }); } const devRunnerCli = Command.make("dev-runner", { mode: Argument.choice("mode", DEV_RUNNER_MODES).pipe( Argument.withDescription("Development mode to run."), ), t3Home: Flag.string("home-dir").pipe( Flag.withDescription( "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.", ), Flag.optional, Flag.map(Option.getOrUndefined), ), browser: Flag.boolean("browser").pipe( Flag.withDescription("Open a browser automatically (disabled by default for web dev)."), ), autoBootstrapProjectFromCwd: Flag.boolean("auto-bootstrap-project-from-cwd").pipe( Flag.withDescription( "Auto-bootstrap toggle (equivalent to T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD).", ), Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD")), ), logWebSocketEvents: Flag.boolean("log-websocket-events").pipe( Flag.withDescription("WebSocket event logging toggle (equivalent to T3CODE_LOG_WS_EVENTS)."), Flag.withAlias("log-ws-events"), Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_LOG_WS_EVENTS")), ), host: Flag.string("host").pipe( Flag.withDescription("Server host/interface override (forwards to T3CODE_HOST)."), Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOST")), ), port: Flag.integer("port").pipe( Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), Flag.withDescription("Server port override (forwards to T3CODE_PORT)."), Flag.withFallbackConfig(optionalPortConfig("T3CODE_PORT")), ), devUrl: Flag.string("dev-url").pipe( Flag.withSchema(Schema.URLFromString), Flag.withDescription( "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.", ), Flag.optional, Flag.map(Option.getOrUndefined), ), dryRun: Flag.boolean("dry-run").pipe( Flag.withDescription("Resolve mode/ports/env and print, but do not spawn Vite+."), Flag.withDefault(false), ), share: Flag.boolean("share").pipe( Flag.withDescription( "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.", ), Flag.withDefault(false), ), runArgs: Argument.string("run-arg").pipe( Argument.withDescription("Additional Vite+ run args (pass after `--`)."), Argument.variadic(), ), }).pipe( Command.withDescription("Run monorepo development modes with deterministic port/env wiring."), Command.withHandler((input) => runDevRunnerWithInput(input)), ); const cliRuntimeLayer = Layer.mergeAll( Logger.layer([Logger.consolePretty()]), NodeServices.layer, NetService.layer, ); if (import.meta.main) { Command.run(devRunnerCli, { version: "0.0.0" }).pipe( Effect.scoped, Effect.provide(cliRuntimeLayer), NodeRuntime.runMain, ); }