diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..d23553c9c832afe38cc95046466a22b10e59f850 --- /dev/null +++ b/apps/server/src/config.ts @@ -0,0 +1,87 @@ +import * as Net from "node:net"; +import { Data, Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; + +export const DEFAULT_PORT = 3773; + +export type RuntimeMode = "web" | "desktop"; + +export interface ServerConfigShape { + readonly mode: RuntimeMode; + readonly port: number; + readonly host: string | undefined; + readonly cwd: string; + readonly stateDir: string; + readonly staticDir: string | undefined; + readonly devUrl: URL | undefined; + readonly noBrowser: boolean; + readonly authToken: string | undefined; +} + +export class ServerConfig extends ServiceMap.Service()( + "server/ServerConfig", +) {} + +// Helpers + +export class NetError extends Data.TaggedError("NetError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface NetServiceShape { + readonly findAvailablePort: (preferred: number) => Effect.Effect; +} + +export class NetService extends ServiceMap.Service()( + "server/NetService", +) { + static readonly layer = Layer.succeed(NetService, { + findAvailablePort: (preferred) => + Effect.callback((resume) => { + const server = Net.createServer(); + server.listen(preferred, () => { + server.close(() => resume(Effect.succeed(preferred))); + }); + server.on("error", () => { + const fallback = Net.createServer(); + fallback.listen(0, () => { + const addr = fallback.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + fallback.close(() => { + resume( + port > 0 + ? Effect.succeed(port) + : Effect.fail(new NetError({ message: "Could not find an available port." })), + ); + }); + }); + fallback.on("error", (cause) => { + resume( + Effect.fail(new NetError({ message: "Failed to find an available port", cause })), + ); + }); + }); + }), + }); +} + +export const resolveStaticDir = Effect.fn(function* () { + const { join, resolve } = yield* Path.Path; + const { stat } = yield* FileSystem.FileSystem; + const bundledClient = resolve(join(import.meta.dirname, "client")); + const bundledStat = yield* stat(join(bundledClient, "index.html")).pipe( + Effect.orElseSucceed(() => undefined), + ); + if (bundledStat) { + return bundledClient; + } + + const monorepoClient = resolve(join(import.meta.dirname, "../../web/dist")); + const monorepoStat = yield* stat(join(monorepoClient, "index.html")).pipe( + Effect.orElseSucceed(() => undefined), + ); + if (monorepoStat) { + return monorepoClient; + } + return undefined; +}); diff --git a/apps/server/src/fixPath.ts b/apps/server/src/fixPath.ts deleted file mode 100644 index be7ea9447c4ddc9e313e642fd7b89a54cc975628..0000000000000000000000000000000000000000 --- a/apps/server/src/fixPath.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { execFileSync } from "node:child_process"; - -export function fixPath(): void { - if (process.platform !== "darwin") return; - - try { - const shell = process.env.SHELL ?? "/bin/zsh"; - const result = execFileSync(shell, ["-ilc", "echo -n $PATH"], { - encoding: "utf8", - timeout: 5000, - }); - if (result) { - process.env.PATH = result; - } - } catch { - // Silently ignore — keep default PATH - } -} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index b8985c9b282a7ca9409adc6bc121a532ab1268fd..711bedff37e0d962e0e82909e6cb3ee75e1d9c0e 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -3,15 +3,19 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { CliConfigLive, makeCliCommand } from "./main"; +import { CliConfig, makeCliCommand } from "./main"; import { OpenLive } from "./open"; import { Command } from "effect/unstable/cli"; import { version } from "../package.json" with { type: "json" }; import { ServerLive } from "./wsServer"; +import { NetService } from "./config"; -const RuntimeLayer = Layer.provideMerge( - Layer.mergeAll(CliConfigLive, ServerLive, OpenLive), - NodeServices.layer, +const RuntimeLayer = Layer.empty.pipe( + Layer.provideMerge(CliConfig.layer), + Layer.provideMerge(ServerLive), + Layer.provideMerge(OpenLive), + Layer.provideMerge(NetService.layer), + Layer.provideMerge(NodeServices.layer), ); Command.run(makeCliCommand(), { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain); diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index a75dbb593769e36c9ac0c4087d7087aa6443bb32..836c97ea7d2d20a21c3d28470dbec2736d66e033 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -8,14 +8,17 @@ import * as Command from "effect/unstable/cli/Command"; import { beforeEach } from "vitest"; import { CliConfig, makeCliCommand, type CliConfigShape } from "./main"; +import { NetService, ServerConfig, type ServerConfigShape } from "./config"; import { Open, type OpenShape } from "./open"; import { Server, type ServerShape, type ServerOptions } from "./wsServer"; const start = vi.fn(() => undefined); const stop = vi.fn(() => undefined); -const createServer = vi.fn((_: ServerOptions) => +let resolvedConfig: ServerConfigShape | null = null; +const createServer = vi.fn((_: ServerOptions | undefined) => Effect.acquireRelease( - Effect.sync(() => { + Effect.gen(function* () { + resolvedConfig = yield* ServerConfig; start(); return {} as unknown as Http.Server; }), @@ -28,9 +31,11 @@ const testLayer = Layer.mergeAll( Layer.succeed(CliConfig, { cwd: "/tmp/t3-test-workspace", fixPath: Effect.void, - findAvailablePort, resolveStaticDir: Effect.undefined, } satisfies CliConfigShape), + Layer.succeed(NetService, { + findAvailablePort, + }), Layer.succeed(Server, { createServer, stopSignal: Effect.void, @@ -52,11 +57,13 @@ const runCli = ( beforeEach(() => { vi.clearAllMocks(); + resolvedConfig = null; start.mockImplementation(() => undefined); stop.mockImplementation(() => undefined); - createServer.mockImplementation((_: ServerOptions) => + createServer.mockImplementation((_: ServerOptions | undefined) => Effect.acquireRelease( - Effect.sync(() => { + Effect.gen(function* () { + resolvedConfig = yield* ServerConfig; start(); return {} as unknown as Http.Server; }), @@ -72,9 +79,8 @@ it.layer(testLayer)("server cli", (it) => { yield* runCli(["--port", "4010", "--token", "secret"]); assert.equal(createServer.mock.calls.length, 1); - const options = createServer.mock.calls[0]?.[0]; - assert.equal(options?.port, 4010); - assert.equal(options?.authToken, "secret"); + assert.equal(resolvedConfig?.port, 4010); + assert.equal(resolvedConfig?.authToken, "secret"); assert.equal(start.mock.calls.length, 1); assert.equal(stop.mock.calls.length, 1); }), @@ -89,9 +95,8 @@ it.layer(testLayer)("server cli", (it) => { }); assert.equal(createServer.mock.calls.length, 1); - const options = createServer.mock.calls[0]?.[0]; - assert.equal(options?.port, 4999); - assert.equal(options?.authToken, "env-token"); + assert.equal(resolvedConfig?.port, 4999); + assert.equal(resolvedConfig?.authToken, "env-token"); assert.equal(findAvailablePort.mock.calls.length, 0); }), ); @@ -103,9 +108,8 @@ it.layer(testLayer)("server cli", (it) => { assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]); assert.equal(createServer.mock.calls.length, 1); - const options = createServer.mock.calls[0]?.[0]; - assert.equal(options?.port, 5444); - assert.equal(options?.host, undefined); + assert.equal(resolvedConfig?.port, 5444); + assert.equal(resolvedConfig?.mode, "web"); }), ); @@ -118,9 +122,8 @@ it.layer(testLayer)("server cli", (it) => { assert.equal(findAvailablePort.mock.calls.length, 0); assert.equal(createServer.mock.calls.length, 1); - const options = createServer.mock.calls[0]?.[0]; - assert.equal(options?.port, 3773); - assert.equal(options?.host, "127.0.0.1"); + assert.equal(resolvedConfig?.port, 3773); + assert.equal(resolvedConfig?.mode, "desktop"); }), ); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 26d1d70beb210f47f02f44ee6e3829691a406902..661ccae1919f8819ef2bc9a4454c2c7ce6d0fbc3 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1,21 +1,19 @@ -import net from "node:net"; -import os from "node:os"; - -import { ServiceMap, Data, Effect, Layer, Option, Config, Path, Schema, FileSystem } from "effect"; +import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect"; import { Command, Flag } from "effect/unstable/cli"; -import { fixPath } from "./fixPath"; -import { Open } from "./open"; import { - makeServerPersistenceLayer, - makeServerProviderLayer, - makeServerRuntimeServicesLayer, - Server, -} from "./wsServer"; - -const DEFAULT_PORT = 3773; - -type RuntimeMode = "web" | "desktop"; + DEFAULT_PORT, + NetService, + resolveStaticDir, + ServerConfig, + type RuntimeMode, + type ServerConfigShape, +} from "./config"; +import { fixPath, resolveStateDir } from "./os-jank"; +import { Open } from "./open"; +import * as SqlitePersistence from "./persistence/Layers/Sqlite"; +import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; +import { Server } from "./wsServer"; export class StartupError extends Data.TaggedError("StartupError")<{ readonly message: string; @@ -29,21 +27,34 @@ interface CliInput { export interface CliConfigShape { readonly cwd: string; - readonly fixPath: Effect.Effect; - readonly findAvailablePort: (preferred: number) => Effect.Effect; + readonly fixPath: Effect.Effect; readonly resolveStaticDir: Effect.Effect; } -export class CliConfig extends ServiceMap.Service()( - "server/CliConfig", -) {} +export class CliConfig extends ServiceMap.Service()("server/CliConfig") { + static readonly layer = Layer.effect( + CliConfig, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return { + cwd: process.cwd(), + fixPath: Effect.sync(fixPath), + resolveStaticDir: resolveStaticDir().pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + } satisfies CliConfigShape; + }), + ); +} const CliEnvConfig = Config.all({ mode: Config.string("T3CODE_MODE").pipe( Config.option, Config.map( - Option.match({ - onNone: () => "web" as const, + Option.match({ + onNone: () => "web", onSome: (value) => (value === "desktop" ? "desktop" : "web"), }), ), @@ -64,159 +75,102 @@ const CliEnvConfig = Config.all({ ), }); -const expandHomePath = Effect.fn(function* (input: string) { - const { join, sep } = yield* Path.Path; - if (input === "~") return os.homedir(); - if (input.startsWith(`~${sep}`)) { - return join(os.homedir(), input.slice(sep.length)); - } - return input; -}); +const ServerConfigLive = (input: CliInput) => + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const cliConfig = yield* CliConfig; + const { findAvailablePort } = yield* NetService; + const env = yield* CliEnvConfig.asEffect().pipe( + Effect.mapError( + (cause) => + new StartupError({ message: "Failed to read environment configuration", cause }), + ), + ); -const resolveStateDir = Effect.fn(function* (raw: string | undefined) { - const { join, resolve } = yield* Path.Path; - if (!raw || raw.trim().length === 0) { - return join(os.homedir(), ".t3", "userdata"); - } - return resolve(yield* expandHomePath(raw.trim())); -}); - -const findAvailablePort = (preferred: number) => - Effect.callback((resume) => { - const server = net.createServer(); - server.listen(preferred, () => { - server.close(() => resume(Effect.succeed(preferred))); - }); - server.on("error", () => { - const fallback = net.createServer(); - fallback.listen(0, () => { - const addr = fallback.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - fallback.close(() => { - resume( - port > 0 - ? Effect.succeed(port) - : Effect.fail(new StartupError({ message: "Could not find an available port." })), - ); - }); - }); - fallback.on("error", (cause) => { - resume( - Effect.fail(new StartupError({ message: "Failed to find an available port", cause })), - ); + const port = yield* Option.match(input.port, { + onSome: (value) => Effect.succeed(value), + onNone: () => { + if (env.port) { + return Effect.succeed(env.port); + } + if (env.mode === "desktop") { + return Effect.succeed(DEFAULT_PORT); + } + return findAvailablePort(DEFAULT_PORT); + }, }); - }); - }); + const stateDir = yield* resolveStateDir(env.stateDir); + const devUrl = env.devUrl; + const noBrowser = env.noBrowser ?? env.mode === "desktop"; + const authToken = Option.getOrUndefined(input.token) ?? env.authToken; + const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; + const host = env.mode === "desktop" ? "127.0.0.1" : undefined; -const resolveStaticDir = Effect.fn(function* () { - const { join, resolve } = yield* Path.Path; - const { stat } = yield* FileSystem.FileSystem; - const bundledClient = resolve(join(import.meta.dirname, "client")); - const bundledStat = yield* stat(join(bundledClient, "index.html")).pipe( - Effect.orElseSucceed(() => undefined), + return { + mode: env.mode, + port, + cwd: cliConfig.cwd, + host, + stateDir, + staticDir, + devUrl, + noBrowser, + authToken, + } satisfies ServerConfigShape; + }), ); - if (bundledStat) return bundledClient; - const monorepoClient = resolve(join(import.meta.dirname, "../../web/dist")); - const monorepoStat = yield* stat(join(monorepoClient, "index.html")).pipe( - Effect.orElseSucceed(() => undefined), +const LayerLive = (input: CliInput) => + Layer.empty.pipe( + Layer.provideMerge(makeServerRuntimeServicesLayer()), + Layer.provideMerge(makeServerProviderLayer()), + Layer.provideMerge(SqlitePersistence.layerConfig), + Layer.provideMerge(ServerConfigLive(input)), ); - if (monorepoStat) return monorepoClient; - return undefined; -}); -const resolveCliConfig = Effect.fn(function* (input: CliInput) { - const cliConfig = yield* CliConfig; - const env = yield* CliEnvConfig.asEffect().pipe( - Effect.mapError( - (cause) => - new StartupError({ - message: "Failed to read environment configuration", - cause, - }), - ), - ); - const mode: RuntimeMode = env.mode === "desktop" ? "desktop" : "web"; +const makeServerProgram = (input: CliInput) => + Effect.gen(function* () { + const cliConfig = yield* CliConfig; + const { createServer, stopSignal } = yield* Server; + const openDeps = yield* Open; + yield* cliConfig.fixPath; - const cliPort = Option.getOrUndefined(input.port); - const port = - (cliPort === undefined ? env.port : cliPort) ?? - (mode === "desktop" ? DEFAULT_PORT : yield* cliConfig.findAvailablePort(DEFAULT_PORT)); - const stateDir = yield* resolveStateDir(env.stateDir); - const devUrl = env.devUrl; - const noBrowser = env.noBrowser ?? mode === "desktop"; - const authToken = Option.getOrUndefined(input.token) ?? env.authToken; - const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; + const config = yield* ServerConfig; - return { - mode, - port, - stateDir, - devUrl, - noBrowser, - authToken, - staticDir, - cwd: cliConfig.cwd, - }; -}); + if (!config.devUrl && !config.staticDir) { + yield* Effect.logWarning( + "web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", + { + hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.", + }, + ); + } -const makeServerProgram = Effect.fn(function* (input: CliInput) { - const cliConfig = yield* CliConfig; - const { createServer, stopSignal } = yield* Server; - const openDeps = yield* Open; - yield* cliConfig.fixPath; - const config = yield* resolveCliConfig(input); + yield* createServer(); - if (!config.devUrl && !config.staticDir) { - yield* Effect.logWarning("web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", { - hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.", + const url = `http://localhost:${config.port}`; + yield* Effect.logInfo("T3 Code running", { + url, + cwd: config.cwd, + mode: config.mode, + stateDir: config.stateDir, + authEnabled: Boolean(config.authToken), }); - } - - const persistenceLayer = makeServerPersistenceLayer(config.stateDir); - const providerLayer = makeServerProviderLayer(config.stateDir); - const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); - const serverRuntimeLayer = Layer.merge( - makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), - infrastructureLayer, - ); - - yield* createServer({ - port: config.port, - host: config.mode === "desktop" ? "127.0.0.1" : undefined, - cwd: config.cwd, - autoBootstrapProjectFromCwd: config.mode === "web", - stateDir: config.stateDir, - staticDir: config.staticDir, - devUrl: config.devUrl?.href, - authToken: config.authToken, - }).pipe( - Effect.provide(serverRuntimeLayer), - Effect.mapError((cause) => new StartupError({ message: "Failed to start server", cause })), - ); - - const url = `http://localhost:${config.port}`; - yield* Effect.logInfo("T3 Code running", { - url, - cwd: config.cwd, - mode: config.mode, - stateDir: config.stateDir, - authEnabled: Boolean(config.authToken), - }); - if (!config.noBrowser) { - const target = config.devUrl ?? url; - yield* openDeps.openBrowser(target.toString()).pipe( - Effect.catch(() => - Effect.logInfo("browser auto-open unavailable", { - hint: `Open ${target} in your browser.`, - }), - ), - ); - } + if (!config.noBrowser) { + const target = config.devUrl?.toString() ?? url; + yield* openDeps.openBrowser(target).pipe( + Effect.catch(() => + Effect.logInfo("browser auto-open unavailable", { + hint: `Open ${target} in your browser.`, + }), + ), + ); + } - return yield* stopSignal; -}, Effect.scoped); + return yield* stopSignal; + }).pipe(Effect.provide(LayerLive(input))); export function makeCliCommand() { const portFlag = Flag.integer("port").pipe( @@ -234,23 +188,6 @@ export function makeCliCommand() { token: tokenFlag, }).pipe( Command.withDescription("Run the T3 Code server."), - Command.withHandler((input) => makeServerProgram(input)), + Command.withHandler((input) => Effect.scoped(makeServerProgram(input))), ); } - -export const CliConfigLive = Layer.effect( - CliConfig, - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return { - cwd: process.cwd(), - fixPath: Effect.sync(fixPath), - findAvailablePort, - resolveStaticDir: resolveStaticDir().pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), - } satisfies CliConfigShape; - }), -); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index 723d656eb6217af4122afeb08d9e0b23acca84ee..91b63cf04478f227bd02c420cb2de1d339529507 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import { EDITORS, type EditorId } from "@t3tools/contracts"; import { ServiceMap, Schema, Effect, Layer } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export class OpenError extends Schema.TaggedErrorClass()("OpenError", { message: Schema.String, @@ -25,7 +26,7 @@ export interface OpenShape { export class Open extends ServiceMap.Service()("server/Open") {} -export function resolveEditorLaunch( +function resolveEditorLaunch( input: OpenInEditorInput, platform: NodeJS.Platform = process.platform, ): EditorLaunch { @@ -52,42 +53,26 @@ export function resolveEditorLaunch( } } -const OpenDefault: OpenShape = { - openBrowser: (target) => - Effect.tryPromise({ - try: async () => { - const open = await import("open"); - await open.default(target); - }, - catch: (cause) => - new OpenError({ - message: "Browser auto-open failed", - cause, - }), - }), - openInEditor: (input) => - Effect.try({ - try: () => { +const make = Effect.gen(function* () { + const open = yield* Effect.promise(() => import("open")); + const { spawn } = yield* ChildProcessSpawner.ChildProcessSpawner; + + return { + openBrowser: (target) => + Effect.tryPromise({ + try: () => open.default(target), + catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }), + }), + openInEditor: (input) => + Effect.gen(function* () { const launch = resolveEditorLaunch(input); - const child = spawn(launch.command, [...launch.args], { - detached: true, - stdio: "ignore", - }); - child.on("error", () => { - // Best-effort behavior for detached editor launches. - }); - child.unref(); - }, - catch: (cause) => - cause instanceof OpenError - ? cause - : new OpenError({ - message: "Failed to open editor", - cause, - }), - }), -}; + const child = yield* spawn(ChildProcess.make(launch.command, launch.args)); + yield* Effect.forkDetach(child.exitCode); + }).pipe( + Effect.catch((error) => Effect.logError("Failed to open editor", { cause: error })), + Effect.scoped, + ), + } satisfies OpenShape; +}); -export const OpenLive = Layer.succeed(Open, OpenDefault); - -export const OpenDefaultService = OpenDefault; +export const OpenLive = Layer.effect(Open, make); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f5e2129da06fae068f70415a18331c03dbc8312 --- /dev/null +++ b/apps/server/src/os-jank.ts @@ -0,0 +1,39 @@ +import * as OS from "node:os"; +import { Effect, Path } from "effect"; +import { execFileSync } from "node:child_process"; + +export function fixPath(): void { + if (process.platform !== "darwin") return; + + try { + const shell = process.env.SHELL ?? "/bin/zsh"; + const result = execFileSync(shell, ["-ilc", "echo -n $PATH"], { + encoding: "utf8", + timeout: 5000, + }); + if (result) { + process.env.PATH = result; + } + } catch { + // Silently ignore — keep default PATH + } +} + +export const expandHomePath = Effect.fn(function* (input: string) { + const { join, sep } = yield* Path.Path; + if (input === "~") { + return OS.homedir(); + } + if (input.startsWith(`~${sep}`)) { + return join(OS.homedir(), input.slice(sep.length)); + } + return input; +}); + +export const resolveStateDir = Effect.fn(function* (raw: string | undefined) { + const { join, resolve } = yield* Path.Path; + if (!raw || raw.trim().length === 0) { + return join(OS.homedir(), ".t3", "userdata"); + } + return resolve(yield* expandHomePath(raw.trim())); +}); diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index cf7e40efa5e96eb31db7d4d704ad42689505a58c..c2396a247cf3409ac96531c65d50111a12df5b11 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -2,6 +2,7 @@ import { Effect, Layer, FileSystem, Path } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; +import { ServerConfig } from "../../config.ts"; type RuntimeSqliteLayerConfig = { readonly filename: string; @@ -47,3 +48,10 @@ export const SqlitePersistenceMemory = Layer.provideMerge( setup, makeRuntimeSqliteLayer({ filename: ":memory:" }), ); + +export const layerConfig = Effect.gen(function* () { + const { stateDir } = yield* ServerConfig; + const { join } = yield* Path.Path; + const dbPath = join(stateDir, "orchestration.sqlite"); + return makeSqlitePersistenceLive(dbPath); +}).pipe(Layer.unwrap); diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts new file mode 100644 index 0000000000000000000000000000000000000000..189dea33a1577aa6be3849e1fd810839c36e7ae4 --- /dev/null +++ b/apps/server/src/serverLayers.ts @@ -0,0 +1,83 @@ +import path from "node:path"; + +import { NodeServices } from "@effect/platform-node"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; +import { ServerConfig } from "./config"; +import { CheckpointStore } from "./checkpointing/Services/CheckpointStore"; +import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; +import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; +import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; +import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; +import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"; +import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor"; +import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor"; +import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; +import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; +import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; +import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; +import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; +import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; +import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; +import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; +import { ProviderService } from "./provider/Services/ProviderService"; + +export function makeServerProviderLayer(): Layer.Layer< + ProviderService, + unknown, + SqlClient.SqlClient | ServerConfig +> { + return Effect.gen(function* () { + const { stateDir } = yield* ServerConfig; + const providerLogsDir = path.join(stateDir, "logs", "providers"); + const codexAdapterLayer = makeCodexAdapterLive({ + nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"), + }); + const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(Layer.provide(codexAdapterLayer)); + const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntimeRepositoryLive), + ); + return makeProviderServiceLive({ + canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"), + }).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); + }).pipe(Layer.unwrap); +} + +export function makeServerRuntimeServicesLayer(): Layer.Layer< + OrchestrationEngineService | ProjectionSnapshotQuery | CheckpointStore | OrchestrationReactor, + unknown, + SqlClient.SqlClient | ProviderService +> { + const orchestrationLayer = OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionPipelineLive), + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + ); + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); + + const runtimeServicesLayer = Layer.mergeAll( + orchestrationLayer, + OrchestrationProjectionSnapshotQueryLive, + checkpointStoreLayer, + ); + const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + ); + const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + ); + const checkpointReactorLayer = CheckpointReactorLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + ); + const orchestrationReactorLayer = OrchestrationReactorLive.pipe( + Layer.provideMerge(runtimeIngestionLayer), + Layer.provideMerge(providerCommandReactorLayer), + Layer.provideMerge(checkpointReactorLayer), + ); + + return orchestrationReactorLayer; +} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index ab92b4353255420db7f649288593c2d1573b0810..eeaf8b1bf80f67dd63a164adcb2b90bd0756840b 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -6,8 +6,10 @@ import { EventEmitter } from "node:events"; import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect"; import { describe, expect, it, afterEach, vi } from "vitest"; -import { createServer, makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./wsServer"; +import { createServer } from "./wsServer"; import WebSocket from "ws"; +import { ServerConfig, type ServerConfigShape } from "./config"; +import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; import { DEFAULT_TERMINAL_ID, @@ -40,7 +42,7 @@ import type { TerminalManager } from "./terminalManager"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; import { SqlClient } from "effect/unstable/sql"; import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; -import type { OpenShape } from "./open"; +import { Open, type OpenShape } from "./open"; interface PendingMessages { queue: unknown[]; @@ -56,6 +58,11 @@ const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); +const defaultOpenService: OpenShape = { + openBrowser: () => Effect.void, + openInEditor: () => Effect.void, +}; + class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> { private readonly sessions = new Map(); @@ -323,28 +330,37 @@ describe("WebSocket Server", () => { const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); const scope = await Effect.runPromise(Scope.make("sequential")); const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; - const providerLayer = options.providerLayer ?? makeServerProviderLayer(stateDir); + const providerLayer = options.providerLayer ?? makeServerProviderLayer(); + const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); + const serverConfigLayer = Layer.succeed(ServerConfig, { + mode: "web", + port: 0, + host: undefined, + cwd: options.cwd ?? "/test/project", + stateDir, + staticDir: undefined, + devUrl: options.devUrl ? new URL(options.devUrl) : undefined, + noBrowser: true, + authToken: options.authToken, + } satisfies ServerConfigShape); const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); const runtimeLayer = Layer.merge( makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), infrastructureLayer, ); + const dependenciesLayer = Layer.empty.pipe( + Layer.provideMerge(runtimeLayer), + Layer.provideMerge(openLayer), + Layer.provideMerge(serverConfigLayer), + ); const runtimeServices = await Effect.runPromise( - Layer.build(runtimeLayer).pipe(Scope.provide(scope)), + Layer.build(dependenciesLayer).pipe(Scope.provide(scope)), ); try { const runtime = await Effect.runPromise( createServer({ - port: 0, - cwd: options.cwd ?? "/test/project", - stateDir, - ...(options.autoBootstrapProjectFromCwd !== undefined - ? { autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd } - : {}), - ...(options.devUrl ? { devUrl: options.devUrl } : {}), - ...(options.authToken ? { authToken: options.authToken } : {}), - ...(options.open ? { open: options.open } : {}), + autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false, ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}), }).pipe(Effect.provide(runtimeServices), Scope.provide(scope)), diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 79deff0cf3a1b5f9ae9e88a98fb8fd1162b5445e..d4afd8e714432fb89255a4955f26c32ff7c9d64d 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -1,6 +1,5 @@ import fs from "node:fs"; import http from "node:http"; -import os from "node:os"; import path from "node:path"; import type { Duplex } from "node:stream"; @@ -16,7 +15,7 @@ import { WsPush, WsResponse, } from "@t3tools/contracts"; -import { NodeHttpServer, NodeServices } from "@effect/platform-node"; +import { NodeHttpServer } from "@effect/platform-node"; import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream } from "effect"; import { WebSocketServer, type WebSocket } from "ws"; @@ -34,30 +33,14 @@ import { import { TerminalManager } from "./terminalManager"; import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; import { searchWorkspaceEntries } from "./workspaceEntries"; -import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; -import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"; -import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor"; -import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor"; -import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; -import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; -import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; -import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; -import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; -import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; -import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; -import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; -import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; -import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; import { ProviderService } from "./provider/Services/ProviderService"; -import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; -import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; import { CheckpointStore } from "./checkpointing/Services/CheckpointStore"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; import { clamp } from "effect/Number"; -import { OpenDefaultService, type OpenShape } from "./open"; +import { Open } from "./open"; +import { ServerConfig } from "./config"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -75,24 +58,16 @@ const MIME_TYPES: Record = { }; export interface ServerOptions { - port: number; - host?: string | undefined; - cwd: string; autoBootstrapProjectFromCwd?: boolean | undefined; - stateDir?: string | undefined; - staticDir?: string | undefined; - devUrl?: string | undefined; logWebSocketEvents?: boolean | undefined; gitManager?: GitManager | undefined; terminalManager?: TerminalManager | undefined; - authToken?: string | undefined; - open?: OpenShape | undefined; } export interface ServerShape { readonly createServer: ( - options: ServerOptions, - ) => Effect.Effect; + options?: ServerOptions, + ) => Effect.Effect; readonly stopSignal: Effect.Effect; } @@ -174,66 +149,11 @@ export type ServerCoreRuntimeServices = | OrchestrationEngineService | ProjectionSnapshotQuery | CheckpointStore - | OrchestrationReactor; + | OrchestrationReactor + | Open; export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService; -export function makeServerPersistenceLayer(stateDir: string) { - const dbPath = path.join(stateDir, "orchestration.sqlite"); - return makeSqlitePersistenceLive(dbPath); -} - -export function makeServerProviderLayer( - stateDir: string, -): Layer.Layer { - const providerLogsDir = path.join(stateDir, "logs", "providers"); - const codexAdapterLayer = makeCodexAdapterLive({ - nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"), - }); - const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(Layer.provide(codexAdapterLayer)); - const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntimeRepositoryLive), - ); - return makeProviderServiceLive({ - canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"), - }).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); -} - -export function makeServerRuntimeServicesLayer(): Layer.Layer< - ServerCoreRuntimeServices, - unknown, - SqlClient.SqlClient | ProviderService -> { - const orchestrationLayer = OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationProjectionPipelineLive), - Layer.provide(OrchestrationEventStoreLive), - Layer.provide(OrchestrationCommandReceiptRepositoryLive), - ); - const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); - - const runtimeServicesLayer = Layer.mergeAll( - orchestrationLayer, - OrchestrationProjectionSnapshotQueryLive, - checkpointStoreLayer, - ); - const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( - Layer.provideMerge(runtimeServicesLayer), - ); - const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( - Layer.provideMerge(runtimeServicesLayer), - ); - const checkpointReactorLayer = CheckpointReactorLive.pipe( - Layer.provideMerge(runtimeServicesLayer), - ); - const orchestrationReactorLayer = OrchestrationReactorLive.pipe( - Layer.provideMerge(runtimeIngestionLayer), - Layer.provideMerge(providerCommandReactorLayer), - Layer.provideMerge(checkpointReactorLayer), - ); - - return orchestrationReactorLayer; -} - export class ServerLifecycleError extends Schema.TaggedErrorClass()( "ServerLifecycleError", { @@ -246,22 +166,18 @@ class RouteRequestError extends Schema.TaggedErrorClass()("Ro message: Schema.String, }) {} -export const createServer = Effect.fn(function* ( - options: ServerOptions, -): Effect.fn.Return { +export const createServer = Effect.fn(function* (options: ServerOptions = {}): Effect.fn.Return< + http.Server, + unknown, // TODO: This should not be unknown + Scope.Scope | ServerRuntimeServices | ServerConfig +> { + const { port, cwd, staticDir, devUrl, authToken, mode, host } = yield* ServerConfig; const { - port, - host, - cwd, - autoBootstrapProjectFromCwd = false, - staticDir, - devUrl, logWebSocketEvents: explicitLogWsEvents, gitManager = new GitManager(), terminalManager = new TerminalManager(), - authToken, - open: openService = OpenDefaultService, } = options; + const autoBootstrapProjectFromCwd = options.autoBootstrapProjectFromCwd ?? mode === "web"; const clients = new Set(); const logger = createLogger("ws"); @@ -299,7 +215,7 @@ export const createServer = Effect.fn(function* ( const httpServer = http.createServer((req, res) => { // In dev mode, redirect to Vite dev server if (devUrl) { - res.writeHead(302, { Location: devUrl }); + res.writeHead(302, { Location: devUrl.href }); res.end(); return; } @@ -382,6 +298,7 @@ export const createServer = Effect.fn(function* ( const liveCheckpointStore = yield* CheckpointStore; const liveProviderService = yield* ProviderService; const orchestrationReactor = yield* OrchestrationReactor; + const { openInEditor } = yield* Open; const subscriptionsScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => @@ -574,7 +491,7 @@ export const createServer = Effect.fn(function* ( case WS_METHODS.shellOpenInEditor: { const body = stripRequestTag(request.body); - return yield* openService.openInEditor(body); + return yield* openInEditor(body); } case WS_METHODS.gitStatus: {