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 @@
1414 "smoke-test": "node scripts/smoke-test.mjs"
1515 },
1616 "dependencies": {
17 "@t3tools/shared": "workspace:*",
1718 "electron": "40.6.0"
1819 },
1920 "devDependencies": {
2021 "@t3tools/contracts": "workspace:*",
21 "@types/node": "^24.10.13",
22 "@types/node": "catalog:",
2223 "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:",
2627 "wait-on": "^8.0.2"
2728 },
2829 "productName": "T3 Code (Alpha)"
apps/desktop/src/main.ts+8-20
......@@ -1,14 +1,15 @@
11import { spawn, type ChildProcess } from "node:child_process";
22import { randomBytes } from "node:crypto";
33import fs from "node:fs";
4import net from "node:net";
54import os from "node:os";
65import path from "node:path";
76
87import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron";
98import type { MenuItemConstructorOptions } from "electron";
9import { Effect } from "effect";
1010
1111import type { ContextMenuItem } from "@t3tools/contracts";
12import { NetService } from "@t3tools/shared/Net";
1213import { showDesktopConfirmDialog } from "./confirmDialog";
1314import { fixPath } from "./fixPath";
1415
......@@ -347,24 +348,6 @@ function configureAppIdentity(): void {
347348 }
348349}
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
368351function backendEnv(): NodeJS.ProcessEnv {
369352 return {
370353 ...process.env,
......@@ -621,7 +604,12 @@ function createWindow(): BrowserWindow {
621604configureAppIdentity();
622605
623606async 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 );
625613 backendAuthToken = randomBytes(24).toString("hex");
626614 backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`;
627615 process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl;
apps/server/package.json+6-6
......@@ -26,17 +26,17 @@
2626 "ws": "^8.18.0"
2727 },
2828 "devDependencies": {
29 "@effect/language-service": "0.75.1",
29 "@effect/language-service": "catalog:",
3030 "@effect/vitest": "catalog:",
3131 "@t3tools/contracts": "workspace:*",
3232 "@t3tools/shared": "workspace:*",
3333 "@t3tools/web": "workspace:*",
34 "@types/bun": "^1.3.9",
35 "@types/node": "^24.10.13",
34 "@types/bun": "catalog:",
35 "@types/node": "catalog:",
3636 "@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:"
4040 },
4141 "engines": {
4242 "node": "^22.13 || ^23.4 || >=24.10"
apps/server/src/config.ts+2-65
......@@ -1,13 +1,12 @@
11/**
2 * ServerConfig and NetService - Runtime configuration services.
2 * ServerConfig - Runtime configuration services.
33 *
44 * Defines process-level server configuration and networking helpers used by
55 * startup and runtime layers.
66 *
77 * @module ServerConfig
88 */
9import * as Net from "node:net";
10import { Data, Effect, FileSystem, Layer, Path, ServiceMap } from "effect";
9import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect";
1110
1211export const DEFAULT_PORT = 3773;
1312
......@@ -60,68 +59,6 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS
6059 );
6160}
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
12562export const resolveStaticDir = Effect.fn(function* () {
12663 const { join, resolve } = yield* Path.Path;
12764 const { exists } = yield* FileSystem.FileSystem;
apps/server/src/index.ts+1-1
......@@ -8,7 +8,7 @@ import { OpenLive } from "./open";
88import { Command } from "effect/unstable/cli";
99import { version } from "../package.json" with { type: "json" };
1010import { ServerLive } from "./wsServer";
11import { NetService } from "./config";
11import { NetService } from "@t3tools/shared/Net";
1212
1313const RuntimeLayer = Layer.empty.pipe(
1414 Layer.provideMerge(CliConfig.layer),
apps/server/src/main.test.ts+5-1
......@@ -6,9 +6,10 @@ import * as Effect from "effect/Effect";
66import * as Layer from "effect/Layer";
77import * as Command from "effect/unstable/cli/Command";
88import { beforeEach } from "vitest";
9import { NetService } from "@t3tools/shared/Net";
910
1011import { CliConfig, t3Cli, type CliConfigShape } from "./main";
11import { NetService, ServerConfig, type ServerConfigShape } from "./config";
12import { ServerConfig, type ServerConfigShape } from "./config";
1213import { Open, type OpenShape } from "./open";
1314import { Server, type ServerShape } from "./wsServer";
1415
......@@ -33,6 +34,9 @@ const testLayer = Layer.mergeAll(
3334 resolveStaticDir: Effect.undefined,
3435 } satisfies CliConfigShape),
3536 Layer.succeed(NetService, {
37 canListenOnHost: () => Effect.succeed(true),
38 isPortAvailableOnLoopback: () => Effect.succeed(true),
39 reserveLoopbackPort: () => Effect.succeed(0),
3640 findAvailablePort,
3741 }),
3842 Layer.succeed(Server, {
apps/server/src/main.ts+1-1
......@@ -8,10 +8,10 @@
88 */
99import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect";
1010import { Command, Flag } from "effect/unstable/cli";
11import { NetService } from "@t3tools/shared/Net";
1112
1213import {
1314 DEFAULT_PORT,
14 NetService,
1515 resolveStaticDir,
1616 ServerConfig,
1717 type RuntimeMode,
apps/web/package.json+1-1
......@@ -43,7 +43,7 @@
4343 "@vitejs/plugin-react": "^5.1.4",
4444 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
4545 "tailwindcss": "^4.0.0",
46 "typescript": "^5.7.3",
46 "typescript": "catalog:",
4747 "vite": "^8.0.0-beta.12"
4848 }
4949}
bun.lock+42-22
......@@ -5,29 +5,26 @@
55 "": {
66 "name": "@t3tools/monorepo",
77 "devDependencies": {
8 "@effect/platform-node": "catalog:",
9 "@types/node": "^24.10.13",
10 "effect": "catalog:",
8 "@types/node": "catalog:",
119 "oxfmt": "^0.35.0",
1210 "oxlint": "^1.50.0",
1311 "turbo": "^2.3.3",
14 "typescript": "^5.7.3",
15 "vitest": "^4.0.0",
1612 },
1713 },
1814 "apps/desktop": {
1915 "name": "@t3tools/desktop",
2016 "version": "0.0.0-alpha.12",
2117 "dependencies": {
18 "@t3tools/shared": "workspace:*",
2219 "electron": "40.6.0",
2320 },
2421 "devDependencies": {
2522 "@t3tools/contracts": "workspace:*",
26 "@types/node": "^24.10.13",
23 "@types/node": "catalog:",
2724 "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:",
3128 "wait-on": "^8.0.2",
3229 },
3330 },
......@@ -47,17 +44,17 @@
4744 "ws": "^8.18.0",
4845 },
4946 "devDependencies": {
50 "@effect/language-service": "0.75.1",
47 "@effect/language-service": "catalog:",
5148 "@effect/vitest": "catalog:",
5249 "@t3tools/contracts": "workspace:*",
5350 "@t3tools/shared": "workspace:*",
5451 "@t3tools/web": "workspace:*",
55 "@types/bun": "^1.3.9",
56 "@types/node": "^24.10.13",
52 "@types/bun": "catalog:",
53 "@types/node": "catalog:",
5754 "@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:",
6158 },
6259 },
6360 "apps/web": {
......@@ -95,7 +92,7 @@
9592 "@vitejs/plugin-react": "^5.1.4",
9693 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
9794 "tailwindcss": "^4.0.0",
98 "typescript": "^5.7.3",
95 "typescript": "catalog:",
9996 "vite": "^8.0.0-beta.12",
10097 },
10198 },
......@@ -106,10 +103,10 @@
106103 "effect": "catalog:",
107104 },
108105 "devDependencies": {
109 "@effect/language-service": "0.75.1",
106 "@effect/language-service": "catalog:",
110107 "@effect/vitest": "catalog:",
111 "tsdown": "^0.20.3",
112 "typescript": "^5.7.3",
108 "tsdown": "catalog:",
109 "typescript": "catalog:",
113110 },
114111 },
115112 "packages/shared": {
......@@ -119,10 +116,26 @@
119116 "effect": "catalog:",
120117 },
121118 "devDependencies": {
122 "@effect/language-service": "0.75.1",
119 "@effect/language-service": "catalog:",
123120 "@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:",
126139 },
127140 },
128141 },
......@@ -131,7 +144,12 @@
131144 "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b",
132145 "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b",
133146 "@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",
134149 "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",
135153 },
136154 "packages": {
137155 "@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 @@
552570
553571 "@t3tools/desktop": ["@t3tools/desktop@workspace:apps/desktop"],
554572
573 "@t3tools/scripts": ["@t3tools/scripts@workspace:scripts"],
574
555575 "@t3tools/shared": ["@t3tools/shared@workspace:packages/shared"],
556576
557577 "@t3tools/web": ["@t3tools/web@workspace:apps/web"],
package.json+14-12
......@@ -4,22 +4,28 @@
44 "workspaces": {
55 "packages": [
66 "apps/*",
7 "packages/*"
7 "packages/*",
8 "scripts"
89 ],
910 "catalog": {
1011 "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b",
1112 "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b",
1213 "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b",
1314 "@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"
1521 }
1622 },
1723 "type": "module",
1824 "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",
2329 "start": "turbo run start --filter=t3",
2430 "start:desktop": "turbo run start --filter=@t3tools/desktop",
2531 "build": "turbo run build",
......@@ -40,14 +46,10 @@
4046 "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs"
4147 },
4248 "devDependencies": {
43 "@effect/platform-node": "catalog:",
44 "@types/node": "^24.10.13",
45 "effect": "catalog:",
49 "@types/node": "catalog:",
4650 "oxfmt": "^0.35.0",
4751 "oxlint": "^1.50.0",
48 "turbo": "^2.3.3",
49 "typescript": "^5.7.3",
50 "vitest": "^4.0.0"
52 "turbo": "^2.3.3"
5153 },
5254 "engines": {
5355 "bun": "^1.3.9",
packages/contracts/package.json+3-3
......@@ -27,9 +27,9 @@
2727 "effect": "catalog:"
2828 },
2929 "devDependencies": {
30 "@effect/language-service": "0.75.1",
30 "@effect/language-service": "catalog:",
3131 "@effect/vitest": "catalog:",
32 "tsdown": "^0.20.3",
33 "typescript": "^5.7.3"
32 "tsdown": "catalog:",
33 "typescript": "catalog:"
3434 }
3535}
packages/shared/package.json+9-4
......@@ -7,20 +7,25 @@
77 "./git": {
88 "types": "./src/git.ts",
99 "import": "./src/git.ts"
10 },
11 "./Net": {
12 "types": "./src/Net.ts",
13 "import": "./src/Net.ts"
1014 }
1115 },
1216 "scripts": {
1317 "prepare": "effect-language-service patch",
1418 "typecheck": "tsc --noEmit",
15 "test": "vitest run --passWithNoTests"
19 "test": "vitest run"
1620 },
1721 "dependencies": {
1822 "effect": "catalog:"
1923 },
2024 "devDependencies": {
21 "@effect/language-service": "0.75.1",
25 "@effect/language-service": "catalog:",
2226 "@effect/vitest": "catalog:",
23 "typescript": "^5.7.3",
24 "vitest": "^4.0.0"
27 "@types/node": "catalog:",
28 "typescript": "catalog:",
29 "vitest": "catalog:"
2530 }
2631}
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";
1010
1111import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
1212import * 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";
1414import { Command, Flag } from "effect/unstable/cli";
1515import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1616
......@@ -23,6 +23,7 @@ const RepoRoot = Effect.service(Path.Path).pipe(
2323const IconSource = Effect.zipWith(RepoRoot, Effect.service(Path.Path), (repoRoot, path) =>
2424 path.join(repoRoot, "assets/macos-icon-1024.png"),
2525);
26const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString);
2627
2728interface PlatformConfig {
2829 readonly cliFlag: "--mac" | "--linux" | "--win";
......@@ -290,9 +291,9 @@ function validateBundledClientAssets(clientDir: string) {
290291 const path = yield* Path.Path;
291292 const indexPath = path.join(clientDir, "index.html");
292293 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);
296297 const missing: string[] = [];
297298
298299 for (const ref of refs) {
......@@ -505,10 +506,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
505506 },
506507 };
507508
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`);
512511
513512 yield* Effect.log("[desktop-artifact] Installing staged production dependencies...");
514513 yield* runCommand(
......@@ -626,9 +625,10 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", {
626625 Command.withHandler((input) => Effect.flatMap(resolveBuildOptions(input), buildDesktopArtifact)),
627626);
628627
628const cliRuntimeLayer = Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer);
629
629630Command.run(buildDesktopArtifactCli, { version: "0.0.0" }).pipe(
630631 Effect.scoped,
631 Effect.provide(Logger.layer([Logger.consolePretty()])),
632 Effect.provide(NodeServices.layer),
632 Effect.provide(cliRuntimeLayer),
633633 NodeRuntime.runMain,
634634);
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 @@
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
35import {
46 DEFAULT_DEV_STATE_DIR,
57 createDevRunnerEnv,
68 findFirstAvailableOffset,
7 parseDevRunnerArgs,
89 resolveModePortOffsets,
9} from "./dev-runner.mjs";
10
11describe("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
12it.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 );
4265
43describe("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({
12069 mode: "dev",
12170 env: {},
12271 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 );
127113});
128114
129describe("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 });
115it.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 );
163155});
164156
165describe("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 });
157it.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 );
223225});
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}