911 lines · 32.8 KB · TypeScript
RawDownload
#!/usr/bin/env node import * as NodeOS from "node:os";import * as NodeCrypto from "node:crypto"; 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 { acquireDevShare, cleanupOwnedDevShare, type DevShareError } 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.TaggedErrorClass<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.TaggedErrorClass<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.TaggedErrorClass<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.TaggedErrorClass<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.TaggedErrorClass<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.TaggedErrorClass<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;    }     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>;} export function findFirstAvailableOffset<R = NetService.NetService>({  startOffset,  requireServerPort,  requireWebPort,  checkPortAvailability,}: FindFirstAvailableOffsetInput<R>): Effect.Effect<number, DevRunnerPortExhaustedError, R> {  return Effect.gen(function* () {

Lines 1–500 of 911.