#!/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-blockingconst 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:<port>`, 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<string, ReadonlyArray<string>>; 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<R = never> = ( port: number, role?: "server" | "web",) => Effect.Effect<boolean, never, R>; const DEV_RUNNER_MODES = Object.keys(MODE_ARGS) as Array<DevMode>; export function getDevRunnerModeArgs(mode: DevMode): ReadonlyArray<string> { return MODE_ARGS[mode];} export function isBrowserAllowedPort(port: number): boolean { return !FETCH_BAD_PORTS.has(port);} export class DevRunnerConfigurationError extends Schema.TaggedError<DevRunnerConfigurationError>()( "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.TaggedError<DevRunnerInvalidPortOffsetError>()( "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.TaggedError<DevRunnerPortExhaustedError>()( "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.TaggedError<DevRunnerProcessError>()( "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.TaggedError<DevRunnerProcessExitError>()( "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.TaggedError<DevRunnerHostNotProxiableError>()( "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<string | undefined> => Config.string(name).pipe( Config.option, Config.map((value) => Option.getOrUndefined(value)), );const optionalBooleanConfig = (name: string): Config.Config<boolean | undefined> => Config.boolean(name).pipe( Config.option, Config.map((value) => Option.getOrUndefined(value)), );const optionalPortConfig = (name: string): Config.Config<number | undefined> => Config.port(name).pipe( Config.option, Config.map((value) => Option.getOrUndefined(value)), );const optionalIntegerConfig = (name: string): Config.Config<number | undefined> => 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<string, never, Path.Path> { 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<NodeJS.ProcessEnv, never, Path.Path> { 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<R>( port: number, hosts: ReadonlyArray<string>, canListenOnHost: (port: number, host: string) => Effect.Effect<boolean, never, R>,): Effect.Effect<boolean, never, R> { 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<string> { 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<NetService.NetService> => (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<R = NetService.NetService> { readonly startOffset: number; readonly requireServerPort: boolean; readonly requireWebPort: boolean; readonly checkPortAvailability?: PortAvailabilityCheck<R>;} Lines 1–500 of 931. Load more
#!/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:<port>`, 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<string, ReadonlyArray<string>>; 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<R = never> = ( port: number, role?: "server" | "web", ) => Effect.Effect<boolean, never, R>; const DEV_RUNNER_MODES = Object.keys(MODE_ARGS) as Array<DevMode>; export function getDevRunnerModeArgs(mode: DevMode): ReadonlyArray<string> { return MODE_ARGS[mode]; } export function isBrowserAllowedPort(port: number): boolean { return !FETCH_BAD_PORTS.has(port); } export class DevRunnerConfigurationError extends Schema.TaggedError<DevRunnerConfigurationError>()( "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.TaggedError<DevRunnerInvalidPortOffsetError>()( "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.TaggedError<DevRunnerPortExhaustedError>()( "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.TaggedError<DevRunnerProcessError>()( "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.TaggedError<DevRunnerProcessExitError>()( "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.TaggedError<DevRunnerHostNotProxiableError>()( "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<string | undefined> => Config.string(name).pipe( Config.option, Config.map((value) => Option.getOrUndefined(value)), ); const optionalBooleanConfig = (name: string): Config.Config<boolean | undefined> => Config.boolean(name).pipe( Config.option, Config.map((value) => Option.getOrUndefined(value)), ); const optionalPortConfig = (name: string): Config.Config<number | undefined> => Config.port(name).pipe( Config.option, Config.map((value) => Option.getOrUndefined(value)), ); const optionalIntegerConfig = (name: string): Config.Config<number | undefined> => 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<string, never, Path.Path> { 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<NodeJS.ProcessEnv, never, Path.Path> { 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<R>( port: number, hosts: ReadonlyArray<string>, canListenOnHost: (port: number, host: string) => Effect.Effect<boolean, never, R>, ): Effect.Effect<boolean, never, R> { 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<string> { 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<NetService.NetService> => (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<R = NetService.NetService> { readonly startOffset: number; readonly requireServerPort: boolean; readonly requireWebPort: boolean; readonly checkPortAvailability?: PortAvailabilityCheck<R>; }