authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-03 11:43:29-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-03 18:22:29-08:00
logb76a0a6c913f680ad5be15bd50b890249aeae24b
tree855cd192fd7903c4263110072efaaae312c72fcf
parent9fdec3c2c8fbc1647843bb2f49a45224daa5aa30

Migrate dev runner to TypeScript and share NetService

- 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:` versions

20 files changed, 1160 insertions(+), 783 deletions(-)

apps/desktop/package.json+5-4
...@@ -14,15 +14,16 @@...@@ -14,15 +14,16 @@
14 "smoke-test": "node scripts/smoke-test.mjs"14 "smoke-test": "node scripts/smoke-test.mjs"
15 },15 },
16 "dependencies": {16 "dependencies": {
17 "@t3tools/shared": "workspace:*",
17 "electron": "40.6.0"18 "electron": "40.6.0"
18 },19 },
19 "devDependencies": {20 "devDependencies": {
20 "@t3tools/contracts": "workspace:*",21 "@t3tools/contracts": "workspace:*",
21 "@types/node": "^24.10.13",22 "@types/node": "catalog:",
22 "electronmon": "^2.0.2",23 "electronmon": "^2.0.2",
23 "tsdown": "^0.20.3",24 "tsdown": "catalog:",
24 "typescript": "^5.7.3",25 "typescript": "catalog:",
25 "vitest": "^4.0.0",26 "vitest": "catalog:",
26 "wait-on": "^8.0.2"27 "wait-on": "^8.0.2"
27 },28 },
28 "productName": "T3 Code (Alpha)"29 "productName": "T3 Code (Alpha)"
apps/desktop/src/main.ts+8-20
...@@ -1,14 +1,15 @@...@@ -1,14 +1,15 @@
1import { spawn, type ChildProcess } from "node:child_process";1import { spawn, type ChildProcess } from "node:child_process";
2import { randomBytes } from "node:crypto";2import { randomBytes } from "node:crypto";
3import fs from "node:fs";3import fs from "node:fs";
4import net from "node:net";
5import os from "node:os";4import os from "node:os";
6import path from "node:path";5import path from "node:path";
76
8import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron";7import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron";
9import type { MenuItemConstructorOptions } from "electron";8import type { MenuItemConstructorOptions } from "electron";
9import { Effect } from "effect";
1010
11import type { ContextMenuItem } from "@t3tools/contracts";11import type { ContextMenuItem } from "@t3tools/contracts";
12import { NetService } from "@t3tools/shared/Net";
12import { showDesktopConfirmDialog } from "./confirmDialog";13import { showDesktopConfirmDialog } from "./confirmDialog";
13import { fixPath } from "./fixPath";14import { fixPath } from "./fixPath";
1415
...@@ -347,24 +348,6 @@ function configureAppIdentity(): void {...@@ -347,24 +348,6 @@ function configureAppIdentity(): void {
347 }348 }
348}349}
349350
350async 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
368function backendEnv(): NodeJS.ProcessEnv {351function backendEnv(): NodeJS.ProcessEnv {
369 return {352 return {
370 ...process.env,353 ...process.env,
...@@ -621,7 +604,12 @@ function createWindow(): BrowserWindow {...@@ -621,7 +604,12 @@ function createWindow(): BrowserWindow {
621configureAppIdentity();604configureAppIdentity();
622605
623async function bootstrap(): Promise<void> {606async 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 backendAuthToken = randomBytes(24).toString("hex");613 backendAuthToken = randomBytes(24).toString("hex");
626 backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`;614 backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`;
627 process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl;615 process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl;
apps/server/package.json+6-6
...@@ -26,17 +26,17 @@...@@ -26,17 +26,17 @@
26 "ws": "^8.18.0"26 "ws": "^8.18.0"
27 },27 },
28 "devDependencies": {28 "devDependencies": {
29 "@effect/language-service": "0.75.1",29 "@effect/language-service": "catalog:",
30 "@effect/vitest": "catalog:",30 "@effect/vitest": "catalog:",
31 "@t3tools/contracts": "workspace:*",31 "@t3tools/contracts": "workspace:*",
32 "@t3tools/shared": "workspace:*",32 "@t3tools/shared": "workspace:*",
33 "@t3tools/web": "workspace:*",33 "@t3tools/web": "workspace:*",
34 "@types/bun": "^1.3.9",34 "@types/bun": "catalog:",
35 "@types/node": "^24.10.13",35 "@types/node": "catalog:",
36 "@types/ws": "^8.5.13",36 "@types/ws": "^8.5.13",
37 "tsdown": "^0.20.3",37 "tsdown": "catalog:",
38 "typescript": "^5.7.3",38 "typescript": "catalog:",
39 "vitest": "^4.0.0"39 "vitest": "catalog:"
40 },40 },
41 "engines": {41 "engines": {
42 "node": "^22.13 || ^23.4 || >=24.10"42 "node": "^22.13 || ^23.4 || >=24.10"
apps/server/src/config.ts+2-65
...@@ -1,13 +1,12 @@...@@ -1,13 +1,12 @@
1/**1/**
2 * ServerConfig and NetService - Runtime configuration services.2 * ServerConfig - Runtime configuration services.
3 *3 *
4 * Defines process-level server configuration and networking helpers used by4 * Defines process-level server configuration and networking helpers used by
5 * startup and runtime layers.5 * startup and runtime layers.
6 *6 *
7 * @module ServerConfig7 * @module ServerConfig
8 */8 */
9import * as Net from "node:net";9import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect";
10import { Data, Effect, FileSystem, Layer, Path, ServiceMap } from "effect";
1110
12export const DEFAULT_PORT = 3773;11export const DEFAULT_PORT = 3773;
1312
...@@ -60,68 +59,6 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS...@@ -60,68 +59,6 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS
60 );59 );
61}60}
6261
63// Helpers
64
65export 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 */
73export 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 */
85export 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
125export const resolveStaticDir = Effect.fn(function* () {62export const resolveStaticDir = Effect.fn(function* () {
126 const { join, resolve } = yield* Path.Path;63 const { join, resolve } = yield* Path.Path;
127 const { exists } = yield* FileSystem.FileSystem;64 const { exists } = yield* FileSystem.FileSystem;
apps/server/src/index.ts+1-1
...@@ -8,7 +8,7 @@ import { OpenLive } from "./open";...@@ -8,7 +8,7 @@ import { OpenLive } from "./open";
8import { Command } from "effect/unstable/cli";8import { Command } from "effect/unstable/cli";
9import { version } from "../package.json" with { type: "json" };9import { version } from "../package.json" with { type: "json" };
10import { ServerLive } from "./wsServer";10import { ServerLive } from "./wsServer";
11import { NetService } from "./config";11import { NetService } from "@t3tools/shared/Net";
1212
13const RuntimeLayer = Layer.empty.pipe(13const RuntimeLayer = Layer.empty.pipe(
14 Layer.provideMerge(CliConfig.layer),14 Layer.provideMerge(CliConfig.layer),
apps/server/src/main.test.ts+5-1
...@@ -6,9 +6,10 @@ import * as Effect from "effect/Effect";...@@ -6,9 +6,10 @@ import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
7import * as Command from "effect/unstable/cli/Command";7import * as Command from "effect/unstable/cli/Command";
8import { beforeEach } from "vitest";8import { beforeEach } from "vitest";
9import { NetService } from "@t3tools/shared/Net";
910
10import { CliConfig, t3Cli, type CliConfigShape } from "./main";11import { CliConfig, t3Cli, type CliConfigShape } from "./main";
11import { NetService, ServerConfig, type ServerConfigShape } from "./config";12import { ServerConfig, type ServerConfigShape } from "./config";
12import { Open, type OpenShape } from "./open";13import { Open, type OpenShape } from "./open";
13import { Server, type ServerShape } from "./wsServer";14import { Server, type ServerShape } from "./wsServer";
1415
...@@ -33,6 +34,9 @@ const testLayer = Layer.mergeAll(...@@ -33,6 +34,9 @@ const testLayer = Layer.mergeAll(
33 resolveStaticDir: Effect.undefined,34 resolveStaticDir: Effect.undefined,
34 } satisfies CliConfigShape),35 } satisfies CliConfigShape),
35 Layer.succeed(NetService, {36 Layer.succeed(NetService, {
37 canListenOnHost: () => Effect.succeed(true),
38 isPortAvailableOnLoopback: () => Effect.succeed(true),
39 reserveLoopbackPort: () => Effect.succeed(0),
36 findAvailablePort,40 findAvailablePort,
37 }),41 }),
38 Layer.succeed(Server, {42 Layer.succeed(Server, {
apps/server/src/main.ts+1-1
...@@ -8,10 +8,10 @@...@@ -8,10 +8,10 @@
8 */8 */
9import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect";9import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect";
10import { Command, Flag } from "effect/unstable/cli";10import { Command, Flag } from "effect/unstable/cli";
11import { NetService } from "@t3tools/shared/Net";
1112
12import {13import {
13 DEFAULT_PORT,14 DEFAULT_PORT,
14 NetService,
15 resolveStaticDir,15 resolveStaticDir,
16 ServerConfig,16 ServerConfig,
17 type RuntimeMode,17 type RuntimeMode,
apps/web/package.json+1-1
...@@ -43,7 +43,7 @@...@@ -43,7 +43,7 @@
43 "@vitejs/plugin-react": "^5.1.4",43 "@vitejs/plugin-react": "^5.1.4",
44 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",44 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
45 "tailwindcss": "^4.0.0",45 "tailwindcss": "^4.0.0",
46 "typescript": "^5.7.3",46 "typescript": "catalog:",
47 "vite": "^8.0.0-beta.12"47 "vite": "^8.0.0-beta.12"
48 }48 }
49}49}
bun.lock+42-22
...@@ -5,29 +5,26 @@...@@ -5,29 +5,26 @@
5 "": {5 "": {
6 "name": "@t3tools/monorepo",6 "name": "@t3tools/monorepo",
7 "devDependencies": {7 "devDependencies": {
8 "@effect/platform-node": "catalog:",8 "@types/node": "catalog:",
9 "@types/node": "^24.10.13",
10 "effect": "catalog:",
11 "oxfmt": "^0.35.0",9 "oxfmt": "^0.35.0",
12 "oxlint": "^1.50.0",10 "oxlint": "^1.50.0",
13 "turbo": "^2.3.3",11 "turbo": "^2.3.3",
14 "typescript": "^5.7.3",
15 "vitest": "^4.0.0",
16 },12 },
17 },13 },
18 "apps/desktop": {14 "apps/desktop": {
19 "name": "@t3tools/desktop",15 "name": "@t3tools/desktop",
20 "version": "0.0.0-alpha.12",16 "version": "0.0.0-alpha.12",
21 "dependencies": {17 "dependencies": {
18 "@t3tools/shared": "workspace:*",
22 "electron": "40.6.0",19 "electron": "40.6.0",
23 },20 },
24 "devDependencies": {21 "devDependencies": {
25 "@t3tools/contracts": "workspace:*",22 "@t3tools/contracts": "workspace:*",
26 "@types/node": "^24.10.13",23 "@types/node": "catalog:",
27 "electronmon": "^2.0.2",24 "electronmon": "^2.0.2",
28 "tsdown": "^0.20.3",25 "tsdown": "catalog:",
29 "typescript": "^5.7.3",26 "typescript": "catalog:",
30 "vitest": "^4.0.0",27 "vitest": "catalog:",
31 "wait-on": "^8.0.2",28 "wait-on": "^8.0.2",
32 },29 },
33 },30 },
...@@ -47,17 +44,17 @@...@@ -47,17 +44,17 @@
47 "ws": "^8.18.0",44 "ws": "^8.18.0",
48 },45 },
49 "devDependencies": {46 "devDependencies": {
50 "@effect/language-service": "0.75.1",47 "@effect/language-service": "catalog:",
51 "@effect/vitest": "catalog:",48 "@effect/vitest": "catalog:",
52 "@t3tools/contracts": "workspace:*",49 "@t3tools/contracts": "workspace:*",
53 "@t3tools/shared": "workspace:*",50 "@t3tools/shared": "workspace:*",
54 "@t3tools/web": "workspace:*",51 "@t3tools/web": "workspace:*",
55 "@types/bun": "^1.3.9",52 "@types/bun": "catalog:",
56 "@types/node": "^24.10.13",53 "@types/node": "catalog:",
57 "@types/ws": "^8.5.13",54 "@types/ws": "^8.5.13",
58 "tsdown": "^0.20.3",55 "tsdown": "catalog:",
59 "typescript": "^5.7.3",56 "typescript": "catalog:",
60 "vitest": "^4.0.0",57 "vitest": "catalog:",
61 },58 },
62 },59 },
63 "apps/web": {60 "apps/web": {
...@@ -95,7 +92,7 @@...@@ -95,7 +92,7 @@
95 "@vitejs/plugin-react": "^5.1.4",92 "@vitejs/plugin-react": "^5.1.4",
96 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",93 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
97 "tailwindcss": "^4.0.0",94 "tailwindcss": "^4.0.0",
98 "typescript": "^5.7.3",95 "typescript": "catalog:",
99 "vite": "^8.0.0-beta.12",96 "vite": "^8.0.0-beta.12",
100 },97 },
101 },98 },
...@@ -106,10 +103,10 @@...@@ -106,10 +103,10 @@
106 "effect": "catalog:",103 "effect": "catalog:",
107 },104 },
108 "devDependencies": {105 "devDependencies": {
109 "@effect/language-service": "0.75.1",106 "@effect/language-service": "catalog:",
110 "@effect/vitest": "catalog:",107 "@effect/vitest": "catalog:",
111 "tsdown": "^0.20.3",108 "tsdown": "catalog:",
112 "typescript": "^5.7.3",109 "typescript": "catalog:",
113 },110 },
114 },111 },
115 "packages/shared": {112 "packages/shared": {
...@@ -119,10 +116,26 @@...@@ -119,10 +116,26 @@
119 "effect": "catalog:",116 "effect": "catalog:",
120 },117 },
121 "devDependencies": {118 "devDependencies": {
122 "@effect/language-service": "0.75.1",119 "@effect/language-service": "catalog:",
123 "@effect/vitest": "catalog:",120 "@effect/vitest": "catalog:",
124 "typescript": "^5.7.3",121 "@types/node": "catalog:",
125 "vitest": "^4.0.0",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,7 +144,12 @@
131 "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b",144 "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b",
132 "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b",145 "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b",
133 "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b",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 "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b",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 "packages": {154 "packages": {
137 "@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=="],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,6 +570,8 @@
552570
553 "@t3tools/desktop": ["@t3tools/desktop@workspace:apps/desktop"],571 "@t3tools/desktop": ["@t3tools/desktop@workspace:apps/desktop"],
554572
573 "@t3tools/scripts": ["@t3tools/scripts@workspace:scripts"],
574
555 "@t3tools/shared": ["@t3tools/shared@workspace:packages/shared"],575 "@t3tools/shared": ["@t3tools/shared@workspace:packages/shared"],
556576
557 "@t3tools/web": ["@t3tools/web@workspace:apps/web"],577 "@t3tools/web": ["@t3tools/web@workspace:apps/web"],
package.json+14-12
...@@ -4,22 +4,28 @@...@@ -4,22 +4,28 @@
4 "workspaces": {4 "workspaces": {
5 "packages": [5 "packages": [
6 "apps/*",6 "apps/*",
7 "packages/*"7 "packages/*",
8 "scripts"
8 ],9 ],
9 "catalog": {10 "catalog": {
10 "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b",11 "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b",
11 "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b",12 "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b",
12 "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b",13 "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b",
13 "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b",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 "type": "module",23 "type": "module",
18 "scripts": {24 "scripts": {
19 "dev": "bun run scripts/dev-runner.mjs dev",25 "dev": "node scripts/dev-runner.ts dev",
20 "dev:server": "bun run scripts/dev-runner.mjs dev:server",26 "dev:server": "node scripts/dev-runner.ts dev:server",
21 "dev:web": "bun run scripts/dev-runner.mjs dev:web",27 "dev:web": "node scripts/dev-runner.ts dev:web",
22 "dev:desktop": "bun run scripts/dev-runner.mjs dev:desktop",28 "dev:desktop": "node scripts/dev-runner.ts dev:desktop",
23 "start": "turbo run start --filter=t3",29 "start": "turbo run start --filter=t3",
24 "start:desktop": "turbo run start --filter=@t3tools/desktop",30 "start:desktop": "turbo run start --filter=@t3tools/desktop",
25 "build": "turbo run build",31 "build": "turbo run build",
...@@ -40,14 +46,10 @@...@@ -40,14 +46,10 @@
40 "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs"46 "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs"
41 },47 },
42 "devDependencies": {48 "devDependencies": {
43 "@effect/platform-node": "catalog:",49 "@types/node": "catalog:",
44 "@types/node": "^24.10.13",
45 "effect": "catalog:",
46 "oxfmt": "^0.35.0",50 "oxfmt": "^0.35.0",
47 "oxlint": "^1.50.0",51 "oxlint": "^1.50.0",
48 "turbo": "^2.3.3",52 "turbo": "^2.3.3"
49 "typescript": "^5.7.3",
50 "vitest": "^4.0.0"
51 },53 },
52 "engines": {54 "engines": {
53 "bun": "^1.3.9",55 "bun": "^1.3.9",
packages/contracts/package.json+3-3
...@@ -27,9 +27,9 @@...@@ -27,9 +27,9 @@
27 "effect": "catalog:"27 "effect": "catalog:"
28 },28 },
29 "devDependencies": {29 "devDependencies": {
30 "@effect/language-service": "0.75.1",30 "@effect/language-service": "catalog:",
31 "@effect/vitest": "catalog:",31 "@effect/vitest": "catalog:",
32 "tsdown": "^0.20.3",32 "tsdown": "catalog:",
33 "typescript": "^5.7.3"33 "typescript": "catalog:"
34 }34 }
35}35}
packages/shared/package.json+9-4
...@@ -7,20 +7,25 @@...@@ -7,20 +7,25 @@
7 "./git": {7 "./git": {
8 "types": "./src/git.ts",8 "types": "./src/git.ts",
9 "import": "./src/git.ts"9 "import": "./src/git.ts"
10 },
11 "./Net": {
12 "types": "./src/Net.ts",
13 "import": "./src/Net.ts"
10 }14 }
11 },15 },
12 "scripts": {16 "scripts": {
13 "prepare": "effect-language-service patch",17 "prepare": "effect-language-service patch",
14 "typecheck": "tsc --noEmit",18 "typecheck": "tsc --noEmit",
15 "test": "vitest run --passWithNoTests"19 "test": "vitest run"
16 },20 },
17 "dependencies": {21 "dependencies": {
18 "effect": "catalog:"22 "effect": "catalog:"
19 },23 },
20 "devDependencies": {24 "devDependencies": {
21 "@effect/language-service": "0.75.1",25 "@effect/language-service": "catalog:",
22 "@effect/vitest": "catalog:",26 "@effect/vitest": "catalog:",
23 "typescript": "^5.7.3",27 "@types/node": "catalog:",
24 "vitest": "^4.0.0"28 "typescript": "catalog:",
29 "vitest": "catalog:"
25 }30 }
26}31}
packages/shared/src/Net.test.ts created+98
...@@ -0,0 +1,98 @@
1import * as Net from "node:net";
2
3import { assert, describe, it } from "@effect/vitest";
4import { Effect } from "effect";
5
6import { NetError, NetService } from "./Net";
7
8const closeServer = (server: Net.Server) =>
9 Effect.sync(() => {
10 try {
11 server.close();
12 } catch {
13 // Ignore cleanup failures in tests.
14 }
15 });
16
17const getPort = (server: Net.Server): number => {
18 const address = server.address();
19 return typeof address === "object" && address !== null ? address.port : 0;
20};
21
22const 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
46it.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 @@
1import * as Net from "node:net";
2
3import { Data, Effect, Layer, ServiceMap } from "effect";
4
5export class NetError extends Data.TaggedError("NetError")<{
6 readonly message: string;
7 readonly cause?: unknown;
8}> {}
9
10function 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
21const closeServer = (server: Net.Server) => {
22 try {
23 server.close();
24 } catch {
25 // Ignore close failures during cleanup.
26 }
27};
28
29const 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
63export 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 */
88export 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,7 +10,7 @@ import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts";
1010
11import * as NodeRuntime from "@effect/platform-node/NodeRuntime";11import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
12import * as NodeServices from "@effect/platform-node/NodeServices";12import * as NodeServices from "@effect/platform-node/NodeServices";
13import { Config, Data, Effect, FileSystem, Logger, Option, Path, Schema } from "effect";13import { Config, Data, Effect, FileSystem, Layer, Logger, Option, Path, Schema } from "effect";
14import { Command, Flag } from "effect/unstable/cli";14import { Command, Flag } from "effect/unstable/cli";
15import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";15import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1616
...@@ -23,6 +23,7 @@ const RepoRoot = Effect.service(Path.Path).pipe(...@@ -23,6 +23,7 @@ const RepoRoot = Effect.service(Path.Path).pipe(
23const IconSource = Effect.zipWith(RepoRoot, Effect.service(Path.Path), (repoRoot, path) =>23const IconSource = Effect.zipWith(RepoRoot, Effect.service(Path.Path), (repoRoot, path) =>
24 path.join(repoRoot, "assets/macos-icon-1024.png"),24 path.join(repoRoot, "assets/macos-icon-1024.png"),
25);25);
26const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString);
2627
27interface PlatformConfig {28interface PlatformConfig {
28 readonly cliFlag: "--mac" | "--linux" | "--win";29 readonly cliFlag: "--mac" | "--linux" | "--win";
...@@ -290,9 +291,9 @@ function validateBundledClientAssets(clientDir: string) {...@@ -290,9 +291,9 @@ function validateBundledClientAssets(clientDir: string) {
290 const path = yield* Path.Path;291 const path = yield* Path.Path;
291 const indexPath = path.join(clientDir, "index.html");292 const indexPath = path.join(clientDir, "index.html");
292 const indexHtml = yield* fs.readFileString(indexPath);293 const indexHtml = yield* fs.readFileString(indexPath);
293 const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map(294 const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)]
294 (match) => match[1],295 .map((match) => match[1])
295 );296 .filter((value): value is string => value !== undefined);
296 const missing: string[] = [];297 const missing: string[] = [];
297298
298 for (const ref of refs) {299 for (const ref of refs) {
...@@ -505,10 +506,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -505,10 +506,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
505 },506 },
506 };507 };
507508
508 yield* fs.writeFileString(509 const stagePackageJsonString = yield* encodeJsonString(stagePackageJson);
509 path.join(stageAppDir, "package.json"),510 yield* fs.writeFileString(path.join(stageAppDir, "package.json"), `${stagePackageJsonString}\n`);
510 `${JSON.stringify(stagePackageJson, null, 2)}\n`,
511 );
512511
513 yield* Effect.log("[desktop-artifact] Installing staged production dependencies...");512 yield* Effect.log("[desktop-artifact] Installing staged production dependencies...");
514 yield* runCommand(513 yield* runCommand(
...@@ -626,9 +625,10 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", {...@@ -626,9 +625,10 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", {
626 Command.withHandler((input) => Effect.flatMap(resolveBuildOptions(input), buildDesktopArtifact)),625 Command.withHandler((input) => Effect.flatMap(resolveBuildOptions(input), buildDesktopArtifact)),
627);626);
628627
628const cliRuntimeLayer = Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer);
629
629Command.run(buildDesktopArtifactCli, { version: "0.0.0" }).pipe(630Command.run(buildDesktopArtifactCli, { version: "0.0.0" }).pipe(
630 Effect.scoped,631 Effect.scoped,
631 Effect.provide(Logger.layer([Logger.consolePretty()])),632 Effect.provide(cliRuntimeLayer),
632 Effect.provide(NodeServices.layer),
633 NodeRuntime.runMain,633 NodeRuntime.runMain,
634);634);
scripts/dev-runner.mjs deleted-425
...@@ -1,425 +0,0 @@
1import { spawn } from "node:child_process";
2import net from "node:net";
3import { homedir } from "node:os";
4import path from "node:path";
5import { pathToFileURL } from "node:url";
6
7const BASE_SERVER_PORT = 3773;
8const BASE_WEB_PORT = 5733;
9const MAX_HASH_OFFSET = 3000;
10const MAX_PORT = 65535;
11export const DEFAULT_DEV_STATE_DIR = path.join(homedir(), ".t3", "dev");
12const 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};
26const 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
42function 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
50function 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
59export 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
82function 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
95function 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
102export 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
156function 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
171export 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
214function portPairForOffset(offset) {
215 return {
216 serverPort: BASE_SERVER_PORT + offset,
217 webPort: BASE_WEB_PORT + offset,
218 };
219}
220
221async 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
243async 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
253const defaultCheckPortAvailability = isPortAvailable;
254
255export 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
296export 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
340export 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
418const entrypointPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
419
420if (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,223 +1,225 @@
1import { describe, expect, it } from "vitest";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import { Effect } from "effect";
24
3import {5import {
4 DEFAULT_DEV_STATE_DIR,6 DEFAULT_DEV_STATE_DIR,
5 createDevRunnerEnv,7 createDevRunnerEnv,
6 findFirstAvailableOffset,8 findFirstAvailableOffset,
7 parseDevRunnerArgs,
8 resolveModePortOffsets,9 resolveModePortOffsets,
9} from "./dev-runner.mjs";10} from "./dev-runner.ts";
1011
11describe("parseDevRunnerArgs", () => {12it.layer(NodeServices.layer)("dev-runner", (it) => {
12 it("maps supported server flags to env overrides and keeps other flags for turbo", () => {13 it.effect("defaults state dir to ~/.t3/dev when not provided", () =>
13 const parsed = parseDevRunnerArgs([14 Effect.gen(function* () {
14 "--",15 const env = yield* createDevRunnerEnv({
15 "--state-dir",16 mode: "dev",
16 "~/alt-state",17 env: {},
17 "--token=abc123",18 offset: 0,
18 "--no-browser=false",19 envOverrides: {},
19 "--dry-run",20 });
20 "--continue",21 const defaultStateDir = yield* DEFAULT_DEV_STATE_DIR;
21 ]);22
2223 assert.equal(env.T3CODE_STATE_DIR, defaultStateDir);
23 expect(parsed.isDryRun).toBe(true);24 }),
24 expect(parsed.envOverrides).toEqual({25 );
25 T3CODE_STATE_DIR: "~/alt-state",26
26 T3CODE_AUTH_TOKEN: "abc123",27 it.effect("uses existing env state dir when --state-dir is not provided", () =>
27 T3CODE_NO_BROWSER: "0",28 Effect.gen(function* () {
28 });29 const env = yield* createDevRunnerEnv({
29 expect(parsed.turboArgs).toEqual(["--continue"]);30 mode: "dev:server",
30 });31 env: { T3CODE_STATE_DIR: "/tmp/existing-state" },
3132 offset: 0,
32 it("throws when a value flag is missing its value", () => {33 envOverrides: {},
33 expect(() => parseDevRunnerArgs(["--state-dir"])).toThrow("Missing value for --state-dir");34 });
34 });35
3536 assert.equal(env.T3CODE_STATE_DIR, "/tmp/existing-state");
36 it("throws when a value flag is followed by another flag token", () => {37 }),
37 expect(() => parseDevRunnerArgs(["--state-dir", "--continue"])).toThrow(38 );
38 "Missing value for --state-dir",39
39 );40 it.effect("lets --state-dir override existing env state dir", () =>
40 });41 Effect.gen(function* () {
41});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 );
4265
43describe("createDevRunnerEnv", () => {66 it.effect("recomputes websocket url when port is overridden", () =>
44 it("defaults state dir to ~/.t3/dev when not provided", () => {67 Effect.gen(function* () {
45 const env = createDevRunnerEnv({68 const env = yield* 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({
120 mode: "dev",69 mode: "dev",
121 env: {},70 env: {},
122 offset: 0,71 offset: 0,
123 envOverrides: { T3CODE_PORT: "not-a-port" },72 envOverrides: { T3CODE_PORT: "4222" },
124 }),73 });
125 ).toThrow("Invalid T3CODE_PORT override");74
126 });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});
128114
129describe("findFirstAvailableOffset", () => {115it.layer(NodeServices.layer)("findFirstAvailableOffset", (it) => {
130 it("returns the starting offset when required ports are available", async () => {116 it.effect("returns the starting offset when required ports are available", () =>
131 const offset = await findFirstAvailableOffset({117 Effect.gen(function* () {
132 startOffset: 0,118 const offset = yield* findFirstAvailableOffset({
133 requireServerPort: true,119 startOffset: 0,
134 requireWebPort: true,120 requireServerPort: true,
135 checkPortAvailability: async () => true,121 requireWebPort: true,
136 });122 checkPortAvailability: () => Effect.succeed(true),
137123 });
138 expect(offset).toBe(0);124
139 });125 assert.equal(offset, 0);
140126 }),
141 it("advances until all required ports are available", async () => {127 );
142 const taken = new Set([3773, 5733, 3774, 5734]);128
143 const offset = await findFirstAvailableOffset({129 it.effect("advances until all required ports are available", () =>
144 startOffset: 0,130 Effect.gen(function* () {
145 requireServerPort: true,131 const taken = new Set([3773, 5733, 3774, 5734]);
146 requireWebPort: true,132 const offset = yield* findFirstAvailableOffset({
147 checkPortAvailability: async (port) => !taken.has(port),133 startOffset: 0,
148 });134 requireServerPort: true,
149135 requireWebPort: true,
150 expect(offset).toBe(2);136 checkPortAvailability: (port) => Effect.succeed(!taken.has(port)),
151 });137 });
152138
153 it("allows offsets where only non-required ports exceed max", async () => {139 assert.equal(offset, 2);
154 const offset = await findFirstAvailableOffset({140 }),
155 startOffset: 59_803,141 );
156 requireServerPort: true,142
157 requireWebPort: false,143 it.effect("allows offsets where only non-required ports exceed max", () =>
158 checkPortAvailability: async () => true,144 Effect.gen(function* () {
159 });145 const offset = yield* findFirstAvailableOffset({
160146 startOffset: 59_803,
161 expect(offset).toBe(59_803);147 requireServerPort: true,
162 });148 requireWebPort: false,
149 checkPortAvailability: () => Effect.succeed(true),
150 });
151
152 assert.equal(offset, 59_803);
153 }),
154 );
163});155});
164156
165describe("resolveModePortOffsets", () => {157it.layer(NodeServices.layer)("resolveModePortOffsets", (it) => {
166 it("uses a shared fallback offset for dev mode", async () => {158 it.effect("uses a shared fallback offset for dev mode", () =>
167 const taken = new Set([3773, 5733]);159 Effect.gen(function* () {
168 const offsets = await resolveModePortOffsets({160 const taken = new Set([3773, 5733]);
169 mode: "dev",161 const offsets = yield* resolveModePortOffsets({
170 startOffset: 0,162 mode: "dev",
171 envOverrides: {},163 startOffset: 0,
172 checkPortAvailability: async (port) => !taken.has(port),164 envOverrides: {},
173 });165 checkPortAvailability: (port) => Effect.succeed(!taken.has(port)),
174166 });
175 expect(offsets).toEqual({ serverOffset: 1, webOffset: 1 });167
176 });168 assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1 });
177169 }),
178 it("keeps server offset stable for dev:web and only shifts web offset", async () => {170 );
179 const taken = new Set([5733]);171
180 const offsets = await resolveModePortOffsets({172 it.effect("keeps server offset stable for dev:web and only shifts web offset", () =>
181 mode: "dev:web",173 Effect.gen(function* () {
182 startOffset: 0,174 const taken = new Set([5733]);
183 envOverrides: {},175 const offsets = yield* resolveModePortOffsets({
184 checkPortAvailability: async (port) => !taken.has(port),176 mode: "dev:web",
185 });177 startOffset: 0,
186178 envOverrides: {},
187 expect(offsets).toEqual({ serverOffset: 0, webOffset: 1 });179 checkPortAvailability: (port) => Effect.succeed(!taken.has(port)),
188 });180 });
189181
190 it("shifts only server offset for dev:server", async () => {182 assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 1 });
191 const taken = new Set([3773]);183 }),
192 const offsets = await resolveModePortOffsets({184 );
193 mode: "dev:server",185
194 startOffset: 0,186 it.effect("shifts only server offset for dev:server", () =>
195 envOverrides: {},187 Effect.gen(function* () {
196 checkPortAvailability: async (port) => !taken.has(port),188 const taken = new Set([3773]);
197 });189 const offsets = yield* resolveModePortOffsets({
198190 mode: "dev:server",
199 expect(offsets).toEqual({ serverOffset: 1, webOffset: 1 });191 startOffset: 0,
200 });192 envOverrides: {},
201193 checkPortAvailability: (port) => Effect.succeed(!taken.has(port)),
202 it("respects explicit dev-url override for dev:web", async () => {194 });
203 const offsets = await resolveModePortOffsets({195
204 mode: "dev:web",196 assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1 });
205 startOffset: 0,197 }),
206 envOverrides: { VITE_DEV_SERVER_URL: "http://localhost:9999" },198 );
207 checkPortAvailability: async () => false,199
208 });200 it.effect("respects explicit dev-url override for dev:web", () =>
209201 Effect.gen(function* () {
210 expect(offsets).toEqual({ serverOffset: 0, webOffset: 0 });202 const offsets = yield* resolveModePortOffsets({
211 });203 mode: "dev:web",
212204 startOffset: 0,
213 it("respects explicit server port override for dev:server", async () => {205 envOverrides: { VITE_DEV_SERVER_URL: "http://localhost:9999" },
214 const offsets = await resolveModePortOffsets({206 checkPortAvailability: () => Effect.succeed(false),
215 mode: "dev:server",207 });
216 startOffset: 0,208
217 envOverrides: { T3CODE_PORT: "4888" },209 assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 0 });
218 checkPortAvailability: async () => false,210 }),
219 });211 );
220212
221 expect(offsets).toEqual({ serverOffset: 0, webOffset: 0 });213 it.effect("respects explicit server port override for dev:server", () =>
222 });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
3import { homedir } from "node:os";
4
5import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { NetService } from "@t3tools/shared/Net";
8import { Data, Effect, Layer, Logger, Option, Path } from "effect";
9import { Argument, Command, Flag } from "effect/unstable/cli";
10import { ChildProcess } from "effect/unstable/process";
11
12const BASE_SERVER_PORT = 3773;
13const BASE_WEB_PORT = 5733;
14const MAX_HASH_OFFSET = 3000;
15const MAX_PORT = 65535;
16
17export 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
23const 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
38type DevMode = keyof typeof MODE_ARGS;
39type EnvOverrides = Record<string, string>;
40type PortAvailabilityCheck<R = never> = (port: number) => Effect.Effect<boolean, never, R>;
41
42const DEV_RUNNER_MODES = Object.keys(MODE_ARGS) as Array<DevMode>;
43
44class DevRunnerError extends Data.TaggedError("DevRunnerError")<{
45 readonly message: string;
46 readonly cause?: unknown;
47}> {}
48
49function 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
57function 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
66export 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
92function 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
99function 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
120interface CreateDevRunnerEnvInput {
121 readonly mode: DevMode;
122 readonly env: NodeJS.ProcessEnv;
123 readonly offset: number;
124 readonly envOverrides: EnvOverrides;
125}
126
127export 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
187function 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
194const defaultCheckPortAvailability: PortAvailabilityCheck<NetService> = (port) =>
195 Effect.gen(function* () {
196 const net = yield* NetService;
197 return yield* net.isPortAvailableOnLoopback(port);
198 });
199
200interface FindFirstAvailableOffsetInput<R = NetService> {
201 readonly startOffset: number;
202 readonly requireServerPort: boolean;
203 readonly requireWebPort: boolean;
204 readonly checkPortAvailability?: PortAvailabilityCheck<R>;
205}
206
207export 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
251interface ResolveModePortOffsetsInput<R = NetService> {
252 readonly mode: DevMode;
253 readonly startOffset: number;
254 readonly envOverrides: EnvOverrides;
255 readonly checkPortAvailability?: PortAvailabilityCheck<R>;
256}
257
258export 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
309const optionValue = <A>(option: Option.Option<A>): A | undefined =>
310 Option.isSome(option) ? option.value : undefined;
311
312interface 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
326function 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
364export 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
453const 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
504export 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
517const runtimeLayer = Layer.mergeAll(
518 Logger.layer([Logger.consolePretty()]),
519 NodeServices.layer,
520 NetService.layer,
521);
522
523const runtimeProgram = runDevRunner().pipe(Effect.scoped, Effect.provide(runtimeLayer));
524
525if (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}