| ... | @@ -5,7 +5,7 @@ import { homedir } from "node:os"; | ... | @@ -5,7 +5,7 @@ import { homedir } from "node:os"; |
| 5 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | 5 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | import { NetService } from "@t3tools/shared/Net"; | 7 | import { NetService } from "@t3tools/shared/Net"; |
| 8 | import { Data, Effect, Layer, Logger, Option, Path } from "effect"; | 8 | import { Config, Data, Effect, Hash, Layer, Logger, Option, Path, Schema } from "effect"; |
| 9 | import { Argument, Command, Flag } from "effect/unstable/cli"; | 9 | import { Argument, Command, Flag } from "effect/unstable/cli"; |
| 10 | import { ChildProcess } from "effect/unstable/process"; | 10 | import { ChildProcess } from "effect/unstable/process"; |
| 11 | | 11 | |
| ... | @@ -14,11 +14,9 @@ const BASE_WEB_PORT = 5733; | ... | @@ -14,11 +14,9 @@ const BASE_WEB_PORT = 5733; |
| 14 | const MAX_HASH_OFFSET = 3000; | 14 | const MAX_HASH_OFFSET = 3000; |
| 15 | const MAX_PORT = 65535; | 15 | const MAX_PORT = 65535; |
| 16 | | 16 | |
| 17 | export const DEFAULT_DEV_STATE_DIR: Effect.Effect<string, never, Path.Path> = Effect.gen( | 17 | export const DEFAULT_DEV_STATE_DIR = Effect.map(Effect.service(Path.Path), (path) => |
| 18 | function* () { | 18 | path.join(homedir(), ".t3", "dev"), |
| 19 | const path = yield* Path.Path; | 19 | ); |
| 20 | return path.join(homedir(), ".t3", "dev"); | | |
| 21 | }); | | |
| 22 | | 20 | |
| 23 | const MODE_ARGS = { | 21 | const MODE_ARGS = { |
| 24 | dev: [ | 22 | dev: [ |
| ... | @@ -36,7 +34,6 @@ const MODE_ARGS = { | ... | @@ -36,7 +34,6 @@ const MODE_ARGS = { |
| 36 | } as const satisfies Record<string, ReadonlyArray<string>>; | 34 | } as const satisfies Record<string, ReadonlyArray<string>>; |
| 37 | | 35 | |
| 38 | type DevMode = keyof typeof MODE_ARGS; | 36 | type DevMode = keyof typeof MODE_ARGS; |
| 39 | type EnvOverrides = Record<string, string>; | | |
| 40 | type PortAvailabilityCheck<R = never> = (port: number) => Effect.Effect<boolean, never, R>; | 37 | type PortAvailabilityCheck<R = never> = (port: number) => Effect.Effect<boolean, never, R>; |
| 41 | | 38 | |
| 42 | const DEV_RUNNER_MODES = Object.keys(MODE_ARGS) as Array<DevMode>; | 39 | const DEV_RUNNER_MODES = Object.keys(MODE_ARGS) as Array<DevMode>; |
| ... | @@ -46,37 +43,52 @@ class DevRunnerError extends Data.TaggedError("DevRunnerError")<{ | ... | @@ -46,37 +43,52 @@ class DevRunnerError extends Data.TaggedError("DevRunnerError")<{ |
| 46 | readonly cause?: unknown; | 43 | readonly cause?: unknown; |
| 47 | }> {} | 44 | }> {} |
| 48 | | 45 | |
| 49 | function parseInteger(value: string, envName: string): number { | 46 | const optionalStringConfig = (name: string): Config.Config<string | undefined> => |
| 50 | const parsed = Number(value); | 47 | Config.string(name).pipe( |
| 51 | if (!Number.isInteger(parsed)) { | 48 | Config.option, |
| 52 | throw new Error(`Invalid ${envName}: ${value}`); | 49 | Config.map((value) => Option.getOrUndefined(value)), |
| 53 | } | 50 | ); |
| 54 | return parsed; | 51 | const optionalBooleanConfig = (name: string): Config.Config<boolean | undefined> => |
| 55 | } | 52 | Config.boolean(name).pipe( |
| | 53 | Config.option, |
| | 54 | Config.map((value) => Option.getOrUndefined(value)), |
| | 55 | ); |
| | 56 | const optionalPortConfig = (name: string): Config.Config<number | undefined> => |
| | 57 | Config.port(name).pipe( |
| | 58 | Config.option, |
| | 59 | Config.map((value) => Option.getOrUndefined(value)), |
| | 60 | ); |
| | 61 | const optionalIntegerConfig = (name: string): Config.Config<number | undefined> => |
| | 62 | Config.int(name).pipe( |
| | 63 | Config.option, |
| | 64 | Config.map((value) => Option.getOrUndefined(value)), |
| | 65 | ); |
| | 66 | const optionalUrlConfig = (name: string): Config.Config<URL | undefined> => |
| | 67 | Config.url(name).pipe( |
| | 68 | Config.option, |
| | 69 | Config.map((value) => Option.getOrUndefined(value)), |
| | 70 | ); |
| 56 | | 71 | |
| 57 | function hashSeed(seed: string): number { | 72 | const OffsetConfig = Config.all({ |
| 58 | let hash = 0x811c9dc5; | 73 | portOffset: optionalIntegerConfig("T3CODE_PORT_OFFSET"), |
| 59 | for (let i = 0; i < seed.length; i += 1) { | 74 | devInstance: optionalStringConfig("T3CODE_DEV_INSTANCE"), |
| 60 | hash ^= seed.charCodeAt(i); | 75 | }); |
| 61 | hash = Math.imul(hash, 0x01000193); | | |
| 62 | } | | |
| 63 | return hash >>> 0; | | |
| 64 | } | | |
| 65 | | 76 | |
| 66 | export function resolveOffset(env: NodeJS.ProcessEnv = process.env): { | 77 | export function resolveOffset(config: { |
| 67 | readonly offset: number; | 78 | readonly portOffset: number | undefined; |
| 68 | readonly source: string; | 79 | readonly devInstance: string | undefined; |
| 69 | } { | 80 | }): { readonly offset: number; readonly source: string } { |
| 70 | const explicitOffset = env.T3CODE_PORT_OFFSET?.trim(); | 81 | if (config.portOffset !== undefined) { |
| 71 | if (explicitOffset) { | 82 | if (config.portOffset < 0) { |
| 72 | const parsed = parseInteger(explicitOffset, "T3CODE_PORT_OFFSET"); | 83 | throw new Error(`Invalid T3CODE_PORT_OFFSET: ${config.portOffset}`); |
| 73 | if (parsed < 0) { | | |
| 74 | throw new Error(`Invalid T3CODE_PORT_OFFSET: ${explicitOffset}`); | | |
| 75 | } | 84 | } |
| 76 | return { offset: parsed, source: `T3CODE_PORT_OFFSET=${explicitOffset}` }; | 85 | return { |
| | 86 | offset: config.portOffset, |
| | 87 | source: `T3CODE_PORT_OFFSET=${config.portOffset}`, |
| | 88 | }; |
| 77 | } | 89 | } |
| 78 | | 90 | |
| 79 | const seed = env.T3CODE_DEV_INSTANCE?.trim(); | 91 | const seed = config.devInstance?.trim(); |
| 80 | if (!seed) { | 92 | if (!seed) { |
| 81 | return { offset: 0, source: "default ports" }; | 93 | return { offset: 0, source: "default ports" }; |
| 82 | } | 94 | } |
| ... | @@ -85,32 +97,18 @@ export function resolveOffset(env: NodeJS.ProcessEnv = process.env): { | ... | @@ -85,32 +97,18 @@ export function resolveOffset(env: NodeJS.ProcessEnv = process.env): { |
| 85 | return { offset: Number(seed), source: `numeric T3CODE_DEV_INSTANCE=${seed}` }; | 97 | return { offset: Number(seed), source: `numeric T3CODE_DEV_INSTANCE=${seed}` }; |
| 86 | } | 98 | } |
| 87 | | 99 | |
| 88 | const offset = (hashSeed(seed) % MAX_HASH_OFFSET) + 1; | 100 | const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1; |
| 89 | return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }; | 101 | return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }; |
| 90 | } | 102 | } |
| 91 | | 103 | |
| 92 | function toBooleanEnvValue(value: string | undefined): string { | 104 | function resolveStateDir(stateDir: string | undefined): Effect.Effect<string, never, Path.Path> { |
| 93 | if (value === undefined) return "1"; | | |
| 94 | const normalized = value.trim().toLowerCase(); | | |
| 95 | if (normalized === "false" || normalized === "0" || normalized === "no") return "0"; | | |
| 96 | return "1"; | | |
| 97 | } | | |
| 98 | | | |
| 99 | function resolveStateDir( | | |
| 100 | env: NodeJS.ProcessEnv, | | |
| 101 | envOverrides: EnvOverrides, | | |
| 102 | ): Effect.Effect<string, never, Path.Path> { | | |
| 103 | return Effect.gen(function* () { | 105 | return Effect.gen(function* () { |
| 104 | const path = yield* Path.Path; | 106 | const path = yield* Path.Path; |
| 105 | const overrideValue = envOverrides?.T3CODE_STATE_DIR?.trim(); | 107 | const configured = stateDir?.trim(); |
| 106 | if (overrideValue) { | | |
| 107 | // Resolve relative paths against cwd (monorepo root) before turbo changes directories. | | |
| 108 | return path.resolve(overrideValue); | | |
| 109 | } | | |
| 110 | | 108 | |
| 111 | const envValue = env?.T3CODE_STATE_DIR?.trim(); | 109 | if (configured) { |
| 112 | if (envValue) { | 110 | // Resolve relative paths against cwd (monorepo root) before turbo changes directories. |
| 113 | return path.resolve(envValue); | 111 | return path.resolve(configured); |
| 114 | } | 112 | } |
| 115 | | 113 | |
| 116 | return yield* DEFAULT_DEV_STATE_DIR; | 114 | return yield* DEFAULT_DEV_STATE_DIR; |
| ... | @@ -119,72 +117,97 @@ function resolveStateDir( | ... | @@ -119,72 +117,97 @@ function resolveStateDir( |
| 119 | | 117 | |
| 120 | interface CreateDevRunnerEnvInput { | 118 | interface CreateDevRunnerEnvInput { |
| 121 | readonly mode: DevMode; | 119 | readonly mode: DevMode; |
| 122 | readonly env: NodeJS.ProcessEnv; | 120 | readonly baseEnv: NodeJS.ProcessEnv; |
| 123 | readonly offset: number; | 121 | readonly serverOffset: number; |
| 124 | readonly envOverrides: EnvOverrides; | 122 | readonly webOffset: number; |
| | 123 | readonly stateDir: string | undefined; |
| | 124 | readonly authToken: string | undefined; |
| | 125 | readonly noBrowser: boolean | undefined; |
| | 126 | readonly autoBootstrapProjectFromCwd: boolean | undefined; |
| | 127 | readonly logWebSocketEvents: boolean | undefined; |
| | 128 | readonly host: string | undefined; |
| | 129 | readonly port: number | undefined; |
| | 130 | readonly devUrl: URL | undefined; |
| 125 | } | 131 | } |
| 126 | | 132 | |
| 127 | export function createDevRunnerEnv({ | 133 | export function createDevRunnerEnv({ |
| 128 | mode, | 134 | mode, |
| 129 | env, | 135 | baseEnv, |
| 130 | offset, | 136 | serverOffset, |
| 131 | envOverrides, | 137 | webOffset, |
| 132 | }: CreateDevRunnerEnvInput): Effect.Effect<NodeJS.ProcessEnv, DevRunnerError, Path.Path> { | 138 | stateDir, |
| | 139 | authToken, |
| | 140 | noBrowser, |
| | 141 | autoBootstrapProjectFromCwd, |
| | 142 | logWebSocketEvents, |
| | 143 | host, |
| | 144 | port, |
| | 145 | devUrl, |
| | 146 | }: CreateDevRunnerEnvInput): Effect.Effect<NodeJS.ProcessEnv, never, Path.Path> { |
| 133 | return Effect.gen(function* () { | 147 | return Effect.gen(function* () { |
| 134 | const serverPort = BASE_SERVER_PORT + offset; | 148 | const serverPort = port ?? BASE_SERVER_PORT + serverOffset; |
| 135 | const webPort = BASE_WEB_PORT + offset; | 149 | const webPort = BASE_WEB_PORT + webOffset; |
| | 150 | const resolvedStateDir = yield* resolveStateDir(stateDir); |
| 136 | | 151 | |
| 137 | const output: NodeJS.ProcessEnv = { | 152 | const output: NodeJS.ProcessEnv = { |
| 138 | ...env, | 153 | ...baseEnv, |
| 139 | T3CODE_PORT: String(serverPort), | 154 | T3CODE_PORT: String(serverPort), |
| 140 | PORT: String(webPort), | 155 | PORT: String(webPort), |
| 141 | ELECTRON_RENDERER_PORT: String(webPort), | 156 | ELECTRON_RENDERER_PORT: String(webPort), |
| 142 | VITE_WS_URL: `ws://localhost:${serverPort}`, | 157 | VITE_WS_URL: `ws://localhost:${serverPort}`, |
| 143 | VITE_DEV_SERVER_URL: `http://localhost:${webPort}`, | 158 | VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`, |
| 144 | ...envOverrides, | 159 | T3CODE_STATE_DIR: resolvedStateDir, |
| 145 | }; | 160 | }; |
| 146 | | 161 | |
| 147 | output.T3CODE_STATE_DIR = yield* resolveStateDir(env, envOverrides); | 162 | if (host !== undefined) { |
| 148 | | 163 | output.T3CODE_HOST = host; |
| 149 | const parsedServerPort = yield* Effect.try({ | 164 | } |
| 150 | try: () => { | 165 | |
| 151 | const parsed = Number(output.T3CODE_PORT); | 166 | if (authToken !== undefined) { |
| 152 | if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_PORT) { | 167 | output.T3CODE_AUTH_TOKEN = authToken; |
| 153 | throw new Error( | 168 | } else { |
| 154 | `Invalid T3CODE_PORT override: '${String(output.T3CODE_PORT)}'. Expected an integer between 1 and 65535.`, | 169 | delete output.T3CODE_AUTH_TOKEN; |
| 155 | ); | 170 | } |
| 156 | } | 171 | |
| 157 | return parsed; | 172 | if (noBrowser !== undefined) { |
| 158 | }, | 173 | output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0"; |
| 159 | catch: (cause) => | 174 | } else { |
| 160 | new DevRunnerError({ | 175 | delete output.T3CODE_NO_BROWSER; |
| 161 | message: cause instanceof Error ? cause.message : String(cause), | 176 | } |
| 162 | cause, | 177 | |
| 163 | }), | 178 | if (autoBootstrapProjectFromCwd !== undefined) { |
| 164 | }); | 179 | output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD = autoBootstrapProjectFromCwd ? "1" : "0"; |
| 165 | output.VITE_WS_URL = `ws://localhost:${parsedServerPort}`; | 180 | } else { |
| | 181 | delete output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD; |
| | 182 | } |
| | 183 | |
| | 184 | if (logWebSocketEvents !== undefined) { |
| | 185 | output.T3CODE_LOG_WS_EVENTS = logWebSocketEvents ? "1" : "0"; |
| | 186 | } else { |
| | 187 | delete output.T3CODE_LOG_WS_EVENTS; |
| | 188 | } |
| 166 | | 189 | |
| 167 | if (mode === "dev" || mode === "dev:server" || mode === "dev:web") { | 190 | if (mode === "dev") { |
| 168 | // Running server/web in browser mode should not inherit desktop launcher state. | | |
| 169 | output.T3CODE_MODE = "web"; | 191 | output.T3CODE_MODE = "web"; |
| 170 | if (!("T3CODE_NO_BROWSER" in envOverrides)) { | 192 | if (logWebSocketEvents === undefined) { |
| 171 | delete output.T3CODE_NO_BROWSER; | 193 | output.T3CODE_LOG_WS_EVENTS = "1"; |
| 172 | } | | |
| 173 | if (!("T3CODE_AUTH_TOKEN" in envOverrides)) { | | |
| 174 | delete output.T3CODE_AUTH_TOKEN; | | |
| 175 | } | 194 | } |
| 176 | delete output.T3CODE_DESKTOP_WS_URL; | 195 | delete output.T3CODE_DESKTOP_WS_URL; |
| 177 | } | 196 | } |
| 178 | | 197 | |
| 179 | if (mode === "dev" && !output.T3CODE_LOG_WS_EVENTS) { | 198 | if (mode === "dev:server" || mode === "dev:web") { |
| 180 | output.T3CODE_LOG_WS_EVENTS = "1"; | 199 | output.T3CODE_MODE = "web"; |
| | 200 | delete output.T3CODE_DESKTOP_WS_URL; |
| 181 | } | 201 | } |
| 182 | | 202 | |
| 183 | return output; | 203 | return output; |
| 184 | }); | 204 | }); |
| 185 | } | 205 | } |
| 186 | | 206 | |
| 187 | function portPairForOffset(offset: number): { readonly serverPort: number; readonly webPort: number } { | 207 | function portPairForOffset(offset: number): { |
| | 208 | readonly serverPort: number; |
| | 209 | readonly webPort: number; |
| | 210 | } { |
| 188 | return { | 211 | return { |
| 189 | serverPort: BASE_SERVER_PORT + offset, | 212 | serverPort: BASE_SERVER_PORT + offset, |
| 190 | webPort: BASE_WEB_PORT + offset, | 213 | webPort: BASE_WEB_PORT + offset, |
| ... | @@ -211,11 +234,14 @@ export function findFirstAvailableOffset<R = NetService>({ | ... | @@ -211,11 +234,14 @@ export function findFirstAvailableOffset<R = NetService>({ |
| 211 | checkPortAvailability, | 234 | checkPortAvailability, |
| 212 | }: FindFirstAvailableOffsetInput<R>): Effect.Effect<number, DevRunnerError, R> { | 235 | }: FindFirstAvailableOffsetInput<R>): Effect.Effect<number, DevRunnerError, R> { |
| 213 | return Effect.gen(function* () { | 236 | return Effect.gen(function* () { |
| 214 | const checkPort = (checkPortAvailability ?? defaultCheckPortAvailability) as PortAvailabilityCheck<R>; | 237 | const checkPort = (checkPortAvailability ?? |
| | 238 | defaultCheckPortAvailability) as PortAvailabilityCheck<R>; |
| | 239 | |
| 215 | for (let candidate = startOffset; ; candidate += 1) { | 240 | for (let candidate = startOffset; ; candidate += 1) { |
| 216 | const { serverPort, webPort } = portPairForOffset(candidate); | 241 | const { serverPort, webPort } = portPairForOffset(candidate); |
| 217 | const serverPortOutOfRange = serverPort > MAX_PORT; | 242 | const serverPortOutOfRange = serverPort > MAX_PORT; |
| 218 | const webPortOutOfRange = webPort > MAX_PORT; | 243 | const webPortOutOfRange = webPort > MAX_PORT; |
| | 244 | |
| 219 | if ( | 245 | if ( |
| 220 | (requireServerPort && serverPortOutOfRange) || | 246 | (requireServerPort && serverPortOutOfRange) || |
| 221 | (requireWebPort && webPortOutOfRange) || | 247 | (requireWebPort && webPortOutOfRange) || |
| ... | @@ -251,14 +277,16 @@ export function findFirstAvailableOffset<R = NetService>({ | ... | @@ -251,14 +277,16 @@ export function findFirstAvailableOffset<R = NetService>({ |
| 251 | interface ResolveModePortOffsetsInput<R = NetService> { | 277 | interface ResolveModePortOffsetsInput<R = NetService> { |
| 252 | readonly mode: DevMode; | 278 | readonly mode: DevMode; |
| 253 | readonly startOffset: number; | 279 | readonly startOffset: number; |
| 254 | readonly envOverrides: EnvOverrides; | 280 | readonly hasExplicitServerPort: boolean; |
| | 281 | readonly hasExplicitDevUrl: boolean; |
| 255 | readonly checkPortAvailability?: PortAvailabilityCheck<R>; | 282 | readonly checkPortAvailability?: PortAvailabilityCheck<R>; |
| 256 | } | 283 | } |
| 257 | | 284 | |
| 258 | export function resolveModePortOffsets<R = NetService>({ | 285 | export function resolveModePortOffsets<R = NetService>({ |
| 259 | mode, | 286 | mode, |
| 260 | startOffset, | 287 | startOffset, |
| 261 | envOverrides, | 288 | hasExplicitServerPort, |
| | 289 | hasExplicitDevUrl, |
| 262 | checkPortAvailability, | 290 | checkPortAvailability, |
| 263 | }: ResolveModePortOffsetsInput<R>): Effect.Effect< | 291 | }: ResolveModePortOffsetsInput<R>): Effect.Effect< |
| 264 | { readonly serverOffset: number; readonly webOffset: number }, | 292 | { readonly serverOffset: number; readonly webOffset: number }, |
| ... | @@ -266,14 +294,14 @@ export function resolveModePortOffsets<R = NetService>({ | ... | @@ -266,14 +294,14 @@ export function resolveModePortOffsets<R = NetService>({ |
| 266 | R | 294 | R |
| 267 | > { | 295 | > { |
| 268 | return Effect.gen(function* () { | 296 | return Effect.gen(function* () { |
| 269 | const checkPort = (checkPortAvailability ?? defaultCheckPortAvailability) as PortAvailabilityCheck<R>; | 297 | const checkPort = (checkPortAvailability ?? |
| 270 | const hasExplicitServerPort = typeof envOverrides.T3CODE_PORT === "string"; | 298 | defaultCheckPortAvailability) as PortAvailabilityCheck<R>; |
| 271 | const hasExplicitDevUrl = typeof envOverrides.VITE_DEV_SERVER_URL === "string"; | | |
| 272 | | 299 | |
| 273 | if (mode === "dev:web") { | 300 | if (mode === "dev:web") { |
| 274 | if (hasExplicitDevUrl) { | 301 | if (hasExplicitDevUrl) { |
| 275 | return { serverOffset: startOffset, webOffset: startOffset }; | 302 | return { serverOffset: startOffset, webOffset: startOffset }; |
| 276 | } | 303 | } |
| | 304 | |
| 277 | const webOffset = yield* findFirstAvailableOffset({ | 305 | const webOffset = yield* findFirstAvailableOffset({ |
| 278 | startOffset, | 306 | startOffset, |
| 279 | requireServerPort: false, | 307 | requireServerPort: false, |
| ... | @@ -287,6 +315,7 @@ export function resolveModePortOffsets<R = NetService>({ | ... | @@ -287,6 +315,7 @@ export function resolveModePortOffsets<R = NetService>({ |
| 287 | if (hasExplicitServerPort) { | 315 | if (hasExplicitServerPort) { |
| 288 | return { serverOffset: startOffset, webOffset: startOffset }; | 316 | return { serverOffset: startOffset, webOffset: startOffset }; |
| 289 | } | 317 | } |
| | 318 | |
| 290 | const serverOffset = yield* findFirstAvailableOffset({ | 319 | const serverOffset = yield* findFirstAvailableOffset({ |
| 291 | startOffset, | 320 | startOffset, |
| 292 | requireServerPort: true, | 321 | requireServerPort: true, |
| ... | @@ -302,71 +331,39 @@ export function resolveModePortOffsets<R = NetService>({ | ... | @@ -302,71 +331,39 @@ export function resolveModePortOffsets<R = NetService>({ |
| 302 | requireWebPort: !hasExplicitDevUrl, | 331 | requireWebPort: !hasExplicitDevUrl, |
| 303 | checkPortAvailability: checkPort, | 332 | checkPortAvailability: checkPort, |
| 304 | }); | 333 | }); |
| | 334 | |
| 305 | return { serverOffset: sharedOffset, webOffset: sharedOffset }; | 335 | return { serverOffset: sharedOffset, webOffset: sharedOffset }; |
| 306 | }); | 336 | }); |
| 307 | } | 337 | } |
| 308 | | 338 | |
| 309 | const optionValue = <A>(option: Option.Option<A>): A | undefined => | | |
| 310 | Option.isSome(option) ? option.value : undefined; | | |
| 311 | | | |
| 312 | interface DevRunnerCliInput { | 339 | interface DevRunnerCliInput { |
| 313 | readonly mode: DevMode; | 340 | readonly mode: DevMode; |
| 314 | readonly stateDir: Option.Option<string>; | 341 | readonly stateDir: string | undefined; |
| 315 | readonly authToken: Option.Option<string>; | 342 | readonly authToken: string | undefined; |
| 316 | readonly noBrowser: Option.Option<string>; | 343 | readonly noBrowser: boolean | undefined; |
| 317 | readonly autoBootstrapProjectFromCwd: Option.Option<string>; | 344 | readonly autoBootstrapProjectFromCwd: boolean | undefined; |
| 318 | readonly logWebSocketEvents: Option.Option<string>; | 345 | readonly logWebSocketEvents: boolean | undefined; |
| 319 | readonly host: Option.Option<string>; | 346 | readonly host: string | undefined; |
| 320 | readonly port: Option.Option<string>; | 347 | readonly port: number | undefined; |
| 321 | readonly devUrl: Option.Option<string>; | 348 | readonly devUrl: URL | undefined; |
| 322 | readonly dryRun: boolean; | 349 | readonly dryRun: boolean; |
| 323 | readonly turboArgs: ReadonlyArray<string>; | 350 | readonly turboArgs: ReadonlyArray<string>; |
| 324 | } | 351 | } |
| 325 | | 352 | |
| 326 | function envOverridesFromCliInput(input: DevRunnerCliInput): EnvOverrides { | | |
| 327 | const envOverrides: EnvOverrides = {}; | | |
| 328 | | | |
| 329 | const stateDir = optionValue(input.stateDir); | | |
| 330 | if (stateDir !== undefined) envOverrides.T3CODE_STATE_DIR = stateDir; | | |
| 331 | | | |
| 332 | const authToken = optionValue(input.authToken); | | |
| 333 | if (authToken !== undefined) envOverrides.T3CODE_AUTH_TOKEN = authToken; | | |
| 334 | | | |
| 335 | const host = optionValue(input.host); | | |
| 336 | if (host !== undefined) envOverrides.T3CODE_HOST = host; | | |
| 337 | | | |
| 338 | const port = optionValue(input.port); | | |
| 339 | if (port !== undefined) envOverrides.T3CODE_PORT = port; | | |
| 340 | | | |
| 341 | const devUrl = optionValue(input.devUrl); | | |
| 342 | if (devUrl !== undefined) envOverrides.VITE_DEV_SERVER_URL = devUrl; | | |
| 343 | | | |
| 344 | const noBrowser = optionValue(input.noBrowser); | | |
| 345 | if (noBrowser !== undefined) { | | |
| 346 | envOverrides.T3CODE_NO_BROWSER = toBooleanEnvValue(noBrowser); | | |
| 347 | } | | |
| 348 | | | |
| 349 | const autoBootstrapProjectFromCwd = optionValue(input.autoBootstrapProjectFromCwd); | | |
| 350 | if (autoBootstrapProjectFromCwd !== undefined) { | | |
| 351 | envOverrides.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD = toBooleanEnvValue( | | |
| 352 | autoBootstrapProjectFromCwd, | | |
| 353 | ); | | |
| 354 | } | | |
| 355 | | | |
| 356 | const logWebSocketEvents = optionValue(input.logWebSocketEvents); | | |
| 357 | if (logWebSocketEvents !== undefined) { | | |
| 358 | envOverrides.T3CODE_LOG_WS_EVENTS = toBooleanEnvValue(logWebSocketEvents); | | |
| 359 | } | | |
| 360 | | | |
| 361 | return envOverrides; | | |
| 362 | } | | |
| 363 | | | |
| 364 | export function runDevRunnerWithInput(input: DevRunnerCliInput) { | 353 | export function runDevRunnerWithInput(input: DevRunnerCliInput) { |
| 365 | return Effect.gen(function* () { | 354 | return Effect.gen(function* () { |
| 366 | const envOverrides = envOverridesFromCliInput(input); | 355 | const { portOffset, devInstance } = yield* OffsetConfig.asEffect().pipe( |
| 367 | const turboArgs = Array.from(input.turboArgs); | 356 | Effect.mapError( |
| | 357 | (cause) => |
| | 358 | new DevRunnerError({ |
| | 359 | message: "Failed to read T3CODE_PORT_OFFSET/T3CODE_DEV_INSTANCE configuration.", |
| | 360 | cause, |
| | 361 | }), |
| | 362 | ), |
| | 363 | ); |
| | 364 | |
| 368 | const { offset, source } = yield* Effect.try({ | 365 | const { offset, source } = yield* Effect.try({ |
| 369 | try: () => resolveOffset(), | 366 | try: () => resolveOffset({ portOffset, devInstance }), |
| 370 | catch: (cause) => | 367 | catch: (cause) => |
| 371 | new DevRunnerError({ | 368 | new DevRunnerError({ |
| 372 | message: cause instanceof Error ? cause.message : String(cause), | 369 | message: cause instanceof Error ? cause.message : String(cause), |
| ... | @@ -377,46 +374,32 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { | ... | @@ -377,46 +374,32 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { |
| 377 | const { serverOffset, webOffset } = yield* resolveModePortOffsets({ | 374 | const { serverOffset, webOffset } = yield* resolveModePortOffsets({ |
| 378 | mode: input.mode, | 375 | mode: input.mode, |
| 379 | startOffset: offset, | 376 | startOffset: offset, |
| 380 | envOverrides, | 377 | hasExplicitServerPort: input.port !== undefined, |
| | 378 | hasExplicitDevUrl: input.devUrl !== undefined, |
| 381 | }); | 379 | }); |
| 382 | | 380 | |
| 383 | const mergedEnvOverrides = { ...envOverrides }; | | |
| 384 | if (!("VITE_DEV_SERVER_URL" in mergedEnvOverrides)) { | | |
| 385 | const { webPort } = portPairForOffset(webOffset); | | |
| 386 | mergedEnvOverrides.PORT = String(webPort); | | |
| 387 | mergedEnvOverrides.ELECTRON_RENDERER_PORT = String(webPort); | | |
| 388 | mergedEnvOverrides.VITE_DEV_SERVER_URL = `http://localhost:${webPort}`; | | |
| 389 | } | | |
| 390 | | | |
| 391 | const env = yield* createDevRunnerEnv({ | 381 | const env = yield* createDevRunnerEnv({ |
| 392 | mode: input.mode, | 382 | mode: input.mode, |
| 393 | env: process.env, | 383 | baseEnv: process.env, |
| 394 | offset: serverOffset, | 384 | serverOffset, |
| 395 | envOverrides: mergedEnvOverrides, | 385 | webOffset, |
| | 386 | stateDir: input.stateDir, |
| | 387 | authToken: input.authToken, |
| | 388 | noBrowser: input.noBrowser, |
| | 389 | autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd, |
| | 390 | logWebSocketEvents: input.logWebSocketEvents, |
| | 391 | host: input.host, |
| | 392 | port: input.port, |
| | 393 | devUrl: input.devUrl, |
| 396 | }); | 394 | }); |
| 397 | | 395 | |
| 398 | const parsedServerPort = Number(env.T3CODE_PORT); | | |
| 399 | const parsedWebPort = Number(env.PORT); | | |
| 400 | if ( | | |
| 401 | !Number.isInteger(parsedServerPort) || | | |
| 402 | parsedServerPort < 1 || | | |
| 403 | parsedServerPort > MAX_PORT || | | |
| 404 | !Number.isInteger(parsedWebPort) || | | |
| 405 | parsedWebPort < 1 || | | |
| 406 | parsedWebPort > MAX_PORT | | |
| 407 | ) { | | |
| 408 | return yield* new DevRunnerError({ | | |
| 409 | message: `Invalid computed dev ports: server='${String(env.T3CODE_PORT)}' web='${String(env.PORT)}'.`, | | |
| 410 | }); | | |
| 411 | } | | |
| 412 | | | |
| 413 | const selectionSuffix = | 396 | const selectionSuffix = |
| 414 | serverOffset !== offset || webOffset !== offset | 397 | serverOffset !== offset || webOffset !== offset |
| 415 | ? ` selectedOffset(server=${serverOffset},web=${webOffset})` | 398 | ? ` selectedOffset(server=${serverOffset},web=${webOffset})` |
| 416 | : ""; | 399 | : ""; |
| 417 | | 400 | |
| 418 | yield* Effect.logInfo( | 401 | yield* Effect.logInfo( |
| 419 | `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${parsedServerPort} webPort=${parsedWebPort} stateDir=${String(env.T3CODE_STATE_DIR)}`, | 402 | `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} stateDir=${String(env.T3CODE_STATE_DIR)}`, |
| 420 | ); | 403 | ); |
| 421 | | 404 | |
| 422 | if (input.dryRun) { | 405 | if (input.dryRun) { |
| ... | @@ -424,7 +407,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { | ... | @@ -424,7 +407,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { |
| 424 | } | 407 | } |
| 425 | | 408 | |
| 426 | const command = process.platform === "win32" ? "turbo.cmd" : "turbo"; | 409 | const command = process.platform === "win32" ? "turbo.cmd" : "turbo"; |
| 427 | const child = yield* ChildProcess.make(command, [...MODE_ARGS[input.mode], ...turboArgs], { | 410 | const child = yield* ChildProcess.make(command, [...MODE_ARGS[input.mode], ...input.turboArgs], { |
| 428 | stdin: "inherit", | 411 | stdin: "inherit", |
| 429 | stdout: "inherit", | 412 | stdout: "inherit", |
| 430 | stderr: "inherit", | 413 | stderr: "inherit", |
| ... | @@ -432,7 +415,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { | ... | @@ -432,7 +415,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { |
| 432 | extendEnv: false, | 415 | extendEnv: false, |
| 433 | }); | 416 | }); |
| 434 | | 417 | |
| 435 | const exitCode = Number(yield* child.exitCode); | 418 | const exitCode = yield* child.exitCode; |
| 436 | if (exitCode !== 0) { | 419 | if (exitCode !== 0) { |
| 437 | return yield* new DevRunnerError({ | 420 | return yield* new DevRunnerError({ |
| 438 | message: `turbo exited with code ${exitCode}`, | 421 | message: `turbo exited with code ${exitCode}`, |
| ... | @@ -456,37 +439,41 @@ const devRunnerCli = Command.make("dev-runner", { | ... | @@ -456,37 +439,41 @@ const devRunnerCli = Command.make("dev-runner", { |
| 456 | ), | 439 | ), |
| 457 | stateDir: Flag.string("state-dir").pipe( | 440 | stateDir: Flag.string("state-dir").pipe( |
| 458 | Flag.withDescription("State directory path (forwards to T3CODE_STATE_DIR)."), | 441 | Flag.withDescription("State directory path (forwards to T3CODE_STATE_DIR)."), |
| 459 | Flag.optional, | 442 | Flag.withFallbackConfig(optionalStringConfig("T3CODE_STATE_DIR")), |
| 460 | ), | 443 | ), |
| 461 | authToken: Flag.string("auth-token").pipe( | 444 | authToken: Flag.string("auth-token").pipe( |
| 462 | Flag.withDescription("Auth token (forwards to T3CODE_AUTH_TOKEN)."), | 445 | Flag.withDescription("Auth token (forwards to T3CODE_AUTH_TOKEN)."), |
| 463 | Flag.withAlias("token"), | 446 | Flag.withAlias("token"), |
| 464 | Flag.optional, | 447 | Flag.withFallbackConfig(optionalStringConfig("T3CODE_AUTH_TOKEN")), |
| 465 | ), | 448 | ), |
| 466 | noBrowser: Flag.string("no-browser").pipe( | 449 | noBrowser: Flag.boolean("no-browser").pipe( |
| 467 | Flag.withDescription("Browser auto-open toggle. Accepts true/false/1/0/no."), | 450 | Flag.withDescription("Browser auto-open toggle (equivalent to T3CODE_NO_BROWSER)."), |
| 468 | Flag.optional, | 451 | Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_NO_BROWSER")), |
| 469 | ), | 452 | ), |
| 470 | autoBootstrapProjectFromCwd: Flag.string("auto-bootstrap-project-from-cwd").pipe( | 453 | autoBootstrapProjectFromCwd: Flag.boolean("auto-bootstrap-project-from-cwd").pipe( |
| 471 | Flag.withDescription("Auto-bootstrap toggle. Accepts true/false/1/0/no."), | 454 | Flag.withDescription( |
| 472 | Flag.optional, | 455 | "Auto-bootstrap toggle (equivalent to T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD).", |
| | 456 | ), |
| | 457 | Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD")), |
| 473 | ), | 458 | ), |
| 474 | logWebSocketEvents: Flag.string("log-websocket-events").pipe( | 459 | logWebSocketEvents: Flag.boolean("log-websocket-events").pipe( |
| 475 | Flag.withDescription("WebSocket event logging toggle. Accepts true/false/1/0/no."), | 460 | Flag.withDescription("WebSocket event logging toggle (equivalent to T3CODE_LOG_WS_EVENTS)."), |
| 476 | Flag.withAlias("log-ws-events"), | 461 | Flag.withAlias("log-ws-events"), |
| 477 | Flag.optional, | 462 | Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_LOG_WS_EVENTS")), |
| 478 | ), | 463 | ), |
| 479 | host: Flag.string("host").pipe( | 464 | host: Flag.string("host").pipe( |
| 480 | Flag.withDescription("Server host/interface override (forwards to T3CODE_HOST)."), | 465 | Flag.withDescription("Server host/interface override (forwards to T3CODE_HOST)."), |
| 481 | Flag.optional, | 466 | Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOST")), |
| 482 | ), | 467 | ), |
| 483 | port: Flag.string("port").pipe( | 468 | port: Flag.integer("port").pipe( |
| | 469 | Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), |
| 484 | Flag.withDescription("Server port override (forwards to T3CODE_PORT)."), | 470 | Flag.withDescription("Server port override (forwards to T3CODE_PORT)."), |
| 485 | Flag.optional, | 471 | Flag.withFallbackConfig(optionalPortConfig("T3CODE_PORT")), |
| 486 | ), | 472 | ), |
| 487 | devUrl: Flag.string("dev-url").pipe( | 473 | devUrl: Flag.string("dev-url").pipe( |
| | 474 | Flag.withSchema(Schema.URLFromString), |
| 488 | Flag.withDescription("Web dev URL override (forwards to VITE_DEV_SERVER_URL)."), | 475 | Flag.withDescription("Web dev URL override (forwards to VITE_DEV_SERVER_URL)."), |
| 489 | Flag.optional, | 476 | Flag.withFallbackConfig(optionalUrlConfig("VITE_DEV_SERVER_URL")), |
| 490 | ), | 477 | ), |
| 491 | dryRun: Flag.boolean("dry-run").pipe( | 478 | dryRun: Flag.boolean("dry-run").pipe( |
| 492 | Flag.withDescription("Resolve mode/ports/env and print, but do not spawn turbo."), | 479 | Flag.withDescription("Resolve mode/ports/env and print, but do not spawn turbo."), |
| ... | @@ -501,26 +488,16 @@ const devRunnerCli = Command.make("dev-runner", { | ... | @@ -501,26 +488,16 @@ const devRunnerCli = Command.make("dev-runner", { |
| 501 | Command.withHandler((input) => runDevRunnerWithInput(input)), | 488 | Command.withHandler((input) => runDevRunnerWithInput(input)), |
| 502 | ); | 489 | ); |
| 503 | | 490 | |
| 504 | export function runDevRunner(argv = process.argv) { | 491 | const cliRuntimeLayer = Layer.mergeAll( |
| 505 | return Command.runWith(devRunnerCli, { version: "0.0.0" })(argv.slice(2)).pipe( | | |
| 506 | Effect.mapError((cause) => | | |
| 507 | cause instanceof DevRunnerError | | |
| 508 | ? cause | | |
| 509 | : new DevRunnerError({ | | |
| 510 | message: cause instanceof Error ? cause.message : "dev-runner argument parsing failed", | | |
| 511 | cause, | | |
| 512 | }), | | |
| 513 | ), | | |
| 514 | ); | | |
| 515 | } | | |
| 516 | | | |
| 517 | const runtimeLayer = Layer.mergeAll( | | |
| 518 | Logger.layer([Logger.consolePretty()]), | 492 | Logger.layer([Logger.consolePretty()]), |
| 519 | NodeServices.layer, | 493 | NodeServices.layer, |
| 520 | NetService.layer, | 494 | NetService.layer, |
| 521 | ); | 495 | ); |
| 522 | | 496 | |
| 523 | const runtimeProgram = runDevRunner().pipe(Effect.scoped, Effect.provide(runtimeLayer)); | 497 | const runtimeProgram = Command.run(devRunnerCli, { version: "0.0.0" }).pipe( |
| | 498 | Effect.scoped, |
| | 499 | Effect.provide(cliRuntimeLayer), |
| | 500 | ); |
| 524 | | 501 | |
| 525 | if (import.meta.main) { | 502 | if (import.meta.main) { |
| 526 | NodeRuntime.runMain(runtimeProgram); | 503 | NodeRuntime.runMain(runtimeProgram); |