authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-25 20:21:20-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log0c4c027e92bae27716d94d14e3531c390d9ecef1
tree0882fdf8fb3966279ec85163e5f3a2a85b273998
parentfefea0bdf6b09a596b768047bc8432af48ba1123

small config change


5 files changed, 99 insertions(+), 60 deletions(-)

apps/server/src/config.ts+2
......@@ -16,6 +16,8 @@ export interface ServerConfigShape {
1616 readonly devUrl: URL | undefined;
1717 readonly noBrowser: boolean;
1818 readonly authToken: string | undefined;
19 readonly autoBootstrapProjectFromCwd: boolean;
20 readonly logWebSocketEvents: boolean;
1921}
2022
2123export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigShape>()(
apps/server/src/main.test.ts+36-33
......@@ -10,20 +10,18 @@ import { beforeEach } from "vitest";
1010import { CliConfig, t3Cli, type CliConfigShape } from "./main";
1111import { NetService, ServerConfig, type ServerConfigShape } from "./config";
1212import { Open, type OpenShape } from "./open";
13import { Server, type ServerShape, type ServerOptions } from "./wsServer";
13import { Server, type ServerShape } from "./wsServer";
1414
1515const start = vi.fn(() => undefined);
1616const stop = vi.fn(() => undefined);
1717let resolvedConfig: ServerConfigShape | null = null;
18const createServer = vi.fn((_: ServerOptions | undefined) =>
19 Effect.acquireRelease(
20 Effect.gen(function* () {
21 resolvedConfig = yield* ServerConfig;
22 start();
23 return {} as unknown as Http.Server;
24 }),
25 () => Effect.sync(() => stop()),
26 ),
18const serverStart = Effect.acquireRelease(
19 Effect.gen(function* () {
20 resolvedConfig = yield* ServerConfig;
21 start();
22 return {} as unknown as Http.Server;
23 }),
24 () => Effect.sync(() => stop()),
2725);
2826const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred));
2927
......@@ -37,7 +35,7 @@ const testLayer = Layer.mergeAll(
3735 findAvailablePort,
3836 }),
3937 Layer.succeed(Server, {
40 createServer,
38 start: serverStart,
4139 stopSignal: Effect.void,
4240 } satisfies ServerShape),
4341 Layer.succeed(Open, {
......@@ -60,16 +58,6 @@ beforeEach(() => {
6058 resolvedConfig = null;
6159 start.mockImplementation(() => undefined);
6260 stop.mockImplementation(() => undefined);
63 createServer.mockImplementation((_: ServerOptions | undefined) =>
64 Effect.acquireRelease(
65 Effect.gen(function* () {
66 resolvedConfig = yield* ServerConfig;
67 start();
68 return {} as unknown as Http.Server;
69 }),
70 () => Effect.sync(() => stop()),
71 ),
72 );
7361 findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred));
7462});
7563
......@@ -92,7 +80,7 @@ it.layer(testLayer)("server cli", (it) => {
9280 "auth-secret",
9381 ]);
9482
95 assert.equal(createServer.mock.calls.length, 1);
83 assert.equal(start.mock.calls.length, 1);
9684 assert.equal(resolvedConfig?.mode, "desktop");
9785 assert.equal(resolvedConfig?.port, 4010);
9886 assert.equal(resolvedConfig?.host, "0.0.0.0");
......@@ -100,7 +88,8 @@ it.layer(testLayer)("server cli", (it) => {
10088 assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/");
10189 assert.equal(resolvedConfig?.noBrowser, true);
10290 assert.equal(resolvedConfig?.authToken, "auth-secret");
103 assert.equal(start.mock.calls.length, 1);
91 assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false);
92 assert.equal(resolvedConfig?.logWebSocketEvents, true);
10493 assert.equal(stop.mock.calls.length, 1);
10594 }),
10695 );
......@@ -109,7 +98,7 @@ it.layer(testLayer)("server cli", (it) => {
10998 Effect.gen(function* () {
11099 yield* runCli(["--token", "token-secret"]);
111100
112 assert.equal(createServer.mock.calls.length, 1);
101 assert.equal(start.mock.calls.length, 1);
113102 assert.equal(resolvedConfig?.authToken, "token-secret");
114103 }),
115104 );
......@@ -126,7 +115,7 @@ it.layer(testLayer)("server cli", (it) => {
126115 T3CODE_AUTH_TOKEN: "env-token",
127116 });
128117
129 assert.equal(createServer.mock.calls.length, 1);
118 assert.equal(start.mock.calls.length, 1);
130119 assert.equal(resolvedConfig?.mode, "desktop");
131120 assert.equal(resolvedConfig?.port, 4999);
132121 assert.equal(resolvedConfig?.host, "100.88.10.4");
......@@ -134,6 +123,8 @@ it.layer(testLayer)("server cli", (it) => {
134123 assert.equal(resolvedConfig?.devUrl?.toString(), "http://localhost:5173/");
135124 assert.equal(resolvedConfig?.noBrowser, true);
136125 assert.equal(resolvedConfig?.authToken, "env-token");
126 assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false);
127 assert.equal(resolvedConfig?.logWebSocketEvents, true);
137128 assert.equal(findAvailablePort.mock.calls.length, 0);
138129 }),
139130 );
......@@ -147,7 +138,7 @@ it.layer(testLayer)("server cli", (it) => {
147138 });
148139
149140 assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]);
150 assert.equal(createServer.mock.calls.length, 1);
141 assert.equal(start.mock.calls.length, 1);
151142 assert.equal(resolvedConfig?.mode, "web");
152143 assert.equal(resolvedConfig?.port, 4666);
153144 assert.equal(resolvedConfig?.host, undefined);
......@@ -160,7 +151,7 @@ it.layer(testLayer)("server cli", (it) => {
160151 T3CODE_NO_BROWSER: "false",
161152 });
162153
163 assert.equal(createServer.mock.calls.length, 1);
154 assert.equal(start.mock.calls.length, 1);
164155 assert.equal(resolvedConfig?.noBrowser, true);
165156 }),
166157 );
......@@ -171,7 +162,7 @@ it.layer(testLayer)("server cli", (it) => {
171162 yield* runCli([]);
172163
173164 assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]);
174 assert.equal(createServer.mock.calls.length, 1);
165 assert.equal(start.mock.calls.length, 1);
175166 assert.equal(resolvedConfig?.port, 5444);
176167 assert.equal(resolvedConfig?.mode, "web");
177168 }),
......@@ -185,7 +176,7 @@ it.layer(testLayer)("server cli", (it) => {
185176 });
186177
187178 assert.equal(findAvailablePort.mock.calls.length, 0);
188 assert.equal(createServer.mock.calls.length, 1);
179 assert.equal(start.mock.calls.length, 1);
189180 assert.equal(resolvedConfig?.port, 3773);
190181 assert.equal(resolvedConfig?.host, "127.0.0.1");
191182 assert.equal(resolvedConfig?.mode, "desktop");
......@@ -199,17 +190,31 @@ it.layer(testLayer)("server cli", (it) => {
199190 T3CODE_NO_BROWSER: "true",
200191 });
201192
202 assert.equal(createServer.mock.calls.length, 1);
193 assert.equal(start.mock.calls.length, 1);
203194 assert.equal(resolvedConfig?.mode, "desktop");
204195 assert.equal(resolvedConfig?.host, "0.0.0.0");
205196 }),
206197 );
207198
199 it.effect("supports CLI and env for bootstrap/log websocket toggles", () =>
200 Effect.gen(function* () {
201 yield* runCli(["--auto-bootstrap-project-from-cwd"], {
202 T3CODE_MODE: "desktop",
203 T3CODE_LOG_WS_EVENTS: "false",
204 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
205 T3CODE_NO_BROWSER: "true",
206 });
207
208 assert.equal(start.mock.calls.length, 1);
209 assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, true);
210 assert.equal(resolvedConfig?.logWebSocketEvents, false);
211 }),
212 );
213
208214 it.effect("does not start server for invalid --mode values", () =>
209215 Effect.gen(function* () {
210216 yield* runCli(["--mode", "invalid"]);
211217
212 assert.equal(createServer.mock.calls.length, 0);
213218 assert.equal(start.mock.calls.length, 0);
214219 assert.equal(stop.mock.calls.length, 0);
215220 }),
......@@ -219,7 +224,6 @@ it.layer(testLayer)("server cli", (it) => {
219224 Effect.gen(function* () {
220225 yield* runCli(["--dev-url", "not-a-url"]).pipe(Effect.catch(() => Effect.void));
221226
222 assert.equal(createServer.mock.calls.length, 0);
223227 assert.equal(start.mock.calls.length, 0);
224228 assert.equal(stop.mock.calls.length, 0);
225229 }),
......@@ -230,7 +234,6 @@ it.layer(testLayer)("server cli", (it) => {
230234 yield* runCli(["--port", "70000"]);
231235
232236 // effect/unstable/cli renders help/errors for parse failures and returns success.
233 assert.equal(createServer.mock.calls.length, 0);
234237 assert.equal(start.mock.calls.length, 0);
235238 assert.equal(stop.mock.calls.length, 0);
236239 }),
apps/server/src/main.ts+41-2
......@@ -28,6 +28,8 @@ interface CliInput {
2828 readonly devUrl: Option.Option<URL>;
2929 readonly noBrowser: Option.Option<boolean>;
3030 readonly authToken: Option.Option<string>;
31 readonly autoBootstrapProjectFromCwd: Option.Option<boolean>;
32 readonly logWebSocketEvents: Option.Option<boolean>;
3133}
3234
3335export interface CliConfigShape {
......@@ -79,6 +81,14 @@ const CliEnvConfig = Config.all({
7981 Config.option,
8082 Config.map(Option.getOrUndefined),
8183 ),
84 autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe(
85 Config.option,
86 Config.map(Option.getOrUndefined),
87 ),
88 logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe(
89 Config.option,
90 Config.map(Option.getOrUndefined),
91 ),
8292});
8393
8494const ServerConfigLive = (input: CliInput) =>
......@@ -119,6 +129,18 @@ const ServerConfigLive = (input: CliInput) =>
119129 onNone: () => env.noBrowser ?? mode === "desktop",
120130 });
121131 const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken;
132 const autoBootstrapProjectFromCwd = Option.match(input.autoBootstrapProjectFromCwd, {
133 // effect/cli boolean flags parse to `false` when absent; in that case
134 // we still want env/mode fallbacks to apply.
135 onSome: (value) => (value ? true : (env.autoBootstrapProjectFromCwd ?? mode === "web")),
136 onNone: () => env.autoBootstrapProjectFromCwd ?? mode === "web",
137 });
138 const logWebSocketEvents = Option.match(input.logWebSocketEvents, {
139 // effect/cli boolean flags parse to `false` when absent; in that case
140 // we still want env/dev fallbacks to apply.
141 onSome: (value) => (value ? true : (env.logWebSocketEvents ?? Boolean(devUrl))),
142 onNone: () => env.logWebSocketEvents ?? Boolean(devUrl),
143 });
122144 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;
123145 const { join } = yield* Path.Path;
124146 const keybindingsConfigPath = join(stateDir, "keybindings.json");
......@@ -138,6 +160,8 @@ const ServerConfigLive = (input: CliInput) =>
138160 devUrl,
139161 noBrowser,
140162 authToken,
163 autoBootstrapProjectFromCwd,
164 logWebSocketEvents,
141165 } satisfies ServerConfigShape;
142166 }),
143167 );
......@@ -159,7 +183,7 @@ const formatHostForUrl = (host: string): string =>
159183const makeServerProgram = (input: CliInput) =>
160184 Effect.gen(function* () {
161185 const cliConfig = yield* CliConfig;
162 const { createServer, stopSignal } = yield* Server;
186 const { start, stopSignal } = yield* Server;
163187 const openDeps = yield* Open;
164188 yield* cliConfig.fixPath;
165189
......@@ -174,7 +198,7 @@ const makeServerProgram = (input: CliInput) =>
174198 );
175199 }
176200
177 yield* createServer();
201 yield* start;
178202
179203 const localUrl = `http://localhost:${config.port}`;
180204 const bindUrl =
......@@ -240,6 +264,19 @@ const authTokenFlag = Flag.string("auth-token").pipe(
240264 Flag.withAlias("token"),
241265 Flag.optional,
242266);
267const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe(
268 Flag.withDescription(
269 "Create a project for the current working directory on startup when missing.",
270 ),
271 Flag.optional,
272);
273const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe(
274 Flag.withDescription(
275 "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).",
276 ),
277 Flag.withAlias("log-ws-events"),
278 Flag.optional,
279);
243280
244281export const t3Cli = Command.make("t3", {
245282 mode: modeFlag,
......@@ -249,6 +286,8 @@ export const t3Cli = Command.make("t3", {
249286 devUrl: devUrlFlag,
250287 noBrowser: noBrowserFlag,
251288 authToken: authTokenFlag,
289 autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
290 logWebSocketEvents: logWebSocketEventsFlag,
252291}).pipe(
253292 Command.withDescription("Run the T3 Code server."),
254293 Command.withHandler((input) => Effect.scoped(makeServerProgram(input))),
apps/server/src/wsServer.test.ts+4-3
......@@ -338,6 +338,7 @@ describe("WebSocket Server", () => {
338338 persistenceLayer?: Layer.Layer<SqlClient.SqlClient, any>;
339339 cwd?: string;
340340 autoBootstrapProjectFromCwd?: boolean;
341 logWebSocketEvents?: boolean;
341342 devUrl?: string;
342343 authToken?: string;
343344 stateDir?: string;
......@@ -368,6 +369,8 @@ describe("WebSocket Server", () => {
368369 devUrl: options.devUrl ? new URL(options.devUrl) : undefined,
369370 noBrowser: true,
370371 authToken: options.authToken,
372 autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false,
373 logWebSocketEvents: options.logWebSocketEvents ?? Boolean(options.devUrl),
371374 } satisfies ServerConfigShape);
372375 const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer));
373376 const runtimeOverrides = Layer.mergeAll(
......@@ -397,9 +400,7 @@ describe("WebSocket Server", () => {
397400
398401 try {
399402 const runtime = await Effect.runPromise(
400 createServer({
401 autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false,
402 }).pipe(Effect.provide(runtimeServices), Scope.provide(scope)),
403 createServer().pipe(Effect.provide(runtimeServices), Scope.provide(scope)),
403404 );
404405 serverScope = scope;
405406 return runtime;
apps/server/src/wsServer.ts+16-22
......@@ -49,15 +49,12 @@ const MIME_TYPES: Record<string, string> = {
4949 ".map": "application/json",
5050};
5151
52export interface ServerOptions {
53 autoBootstrapProjectFromCwd?: boolean | undefined;
54 logWebSocketEvents?: boolean | undefined;
55}
56
5752export interface ServerShape {
58 readonly createServer: (
59 options?: ServerOptions,
60 ) => Effect.Effect<http.Server, unknown, Scope.Scope | ServerRuntimeServices | ServerConfig>;
53 readonly start: Effect.Effect<
54 http.Server,
55 unknown,
56 Scope.Scope | ServerRuntimeServices | ServerConfig
57 >;
6158 readonly stopSignal: Effect.Effect<unknown, never>;
6259}
6360
......@@ -82,14 +79,6 @@ function rejectUpgrade(socket: Duplex, statusCode: number, message: string): voi
8279 );
8380}
8481
85function parseBooleanEnv(value: string | undefined): boolean | undefined {
86 if (value === undefined) return undefined;
87 const normalized = value.trim().toLowerCase();
88 if (["1", "true", "yes", "on"].includes(normalized)) return true;
89 if (["0", "false", "no", "off"].includes(normalized)) return false;
90 return undefined;
91}
92
9382function websocketRawToString(raw: unknown): string | null {
9483 if (typeof raw === "string") {
9584 return raw;
......@@ -153,17 +142,22 @@ class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("Ro
153142 message: Schema.String,
154143}) {}
155144
156export const createServer = Effect.fn(function* (options: ServerOptions = {}): Effect.fn.Return<
145export const createServer = Effect.fn(function* (): Effect.fn.Return<
157146 http.Server,
158147 unknown, // TODO: This should not be unknown
159148 Scope.Scope | ServerRuntimeServices | ServerConfig
160149> {
161150 const serverConfig = yield* ServerConfig;
162 const { port, cwd, staticDir, devUrl, authToken, mode, host } = serverConfig;
163151 const {
164 logWebSocketEvents = parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl),
165 autoBootstrapProjectFromCwd = mode === "web",
166 } = options;
152 port,
153 cwd,
154 staticDir,
155 devUrl,
156 authToken,
157 host,
158 logWebSocketEvents,
159 autoBootstrapProjectFromCwd,
160 } = serverConfig;
167161
168162 const gitManager = yield* GitManager;
169163 const terminalManager = yield* TerminalManager;
......@@ -599,6 +593,6 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E
599593});
600594
601595export const ServerLive = Layer.succeed(Server, {
602 createServer,
596 start: createServer(),
603597 stopSignal: Effect.never,
604598} satisfies ServerShape);