| ... | ... | @@ -1,17 +1,19 @@ |
| 1 | | import fs from "node:fs"; |
| 2 | 1 | import net from "node:net"; |
| 3 | 2 | import os from "node:os"; |
| 4 | | import path from "node:path"; |
| 5 | | import { fileURLToPath } from "node:url"; |
| 6 | 3 | |
| 7 | | import * as Data from "effect/Data"; |
| 8 | | import * as Effect from "effect/Effect"; |
| 9 | | import * as Layer from "effect/Layer"; |
| 10 | | import * as Option from "effect/Option"; |
| 11 | | import * as Config from "effect/Config"; |
| 12 | | import { ServiceMap } from "effect"; |
| 13 | | import * as Command from "effect/unstable/cli/Command"; |
| 14 | | import * as Flag from "effect/unstable/cli/Flag"; |
| 4 | import { |
| 5 | ServiceMap, |
| 6 | Data, |
| 7 | Effect, |
| 8 | Layer, |
| 9 | Option, |
| 10 | Config, |
| 11 | Path, |
| 12 | Schema, |
| 13 | FileSystem, |
| 14 | PlatformError, |
| 15 | } from "effect"; |
| 16 | import { Command, Flag } from "effect/unstable/cli"; |
| 15 | 17 | |
| 16 | 18 | import { fixPath } from "./fixPath"; |
| 17 | 19 | import { Open } from "./open"; |
| ... | ... | @@ -31,22 +33,11 @@ interface CliInput { |
| 31 | 33 | readonly token: Option.Option<string>; |
| 32 | 34 | } |
| 33 | 35 | |
| 34 | | interface ResolvedCliConfig { |
| 35 | | readonly port: number; |
| 36 | | readonly mode: RuntimeMode; |
| 37 | | readonly cwd: string; |
| 38 | | readonly stateDir: string; |
| 39 | | readonly devUrl: URL | undefined; |
| 40 | | readonly noBrowser: boolean; |
| 41 | | readonly authToken: string | undefined; |
| 42 | | readonly staticDir: string | undefined; |
| 43 | | } |
| 44 | | |
| 45 | 36 | export interface CliConfigShape { |
| 46 | 37 | readonly cwd: string; |
| 47 | 38 | readonly fixPath: Effect.Effect<unknown, never>; |
| 48 | 39 | readonly findAvailablePort: (preferred: number) => Effect.Effect<number, StartupError>; |
| 49 | | readonly resolveStaticDir: () => string | undefined; |
| 40 | readonly resolveStaticDir: () => Effect.Effect<string | undefined, PlatformError.PlatformError>; |
| 50 | 41 | } |
| 51 | 42 | |
| 52 | 43 | export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()( |
| ... | ... | @@ -79,31 +70,22 @@ const CliEnvConfig = Config.all({ |
| 79 | 70 | ), |
| 80 | 71 | }); |
| 81 | 72 | |
| 82 | | function assertValidPort(value: number, source: string): Effect.Effect<number, StartupError> { |
| 83 | | if (Number.isInteger(value) && value >= 1 && value <= 65535) { |
| 84 | | return Effect.succeed(value); |
| 85 | | } |
| 86 | | return Effect.fail( |
| 87 | | new StartupError({ |
| 88 | | message: `Invalid ${source}: ${value}. Expected an integer between 1 and 65535.`, |
| 89 | | }), |
| 90 | | ); |
| 91 | | } |
| 92 | | |
| 93 | | function expandHomePath(input: string): string { |
| 73 | const expandHomePath = Effect.fn(function* (input: string) { |
| 74 | const { join } = yield* Path.Path; |
| 94 | 75 | if (input === "~") return os.homedir(); |
| 95 | 76 | if (input.startsWith("~/")) { |
| 96 | | return path.join(os.homedir(), input.slice(2)); |
| 77 | return join(os.homedir(), input.slice(2)); |
| 97 | 78 | } |
| 98 | 79 | return input; |
| 99 | | } |
| 80 | }); |
| 100 | 81 | |
| 101 | | function resolveStateDir(raw: string | undefined): string { |
| 82 | const resolveStateDir = Effect.fn(function* (raw: string | undefined) { |
| 83 | const { join, resolve } = yield* Path.Path; |
| 102 | 84 | if (!raw || raw.trim().length === 0) { |
| 103 | | return path.join(os.homedir(), ".t3", "userdata"); |
| 85 | return join(os.homedir(), ".t3", "userdata"); |
| 104 | 86 | } |
| 105 | | return path.resolve(expandHomePath(raw.trim())); |
| 106 | | } |
| 87 | return resolve(yield* expandHomePath(raw.trim())); |
| 88 | }); |
| 107 | 89 | |
| 108 | 90 | async function findAvailablePort(preferred: number): Promise<number> { |
| 109 | 91 | return new Promise((resolve, reject) => { |
| ... | ... | @@ -126,69 +108,57 @@ async function findAvailablePort(preferred: number): Promise<number> { |
| 126 | 108 | }); |
| 127 | 109 | } |
| 128 | 110 | |
| 129 | | function resolveStaticDir(): string | undefined { |
| 130 | | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 131 | | |
| 132 | | const bundledClient = path.resolve(__dirname, "client"); |
| 133 | | try { |
| 134 | | const stat = fs.statSync(path.join(bundledClient, "index.html")); |
| 135 | | if (stat.isFile()) return bundledClient; |
| 136 | | } catch { |
| 137 | | // Not bundled — check monorepo layout. |
| 138 | | } |
| 139 | | |
| 140 | | const monorepoClient = path.resolve(__dirname, "../../web/dist"); |
| 141 | | try { |
| 142 | | const stat = fs.statSync(path.join(monorepoClient, "index.html")); |
| 143 | | if (stat.isFile()) return monorepoClient; |
| 144 | | } catch { |
| 145 | | // Not found — probably dev mode. |
| 146 | | } |
| 111 | const resolveStaticDir = Effect.fn(function* () { |
| 112 | const { join, resolve } = yield* Path.Path; |
| 113 | const { stat } = yield* FileSystem.FileSystem; |
| 114 | const bundledClient = resolve(join(import.meta.dirname, "client")); |
| 115 | const bundledStat = yield* stat(join(bundledClient, "index.html")).pipe( |
| 116 | Effect.orElseSucceed(() => undefined), |
| 117 | ); |
| 118 | if (bundledStat) return bundledClient; |
| 147 | 119 | |
| 120 | const monorepoClient = resolve(join(import.meta.dirname, "../../web/dist")); |
| 121 | const monorepoStat = yield* stat(join(monorepoClient, "index.html")).pipe( |
| 122 | Effect.orElseSucceed(() => undefined), |
| 123 | ); |
| 124 | if (monorepoStat) return monorepoClient; |
| 148 | 125 | return undefined; |
| 149 | | } |
| 150 | | |
| 151 | | function resolveCliConfig( |
| 152 | | input: CliInput, |
| 153 | | ): Effect.Effect<ResolvedCliConfig, StartupError, CliConfig> { |
| 154 | | return Effect.gen(function* () { |
| 155 | | const cliConfig = yield* CliConfig; |
| 156 | | const env = yield* CliEnvConfig.asEffect().pipe( |
| 157 | | Effect.mapError( |
| 158 | | (cause) => |
| 159 | | new StartupError({ |
| 160 | | message: "Failed to read environment configuration", |
| 161 | | cause, |
| 162 | | }), |
| 163 | | ), |
| 164 | | ); |
| 165 | | const mode: RuntimeMode = env.mode === "desktop" ? "desktop" : "web"; |
| 126 | }); |
| 166 | 127 | |
| 167 | | const cliPort = Option.getOrUndefined(input.port); |
| 168 | | const requestedPort = |
| 169 | | cliPort === undefined ? env.port : yield* assertValidPort(cliPort, "--port"); |
| 128 | const resolveCliConfig = Effect.fn(function* (input: CliInput) { |
| 129 | const cliConfig = yield* CliConfig; |
| 130 | const env = yield* CliEnvConfig.asEffect().pipe( |
| 131 | Effect.mapError( |
| 132 | (cause) => |
| 133 | new StartupError({ |
| 134 | message: "Failed to read environment configuration", |
| 135 | cause, |
| 136 | }), |
| 137 | ), |
| 138 | ); |
| 139 | const mode: RuntimeMode = env.mode === "desktop" ? "desktop" : "web"; |
| 170 | 140 | |
| 171 | | const port = |
| 172 | | requestedPort ?? |
| 173 | | (mode === "desktop" ? DEFAULT_PORT : yield* cliConfig.findAvailablePort(DEFAULT_PORT)); |
| 174 | | const stateDir = resolveStateDir(env.stateDir); |
| 175 | | const devUrl = env.devUrl; |
| 176 | | const noBrowser = env.noBrowser ?? mode === "desktop"; |
| 177 | | const authToken = Option.getOrUndefined(input.token) ?? env.authToken; |
| 178 | | const staticDir = devUrl ? undefined : cliConfig.resolveStaticDir(); |
| 141 | const cliPort = Option.getOrUndefined(input.port); |
| 142 | const port = |
| 143 | (cliPort === undefined ? env.port : cliPort) ?? |
| 144 | (mode === "desktop" ? DEFAULT_PORT : yield* cliConfig.findAvailablePort(DEFAULT_PORT)); |
| 145 | const stateDir = yield* resolveStateDir(env.stateDir); |
| 146 | const devUrl = env.devUrl; |
| 147 | const noBrowser = env.noBrowser ?? mode === "desktop"; |
| 148 | const authToken = Option.getOrUndefined(input.token) ?? env.authToken; |
| 149 | const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir(); |
| 179 | 150 | |
| 180 | | return { |
| 181 | | mode, |
| 182 | | port, |
| 183 | | stateDir, |
| 184 | | devUrl, |
| 185 | | noBrowser, |
| 186 | | authToken, |
| 187 | | staticDir, |
| 188 | | cwd: cliConfig.cwd, |
| 189 | | }; |
| 190 | | }); |
| 191 | | } |
| 151 | return { |
| 152 | mode, |
| 153 | port, |
| 154 | stateDir, |
| 155 | devUrl, |
| 156 | noBrowser, |
| 157 | authToken, |
| 158 | staticDir, |
| 159 | cwd: cliConfig.cwd, |
| 160 | }; |
| 161 | }); |
| 192 | 162 | |
| 193 | 163 | const makeServerProgram = Effect.fn(function* (input: CliInput) { |
| 194 | 164 | const cliConfig = yield* CliConfig; |
| ... | ... | @@ -253,6 +223,7 @@ const makeServerProgram = Effect.fn(function* (input: CliInput) { |
| 253 | 223 | |
| 254 | 224 | export function makeCliCommand() { |
| 255 | 225 | const portFlag = Flag.integer("port").pipe( |
| 226 | Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), |
| 256 | 227 | Flag.withDescription("Port for the HTTP/WebSocket server."), |
| 257 | 228 | Flag.optional, |
| 258 | 229 | ); |
| ... | ... | @@ -270,14 +241,25 @@ export function makeCliCommand() { |
| 270 | 241 | ); |
| 271 | 242 | } |
| 272 | 243 | |
| 273 | | export const CliConfigLive = Layer.succeed(CliConfig, { |
| 274 | | cwd: process.cwd(), |
| 275 | | fixPath: Effect.sync(fixPath), |
| 276 | | findAvailablePort: (preferred) => |
| 277 | | Effect.tryPromise({ |
| 278 | | try: () => findAvailablePort(preferred), |
| 279 | | catch: (cause) => |
| 280 | | new StartupError({ message: "Failed to discover an available port", cause }), |
| 281 | | }), |
| 282 | | resolveStaticDir, |
| 283 | | } satisfies CliConfigShape); |
| 244 | export const CliConfigLive = Layer.effect( |
| 245 | CliConfig, |
| 246 | Effect.gen(function* () { |
| 247 | const fileSystem = yield* FileSystem.FileSystem; |
| 248 | const path = yield* Path.Path; |
| 249 | return { |
| 250 | cwd: process.cwd(), |
| 251 | fixPath: Effect.sync(fixPath), |
| 252 | findAvailablePort: (preferred) => |
| 253 | Effect.tryPromise({ |
| 254 | try: () => findAvailablePort(preferred), |
| 255 | catch: (cause) => |
| 256 | new StartupError({ message: "Failed to discover an available port", cause }), |
| 257 | }), |
| 258 | resolveStaticDir: () => |
| 259 | resolveStaticDir().pipe( |
| 260 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 261 | Effect.provideService(Path.Path, path), |
| 262 | ), |
| 263 | } satisfies CliConfigShape; |
| 264 | }), |
| 265 | ); |