| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | import * as NodeOS from "node:os"; |
| 4 | |
| 5 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | import * as NetService from "@t3tools/shared/Net"; |
| 8 | import { resolveGitWorktreePath, resolveWorktreeT3Home } from "@t3tools/shared/devHome"; |
| 9 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 10 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 11 | import * as Config from "effect/Config"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Hash from "effect/Hash"; |
| 14 | import * as Layer from "effect/Layer"; |
| 15 | import * as Logger from "effect/Logger"; |
| 16 | import * as Option from "effect/Option"; |
| 17 | import * as Path from "effect/Path"; |
| 18 | import * as Schema from "effect/Schema"; |
| 19 | import { Argument, Command, Flag } from "effect/cli"; |
| 20 | import { ChildProcess } from "effect/process"; |
| 21 | |
| 22 | import { type DevShareError, shareDevServer, unshareDevServer } from "./lib/dev-share.ts"; |
| 23 | import { loadRepoEnv } from "./lib/public-config.ts"; |
| 24 | |
| 25 | Object.assign(process.env, loadRepoEnv()); |
| 26 | |
| 27 | const BASE_SERVER_PORT = 13773; |
| 28 | const BASE_WEB_PORT = 5733; |
| 29 | const MAX_HASH_OFFSET = 3000; |
| 30 | const MAX_PORT = 65535; |
| 31 | const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; |
| 32 | // HTTP(S) requests to these ports are blocked by the Fetch standard before a |
| 33 | // browser reaches the network. Keep the complete list here so explicit or |
| 34 | // future wider offsets cannot produce a URL that curl accepts but browsers |
| 35 | // reject. https://fetch.spec.whatwg.org/#port-blocking |
| 36 | const FETCH_BAD_PORTS = new Set([ |
| 37 | 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, |
| 38 | 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, |
| 39 | 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, |
| 40 | 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, |
| 41 | 6669, 6679, 6697, 10080, |
| 42 | ]); |
| 43 | // Dev servers bind loopback, so loopback is the only interface whose |
| 44 | // availability decides whether we can use a port. Probing wildcards too made |
| 45 | // the runner walk away from a perfectly free port whenever something else held |
| 46 | // the same number on another interface — `tailscale serve` does exactly that, |
| 47 | // which silently moved the ports out from under a URL that had just been shared. |
| 48 | const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "::1"] as const; |
| 49 | |
| 50 | /** |
| 51 | * Bind hosts on which a backend still answers `http://localhost:<port>`, which |
| 52 | * is where single-origin browser dev proxies to. Loopback and the wildcards |
| 53 | * qualify; a specific interface (e.g. a LAN IP) does not — the OS binds only |
| 54 | * that address and the proxy target goes dark. |
| 55 | */ |
| 56 | export function isProxiableBindHost(host: string): boolean { |
| 57 | const normalized = host.trim(); |
| 58 | return ( |
| 59 | normalized === "" || |
| 60 | normalized === "localhost" || |
| 61 | normalized === "127.0.0.1" || |
| 62 | normalized === "::1" || |
| 63 | normalized === "[::1]" || |
| 64 | normalized === "0.0.0.0" || |
| 65 | normalized === "::" || |
| 66 | normalized === "[::]" |
| 67 | ); |
| 68 | } |
| 69 | |
| 70 | export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => |
| 71 | path.join(NodeOS.homedir(), ".t3"), |
| 72 | ); |
| 73 | |
| 74 | const MODE_ARGS = { |
| 75 | dev: [ |
| 76 | "run", |
| 77 | "--filter=@t3tools/contracts", |
| 78 | "--filter=@t3tools/web", |
| 79 | "--filter=t3", |
| 80 | "--parallel", |
| 81 | "dev", |
| 82 | ], |
| 83 | "dev:server": ["run", "--filter=t3", "dev"], |
| 84 | "dev:web": ["run", "--filter=@t3tools/web", "dev"], |
| 85 | "dev:desktop": ["run", "--filter=@t3tools/desktop", "--filter=@t3tools/web", "dev"], |
| 86 | } as const satisfies Record<string, ReadonlyArray<string>>; |
| 87 | |
| 88 | type DevMode = keyof typeof MODE_ARGS; |
| 89 | /** |
| 90 | * `role` matters because only the backend honours `--host`/`T3CODE_HOST`; the |
| 91 | * web port is always loopback. Passed explicitly rather than inferred from the |
| 92 | * port number, which stops distinguishing them under a large port offset. |
| 93 | */ |
| 94 | type PortAvailabilityCheck<R = never> = ( |
| 95 | port: number, |
| 96 | role?: "server" | "web", |
| 97 | ) => Effect.Effect<boolean, never, R>; |
| 98 | |
| 99 | const DEV_RUNNER_MODES = Object.keys(MODE_ARGS) as Array<DevMode>; |
| 100 | |
| 101 | export function getDevRunnerModeArgs(mode: DevMode): ReadonlyArray<string> { |
| 102 | return MODE_ARGS[mode]; |
| 103 | } |
| 104 | |
| 105 | export function isBrowserAllowedPort(port: number): boolean { |
| 106 | return !FETCH_BAD_PORTS.has(port); |
| 107 | } |
| 108 | |
| 109 | export class DevRunnerConfigurationError extends Schema.TaggedError<DevRunnerConfigurationError>()( |
| 110 | "DevRunnerConfigurationError", |
| 111 | { |
| 112 | configKeys: Schema.Array(Schema.String), |
| 113 | cause: Schema.Defect(), |
| 114 | }, |
| 115 | ) { |
| 116 | override get message(): string { |
| 117 | return `Failed to read dev-runner configuration: ${this.configKeys.join(", ")}.`; |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | export class DevRunnerInvalidPortOffsetError extends Schema.TaggedError<DevRunnerInvalidPortOffsetError>()( |
| 122 | "DevRunnerInvalidPortOffsetError", |
| 123 | { |
| 124 | configKey: Schema.Literal("T3CODE_PORT_OFFSET"), |
| 125 | portOffset: Schema.Number, |
| 126 | minimum: Schema.Number, |
| 127 | }, |
| 128 | ) { |
| 129 | override get message(): string { |
| 130 | return `${this.configKey} must be at least ${this.minimum}; received ${this.portOffset}.`; |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | export class DevRunnerPortExhaustedError extends Schema.TaggedError<DevRunnerPortExhaustedError>()( |
| 135 | "DevRunnerPortExhaustedError", |
| 136 | { |
| 137 | startOffset: Schema.Number, |
| 138 | requireServerPort: Schema.Boolean, |
| 139 | requireWebPort: Schema.Boolean, |
| 140 | baseServerPort: Schema.Number, |
| 141 | baseWebPort: Schema.Number, |
| 142 | maximumPort: Schema.Number, |
| 143 | }, |
| 144 | ) { |
| 145 | override get message(): string { |
| 146 | return `No required dev ports were available from offset ${this.startOffset} through maximum port ${this.maximumPort}.`; |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | export class DevRunnerProcessError extends Schema.TaggedError<DevRunnerProcessError>()( |
| 151 | "DevRunnerProcessError", |
| 152 | { |
| 153 | operation: Schema.Literals(["spawn", "wait-for-exit"]), |
| 154 | mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]), |
| 155 | executable: Schema.Literal("vp"), |
| 156 | argumentCount: Schema.Number, |
| 157 | shell: Schema.Boolean, |
| 158 | cause: Schema.Defect(), |
| 159 | }, |
| 160 | ) { |
| 161 | override get message(): string { |
| 162 | return `Dev-runner process operation "${this.operation}" failed for mode "${this.mode}".`; |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | export class DevRunnerProcessExitError extends Schema.TaggedError<DevRunnerProcessExitError>()( |
| 167 | "DevRunnerProcessExitError", |
| 168 | { |
| 169 | mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]), |
| 170 | executable: Schema.Literal("vp"), |
| 171 | argumentCount: Schema.Number, |
| 172 | shell: Schema.Boolean, |
| 173 | exitCode: Schema.Number, |
| 174 | }, |
| 175 | ) { |
| 176 | override get message(): string { |
| 177 | return `Dev-runner process exited with code ${this.exitCode} in mode "${this.mode}".`; |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | export class DevRunnerHostNotProxiableError extends Schema.TaggedError<DevRunnerHostNotProxiableError>()( |
| 182 | "DevRunnerHostNotProxiableError", |
| 183 | { |
| 184 | mode: Schema.Literals(["dev", "dev:web"]), |
| 185 | host: Schema.String, |
| 186 | }, |
| 187 | ) { |
| 188 | override get message(): string { |
| 189 | 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.`; |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | const optionalStringConfig = (name: string): Config.Config<string | undefined> => |
| 194 | Config.String(name).pipe( |
| 195 | Config.option, |
| 196 | Config.map((value) => Option.getOrUndefined(value)), |
| 197 | ); |
| 198 | const optionalBooleanConfig = (name: string): Config.Config<boolean | undefined> => |
| 199 | Config.Boolean(name).pipe( |
| 200 | Config.option, |
| 201 | Config.map((value) => Option.getOrUndefined(value)), |
| 202 | ); |
| 203 | const optionalPortConfig = (name: string): Config.Config<number | undefined> => |
| 204 | Config.Port(name).pipe( |
| 205 | Config.option, |
| 206 | Config.map((value) => Option.getOrUndefined(value)), |
| 207 | ); |
| 208 | const optionalIntegerConfig = (name: string): Config.Config<number | undefined> => |
| 209 | Config.Int(name).pipe( |
| 210 | Config.option, |
| 211 | Config.map((value) => Option.getOrUndefined(value)), |
| 212 | ); |
| 213 | const OffsetConfig = Config.all({ |
| 214 | portOffset: optionalIntegerConfig("T3CODE_PORT_OFFSET"), |
| 215 | devInstance: optionalStringConfig("T3CODE_DEV_INSTANCE"), |
| 216 | }); |
| 217 | |
| 218 | export function resolveOffset(config: { |
| 219 | readonly portOffset: number | undefined; |
| 220 | readonly devInstance: string | undefined; |
| 221 | readonly worktreePath?: string | undefined; |
| 222 | }): Effect.Effect< |
| 223 | { readonly offset: number; readonly source: string }, |
| 224 | DevRunnerInvalidPortOffsetError |
| 225 | > { |
| 226 | if (config.portOffset !== undefined) { |
| 227 | if (config.portOffset < 0) { |
| 228 | return Effect.fail( |
| 229 | new DevRunnerInvalidPortOffsetError({ |
| 230 | configKey: "T3CODE_PORT_OFFSET", |
| 231 | portOffset: config.portOffset, |
| 232 | minimum: 0, |
| 233 | }), |
| 234 | ); |
| 235 | } |
| 236 | return Effect.succeed({ |
| 237 | offset: config.portOffset, |
| 238 | source: `T3CODE_PORT_OFFSET=${config.portOffset}`, |
| 239 | }); |
| 240 | } |
| 241 | |
| 242 | const seed = config.devInstance?.trim(); |
| 243 | if (seed) { |
| 244 | if (/^\d+$/.test(seed)) { |
| 245 | return Effect.succeed({ |
| 246 | offset: Number(seed), |
| 247 | source: `numeric T3CODE_DEV_INSTANCE=${seed}`, |
| 248 | }); |
| 249 | } |
| 250 | |
| 251 | const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1; |
| 252 | return Effect.succeed({ offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }); |
| 253 | } |
| 254 | |
| 255 | // Worktrees get ports derived from their path so each one is stable across |
| 256 | // restarts and distinct from its siblings. Without this every worktree starts |
| 257 | // at offset 0 and scan-collides onto whatever happens to be free that minute, |
| 258 | // so ports move under you between runs — which breaks any URL you already |
| 259 | // shared. The main checkout keeps the documented 5733/13773. |
| 260 | const worktreePath = config.worktreePath?.trim(); |
| 261 | if (worktreePath) { |
| 262 | const offset = ((Hash.string(worktreePath) >>> 0) % MAX_HASH_OFFSET) + 1; |
| 263 | return Effect.succeed({ offset, source: `worktree ${worktreePath}` }); |
| 264 | } |
| 265 | |
| 266 | return Effect.succeed({ offset: 0, source: "default ports" }); |
| 267 | } |
| 268 | |
| 269 | function resolveBaseDir(baseDir: string | undefined): Effect.Effect<string, never, Path.Path> { |
| 270 | return Effect.gen(function* () { |
| 271 | const path = yield* Path.Path; |
| 272 | const configured = baseDir?.trim(); |
| 273 | |
| 274 | if (configured) { |
| 275 | return path.resolve(configured); |
| 276 | } |
| 277 | |
| 278 | return yield* DEFAULT_T3_HOME; |
| 279 | }); |
| 280 | } |
| 281 | |
| 282 | interface CreateDevRunnerEnvInput { |
| 283 | readonly mode: DevMode; |
| 284 | readonly baseEnv: NodeJS.ProcessEnv; |
| 285 | readonly serverOffset: number; |
| 286 | readonly webOffset: number; |
| 287 | readonly t3Home: string | undefined; |
| 288 | readonly browser: boolean | undefined; |
| 289 | readonly autoBootstrapProjectFromCwd: boolean | undefined; |
| 290 | readonly logWebSocketEvents: boolean | undefined; |
| 291 | readonly host: string | undefined; |
| 292 | readonly port: number | undefined; |
| 293 | readonly devUrl: URL | undefined; |
| 294 | } |
| 295 | |
| 296 | export function createDevRunnerEnv({ |
| 297 | mode, |
| 298 | baseEnv, |
| 299 | serverOffset, |
| 300 | webOffset, |
| 301 | t3Home, |
| 302 | browser, |
| 303 | autoBootstrapProjectFromCwd, |
| 304 | logWebSocketEvents, |
| 305 | host, |
| 306 | port, |
| 307 | devUrl, |
| 308 | }: CreateDevRunnerEnvInput): Effect.Effect<NodeJS.ProcessEnv, never, Path.Path> { |
| 309 | return Effect.gen(function* () { |
| 310 | const serverPort = port ?? BASE_SERVER_PORT + serverOffset; |
| 311 | const webPort = BASE_WEB_PORT + webOffset; |
| 312 | // Precedence (--home-dir > worktree .t3 > ambient T3CODE_HOME) is resolved |
| 313 | // by the caller; an unset t3Home here genuinely means "use the default". |
| 314 | const configuredBaseDir = t3Home?.trim() || undefined; |
| 315 | const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir); |
| 316 | const isDesktopMode = mode === "dev:desktop"; |
| 317 | |
| 318 | const output: NodeJS.ProcessEnv = { |
| 319 | ...baseEnv, |
| 320 | PORT: String(webPort), |
| 321 | VITE_DEV_SERVER_URL: |
| 322 | devUrl?.toString() ?? |
| 323 | `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`, |
| 324 | }; |
| 325 | |
| 326 | if (configuredBaseDir !== undefined) { |
| 327 | output.T3CODE_HOME = resolvedBaseDir; |
| 328 | } else { |
| 329 | delete output.T3CODE_HOME; |
| 330 | } |
| 331 | |
| 332 | // A dev-runner server is never launcher-managed. When the shell that runs |
| 333 | // this script was itself spawned by the machine's managed t3 service (an |
| 334 | // agent working inside T3 Code), these leak through and the child server |
| 335 | // fails startup with "The service launcher started a different t3 version" |
| 336 | // (serviceLauncherClient.ts resolveStartup). |
| 337 | delete output.T3_SERVICE_LAUNCHER_CONTEXT; |
| 338 | delete output.T3_BOOT_SERVICE_UNIT; |
| 339 | |
| 340 | if (!isDesktopMode) { |
| 341 | output.T3CODE_PORT = String(serverPort); |
| 342 | // HOST is Vite's own bind address, and the desktop branch below is the |
| 343 | // only place we set it. An inherited one (an exported HOST, a container, |
| 344 | // a `HOST=0.0.0.0 npm start` habit) would otherwise reach Vite and pin |
| 345 | // its HMR socket to that address — see the `explicitHost` gate in |
| 346 | // apps/web/vite.config.ts. Over a shared origin that is invisible: the |
| 347 | // page loads and only HMR quietly dials the wrong machine. |
| 348 | delete output.HOST; |
| 349 | if (mode === "dev" || mode === "dev:web") { |
| 350 | // Browser dev is single-origin: everything (including /ws) is proxied |
| 351 | // through Vite, so the client must resolve its backend from |
| 352 | // window.location.origin rather than a baked-in localhost URL. See |
| 353 | // resolveConfiguredPrimaryTarget in apps/web/src/environments/primary/target.ts |
| 354 | // — it only defers to the origin when both of these are absent. Baking |
| 355 | // localhost here is what breaks any non-localhost origin (tailnet, LAN, |
| 356 | // phone): the remote browser dials its own machine. |
| 357 | delete output.VITE_HTTP_URL; |
| 358 | delete output.VITE_WS_URL; |
| 359 | // Deleting is not enough on its own: vite.config.ts calls loadRepoEnv, |
| 360 | // which merges `.env`/`.env.local` *under* this env, so a developer |
| 361 | // with either URL in their `.env` would get it back and silently lose |
| 362 | // single-origin mode. This states the intent positively so Vite can |
| 363 | // ignore those values rather than infer from their absence. |
| 364 | output.T3CODE_SINGLE_ORIGIN_DEV = "1"; |
| 365 | } else { |
| 366 | output.VITE_HTTP_URL = `http://localhost:${serverPort}`; |
| 367 | output.VITE_WS_URL = `ws://localhost:${serverPort}`; |
| 368 | delete output.T3CODE_SINGLE_ORIGIN_DEV; |
| 369 | } |
| 370 | } else { |
| 371 | output.T3CODE_PORT = String(serverPort); |
| 372 | output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; |
| 373 | output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; |
| 374 | // Desktop pins the renderer to loopback on purpose; an ambient marker |
| 375 | // must not make Vite drop those URLs. |
| 376 | delete output.T3CODE_SINGLE_ORIGIN_DEV; |
| 377 | delete output.T3CODE_MODE; |
| 378 | delete output.T3CODE_NO_BROWSER; |
| 379 | delete output.T3CODE_HOST; |
| 380 | delete output.T3CODE_DEV_AUTH_TOKEN; |
| 381 | } |
| 382 | |
| 383 | if (!isDesktopMode && host !== undefined) { |
| 384 | output.T3CODE_HOST = host; |
| 385 | } |
| 386 | |
| 387 | if (!isDesktopMode) { |
| 388 | output.T3CODE_NO_BROWSER = browser === true ? "0" : "1"; |
| 389 | } |
| 390 | |
| 391 | if (autoBootstrapProjectFromCwd !== undefined) { |
| 392 | output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD = autoBootstrapProjectFromCwd ? "1" : "0"; |
| 393 | } else { |
| 394 | delete output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD; |
| 395 | } |
| 396 | |
| 397 | if (logWebSocketEvents !== undefined) { |
| 398 | output.T3CODE_LOG_WS_EVENTS = logWebSocketEvents ? "1" : "0"; |
| 399 | } else { |
| 400 | delete output.T3CODE_LOG_WS_EVENTS; |
| 401 | } |
| 402 | |
| 403 | if (mode === "dev") { |
| 404 | output.T3CODE_MODE = "web"; |
| 405 | delete output.T3CODE_DESKTOP_WS_URL; |
| 406 | } |
| 407 | |
| 408 | if (mode === "dev:server" || mode === "dev:web") { |
| 409 | output.T3CODE_MODE = "web"; |
| 410 | delete output.T3CODE_DESKTOP_WS_URL; |
| 411 | } |
| 412 | |
| 413 | if (isDesktopMode) { |
| 414 | output.HOST = DESKTOP_DEV_LOOPBACK_HOST; |
| 415 | delete output.T3CODE_DESKTOP_WS_URL; |
| 416 | } |
| 417 | |
| 418 | return output; |
| 419 | }); |
| 420 | } |
| 421 | |
| 422 | function portPairForOffset(offset: number): { |
| 423 | readonly serverPort: number; |
| 424 | readonly webPort: number; |
| 425 | } { |
| 426 | return { |
| 427 | serverPort: BASE_SERVER_PORT + offset, |
| 428 | webPort: BASE_WEB_PORT + offset, |
| 429 | }; |
| 430 | } |
| 431 | |
| 432 | export function checkPortAvailabilityOnHosts<R>( |
| 433 | port: number, |
| 434 | hosts: ReadonlyArray<string>, |
| 435 | canListenOnHost: (port: number, host: string) => Effect.Effect<boolean, never, R>, |
| 436 | ): Effect.Effect<boolean, never, R> { |
| 437 | return Effect.gen(function* () { |
| 438 | for (const host of hosts) { |
| 439 | if (!(yield* canListenOnHost(port, host))) { |
| 440 | return false; |
| 441 | } |
| 442 | } |
| 443 | |
| 444 | return true; |
| 445 | }); |
| 446 | } |
| 447 | |
| 448 | /** |
| 449 | * Hosts to probe for a dev server bound to `configuredHost`. |
| 450 | * |
| 451 | * Loopback is always checked because the web server and the desktop renderer |
| 452 | * target reach it there. When `--host`/`T3CODE_HOST` moves the backend onto |
| 453 | * another interface, that interface decides whether the bind actually |
| 454 | * succeeds — probing only loopback would hand back a port that is free here |
| 455 | * and taken there, and the server would fail to start. |
| 456 | * |
| 457 | * `configuredHost` applies to the *backend* only. Vite takes its bind address |
| 458 | * from `HOST`, which the runner sets for desktop alone, so the web port stays |
| 459 | * on loopback and must not be judged against the backend's interface — |
| 460 | * a port free on loopback but busy on that interface would otherwise be |
| 461 | * rejected for a server that was never going to bind there. |
| 462 | */ |
| 463 | export function devPortProbeHosts(configuredHost: string | undefined): ReadonlyArray<string> { |
| 464 | const host = configuredHost?.trim(); |
| 465 | if (!host || DEV_PORT_PROBE_HOSTS.includes(host as (typeof DEV_PORT_PROBE_HOSTS)[number])) { |
| 466 | return DEV_PORT_PROBE_HOSTS; |
| 467 | } |
| 468 | return [...DEV_PORT_PROBE_HOSTS, host]; |
| 469 | } |
| 470 | |
| 471 | const makeDefaultCheckPortAvailability = |
| 472 | (configuredHost: string | undefined): PortAvailabilityCheck<NetService.NetService> => |
| 473 | (port, role) => |
| 474 | Effect.gen(function* () { |
| 475 | const net = yield* NetService.NetService; |
| 476 | const hosts = role === "web" ? DEV_PORT_PROBE_HOSTS : devPortProbeHosts(configuredHost); |
| 477 | return yield* checkPortAvailabilityOnHosts(port, hosts, (candidatePort, host) => |
| 478 | net.canListenOnHost(candidatePort, host), |
| 479 | ); |
| 480 | }); |
| 481 | |
| 482 | const defaultCheckPortAvailability = makeDefaultCheckPortAvailability(undefined); |
| 483 | |
| 484 | interface FindFirstAvailableOffsetInput<R = NetService.NetService> { |
| 485 | readonly startOffset: number; |
| 486 | readonly requireServerPort: boolean; |
| 487 | readonly requireWebPort: boolean; |
| 488 | readonly checkPortAvailability?: PortAvailabilityCheck<R>; |
| 489 | } |
| 490 | |
| 491 | export function findFirstAvailableOffset<R = NetService.NetService>({ |
| 492 | startOffset, |
| 493 | requireServerPort, |
| 494 | requireWebPort, |
| 495 | checkPortAvailability, |
| 496 | }: FindFirstAvailableOffsetInput<R>): Effect.Effect<number, DevRunnerPortExhaustedError, R> { |
| 497 | return Effect.gen(function* () { |
| 498 | const checkPort = (checkPortAvailability ?? |
| 499 | defaultCheckPortAvailability) as PortAvailabilityCheck<R>; |
| 500 | |
| 501 | for (let candidate = startOffset; ; candidate += 1) { |
| 502 | const { serverPort, webPort } = portPairForOffset(candidate); |
| 503 | const serverPortOutOfRange = serverPort > MAX_PORT; |
| 504 | const webPortOutOfRange = webPort > MAX_PORT; |
| 505 | |
| 506 | if ( |
| 507 | (requireServerPort && serverPortOutOfRange) || |
| 508 | (requireWebPort && webPortOutOfRange) || |
| 509 | (!requireServerPort && !requireWebPort && (serverPortOutOfRange || webPortOutOfRange)) |
| 510 | ) { |
| 511 | break; |
| 512 | } |
| 513 | |
| 514 | if (requireWebPort && !isBrowserAllowedPort(webPort)) { |
| 515 | continue; |
| 516 | } |
| 517 | |
| 518 | const checks: Array<Effect.Effect<boolean, never, R>> = []; |
| 519 | if (requireServerPort) { |
| 520 | checks.push(checkPort(serverPort, "server")); |
| 521 | } |
| 522 | if (requireWebPort) { |
| 523 | checks.push(checkPort(webPort, "web")); |
| 524 | } |
| 525 | |
| 526 | if (checks.length === 0) { |
| 527 | return candidate; |
| 528 | } |
| 529 | |
| 530 | const availability = yield* Effect.all(checks); |
| 531 | if (availability.every(Boolean)) { |
| 532 | return candidate; |
| 533 | } |
| 534 | } |
| 535 | |
| 536 | return yield* new DevRunnerPortExhaustedError({ |
| 537 | startOffset, |
| 538 | requireServerPort, |
| 539 | requireWebPort, |
| 540 | baseServerPort: BASE_SERVER_PORT, |
| 541 | baseWebPort: BASE_WEB_PORT, |
| 542 | maximumPort: MAX_PORT, |
| 543 | }); |
| 544 | }); |
| 545 | } |
| 546 | |
| 547 | interface ResolveModePortOffsetsInput<R = NetService.NetService> { |
| 548 | readonly mode: DevMode; |
| 549 | readonly startOffset: number; |
| 550 | readonly hasExplicitServerPort: boolean; |
| 551 | readonly hasExplicitDevUrl: boolean; |
| 552 | readonly checkPortAvailability?: PortAvailabilityCheck<R>; |
| 553 | } |
| 554 | |
| 555 | export function resolveModePortOffsets<R = NetService.NetService>({ |
| 556 | mode, |
| 557 | startOffset, |
| 558 | hasExplicitServerPort, |
| 559 | hasExplicitDevUrl, |
| 560 | checkPortAvailability, |
| 561 | }: ResolveModePortOffsetsInput<R>): Effect.Effect< |
| 562 | { readonly serverOffset: number; readonly webOffset: number }, |
| 563 | DevRunnerPortExhaustedError, |
| 564 | R |
| 565 | > { |
| 566 | return Effect.gen(function* () { |
| 567 | const checkPort = (checkPortAvailability ?? |
| 568 | defaultCheckPortAvailability) as PortAvailabilityCheck<R>; |
| 569 | |
| 570 | if (mode === "dev:web") { |
| 571 | if (hasExplicitDevUrl) { |
| 572 | return { serverOffset: startOffset, webOffset: startOffset }; |
| 573 | } |
| 574 | |
| 575 | const webOffset = yield* findFirstAvailableOffset({ |
| 576 | startOffset, |
| 577 | requireServerPort: false, |
| 578 | requireWebPort: true, |
| 579 | checkPortAvailability: checkPort, |
| 580 | }); |
| 581 | return { serverOffset: startOffset, webOffset }; |
| 582 | } |
| 583 | |
| 584 | if (mode === "dev:server") { |
| 585 | if (hasExplicitServerPort) { |
| 586 | return { serverOffset: startOffset, webOffset: startOffset }; |
| 587 | } |
| 588 | |
| 589 | const serverOffset = yield* findFirstAvailableOffset({ |
| 590 | startOffset, |
| 591 | requireServerPort: true, |
| 592 | requireWebPort: false, |
| 593 | checkPortAvailability: checkPort, |
| 594 | }); |
| 595 | return { serverOffset, webOffset: serverOffset }; |
| 596 | } |
| 597 | |
| 598 | const sharedOffset = yield* findFirstAvailableOffset({ |
| 599 | startOffset, |
| 600 | requireServerPort: !hasExplicitServerPort, |
| 601 | requireWebPort: !hasExplicitDevUrl, |
| 602 | checkPortAvailability: checkPort, |
| 603 | }); |
| 604 | |
| 605 | return { serverOffset: sharedOffset, webOffset: sharedOffset }; |
| 606 | }); |
| 607 | } |
| 608 | |
| 609 | interface DevRunnerCliInput { |
| 610 | readonly mode: DevMode; |
| 611 | readonly t3Home: string | undefined; |
| 612 | readonly browser: boolean | undefined; |
| 613 | readonly autoBootstrapProjectFromCwd: boolean | undefined; |
| 614 | readonly logWebSocketEvents: boolean | undefined; |
| 615 | readonly host: string | undefined; |
| 616 | readonly port: number | undefined; |
| 617 | readonly devUrl: URL | undefined; |
| 618 | readonly dryRun: boolean; |
| 619 | readonly share: boolean; |
| 620 | readonly runArgs: ReadonlyArray<string>; |
| 621 | } |
| 622 | |
| 623 | export function runDevRunnerWithInput(input: DevRunnerCliInput) { |
| 624 | return Effect.gen(function* () { |
| 625 | const { portOffset, devInstance } = yield* OffsetConfig.pipe( |
| 626 | Effect.mapError( |
| 627 | (cause) => |
| 628 | new DevRunnerConfigurationError({ |
| 629 | configKeys: ["T3CODE_PORT_OFFSET", "T3CODE_DEV_INSTANCE"], |
| 630 | cause, |
| 631 | }), |
| 632 | ), |
| 633 | ); |
| 634 | |
| 635 | // Single-origin browser dev proxies the backend at localhost. A wildcard |
| 636 | // bind still answers there; a specific non-loopback interface does not, |
| 637 | // which breaks every proxied request in a way that reads as "server is |
| 638 | // broken" rather than "flag combination is unsupported". Reject it up |
| 639 | // front instead. (dev:server and dev:desktop don't proxy — untouched.) |
| 640 | if ( |
| 641 | (input.mode === "dev" || input.mode === "dev:web") && |
| 642 | input.host !== undefined && |
| 643 | !isProxiableBindHost(input.host) |
| 644 | ) { |
| 645 | return yield* new DevRunnerHostNotProxiableError({ mode: input.mode, host: input.host }); |
| 646 | } |
| 647 | |
| 648 | const worktreePath = yield* resolveGitWorktreePath(yield* HostProcess.WorkingDirectory); |
| 649 | |
| 650 | const { offset, source } = yield* resolveOffset({ |
| 651 | portOffset, |
| 652 | devInstance, |
| 653 | worktreePath, |
| 654 | }); |
| 655 | |
| 656 | const { serverOffset, webOffset } = yield* resolveModePortOffsets({ |
| 657 | mode: input.mode, |
| 658 | startOffset: offset, |
| 659 | hasExplicitServerPort: input.port !== undefined, |
| 660 | hasExplicitDevUrl: input.devUrl !== undefined, |
| 661 | // A non-loopback bind host decides whether the backend can actually take |
| 662 | // the port, so it has to be probed alongside loopback. |
| 663 | checkPortAvailability: makeDefaultCheckPortAvailability(input.host), |
| 664 | }); |
| 665 | |
| 666 | const hostEnvironment = yield* HostProcess.Environment; |
| 667 | // A dev server started inside a worktree defaults to that worktree's own |
| 668 | // (gitignored) `.t3` — see @t3tools/shared/devHome for why this must |
| 669 | // outrank an ambient T3CODE_HOME. `--home-dir` still wins. |
| 670 | const worktreeHome = yield* resolveWorktreeT3Home(yield* HostProcess.WorkingDirectory); |
| 671 | // Trim before choosing: `--home-dir ""` is not a selection, and treating it |
| 672 | // as one would skip the worktree default and land on the shared home — |
| 673 | // exactly the outcome this precedence exists to prevent. |
| 674 | const resolvedT3Home = |
| 675 | (input.t3Home?.trim() || undefined) ?? |
| 676 | worktreeHome ?? |
| 677 | (hostEnvironment.T3CODE_HOME?.trim() || undefined); |
| 678 | const env = yield* createDevRunnerEnv({ |
| 679 | mode: input.mode, |
| 680 | baseEnv: hostEnvironment, |
| 681 | serverOffset, |
| 682 | webOffset, |
| 683 | t3Home: resolvedT3Home, |
| 684 | browser: input.browser, |
| 685 | autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd, |
| 686 | logWebSocketEvents: input.logWebSocketEvents, |
| 687 | host: input.host, |
| 688 | port: input.port, |
| 689 | devUrl: input.devUrl, |
| 690 | }); |
| 691 | |
| 692 | const selectionSuffix = |
| 693 | serverOffset !== offset || webOffset !== offset |
| 694 | ? ` selectedOffset(server=${serverOffset},web=${webOffset})` |
| 695 | : ""; |
| 696 | const baseDir = env.T3CODE_HOME ?? (yield* DEFAULT_T3_HOME); |
| 697 | |
| 698 | yield* Effect.logInfo( |
| 699 | `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`, |
| 700 | ); |
| 701 | |
| 702 | // Before the share block: --dry-run only resolves and prints. Sharing would |
| 703 | // replace, then tear down, whatever mapping the port already had — a |
| 704 | // surprising side effect from a command documented as inert. |
| 705 | if (input.dryRun) { |
| 706 | return; |
| 707 | } |
| 708 | |
| 709 | const sharedWebPort = BASE_WEB_PORT + webOffset; |
| 710 | if (input.share) { |
| 711 | if (input.mode === "dev:server") { |
| 712 | yield* Effect.logInfo("[dev-runner] --share has no effect for dev:server (no web server)."); |
| 713 | } else if (input.mode === "dev:desktop") { |
| 714 | // Desktop is not single-origin: the renderer gets VITE_HTTP_URL and |
| 715 | // VITE_WS_URL baked to loopback, so a tailnet visitor would load the UI |
| 716 | // and then watch it dial its own 127.0.0.1 for the backend. Worse, |
| 717 | // sharing would overwrite VITE_DEV_SERVER_URL, which is the origin |
| 718 | // Electron itself loads the renderer from. Refuse rather than hand out |
| 719 | // a URL that is broken in a way the user cannot see. |
| 720 | yield* Effect.logWarning( |
| 721 | "[dev-runner] --share is not supported for dev:desktop (the renderer is pinned to loopback). Use `dev`, which runs the whole browser stack.", |
| 722 | ); |
| 723 | } else { |
| 724 | // acquireRelease, not share-then-addFinalizer: the mapping outlives this |
| 725 | // process (and reboots), so the cleanup has to be registered atomically |
| 726 | // with creating it. An interrupt landing in between would otherwise |
| 727 | // leave a mapping pointing at a port nothing is listening on. |
| 728 | // |
| 729 | // Deliberately no ownership tracking beyond that: if a second runner |
| 730 | // takes this port during a fast restart, the first's exit can briefly |
| 731 | // tear down the new mapping — visible (the URL stops working) and fixed |
| 732 | // by re-running --share. A lease protocol closing that window existed |
| 733 | // and was removed as more machinery than a dev convenience warrants. |
| 734 | // |
| 735 | // A tailnet that isn't up shouldn't stop the dev server from starting — |
| 736 | // warn, and carry on serving locally. |
| 737 | const shared = yield* Effect.acquireRelease( |
| 738 | shareDevServer({ webPort: sharedWebPort }), |
| 739 | () => |
| 740 | // Serve config outlives this process, so a cleanup that did not |
| 741 | // take leaves a tailnet URL pointing at a port nothing serves. |
| 742 | unshareDevServer(sharedWebPort).pipe( |
| 743 | Effect.flatMap((result) => |
| 744 | result.cleared |
| 745 | ? Effect.void |
| 746 | : Effect.logWarning( |
| 747 | `[dev-runner] could not remove the tailnet mapping for port ${String(sharedWebPort)}${ |
| 748 | result.explanation ? `: ${result.explanation}` : "" |
| 749 | }. Remove it with \`tailscale serve --https=${String(sharedWebPort)} off\`.`, |
| 750 | ), |
| 751 | ), |
| 752 | ), |
| 753 | ).pipe( |
| 754 | Effect.tapError((error: DevShareError) => |
| 755 | Effect.logWarning( |
| 756 | `[dev-runner] could not share on the tailnet: ${error.message}${ |
| 757 | error.hint ? ` — ${error.hint}` : "" |
| 758 | }`, |
| 759 | ), |
| 760 | ), |
| 761 | Effect.option, |
| 762 | Effect.map(Option.getOrUndefined), |
| 763 | ); |
| 764 | |
| 765 | if (shared) { |
| 766 | // The app is reached from the tailnet origin. Vite already allows |
| 767 | // *.ts.net hosts; the backend needs the origin for credentialed |
| 768 | // requests that bypass the proxy (desktop renderer, direct calls). |
| 769 | env.T3CODE_DEV_ALLOWED_ORIGINS = [ |
| 770 | env.T3CODE_DEV_ALLOWED_ORIGINS, |
| 771 | new URL(shared.url).origin, |
| 772 | ] |
| 773 | .filter((entry) => entry && entry.length > 0) |
| 774 | .join(","); |
| 775 | // The server builds its pairing URL from this, so the URL printed at |
| 776 | // startup is already the shareable one — no rewriting by hand. An |
| 777 | // explicit --dev-url still wins. |
| 778 | if (input.devUrl === undefined) { |
| 779 | env.VITE_DEV_SERVER_URL = shared.url; |
| 780 | } |
| 781 | // A shared origin serves a remote browser, where unbundled dev's |
| 782 | // per-module requests each pay a tailnet round trip — a cold module |
| 783 | // graph takes minutes to first paint. Bundled dev collapses that to |
| 784 | // a few chunk requests. Only defaulted, so T3CODE_BUNDLED_DEV=0 |
| 785 | // still opts a --share run back out. |
| 786 | if (env.T3CODE_BUNDLED_DEV === undefined) { |
| 787 | env.T3CODE_BUNDLED_DEV = "1"; |
| 788 | } |
| 789 | yield* Effect.logInfo(`[dev-runner] shared on tailnet: ${shared.url}`); |
| 790 | } |
| 791 | } |
| 792 | } |
| 793 | |
| 794 | const args = [...MODE_ARGS[input.mode], ...input.runArgs]; |
| 795 | const spawnCommand = yield* resolveSpawnCommand("vp", args, { env }); |
| 796 | const processContext = { |
| 797 | mode: input.mode, |
| 798 | executable: "vp" as const, |
| 799 | argumentCount: args.length, |
| 800 | shell: spawnCommand.shell, |
| 801 | } as const; |
| 802 | const child = yield* ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| 803 | stdin: "inherit", |
| 804 | stdout: "inherit", |
| 805 | stderr: "inherit", |
| 806 | env, |
| 807 | extendEnv: false, |
| 808 | shell: spawnCommand.shell, |
| 809 | // Keep Vite+ in the same process group so terminal signals (Ctrl+C) |
| 810 | // reach it directly. Effect defaults to detached: true on non-Windows, |
| 811 | // which would put the runner in a new group and require manual forwarding. |
| 812 | detached: false, |
| 813 | forceKillAfter: "1500 millis", |
| 814 | }).pipe( |
| 815 | Effect.mapError( |
| 816 | (cause) => |
| 817 | new DevRunnerProcessError({ |
| 818 | ...processContext, |
| 819 | operation: "spawn", |
| 820 | cause, |
| 821 | }), |
| 822 | ), |
| 823 | ); |
| 824 | |
| 825 | const exitCode = yield* child.exitCode.pipe( |
| 826 | Effect.mapError( |
| 827 | (cause) => |
| 828 | new DevRunnerProcessError({ |
| 829 | ...processContext, |
| 830 | operation: "wait-for-exit", |
| 831 | cause, |
| 832 | }), |
| 833 | ), |
| 834 | ); |
| 835 | if (exitCode !== 0) { |
| 836 | return yield* new DevRunnerProcessExitError({ |
| 837 | ...processContext, |
| 838 | exitCode, |
| 839 | }); |
| 840 | } |
| 841 | }); |
| 842 | } |
| 843 | |
| 844 | const devRunnerCli = Command.make("dev-runner", { |
| 845 | mode: Argument.Literals("mode", DEV_RUNNER_MODES).pipe( |
| 846 | Argument.withDescription("Development mode to run."), |
| 847 | ), |
| 848 | t3Home: Flag.String("home-dir").pipe( |
| 849 | Flag.withDescription( |
| 850 | "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.", |
| 851 | ), |
| 852 | Flag.optional, |
| 853 | Flag.map(Option.getOrUndefined), |
| 854 | ), |
| 855 | browser: Flag.Boolean("browser").pipe( |
| 856 | Flag.withDescription("Open a browser automatically (disabled by default for web dev)."), |
| 857 | Flag.withDefault(false), |
| 858 | ), |
| 859 | autoBootstrapProjectFromCwd: Flag.Boolean("auto-bootstrap-project-from-cwd").pipe( |
| 860 | Flag.withDescription( |
| 861 | "Auto-bootstrap toggle (equivalent to T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD).", |
| 862 | ), |
| 863 | Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD")), |
| 864 | ), |
| 865 | logWebSocketEvents: Flag.Boolean("log-websocket-events").pipe( |
| 866 | Flag.withDescription("WebSocket event logging toggle (equivalent to T3CODE_LOG_WS_EVENTS)."), |
| 867 | Flag.withAlias("log-ws-events"), |
| 868 | Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_LOG_WS_EVENTS")), |
| 869 | ), |
| 870 | host: Flag.String("host").pipe( |
| 871 | Flag.withDescription("Server host/interface override (forwards to T3CODE_HOST)."), |
| 872 | Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOST")), |
| 873 | ), |
| 874 | port: Flag.Int("port").pipe( |
| 875 | Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), |
| 876 | Flag.withDescription("Server port override (forwards to T3CODE_PORT)."), |
| 877 | Flag.withFallbackConfig(optionalPortConfig("T3CODE_PORT")), |
| 878 | ), |
| 879 | devUrl: Flag.String("dev-url").pipe( |
| 880 | Flag.withSchema(Schema.URLFromString), |
| 881 | Flag.withDescription( |
| 882 | "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.", |
| 883 | ), |
| 884 | Flag.optional, |
| 885 | Flag.map(Option.getOrUndefined), |
| 886 | ), |
| 887 | dryRun: Flag.Boolean("dry-run").pipe( |
| 888 | Flag.withDescription("Resolve mode/ports/env and print, but do not spawn Vite+."), |
| 889 | Flag.withDefault(false), |
| 890 | ), |
| 891 | share: Flag.Boolean("share").pipe( |
| 892 | Flag.withDescription( |
| 893 | "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.", |
| 894 | ), |
| 895 | Flag.withDefault(false), |
| 896 | ), |
| 897 | runArgs: Argument.String("run-arg").pipe( |
| 898 | Argument.withDescription("Additional Vite+ run args (pass after `--`)."), |
| 899 | Argument.variadic(), |
| 900 | ), |
| 901 | }).pipe( |
| 902 | Command.withDescription("Run monorepo development modes with deterministic port/env wiring."), |
| 903 | Command.withHandler((input) => runDevRunnerWithInput(input)), |
| 904 | ); |
| 905 | |
| 906 | const cliRuntimeLayer = Layer.mergeAll( |
| 907 | Logger.layer([Logger.consolePretty()]), |
| 908 | NodeServices.layer, |
| 909 | NetService.layer, |
| 910 | ); |
| 911 | |
| 912 | if (import.meta.main) { |
| 913 | Command.run(devRunnerCli, { version: "0.0.0" }).pipe( |
| 914 | Effect.scoped, |
| 915 | Effect.provide(cliRuntimeLayer), |
| 916 | NodeRuntime.runMain, |
| 917 | ); |
| 918 | } |