| author | |
| committer | |
| log | b76a0a6c913f680ad5be15bd50b890249aeae24b |
| tree | 855cd192fd7903c4263110072efaaae312c72fcf |
| parent | 9fdec3c2c8fbc1647843bb2f49a45224daa5aa30 |
- Replace `scripts/dev-runner.mjs` with typed `scripts/dev-runner.ts` in a new `@t3tools/scripts` workspace
- Move networking helpers into `@t3tools/shared/Net` and reuse them in server and desktop
- Add shared NetService tests and align workspace deps to `catalog:` versions20 files changed, 1160 insertions(+), 783 deletions(-)
apps/desktop/package.json+5-4| ... | ... | @@ -14,15 +14,16 @@ |
| 14 | 14 | "smoke-test": "node scripts/smoke-test.mjs" |
| 15 | 15 | }, |
| 16 | 16 | "dependencies": { |
| 17 | "@t3tools/shared": "workspace:*", | |
| 17 | 18 | "electron": "40.6.0" |
| 18 | 19 | }, |
| 19 | 20 | "devDependencies": { |
| 20 | 21 | "@t3tools/contracts": "workspace:*", |
| 21 | "@types/node": "^24.10.13", | |
| 22 | "@types/node": "catalog:", | |
| 22 | 23 | "electronmon": "^2.0.2", |
| 23 | "tsdown": "^0.20.3", | |
| 24 | "typescript": "^5.7.3", | |
| 25 | "vitest": "^4.0.0", | |
| 24 | "tsdown": "catalog:", | |
| 25 | "typescript": "catalog:", | |
| 26 | "vitest": "catalog:", | |
| 26 | 27 | "wait-on": "^8.0.2" |
| 27 | 28 | }, |
| 28 | 29 | "productName": "T3 Code (Alpha)" |
apps/desktop/src/main.ts+8-20| ... | ... | @@ -1,14 +1,15 @@ |
| 1 | 1 | import { spawn, type ChildProcess } from "node:child_process"; |
| 2 | 2 | import { randomBytes } from "node:crypto"; |
| 3 | 3 | import fs from "node:fs"; |
| 4 | import net from "node:net"; | |
| 5 | 4 | import os from "node:os"; |
| 6 | 5 | import path from "node:path"; |
| 7 | 6 | |
| 8 | 7 | import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron"; |
| 9 | 8 | import type { MenuItemConstructorOptions } from "electron"; |
| 9 | import { Effect } from "effect"; | |
| 10 | 10 | |
| 11 | 11 | import type { ContextMenuItem } from "@t3tools/contracts"; |
| 12 | import { NetService } from "@t3tools/shared/Net"; | |
| 12 | 13 | import { showDesktopConfirmDialog } from "./confirmDialog"; |
| 13 | 14 | import { fixPath } from "./fixPath"; |
| 14 | 15 | |
| ... | ... | @@ -347,24 +348,6 @@ function configureAppIdentity(): void { |
| 347 | 348 | } |
| 348 | 349 | } |
| 349 | 350 | |
| 350 | async function reserveLoopbackPort(): Promise<number> { | |
| 351 | return new Promise((resolve, reject) => { | |
| 352 | const probe = net.createServer(); | |
| 353 | probe.listen(0, "127.0.0.1", () => { | |
| 354 | const address = probe.address(); | |
| 355 | const port = typeof address === "object" && address !== null ? address.port : 0; | |
| 356 | probe.close(() => { | |
| 357 | if (port > 0) { | |
| 358 | resolve(port); | |
| 359 | return; | |
| 360 | } | |
| 361 | reject(new Error("Failed to reserve backend port")); | |
| 362 | }); | |
| 363 | }); | |
| 364 | probe.on("error", reject); | |
| 365 | }); | |
| 366 | } | |
| 367 | ||
| 368 | 351 | function backendEnv(): NodeJS.ProcessEnv { |
| 369 | 352 | return { |
| 370 | 353 | ...process.env, |
| ... | ... | @@ -621,7 +604,12 @@ function createWindow(): BrowserWindow { |
| 621 | 604 | configureAppIdentity(); |
| 622 | 605 | |
| 623 | 606 | async function bootstrap(): Promise<void> { |
| 624 | backendPort = await reserveLoopbackPort(); | |
| 607 | backendPort = await Effect.service(NetService).pipe( | |
| 608 | Effect.flatMap((net) => net.reserveLoopbackPort()), | |
| 609 | Effect.flatMap((port) => Effect.succeed(port)), | |
| 610 | Effect.provide(NetService.layer), | |
| 611 | Effect.runPromise, | |
| 612 | ); | |
| 625 | 613 | backendAuthToken = randomBytes(24).toString("hex"); |
| 626 | 614 | backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`; |
| 627 | 615 | process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl; |
apps/server/package.json+6-6| ... | ... | @@ -26,17 +26,17 @@ |
| 26 | 26 | "ws": "^8.18.0" |
| 27 | 27 | }, |
| 28 | 28 | "devDependencies": { |
| 29 | "@effect/language-service": "0.75.1", | |
| 29 | "@effect/language-service": "catalog:", | |
| 30 | 30 | "@effect/vitest": "catalog:", |
| 31 | 31 | "@t3tools/contracts": "workspace:*", |
| 32 | 32 | "@t3tools/shared": "workspace:*", |
| 33 | 33 | "@t3tools/web": "workspace:*", |
| 34 | "@types/bun": "^1.3.9", | |
| 35 | "@types/node": "^24.10.13", | |
| 34 | "@types/bun": "catalog:", | |
| 35 | "@types/node": "catalog:", | |
| 36 | 36 | "@types/ws": "^8.5.13", |
| 37 | "tsdown": "^0.20.3", | |
| 38 | "typescript": "^5.7.3", | |
| 39 | "vitest": "^4.0.0" | |
| 37 | "tsdown": "catalog:", | |
| 38 | "typescript": "catalog:", | |
| 39 | "vitest": "catalog:" | |
| 40 | 40 | }, |
| 41 | 41 | "engines": { |
| 42 | 42 | "node": "^22.13 || ^23.4 || >=24.10" |
apps/server/src/config.ts+2-65| ... | ... | @@ -1,13 +1,12 @@ |
| 1 | 1 | /** |
| 2 | * ServerConfig and NetService - Runtime configuration services. | |
| 2 | * ServerConfig - Runtime configuration services. | |
| 3 | 3 | * |
| 4 | 4 | * Defines process-level server configuration and networking helpers used by |
| 5 | 5 | * startup and runtime layers. |
| 6 | 6 | * |
| 7 | 7 | * @module ServerConfig |
| 8 | 8 | */ |
| 9 | import * as Net from "node:net"; | |
| 10 | import { Data, Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; | |
| 9 | import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; | |
| 11 | 10 | |
| 12 | 11 | export const DEFAULT_PORT = 3773; |
| 13 | 12 | |
| ... | ... | @@ -60,68 +59,6 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS |
| 60 | 59 | ); |
| 61 | 60 | } |
| 62 | 61 | |
| 63 | // Helpers | |
| 64 | ||
| 65 | export class NetError extends Data.TaggedError("NetError")<{ | |
| 66 | readonly message: string; | |
| 67 | readonly cause?: unknown; | |
| 68 | }> {} | |
| 69 | ||
| 70 | /** | |
| 71 | * NetServiceShape - Networking helper operations used during startup. | |
| 72 | */ | |
| 73 | export interface NetServiceShape { | |
| 74 | /** | |
| 75 | * Resolve an available listening port, preferring the provided port first. | |
| 76 | * | |
| 77 | * Falls back to an ephemeral OS-assigned port when the preferred port is in use. | |
| 78 | */ | |
| 79 | readonly findAvailablePort: (preferred: number) => Effect.Effect<number, NetError>; | |
| 80 | } | |
| 81 | ||
| 82 | /** | |
| 83 | * NetService - Service tag for startup networking helpers. | |
| 84 | */ | |
| 85 | export class NetService extends ServiceMap.Service<NetService, NetServiceShape>()( | |
| 86 | "t3/config/NetService", | |
| 87 | ) { | |
| 88 | static readonly layer = Layer.succeed(NetService, { | |
| 89 | findAvailablePort: (preferred) => | |
| 90 | Effect.callback<number, NetError>((resume) => { | |
| 91 | let fallbackServer: Net.Server | null = null; | |
| 92 | const server = Net.createServer(); | |
| 93 | server.listen(preferred, () => { | |
| 94 | server.close(() => resume(Effect.succeed(preferred))); | |
| 95 | }); | |
| 96 | server.on("error", () => { | |
| 97 | const fallback = Net.createServer(); | |
| 98 | fallback.listen(0, () => { | |
| 99 | const addr = fallback.address(); | |
| 100 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 101 | fallback.close(() => { | |
| 102 | resume( | |
| 103 | port > 0 | |
| 104 | ? Effect.succeed(port) | |
| 105 | : Effect.fail(new NetError({ message: "Could not find an available port." })), | |
| 106 | ); | |
| 107 | }); | |
| 108 | }); | |
| 109 | fallback.on("error", (cause) => { | |
| 110 | resume( | |
| 111 | Effect.fail(new NetError({ message: "Failed to find an available port", cause })), | |
| 112 | ); | |
| 113 | }); | |
| 114 | fallbackServer = fallback; | |
| 115 | }); | |
| 116 | ||
| 117 | return Effect.sync(() => { | |
| 118 | server.close(); | |
| 119 | fallbackServer?.close(); | |
| 120 | }); | |
| 121 | }), | |
| 122 | }); | |
| 123 | } | |
| 124 | ||
| 125 | 62 | export const resolveStaticDir = Effect.fn(function* () { |
| 126 | 63 | const { join, resolve } = yield* Path.Path; |
| 127 | 64 | const { exists } = yield* FileSystem.FileSystem; |
apps/server/src/index.ts+1-1| ... | ... | @@ -8,7 +8,7 @@ import { OpenLive } from "./open"; |
| 8 | 8 | import { Command } from "effect/unstable/cli"; |
| 9 | 9 | import { version } from "../package.json" with { type: "json" }; |
| 10 | 10 | import { ServerLive } from "./wsServer"; |
| 11 | import { NetService } from "./config"; | |
| 11 | import { NetService } from "@t3tools/shared/Net"; | |
| 12 | 12 | |
| 13 | 13 | const RuntimeLayer = Layer.empty.pipe( |
| 14 | 14 | Layer.provideMerge(CliConfig.layer), |
apps/server/src/main.test.ts+5-1| ... | ... | @@ -6,9 +6,10 @@ import * as Effect from "effect/Effect"; |
| 6 | 6 | import * as Layer from "effect/Layer"; |
| 7 | 7 | import * as Command from "effect/unstable/cli/Command"; |
| 8 | 8 | import { beforeEach } from "vitest"; |
| 9 | import { NetService } from "@t3tools/shared/Net"; | |
| 9 | 10 | |
| 10 | 11 | import { CliConfig, t3Cli, type CliConfigShape } from "./main"; |
| 11 | import { NetService, ServerConfig, type ServerConfigShape } from "./config"; | |
| 12 | import { ServerConfig, type ServerConfigShape } from "./config"; | |
| 12 | 13 | import { Open, type OpenShape } from "./open"; |
| 13 | 14 | import { Server, type ServerShape } from "./wsServer"; |
| 14 | 15 | |
| ... | ... | @@ -33,6 +34,9 @@ const testLayer = Layer.mergeAll( |
| 33 | 34 | resolveStaticDir: Effect.undefined, |
| 34 | 35 | } satisfies CliConfigShape), |
| 35 | 36 | Layer.succeed(NetService, { |
| 37 | canListenOnHost: () => Effect.succeed(true), | |
| 38 | isPortAvailableOnLoopback: () => Effect.succeed(true), | |
| 39 | reserveLoopbackPort: () => Effect.succeed(0), | |
| 36 | 40 | findAvailablePort, |
| 37 | 41 | }), |
| 38 | 42 | Layer.succeed(Server, { |
apps/server/src/main.ts+1-1| ... | ... | @@ -8,10 +8,10 @@ |
| 8 | 8 | */ |
| 9 | 9 | import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect"; |
| 10 | 10 | import { Command, Flag } from "effect/unstable/cli"; |
| 11 | import { NetService } from "@t3tools/shared/Net"; | |
| 11 | 12 | |
| 12 | 13 | import { |
| 13 | 14 | DEFAULT_PORT, |
| 14 | NetService, | |
| 15 | 15 | resolveStaticDir, |
| 16 | 16 | ServerConfig, |
| 17 | 17 | type RuntimeMode, |
apps/web/package.json+1-1| ... | ... | @@ -43,7 +43,7 @@ |
| 43 | 43 | "@vitejs/plugin-react": "^5.1.4", |
| 44 | 44 | "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", |
| 45 | 45 | "tailwindcss": "^4.0.0", |
| 46 | "typescript": "^5.7.3", | |
| 46 | "typescript": "catalog:", | |
| 47 | 47 | "vite": "^8.0.0-beta.12" |
| 48 | 48 | } |
| 49 | 49 | } |
bun.lock+42-22| ... | ... | @@ -5,29 +5,26 @@ |
| 5 | 5 | "": { |
| 6 | 6 | "name": "@t3tools/monorepo", |
| 7 | 7 | "devDependencies": { |
| 8 | "@effect/platform-node": "catalog:", | |
| 9 | "@types/node": "^24.10.13", | |
| 10 | "effect": "catalog:", | |
| 8 | "@types/node": "catalog:", | |
| 11 | 9 | "oxfmt": "^0.35.0", |
| 12 | 10 | "oxlint": "^1.50.0", |
| 13 | 11 | "turbo": "^2.3.3", |
| 14 | "typescript": "^5.7.3", | |
| 15 | "vitest": "^4.0.0", | |
| 16 | 12 | }, |
| 17 | 13 | }, |
| 18 | 14 | "apps/desktop": { |
| 19 | 15 | "name": "@t3tools/desktop", |
| 20 | 16 | "version": "0.0.0-alpha.12", |
| 21 | 17 | "dependencies": { |
| 18 | "@t3tools/shared": "workspace:*", | |
| 22 | 19 | "electron": "40.6.0", |
| 23 | 20 | }, |
| 24 | 21 | "devDependencies": { |
| 25 | 22 | "@t3tools/contracts": "workspace:*", |
| 26 | "@types/node": "^24.10.13", | |
| 23 | "@types/node": "catalog:", | |
| 27 | 24 | "electronmon": "^2.0.2", |
| 28 | "tsdown": "^0.20.3", | |
| 29 | "typescript": "^5.7.3", | |
| 30 | "vitest": "^4.0.0", | |
| 25 | "tsdown": "catalog:", | |
| 26 | "typescript": "catalog:", | |
| 27 | "vitest": "catalog:", | |
| 31 | 28 | "wait-on": "^8.0.2", |
| 32 | 29 | }, |
| 33 | 30 | }, |
| ... | ... | @@ -47,17 +44,17 @@ |
| 47 | 44 | "ws": "^8.18.0", |
| 48 | 45 | }, |
| 49 | 46 | "devDependencies": { |
| 50 | "@effect/language-service": "0.75.1", | |
| 47 | "@effect/language-service": "catalog:", | |
| 51 | 48 | "@effect/vitest": "catalog:", |
| 52 | 49 | "@t3tools/contracts": "workspace:*", |
| 53 | 50 | "@t3tools/shared": "workspace:*", |
| 54 | 51 | "@t3tools/web": "workspace:*", |
| 55 | "@types/bun": "^1.3.9", | |
| 56 | "@types/node": "^24.10.13", | |
| 52 | "@types/bun": "catalog:", | |
| 53 | "@types/node": "catalog:", | |
| 57 | 54 | "@types/ws": "^8.5.13", |
| 58 | "tsdown": "^0.20.3", | |
| 59 | "typescript": "^5.7.3", | |
| 60 | "vitest": "^4.0.0", | |
| 55 | "tsdown": "catalog:", | |
| 56 | "typescript": "catalog:", | |
| 57 | "vitest": "catalog:", | |
| 61 | 58 | }, |
| 62 | 59 | }, |
| 63 | 60 | "apps/web": { |
| ... | ... | @@ -95,7 +92,7 @@ |
| 95 | 92 | "@vitejs/plugin-react": "^5.1.4", |
| 96 | 93 | "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", |
| 97 | 94 | "tailwindcss": "^4.0.0", |
| 98 | "typescript": "^5.7.3", | |
| 95 | "typescript": "catalog:", | |
| 99 | 96 | "vite": "^8.0.0-beta.12", |
| 100 | 97 | }, |
| 101 | 98 | }, |
| ... | ... | @@ -106,10 +103,10 @@ |
| 106 | 103 | "effect": "catalog:", |
| 107 | 104 | }, |
| 108 | 105 | "devDependencies": { |
| 109 | "@effect/language-service": "0.75.1", | |
| 106 | "@effect/language-service": "catalog:", | |
| 110 | 107 | "@effect/vitest": "catalog:", |
| 111 | "tsdown": "^0.20.3", | |
| 112 | "typescript": "^5.7.3", | |
| 108 | "tsdown": "catalog:", | |
| 109 | "typescript": "catalog:", | |
| 113 | 110 | }, |
| 114 | 111 | }, |
| 115 | 112 | "packages/shared": { |
| ... | ... | @@ -119,10 +116,26 @@ |
| 119 | 116 | "effect": "catalog:", |
| 120 | 117 | }, |
| 121 | 118 | "devDependencies": { |
| 122 | "@effect/language-service": "0.75.1", | |
| 119 | "@effect/language-service": "catalog:", | |
| 123 | 120 | "@effect/vitest": "catalog:", |
| 124 | "typescript": "^5.7.3", | |
| 125 | "vitest": "^4.0.0", | |
| 121 | "@types/node": "catalog:", | |
| 122 | "typescript": "catalog:", | |
| 123 | "vitest": "catalog:", | |
| 124 | }, | |
| 125 | }, | |
| 126 | "scripts": { | |
| 127 | "name": "@t3tools/scripts", | |
| 128 | "dependencies": { | |
| 129 | "@t3tools/contracts": "workspace:*", | |
| 130 | "@t3tools/shared": "workspace:*", | |
| 131 | "effect": "catalog:", | |
| 132 | }, | |
| 133 | "devDependencies": { | |
| 134 | "@effect/language-service": "catalog:", | |
| 135 | "@effect/vitest": "catalog:", | |
| 136 | "@types/bun": "catalog:", | |
| 137 | "typescript": "catalog:", | |
| 138 | "vitest": "catalog:", | |
| 126 | 139 | }, |
| 127 | 140 | }, |
| 128 | 141 | }, |
| ... | ... | @@ -131,7 +144,12 @@ |
| 131 | 144 | "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", |
| 132 | 145 | "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", |
| 133 | 146 | "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", |
| 147 | "@types/bun": "^1.3.9", | |
| 148 | "@types/node": "^24.10.13", | |
| 134 | 149 | "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", |
| 150 | "tsdown": "^0.20.3", | |
| 151 | "typescript": "^5.7.3", | |
| 152 | "vitest": "^4.0.0", | |
| 135 | 153 | }, |
| 136 | 154 | "packages": { |
| 137 | 155 | "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], |
| ... | ... | @@ -552,6 +570,8 @@ |
| 552 | 570 | |
| 553 | 571 | "@t3tools/desktop": ["@t3tools/desktop@workspace:apps/desktop"], |
| 554 | 572 | |
| 573 | "@t3tools/scripts": ["@t3tools/scripts@workspace:scripts"], | |
| 574 | ||
| 555 | 575 | "@t3tools/shared": ["@t3tools/shared@workspace:packages/shared"], |
| 556 | 576 | |
| 557 | 577 | "@t3tools/web": ["@t3tools/web@workspace:apps/web"], |
package.json+14-12| ... | ... | @@ -4,22 +4,28 @@ |
| 4 | 4 | "workspaces": { |
| 5 | 5 | "packages": [ |
| 6 | 6 | "apps/*", |
| 7 | "packages/*" | |
| 7 | "packages/*", | |
| 8 | "scripts" | |
| 8 | 9 | ], |
| 9 | 10 | "catalog": { |
| 10 | 11 | "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", |
| 11 | 12 | "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", |
| 12 | 13 | "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", |
| 13 | 14 | "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", |
| 14 | "@effect/language-service": "0.75.1" | |
| 15 | "@effect/language-service": "0.75.1", | |
| 16 | "@types/bun": "^1.3.9", | |
| 17 | "@types/node": "^24.10.13", | |
| 18 | "tsdown": "^0.20.3", | |
| 19 | "typescript": "^5.7.3", | |
| 20 | "vitest": "^4.0.0" | |
| 15 | 21 | } |
| 16 | 22 | }, |
| 17 | 23 | "type": "module", |
| 18 | 24 | "scripts": { |
| 19 | "dev": "bun run scripts/dev-runner.mjs dev", | |
| 20 | "dev:server": "bun run scripts/dev-runner.mjs dev:server", | |
| 21 | "dev:web": "bun run scripts/dev-runner.mjs dev:web", | |
| 22 | "dev:desktop": "bun run scripts/dev-runner.mjs dev:desktop", | |
| 25 | "dev": "node scripts/dev-runner.ts dev", | |
| 26 | "dev:server": "node scripts/dev-runner.ts dev:server", | |
| 27 | "dev:web": "node scripts/dev-runner.ts dev:web", | |
| 28 | "dev:desktop": "node scripts/dev-runner.ts dev:desktop", | |
| 23 | 29 | "start": "turbo run start --filter=t3", |
| 24 | 30 | "start:desktop": "turbo run start --filter=@t3tools/desktop", |
| 25 | 31 | "build": "turbo run build", |
| ... | ... | @@ -40,14 +46,10 @@ |
| 40 | 46 | "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" |
| 41 | 47 | }, |
| 42 | 48 | "devDependencies": { |
| 43 | "@effect/platform-node": "catalog:", | |
| 44 | "@types/node": "^24.10.13", | |
| 45 | "effect": "catalog:", | |
| 49 | "@types/node": "catalog:", | |
| 46 | 50 | "oxfmt": "^0.35.0", |
| 47 | 51 | "oxlint": "^1.50.0", |
| 48 | "turbo": "^2.3.3", | |
| 49 | "typescript": "^5.7.3", | |
| 50 | "vitest": "^4.0.0" | |
| 52 | "turbo": "^2.3.3" | |
| 51 | 53 | }, |
| 52 | 54 | "engines": { |
| 53 | 55 | "bun": "^1.3.9", |
packages/contracts/package.json+3-3| ... | ... | @@ -27,9 +27,9 @@ |
| 27 | 27 | "effect": "catalog:" |
| 28 | 28 | }, |
| 29 | 29 | "devDependencies": { |
| 30 | "@effect/language-service": "0.75.1", | |
| 30 | "@effect/language-service": "catalog:", | |
| 31 | 31 | "@effect/vitest": "catalog:", |
| 32 | "tsdown": "^0.20.3", | |
| 33 | "typescript": "^5.7.3" | |
| 32 | "tsdown": "catalog:", | |
| 33 | "typescript": "catalog:" | |
| 34 | 34 | } |
| 35 | 35 | } |
packages/shared/package.json+9-4| ... | ... | @@ -7,20 +7,25 @@ |
| 7 | 7 | "./git": { |
| 8 | 8 | "types": "./src/git.ts", |
| 9 | 9 | "import": "./src/git.ts" |
| 10 | }, | |
| 11 | "./Net": { | |
| 12 | "types": "./src/Net.ts", | |
| 13 | "import": "./src/Net.ts" | |
| 10 | 14 | } |
| 11 | 15 | }, |
| 12 | 16 | "scripts": { |
| 13 | 17 | "prepare": "effect-language-service patch", |
| 14 | 18 | "typecheck": "tsc --noEmit", |
| 15 | "test": "vitest run --passWithNoTests" | |
| 19 | "test": "vitest run" | |
| 16 | 20 | }, |
| 17 | 21 | "dependencies": { |
| 18 | 22 | "effect": "catalog:" |
| 19 | 23 | }, |
| 20 | 24 | "devDependencies": { |
| 21 | "@effect/language-service": "0.75.1", | |
| 25 | "@effect/language-service": "catalog:", | |
| 22 | 26 | "@effect/vitest": "catalog:", |
| 23 | "typescript": "^5.7.3", | |
| 24 | "vitest": "^4.0.0" | |
| 27 | "@types/node": "catalog:", | |
| 28 | "typescript": "catalog:", | |
| 29 | "vitest": "catalog:" | |
| 25 | 30 | } |
| 26 | 31 | } |
packages/shared/src/Net.test.ts created+98| ... | ... | @@ -0,0 +1,98 @@ |
| 1 | import * as Net from "node:net"; | |
| 2 | ||
| 3 | import { assert, describe, it } from "@effect/vitest"; | |
| 4 | import { Effect } from "effect"; | |
| 5 | ||
| 6 | import { NetError, NetService } from "./Net"; | |
| 7 | ||
| 8 | const closeServer = (server: Net.Server) => | |
| 9 | Effect.sync(() => { | |
| 10 | try { | |
| 11 | server.close(); | |
| 12 | } catch { | |
| 13 | // Ignore cleanup failures in tests. | |
| 14 | } | |
| 15 | }); | |
| 16 | ||
| 17 | const getPort = (server: Net.Server): number => { | |
| 18 | const address = server.address(); | |
| 19 | return typeof address === "object" && address !== null ? address.port : 0; | |
| 20 | }; | |
| 21 | ||
| 22 | const openServer = (host?: string): Effect.Effect<Net.Server, NetError> => | |
| 23 | Effect.callback<Net.Server, NetError>((resume) => { | |
| 24 | const server = Net.createServer(); | |
| 25 | let settled = false; | |
| 26 | ||
| 27 | const settle = (effect: Effect.Effect<Net.Server, NetError>) => { | |
| 28 | if (settled) return; | |
| 29 | settled = true; | |
| 30 | resume(effect); | |
| 31 | }; | |
| 32 | ||
| 33 | server.once("error", (cause) => { | |
| 34 | settle(Effect.fail(new NetError({ message: "Failed to open test server", cause }))); | |
| 35 | }); | |
| 36 | ||
| 37 | if (host) { | |
| 38 | server.listen(0, host, () => settle(Effect.succeed(server))); | |
| 39 | } else { | |
| 40 | server.listen(0, () => settle(Effect.succeed(server))); | |
| 41 | } | |
| 42 | ||
| 43 | return closeServer(server); | |
| 44 | }); | |
| 45 | ||
| 46 | it.layer(NetService.layer)("NetService", (it) => { | |
| 47 | describe("Net helpers", () => { | |
| 48 | it.effect("reserveLoopbackPort returns a positive loopback port", () => | |
| 49 | Effect.gen(function* () { | |
| 50 | const net = yield* NetService; | |
| 51 | const port = yield* net.reserveLoopbackPort(); | |
| 52 | ||
| 53 | assert.ok(port > 0); | |
| 54 | }), | |
| 55 | ); | |
| 56 | ||
| 57 | it.effect("isPortAvailableOnLoopback reports false for an occupied port", () => | |
| 58 | Effect.acquireUseRelease( | |
| 59 | openServer("127.0.0.1"), | |
| 60 | (server) => | |
| 61 | Effect.gen(function* () { | |
| 62 | const net = yield* NetService; | |
| 63 | const port = getPort(server); | |
| 64 | ||
| 65 | const available = yield* net.isPortAvailableOnLoopback(port); | |
| 66 | assert.equal(available, false); | |
| 67 | }), | |
| 68 | closeServer, | |
| 69 | ), | |
| 70 | ); | |
| 71 | ||
| 72 | it.effect("findAvailablePort returns preferred when it is free", () => | |
| 73 | Effect.gen(function* () { | |
| 74 | const net = yield* NetService; | |
| 75 | const preferred = yield* net.reserveLoopbackPort(); | |
| 76 | ||
| 77 | const resolved = yield* net.findAvailablePort(preferred); | |
| 78 | assert.equal(resolved, preferred); | |
| 79 | }), | |
| 80 | ); | |
| 81 | ||
| 82 | it.effect("findAvailablePort falls back when preferred is occupied", () => | |
| 83 | Effect.acquireUseRelease( | |
| 84 | openServer(), | |
| 85 | (server) => | |
| 86 | Effect.gen(function* () { | |
| 87 | const net = yield* NetService; | |
| 88 | const preferred = getPort(server); | |
| 89 | ||
| 90 | const resolved = yield* net.findAvailablePort(preferred); | |
| 91 | assert.ok(resolved > 0); | |
| 92 | assert.notEqual(resolved, preferred); | |
| 93 | }), | |
| 94 | closeServer, | |
| 95 | ), | |
| 96 | ); | |
| 97 | }); | |
| 98 | }); |
packages/shared/src/Net.ts created+180| ... | ... | @@ -0,0 +1,180 @@ |
| 1 | import * as Net from "node:net"; | |
| 2 | ||
| 3 | import { Data, Effect, Layer, ServiceMap } from "effect"; | |
| 4 | ||
| 5 | export class NetError extends Data.TaggedError("NetError")<{ | |
| 6 | readonly message: string; | |
| 7 | readonly cause?: unknown; | |
| 8 | }> {} | |
| 9 | ||
| 10 | function isErrnoExceptionWithCode(cause: unknown): cause is { | |
| 11 | readonly code: string; | |
| 12 | } { | |
| 13 | return ( | |
| 14 | typeof cause === "object" && | |
| 15 | cause !== null && | |
| 16 | "code" in cause && | |
| 17 | typeof (cause as { readonly code: unknown }).code === "string" | |
| 18 | ); | |
| 19 | } | |
| 20 | ||
| 21 | const closeServer = (server: Net.Server) => { | |
| 22 | try { | |
| 23 | server.close(); | |
| 24 | } catch { | |
| 25 | // Ignore close failures during cleanup. | |
| 26 | } | |
| 27 | }; | |
| 28 | ||
| 29 | const tryReservePort = (port: number): Effect.Effect<number, NetError> => | |
| 30 | Effect.callback<number, NetError>((resume) => { | |
| 31 | const server = Net.createServer(); | |
| 32 | let settled = false; | |
| 33 | ||
| 34 | const settle = (effect: Effect.Effect<number, NetError>) => { | |
| 35 | if (settled) return; | |
| 36 | settled = true; | |
| 37 | resume(effect); | |
| 38 | }; | |
| 39 | ||
| 40 | server.unref(); | |
| 41 | ||
| 42 | server.once("error", (cause) => { | |
| 43 | settle(Effect.fail(new NetError({ message: "Could not find an available port.", cause }))); | |
| 44 | }); | |
| 45 | ||
| 46 | server.listen(port, () => { | |
| 47 | const address = server.address(); | |
| 48 | const resolved = typeof address === "object" && address !== null ? address.port : 0; | |
| 49 | server.close(() => { | |
| 50 | if (resolved > 0) { | |
| 51 | settle(Effect.succeed(resolved)); | |
| 52 | return; | |
| 53 | } | |
| 54 | settle(Effect.fail(new NetError({ message: "Could not find an available port." }))); | |
| 55 | }); | |
| 56 | }); | |
| 57 | ||
| 58 | return Effect.sync(() => { | |
| 59 | closeServer(server); | |
| 60 | }); | |
| 61 | }); | |
| 62 | ||
| 63 | export interface NetServiceShape { | |
| 64 | /** | |
| 65 | * Returns true when a TCP server can bind to {host, port}. | |
| 66 | */ | |
| 67 | readonly canListenOnHost: (port: number, host: string) => Effect.Effect<boolean>; | |
| 68 | ||
| 69 | /** | |
| 70 | * Checks loopback availability on both IPv4 and IPv6 localhost addresses. | |
| 71 | */ | |
| 72 | readonly isPortAvailableOnLoopback: (port: number) => Effect.Effect<boolean>; | |
| 73 | ||
| 74 | /** | |
| 75 | * Reserve an ephemeral loopback port and release it immediately. | |
| 76 | */ | |
| 77 | readonly reserveLoopbackPort: (host?: string) => Effect.Effect<number, NetError>; | |
| 78 | ||
| 79 | /** | |
| 80 | * Resolve an available listening port, preferring the provided port first. | |
| 81 | */ | |
| 82 | readonly findAvailablePort: (preferred: number) => Effect.Effect<number, NetError>; | |
| 83 | } | |
| 84 | ||
| 85 | /** | |
| 86 | * NetService - Service tag for startup networking helpers. | |
| 87 | */ | |
| 88 | export class NetService extends ServiceMap.Service<NetService, NetServiceShape>()( | |
| 89 | "@t3tools/shared/Net/NetService", | |
| 90 | ) { | |
| 91 | static readonly layer = Layer.sync(NetService, () => { | |
| 92 | /** | |
| 93 | * Returns true when a TCP server can bind to {host, port}. | |
| 94 | * `EADDRNOTAVAIL` is treated as available so IPv6-absent hosts don't fail | |
| 95 | * loopback availability checks. | |
| 96 | */ | |
| 97 | const canListenOnHost = (port: number, host: string): Effect.Effect<boolean> => | |
| 98 | Effect.callback<boolean>((resume) => { | |
| 99 | const server = Net.createServer(); | |
| 100 | let settled = false; | |
| 101 | ||
| 102 | const settle = (value: boolean) => { | |
| 103 | if (settled) return; | |
| 104 | settled = true; | |
| 105 | resume(Effect.succeed(value)); | |
| 106 | }; | |
| 107 | ||
| 108 | server.unref(); | |
| 109 | ||
| 110 | server.once("error", (cause) => { | |
| 111 | if (isErrnoExceptionWithCode(cause) && cause.code === "EADDRNOTAVAIL") { | |
| 112 | settle(true); | |
| 113 | return; | |
| 114 | } | |
| 115 | settle(false); | |
| 116 | }); | |
| 117 | ||
| 118 | server.once("listening", () => { | |
| 119 | server.close(() => { | |
| 120 | settle(true); | |
| 121 | }); | |
| 122 | }); | |
| 123 | ||
| 124 | server.listen({ host, port }); | |
| 125 | ||
| 126 | return Effect.sync(() => { | |
| 127 | closeServer(server); | |
| 128 | }); | |
| 129 | }); | |
| 130 | ||
| 131 | /** | |
| 132 | * Reserve an ephemeral loopback port and release it immediately. | |
| 133 | * Returns the reserved port number. | |
| 134 | */ | |
| 135 | const reserveLoopbackPort = (host = "127.0.0.1"): Effect.Effect<number, NetError> => | |
| 136 | Effect.callback<number, NetError>((resume) => { | |
| 137 | const probe = Net.createServer(); | |
| 138 | let settled = false; | |
| 139 | ||
| 140 | const settle = (effect: Effect.Effect<number, NetError>) => { | |
| 141 | if (settled) return; | |
| 142 | settled = true; | |
| 143 | resume(effect); | |
| 144 | }; | |
| 145 | ||
| 146 | probe.once("error", (cause) => { | |
| 147 | settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port", cause }))); | |
| 148 | }); | |
| 149 | ||
| 150 | probe.listen(0, host, () => { | |
| 151 | const address = probe.address(); | |
| 152 | const port = typeof address === "object" && address !== null ? address.port : 0; | |
| 153 | probe.close(() => { | |
| 154 | if (port > 0) { | |
| 155 | settle(Effect.succeed(port)); | |
| 156 | return; | |
| 157 | } | |
| 158 | settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port" }))); | |
| 159 | }); | |
| 160 | }); | |
| 161 | ||
| 162 | return Effect.sync(() => { | |
| 163 | closeServer(probe); | |
| 164 | }); | |
| 165 | }); | |
| 166 | ||
| 167 | return { | |
| 168 | canListenOnHost, | |
| 169 | isPortAvailableOnLoopback: (port) => | |
| 170 | Effect.zipWith( | |
| 171 | canListenOnHost(port, "127.0.0.1"), | |
| 172 | canListenOnHost(port, "::1"), | |
| 173 | (ipv4, ipv6) => ipv4 && ipv6, | |
| 174 | ), | |
| 175 | reserveLoopbackPort, | |
| 176 | findAvailablePort: (preferred) => | |
| 177 | Effect.catch(tryReservePort(preferred), () => tryReservePort(0)), | |
| 178 | } satisfies NetServiceShape; | |
| 179 | }); | |
| 180 | } |
scripts/build-desktop-artifact.ts+10-10| ... | ... | @@ -10,7 +10,7 @@ import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; |
| 10 | 10 | |
| 11 | 11 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 12 | 12 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 13 | import { Config, Data, Effect, FileSystem, Logger, Option, Path, Schema } from "effect"; | |
| 13 | import { Config, Data, Effect, FileSystem, Layer, Logger, Option, Path, Schema } from "effect"; | |
| 14 | 14 | import { Command, Flag } from "effect/unstable/cli"; |
| 15 | 15 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 16 | 16 | |
| ... | ... | @@ -23,6 +23,7 @@ const RepoRoot = Effect.service(Path.Path).pipe( |
| 23 | 23 | const IconSource = Effect.zipWith(RepoRoot, Effect.service(Path.Path), (repoRoot, path) => |
| 24 | 24 | path.join(repoRoot, "assets/macos-icon-1024.png"), |
| 25 | 25 | ); |
| 26 | const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); | |
| 26 | 27 | |
| 27 | 28 | interface PlatformConfig { |
| 28 | 29 | readonly cliFlag: "--mac" | "--linux" | "--win"; |
| ... | ... | @@ -290,9 +291,9 @@ function validateBundledClientAssets(clientDir: string) { |
| 290 | 291 | const path = yield* Path.Path; |
| 291 | 292 | const indexPath = path.join(clientDir, "index.html"); |
| 292 | 293 | const indexHtml = yield* fs.readFileString(indexPath); |
| 293 | const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map( | |
| 294 | (match) => match[1], | |
| 295 | ); | |
| 294 | const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)] | |
| 295 | .map((match) => match[1]) | |
| 296 | .filter((value): value is string => value !== undefined); | |
| 296 | 297 | const missing: string[] = []; |
| 297 | 298 | |
| 298 | 299 | for (const ref of refs) { |
| ... | ... | @@ -505,10 +506,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 505 | 506 | }, |
| 506 | 507 | }; |
| 507 | 508 | |
| 508 | yield* fs.writeFileString( | |
| 509 | path.join(stageAppDir, "package.json"), | |
| 510 | `${JSON.stringify(stagePackageJson, null, 2)}\n`, | |
| 511 | ); | |
| 509 | const stagePackageJsonString = yield* encodeJsonString(stagePackageJson); | |
| 510 | yield* fs.writeFileString(path.join(stageAppDir, "package.json"), `${stagePackageJsonString}\n`); | |
| 512 | 511 | |
| 513 | 512 | yield* Effect.log("[desktop-artifact] Installing staged production dependencies..."); |
| 514 | 513 | yield* runCommand( |
| ... | ... | @@ -626,9 +625,10 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { |
| 626 | 625 | Command.withHandler((input) => Effect.flatMap(resolveBuildOptions(input), buildDesktopArtifact)), |
| 627 | 626 | ); |
| 628 | 627 | |
| 628 | const cliRuntimeLayer = Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer); | |
| 629 | ||
| 629 | 630 | Command.run(buildDesktopArtifactCli, { version: "0.0.0" }).pipe( |
| 630 | 631 | Effect.scoped, |
| 631 | Effect.provide(Logger.layer([Logger.consolePretty()])), | |
| 632 | Effect.provide(NodeServices.layer), | |
| 632 | Effect.provide(cliRuntimeLayer), | |
| 633 | 633 | NodeRuntime.runMain, |
| 634 | 634 | ); |
scripts/dev-runner.mjs deleted-425| ... | ... | @@ -1,425 +0,0 @@ |
| 1 | import { spawn } from "node:child_process"; | |
| 2 | import net from "node:net"; | |
| 3 | import { homedir } from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | import { pathToFileURL } from "node:url"; | |
| 6 | ||
| 7 | const BASE_SERVER_PORT = 3773; | |
| 8 | const BASE_WEB_PORT = 5733; | |
| 9 | const MAX_HASH_OFFSET = 3000; | |
| 10 | const MAX_PORT = 65535; | |
| 11 | export const DEFAULT_DEV_STATE_DIR = path.join(homedir(), ".t3", "dev"); | |
| 12 | const MODE_ARGS = { | |
| 13 | dev: [ | |
| 14 | "run", | |
| 15 | "dev", | |
| 16 | "--ui=tui", | |
| 17 | "--filter=@t3tools/contracts", | |
| 18 | "--filter=@t3tools/web", | |
| 19 | "--filter=t3", | |
| 20 | "--parallel", | |
| 21 | ], | |
| 22 | "dev:server": ["run", "dev", "--filter=t3"], | |
| 23 | "dev:web": ["run", "dev", "--filter=@t3tools/web"], | |
| 24 | "dev:desktop": ["run", "dev", "--filter=@t3tools/desktop", "--filter=@t3tools/web", "--parallel"], | |
| 25 | }; | |
| 26 | const FORWARDED_ENV_FLAGS = { | |
| 27 | "state-dir": { envName: "T3CODE_STATE_DIR", expectsValue: true }, | |
| 28 | "auth-token": { envName: "T3CODE_AUTH_TOKEN", expectsValue: true }, | |
| 29 | token: { envName: "T3CODE_AUTH_TOKEN", expectsValue: true }, | |
| 30 | "no-browser": { envName: "T3CODE_NO_BROWSER", expectsValue: false }, | |
| 31 | "auto-bootstrap-project-from-cwd": { | |
| 32 | envName: "T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD", | |
| 33 | expectsValue: false, | |
| 34 | }, | |
| 35 | "log-websocket-events": { envName: "T3CODE_LOG_WS_EVENTS", expectsValue: false }, | |
| 36 | "log-ws-events": { envName: "T3CODE_LOG_WS_EVENTS", expectsValue: false }, | |
| 37 | host: { envName: "T3CODE_HOST", expectsValue: true }, | |
| 38 | port: { envName: "T3CODE_PORT", expectsValue: true }, | |
| 39 | "dev-url": { envName: "VITE_DEV_SERVER_URL", expectsValue: true }, | |
| 40 | }; | |
| 41 | ||
| 42 | function parseInteger(value, envName) { | |
| 43 | const parsed = Number(value); | |
| 44 | if (!Number.isInteger(parsed)) { | |
| 45 | throw new Error(`Invalid ${envName}: ${value}`); | |
| 46 | } | |
| 47 | return parsed; | |
| 48 | } | |
| 49 | ||
| 50 | function hashSeed(seed) { | |
| 51 | let hash = 0x811c9dc5; | |
| 52 | for (let i = 0; i < seed.length; i += 1) { | |
| 53 | hash ^= seed.charCodeAt(i); | |
| 54 | hash = Math.imul(hash, 0x01000193); | |
| 55 | } | |
| 56 | return hash >>> 0; | |
| 57 | } | |
| 58 | ||
| 59 | export function resolveOffset(env = process.env) { | |
| 60 | const explicitOffset = env.T3CODE_PORT_OFFSET?.trim(); | |
| 61 | if (explicitOffset) { | |
| 62 | const parsed = parseInteger(explicitOffset, "T3CODE_PORT_OFFSET"); | |
| 63 | if (parsed < 0) { | |
| 64 | throw new Error(`Invalid T3CODE_PORT_OFFSET: ${explicitOffset}`); | |
| 65 | } | |
| 66 | return { offset: parsed, source: `T3CODE_PORT_OFFSET=${explicitOffset}` }; | |
| 67 | } | |
| 68 | ||
| 69 | const seed = env.T3CODE_DEV_INSTANCE?.trim(); | |
| 70 | if (!seed) { | |
| 71 | return { offset: 0, source: "default ports" }; | |
| 72 | } | |
| 73 | ||
| 74 | if (/^\d+$/.test(seed)) { | |
| 75 | return { offset: Number(seed), source: `numeric T3CODE_DEV_INSTANCE=${seed}` }; | |
| 76 | } | |
| 77 | ||
| 78 | const offset = (hashSeed(seed) % MAX_HASH_OFFSET) + 1; | |
| 79 | return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }; | |
| 80 | } | |
| 81 | ||
| 82 | function parseLongFlag(raw) { | |
| 83 | if (!raw.startsWith("--")) return null; | |
| 84 | const normalized = raw.slice(2); | |
| 85 | const equalsIndex = normalized.indexOf("="); | |
| 86 | if (equalsIndex === -1) { | |
| 87 | return { name: normalized, value: undefined }; | |
| 88 | } | |
| 89 | return { | |
| 90 | name: normalized.slice(0, equalsIndex), | |
| 91 | value: normalized.slice(equalsIndex + 1), | |
| 92 | }; | |
| 93 | } | |
| 94 | ||
| 95 | function toBooleanEnvValue(value) { | |
| 96 | if (value === undefined) return "1"; | |
| 97 | const normalized = value.trim().toLowerCase(); | |
| 98 | if (normalized === "false" || normalized === "0" || normalized === "no") return "0"; | |
| 99 | return "1"; | |
| 100 | } | |
| 101 | ||
| 102 | export function parseDevRunnerArgs(rawArgs) { | |
| 103 | const args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs.slice(); | |
| 104 | const envOverrides = {}; | |
| 105 | const turboArgs = []; | |
| 106 | let isDryRun = false; | |
| 107 | ||
| 108 | for (let index = 0; index < args.length; index += 1) { | |
| 109 | const arg = args[index]; | |
| 110 | if (arg === "--dry-run") { | |
| 111 | isDryRun = true; | |
| 112 | continue; | |
| 113 | } | |
| 114 | ||
| 115 | if (arg === "--") { | |
| 116 | turboArgs.push(...args.slice(index + 1)); | |
| 117 | break; | |
| 118 | } | |
| 119 | ||
| 120 | const parsedFlag = parseLongFlag(arg); | |
| 121 | if (!parsedFlag) { | |
| 122 | turboArgs.push(arg); | |
| 123 | continue; | |
| 124 | } | |
| 125 | ||
| 126 | const definition = FORWARDED_ENV_FLAGS[parsedFlag.name]; | |
| 127 | if (!definition) { | |
| 128 | turboArgs.push(arg); | |
| 129 | continue; | |
| 130 | } | |
| 131 | ||
| 132 | if (definition.expectsValue) { | |
| 133 | let value = parsedFlag.value; | |
| 134 | if (value === undefined) { | |
| 135 | const next = args[index + 1]; | |
| 136 | if (next === undefined || next === "--" || next.startsWith("-")) { | |
| 137 | throw new Error(`Missing value for --${parsedFlag.name}`); | |
| 138 | } | |
| 139 | value = next; | |
| 140 | index += 1; | |
| 141 | } | |
| 142 | envOverrides[definition.envName] = value; | |
| 143 | continue; | |
| 144 | } | |
| 145 | ||
| 146 | envOverrides[definition.envName] = toBooleanEnvValue(parsedFlag.value); | |
| 147 | } | |
| 148 | ||
| 149 | return { | |
| 150 | isDryRun, | |
| 151 | envOverrides, | |
| 152 | turboArgs, | |
| 153 | }; | |
| 154 | } | |
| 155 | ||
| 156 | function resolveStateDir(env, envOverrides) { | |
| 157 | const overrideValue = envOverrides?.T3CODE_STATE_DIR?.trim(); | |
| 158 | if (overrideValue) { | |
| 159 | // Resolve relative paths against cwd (monorepo root) before turbo changes directories | |
| 160 | return path.resolve(overrideValue); | |
| 161 | } | |
| 162 | ||
| 163 | const envValue = env?.T3CODE_STATE_DIR?.trim(); | |
| 164 | if (envValue) { | |
| 165 | return path.resolve(envValue); | |
| 166 | } | |
| 167 | ||
| 168 | return DEFAULT_DEV_STATE_DIR; | |
| 169 | } | |
| 170 | ||
| 171 | export function createDevRunnerEnv({ mode, env, offset, envOverrides }) { | |
| 172 | const serverPort = BASE_SERVER_PORT + offset; | |
| 173 | const webPort = BASE_WEB_PORT + offset; | |
| 174 | ||
| 175 | const output = { | |
| 176 | ...env, | |
| 177 | T3CODE_PORT: String(serverPort), | |
| 178 | PORT: String(webPort), | |
| 179 | ELECTRON_RENDERER_PORT: String(webPort), | |
| 180 | VITE_WS_URL: `ws://localhost:${serverPort}`, | |
| 181 | VITE_DEV_SERVER_URL: `http://localhost:${webPort}`, | |
| 182 | ...envOverrides, | |
| 183 | }; | |
| 184 | ||
| 185 | output.T3CODE_STATE_DIR = resolveStateDir(env, envOverrides); | |
| 186 | ||
| 187 | const parsedServerPort = Number(output.T3CODE_PORT); | |
| 188 | if (!Number.isInteger(parsedServerPort) || parsedServerPort < 1 || parsedServerPort > 65535) { | |
| 189 | throw new Error( | |
| 190 | `Invalid T3CODE_PORT override: '${String(output.T3CODE_PORT)}'. Expected an integer between 1 and 65535.`, | |
| 191 | ); | |
| 192 | } | |
| 193 | output.VITE_WS_URL = `ws://localhost:${parsedServerPort}`; | |
| 194 | ||
| 195 | if (mode === "dev" || mode === "dev:server" || mode === "dev:web") { | |
| 196 | // Running server/web in browser mode should not inherit desktop launcher state. | |
| 197 | output.T3CODE_MODE = "web"; | |
| 198 | if (!("T3CODE_NO_BROWSER" in envOverrides)) { | |
| 199 | delete output.T3CODE_NO_BROWSER; | |
| 200 | } | |
| 201 | if (!("T3CODE_AUTH_TOKEN" in envOverrides)) { | |
| 202 | delete output.T3CODE_AUTH_TOKEN; | |
| 203 | } | |
| 204 | delete output.T3CODE_DESKTOP_WS_URL; | |
| 205 | } | |
| 206 | ||
| 207 | if (mode === "dev" && !output.T3CODE_LOG_WS_EVENTS) { | |
| 208 | output.T3CODE_LOG_WS_EVENTS = "1"; | |
| 209 | } | |
| 210 | ||
| 211 | return output; | |
| 212 | } | |
| 213 | ||
| 214 | function portPairForOffset(offset) { | |
| 215 | return { | |
| 216 | serverPort: BASE_SERVER_PORT + offset, | |
| 217 | webPort: BASE_WEB_PORT + offset, | |
| 218 | }; | |
| 219 | } | |
| 220 | ||
| 221 | async function canListenOnHost(port, host) { | |
| 222 | return await new Promise((resolve) => { | |
| 223 | const server = net.createServer(); | |
| 224 | server.unref(); | |
| 225 | server.once("error", (error) => { | |
| 226 | const code = error?.code; | |
| 227 | if (code === "EADDRNOTAVAIL") { | |
| 228 | // Host family unavailable on this machine (for example, no IPv6 loopback). | |
| 229 | resolve(true); | |
| 230 | return; | |
| 231 | } | |
| 232 | resolve(false); | |
| 233 | }); | |
| 234 | server.once("listening", () => { | |
| 235 | server.close(() => { | |
| 236 | resolve(true); | |
| 237 | }); | |
| 238 | }); | |
| 239 | server.listen({ host, port }); | |
| 240 | }); | |
| 241 | } | |
| 242 | ||
| 243 | async function isPortAvailable(port) { | |
| 244 | // Vite/dev traffic uses localhost loopback. Verify both IPv4 and IPv6 loopback | |
| 245 | // so we don't pick a port that fails later on one address family. | |
| 246 | const [ipv4, ipv6] = await Promise.all([ | |
| 247 | canListenOnHost(port, "127.0.0.1"), | |
| 248 | canListenOnHost(port, "::1"), | |
| 249 | ]); | |
| 250 | return ipv4 && ipv6; | |
| 251 | } | |
| 252 | ||
| 253 | const defaultCheckPortAvailability = isPortAvailable; | |
| 254 | ||
| 255 | export async function findFirstAvailableOffset({ | |
| 256 | startOffset, | |
| 257 | requireServerPort, | |
| 258 | requireWebPort, | |
| 259 | checkPortAvailability = defaultCheckPortAvailability, | |
| 260 | }) { | |
| 261 | for (let candidate = startOffset; ; candidate += 1) { | |
| 262 | const { serverPort, webPort } = portPairForOffset(candidate); | |
| 263 | const serverPortOutOfRange = serverPort > MAX_PORT; | |
| 264 | const webPortOutOfRange = webPort > MAX_PORT; | |
| 265 | if ( | |
| 266 | (requireServerPort && serverPortOutOfRange) || | |
| 267 | (requireWebPort && webPortOutOfRange) || | |
| 268 | (!requireServerPort && !requireWebPort && (serverPortOutOfRange || webPortOutOfRange)) | |
| 269 | ) { | |
| 270 | break; | |
| 271 | } | |
| 272 | ||
| 273 | const checks = []; | |
| 274 | if (requireServerPort) { | |
| 275 | checks.push(checkPortAvailability(serverPort)); | |
| 276 | } | |
| 277 | if (requireWebPort) { | |
| 278 | checks.push(checkPortAvailability(webPort)); | |
| 279 | } | |
| 280 | ||
| 281 | if (checks.length === 0) { | |
| 282 | return candidate; | |
| 283 | } | |
| 284 | ||
| 285 | const availability = await Promise.all(checks); | |
| 286 | if (availability.every(Boolean)) { | |
| 287 | return candidate; | |
| 288 | } | |
| 289 | } | |
| 290 | ||
| 291 | throw new Error( | |
| 292 | `No available dev ports found from offset ${startOffset}. Tried server=${BASE_SERVER_PORT}+n web=${BASE_WEB_PORT}+n up to port ${MAX_PORT}.`, | |
| 293 | ); | |
| 294 | } | |
| 295 | ||
| 296 | export async function resolveModePortOffsets({ | |
| 297 | mode, | |
| 298 | startOffset, | |
| 299 | envOverrides, | |
| 300 | checkPortAvailability, | |
| 301 | }) { | |
| 302 | const hasExplicitServerPort = typeof envOverrides.T3CODE_PORT === "string"; | |
| 303 | const hasExplicitDevUrl = typeof envOverrides.VITE_DEV_SERVER_URL === "string"; | |
| 304 | ||
| 305 | if (mode === "dev:web") { | |
| 306 | if (hasExplicitDevUrl) { | |
| 307 | return { serverOffset: startOffset, webOffset: startOffset }; | |
| 308 | } | |
| 309 | const webOffset = await findFirstAvailableOffset({ | |
| 310 | startOffset, | |
| 311 | requireServerPort: false, | |
| 312 | requireWebPort: true, | |
| 313 | checkPortAvailability, | |
| 314 | }); | |
| 315 | return { serverOffset: startOffset, webOffset }; | |
| 316 | } | |
| 317 | ||
| 318 | if (mode === "dev:server") { | |
| 319 | if (hasExplicitServerPort) { | |
| 320 | return { serverOffset: startOffset, webOffset: startOffset }; | |
| 321 | } | |
| 322 | const serverOffset = await findFirstAvailableOffset({ | |
| 323 | startOffset, | |
| 324 | requireServerPort: true, | |
| 325 | requireWebPort: false, | |
| 326 | checkPortAvailability, | |
| 327 | }); | |
| 328 | return { serverOffset, webOffset: serverOffset }; | |
| 329 | } | |
| 330 | ||
| 331 | const sharedOffset = await findFirstAvailableOffset({ | |
| 332 | startOffset, | |
| 333 | requireServerPort: !hasExplicitServerPort, | |
| 334 | requireWebPort: !hasExplicitDevUrl, | |
| 335 | checkPortAvailability, | |
| 336 | }); | |
| 337 | return { serverOffset: sharedOffset, webOffset: sharedOffset }; | |
| 338 | } | |
| 339 | ||
| 340 | export async function runDevRunner(argv = process.argv) { | |
| 341 | const mode = argv[2]; | |
| 342 | const parsedArgs = parseDevRunnerArgs(argv.slice(3)); | |
| 343 | if (!mode || !(mode in MODE_ARGS)) { | |
| 344 | const supportedModes = Object.keys(MODE_ARGS).join(", "); | |
| 345 | throw new Error(`Usage: bun scripts/dev-runner.mjs <mode>. Supported modes: ${supportedModes}`); | |
| 346 | } | |
| 347 | ||
| 348 | const { offset, source } = resolveOffset(); | |
| 349 | const { serverOffset, webOffset } = await resolveModePortOffsets({ | |
| 350 | mode, | |
| 351 | startOffset: offset, | |
| 352 | envOverrides: parsedArgs.envOverrides, | |
| 353 | }); | |
| 354 | ||
| 355 | const mergedEnvOverrides = { ...parsedArgs.envOverrides }; | |
| 356 | if (!("VITE_DEV_SERVER_URL" in mergedEnvOverrides)) { | |
| 357 | const { webPort } = portPairForOffset(webOffset); | |
| 358 | mergedEnvOverrides.PORT = String(webPort); | |
| 359 | mergedEnvOverrides.ELECTRON_RENDERER_PORT = String(webPort); | |
| 360 | mergedEnvOverrides.VITE_DEV_SERVER_URL = `http://localhost:${webPort}`; | |
| 361 | } | |
| 362 | ||
| 363 | const env = createDevRunnerEnv({ | |
| 364 | mode, | |
| 365 | env: process.env, | |
| 366 | offset: serverOffset, | |
| 367 | envOverrides: mergedEnvOverrides, | |
| 368 | }); | |
| 369 | ||
| 370 | const parsedServerPort = Number(env.T3CODE_PORT); | |
| 371 | const parsedWebPort = Number(env.PORT); | |
| 372 | if ( | |
| 373 | !Number.isInteger(parsedServerPort) || | |
| 374 | parsedServerPort < 1 || | |
| 375 | parsedServerPort > MAX_PORT || | |
| 376 | !Number.isInteger(parsedWebPort) || | |
| 377 | parsedWebPort < 1 || | |
| 378 | parsedWebPort > MAX_PORT | |
| 379 | ) { | |
| 380 | throw new Error( | |
| 381 | `Invalid computed dev ports: server='${String(env.T3CODE_PORT)}' web='${String(env.PORT)}'.`, | |
| 382 | ); | |
| 383 | } | |
| 384 | ||
| 385 | const selectionSuffix = | |
| 386 | serverOffset !== offset || webOffset !== offset | |
| 387 | ? ` selectedOffset(server=${serverOffset},web=${webOffset})` | |
| 388 | : ""; | |
| 389 | ||
| 390 | console.info( | |
| 391 | `[dev-runner] mode=${mode} source=${source}${selectionSuffix} serverPort=${parsedServerPort} webPort=${parsedWebPort} stateDir=${env.T3CODE_STATE_DIR}`, | |
| 392 | ); | |
| 393 | ||
| 394 | if (parsedArgs.isDryRun) { | |
| 395 | return; | |
| 396 | } | |
| 397 | ||
| 398 | const command = process.platform === "win32" ? "turbo.cmd" : "turbo"; | |
| 399 | const child = spawn(command, [...MODE_ARGS[mode], ...parsedArgs.turboArgs], { | |
| 400 | stdio: "inherit", | |
| 401 | env, | |
| 402 | }); | |
| 403 | ||
| 404 | child.on("exit", (code, signal) => { | |
| 405 | if (signal) { | |
| 406 | process.kill(process.pid, signal); | |
| 407 | return; | |
| 408 | } | |
| 409 | process.exit(code ?? 0); | |
| 410 | }); | |
| 411 | ||
| 412 | child.on("error", (error) => { | |
| 413 | console.error("[dev-runner] failed to start turbo", error); | |
| 414 | process.exit(1); | |
| 415 | }); | |
| 416 | } | |
| 417 | ||
| 418 | const entrypointPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : ""; | |
| 419 | ||
| 420 | if (import.meta.url === entrypointPath) { | |
| 421 | runDevRunner().catch((error) => { | |
| 422 | console.error("[dev-runner]", error); | |
| 423 | process.exit(1); | |
| 424 | }); | |
| 425 | } |
scripts/dev-runner.test.ts+210-208| ... | ... | @@ -1,223 +1,225 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { Effect } from "effect"; | |
| 2 | 4 | |
| 3 | 5 | import { |
| 4 | 6 | DEFAULT_DEV_STATE_DIR, |
| 5 | 7 | createDevRunnerEnv, |
| 6 | 8 | findFirstAvailableOffset, |
| 7 | parseDevRunnerArgs, | |
| 8 | 9 | resolveModePortOffsets, |
| 9 | } from "./dev-runner.mjs"; | |
| 10 | ||
| 11 | describe("parseDevRunnerArgs", () => { | |
| 12 | it("maps supported server flags to env overrides and keeps other flags for turbo", () => { | |
| 13 | const parsed = parseDevRunnerArgs([ | |
| 14 | "--", | |
| 15 | "--state-dir", | |
| 16 | "~/alt-state", | |
| 17 | "--token=abc123", | |
| 18 | "--no-browser=false", | |
| 19 | "--dry-run", | |
| 20 | "--continue", | |
| 21 | ]); | |
| 22 | ||
| 23 | expect(parsed.isDryRun).toBe(true); | |
| 24 | expect(parsed.envOverrides).toEqual({ | |
| 25 | T3CODE_STATE_DIR: "~/alt-state", | |
| 26 | T3CODE_AUTH_TOKEN: "abc123", | |
| 27 | T3CODE_NO_BROWSER: "0", | |
| 28 | }); | |
| 29 | expect(parsed.turboArgs).toEqual(["--continue"]); | |
| 30 | }); | |
| 31 | ||
| 32 | it("throws when a value flag is missing its value", () => { | |
| 33 | expect(() => parseDevRunnerArgs(["--state-dir"])).toThrow("Missing value for --state-dir"); | |
| 34 | }); | |
| 35 | ||
| 36 | it("throws when a value flag is followed by another flag token", () => { | |
| 37 | expect(() => parseDevRunnerArgs(["--state-dir", "--continue"])).toThrow( | |
| 38 | "Missing value for --state-dir", | |
| 39 | ); | |
| 40 | }); | |
| 41 | }); | |
| 10 | } from "./dev-runner.ts"; | |
| 11 | ||
| 12 | it.layer(NodeServices.layer)("dev-runner", (it) => { | |
| 13 | it.effect("defaults state dir to ~/.t3/dev when not provided", () => | |
| 14 | Effect.gen(function* () { | |
| 15 | const env = yield* createDevRunnerEnv({ | |
| 16 | mode: "dev", | |
| 17 | env: {}, | |
| 18 | offset: 0, | |
| 19 | envOverrides: {}, | |
| 20 | }); | |
| 21 | const defaultStateDir = yield* DEFAULT_DEV_STATE_DIR; | |
| 22 | ||
| 23 | assert.equal(env.T3CODE_STATE_DIR, defaultStateDir); | |
| 24 | }), | |
| 25 | ); | |
| 26 | ||
| 27 | it.effect("uses existing env state dir when --state-dir is not provided", () => | |
| 28 | Effect.gen(function* () { | |
| 29 | const env = yield* createDevRunnerEnv({ | |
| 30 | mode: "dev:server", | |
| 31 | env: { T3CODE_STATE_DIR: "/tmp/existing-state" }, | |
| 32 | offset: 0, | |
| 33 | envOverrides: {}, | |
| 34 | }); | |
| 35 | ||
| 36 | assert.equal(env.T3CODE_STATE_DIR, "/tmp/existing-state"); | |
| 37 | }), | |
| 38 | ); | |
| 39 | ||
| 40 | it.effect("lets --state-dir override existing env state dir", () => | |
| 41 | Effect.gen(function* () { | |
| 42 | const env = yield* createDevRunnerEnv({ | |
| 43 | mode: "dev:server", | |
| 44 | env: { T3CODE_STATE_DIR: "/tmp/existing-state" }, | |
| 45 | offset: 0, | |
| 46 | envOverrides: { T3CODE_STATE_DIR: "/tmp/override-state" }, | |
| 47 | }); | |
| 48 | ||
| 49 | assert.equal(env.T3CODE_STATE_DIR, "/tmp/override-state"); | |
| 50 | }), | |
| 51 | ); | |
| 52 | ||
| 53 | it.effect("treats whitespace-only --state-dir as missing and falls back to env/default", () => | |
| 54 | Effect.gen(function* () { | |
| 55 | const env = yield* createDevRunnerEnv({ | |
| 56 | mode: "dev:server", | |
| 57 | env: { T3CODE_STATE_DIR: "/tmp/existing-state" }, | |
| 58 | offset: 0, | |
| 59 | envOverrides: { T3CODE_STATE_DIR: " " }, | |
| 60 | }); | |
| 61 | ||
| 62 | assert.equal(env.T3CODE_STATE_DIR, "/tmp/existing-state"); | |
| 63 | }), | |
| 64 | ); | |
| 42 | 65 | |
| 43 | describe("createDevRunnerEnv", () => { | |
| 44 | it("defaults state dir to ~/.t3/dev when not provided", () => { | |
| 45 | const env = createDevRunnerEnv({ | |
| 46 | mode: "dev", | |
| 47 | env: {}, | |
| 48 | offset: 0, | |
| 49 | envOverrides: {}, | |
| 50 | }); | |
| 51 | ||
| 52 | expect(env.T3CODE_STATE_DIR).toBe(DEFAULT_DEV_STATE_DIR); | |
| 53 | }); | |
| 54 | ||
| 55 | it("uses existing env state dir when --state-dir is not provided", () => { | |
| 56 | const env = createDevRunnerEnv({ | |
| 57 | mode: "dev:server", | |
| 58 | env: { T3CODE_STATE_DIR: "/tmp/existing-state" }, | |
| 59 | offset: 0, | |
| 60 | envOverrides: {}, | |
| 61 | }); | |
| 62 | ||
| 63 | expect(env.T3CODE_STATE_DIR).toBe("/tmp/existing-state"); | |
| 64 | }); | |
| 65 | ||
| 66 | it("lets --state-dir override existing env state dir", () => { | |
| 67 | const env = createDevRunnerEnv({ | |
| 68 | mode: "dev:server", | |
| 69 | env: { T3CODE_STATE_DIR: "/tmp/existing-state" }, | |
| 70 | offset: 0, | |
| 71 | envOverrides: { T3CODE_STATE_DIR: "/tmp/override-state" }, | |
| 72 | }); | |
| 73 | ||
| 74 | expect(env.T3CODE_STATE_DIR).toBe("/tmp/override-state"); | |
| 75 | }); | |
| 76 | ||
| 77 | it("treats whitespace-only --state-dir as missing and falls back to env/default", () => { | |
| 78 | const env = createDevRunnerEnv({ | |
| 79 | mode: "dev:server", | |
| 80 | env: { T3CODE_STATE_DIR: "/tmp/existing-state" }, | |
| 81 | offset: 0, | |
| 82 | envOverrides: { T3CODE_STATE_DIR: " " }, | |
| 83 | }); | |
| 84 | ||
| 85 | expect(env.T3CODE_STATE_DIR).toBe("/tmp/existing-state"); | |
| 86 | }); | |
| 87 | ||
| 88 | it("recomputes websocket url when port is overridden", () => { | |
| 89 | const env = createDevRunnerEnv({ | |
| 90 | mode: "dev", | |
| 91 | env: {}, | |
| 92 | offset: 0, | |
| 93 | envOverrides: { T3CODE_PORT: "4222" }, | |
| 94 | }); | |
| 95 | ||
| 96 | expect(env.T3CODE_PORT).toBe("4222"); | |
| 97 | expect(env.VITE_WS_URL).toBe("ws://localhost:4222"); | |
| 98 | }); | |
| 99 | ||
| 100 | it("keeps explicitly forwarded web-mode flags", () => { | |
| 101 | const env = createDevRunnerEnv({ | |
| 102 | mode: "dev", | |
| 103 | env: { | |
| 104 | T3CODE_AUTH_TOKEN: "desktop-token", | |
| 105 | }, | |
| 106 | offset: 0, | |
| 107 | envOverrides: { | |
| 108 | T3CODE_NO_BROWSER: "1", | |
| 109 | T3CODE_AUTH_TOKEN: "cli-token", | |
| 110 | }, | |
| 111 | }); | |
| 112 | ||
| 113 | expect(env.T3CODE_NO_BROWSER).toBe("1"); | |
| 114 | expect(env.T3CODE_AUTH_TOKEN).toBe("cli-token"); | |
| 115 | }); | |
| 116 | ||
| 117 | it("fails fast for invalid port overrides", () => { | |
| 118 | expect(() => | |
| 119 | createDevRunnerEnv({ | |
| 66 | it.effect("recomputes websocket url when port is overridden", () => | |
| 67 | Effect.gen(function* () { | |
| 68 | const env = yield* createDevRunnerEnv({ | |
| 120 | 69 | mode: "dev", |
| 121 | 70 | env: {}, |
| 122 | 71 | offset: 0, |
| 123 | envOverrides: { T3CODE_PORT: "not-a-port" }, | |
| 124 | }), | |
| 125 | ).toThrow("Invalid T3CODE_PORT override"); | |
| 126 | }); | |
| 72 | envOverrides: { T3CODE_PORT: "4222" }, | |
| 73 | }); | |
| 74 | ||
| 75 | assert.equal(env.T3CODE_PORT, "4222"); | |
| 76 | assert.equal(env.VITE_WS_URL, "ws://localhost:4222"); | |
| 77 | }), | |
| 78 | ); | |
| 79 | ||
| 80 | it.effect("keeps explicitly forwarded web-mode flags", () => | |
| 81 | Effect.gen(function* () { | |
| 82 | const env = yield* createDevRunnerEnv({ | |
| 83 | mode: "dev", | |
| 84 | env: { | |
| 85 | T3CODE_AUTH_TOKEN: "desktop-token", | |
| 86 | }, | |
| 87 | offset: 0, | |
| 88 | envOverrides: { | |
| 89 | T3CODE_NO_BROWSER: "1", | |
| 90 | T3CODE_AUTH_TOKEN: "cli-token", | |
| 91 | }, | |
| 92 | }); | |
| 93 | ||
| 94 | assert.equal(env.T3CODE_NO_BROWSER, "1"); | |
| 95 | assert.equal(env.T3CODE_AUTH_TOKEN, "cli-token"); | |
| 96 | }), | |
| 97 | ); | |
| 98 | ||
| 99 | it.effect("fails fast for invalid port overrides", () => | |
| 100 | Effect.gen(function* () { | |
| 101 | const error = yield* Effect.flip( | |
| 102 | createDevRunnerEnv({ | |
| 103 | mode: "dev", | |
| 104 | env: {}, | |
| 105 | offset: 0, | |
| 106 | envOverrides: { T3CODE_PORT: "not-a-port" }, | |
| 107 | }), | |
| 108 | ); | |
| 109 | ||
| 110 | assert.ok(error.message.includes("Invalid T3CODE_PORT override")); | |
| 111 | }), | |
| 112 | ); | |
| 127 | 113 | }); |
| 128 | 114 | |
| 129 | describe("findFirstAvailableOffset", () => { | |
| 130 | it("returns the starting offset when required ports are available", async () => { | |
| 131 | const offset = await findFirstAvailableOffset({ | |
| 132 | startOffset: 0, | |
| 133 | requireServerPort: true, | |
| 134 | requireWebPort: true, | |
| 135 | checkPortAvailability: async () => true, | |
| 136 | }); | |
| 137 | ||
| 138 | expect(offset).toBe(0); | |
| 139 | }); | |
| 140 | ||
| 141 | it("advances until all required ports are available", async () => { | |
| 142 | const taken = new Set([3773, 5733, 3774, 5734]); | |
| 143 | const offset = await findFirstAvailableOffset({ | |
| 144 | startOffset: 0, | |
| 145 | requireServerPort: true, | |
| 146 | requireWebPort: true, | |
| 147 | checkPortAvailability: async (port) => !taken.has(port), | |
| 148 | }); | |
| 149 | ||
| 150 | expect(offset).toBe(2); | |
| 151 | }); | |
| 152 | ||
| 153 | it("allows offsets where only non-required ports exceed max", async () => { | |
| 154 | const offset = await findFirstAvailableOffset({ | |
| 155 | startOffset: 59_803, | |
| 156 | requireServerPort: true, | |
| 157 | requireWebPort: false, | |
| 158 | checkPortAvailability: async () => true, | |
| 159 | }); | |
| 160 | ||
| 161 | expect(offset).toBe(59_803); | |
| 162 | }); | |
| 115 | it.layer(NodeServices.layer)("findFirstAvailableOffset", (it) => { | |
| 116 | it.effect("returns the starting offset when required ports are available", () => | |
| 117 | Effect.gen(function* () { | |
| 118 | const offset = yield* findFirstAvailableOffset({ | |
| 119 | startOffset: 0, | |
| 120 | requireServerPort: true, | |
| 121 | requireWebPort: true, | |
| 122 | checkPortAvailability: () => Effect.succeed(true), | |
| 123 | }); | |
| 124 | ||
| 125 | assert.equal(offset, 0); | |
| 126 | }), | |
| 127 | ); | |
| 128 | ||
| 129 | it.effect("advances until all required ports are available", () => | |
| 130 | Effect.gen(function* () { | |
| 131 | const taken = new Set([3773, 5733, 3774, 5734]); | |
| 132 | const offset = yield* findFirstAvailableOffset({ | |
| 133 | startOffset: 0, | |
| 134 | requireServerPort: true, | |
| 135 | requireWebPort: true, | |
| 136 | checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), | |
| 137 | }); | |
| 138 | ||
| 139 | assert.equal(offset, 2); | |
| 140 | }), | |
| 141 | ); | |
| 142 | ||
| 143 | it.effect("allows offsets where only non-required ports exceed max", () => | |
| 144 | Effect.gen(function* () { | |
| 145 | const offset = yield* findFirstAvailableOffset({ | |
| 146 | startOffset: 59_803, | |
| 147 | requireServerPort: true, | |
| 148 | requireWebPort: false, | |
| 149 | checkPortAvailability: () => Effect.succeed(true), | |
| 150 | }); | |
| 151 | ||
| 152 | assert.equal(offset, 59_803); | |
| 153 | }), | |
| 154 | ); | |
| 163 | 155 | }); |
| 164 | 156 | |
| 165 | describe("resolveModePortOffsets", () => { | |
| 166 | it("uses a shared fallback offset for dev mode", async () => { | |
| 167 | const taken = new Set([3773, 5733]); | |
| 168 | const offsets = await resolveModePortOffsets({ | |
| 169 | mode: "dev", | |
| 170 | startOffset: 0, | |
| 171 | envOverrides: {}, | |
| 172 | checkPortAvailability: async (port) => !taken.has(port), | |
| 173 | }); | |
| 174 | ||
| 175 | expect(offsets).toEqual({ serverOffset: 1, webOffset: 1 }); | |
| 176 | }); | |
| 177 | ||
| 178 | it("keeps server offset stable for dev:web and only shifts web offset", async () => { | |
| 179 | const taken = new Set([5733]); | |
| 180 | const offsets = await resolveModePortOffsets({ | |
| 181 | mode: "dev:web", | |
| 182 | startOffset: 0, | |
| 183 | envOverrides: {}, | |
| 184 | checkPortAvailability: async (port) => !taken.has(port), | |
| 185 | }); | |
| 186 | ||
| 187 | expect(offsets).toEqual({ serverOffset: 0, webOffset: 1 }); | |
| 188 | }); | |
| 189 | ||
| 190 | it("shifts only server offset for dev:server", async () => { | |
| 191 | const taken = new Set([3773]); | |
| 192 | const offsets = await resolveModePortOffsets({ | |
| 193 | mode: "dev:server", | |
| 194 | startOffset: 0, | |
| 195 | envOverrides: {}, | |
| 196 | checkPortAvailability: async (port) => !taken.has(port), | |
| 197 | }); | |
| 198 | ||
| 199 | expect(offsets).toEqual({ serverOffset: 1, webOffset: 1 }); | |
| 200 | }); | |
| 201 | ||
| 202 | it("respects explicit dev-url override for dev:web", async () => { | |
| 203 | const offsets = await resolveModePortOffsets({ | |
| 204 | mode: "dev:web", | |
| 205 | startOffset: 0, | |
| 206 | envOverrides: { VITE_DEV_SERVER_URL: "http://localhost:9999" }, | |
| 207 | checkPortAvailability: async () => false, | |
| 208 | }); | |
| 209 | ||
| 210 | expect(offsets).toEqual({ serverOffset: 0, webOffset: 0 }); | |
| 211 | }); | |
| 212 | ||
| 213 | it("respects explicit server port override for dev:server", async () => { | |
| 214 | const offsets = await resolveModePortOffsets({ | |
| 215 | mode: "dev:server", | |
| 216 | startOffset: 0, | |
| 217 | envOverrides: { T3CODE_PORT: "4888" }, | |
| 218 | checkPortAvailability: async () => false, | |
| 219 | }); | |
| 220 | ||
| 221 | expect(offsets).toEqual({ serverOffset: 0, webOffset: 0 }); | |
| 222 | }); | |
| 157 | it.layer(NodeServices.layer)("resolveModePortOffsets", (it) => { | |
| 158 | it.effect("uses a shared fallback offset for dev mode", () => | |
| 159 | Effect.gen(function* () { | |
| 160 | const taken = new Set([3773, 5733]); | |
| 161 | const offsets = yield* resolveModePortOffsets({ | |
| 162 | mode: "dev", | |
| 163 | startOffset: 0, | |
| 164 | envOverrides: {}, | |
| 165 | checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), | |
| 166 | }); | |
| 167 | ||
| 168 | assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1 }); | |
| 169 | }), | |
| 170 | ); | |
| 171 | ||
| 172 | it.effect("keeps server offset stable for dev:web and only shifts web offset", () => | |
| 173 | Effect.gen(function* () { | |
| 174 | const taken = new Set([5733]); | |
| 175 | const offsets = yield* resolveModePortOffsets({ | |
| 176 | mode: "dev:web", | |
| 177 | startOffset: 0, | |
| 178 | envOverrides: {}, | |
| 179 | checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), | |
| 180 | }); | |
| 181 | ||
| 182 | assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 1 }); | |
| 183 | }), | |
| 184 | ); | |
| 185 | ||
| 186 | it.effect("shifts only server offset for dev:server", () => | |
| 187 | Effect.gen(function* () { | |
| 188 | const taken = new Set([3773]); | |
| 189 | const offsets = yield* resolveModePortOffsets({ | |
| 190 | mode: "dev:server", | |
| 191 | startOffset: 0, | |
| 192 | envOverrides: {}, | |
| 193 | checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), | |
| 194 | }); | |
| 195 | ||
| 196 | assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1 }); | |
| 197 | }), | |
| 198 | ); | |
| 199 | ||
| 200 | it.effect("respects explicit dev-url override for dev:web", () => | |
| 201 | Effect.gen(function* () { | |
| 202 | const offsets = yield* resolveModePortOffsets({ | |
| 203 | mode: "dev:web", | |
| 204 | startOffset: 0, | |
| 205 | envOverrides: { VITE_DEV_SERVER_URL: "http://localhost:9999" }, | |
| 206 | checkPortAvailability: () => Effect.succeed(false), | |
| 207 | }); | |
| 208 | ||
| 209 | assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 0 }); | |
| 210 | }), | |
| 211 | ); | |
| 212 | ||
| 213 | it.effect("respects explicit server port override for dev:server", () => | |
| 214 | Effect.gen(function* () { | |
| 215 | const offsets = yield* resolveModePortOffsets({ | |
| 216 | mode: "dev:server", | |
| 217 | startOffset: 0, | |
| 218 | envOverrides: { T3CODE_PORT: "4888" }, | |
| 219 | checkPortAvailability: () => Effect.succeed(false), | |
| 220 | }); | |
| 221 | ||
| 222 | assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 0 }); | |
| 223 | }), | |
| 224 | ); | |
| 223 | 225 | }); |
scripts/dev-runner.ts created+527| ... | ... | @@ -0,0 +1,527 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | import { homedir } from "node:os"; | |
| 4 | ||
| 5 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | |
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 7 | import { NetService } from "@t3tools/shared/Net"; | |
| 8 | import { Data, Effect, Layer, Logger, Option, Path } from "effect"; | |
| 9 | import { Argument, Command, Flag } from "effect/unstable/cli"; | |
| 10 | import { ChildProcess } from "effect/unstable/process"; | |
| 11 | ||
| 12 | const BASE_SERVER_PORT = 3773; | |
| 13 | const BASE_WEB_PORT = 5733; | |
| 14 | const MAX_HASH_OFFSET = 3000; | |
| 15 | const MAX_PORT = 65535; | |
| 16 | ||
| 17 | export const DEFAULT_DEV_STATE_DIR: Effect.Effect<string, never, Path.Path> = Effect.gen( | |
| 18 | function* () { | |
| 19 | const path = yield* Path.Path; | |
| 20 | return path.join(homedir(), ".t3", "dev"); | |
| 21 | }); | |
| 22 | ||
| 23 | const MODE_ARGS = { | |
| 24 | dev: [ | |
| 25 | "run", | |
| 26 | "dev", | |
| 27 | "--ui=tui", | |
| 28 | "--filter=@t3tools/contracts", | |
| 29 | "--filter=@t3tools/web", | |
| 30 | "--filter=t3", | |
| 31 | "--parallel", | |
| 32 | ], | |
| 33 | "dev:server": ["run", "dev", "--filter=t3"], | |
| 34 | "dev:web": ["run", "dev", "--filter=@t3tools/web"], | |
| 35 | "dev:desktop": ["run", "dev", "--filter=@t3tools/desktop", "--filter=@t3tools/web", "--parallel"], | |
| 36 | } as const satisfies Record<string, ReadonlyArray<string>>; | |
| 37 | ||
| 38 | type DevMode = keyof typeof MODE_ARGS; | |
| 39 | type EnvOverrides = Record<string, string>; | |
| 40 | type PortAvailabilityCheck<R = never> = (port: number) => Effect.Effect<boolean, never, R>; | |
| 41 | ||
| 42 | const DEV_RUNNER_MODES = Object.keys(MODE_ARGS) as Array<DevMode>; | |
| 43 | ||
| 44 | class DevRunnerError extends Data.TaggedError("DevRunnerError")<{ | |
| 45 | readonly message: string; | |
| 46 | readonly cause?: unknown; | |
| 47 | }> {} | |
| 48 | ||
| 49 | function parseInteger(value: string, envName: string): number { | |
| 50 | const parsed = Number(value); | |
| 51 | if (!Number.isInteger(parsed)) { | |
| 52 | throw new Error(`Invalid ${envName}: ${value}`); | |
| 53 | } | |
| 54 | return parsed; | |
| 55 | } | |
| 56 | ||
| 57 | function hashSeed(seed: string): number { | |
| 58 | let hash = 0x811c9dc5; | |
| 59 | for (let i = 0; i < seed.length; i += 1) { | |
| 60 | hash ^= seed.charCodeAt(i); | |
| 61 | hash = Math.imul(hash, 0x01000193); | |
| 62 | } | |
| 63 | return hash >>> 0; | |
| 64 | } | |
| 65 | ||
| 66 | export function resolveOffset(env: NodeJS.ProcessEnv = process.env): { | |
| 67 | readonly offset: number; | |
| 68 | readonly source: string; | |
| 69 | } { | |
| 70 | const explicitOffset = env.T3CODE_PORT_OFFSET?.trim(); | |
| 71 | if (explicitOffset) { | |
| 72 | const parsed = parseInteger(explicitOffset, "T3CODE_PORT_OFFSET"); | |
| 73 | if (parsed < 0) { | |
| 74 | throw new Error(`Invalid T3CODE_PORT_OFFSET: ${explicitOffset}`); | |
| 75 | } | |
| 76 | return { offset: parsed, source: `T3CODE_PORT_OFFSET=${explicitOffset}` }; | |
| 77 | } | |
| 78 | ||
| 79 | const seed = env.T3CODE_DEV_INSTANCE?.trim(); | |
| 80 | if (!seed) { | |
| 81 | return { offset: 0, source: "default ports" }; | |
| 82 | } | |
| 83 | ||
| 84 | if (/^\d+$/.test(seed)) { | |
| 85 | return { offset: Number(seed), source: `numeric T3CODE_DEV_INSTANCE=${seed}` }; | |
| 86 | } | |
| 87 | ||
| 88 | const offset = (hashSeed(seed) % MAX_HASH_OFFSET) + 1; | |
| 89 | return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }; | |
| 90 | } | |
| 91 | ||
| 92 | function toBooleanEnvValue(value: string | undefined): string { | |
| 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* () { | |
| 104 | const path = yield* Path.Path; | |
| 105 | const overrideValue = envOverrides?.T3CODE_STATE_DIR?.trim(); | |
| 106 | if (overrideValue) { | |
| 107 | // Resolve relative paths against cwd (monorepo root) before turbo changes directories. | |
| 108 | return path.resolve(overrideValue); | |
| 109 | } | |
| 110 | ||
| 111 | const envValue = env?.T3CODE_STATE_DIR?.trim(); | |
| 112 | if (envValue) { | |
| 113 | return path.resolve(envValue); | |
| 114 | } | |
| 115 | ||
| 116 | return yield* DEFAULT_DEV_STATE_DIR; | |
| 117 | }); | |
| 118 | } | |
| 119 | ||
| 120 | interface CreateDevRunnerEnvInput { | |
| 121 | readonly mode: DevMode; | |
| 122 | readonly env: NodeJS.ProcessEnv; | |
| 123 | readonly offset: number; | |
| 124 | readonly envOverrides: EnvOverrides; | |
| 125 | } | |
| 126 | ||
| 127 | export function createDevRunnerEnv({ | |
| 128 | mode, | |
| 129 | env, | |
| 130 | offset, | |
| 131 | envOverrides, | |
| 132 | }: CreateDevRunnerEnvInput): Effect.Effect<NodeJS.ProcessEnv, DevRunnerError, Path.Path> { | |
| 133 | return Effect.gen(function* () { | |
| 134 | const serverPort = BASE_SERVER_PORT + offset; | |
| 135 | const webPort = BASE_WEB_PORT + offset; | |
| 136 | ||
| 137 | const output: NodeJS.ProcessEnv = { | |
| 138 | ...env, | |
| 139 | T3CODE_PORT: String(serverPort), | |
| 140 | PORT: String(webPort), | |
| 141 | ELECTRON_RENDERER_PORT: String(webPort), | |
| 142 | VITE_WS_URL: `ws://localhost:${serverPort}`, | |
| 143 | VITE_DEV_SERVER_URL: `http://localhost:${webPort}`, | |
| 144 | ...envOverrides, | |
| 145 | }; | |
| 146 | ||
| 147 | output.T3CODE_STATE_DIR = yield* resolveStateDir(env, envOverrides); | |
| 148 | ||
| 149 | const parsedServerPort = yield* Effect.try({ | |
| 150 | try: () => { | |
| 151 | const parsed = Number(output.T3CODE_PORT); | |
| 152 | if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_PORT) { | |
| 153 | throw new Error( | |
| 154 | `Invalid T3CODE_PORT override: '${String(output.T3CODE_PORT)}'. Expected an integer between 1 and 65535.`, | |
| 155 | ); | |
| 156 | } | |
| 157 | return parsed; | |
| 158 | }, | |
| 159 | catch: (cause) => | |
| 160 | new DevRunnerError({ | |
| 161 | message: cause instanceof Error ? cause.message : String(cause), | |
| 162 | cause, | |
| 163 | }), | |
| 164 | }); | |
| 165 | output.VITE_WS_URL = `ws://localhost:${parsedServerPort}`; | |
| 166 | ||
| 167 | if (mode === "dev" || mode === "dev:server" || mode === "dev:web") { | |
| 168 | // Running server/web in browser mode should not inherit desktop launcher state. | |
| 169 | output.T3CODE_MODE = "web"; | |
| 170 | if (!("T3CODE_NO_BROWSER" in envOverrides)) { | |
| 171 | delete output.T3CODE_NO_BROWSER; | |
| 172 | } | |
| 173 | if (!("T3CODE_AUTH_TOKEN" in envOverrides)) { | |
| 174 | delete output.T3CODE_AUTH_TOKEN; | |
| 175 | } | |
| 176 | delete output.T3CODE_DESKTOP_WS_URL; | |
| 177 | } | |
| 178 | ||
| 179 | if (mode === "dev" && !output.T3CODE_LOG_WS_EVENTS) { | |
| 180 | output.T3CODE_LOG_WS_EVENTS = "1"; | |
| 181 | } | |
| 182 | ||
| 183 | return output; | |
| 184 | }); | |
| 185 | } | |
| 186 | ||
| 187 | function portPairForOffset(offset: number): { readonly serverPort: number; readonly webPort: number } { | |
| 188 | return { | |
| 189 | serverPort: BASE_SERVER_PORT + offset, | |
| 190 | webPort: BASE_WEB_PORT + offset, | |
| 191 | }; | |
| 192 | } | |
| 193 | ||
| 194 | const defaultCheckPortAvailability: PortAvailabilityCheck<NetService> = (port) => | |
| 195 | Effect.gen(function* () { | |
| 196 | const net = yield* NetService; | |
| 197 | return yield* net.isPortAvailableOnLoopback(port); | |
| 198 | }); | |
| 199 | ||
| 200 | interface FindFirstAvailableOffsetInput<R = NetService> { | |
| 201 | readonly startOffset: number; | |
| 202 | readonly requireServerPort: boolean; | |
| 203 | readonly requireWebPort: boolean; | |
| 204 | readonly checkPortAvailability?: PortAvailabilityCheck<R>; | |
| 205 | } | |
| 206 | ||
| 207 | export function findFirstAvailableOffset<R = NetService>({ | |
| 208 | startOffset, | |
| 209 | requireServerPort, | |
| 210 | requireWebPort, | |
| 211 | checkPortAvailability, | |
| 212 | }: FindFirstAvailableOffsetInput<R>): Effect.Effect<number, DevRunnerError, R> { | |
| 213 | return Effect.gen(function* () { | |
| 214 | const checkPort = (checkPortAvailability ?? defaultCheckPortAvailability) as PortAvailabilityCheck<R>; | |
| 215 | for (let candidate = startOffset; ; candidate += 1) { | |
| 216 | const { serverPort, webPort } = portPairForOffset(candidate); | |
| 217 | const serverPortOutOfRange = serverPort > MAX_PORT; | |
| 218 | const webPortOutOfRange = webPort > MAX_PORT; | |
| 219 | if ( | |
| 220 | (requireServerPort && serverPortOutOfRange) || | |
| 221 | (requireWebPort && webPortOutOfRange) || | |
| 222 | (!requireServerPort && !requireWebPort && (serverPortOutOfRange || webPortOutOfRange)) | |
| 223 | ) { | |
| 224 | break; | |
| 225 | } | |
| 226 | ||
| 227 | const checks: Array<Effect.Effect<boolean, never, R>> = []; | |
| 228 | if (requireServerPort) { | |
| 229 | checks.push(checkPort(serverPort)); | |
| 230 | } | |
| 231 | if (requireWebPort) { | |
| 232 | checks.push(checkPort(webPort)); | |
| 233 | } | |
| 234 | ||
| 235 | if (checks.length === 0) { | |
| 236 | return candidate; | |
| 237 | } | |
| 238 | ||
| 239 | const availability = yield* Effect.all(checks); | |
| 240 | if (availability.every(Boolean)) { | |
| 241 | return candidate; | |
| 242 | } | |
| 243 | } | |
| 244 | ||
| 245 | return yield* new DevRunnerError({ | |
| 246 | message: `No available dev ports found from offset ${startOffset}. Tried server=${BASE_SERVER_PORT}+n web=${BASE_WEB_PORT}+n up to port ${MAX_PORT}.`, | |
| 247 | }); | |
| 248 | }); | |
| 249 | } | |
| 250 | ||
| 251 | interface ResolveModePortOffsetsInput<R = NetService> { | |
| 252 | readonly mode: DevMode; | |
| 253 | readonly startOffset: number; | |
| 254 | readonly envOverrides: EnvOverrides; | |
| 255 | readonly checkPortAvailability?: PortAvailabilityCheck<R>; | |
| 256 | } | |
| 257 | ||
| 258 | export function resolveModePortOffsets<R = NetService>({ | |
| 259 | mode, | |
| 260 | startOffset, | |
| 261 | envOverrides, | |
| 262 | checkPortAvailability, | |
| 263 | }: ResolveModePortOffsetsInput<R>): Effect.Effect< | |
| 264 | { readonly serverOffset: number; readonly webOffset: number }, | |
| 265 | DevRunnerError, | |
| 266 | R | |
| 267 | > { | |
| 268 | return Effect.gen(function* () { | |
| 269 | const checkPort = (checkPortAvailability ?? defaultCheckPortAvailability) as PortAvailabilityCheck<R>; | |
| 270 | const hasExplicitServerPort = typeof envOverrides.T3CODE_PORT === "string"; | |
| 271 | const hasExplicitDevUrl = typeof envOverrides.VITE_DEV_SERVER_URL === "string"; | |
| 272 | ||
| 273 | if (mode === "dev:web") { | |
| 274 | if (hasExplicitDevUrl) { | |
| 275 | return { serverOffset: startOffset, webOffset: startOffset }; | |
| 276 | } | |
| 277 | const webOffset = yield* findFirstAvailableOffset({ | |
| 278 | startOffset, | |
| 279 | requireServerPort: false, | |
| 280 | requireWebPort: true, | |
| 281 | checkPortAvailability: checkPort, | |
| 282 | }); | |
| 283 | return { serverOffset: startOffset, webOffset }; | |
| 284 | } | |
| 285 | ||
| 286 | if (mode === "dev:server") { | |
| 287 | if (hasExplicitServerPort) { | |
| 288 | return { serverOffset: startOffset, webOffset: startOffset }; | |
| 289 | } | |
| 290 | const serverOffset = yield* findFirstAvailableOffset({ | |
| 291 | startOffset, | |
| 292 | requireServerPort: true, | |
| 293 | requireWebPort: false, | |
| 294 | checkPortAvailability: checkPort, | |
| 295 | }); | |
| 296 | return { serverOffset, webOffset: serverOffset }; | |
| 297 | } | |
| 298 | ||
| 299 | const sharedOffset = yield* findFirstAvailableOffset({ | |
| 300 | startOffset, | |
| 301 | requireServerPort: !hasExplicitServerPort, | |
| 302 | requireWebPort: !hasExplicitDevUrl, | |
| 303 | checkPortAvailability: checkPort, | |
| 304 | }); | |
| 305 | return { serverOffset: sharedOffset, webOffset: sharedOffset }; | |
| 306 | }); | |
| 307 | } | |
| 308 | ||
| 309 | const optionValue = <A>(option: Option.Option<A>): A | undefined => | |
| 310 | Option.isSome(option) ? option.value : undefined; | |
| 311 | ||
| 312 | interface DevRunnerCliInput { | |
| 313 | readonly mode: DevMode; | |
| 314 | readonly stateDir: Option.Option<string>; | |
| 315 | readonly authToken: Option.Option<string>; | |
| 316 | readonly noBrowser: Option.Option<string>; | |
| 317 | readonly autoBootstrapProjectFromCwd: Option.Option<string>; | |
| 318 | readonly logWebSocketEvents: Option.Option<string>; | |
| 319 | readonly host: Option.Option<string>; | |
| 320 | readonly port: Option.Option<string>; | |
| 321 | readonly devUrl: Option.Option<string>; | |
| 322 | readonly dryRun: boolean; | |
| 323 | readonly turboArgs: ReadonlyArray<string>; | |
| 324 | } | |
| 325 | ||
| 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) { | |
| 365 | return Effect.gen(function* () { | |
| 366 | const envOverrides = envOverridesFromCliInput(input); | |
| 367 | const turboArgs = Array.from(input.turboArgs); | |
| 368 | const { offset, source } = yield* Effect.try({ | |
| 369 | try: () => resolveOffset(), | |
| 370 | catch: (cause) => | |
| 371 | new DevRunnerError({ | |
| 372 | message: cause instanceof Error ? cause.message : String(cause), | |
| 373 | cause, | |
| 374 | }), | |
| 375 | }); | |
| 376 | ||
| 377 | const { serverOffset, webOffset } = yield* resolveModePortOffsets({ | |
| 378 | mode: input.mode, | |
| 379 | startOffset: offset, | |
| 380 | envOverrides, | |
| 381 | }); | |
| 382 | ||
| 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({ | |
| 392 | mode: input.mode, | |
| 393 | env: process.env, | |
| 394 | offset: serverOffset, | |
| 395 | envOverrides: mergedEnvOverrides, | |
| 396 | }); | |
| 397 | ||
| 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 = | |
| 414 | serverOffset !== offset || webOffset !== offset | |
| 415 | ? ` selectedOffset(server=${serverOffset},web=${webOffset})` | |
| 416 | : ""; | |
| 417 | ||
| 418 | yield* Effect.logInfo( | |
| 419 | `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${parsedServerPort} webPort=${parsedWebPort} stateDir=${String(env.T3CODE_STATE_DIR)}`, | |
| 420 | ); | |
| 421 | ||
| 422 | if (input.dryRun) { | |
| 423 | return; | |
| 424 | } | |
| 425 | ||
| 426 | const command = process.platform === "win32" ? "turbo.cmd" : "turbo"; | |
| 427 | const child = yield* ChildProcess.make(command, [...MODE_ARGS[input.mode], ...turboArgs], { | |
| 428 | stdin: "inherit", | |
| 429 | stdout: "inherit", | |
| 430 | stderr: "inherit", | |
| 431 | env, | |
| 432 | extendEnv: false, | |
| 433 | }); | |
| 434 | ||
| 435 | const exitCode = Number(yield* child.exitCode); | |
| 436 | if (exitCode !== 0) { | |
| 437 | return yield* new DevRunnerError({ | |
| 438 | message: `turbo exited with code ${exitCode}`, | |
| 439 | }); | |
| 440 | } | |
| 441 | }).pipe( | |
| 442 | Effect.mapError((cause) => | |
| 443 | cause instanceof DevRunnerError | |
| 444 | ? cause | |
| 445 | : new DevRunnerError({ | |
| 446 | message: cause instanceof Error ? cause.message : "dev-runner failed", | |
| 447 | cause, | |
| 448 | }), | |
| 449 | ), | |
| 450 | ); | |
| 451 | } | |
| 452 | ||
| 453 | const devRunnerCli = Command.make("dev-runner", { | |
| 454 | mode: Argument.choice("mode", DEV_RUNNER_MODES).pipe( | |
| 455 | Argument.withDescription("Development mode to run."), | |
| 456 | ), | |
| 457 | stateDir: Flag.string("state-dir").pipe( | |
| 458 | Flag.withDescription("State directory path (forwards to T3CODE_STATE_DIR)."), | |
| 459 | Flag.optional, | |
| 460 | ), | |
| 461 | authToken: Flag.string("auth-token").pipe( | |
| 462 | Flag.withDescription("Auth token (forwards to T3CODE_AUTH_TOKEN)."), | |
| 463 | Flag.withAlias("token"), | |
| 464 | Flag.optional, | |
| 465 | ), | |
| 466 | noBrowser: Flag.string("no-browser").pipe( | |
| 467 | Flag.withDescription("Browser auto-open toggle. Accepts true/false/1/0/no."), | |
| 468 | Flag.optional, | |
| 469 | ), | |
| 470 | autoBootstrapProjectFromCwd: Flag.string("auto-bootstrap-project-from-cwd").pipe( | |
| 471 | Flag.withDescription("Auto-bootstrap toggle. Accepts true/false/1/0/no."), | |
| 472 | Flag.optional, | |
| 473 | ), | |
| 474 | logWebSocketEvents: Flag.string("log-websocket-events").pipe( | |
| 475 | Flag.withDescription("WebSocket event logging toggle. Accepts true/false/1/0/no."), | |
| 476 | Flag.withAlias("log-ws-events"), | |
| 477 | Flag.optional, | |
| 478 | ), | |
| 479 | host: Flag.string("host").pipe( | |
| 480 | Flag.withDescription("Server host/interface override (forwards to T3CODE_HOST)."), | |
| 481 | Flag.optional, | |
| 482 | ), | |
| 483 | port: Flag.string("port").pipe( | |
| 484 | Flag.withDescription("Server port override (forwards to T3CODE_PORT)."), | |
| 485 | Flag.optional, | |
| 486 | ), | |
| 487 | devUrl: Flag.string("dev-url").pipe( | |
| 488 | Flag.withDescription("Web dev URL override (forwards to VITE_DEV_SERVER_URL)."), | |
| 489 | Flag.optional, | |
| 490 | ), | |
| 491 | dryRun: Flag.boolean("dry-run").pipe( | |
| 492 | Flag.withDescription("Resolve mode/ports/env and print, but do not spawn turbo."), | |
| 493 | Flag.withDefault(false), | |
| 494 | ), | |
| 495 | turboArgs: Argument.string("turbo-arg").pipe( | |
| 496 | Argument.withDescription("Additional turbo args (pass after `--`)."), | |
| 497 | Argument.variadic(), | |
| 498 | ), | |
| 499 | }).pipe( | |
| 500 | Command.withDescription("Run monorepo development modes with deterministic port/env wiring."), | |
| 501 | Command.withHandler((input) => runDevRunnerWithInput(input)), | |
| 502 | ); | |
| 503 | ||
| 504 | export function runDevRunner(argv = process.argv) { | |
| 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()]), | |
| 519 | NodeServices.layer, | |
| 520 | NetService.layer, | |
| 521 | ); | |
| 522 | ||
| 523 | const runtimeProgram = runDevRunner().pipe(Effect.scoped, Effect.provide(runtimeLayer)); | |
| 524 | ||
| 525 | if (import.meta.main) { | |
| 526 | NodeRuntime.runMain(runtimeProgram); | |
| 527 | } |
scripts/package.json created+22| ... | ... | @@ -0,0 +1,22 @@ |
| 1 | { | |
| 2 | "name": "@t3tools/scripts", | |
| 3 | "private": true, | |
| 4 | "type": "module", | |
| 5 | "scripts": { | |
| 6 | "prepare": "effect-language-service patch", | |
| 7 | "typecheck": "tsc --noEmit", | |
| 8 | "test": "vitest run" | |
| 9 | }, | |
| 10 | "dependencies": { | |
| 11 | "@t3tools/contracts": "workspace:*", | |
| 12 | "@t3tools/shared": "workspace:*", | |
| 13 | "effect": "catalog:" | |
| 14 | }, | |
| 15 | "devDependencies": { | |
| 16 | "@effect/language-service": "catalog:", | |
| 17 | "@effect/vitest": "catalog:", | |
| 18 | "@types/bun": "catalog:", | |
| 19 | "typescript": "catalog:", | |
| 20 | "vitest": "catalog:" | |
| 21 | } | |
| 22 | } |
scripts/tsconfig.json created+16| ... | ... | @@ -0,0 +1,16 @@ |
| 1 | { | |
| 2 | "extends": "../tsconfig.base.json", | |
| 3 | "compilerOptions": { | |
| 4 | "composite": true, | |
| 5 | "types": ["node", "bun"], | |
| 6 | "lib": ["ES2023", "esnext.disposable"], | |
| 7 | "noEmit": true, | |
| 8 | "allowImportingTsExtensions": true, | |
| 9 | "plugins": [ | |
| 10 | { | |
| 11 | "name": "@effect/language-service" | |
| 12 | } | |
| 13 | ] | |
| 14 | }, | |
| 15 | "include": ["**/*.ts"] | |
| 16 | } |