749 lines · 23.4 KB · TypeScript
RawDownload
#!/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 { HostProcessEnvironment } 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 { 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";const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const; 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;type PortAvailabilityCheck<R = never> = (port: number) => 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 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 DevRunnerPublicUrlError extends Schema.TaggedErrorClass<DevRunnerPublicUrlError>()(  "DevRunnerPublicUrlError",  {    reason: Schema.Literals(["unsupported-protocol", "not-an-origin"]),    protocol: Schema.String,  },) {  override get message(): string {    return this.reason === "unsupported-protocol"      ? `Public URL must use HTTP or HTTPS; received protocol "${this.protocol}".`      : "Public URL must be an origin without credentials, a path, query parameters, or a fragment.";  }} export class DevRunnerConflictingUrlOptionsError extends Schema.TaggedErrorClass<DevRunnerConflictingUrlOptionsError>()(  "DevRunnerConflictingUrlOptionsError",  {},) {  override get message(): string {    return "Use either --public-url or --dev-url, not both.";  }} 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 const DevRunnerError = Schema.Union([  DevRunnerConfigurationError,  DevRunnerInvalidPortOffsetError,  DevRunnerPublicUrlError,  DevRunnerConflictingUrlOptionsError,  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 optionalUrlConfig = (name: string): Config.Config<URL | undefined> =>  Config.url(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;}): 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) {    return Effect.succeed({ offset: 0, source: "default ports" });  }   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}` });} 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;  readonly publicUrl?: URL | undefined;} export function createDevRunnerEnv({  mode,  baseEnv,  serverOffset,  webOffset,  t3Home,  browser,  autoBootstrapProjectFromCwd,  logWebSocketEvents,  host,  port,  devUrl,  publicUrl,}: CreateDevRunnerEnvInput): Effect.Effect<  NodeJS.ProcessEnv,  DevRunnerPublicUrlError | DevRunnerConflictingUrlOptionsError,  Path.Path> {  return Effect.gen(function* () {    const serverPort = port ?? BASE_SERVER_PORT + serverOffset;    const webPort = BASE_WEB_PORT + webOffset;    const configuredBaseDir = t3Home?.trim() || baseEnv.T3CODE_HOME?.trim() || undefined;    const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir);    const isDesktopMode = mode === "dev:desktop";    const configuredPublicUrl = isDesktopMode ? undefined : publicUrl;     if (configuredPublicUrl !== undefined && devUrl !== undefined) {      return yield* new DevRunnerConflictingUrlOptionsError();    }    if (      configuredPublicUrl !== undefined &&      configuredPublicUrl.protocol !== "http:" &&      configuredPublicUrl.protocol !== "https:"    ) {      return yield* new DevRunnerPublicUrlError({        reason: "unsupported-protocol",        protocol: configuredPublicUrl.protocol,      });    }    if (      configuredPublicUrl !== undefined &&      (configuredPublicUrl.username !== "" ||        configuredPublicUrl.password !== "" ||        configuredPublicUrl.pathname !== "/" ||        configuredPublicUrl.search !== "" ||        configuredPublicUrl.hash !== "")    ) {      return yield* new DevRunnerPublicUrlError({        reason: "not-an-origin",        protocol: configuredPublicUrl.protocol,      });    }     const publicHttpUrl = configuredPublicUrl?.origin;    const publicWsUrl =      configuredPublicUrl === undefined        ? undefined        : (() => {            const url = new URL(configuredPublicUrl);            url.protocol = url.protocol === "https:" ? "wss:" : "ws:";            return url.origin;          })();    const publicPort =      configuredPublicUrl === undefined        ? undefined        : Number(            configuredPublicUrl.port || (configuredPublicUrl.protocol === "https:" ? "443" : "80"),          );     const output: NodeJS.ProcessEnv = {      ...baseEnv,      PORT: String(webPort),      VITE_DEV_SERVER_URL:        publicHttpUrl ??        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);      output.T3CODE_DEV_PROXY_URL = `http://localhost:${serverPort}`;      output.VITE_HTTP_URL = publicHttpUrl ?? `http://localhost:${serverPort}`;      output.VITE_WS_URL = publicWsUrl ?? `ws://localhost:${serverPort}`;      if (publicPort !== undefined) {        output.T3CODE_AUTH_COOKIE_PORT = String(publicPort);      }    } 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}`;      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;  });} const defaultCheckPortAvailability: PortAvailabilityCheck<NetService.NetService> = (port) =>  Effect.gen(function* () {    const net = yield* NetService.NetService;    return yield* checkPortAvailabilityOnHosts(port, DEV_PORT_PROBE_HOSTS, (candidatePort, host) =>      net.canListenOnHost(candidatePort, host),    );  }); 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;      }       const checks: Array<Effect.Effect<boolean, never, R>> = [];      if (requireServerPort) {        checks.push(checkPort(serverPort));      }      if (requireWebPort) {        checks.push(checkPort(webPort));      }       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;

Lines 1–500 of 749.