t3-code-android-nightly/scripts/dev-runner.ts
Julius Marminge 194c73f3f9
chore(deps): upgrade Effect to stable 4.0.1 (#16138)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:15:14 -07:00

921 lines
34 KiB
JavaScript

#!/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/cli";
import { ChildProcess } from "effect/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.`;
}
}
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;
delete output.T3CODE_DEV_AUTH_TOKEN;
}
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>;
}
export function findFirstAvailableOffset<R = NetService.NetService>({
startOffset,
requireServerPort,
requireWebPort,
checkPortAvailability,
}: FindFirstAvailableOffsetInput<R>): Effect.Effect<number, DevRunnerPortExhaustedError, R> {
return Effect.gen(function* () {
const checkPort = (checkPortAvailability ??
defaultCheckPortAvailability) as PortAvailabilityCheck<R>;
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<Effect.Effect<boolean, never, R>> = [];
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<R = NetService.NetService> {
readonly mode: DevMode;
readonly startOffset: number;
readonly hasExplicitServerPort: boolean;
readonly hasExplicitDevUrl: boolean;
readonly checkPortAvailability?: PortAvailabilityCheck<R>;
}
export function resolveModePortOffsets<R = NetService.NetService>({
mode,
startOffset,
hasExplicitServerPort,
hasExplicitDevUrl,
checkPortAvailability,
}: ResolveModePortOffsetsInput<R>): Effect.Effect<
{ readonly serverOffset: number; readonly webOffset: number },
DevRunnerPortExhaustedError,
R
> {
return Effect.gen(function* () {
const checkPort = (checkPortAvailability ??
defaultCheckPortAvailability) as PortAvailabilityCheck<R>;
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<string>;
}
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.Literals("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)."),
Flag.withDefault(false),
),
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.Int("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,
);
}