authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-24 18:25:26-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log3fbd02c6cfc667dbf239cb325f00caa2e4ac662c
tree3b8cacee840bb3b8e2f8535e41bf7cc0f2e1ebea
parentda3d8e2ecb572e107b4ceec41d8966fe2f8f3db1

alright


11 files changed, 436 insertions(+), 375 deletions(-)

apps/server/src/config.ts created+87
......@@ -0,0 +1,87 @@
1import * as Net from "node:net";
2import { Data, Effect, FileSystem, Layer, Path, ServiceMap } from "effect";
3
4export const DEFAULT_PORT = 3773;
5
6export type RuntimeMode = "web" | "desktop";
7
8export interface ServerConfigShape {
9 readonly mode: RuntimeMode;
10 readonly port: number;
11 readonly host: string | undefined;
12 readonly cwd: string;
13 readonly stateDir: string;
14 readonly staticDir: string | undefined;
15 readonly devUrl: URL | undefined;
16 readonly noBrowser: boolean;
17 readonly authToken: string | undefined;
18}
19
20export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigShape>()(
21 "server/ServerConfig",
22) {}
23
24// Helpers
25
26export class NetError extends Data.TaggedError("NetError")<{
27 readonly message: string;
28 readonly cause?: unknown;
29}> {}
30
31export interface NetServiceShape {
32 readonly findAvailablePort: (preferred: number) => Effect.Effect<number, NetError>;
33}
34
35export class NetService extends ServiceMap.Service<NetService, NetServiceShape>()(
36 "server/NetService",
37) {
38 static readonly layer = Layer.succeed(NetService, {
39 findAvailablePort: (preferred) =>
40 Effect.callback<number, NetError>((resume) => {
41 const server = Net.createServer();
42 server.listen(preferred, () => {
43 server.close(() => resume(Effect.succeed(preferred)));
44 });
45 server.on("error", () => {
46 const fallback = Net.createServer();
47 fallback.listen(0, () => {
48 const addr = fallback.address();
49 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
50 fallback.close(() => {
51 resume(
52 port > 0
53 ? Effect.succeed(port)
54 : Effect.fail(new NetError({ message: "Could not find an available port." })),
55 );
56 });
57 });
58 fallback.on("error", (cause) => {
59 resume(
60 Effect.fail(new NetError({ message: "Failed to find an available port", cause })),
61 );
62 });
63 });
64 }),
65 });
66}
67
68export const resolveStaticDir = Effect.fn(function* () {
69 const { join, resolve } = yield* Path.Path;
70 const { stat } = yield* FileSystem.FileSystem;
71 const bundledClient = resolve(join(import.meta.dirname, "client"));
72 const bundledStat = yield* stat(join(bundledClient, "index.html")).pipe(
73 Effect.orElseSucceed(() => undefined),
74 );
75 if (bundledStat) {
76 return bundledClient;
77 }
78
79 const monorepoClient = resolve(join(import.meta.dirname, "../../web/dist"));
80 const monorepoStat = yield* stat(join(monorepoClient, "index.html")).pipe(
81 Effect.orElseSucceed(() => undefined),
82 );
83 if (monorepoStat) {
84 return monorepoClient;
85 }
86 return undefined;
87});
apps/server/src/fixPath.ts deleted-18
......@@ -1,18 +0,0 @@
1import { execFileSync } from "node:child_process";
2
3export function fixPath(): void {
4 if (process.platform !== "darwin") return;
5
6 try {
7 const shell = process.env.SHELL ?? "/bin/zsh";
8 const result = execFileSync(shell, ["-ilc", "echo -n $PATH"], {
9 encoding: "utf8",
10 timeout: 5000,
11 });
12 if (result) {
13 process.env.PATH = result;
14 }
15 } catch {
16 // Silently ignore — keep default PATH
17 }
18}
apps/server/src/index.ts+8-4
......@@ -3,15 +3,19 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
33import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55
6import { CliConfigLive, makeCliCommand } from "./main";
6import { CliConfig, makeCliCommand } from "./main";
77import { 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";
1112
12const RuntimeLayer = Layer.provideMerge(
13 Layer.mergeAll(CliConfigLive, ServerLive, OpenLive),
14 NodeServices.layer,
13const RuntimeLayer = Layer.empty.pipe(
14 Layer.provideMerge(CliConfig.layer),
15 Layer.provideMerge(ServerLive),
16 Layer.provideMerge(OpenLive),
17 Layer.provideMerge(NetService.layer),
18 Layer.provideMerge(NodeServices.layer),
1519);
1620
1721Command.run(makeCliCommand(), { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain);
apps/server/src/main.test.ts+20-17
......@@ -8,14 +8,17 @@ import * as Command from "effect/unstable/cli/Command";
88import { beforeEach } from "vitest";
99
1010import { CliConfig, makeCliCommand, type CliConfigShape } from "./main";
11import { NetService, ServerConfig, type ServerConfigShape } from "./config";
1112import { Open, type OpenShape } from "./open";
1213import { Server, type ServerShape, type ServerOptions } from "./wsServer";
1314
1415const start = vi.fn(() => undefined);
1516const stop = vi.fn(() => undefined);
16const createServer = vi.fn((_: ServerOptions) =>
17let resolvedConfig: ServerConfigShape | null = null;
18const createServer = vi.fn((_: ServerOptions | undefined) =>
1719 Effect.acquireRelease(
18 Effect.sync(() => {
20 Effect.gen(function* () {
21 resolvedConfig = yield* ServerConfig;
1922 start();
2023 return {} as unknown as Http.Server;
2124 }),
......@@ -28,9 +31,11 @@ const testLayer = Layer.mergeAll(
2831 Layer.succeed(CliConfig, {
2932 cwd: "/tmp/t3-test-workspace",
3033 fixPath: Effect.void,
31 findAvailablePort,
3234 resolveStaticDir: Effect.undefined,
3335 } satisfies CliConfigShape),
36 Layer.succeed(NetService, {
37 findAvailablePort,
38 }),
3439 Layer.succeed(Server, {
3540 createServer,
3641 stopSignal: Effect.void,
......@@ -52,11 +57,13 @@ const runCli = (
5257
5358beforeEach(() => {
5459 vi.clearAllMocks();
60 resolvedConfig = null;
5561 start.mockImplementation(() => undefined);
5662 stop.mockImplementation(() => undefined);
57 createServer.mockImplementation((_: ServerOptions) =>
63 createServer.mockImplementation((_: ServerOptions | undefined) =>
5864 Effect.acquireRelease(
59 Effect.sync(() => {
65 Effect.gen(function* () {
66 resolvedConfig = yield* ServerConfig;
6067 start();
6168 return {} as unknown as Http.Server;
6269 }),
......@@ -72,9 +79,8 @@ it.layer(testLayer)("server cli", (it) => {
7279 yield* runCli(["--port", "4010", "--token", "secret"]);
7380
7481 assert.equal(createServer.mock.calls.length, 1);
75 const options = createServer.mock.calls[0]?.[0];
76 assert.equal(options?.port, 4010);
77 assert.equal(options?.authToken, "secret");
82 assert.equal(resolvedConfig?.port, 4010);
83 assert.equal(resolvedConfig?.authToken, "secret");
7884 assert.equal(start.mock.calls.length, 1);
7985 assert.equal(stop.mock.calls.length, 1);
8086 }),
......@@ -89,9 +95,8 @@ it.layer(testLayer)("server cli", (it) => {
8995 });
9096
9197 assert.equal(createServer.mock.calls.length, 1);
92 const options = createServer.mock.calls[0]?.[0];
93 assert.equal(options?.port, 4999);
94 assert.equal(options?.authToken, "env-token");
98 assert.equal(resolvedConfig?.port, 4999);
99 assert.equal(resolvedConfig?.authToken, "env-token");
95100 assert.equal(findAvailablePort.mock.calls.length, 0);
96101 }),
97102 );
......@@ -103,9 +108,8 @@ it.layer(testLayer)("server cli", (it) => {
103108
104109 assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]);
105110 assert.equal(createServer.mock.calls.length, 1);
106 const options = createServer.mock.calls[0]?.[0];
107 assert.equal(options?.port, 5444);
108 assert.equal(options?.host, undefined);
111 assert.equal(resolvedConfig?.port, 5444);
112 assert.equal(resolvedConfig?.mode, "web");
109113 }),
110114 );
111115
......@@ -118,9 +122,8 @@ it.layer(testLayer)("server cli", (it) => {
118122
119123 assert.equal(findAvailablePort.mock.calls.length, 0);
120124 assert.equal(createServer.mock.calls.length, 1);
121 const options = createServer.mock.calls[0]?.[0];
122 assert.equal(options?.port, 3773);
123 assert.equal(options?.host, "127.0.0.1");
125 assert.equal(resolvedConfig?.port, 3773);
126 assert.equal(resolvedConfig?.mode, "desktop");
124127 }),
125128 );
126129
apps/server/src/main.ts+122-185
......@@ -1,21 +1,19 @@
1import net from "node:net";
2import os from "node:os";
3
4import { ServiceMap, Data, Effect, Layer, Option, Config, Path, Schema, FileSystem } from "effect";
1import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect";
52import { Command, Flag } from "effect/unstable/cli";
63
7import { fixPath } from "./fixPath";
8import { Open } from "./open";
94import {
10 makeServerPersistenceLayer,
11 makeServerProviderLayer,
12 makeServerRuntimeServicesLayer,
13 Server,
14} from "./wsServer";
15
16const DEFAULT_PORT = 3773;
17
18type RuntimeMode = "web" | "desktop";
5 DEFAULT_PORT,
6 NetService,
7 resolveStaticDir,
8 ServerConfig,
9 type RuntimeMode,
10 type ServerConfigShape,
11} from "./config";
12import { fixPath, resolveStateDir } from "./os-jank";
13import { Open } from "./open";
14import * as SqlitePersistence from "./persistence/Layers/Sqlite";
15import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
16import { Server } from "./wsServer";
1917
2018export class StartupError extends Data.TaggedError("StartupError")<{
2119 readonly message: string;
......@@ -29,21 +27,34 @@ interface CliInput {
2927
3028export interface CliConfigShape {
3129 readonly cwd: string;
32 readonly fixPath: Effect.Effect<unknown, never>;
33 readonly findAvailablePort: (preferred: number) => Effect.Effect<number, StartupError>;
30 readonly fixPath: Effect.Effect<void>;
3431 readonly resolveStaticDir: Effect.Effect<string | undefined>;
3532}
3633
37export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()(
38 "server/CliConfig",
39) {}
34export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()("server/CliConfig") {
35 static readonly layer = Layer.effect(
36 CliConfig,
37 Effect.gen(function* () {
38 const fileSystem = yield* FileSystem.FileSystem;
39 const path = yield* Path.Path;
40 return {
41 cwd: process.cwd(),
42 fixPath: Effect.sync(fixPath),
43 resolveStaticDir: resolveStaticDir().pipe(
44 Effect.provideService(FileSystem.FileSystem, fileSystem),
45 Effect.provideService(Path.Path, path),
46 ),
47 } satisfies CliConfigShape;
48 }),
49 );
50}
4051
4152const CliEnvConfig = Config.all({
4253 mode: Config.string("T3CODE_MODE").pipe(
4354 Config.option,
4455 Config.map(
45 Option.match({
46 onNone: () => "web" as const,
56 Option.match<RuntimeMode, string>({
57 onNone: () => "web",
4758 onSome: (value) => (value === "desktop" ? "desktop" : "web"),
4859 }),
4960 ),
......@@ -64,159 +75,102 @@ const CliEnvConfig = Config.all({
6475 ),
6576});
6677
67const expandHomePath = Effect.fn(function* (input: string) {
68 const { join, sep } = yield* Path.Path;
69 if (input === "~") return os.homedir();
70 if (input.startsWith(`~${sep}`)) {
71 return join(os.homedir(), input.slice(sep.length));
72 }
73 return input;
74});
75
76const resolveStateDir = Effect.fn(function* (raw: string | undefined) {
77 const { join, resolve } = yield* Path.Path;
78 if (!raw || raw.trim().length === 0) {
79 return join(os.homedir(), ".t3", "userdata");
80 }
81 return resolve(yield* expandHomePath(raw.trim()));
82});
83
84const findAvailablePort = (preferred: number) =>
85 Effect.callback<number, StartupError>((resume) => {
86 const server = net.createServer();
87 server.listen(preferred, () => {
88 server.close(() => resume(Effect.succeed(preferred)));
89 });
90 server.on("error", () => {
91 const fallback = net.createServer();
92 fallback.listen(0, () => {
93 const addr = fallback.address();
94 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
95 fallback.close(() => {
96 resume(
97 port > 0
98 ? Effect.succeed(port)
99 : Effect.fail(new StartupError({ message: "Could not find an available port." })),
100 );
101 });
78const ServerConfigLive = (input: CliInput) =>
79 Layer.effect(
80 ServerConfig,
81 Effect.gen(function* () {
82 const cliConfig = yield* CliConfig;
83 const { findAvailablePort } = yield* NetService;
84 const env = yield* CliEnvConfig.asEffect().pipe(
85 Effect.mapError(
86 (cause) =>
87 new StartupError({ message: "Failed to read environment configuration", cause }),
88 ),
89 );
90
91 const port = yield* Option.match(input.port, {
92 onSome: (value) => Effect.succeed(value),
93 onNone: () => {
94 if (env.port) {
95 return Effect.succeed(env.port);
96 }
97 if (env.mode === "desktop") {
98 return Effect.succeed(DEFAULT_PORT);
99 }
100 return findAvailablePort(DEFAULT_PORT);
101 },
102102 });
103 fallback.on("error", (cause) => {
104 resume(
105 Effect.fail(new StartupError({ message: "Failed to find an available port", cause })),
106 );
107 });
108 });
109 });
110
111const resolveStaticDir = Effect.fn(function* () {
112 const { join, resolve } = yield* Path.Path;
113 const { stat } = yield* FileSystem.FileSystem;
114 const bundledClient = resolve(join(import.meta.dirname, "client"));
115 const bundledStat = yield* stat(join(bundledClient, "index.html")).pipe(
116 Effect.orElseSucceed(() => undefined),
117 );
118 if (bundledStat) return bundledClient;
119
120 const monorepoClient = resolve(join(import.meta.dirname, "../../web/dist"));
121 const monorepoStat = yield* stat(join(monorepoClient, "index.html")).pipe(
122 Effect.orElseSucceed(() => undefined),
103 const stateDir = yield* resolveStateDir(env.stateDir);
104 const devUrl = env.devUrl;
105 const noBrowser = env.noBrowser ?? env.mode === "desktop";
106 const authToken = Option.getOrUndefined(input.token) ?? env.authToken;
107 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;
108 const host = env.mode === "desktop" ? "127.0.0.1" : undefined;
109
110 return {
111 mode: env.mode,
112 port,
113 cwd: cliConfig.cwd,
114 host,
115 stateDir,
116 staticDir,
117 devUrl,
118 noBrowser,
119 authToken,
120 } satisfies ServerConfigShape;
121 }),
123122 );
124 if (monorepoStat) return monorepoClient;
125 return undefined;
126});
127123
128const resolveCliConfig = Effect.fn(function* (input: CliInput) {
129 const cliConfig = yield* CliConfig;
130 const env = yield* CliEnvConfig.asEffect().pipe(
131 Effect.mapError(
132 (cause) =>
133 new StartupError({
134 message: "Failed to read environment configuration",
135 cause,
136 }),
137 ),
124const LayerLive = (input: CliInput) =>
125 Layer.empty.pipe(
126 Layer.provideMerge(makeServerRuntimeServicesLayer()),
127 Layer.provideMerge(makeServerProviderLayer()),
128 Layer.provideMerge(SqlitePersistence.layerConfig),
129 Layer.provideMerge(ServerConfigLive(input)),
138130 );
139 const mode: RuntimeMode = env.mode === "desktop" ? "desktop" : "web";
140
141 const cliPort = Option.getOrUndefined(input.port);
142 const port =
143 (cliPort === undefined ? env.port : cliPort) ??
144 (mode === "desktop" ? DEFAULT_PORT : yield* cliConfig.findAvailablePort(DEFAULT_PORT));
145 const stateDir = yield* resolveStateDir(env.stateDir);
146 const devUrl = env.devUrl;
147 const noBrowser = env.noBrowser ?? mode === "desktop";
148 const authToken = Option.getOrUndefined(input.token) ?? env.authToken;
149 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;
150131
151 return {
152 mode,
153 port,
154 stateDir,
155 devUrl,
156 noBrowser,
157 authToken,
158 staticDir,
159 cwd: cliConfig.cwd,
160 };
161});
162
163const makeServerProgram = Effect.fn(function* (input: CliInput) {
164 const cliConfig = yield* CliConfig;
165 const { createServer, stopSignal } = yield* Server;
166 const openDeps = yield* Open;
167 yield* cliConfig.fixPath;
168 const config = yield* resolveCliConfig(input);
169
170 if (!config.devUrl && !config.staticDir) {
171 yield* Effect.logWarning("web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", {
172 hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.",
132const makeServerProgram = (input: CliInput) =>
133 Effect.gen(function* () {
134 const cliConfig = yield* CliConfig;
135 const { createServer, stopSignal } = yield* Server;
136 const openDeps = yield* Open;
137 yield* cliConfig.fixPath;
138
139 const config = yield* ServerConfig;
140
141 if (!config.devUrl && !config.staticDir) {
142 yield* Effect.logWarning(
143 "web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable",
144 {
145 hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.",
146 },
147 );
148 }
149
150 yield* createServer();
151
152 const url = `http://localhost:${config.port}`;
153 yield* Effect.logInfo("T3 Code running", {
154 url,
155 cwd: config.cwd,
156 mode: config.mode,
157 stateDir: config.stateDir,
158 authEnabled: Boolean(config.authToken),
173159 });
174 }
175160
176 const persistenceLayer = makeServerPersistenceLayer(config.stateDir);
177 const providerLayer = makeServerProviderLayer(config.stateDir);
178 const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer));
179 const serverRuntimeLayer = Layer.merge(
180 makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)),
181 infrastructureLayer,
182 );
183
184 yield* createServer({
185 port: config.port,
186 host: config.mode === "desktop" ? "127.0.0.1" : undefined,
187 cwd: config.cwd,
188 autoBootstrapProjectFromCwd: config.mode === "web",
189 stateDir: config.stateDir,
190 staticDir: config.staticDir,
191 devUrl: config.devUrl?.href,
192 authToken: config.authToken,
193 }).pipe(
194 Effect.provide(serverRuntimeLayer),
195 Effect.mapError((cause) => new StartupError({ message: "Failed to start server", cause })),
196 );
161 if (!config.noBrowser) {
162 const target = config.devUrl?.toString() ?? url;
163 yield* openDeps.openBrowser(target).pipe(
164 Effect.catch(() =>
165 Effect.logInfo("browser auto-open unavailable", {
166 hint: `Open ${target} in your browser.`,
167 }),
168 ),
169 );
170 }
197171
198 const url = `http://localhost:${config.port}`;
199 yield* Effect.logInfo("T3 Code running", {
200 url,
201 cwd: config.cwd,
202 mode: config.mode,
203 stateDir: config.stateDir,
204 authEnabled: Boolean(config.authToken),
205 });
206
207 if (!config.noBrowser) {
208 const target = config.devUrl ?? url;
209 yield* openDeps.openBrowser(target.toString()).pipe(
210 Effect.catch(() =>
211 Effect.logInfo("browser auto-open unavailable", {
212 hint: `Open ${target} in your browser.`,
213 }),
214 ),
215 );
216 }
217
218 return yield* stopSignal;
219}, Effect.scoped);
172 return yield* stopSignal;
173 }).pipe(Effect.provide(LayerLive(input)));
220174
221175export function makeCliCommand() {
222176 const portFlag = Flag.integer("port").pipe(
......@@ -234,23 +188,6 @@ export function makeCliCommand() {
234188 token: tokenFlag,
235189 }).pipe(
236190 Command.withDescription("Run the T3 Code server."),
237 Command.withHandler((input) => makeServerProgram(input)),
191 Command.withHandler((input) => Effect.scoped(makeServerProgram(input))),
238192 );
239193}
240
241export const CliConfigLive = Layer.effect(
242 CliConfig,
243 Effect.gen(function* () {
244 const fileSystem = yield* FileSystem.FileSystem;
245 const path = yield* Path.Path;
246 return {
247 cwd: process.cwd(),
248 fixPath: Effect.sync(fixPath),
249 findAvailablePort,
250 resolveStaticDir: resolveStaticDir().pipe(
251 Effect.provideService(FileSystem.FileSystem, fileSystem),
252 Effect.provideService(Path.Path, path),
253 ),
254 } satisfies CliConfigShape;
255 }),
256);
apps/server/src/open.ts+23-38
......@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
22
33import { EDITORS, type EditorId } from "@t3tools/contracts";
44import { ServiceMap, Schema, Effect, Layer } from "effect";
5import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
56
67export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", {
78 message: Schema.String,
......@@ -25,7 +26,7 @@ export interface OpenShape {
2526
2627export class Open extends ServiceMap.Service<Open, OpenShape>()("server/Open") {}
2728
28export function resolveEditorLaunch(
29function resolveEditorLaunch(
2930 input: OpenInEditorInput,
3031 platform: NodeJS.Platform = process.platform,
3132): EditorLaunch {
......@@ -52,42 +53,26 @@ export function resolveEditorLaunch(
5253 }
5354}
5455
55const OpenDefault: OpenShape = {
56 openBrowser: (target) =>
57 Effect.tryPromise({
58 try: async () => {
59 const open = await import("open");
60 await open.default(target);
61 },
62 catch: (cause) =>
63 new OpenError({
64 message: "Browser auto-open failed",
65 cause,
66 }),
67 }),
68 openInEditor: (input) =>
69 Effect.try({
70 try: () => {
71 const launch = resolveEditorLaunch(input);
72 const child = spawn(launch.command, [...launch.args], {
73 detached: true,
74 stdio: "ignore",
75 });
76 child.on("error", () => {
77 // Best-effort behavior for detached editor launches.
78 });
79 child.unref();
80 },
81 catch: (cause) =>
82 cause instanceof OpenError
83 ? cause
84 : new OpenError({
85 message: "Failed to open editor",
86 cause,
87 }),
88 }),
89};
56const make = Effect.gen(function* () {
57 const open = yield* Effect.promise(() => import("open"));
58 const { spawn } = yield* ChildProcessSpawner.ChildProcessSpawner;
9059
91export const OpenLive = Layer.succeed(Open, OpenDefault);
60 return {
61 openBrowser: (target) =>
62 Effect.tryPromise({
63 try: () => open.default(target),
64 catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }),
65 }),
66 openInEditor: (input) =>
67 Effect.gen(function* () {
68 const launch = resolveEditorLaunch(input);
69 const child = yield* spawn(ChildProcess.make(launch.command, launch.args));
70 yield* Effect.forkDetach(child.exitCode);
71 }).pipe(
72 Effect.catch((error) => Effect.logError("Failed to open editor", { cause: error })),
73 Effect.scoped,
74 ),
75 } satisfies OpenShape;
76});
9277
93export const OpenDefaultService = OpenDefault;
78export const OpenLive = Layer.effect(Open, make);
apps/server/src/os-jank.ts created+39
......@@ -0,0 +1,39 @@
1import * as OS from "node:os";
2import { Effect, Path } from "effect";
3import { execFileSync } from "node:child_process";
4
5export function fixPath(): void {
6 if (process.platform !== "darwin") return;
7
8 try {
9 const shell = process.env.SHELL ?? "/bin/zsh";
10 const result = execFileSync(shell, ["-ilc", "echo -n $PATH"], {
11 encoding: "utf8",
12 timeout: 5000,
13 });
14 if (result) {
15 process.env.PATH = result;
16 }
17 } catch {
18 // Silently ignore — keep default PATH
19 }
20}
21
22export const expandHomePath = Effect.fn(function* (input: string) {
23 const { join, sep } = yield* Path.Path;
24 if (input === "~") {
25 return OS.homedir();
26 }
27 if (input.startsWith(`~${sep}`)) {
28 return join(OS.homedir(), input.slice(sep.length));
29 }
30 return input;
31});
32
33export const resolveStateDir = Effect.fn(function* (raw: string | undefined) {
34 const { join, resolve } = yield* Path.Path;
35 if (!raw || raw.trim().length === 0) {
36 return join(OS.homedir(), ".t3", "userdata");
37 }
38 return resolve(yield* expandHomePath(raw.trim()));
39});
apps/server/src/persistence/Layers/Sqlite.ts+8
......@@ -2,6 +2,7 @@ import { Effect, Layer, FileSystem, Path } from "effect";
22import * as SqlClient from "effect/unstable/sql/SqlClient";
33
44import { runMigrations } from "../Migrations.ts";
5import { ServerConfig } from "../../config.ts";
56
67type RuntimeSqliteLayerConfig = {
78 readonly filename: string;
......@@ -47,3 +48,10 @@ export const SqlitePersistenceMemory = Layer.provideMerge(
4748 setup,
4849 makeRuntimeSqliteLayer({ filename: ":memory:" }),
4950);
51
52export const layerConfig = Effect.gen(function* () {
53 const { stateDir } = yield* ServerConfig;
54 const { join } = yield* Path.Path;
55 const dbPath = join(stateDir, "orchestration.sqlite");
56 return makeSqlitePersistenceLive(dbPath);
57}).pipe(Layer.unwrap);
apps/server/src/serverLayers.ts created+83
......@@ -0,0 +1,83 @@
1import path from "node:path";
2
3import { NodeServices } from "@effect/platform-node";
4import { Effect, Layer } from "effect";
5import * as SqlClient from "effect/unstable/sql/SqlClient";
6
7import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
8import { ServerConfig } from "./config";
9import { CheckpointStore } from "./checkpointing/Services/CheckpointStore";
10import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
11import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
12import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
13import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine";
14import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor";
15import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor";
16import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor";
17import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
18import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
19import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
20import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
21import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
22import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
23import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
24import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
25import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
26import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
27import { ProviderService } from "./provider/Services/ProviderService";
28
29export function makeServerProviderLayer(): Layer.Layer<
30 ProviderService,
31 unknown,
32 SqlClient.SqlClient | ServerConfig
33> {
34 return Effect.gen(function* () {
35 const { stateDir } = yield* ServerConfig;
36 const providerLogsDir = path.join(stateDir, "logs", "providers");
37 const codexAdapterLayer = makeCodexAdapterLive({
38 nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"),
39 });
40 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(Layer.provide(codexAdapterLayer));
41 const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
42 Layer.provide(ProviderSessionRuntimeRepositoryLive),
43 );
44 return makeProviderServiceLive({
45 canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"),
46 }).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer));
47 }).pipe(Layer.unwrap);
48}
49
50export function makeServerRuntimeServicesLayer(): Layer.Layer<
51 OrchestrationEngineService | ProjectionSnapshotQuery | CheckpointStore | OrchestrationReactor,
52 unknown,
53 SqlClient.SqlClient | ProviderService
54> {
55 const orchestrationLayer = OrchestrationEngineLive.pipe(
56 Layer.provide(OrchestrationProjectionPipelineLive),
57 Layer.provide(OrchestrationEventStoreLive),
58 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
59 );
60 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));
61
62 const runtimeServicesLayer = Layer.mergeAll(
63 orchestrationLayer,
64 OrchestrationProjectionSnapshotQueryLive,
65 checkpointStoreLayer,
66 );
67 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
68 Layer.provideMerge(runtimeServicesLayer),
69 );
70 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
71 Layer.provideMerge(runtimeServicesLayer),
72 );
73 const checkpointReactorLayer = CheckpointReactorLive.pipe(
74 Layer.provideMerge(runtimeServicesLayer),
75 );
76 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
77 Layer.provideMerge(runtimeIngestionLayer),
78 Layer.provideMerge(providerCommandReactorLayer),
79 Layer.provideMerge(checkpointReactorLayer),
80 );
81
82 return orchestrationReactorLayer;
83}
apps/server/src/wsServer.test.ts+29-13
......@@ -6,8 +6,10 @@ import { EventEmitter } from "node:events";
66
77import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect";
88import { describe, expect, it, afterEach, vi } from "vitest";
9import { createServer, makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./wsServer";
9import { createServer } from "./wsServer";
1010import WebSocket from "ws";
11import { ServerConfig, type ServerConfigShape } from "./config";
12import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
1113
1214import {
1315 DEFAULT_TERMINAL_ID,
......@@ -40,7 +42,7 @@ import type { TerminalManager } from "./terminalManager";
4042import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
4143import { SqlClient } from "effect/unstable/sql";
4244import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
43import type { OpenShape } from "./open";
45import { Open, type OpenShape } from "./open";
4446
4547interface PendingMessages {
4648 queue: unknown[];
......@@ -56,6 +58,11 @@ const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId
5658const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
5759const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
5860
61const defaultOpenService: OpenShape = {
62 openBrowser: () => Effect.void,
63 openInEditor: () => Effect.void,
64};
65
5966class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> {
6067 private readonly sessions = new Map<string, TerminalSessionSnapshot>();
6168
......@@ -323,28 +330,37 @@ describe("WebSocket Server", () => {
323330 const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-");
324331 const scope = await Effect.runPromise(Scope.make("sequential"));
325332 const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory;
326 const providerLayer = options.providerLayer ?? makeServerProviderLayer(stateDir);
333 const providerLayer = options.providerLayer ?? makeServerProviderLayer();
334 const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService);
335 const serverConfigLayer = Layer.succeed(ServerConfig, {
336 mode: "web",
337 port: 0,
338 host: undefined,
339 cwd: options.cwd ?? "/test/project",
340 stateDir,
341 staticDir: undefined,
342 devUrl: options.devUrl ? new URL(options.devUrl) : undefined,
343 noBrowser: true,
344 authToken: options.authToken,
345 } satisfies ServerConfigShape);
327346 const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer));
328347 const runtimeLayer = Layer.merge(
329348 makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)),
330349 infrastructureLayer,
331350 );
351 const dependenciesLayer = Layer.empty.pipe(
352 Layer.provideMerge(runtimeLayer),
353 Layer.provideMerge(openLayer),
354 Layer.provideMerge(serverConfigLayer),
355 );
332356 const runtimeServices = await Effect.runPromise(
333 Layer.build(runtimeLayer).pipe(Scope.provide(scope)),
357 Layer.build(dependenciesLayer).pipe(Scope.provide(scope)),
334358 );
335359
336360 try {
337361 const runtime = await Effect.runPromise(
338362 createServer({
339 port: 0,
340 cwd: options.cwd ?? "/test/project",
341 stateDir,
342 ...(options.autoBootstrapProjectFromCwd !== undefined
343 ? { autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd }
344 : {}),
345 ...(options.devUrl ? { devUrl: options.devUrl } : {}),
346 ...(options.authToken ? { authToken: options.authToken } : {}),
347 ...(options.open ? { open: options.open } : {}),
363 autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false,
348364 ...(options.gitManager ? { gitManager: options.gitManager as never } : {}),
349365 ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}),
350366 }).pipe(Effect.provide(runtimeServices), Scope.provide(scope)),
apps/server/src/wsServer.ts+17-100
......@@ -1,6 +1,5 @@
11import fs from "node:fs";
22import http from "node:http";
3import os from "node:os";
43import path from "node:path";
54import type { Duplex } from "node:stream";
65
......@@ -16,7 +15,7 @@ import {
1615 WsPush,
1716 WsResponse,
1817} from "@t3tools/contracts";
19import { NodeHttpServer, NodeServices } from "@effect/platform-node";
18import { NodeHttpServer } from "@effect/platform-node";
2019import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream } from "effect";
2120import { WebSocketServer, type WebSocket } from "ws";
2221
......@@ -34,30 +33,14 @@ import {
3433import { TerminalManager } from "./terminalManager";
3534import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings";
3635import { searchWorkspaceEntries } from "./workspaceEntries";
37import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine";
38import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor";
39import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor";
40import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor";
41import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
42import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
43import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
4436import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
4537import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
4638import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
47import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite";
48import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
49import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
50import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
51import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
52import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
53import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
5439import { ProviderService } from "./provider/Services/ProviderService";
55import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
56import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
5740import { CheckpointStore } from "./checkpointing/Services/CheckpointStore";
58import * as SqlClient from "effect/unstable/sql/SqlClient";
5941import { clamp } from "effect/Number";
60import { OpenDefaultService, type OpenShape } from "./open";
42import { Open } from "./open";
43import { ServerConfig } from "./config";
6144
6245const MIME_TYPES: Record<string, string> = {
6346 ".html": "text/html; charset=utf-8",
......@@ -75,24 +58,16 @@ const MIME_TYPES: Record<string, string> = {
7558};
7659
7760export interface ServerOptions {
78 port: number;
79 host?: string | undefined;
80 cwd: string;
8161 autoBootstrapProjectFromCwd?: boolean | undefined;
82 stateDir?: string | undefined;
83 staticDir?: string | undefined;
84 devUrl?: string | undefined;
8562 logWebSocketEvents?: boolean | undefined;
8663 gitManager?: GitManager | undefined;
8764 terminalManager?: TerminalManager | undefined;
88 authToken?: string | undefined;
89 open?: OpenShape | undefined;
9065}
9166
9267export interface ServerShape {
9368 readonly createServer: (
94 options: ServerOptions,
95 ) => Effect.Effect<http.Server, unknown, Scope.Scope | ServerRuntimeServices>;
69 options?: ServerOptions,
70 ) => Effect.Effect<http.Server, unknown, Scope.Scope | ServerRuntimeServices | ServerConfig>;
9671 readonly stopSignal: Effect.Effect<unknown, never>;
9772}
9873
......@@ -174,66 +149,11 @@ export type ServerCoreRuntimeServices =
174149 | OrchestrationEngineService
175150 | ProjectionSnapshotQuery
176151 | CheckpointStore
177 | OrchestrationReactor;
152 | OrchestrationReactor
153 | Open;
178154
179155export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService;
180156
181export function makeServerPersistenceLayer(stateDir: string) {
182 const dbPath = path.join(stateDir, "orchestration.sqlite");
183 return makeSqlitePersistenceLive(dbPath);
184}
185
186export function makeServerProviderLayer(
187 stateDir: string,
188): Layer.Layer<ProviderService, unknown, SqlClient.SqlClient> {
189 const providerLogsDir = path.join(stateDir, "logs", "providers");
190 const codexAdapterLayer = makeCodexAdapterLive({
191 nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"),
192 });
193 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(Layer.provide(codexAdapterLayer));
194 const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
195 Layer.provide(ProviderSessionRuntimeRepositoryLive),
196 );
197 return makeProviderServiceLive({
198 canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"),
199 }).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer));
200}
201
202export function makeServerRuntimeServicesLayer(): Layer.Layer<
203 ServerCoreRuntimeServices,
204 unknown,
205 SqlClient.SqlClient | ProviderService
206> {
207 const orchestrationLayer = OrchestrationEngineLive.pipe(
208 Layer.provide(OrchestrationProjectionPipelineLive),
209 Layer.provide(OrchestrationEventStoreLive),
210 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
211 );
212 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));
213
214 const runtimeServicesLayer = Layer.mergeAll(
215 orchestrationLayer,
216 OrchestrationProjectionSnapshotQueryLive,
217 checkpointStoreLayer,
218 );
219 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
220 Layer.provideMerge(runtimeServicesLayer),
221 );
222 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
223 Layer.provideMerge(runtimeServicesLayer),
224 );
225 const checkpointReactorLayer = CheckpointReactorLive.pipe(
226 Layer.provideMerge(runtimeServicesLayer),
227 );
228 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
229 Layer.provideMerge(runtimeIngestionLayer),
230 Layer.provideMerge(providerCommandReactorLayer),
231 Layer.provideMerge(checkpointReactorLayer),
232 );
233
234 return orchestrationReactorLayer;
235}
236
237157export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()(
238158 "ServerLifecycleError",
239159 {
......@@ -246,22 +166,18 @@ class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("Ro
246166 message: Schema.String,
247167}) {}
248168
249export const createServer = Effect.fn(function* (
250 options: ServerOptions,
251): Effect.fn.Return<http.Server, unknown, Scope.Scope | ServerRuntimeServices> {
169export const createServer = Effect.fn(function* (options: ServerOptions = {}): Effect.fn.Return<
170 http.Server,
171 unknown, // TODO: This should not be unknown
172 Scope.Scope | ServerRuntimeServices | ServerConfig
173> {
174 const { port, cwd, staticDir, devUrl, authToken, mode, host } = yield* ServerConfig;
252175 const {
253 port,
254 host,
255 cwd,
256 autoBootstrapProjectFromCwd = false,
257 staticDir,
258 devUrl,
259176 logWebSocketEvents: explicitLogWsEvents,
260177 gitManager = new GitManager(),
261178 terminalManager = new TerminalManager(),
262 authToken,
263 open: openService = OpenDefaultService,
264179 } = options;
180 const autoBootstrapProjectFromCwd = options.autoBootstrapProjectFromCwd ?? mode === "web";
265181
266182 const clients = new Set<WebSocket>();
267183 const logger = createLogger("ws");
......@@ -299,7 +215,7 @@ export const createServer = Effect.fn(function* (
299215 const httpServer = http.createServer((req, res) => {
300216 // In dev mode, redirect to Vite dev server
301217 if (devUrl) {
302 res.writeHead(302, { Location: devUrl });
218 res.writeHead(302, { Location: devUrl.href });
303219 res.end();
304220 return;
305221 }
......@@ -382,6 +298,7 @@ export const createServer = Effect.fn(function* (
382298 const liveCheckpointStore = yield* CheckpointStore;
383299 const liveProviderService = yield* ProviderService;
384300 const orchestrationReactor = yield* OrchestrationReactor;
301 const { openInEditor } = yield* Open;
385302
386303 const subscriptionsScope = yield* Scope.make("sequential");
387304 yield* Effect.addFinalizer(() =>
......@@ -574,7 +491,7 @@ export const createServer = Effect.fn(function* (
574491
575492 case WS_METHODS.shellOpenInEditor: {
576493 const body = stripRequestTag(request.body);
577 return yield* openService.openInEditor(body);
494 return yield* openInEditor(body);
578495 }
579496
580497 case WS_METHODS.gitStatus: {