History

Add bootstrap fd for desktop auth token

- Pass one-shot startup config over inherited fd instead of env - Wire server bootstrap parsing and desktop IPC for websocket URL - Add tests for bootstrap envelope precedence and timeout handling

Julius Marminge committed 7 months ago

Commit 20c05641 parent a542a3b7 files · +488 −59Browse files

Unified · Split

7 files
  1. REMOTE.md +12 −9
  2. apps/desktop/src/main.ts +31 −17
  3. apps/desktop/src/preload.ts +5 −2
  4. apps/server/src/bootstrap.test.ts +88 −0
  5. apps/server/src/bootstrap.ts +121 −0
  6. apps/server/src/main.test.ts +94 −0
  7. apps/server/src/main.ts +137 −31
REMOTE.md+12−9
@@ -6,21 +6,24 @@ Use this when you want to open T3 Code from another device (phone, tablet, anoth · 5 unmodified lines
6 6
7The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables:7The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables:
8 8
9| CLI flag | Env var | Notes |9| CLI flag | Env var | Notes |
10| ----------------------- | --------------------- | ---------------------------------- |10| ----------------------- | --------------------- | ------------------------------------------------------------------------------------ |
11| `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. |11| `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. |
12| `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. |12| `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. |
13| `--host <address>` | `T3CODE_HOST` | Bind interface/address. |13| `--host <address>` | `T3CODE_HOST` | Bind interface/address. |
14| `--base-dir <path>` | `T3CODE_HOME` | Base directory. |14| `--base-dir <path>` | `T3CODE_HOME` | Base directory. |
15| `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. |15| `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. |
16| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. |16| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. |
17| `--auth-token <token>` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. |17| `--auth-token <token>` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. Use this for standard CLI and remote-server flows. |
18| `--bootstrap-fd <fd>` | `T3CODE_BOOTSTRAP_FD` | Read a one-shot bootstrap envelope from an inherited file descriptor during startup. |
18 19
19> TIP: Use the `--help` flag to see all available options and their descriptions.20> TIP: Use the `--help` flag to see all available options and their descriptions.
20 21
21## Security First22## Security First
22 23
23- Always set `--auth-token` before exposing the server outside localhost.24- Always set `--auth-token` before exposing the server outside localhost.
25 - When you control the process launcher, prefer sending the auth token in a JSON envelope via `--bootstrap-fd <fd>`.
26 With `--bootstrap-fd <fd>`, the launcher starts the server first, then sends a one-shot JSON envelope over the inherited file descriptor. This allows the auth token to be delivered without putting it in process environment or command line arguments.
24- Treat the token like a password.27- Treat the token like a password.
25- Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed.28- Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed.
26 29
apps/desktop/src/main.ts+31−17
@@ -56,6 +56,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; · 55 unmodified lines
56const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";56const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
57const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";57const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
58const UPDATE_INSTALL_CHANNEL = "desktop:update-install";58const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
59const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
59const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");60const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
60const STATE_DIR = Path.join(BASE_DIR, "userdata");61const STATE_DIR = Path.join(BASE_DIR, "userdata");
61const DESKTOP_SCHEME = "t3";62const DESKTOP_SCHEME = "t3";
@@ -918,17 +919,6 @@ function configureAutoUpdater(): void { · 856 unmodified lines
918 }, AUTO_UPDATE_POLL_INTERVAL_MS);919 }, AUTO_UPDATE_POLL_INTERVAL_MS);
919 updatePollTimer.unref();920 updatePollTimer.unref();
920}921}
921function backendEnv(): NodeJS.ProcessEnv {
922 return {
923 ...process.env,
924 T3CODE_MODE: "desktop",
925 T3CODE_NO_BROWSER: "1",
926 T3CODE_PORT: String(backendPort),
927 T3CODE_HOME: BASE_DIR,
928 T3CODE_AUTH_TOKEN: backendAuthToken,
929 };
930}
931
932function scheduleBackendRestart(reason: string): void {922function scheduleBackendRestart(reason: string): void {
933 if (isQuitting || restartTimer) return;923 if (isQuitting || restartTimer) return;
934 924
@@ -952,16 +942,35 @@ function startBackend(): void { · 17 unmodified lines
952 }942 }
953 943
954 const captureBackendLogs = app.isPackaged && backendLogSink !== null;944 const captureBackendLogs = app.isPackaged && backendLogSink !== null;
955 const child = ChildProcess.spawn(process.execPath, [backendEntry], {945 const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], {
956 cwd: resolveBackendCwd(),946 cwd: resolveBackendCwd(),
957 // In Electron main, process.execPath points to the Electron binary.947 // In Electron main, process.execPath points to the Electron binary.
958 // Run the child in Node mode so this backend process does not become a GUI app instance.948 // Run the child in Node mode so this backend process does not become a GUI app instance.
959 env: {949 env: {
960 ...backendEnv(),950 ...process.env,
961 ELECTRON_RUN_AS_NODE: "1",951 ELECTRON_RUN_AS_NODE: "1",
962 },952 },
963 stdio: captureBackendLogs ? ["ignore", "pipe", "pipe"] : "inherit",953 stdio: captureBackendLogs
954 ? ["ignore", "pipe", "pipe", "pipe"]
955 : ["ignore", "inherit", "inherit", "pipe"],
964 });956 });
957 const bootstrapStream = child.stdio[3];
958 if (bootstrapStream && "write" in bootstrapStream) {
959 bootstrapStream.write(
960 `${JSON.stringify({
961 mode: "desktop",
962 noBrowser: true,
963 port: backendPort,
964 t3Home: BASE_DIR,
965 authToken: backendAuthToken,
966 })}\n`,
967 );
968 bootstrapStream.end();
969 } else {
970 child.kill("SIGTERM");
971 scheduleBackendRestart("missing desktop bootstrap pipe");
972 return;
973 }
965 backendProcess = child;974 backendProcess = child;
966 let backendSessionClosed = false;975 let backendSessionClosed = false;
967 const closeBackendSession = (details: string) => {976 const closeBackendSession = (details: string) => {
@@ -1072,6 +1081,11 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> { · 104 unmodified lines
1072}1081}
1073 1082
1074function registerIpcHandlers(): void {1083function registerIpcHandlers(): void {
1084 ipcMain.removeAllListeners(GET_WS_URL_CHANNEL);
1085 ipcMain.on(GET_WS_URL_CHANNEL, (event) => {
1086 event.returnValue = backendWsUrl;
1087 });
1088
1075 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);1089 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
1076 ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {1090 ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
1077 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;1091 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
@@ -1320,9 +1334,9 @@ async function bootstrap(): Promise<void> { · 242 unmodified lines
1320 );1334 );
1321 writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`);1335 writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`);
1322 backendAuthToken = Crypto.randomBytes(24).toString("hex");1336 backendAuthToken = Crypto.randomBytes(24).toString("hex");
1323 backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`;1337 const baseUrl = `ws://127.0.0.1:${backendPort}`;
1324 process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl;1338 backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`;
1325 writeDesktopLogHeader(`bootstrap resolved websocket url=${backendWsUrl}`);1339 writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`);
1326 1340
1327 registerIpcHandlers();1341 registerIpcHandlers();
1328 writeDesktopLogHeader("bootstrap ipc handlers registered");1342 writeDesktopLogHeader("bootstrap ipc handlers registered");
apps/desktop/src/preload.ts+5−2
@@ -11,10 +11,13 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; · 10 unmodified lines
11const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";11const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
12const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";12const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
13const UPDATE_INSTALL_CHANNEL = "desktop:update-install";13const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
14const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;14const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
15 15
16contextBridge.exposeInMainWorld("desktopBridge", {16contextBridge.exposeInMainWorld("desktopBridge", {
17 getWsUrl: () => wsUrl,17 getWsUrl: () => {
18 const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL);
19 return typeof result === "string" ? result : null;
20 },
18 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),21 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
19 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),22 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
20 setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),23 setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
apps/server/src/bootstrap.test.ts+88−0
@@ -0,0 +1,88 @@
1import * as NFS from "node:fs";
2import * as path from "node:path";
3import { execFileSync, spawn } from "node:child_process";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { it } from "@effect/vitest";
6import { FileSystem, Schema } from "effect";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
10import { TestClock } from "effect/testing";
11
12import { readBootstrapEnvelope } from "./bootstrap";
13import { assertNone, assertSome } from "@effect/vitest/utils";
14
15const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String });
16
17it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => {
18 it.effect("reads a bootstrap envelope from a provided fd", () =>
19 Effect.gen(function* () {
20 const fs = yield* FileSystem.FileSystem;
21 const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
22
23 yield* fs.writeFileString(
24 filePath,
25 `${yield* Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema))({
26 mode: "desktop",
27 })}\n`,
28 );
29
30 const fd = yield* Effect.acquireRelease(
31 Effect.sync(() => NFS.openSync(filePath, "r")),
32 (fd) => Effect.sync(() => NFS.closeSync(fd)),
33 );
34
35 const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 });
36 assertSome(payload, {
37 mode: "desktop",
38 });
39 }),
40 );
41
42 it.effect("returns none when the fd is unavailable", () =>
43 Effect.gen(function* () {
44 const fd = NFS.openSync("/dev/null", "r");
45 NFS.closeSync(fd);
46
47 const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 });
48 assertNone(payload);
49 }),
50 );
51
52 it.effect("returns none when the bootstrap read times out before any value arrives", () =>
53 Effect.gen(function* () {
54 const fs = yield* FileSystem.FileSystem;
55 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bootstrap-" });
56 const fifoPath = path.join(tempDir, "bootstrap.pipe");
57
58 yield* Effect.sync(() => execFileSync("mkfifo", [fifoPath]));
59
60 const _writer = yield* Effect.acquireRelease(
61 Effect.sync(() =>
62 spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], {
63 stdio: ["ignore", "ignore", "ignore"],
64 }),
65 ),
66 (writer) =>
67 Effect.sync(() => {
68 writer.kill("SIGKILL");
69 }),
70 );
71
72 const fd = yield* Effect.acquireRelease(
73 Effect.sync(() => NFS.openSync(fifoPath, "r")),
74 (fd) => Effect.sync(() => NFS.closeSync(fd)),
75 );
76
77 const fiber = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, {
78 timeoutMs: 100,
79 }).pipe(Effect.forkScoped);
80
81 yield* Effect.yieldNow;
82 yield* TestClock.adjust(Duration.millis(100));
83
84 const payload = yield* Fiber.join(fiber);
85 assertNone(payload);
86 }).pipe(Effect.provide(TestClock.layer())),
87 );
88});
apps/server/src/bootstrap.ts+121−0
@@ -0,0 +1,121 @@
1import * as NFS from "node:fs";
2import * as readline from "node:readline";
3
4import { Data, Effect, Option, Result, Schema } from "effect";
5import { decodeJsonResult } from "@t3tools/shared/schemaJson";
6
7class BootstrapError extends Data.TaggedError("BootstrapError")<{
8 readonly message: string;
9 readonly cause?: unknown;
10}> {}
11
12export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function* <
13 S extends Schema.Codec<unknown, unknown, never, never>,
14>(
15 schema: S,
16 fd: number,
17 options?: {
18 timeoutMs?: number;
19 },
20): Effect.fn.Return<Option.Option<Schema.Schema.Type<S>>, BootstrapError> {
21 const fdReady = yield* Effect.try({
22 try: () => NFS.fstatSync(fd),
23 catch: (error) =>
24 new BootstrapError({
25 message: "Failed to stat bootstrap fd.",
26 cause: error,
27 }),
28 }).pipe(
29 Effect.as(true),
30 Effect.catchIf(
31 (error) => isUnavailableBootstrapFdError(error.cause),
32 () => Effect.succeed(false),
33 ),
34 );
35 if (!fdReady) return Option.none();
36
37 const streamFd = yield* Effect.try({
38 try: () => NFS.openSync(resolveFdPath(fd), "r"),
39 catch: (error) =>
40 new BootstrapError({
41 message: "Failed to duplicate bootstrap fd.",
42 cause: error,
43 }),
44 });
45
46 const stream = NFS.createReadStream("", {
47 fd: streamFd,
48 encoding: "utf8",
49 autoClose: true,
50 });
51
52 const timeoutMs = options?.timeoutMs ?? 1000;
53
54 return yield* Effect.callback<Option.Option<Schema.Schema.Type<S>>, BootstrapError>((resume) => {
55 const input = readline.createInterface({
56 input: stream,
57 crlfDelay: Infinity,
58 });
59
60 const cleanup = () => {
61 stream.removeListener("error", handleError);
62 input.removeListener("line", handleLine);
63 input.removeListener("close", handleClose);
64 input.close();
65 };
66
67 const handleError = (error: Error) => {
68 if (isUnavailableBootstrapFdError(error)) {
69 resume(Effect.succeed(Option.none<Schema.Schema.Type<S>>()));
70 return;
71 }
72 resume(
73 Effect.fail(
74 new BootstrapError({
75 message: "Failed to read bootstrap envelope.",
76 cause: error,
77 }),
78 ),
79 );
80 };
81
82 const handleLine = (line: string) => {
83 const parsed = decodeJsonResult(schema)(line);
84 if (Result.isSuccess(parsed)) {
85 resume(Effect.succeed(Option.some(parsed.success as Schema.Schema.Type<S>)));
86 return;
87 }
88 resume(
89 Effect.fail(
90 new BootstrapError({
91 message: "Failed to decode bootstrap envelope.",
92 cause: parsed.failure,
93 }),
94 ),
95 );
96 };
97
98 const handleClose = () => {
99 resume(Effect.succeed(Option.none<Schema.Schema.Type<S>>()));
100 };
101
102 stream.once("error", handleError);
103 input.once("line", handleLine);
104 input.once("close", handleClose);
105
106 return Effect.sync(cleanup);
107 }).pipe(Effect.timeoutOption(timeoutMs), Effect.map(Option.flatten));
108});
109
110function isUnavailableBootstrapFdError(error: unknown): boolean {
111 return (
112 error instanceof Error && "code" in error && (error.code === "EBADF" || error.code === "ENOENT")
113 );
114}
115
116function resolveFdPath(fd: number): string {
117 if (process.platform === "linux") {
118 return `/proc/self/fd/${fd}`;
119 }
120 return `/dev/fd/${fd}`;
121}
apps/server/src/main.test.ts+94−0
@@ -4,6 +4,7 @@ import { assert, it, vi } from "@effect/vitest"; · 3 unmodified lines
4import type { OrchestrationReadModel } from "@t3tools/contracts";4import type { OrchestrationReadModel } from "@t3tools/contracts";
5import * as ConfigProvider from "effect/ConfigProvider";5import * as ConfigProvider from "effect/ConfigProvider";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
8import * as Command from "effect/unstable/cli/Command";9import * as Command from "effect/unstable/cli/Command";
9import { FetchHttpClient } from "effect/unstable/http";10import { FetchHttpClient } from "effect/unstable/http";
@@ -151,6 +152,99 @@ it.layer(testLayer)("server CLI command", (it) => { · 141 unmodified lines
151 }),152 }),
152 );153 );
153 154
155 const openBootstrapFd = Effect.fn(function* (payload: Record<string, unknown>) {
156 const fs = yield* FileSystem.FileSystem;
157 const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
158 yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`);
159 const { fd } = yield* fs.open(filePath, { flag: "r" });
160 return fd;
161 });
162
163 it.effect("recognizes bootstrap fd from environment config", () =>
164 Effect.gen(function* () {
165 const fd = yield* openBootstrapFd({ authToken: "bootstrap-token" });
166
167 yield* runCli([], {
168 T3CODE_MODE: "web",
169 T3CODE_BOOTSTRAP_FD: String(fd),
170 T3CODE_AUTH_TOKEN: "env-token",
171 T3CODE_NO_BROWSER: "true",
172 });
173
174 assert.equal(start.mock.calls.length, 1);
175 assert.equal(resolvedConfig?.mode, "web");
176 assert.equal(resolvedConfig?.authToken, "env-token");
177 }),
178 );
179
180 it.effect("uses bootstrap envelope values as fallbacks when CLI and env are absent", () =>
181 Effect.gen(function* () {
182 const fd = yield* openBootstrapFd({
183 mode: "desktop",
184 port: 4888,
185 host: "127.0.0.2",
186 t3Home: "/tmp/t3-bootstrap-home",
187 devUrl: "http://127.0.0.1:5173",
188 noBrowser: true,
189 authToken: "bootstrap-token",
190 autoBootstrapProjectFromCwd: false,
191 logWebSocketEvents: true,
192 });
193
194 yield* runCli([], {
195 T3CODE_BOOTSTRAP_FD: String(fd),
196 });
197
198 assert.equal(start.mock.calls.length, 1);
199 assert.equal(resolvedConfig?.mode, "desktop");
200 assert.equal(resolvedConfig?.port, 4888);
201 assert.equal(resolvedConfig?.host, "127.0.0.2");
202 assert.equal(resolvedConfig?.baseDir, "/tmp/t3-bootstrap-home");
203 assert.equal(resolvedConfig?.stateDir, "/tmp/t3-bootstrap-home/dev");
204 assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/");
205 assert.equal(resolvedConfig?.noBrowser, true);
206 assert.equal(resolvedConfig?.authToken, "bootstrap-token");
207 assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false);
208 assert.equal(resolvedConfig?.logWebSocketEvents, true);
209 }),
210 );
211
212 it.effect("applies CLI then env precedence over bootstrap envelope values", () =>
213 Effect.gen(function* () {
214 const fd = yield* openBootstrapFd({
215 mode: "desktop",
216 port: 4888,
217 host: "127.0.0.2",
218 t3Home: "/tmp/t3-bootstrap-home",
219 devUrl: "http://127.0.0.1:5173",
220 noBrowser: false,
221 authToken: "bootstrap-token",
222 autoBootstrapProjectFromCwd: false,
223 logWebSocketEvents: false,
224 });
225
226 yield* runCli(["--port", "4999", "--host", "0.0.0.0", "--auth-token", "cli-token"], {
227 T3CODE_MODE: "web",
228 T3CODE_BOOTSTRAP_FD: String(fd),
229 T3CODE_HOME: "/tmp/t3-env-home",
230 T3CODE_NO_BROWSER: "true",
231 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
232 T3CODE_LOG_WS_EVENTS: "true",
233 });
234
235 assert.equal(start.mock.calls.length, 1);
236 assert.equal(resolvedConfig?.mode, "web");
237 assert.equal(resolvedConfig?.port, 4999);
238 assert.equal(resolvedConfig?.host, "0.0.0.0");
239 assert.equal(resolvedConfig?.baseDir, "/tmp/t3-env-home");
240 assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/");
241 assert.equal(resolvedConfig?.noBrowser, true);
242 assert.equal(resolvedConfig?.authToken, "cli-token");
243 assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, true);
244 assert.equal(resolvedConfig?.logWebSocketEvents, true);
245 }),
246 );
247
154 it.effect("prefers --mode over T3CODE_MODE", () =>248 it.effect("prefers --mode over T3CODE_MODE", () =>
155 Effect.gen(function* () {249 Effect.gen(function* () {
156 findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666));250 findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666));
apps/server/src/main.ts+137−31
@@ -27,12 +27,27 @@ import { Server } from "./wsServer"; · 26 unmodified lines
27import { ServerLoggerLive } from "./serverLogger";27import { ServerLoggerLive } from "./serverLogger";
28import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";28import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";
29import { AnalyticsService } from "./telemetry/Services/AnalyticsService";29import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
30import { readBootstrapEnvelope } from "./bootstrap";
30 31
31export class StartupError extends Data.TaggedError("StartupError")<{32export class StartupError extends Data.TaggedError("StartupError")<{
32 readonly message: string;33 readonly message: string;
33 readonly cause?: unknown;34 readonly cause?: unknown;
34}> {}35}> {}
35 36
37const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
38
39const BootstrapEnvelopeSchema = Schema.Struct({
40 mode: Schema.optional(Schema.String),
41 port: Schema.optional(PortSchema),
42 host: Schema.optional(Schema.String),
43 t3Home: Schema.optional(Schema.String),
44 devUrl: Schema.optional(Schema.URLFromString),
45 noBrowser: Schema.optional(Schema.Boolean),
46 authToken: Schema.optional(Schema.String),
47 autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),
48 logWebSocketEvents: Schema.optional(Schema.Boolean),
49});
50
36interface CliInput {51interface CliInput {
37 readonly mode: Option.Option<RuntimeMode>;52 readonly mode: Option.Option<RuntimeMode>;
38 readonly port: Option.Option<number>;53 readonly port: Option.Option<number>;
@@ -41,6 +56,7 @@ interface CliInput { · 2 unmodified lines
41 readonly devUrl: Option.Option<URL>;56 readonly devUrl: Option.Option<URL>;
42 readonly noBrowser: Option.Option<boolean>;57 readonly noBrowser: Option.Option<boolean>;
43 readonly authToken: Option.Option<string>;58 readonly authToken: Option.Option<string>;
59 readonly bootstrapFd: Option.Option<number>;
44 readonly autoBootstrapProjectFromCwd: Option.Option<boolean>;60 readonly autoBootstrapProjectFromCwd: Option.Option<boolean>;
45 readonly logWebSocketEvents: Option.Option<boolean>;61 readonly logWebSocketEvents: Option.Option<boolean>;
46}62}
@@ -91,12 +107,8 @@ export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()( · 44 unmodified lines
91const CliEnvConfig = Config.all({107const CliEnvConfig = Config.all({
92 mode: Config.string("T3CODE_MODE").pipe(108 mode: Config.string("T3CODE_MODE").pipe(
93 Config.option,109 Config.option,
94 Config.map(110 Config.map(Option.map((value) => (value === "desktop" ? "desktop" : "web"))),
95 Option.match<RuntimeMode, string>({111 Config.map(Option.getOrUndefined),
96 onNone: () => "web",
97 onSome: (value) => (value === "desktop" ? "desktop" : "web"),
98 }),
99 ),
100 ),112 ),
101 port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)),113 port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)),
102 host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),114 host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),
@@ -110,6 +122,10 @@ const CliEnvConfig = Config.all({ · 7 unmodified lines
110 Config.option,122 Config.option,
111 Config.map(Option.getOrUndefined),123 Config.map(Option.getOrUndefined),
112 ),124 ),
125 bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe(
126 Config.option,
127 Config.map(Option.getOrUndefined),
128 ),
113 autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe(129 autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe(
114 Config.option,130 Config.option,
115 Config.map(Option.getOrUndefined),131 Config.map(Option.getOrUndefined),
@@ -123,6 +139,14 @@ const CliEnvConfig = Config.all({ · 7 unmodified lines
123const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>139const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
124 Option.getOrElse(Option.filter(flag, Boolean), () => envValue);140 Option.getOrElse(Option.filter(flag, Boolean), () => envValue);
125 141
142const resolveOptionPrecedence = <Value>(
143 ...values: ReadonlyArray<Option.Option<Value>>
144): Option.Option<Value> => Option.firstSomeOf(values);
145
146const isValidPort = (value: number): boolean => value >= 1 && value <= 65_535;
147const isRuntimeMode = (value: string): value is RuntimeMode =>
148 value === "web" || value === "desktop";
149
126const ServerConfigLive = (input: CliInput) =>150const ServerConfigLive = (input: CliInput) =>
127 Layer.effect(151 Layer.effect(
128 ServerConfig,152 ServerConfig,
@@ -136,39 +160,115 @@ const ServerConfigLive = (input: CliInput) => · 7 unmodified lines
136 ),160 ),
137 );161 );
138 162
139 const mode = Option.getOrElse(input.mode, () => env.mode);163 const bootstrapFd = Option.getOrUndefined(input.bootstrapFd) ?? env.bootstrapFd;
140 164 const bootstrapEnvelope =
141 const port = yield* Option.match(input.port, {165 bootstrapFd !== undefined
142 onSome: (value) => Effect.succeed(value),166 ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd)
143 onNone: () => {167 : Option.none();
144 if (env.port) {168
145 return Effect.succeed(env.port);169 const mode: RuntimeMode = Option.getOrElse(
146 }170 resolveOptionPrecedence(
147 if (mode === "desktop") {171 input.mode,
148 return Effect.succeed(DEFAULT_PORT);172 Option.fromUndefinedOr(env.mode),
149 }173 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
150 return findAvailablePort(DEFAULT_PORT);174 Option.filter(Option.fromUndefinedOr(bootstrap.mode), isRuntimeMode),
175 ),
176 ),
177 () => "web",
178 );
179 const port = yield* Option.match(
180 resolveOptionPrecedence(
181 input.port,
182 Option.fromUndefinedOr(env.port),
183 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
184 Option.filter(Option.fromUndefinedOr(bootstrap.port), isValidPort),
185 ),
186 ),
187 {
188 onSome: (value) => Effect.succeed(value),
189 onNone: () => {
190 if (mode === "desktop") {
191 return Effect.succeed(DEFAULT_PORT);
192 }
193 return findAvailablePort(DEFAULT_PORT);
194 },
151 },195 },
152 });196 );
153 197
154 const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl);198 const devUrl = Option.getOrElse(
155 const baseDir = yield* resolveBaseDir(Option.getOrUndefined(input.t3Home) ?? env.t3Home);199 resolveOptionPrecedence(
200 input.devUrl,
201 Option.fromUndefinedOr(env.devUrl),
202 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
203 Option.fromUndefinedOr(bootstrap.devUrl),
204 ),
205 ),
206 () => undefined,
207 );
208 const baseDir = yield* resolveBaseDir(
209 Option.getOrUndefined(
210 resolveOptionPrecedence(
211 input.t3Home,
212 Option.fromUndefinedOr(env.t3Home),
213 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
214 Option.fromUndefinedOr(bootstrap.t3Home),
215 ),
216 ),
217 ),
218 );
156 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);219 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
157 const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop");220 const noBrowser = resolveBooleanFlag(
158 const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken;221 input.noBrowser,
222 Option.getOrElse(
223 resolveOptionPrecedence(
224 Option.fromUndefinedOr(env.noBrowser),
225 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
226 Option.fromUndefinedOr(bootstrap.noBrowser),
227 ),
228 ),
229 () => mode === "desktop",
230 ),
231 );
232 const authToken = resolveOptionPrecedence(
233 input.authToken,
234 Option.fromUndefinedOr(env.authToken),
235 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
236 Option.fromUndefinedOr(bootstrap.authToken),
237 ),
238 );
159 const autoBootstrapProjectFromCwd = resolveBooleanFlag(239 const autoBootstrapProjectFromCwd = resolveBooleanFlag(
160 input.autoBootstrapProjectFromCwd,240 input.autoBootstrapProjectFromCwd,
161 env.autoBootstrapProjectFromCwd ?? mode === "web",241 Option.getOrElse(
242 resolveOptionPrecedence(
243 Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),
244 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
245 Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd),
246 ),
247 ),
248 () => mode === "web",
249 ),
162 );250 );
163 const logWebSocketEvents = resolveBooleanFlag(251 const logWebSocketEvents = resolveBooleanFlag(
164 input.logWebSocketEvents,252 input.logWebSocketEvents,
165 env.logWebSocketEvents ?? Boolean(devUrl),253 Option.getOrElse(
254 resolveOptionPrecedence(
255 Option.fromUndefinedOr(env.logWebSocketEvents),
256 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
257 Option.fromUndefinedOr(bootstrap.logWebSocketEvents),
258 ),
259 ),
260 () => Boolean(devUrl),
261 ),
166 );262 );
167 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;263 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;
168 const host =264 const host = Option.getOrElse(
169 Option.getOrUndefined(input.host) ??265 resolveOptionPrecedence(
170 env.host ??266 input.host,
171 (mode === "desktop" ? "127.0.0.1" : undefined);267 Option.fromUndefinedOr(env.host),
268 Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)),
269 ),
270 () => (mode === "desktop" ? "127.0.0.1" : undefined),
271 );
172 272
173 const config: ServerConfigShape = {273 const config: ServerConfigShape = {
174 mode,274 mode,
@@ -180,7 +280,7 @@ const ServerConfigLive = (input: CliInput) => · 5 unmodified lines
180 staticDir,280 staticDir,
181 devUrl,281 devUrl,
182 noBrowser,282 noBrowser,
183 authToken,283 authToken: Option.getOrUndefined(authToken),
184 autoBootstrapProjectFromCwd,284 autoBootstrapProjectFromCwd,
185 logWebSocketEvents,285 logWebSocketEvents,
186 } satisfies ServerConfigShape;286 } satisfies ServerConfigShape;
@@ -287,7 +387,7 @@ const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe( · 100 unmodified lines
287 Flag.optional,387 Flag.optional,
288);388);
289const portFlag = Flag.integer("port").pipe(389const portFlag = Flag.integer("port").pipe(
290 Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),390 Flag.withSchema(PortSchema),
291 Flag.withDescription("Port for the HTTP/WebSocket server."),391 Flag.withDescription("Port for the HTTP/WebSocket server."),
292 Flag.optional,392 Flag.optional,
293);393);
@@ -313,6 +413,11 @@ const authTokenFlag = Flag.string("auth-token").pipe( · 19 unmodified lines
313 Flag.withAlias("token"),413 Flag.withAlias("token"),
314 Flag.optional,414 Flag.optional,
315);415);
416const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe(
417 Flag.withSchema(Schema.Int),
418 Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."),
419 Flag.optional,
420);
316const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe(421const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe(
317 Flag.withDescription(422 Flag.withDescription(
318 "Create a project for the current working directory on startup when missing.",423 "Create a project for the current working directory on startup when missing.",
@@ -335,6 +440,7 @@ export const t3Cli = Command.make("t3", { · 16 unmodified lines
335 devUrl: devUrlFlag,440 devUrl: devUrlFlag,
336 noBrowser: noBrowserFlag,441 noBrowser: noBrowserFlag,
337 authToken: authTokenFlag,442 authToken: authTokenFlag,
443 bootstrapFd: bootstrapFdFlag,
338 autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,444 autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
339 logWebSocketEvents: logWebSocketEventsFlag,445 logWebSocketEvents: logWebSocketEventsFlag,
340}).pipe(446}).pipe(