| author | |
| committer | |
| log | 3fbd02c6cfc667dbf239cb325f00caa2e4ac662c |
| tree | 3b8cacee840bb3b8e2f8535e41bf7cc0f2e1ebea |
| parent | da3d8e2ecb572e107b4ceec41d8966fe2f8f3db1 |
11 files changed, 436 insertions(+), 375 deletions(-)
apps/server/src/config.ts created+87| ... | ... | @@ -0,0 +1,87 @@ |
| 1 | import * as Net from "node:net"; | |
| 2 | import { Data, Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; | |
| 3 | ||
| 4 | export const DEFAULT_PORT = 3773; | |
| 5 | ||
| 6 | export type RuntimeMode = "web" | "desktop"; | |
| 7 | ||
| 8 | export 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 | ||
| 20 | export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigShape>()( | |
| 21 | "server/ServerConfig", | |
| 22 | ) {} | |
| 23 | ||
| 24 | // Helpers | |
| 25 | ||
| 26 | export class NetError extends Data.TaggedError("NetError")<{ | |
| 27 | readonly message: string; | |
| 28 | readonly cause?: unknown; | |
| 29 | }> {} | |
| 30 | ||
| 31 | export interface NetServiceShape { | |
| 32 | readonly findAvailablePort: (preferred: number) => Effect.Effect<number, NetError>; | |
| 33 | } | |
| 34 | ||
| 35 | export 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 | ||
| 68 | export 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 @@ |
| 1 | import { execFileSync } from "node:child_process"; | |
| 2 | ||
| 3 | export 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"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| 5 | 5 | |
| 6 | import { CliConfigLive, makeCliCommand } from "./main"; | |
| 6 | import { CliConfig, makeCliCommand } from "./main"; | |
| 7 | 7 | import { OpenLive } from "./open"; |
| 8 | 8 | import { Command } from "effect/unstable/cli"; |
| 9 | 9 | import { version } from "../package.json" with { type: "json" }; |
| 10 | 10 | import { ServerLive } from "./wsServer"; |
| 11 | import { NetService } from "./config"; | |
| 11 | 12 | |
| 12 | const RuntimeLayer = Layer.provideMerge( | |
| 13 | Layer.mergeAll(CliConfigLive, ServerLive, OpenLive), | |
| 14 | NodeServices.layer, | |
| 13 | const 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), | |
| 15 | 19 | ); |
| 16 | 20 | |
| 17 | 21 | Command.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"; |
| 8 | 8 | import { beforeEach } from "vitest"; |
| 9 | 9 | |
| 10 | 10 | import { CliConfig, makeCliCommand, type CliConfigShape } from "./main"; |
| 11 | import { NetService, ServerConfig, type ServerConfigShape } from "./config"; | |
| 11 | 12 | import { Open, type OpenShape } from "./open"; |
| 12 | 13 | import { Server, type ServerShape, type ServerOptions } from "./wsServer"; |
| 13 | 14 | |
| 14 | 15 | const start = vi.fn(() => undefined); |
| 15 | 16 | const stop = vi.fn(() => undefined); |
| 16 | const createServer = vi.fn((_: ServerOptions) => | |
| 17 | let resolvedConfig: ServerConfigShape | null = null; | |
| 18 | const createServer = vi.fn((_: ServerOptions | undefined) => | |
| 17 | 19 | Effect.acquireRelease( |
| 18 | Effect.sync(() => { | |
| 20 | Effect.gen(function* () { | |
| 21 | resolvedConfig = yield* ServerConfig; | |
| 19 | 22 | start(); |
| 20 | 23 | return {} as unknown as Http.Server; |
| 21 | 24 | }), |
| ... | ... | @@ -28,9 +31,11 @@ const testLayer = Layer.mergeAll( |
| 28 | 31 | Layer.succeed(CliConfig, { |
| 29 | 32 | cwd: "/tmp/t3-test-workspace", |
| 30 | 33 | fixPath: Effect.void, |
| 31 | findAvailablePort, | |
| 32 | 34 | resolveStaticDir: Effect.undefined, |
| 33 | 35 | } satisfies CliConfigShape), |
| 36 | Layer.succeed(NetService, { | |
| 37 | findAvailablePort, | |
| 38 | }), | |
| 34 | 39 | Layer.succeed(Server, { |
| 35 | 40 | createServer, |
| 36 | 41 | stopSignal: Effect.void, |
| ... | ... | @@ -52,11 +57,13 @@ const runCli = ( |
| 52 | 57 | |
| 53 | 58 | beforeEach(() => { |
| 54 | 59 | vi.clearAllMocks(); |
| 60 | resolvedConfig = null; | |
| 55 | 61 | start.mockImplementation(() => undefined); |
| 56 | 62 | stop.mockImplementation(() => undefined); |
| 57 | createServer.mockImplementation((_: ServerOptions) => | |
| 63 | createServer.mockImplementation((_: ServerOptions | undefined) => | |
| 58 | 64 | Effect.acquireRelease( |
| 59 | Effect.sync(() => { | |
| 65 | Effect.gen(function* () { | |
| 66 | resolvedConfig = yield* ServerConfig; | |
| 60 | 67 | start(); |
| 61 | 68 | return {} as unknown as Http.Server; |
| 62 | 69 | }), |
| ... | ... | @@ -72,9 +79,8 @@ it.layer(testLayer)("server cli", (it) => { |
| 72 | 79 | yield* runCli(["--port", "4010", "--token", "secret"]); |
| 73 | 80 | |
| 74 | 81 | 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"); | |
| 78 | 84 | assert.equal(start.mock.calls.length, 1); |
| 79 | 85 | assert.equal(stop.mock.calls.length, 1); |
| 80 | 86 | }), |
| ... | ... | @@ -89,9 +95,8 @@ it.layer(testLayer)("server cli", (it) => { |
| 89 | 95 | }); |
| 90 | 96 | |
| 91 | 97 | 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"); | |
| 95 | 100 | assert.equal(findAvailablePort.mock.calls.length, 0); |
| 96 | 101 | }), |
| 97 | 102 | ); |
| ... | ... | @@ -103,9 +108,8 @@ it.layer(testLayer)("server cli", (it) => { |
| 103 | 108 | |
| 104 | 109 | assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]); |
| 105 | 110 | 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"); | |
| 109 | 113 | }), |
| 110 | 114 | ); |
| 111 | 115 | |
| ... | ... | @@ -118,9 +122,8 @@ it.layer(testLayer)("server cli", (it) => { |
| 118 | 122 | |
| 119 | 123 | assert.equal(findAvailablePort.mock.calls.length, 0); |
| 120 | 124 | 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"); | |
| 124 | 127 | }), |
| 125 | 128 | ); |
| 126 | 129 |
apps/server/src/main.ts+122-185| ... | ... | @@ -1,21 +1,19 @@ |
| 1 | import net from "node:net"; | |
| 2 | import os from "node:os"; | |
| 3 | ||
| 4 | import { ServiceMap, Data, Effect, Layer, Option, Config, Path, Schema, FileSystem } from "effect"; | |
| 1 | import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect"; | |
| 5 | 2 | import { Command, Flag } from "effect/unstable/cli"; |
| 6 | 3 | |
| 7 | import { fixPath } from "./fixPath"; | |
| 8 | import { Open } from "./open"; | |
| 9 | 4 | import { |
| 10 | makeServerPersistenceLayer, | |
| 11 | makeServerProviderLayer, | |
| 12 | makeServerRuntimeServicesLayer, | |
| 13 | Server, | |
| 14 | } from "./wsServer"; | |
| 15 | ||
| 16 | const DEFAULT_PORT = 3773; | |
| 17 | ||
| 18 | type RuntimeMode = "web" | "desktop"; | |
| 5 | DEFAULT_PORT, | |
| 6 | NetService, | |
| 7 | resolveStaticDir, | |
| 8 | ServerConfig, | |
| 9 | type RuntimeMode, | |
| 10 | type ServerConfigShape, | |
| 11 | } from "./config"; | |
| 12 | import { fixPath, resolveStateDir } from "./os-jank"; | |
| 13 | import { Open } from "./open"; | |
| 14 | import * as SqlitePersistence from "./persistence/Layers/Sqlite"; | |
| 15 | import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; | |
| 16 | import { Server } from "./wsServer"; | |
| 19 | 17 | |
| 20 | 18 | export class StartupError extends Data.TaggedError("StartupError")<{ |
| 21 | 19 | readonly message: string; |
| ... | ... | @@ -29,21 +27,34 @@ interface CliInput { |
| 29 | 27 | |
| 30 | 28 | export interface CliConfigShape { |
| 31 | 29 | 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>; | |
| 34 | 31 | readonly resolveStaticDir: Effect.Effect<string | undefined>; |
| 35 | 32 | } |
| 36 | 33 | |
| 37 | export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()( | |
| 38 | "server/CliConfig", | |
| 39 | ) {} | |
| 34 | export 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 | } | |
| 40 | 51 | |
| 41 | 52 | const CliEnvConfig = Config.all({ |
| 42 | 53 | mode: Config.string("T3CODE_MODE").pipe( |
| 43 | 54 | Config.option, |
| 44 | 55 | Config.map( |
| 45 | Option.match({ | |
| 46 | onNone: () => "web" as const, | |
| 56 | Option.match<RuntimeMode, string>({ | |
| 57 | onNone: () => "web", | |
| 47 | 58 | onSome: (value) => (value === "desktop" ? "desktop" : "web"), |
| 48 | 59 | }), |
| 49 | 60 | ), |
| ... | ... | @@ -64,159 +75,102 @@ const CliEnvConfig = Config.all({ |
| 64 | 75 | ), |
| 65 | 76 | }); |
| 66 | 77 | |
| 67 | const 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 | ||
| 76 | const 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 | ||
| 84 | const 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 | }); | |
| 78 | const 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 | }, | |
| 102 | 102 | }); |
| 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 | ||
| 111 | const 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 | }), | |
| 123 | 122 | ); |
| 124 | if (monorepoStat) return monorepoClient; | |
| 125 | return undefined; | |
| 126 | }); | |
| 127 | 123 | |
| 128 | const 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 | ), | |
| 124 | const 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)), | |
| 138 | 130 | ); |
| 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; | |
| 150 | 131 | |
| 151 | return { | |
| 152 | mode, | |
| 153 | port, | |
| 154 | stateDir, | |
| 155 | devUrl, | |
| 156 | noBrowser, | |
| 157 | authToken, | |
| 158 | staticDir, | |
| 159 | cwd: cliConfig.cwd, | |
| 160 | }; | |
| 161 | }); | |
| 162 | ||
| 163 | const 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.", | |
| 132 | const 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), | |
| 173 | 159 | }); |
| 174 | } | |
| 175 | 160 | |
| 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 | } | |
| 197 | 171 | |
| 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))); | |
| 220 | 174 | |
| 221 | 175 | export function makeCliCommand() { |
| 222 | 176 | const portFlag = Flag.integer("port").pipe( |
| ... | ... | @@ -234,23 +188,6 @@ export function makeCliCommand() { |
| 234 | 188 | token: tokenFlag, |
| 235 | 189 | }).pipe( |
| 236 | 190 | Command.withDescription("Run the T3 Code server."), |
| 237 | Command.withHandler((input) => makeServerProgram(input)), | |
| 191 | Command.withHandler((input) => Effect.scoped(makeServerProgram(input))), | |
| 238 | 192 | ); |
| 239 | 193 | } |
| 240 | ||
| 241 | export 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"; |
| 2 | 2 | |
| 3 | 3 | import { EDITORS, type EditorId } from "@t3tools/contracts"; |
| 4 | 4 | import { ServiceMap, Schema, Effect, Layer } from "effect"; |
| 5 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 5 | 6 | |
| 6 | 7 | export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", { |
| 7 | 8 | message: Schema.String, |
| ... | ... | @@ -25,7 +26,7 @@ export interface OpenShape { |
| 25 | 26 | |
| 26 | 27 | export class Open extends ServiceMap.Service<Open, OpenShape>()("server/Open") {} |
| 27 | 28 | |
| 28 | export function resolveEditorLaunch( | |
| 29 | function resolveEditorLaunch( | |
| 29 | 30 | input: OpenInEditorInput, |
| 30 | 31 | platform: NodeJS.Platform = process.platform, |
| 31 | 32 | ): EditorLaunch { |
| ... | ... | @@ -52,42 +53,26 @@ export function resolveEditorLaunch( |
| 52 | 53 | } |
| 53 | 54 | } |
| 54 | 55 | |
| 55 | const 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 | }; | |
| 56 | const make = Effect.gen(function* () { | |
| 57 | const open = yield* Effect.promise(() => import("open")); | |
| 58 | const { spawn } = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 90 | 59 | |
| 91 | export 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 | }); | |
| 92 | 77 | |
| 93 | export const OpenDefaultService = OpenDefault; | |
| 78 | export const OpenLive = Layer.effect(Open, make); |
apps/server/src/os-jank.ts created+39| ... | ... | @@ -0,0 +1,39 @@ |
| 1 | import * as OS from "node:os"; | |
| 2 | import { Effect, Path } from "effect"; | |
| 3 | import { execFileSync } from "node:child_process"; | |
| 4 | ||
| 5 | export 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 | ||
| 22 | export 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 | ||
| 33 | export 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"; |
| 2 | 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 3 | 3 | |
| 4 | 4 | import { runMigrations } from "../Migrations.ts"; |
| 5 | import { ServerConfig } from "../../config.ts"; | |
| 5 | 6 | |
| 6 | 7 | type RuntimeSqliteLayerConfig = { |
| 7 | 8 | readonly filename: string; |
| ... | ... | @@ -47,3 +48,10 @@ export const SqlitePersistenceMemory = Layer.provideMerge( |
| 47 | 48 | setup, |
| 48 | 49 | makeRuntimeSqliteLayer({ filename: ":memory:" }), |
| 49 | 50 | ); |
| 51 | ||
| 52 | export 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 @@ |
| 1 | import path from "node:path"; | |
| 2 | ||
| 3 | import { NodeServices } from "@effect/platform-node"; | |
| 4 | import { Effect, Layer } from "effect"; | |
| 5 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 6 | ||
| 7 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; | |
| 8 | import { ServerConfig } from "./config"; | |
| 9 | import { CheckpointStore } from "./checkpointing/Services/CheckpointStore"; | |
| 10 | import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; | |
| 11 | import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; | |
| 12 | import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; | |
| 13 | import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; | |
| 14 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"; | |
| 15 | import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor"; | |
| 16 | import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor"; | |
| 17 | import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; | |
| 18 | import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; | |
| 19 | import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; | |
| 20 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; | |
| 21 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; | |
| 22 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; | |
| 23 | import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; | |
| 24 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; | |
| 25 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; | |
| 26 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; | |
| 27 | import { ProviderService } from "./provider/Services/ProviderService"; | |
| 28 | ||
| 29 | export 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 | ||
| 50 | export 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"; |
| 6 | 6 | |
| 7 | 7 | import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect"; |
| 8 | 8 | import { describe, expect, it, afterEach, vi } from "vitest"; |
| 9 | import { createServer, makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./wsServer"; | |
| 9 | import { createServer } from "./wsServer"; | |
| 10 | 10 | import WebSocket from "ws"; |
| 11 | import { ServerConfig, type ServerConfigShape } from "./config"; | |
| 12 | import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; | |
| 11 | 13 | |
| 12 | 14 | import { |
| 13 | 15 | DEFAULT_TERMINAL_ID, |
| ... | ... | @@ -40,7 +42,7 @@ import type { TerminalManager } from "./terminalManager"; |
| 40 | 42 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; |
| 41 | 43 | import { SqlClient } from "effect/unstable/sql"; |
| 42 | 44 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; |
| 43 | import type { OpenShape } from "./open"; | |
| 45 | import { Open, type OpenShape } from "./open"; | |
| 44 | 46 | |
| 45 | 47 | interface PendingMessages { |
| 46 | 48 | queue: unknown[]; |
| ... | ... | @@ -56,6 +58,11 @@ const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId |
| 56 | 58 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); |
| 57 | 59 | const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); |
| 58 | 60 | |
| 61 | const defaultOpenService: OpenShape = { | |
| 62 | openBrowser: () => Effect.void, | |
| 63 | openInEditor: () => Effect.void, | |
| 64 | }; | |
| 65 | ||
| 59 | 66 | class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> { |
| 60 | 67 | private readonly sessions = new Map<string, TerminalSessionSnapshot>(); |
| 61 | 68 | |
| ... | ... | @@ -323,28 +330,37 @@ describe("WebSocket Server", () => { |
| 323 | 330 | const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); |
| 324 | 331 | const scope = await Effect.runPromise(Scope.make("sequential")); |
| 325 | 332 | 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); | |
| 327 | 346 | const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); |
| 328 | 347 | const runtimeLayer = Layer.merge( |
| 329 | 348 | makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), |
| 330 | 349 | infrastructureLayer, |
| 331 | 350 | ); |
| 351 | const dependenciesLayer = Layer.empty.pipe( | |
| 352 | Layer.provideMerge(runtimeLayer), | |
| 353 | Layer.provideMerge(openLayer), | |
| 354 | Layer.provideMerge(serverConfigLayer), | |
| 355 | ); | |
| 332 | 356 | const runtimeServices = await Effect.runPromise( |
| 333 | Layer.build(runtimeLayer).pipe(Scope.provide(scope)), | |
| 357 | Layer.build(dependenciesLayer).pipe(Scope.provide(scope)), | |
| 334 | 358 | ); |
| 335 | 359 | |
| 336 | 360 | try { |
| 337 | 361 | const runtime = await Effect.runPromise( |
| 338 | 362 | 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, | |
| 348 | 364 | ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), |
| 349 | 365 | ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}), |
| 350 | 366 | }).pipe(Effect.provide(runtimeServices), Scope.provide(scope)), |
apps/server/src/wsServer.ts+17-100| ... | ... | @@ -1,6 +1,5 @@ |
| 1 | 1 | import fs from "node:fs"; |
| 2 | 2 | import http from "node:http"; |
| 3 | import os from "node:os"; | |
| 4 | 3 | import path from "node:path"; |
| 5 | 4 | import type { Duplex } from "node:stream"; |
| 6 | 5 | |
| ... | ... | @@ -16,7 +15,7 @@ import { |
| 16 | 15 | WsPush, |
| 17 | 16 | WsResponse, |
| 18 | 17 | } from "@t3tools/contracts"; |
| 19 | import { NodeHttpServer, NodeServices } from "@effect/platform-node"; | |
| 18 | import { NodeHttpServer } from "@effect/platform-node"; | |
| 20 | 19 | import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream } from "effect"; |
| 21 | 20 | import { WebSocketServer, type WebSocket } from "ws"; |
| 22 | 21 | |
| ... | ... | @@ -34,30 +33,14 @@ import { |
| 34 | 33 | import { TerminalManager } from "./terminalManager"; |
| 35 | 34 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; |
| 36 | 35 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 37 | import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; | |
| 38 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"; | |
| 39 | import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor"; | |
| 40 | import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor"; | |
| 41 | import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; | |
| 42 | import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; | |
| 43 | import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; | |
| 44 | 36 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; |
| 45 | 37 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; |
| 46 | 38 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; |
| 47 | import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; | |
| 48 | import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; | |
| 49 | import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; | |
| 50 | import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; | |
| 51 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; | |
| 52 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; | |
| 53 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; | |
| 54 | 39 | import { ProviderService } from "./provider/Services/ProviderService"; |
| 55 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; | |
| 56 | import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; | |
| 57 | 40 | import { CheckpointStore } from "./checkpointing/Services/CheckpointStore"; |
| 58 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 59 | 41 | import { clamp } from "effect/Number"; |
| 60 | import { OpenDefaultService, type OpenShape } from "./open"; | |
| 42 | import { Open } from "./open"; | |
| 43 | import { ServerConfig } from "./config"; | |
| 61 | 44 | |
| 62 | 45 | const MIME_TYPES: Record<string, string> = { |
| 63 | 46 | ".html": "text/html; charset=utf-8", |
| ... | ... | @@ -75,24 +58,16 @@ const MIME_TYPES: Record<string, string> = { |
| 75 | 58 | }; |
| 76 | 59 | |
| 77 | 60 | export interface ServerOptions { |
| 78 | port: number; | |
| 79 | host?: string | undefined; | |
| 80 | cwd: string; | |
| 81 | 61 | autoBootstrapProjectFromCwd?: boolean | undefined; |
| 82 | stateDir?: string | undefined; | |
| 83 | staticDir?: string | undefined; | |
| 84 | devUrl?: string | undefined; | |
| 85 | 62 | logWebSocketEvents?: boolean | undefined; |
| 86 | 63 | gitManager?: GitManager | undefined; |
| 87 | 64 | terminalManager?: TerminalManager | undefined; |
| 88 | authToken?: string | undefined; | |
| 89 | open?: OpenShape | undefined; | |
| 90 | 65 | } |
| 91 | 66 | |
| 92 | 67 | export interface ServerShape { |
| 93 | 68 | 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>; | |
| 96 | 71 | readonly stopSignal: Effect.Effect<unknown, never>; |
| 97 | 72 | } |
| 98 | 73 | |
| ... | ... | @@ -174,66 +149,11 @@ export type ServerCoreRuntimeServices = |
| 174 | 149 | | OrchestrationEngineService |
| 175 | 150 | | ProjectionSnapshotQuery |
| 176 | 151 | | CheckpointStore |
| 177 | | OrchestrationReactor; | |
| 152 | | OrchestrationReactor | |
| 153 | | Open; | |
| 178 | 154 | |
| 179 | 155 | export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService; |
| 180 | 156 | |
| 181 | export function makeServerPersistenceLayer(stateDir: string) { | |
| 182 | const dbPath = path.join(stateDir, "orchestration.sqlite"); | |
| 183 | return makeSqlitePersistenceLive(dbPath); | |
| 184 | } | |
| 185 | ||
| 186 | export 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 | ||
| 202 | export 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 | ||
| 237 | 157 | export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()( |
| 238 | 158 | "ServerLifecycleError", |
| 239 | 159 | { |
| ... | ... | @@ -246,22 +166,18 @@ class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("Ro |
| 246 | 166 | message: Schema.String, |
| 247 | 167 | }) {} |
| 248 | 168 | |
| 249 | export const createServer = Effect.fn(function* ( | |
| 250 | options: ServerOptions, | |
| 251 | ): Effect.fn.Return<http.Server, unknown, Scope.Scope | ServerRuntimeServices> { | |
| 169 | export 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; | |
| 252 | 175 | const { |
| 253 | port, | |
| 254 | host, | |
| 255 | cwd, | |
| 256 | autoBootstrapProjectFromCwd = false, | |
| 257 | staticDir, | |
| 258 | devUrl, | |
| 259 | 176 | logWebSocketEvents: explicitLogWsEvents, |
| 260 | 177 | gitManager = new GitManager(), |
| 261 | 178 | terminalManager = new TerminalManager(), |
| 262 | authToken, | |
| 263 | open: openService = OpenDefaultService, | |
| 264 | 179 | } = options; |
| 180 | const autoBootstrapProjectFromCwd = options.autoBootstrapProjectFromCwd ?? mode === "web"; | |
| 265 | 181 | |
| 266 | 182 | const clients = new Set<WebSocket>(); |
| 267 | 183 | const logger = createLogger("ws"); |
| ... | ... | @@ -299,7 +215,7 @@ export const createServer = Effect.fn(function* ( |
| 299 | 215 | const httpServer = http.createServer((req, res) => { |
| 300 | 216 | // In dev mode, redirect to Vite dev server |
| 301 | 217 | if (devUrl) { |
| 302 | res.writeHead(302, { Location: devUrl }); | |
| 218 | res.writeHead(302, { Location: devUrl.href }); | |
| 303 | 219 | res.end(); |
| 304 | 220 | return; |
| 305 | 221 | } |
| ... | ... | @@ -382,6 +298,7 @@ export const createServer = Effect.fn(function* ( |
| 382 | 298 | const liveCheckpointStore = yield* CheckpointStore; |
| 383 | 299 | const liveProviderService = yield* ProviderService; |
| 384 | 300 | const orchestrationReactor = yield* OrchestrationReactor; |
| 301 | const { openInEditor } = yield* Open; | |
| 385 | 302 | |
| 386 | 303 | const subscriptionsScope = yield* Scope.make("sequential"); |
| 387 | 304 | yield* Effect.addFinalizer(() => |
| ... | ... | @@ -574,7 +491,7 @@ export const createServer = Effect.fn(function* ( |
| 574 | 491 | |
| 575 | 492 | case WS_METHODS.shellOpenInEditor: { |
| 576 | 493 | const body = stripRequestTag(request.body); |
| 577 | return yield* openService.openInEditor(body); | |
| 494 | return yield* openInEditor(body); | |
| 578 | 495 | } |
| 579 | 496 | |
| 580 | 497 | case WS_METHODS.gitStatus: { |