| 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,15 +3,19 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | 5 | ||
| 6 | import { CliConfigLive, makeCliCommand } from "./main"; | 6 | import { CliConfig, makeCliCommand } from "./main"; |
| 7 | import { OpenLive } from "./open"; | 7 | import { OpenLive } from "./open"; |
| 8 | import { Command } from "effect/unstable/cli"; | 8 | import { Command } from "effect/unstable/cli"; |
| 9 | import { version } from "../package.json" with { type: "json" }; | 9 | import { version } from "../package.json" with { type: "json" }; |
| 10 | import { ServerLive } from "./wsServer"; | 10 | import { ServerLive } from "./wsServer"; |
| 11 | import { NetService } from "./config"; | ||
| 11 | 12 | ||
| 12 | const RuntimeLayer = Layer.provideMerge( | 13 | const RuntimeLayer = Layer.empty.pipe( |
| 13 | Layer.mergeAll(CliConfigLive, ServerLive, OpenLive), | 14 | Layer.provideMerge(CliConfig.layer), |
| 14 | NodeServices.layer, | 15 | Layer.provideMerge(ServerLive), |
| 16 | Layer.provideMerge(OpenLive), | ||
| 17 | Layer.provideMerge(NetService.layer), | ||
| 18 | Layer.provideMerge(NodeServices.layer), | ||
| 15 | ); | 19 | ); |
| 16 | 20 | ||
| 17 | Command.run(makeCliCommand(), { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain); | 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,14 +8,17 @@ import * as Command from "effect/unstable/cli/Command"; |
| 8 | import { beforeEach } from "vitest"; | 8 | import { beforeEach } from "vitest"; |
| 9 | 9 | ||
| 10 | import { CliConfig, makeCliCommand, type CliConfigShape } from "./main"; | 10 | import { CliConfig, makeCliCommand, type CliConfigShape } from "./main"; |
| 11 | import { NetService, ServerConfig, type ServerConfigShape } from "./config"; | ||
| 11 | import { Open, type OpenShape } from "./open"; | 12 | import { Open, type OpenShape } from "./open"; |
| 12 | import { Server, type ServerShape, type ServerOptions } from "./wsServer"; | 13 | import { Server, type ServerShape, type ServerOptions } from "./wsServer"; |
| 13 | 14 | ||
| 14 | const start = vi.fn(() => undefined); | 15 | const start = vi.fn(() => undefined); |
| 15 | const stop = vi.fn(() => undefined); | 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 | Effect.acquireRelease( | 19 | Effect.acquireRelease( |
| 18 | Effect.sync(() => { | 20 | Effect.gen(function* () { |
| 21 | resolvedConfig = yield* ServerConfig; | ||
| 19 | start(); | 22 | start(); |
| 20 | return {} as unknown as Http.Server; | 23 | return {} as unknown as Http.Server; |
| 21 | }), | 24 | }), |
| ... | @@ -28,9 +31,11 @@ const testLayer = Layer.mergeAll( | ... | @@ -28,9 +31,11 @@ const testLayer = Layer.mergeAll( |
| 28 | Layer.succeed(CliConfig, { | 31 | Layer.succeed(CliConfig, { |
| 29 | cwd: "/tmp/t3-test-workspace", | 32 | cwd: "/tmp/t3-test-workspace", |
| 30 | fixPath: Effect.void, | 33 | fixPath: Effect.void, |
| 31 | findAvailablePort, | ||
| 32 | resolveStaticDir: Effect.undefined, | 34 | resolveStaticDir: Effect.undefined, |
| 33 | } satisfies CliConfigShape), | 35 | } satisfies CliConfigShape), |
| 36 | Layer.succeed(NetService, { | ||
| 37 | findAvailablePort, | ||
| 38 | }), | ||
| 34 | Layer.succeed(Server, { | 39 | Layer.succeed(Server, { |
| 35 | createServer, | 40 | createServer, |
| 36 | stopSignal: Effect.void, | 41 | stopSignal: Effect.void, |
| ... | @@ -52,11 +57,13 @@ const runCli = ( | ... | @@ -52,11 +57,13 @@ const runCli = ( |
| 52 | 57 | ||
| 53 | beforeEach(() => { | 58 | beforeEach(() => { |
| 54 | vi.clearAllMocks(); | 59 | vi.clearAllMocks(); |
| 60 | resolvedConfig = null; | ||
| 55 | start.mockImplementation(() => undefined); | 61 | start.mockImplementation(() => undefined); |
| 56 | stop.mockImplementation(() => undefined); | 62 | stop.mockImplementation(() => undefined); |
| 57 | createServer.mockImplementation((_: ServerOptions) => | 63 | createServer.mockImplementation((_: ServerOptions | undefined) => |
| 58 | Effect.acquireRelease( | 64 | Effect.acquireRelease( |
| 59 | Effect.sync(() => { | 65 | Effect.gen(function* () { |
| 66 | resolvedConfig = yield* ServerConfig; | ||
| 60 | start(); | 67 | start(); |
| 61 | return {} as unknown as Http.Server; | 68 | return {} as unknown as Http.Server; |
| 62 | }), | 69 | }), |
| ... | @@ -72,9 +79,8 @@ it.layer(testLayer)("server cli", (it) => { | ... | @@ -72,9 +79,8 @@ it.layer(testLayer)("server cli", (it) => { |
| 72 | yield* runCli(["--port", "4010", "--token", "secret"]); | 79 | yield* runCli(["--port", "4010", "--token", "secret"]); |
| 73 | 80 | ||
| 74 | assert.equal(createServer.mock.calls.length, 1); | 81 | assert.equal(createServer.mock.calls.length, 1); |
| 75 | const options = createServer.mock.calls[0]?.[0]; | 82 | assert.equal(resolvedConfig?.port, 4010); |
| 76 | assert.equal(options?.port, 4010); | 83 | assert.equal(resolvedConfig?.authToken, "secret"); |
| 77 | assert.equal(options?.authToken, "secret"); | ||
| 78 | assert.equal(start.mock.calls.length, 1); | 84 | assert.equal(start.mock.calls.length, 1); |
| 79 | assert.equal(stop.mock.calls.length, 1); | 85 | assert.equal(stop.mock.calls.length, 1); |
| 80 | }), | 86 | }), |
| ... | @@ -89,9 +95,8 @@ it.layer(testLayer)("server cli", (it) => { | ... | @@ -89,9 +95,8 @@ it.layer(testLayer)("server cli", (it) => { |
| 89 | }); | 95 | }); |
| 90 | 96 | ||
| 91 | assert.equal(createServer.mock.calls.length, 1); | 97 | assert.equal(createServer.mock.calls.length, 1); |
| 92 | const options = createServer.mock.calls[0]?.[0]; | 98 | assert.equal(resolvedConfig?.port, 4999); |
| 93 | assert.equal(options?.port, 4999); | 99 | assert.equal(resolvedConfig?.authToken, "env-token"); |
| 94 | assert.equal(options?.authToken, "env-token"); | ||
| 95 | assert.equal(findAvailablePort.mock.calls.length, 0); | 100 | assert.equal(findAvailablePort.mock.calls.length, 0); |
| 96 | }), | 101 | }), |
| 97 | ); | 102 | ); |
| ... | @@ -103,9 +108,8 @@ it.layer(testLayer)("server cli", (it) => { | ... | @@ -103,9 +108,8 @@ it.layer(testLayer)("server cli", (it) => { |
| 103 | 108 | ||
| 104 | assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]); | 109 | assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]); |
| 105 | assert.equal(createServer.mock.calls.length, 1); | 110 | assert.equal(createServer.mock.calls.length, 1); |
| 106 | const options = createServer.mock.calls[0]?.[0]; | 111 | assert.equal(resolvedConfig?.port, 5444); |
| 107 | assert.equal(options?.port, 5444); | 112 | assert.equal(resolvedConfig?.mode, "web"); |
| 108 | assert.equal(options?.host, undefined); | ||
| 109 | }), | 113 | }), |
| 110 | ); | 114 | ); |
| 111 | 115 | ||
| ... | @@ -118,9 +122,8 @@ it.layer(testLayer)("server cli", (it) => { | ... | @@ -118,9 +122,8 @@ it.layer(testLayer)("server cli", (it) => { |
| 118 | 122 | ||
| 119 | assert.equal(findAvailablePort.mock.calls.length, 0); | 123 | assert.equal(findAvailablePort.mock.calls.length, 0); |
| 120 | assert.equal(createServer.mock.calls.length, 1); | 124 | assert.equal(createServer.mock.calls.length, 1); |
| 121 | const options = createServer.mock.calls[0]?.[0]; | 125 | assert.equal(resolvedConfig?.port, 3773); |
| 122 | assert.equal(options?.port, 3773); | 126 | assert.equal(resolvedConfig?.mode, "desktop"); |
| 123 | assert.equal(options?.host, "127.0.0.1"); | ||
| 124 | }), | 127 | }), |
| 125 | ); | 128 | ); |
| 126 | 129 |
apps/server/src/main.ts+122-185| ... | @@ -1,21 +1,19 @@ | ... | @@ -1,21 +1,19 @@ |
| 1 | import net from "node:net"; | 1 | import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect"; |
| 2 | import os from "node:os"; | ||
| 3 | |||
| 4 | import { ServiceMap, Data, Effect, Layer, Option, Config, Path, Schema, FileSystem } from "effect"; | ||
| 5 | import { Command, Flag } from "effect/unstable/cli"; | 2 | import { Command, Flag } from "effect/unstable/cli"; |
| 6 | 3 | ||
| 7 | import { fixPath } from "./fixPath"; | ||
| 8 | import { Open } from "./open"; | ||
| 9 | import { | 4 | import { |
| 10 | makeServerPersistenceLayer, | 5 | DEFAULT_PORT, |
| 11 | makeServerProviderLayer, | 6 | NetService, |
| 12 | makeServerRuntimeServicesLayer, | 7 | resolveStaticDir, |
| 13 | Server, | 8 | ServerConfig, |
| 14 | } from "./wsServer"; | 9 | type RuntimeMode, |
| 15 | 10 | type ServerConfigShape, | |
| 16 | const DEFAULT_PORT = 3773; | 11 | } from "./config"; |
| 17 | 12 | import { fixPath, resolveStateDir } from "./os-jank"; | |
| 18 | type RuntimeMode = "web" | "desktop"; | 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 | export class StartupError extends Data.TaggedError("StartupError")<{ | 18 | export class StartupError extends Data.TaggedError("StartupError")<{ |
| 21 | readonly message: string; | 19 | readonly message: string; |
| ... | @@ -29,21 +27,34 @@ interface CliInput { | ... | @@ -29,21 +27,34 @@ interface CliInput { |
| 29 | 27 | ||
| 30 | export interface CliConfigShape { | 28 | export interface CliConfigShape { |
| 31 | readonly cwd: string; | 29 | readonly cwd: string; |
| 32 | readonly fixPath: Effect.Effect<unknown, never>; | 30 | readonly fixPath: Effect.Effect<void>; |
| 33 | readonly findAvailablePort: (preferred: number) => Effect.Effect<number, StartupError>; | ||
| 34 | readonly resolveStaticDir: Effect.Effect<string | undefined>; | 31 | readonly resolveStaticDir: Effect.Effect<string | undefined>; |
| 35 | } | 32 | } |
| 36 | 33 | ||
| 37 | export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()( | 34 | export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()("server/CliConfig") { |
| 38 | "server/CliConfig", | 35 | static readonly layer = Layer.effect( |
| 39 | ) {} | 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 | const CliEnvConfig = Config.all({ | 52 | const CliEnvConfig = Config.all({ |
| 42 | mode: Config.string("T3CODE_MODE").pipe( | 53 | mode: Config.string("T3CODE_MODE").pipe( |
| 43 | Config.option, | 54 | Config.option, |
| 44 | Config.map( | 55 | Config.map( |
| 45 | Option.match({ | 56 | Option.match<RuntimeMode, string>({ |
| 46 | onNone: () => "web" as const, | 57 | onNone: () => "web", |
| 47 | onSome: (value) => (value === "desktop" ? "desktop" : "web"), | 58 | onSome: (value) => (value === "desktop" ? "desktop" : "web"), |
| 48 | }), | 59 | }), |
| 49 | ), | 60 | ), |
| ... | @@ -64,159 +75,102 @@ const CliEnvConfig = Config.all({ | ... | @@ -64,159 +75,102 @@ const CliEnvConfig = Config.all({ |
| 64 | ), | 75 | ), |
| 65 | }); | 76 | }); |
| 66 | 77 | ||
| 67 | const expandHomePath = Effect.fn(function* (input: string) { | 78 | const ServerConfigLive = (input: CliInput) => |
| 68 | const { join, sep } = yield* Path.Path; | 79 | Layer.effect( |
| 69 | if (input === "~") return os.homedir(); | 80 | ServerConfig, |
| 70 | if (input.startsWith(`~${sep}`)) { | 81 | Effect.gen(function* () { |
| 71 | return join(os.homedir(), input.slice(sep.length)); | 82 | const cliConfig = yield* CliConfig; |
| 72 | } | 83 | const { findAvailablePort } = yield* NetService; |
| 73 | return input; | 84 | const env = yield* CliEnvConfig.asEffect().pipe( |
| 74 | }); | 85 | Effect.mapError( |
| 75 | 86 | (cause) => | |
| 76 | const resolveStateDir = Effect.fn(function* (raw: string | undefined) { | 87 | new StartupError({ message: "Failed to read environment configuration", cause }), |
| 77 | const { join, resolve } = yield* Path.Path; | 88 | ), |
| 78 | if (!raw || raw.trim().length === 0) { | 89 | ); |
| 79 | return join(os.homedir(), ".t3", "userdata"); | 90 | |
| 80 | } | 91 | const port = yield* Option.match(input.port, { |
| 81 | return resolve(yield* expandHomePath(raw.trim())); | 92 | onSome: (value) => Effect.succeed(value), |
| 82 | }); | 93 | onNone: () => { |
| 83 | 94 | if (env.port) { | |
| 84 | const findAvailablePort = (preferred: number) => | 95 | return Effect.succeed(env.port); |
| 85 | Effect.callback<number, StartupError>((resume) => { | 96 | } |
| 86 | const server = net.createServer(); | 97 | if (env.mode === "desktop") { |
| 87 | server.listen(preferred, () => { | 98 | return Effect.succeed(DEFAULT_PORT); |
| 88 | server.close(() => resume(Effect.succeed(preferred))); | 99 | } |
| 89 | }); | 100 | return findAvailablePort(DEFAULT_PORT); |
| 90 | server.on("error", () => { | 101 | }, |
| 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 | }); | ||
| 102 | }); | 102 | }); |
| 103 | fallback.on("error", (cause) => { | 103 | const stateDir = yield* resolveStateDir(env.stateDir); |
| 104 | resume( | 104 | const devUrl = env.devUrl; |
| 105 | Effect.fail(new StartupError({ message: "Failed to find an available port", cause })), | 105 | const noBrowser = env.noBrowser ?? env.mode === "desktop"; |
| 106 | ); | 106 | const authToken = Option.getOrUndefined(input.token) ?? env.authToken; |
| 107 | }); | 107 | const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; |
| 108 | }); | 108 | const host = env.mode === "desktop" ? "127.0.0.1" : undefined; |
| 109 | }); | 109 | |
| 110 | 110 | return { | |
| 111 | const resolveStaticDir = Effect.fn(function* () { | 111 | mode: env.mode, |
| 112 | const { join, resolve } = yield* Path.Path; | 112 | port, |
| 113 | const { stat } = yield* FileSystem.FileSystem; | 113 | cwd: cliConfig.cwd, |
| 114 | const bundledClient = resolve(join(import.meta.dirname, "client")); | 114 | host, |
| 115 | const bundledStat = yield* stat(join(bundledClient, "index.html")).pipe( | 115 | stateDir, |
| 116 | Effect.orElseSucceed(() => undefined), | 116 | staticDir, |
| 117 | ); | 117 | devUrl, |
| 118 | if (bundledStat) return bundledClient; | 118 | noBrowser, |
| 119 | 119 | authToken, | |
| 120 | const monorepoClient = resolve(join(import.meta.dirname, "../../web/dist")); | 120 | } satisfies ServerConfigShape; |
| 121 | const monorepoStat = yield* stat(join(monorepoClient, "index.html")).pipe( | 121 | }), |
| 122 | Effect.orElseSucceed(() => undefined), | ||
| 123 | ); | 122 | ); |
| 124 | if (monorepoStat) return monorepoClient; | ||
| 125 | return undefined; | ||
| 126 | }); | ||
| 127 | 123 | ||
| 128 | const resolveCliConfig = Effect.fn(function* (input: CliInput) { | 124 | const LayerLive = (input: CliInput) => |
| 129 | const cliConfig = yield* CliConfig; | 125 | Layer.empty.pipe( |
| 130 | const env = yield* CliEnvConfig.asEffect().pipe( | 126 | Layer.provideMerge(makeServerRuntimeServicesLayer()), |
| 131 | Effect.mapError( | 127 | Layer.provideMerge(makeServerProviderLayer()), |
| 132 | (cause) => | 128 | Layer.provideMerge(SqlitePersistence.layerConfig), |
| 133 | new StartupError({ | 129 | Layer.provideMerge(ServerConfigLive(input)), |
| 134 | message: "Failed to read environment configuration", | ||
| 135 | cause, | ||
| 136 | }), | ||
| 137 | ), | ||
| 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 { | 132 | const makeServerProgram = (input: CliInput) => |
| 152 | mode, | 133 | Effect.gen(function* () { |
| 153 | port, | 134 | const cliConfig = yield* CliConfig; |
| 154 | stateDir, | 135 | const { createServer, stopSignal } = yield* Server; |
| 155 | devUrl, | 136 | const openDeps = yield* Open; |
| 156 | noBrowser, | 137 | yield* cliConfig.fixPath; |
| 157 | authToken, | 138 | |
| 158 | staticDir, | 139 | const config = yield* ServerConfig; |
| 159 | cwd: cliConfig.cwd, | 140 | |
| 160 | }; | 141 | if (!config.devUrl && !config.staticDir) { |
| 161 | }); | 142 | yield* Effect.logWarning( |
| 162 | 143 | "web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", | |
| 163 | const makeServerProgram = Effect.fn(function* (input: CliInput) { | 144 | { |
| 164 | const cliConfig = yield* CliConfig; | 145 | hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.", |
| 165 | const { createServer, stopSignal } = yield* Server; | 146 | }, |
| 166 | const openDeps = yield* Open; | 147 | ); |
| 167 | yield* cliConfig.fixPath; | 148 | } |
| 168 | const config = yield* resolveCliConfig(input); | 149 | |
| 169 | 150 | yield* createServer(); | |
| 170 | if (!config.devUrl && !config.staticDir) { | 151 | |
| 171 | yield* Effect.logWarning("web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", { | 152 | const url = `http://localhost:${config.port}`; |
| 172 | hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.", | 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); | 161 | if (!config.noBrowser) { |
| 177 | const providerLayer = makeServerProviderLayer(config.stateDir); | 162 | const target = config.devUrl?.toString() ?? url; |
| 178 | const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); | 163 | yield* openDeps.openBrowser(target).pipe( |
| 179 | const serverRuntimeLayer = Layer.merge( | 164 | Effect.catch(() => |
| 180 | makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), | 165 | Effect.logInfo("browser auto-open unavailable", { |
| 181 | infrastructureLayer, | 166 | hint: `Open ${target} in your browser.`, |
| 182 | ); | 167 | }), |
| 183 | 168 | ), | |
| 184 | yield* createServer({ | 169 | ); |
| 185 | port: config.port, | 170 | } |
| 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 | ); | ||
| 197 | 171 | ||
| 198 | const url = `http://localhost:${config.port}`; | 172 | return yield* stopSignal; |
| 199 | yield* Effect.logInfo("T3 Code running", { | 173 | }).pipe(Effect.provide(LayerLive(input))); |
| 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); | ||
| 220 | 174 | ||
| 221 | export function makeCliCommand() { | 175 | export function makeCliCommand() { |
| 222 | const portFlag = Flag.integer("port").pipe( | 176 | const portFlag = Flag.integer("port").pipe( |
| ... | @@ -234,23 +188,6 @@ export function makeCliCommand() { | ... | @@ -234,23 +188,6 @@ export function makeCliCommand() { |
| 234 | token: tokenFlag, | 188 | token: tokenFlag, |
| 235 | }).pipe( | 189 | }).pipe( |
| 236 | Command.withDescription("Run the T3 Code server."), | 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,6 +2,7 @@ import { spawn } from "node:child_process"; |
| 2 | 2 | ||
| 3 | import { EDITORS, type EditorId } from "@t3tools/contracts"; | 3 | import { EDITORS, type EditorId } from "@t3tools/contracts"; |
| 4 | import { ServiceMap, Schema, Effect, Layer } from "effect"; | 4 | import { ServiceMap, Schema, Effect, Layer } from "effect"; |
| 5 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 5 | 6 | ||
| 6 | export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", { | 7 | export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", { |
| 7 | message: Schema.String, | 8 | message: Schema.String, |
| ... | @@ -25,7 +26,7 @@ export interface OpenShape { | ... | @@ -25,7 +26,7 @@ export interface OpenShape { |
| 25 | 26 | ||
| 26 | export class Open extends ServiceMap.Service<Open, OpenShape>()("server/Open") {} | 27 | export class Open extends ServiceMap.Service<Open, OpenShape>()("server/Open") {} |
| 27 | 28 | ||
| 28 | export function resolveEditorLaunch( | 29 | function resolveEditorLaunch( |
| 29 | input: OpenInEditorInput, | 30 | input: OpenInEditorInput, |
| 30 | platform: NodeJS.Platform = process.platform, | 31 | platform: NodeJS.Platform = process.platform, |
| 31 | ): EditorLaunch { | 32 | ): EditorLaunch { |
| ... | @@ -52,42 +53,26 @@ export function resolveEditorLaunch( | ... | @@ -52,42 +53,26 @@ export function resolveEditorLaunch( |
| 52 | } | 53 | } |
| 53 | } | 54 | } |
| 54 | 55 | ||
| 55 | const OpenDefault: OpenShape = { | 56 | const make = Effect.gen(function* () { |
| 56 | openBrowser: (target) => | 57 | const open = yield* Effect.promise(() => import("open")); |
| 57 | Effect.tryPromise({ | 58 | const { spawn } = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 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 | }; | ||
| 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,6 +2,7 @@ import { Effect, Layer, FileSystem, Path } from "effect"; |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 3 | 3 | ||
| 4 | import { runMigrations } from "../Migrations.ts"; | 4 | import { runMigrations } from "../Migrations.ts"; |
| 5 | import { ServerConfig } from "../../config.ts"; | ||
| 5 | 6 | ||
| 6 | type RuntimeSqliteLayerConfig = { | 7 | type RuntimeSqliteLayerConfig = { |
| 7 | readonly filename: string; | 8 | readonly filename: string; |
| ... | @@ -47,3 +48,10 @@ export const SqlitePersistenceMemory = Layer.provideMerge( | ... | @@ -47,3 +48,10 @@ export const SqlitePersistenceMemory = Layer.provideMerge( |
| 47 | setup, | 48 | setup, |
| 48 | makeRuntimeSqliteLayer({ filename: ":memory:" }), | 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,8 +6,10 @@ import { EventEmitter } from "node:events"; |
| 6 | 6 | ||
| 7 | import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect"; | 7 | import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect"; |
| 8 | import { describe, expect, it, afterEach, vi } from "vitest"; | 8 | import { describe, expect, it, afterEach, vi } from "vitest"; |
| 9 | import { createServer, makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./wsServer"; | 9 | import { createServer } from "./wsServer"; |
| 10 | import WebSocket from "ws"; | 10 | import WebSocket from "ws"; |
| 11 | import { ServerConfig, type ServerConfigShape } from "./config"; | ||
| 12 | import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; | ||
| 11 | 13 | ||
| 12 | import { | 14 | import { |
| 13 | DEFAULT_TERMINAL_ID, | 15 | DEFAULT_TERMINAL_ID, |
| ... | @@ -40,7 +42,7 @@ import type { TerminalManager } from "./terminalManager"; | ... | @@ -40,7 +42,7 @@ import type { TerminalManager } from "./terminalManager"; |
| 40 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; | 42 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; |
| 41 | import { SqlClient } from "effect/unstable/sql"; | 43 | import { SqlClient } from "effect/unstable/sql"; |
| 42 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; | 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 | interface PendingMessages { | 47 | interface PendingMessages { |
| 46 | queue: unknown[]; | 48 | queue: unknown[]; |
| ... | @@ -56,6 +58,11 @@ const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId | ... | @@ -56,6 +58,11 @@ const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId |
| 56 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | 58 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); |
| 57 | const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); | 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 | class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> { | 66 | class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> { |
| 60 | private readonly sessions = new Map<string, TerminalSessionSnapshot>(); | 67 | private readonly sessions = new Map<string, TerminalSessionSnapshot>(); |
| 61 | 68 | ||
| ... | @@ -323,28 +330,37 @@ describe("WebSocket Server", () => { | ... | @@ -323,28 +330,37 @@ describe("WebSocket Server", () => { |
| 323 | const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); | 330 | const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); |
| 324 | const scope = await Effect.runPromise(Scope.make("sequential")); | 331 | const scope = await Effect.runPromise(Scope.make("sequential")); |
| 325 | const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; | 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 | const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); | 346 | const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); |
| 328 | const runtimeLayer = Layer.merge( | 347 | const runtimeLayer = Layer.merge( |
| 329 | makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), | 348 | makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), |
| 330 | infrastructureLayer, | 349 | infrastructureLayer, |
| 331 | ); | 350 | ); |
| 351 | const dependenciesLayer = Layer.empty.pipe( | ||
| 352 | Layer.provideMerge(runtimeLayer), | ||
| 353 | Layer.provideMerge(openLayer), | ||
| 354 | Layer.provideMerge(serverConfigLayer), | ||
| 355 | ); | ||
| 332 | const runtimeServices = await Effect.runPromise( | 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 | try { | 360 | try { |
| 337 | const runtime = await Effect.runPromise( | 361 | const runtime = await Effect.runPromise( |
| 338 | createServer({ | 362 | createServer({ |
| 339 | port: 0, | 363 | autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false, |
| 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 } : {}), | ||
| 348 | ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), | 364 | ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), |
| 349 | ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}), | 365 | ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}), |
| 350 | }).pipe(Effect.provide(runtimeServices), Scope.provide(scope)), | 366 | }).pipe(Effect.provide(runtimeServices), Scope.provide(scope)), |
apps/server/src/wsServer.ts+17-100| ... | @@ -1,6 +1,5 @@ | ... | @@ -1,6 +1,5 @@ |
| 1 | import fs from "node:fs"; | 1 | import fs from "node:fs"; |
| 2 | import http from "node:http"; | 2 | import http from "node:http"; |
| 3 | import os from "node:os"; | ||
| 4 | import path from "node:path"; | 3 | import path from "node:path"; |
| 5 | import type { Duplex } from "node:stream"; | 4 | import type { Duplex } from "node:stream"; |
| 6 | 5 | ||
| ... | @@ -16,7 +15,7 @@ import { | ... | @@ -16,7 +15,7 @@ import { |
| 16 | WsPush, | 15 | WsPush, |
| 17 | WsResponse, | 16 | WsResponse, |
| 18 | } from "@t3tools/contracts"; | 17 | } from "@t3tools/contracts"; |
| 19 | import { NodeHttpServer, NodeServices } from "@effect/platform-node"; | 18 | import { NodeHttpServer } from "@effect/platform-node"; |
| 20 | import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream } from "effect"; | 19 | import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream } from "effect"; |
| 21 | import { WebSocketServer, type WebSocket } from "ws"; | 20 | import { WebSocketServer, type WebSocket } from "ws"; |
| 22 | 21 | ||
| ... | @@ -34,30 +33,14 @@ import { | ... | @@ -34,30 +33,14 @@ import { |
| 34 | import { TerminalManager } from "./terminalManager"; | 33 | import { TerminalManager } from "./terminalManager"; |
| 35 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; | 34 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; |
| 36 | import { searchWorkspaceEntries } from "./workspaceEntries"; | 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 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; | 36 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; |
| 45 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; | 37 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; |
| 46 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; | 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 | import { ProviderService } from "./provider/Services/ProviderService"; | 39 | import { ProviderService } from "./provider/Services/ProviderService"; |
| 55 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; | ||
| 56 | import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; | ||
| 57 | import { CheckpointStore } from "./checkpointing/Services/CheckpointStore"; | 40 | import { CheckpointStore } from "./checkpointing/Services/CheckpointStore"; |
| 58 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 59 | import { clamp } from "effect/Number"; | 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 | const MIME_TYPES: Record<string, string> = { | 45 | const MIME_TYPES: Record<string, string> = { |
| 63 | ".html": "text/html; charset=utf-8", | 46 | ".html": "text/html; charset=utf-8", |
| ... | @@ -75,24 +58,16 @@ const MIME_TYPES: Record<string, string> = { | ... | @@ -75,24 +58,16 @@ const MIME_TYPES: Record<string, string> = { |
| 75 | }; | 58 | }; |
| 76 | 59 | ||
| 77 | export interface ServerOptions { | 60 | export interface ServerOptions { |
| 78 | port: number; | ||
| 79 | host?: string | undefined; | ||
| 80 | cwd: string; | ||
| 81 | autoBootstrapProjectFromCwd?: boolean | undefined; | 61 | autoBootstrapProjectFromCwd?: boolean | undefined; |
| 82 | stateDir?: string | undefined; | ||
| 83 | staticDir?: string | undefined; | ||
| 84 | devUrl?: string | undefined; | ||
| 85 | logWebSocketEvents?: boolean | undefined; | 62 | logWebSocketEvents?: boolean | undefined; |
| 86 | gitManager?: GitManager | undefined; | 63 | gitManager?: GitManager | undefined; |
| 87 | terminalManager?: TerminalManager | undefined; | 64 | terminalManager?: TerminalManager | undefined; |
| 88 | authToken?: string | undefined; | ||
| 89 | open?: OpenShape | undefined; | ||
| 90 | } | 65 | } |
| 91 | 66 | ||
| 92 | export interface ServerShape { | 67 | export interface ServerShape { |
| 93 | readonly createServer: ( | 68 | readonly createServer: ( |
| 94 | options: ServerOptions, | 69 | options?: ServerOptions, |
| 95 | ) => Effect.Effect<http.Server, unknown, Scope.Scope | ServerRuntimeServices>; | 70 | ) => Effect.Effect<http.Server, unknown, Scope.Scope | ServerRuntimeServices | ServerConfig>; |
| 96 | readonly stopSignal: Effect.Effect<unknown, never>; | 71 | readonly stopSignal: Effect.Effect<unknown, never>; |
| 97 | } | 72 | } |
| 98 | 73 | ||
| ... | @@ -174,66 +149,11 @@ export type ServerCoreRuntimeServices = | ... | @@ -174,66 +149,11 @@ export type ServerCoreRuntimeServices = |
| 174 | | OrchestrationEngineService | 149 | | OrchestrationEngineService |
| 175 | | ProjectionSnapshotQuery | 150 | | ProjectionSnapshotQuery |
| 176 | | CheckpointStore | 151 | | CheckpointStore |
| 177 | | OrchestrationReactor; | 152 | | OrchestrationReactor |
| 153 | | Open; | ||
| 178 | 154 | ||
| 179 | export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService; | 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 | export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()( | 157 | export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()( |
| 238 | "ServerLifecycleError", | 158 | "ServerLifecycleError", |
| 239 | { | 159 | { |
| ... | @@ -246,22 +166,18 @@ class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("Ro | ... | @@ -246,22 +166,18 @@ class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("Ro |
| 246 | message: Schema.String, | 166 | message: Schema.String, |
| 247 | }) {} | 167 | }) {} |
| 248 | 168 | ||
| 249 | export const createServer = Effect.fn(function* ( | 169 | export const createServer = Effect.fn(function* (options: ServerOptions = {}): Effect.fn.Return< |
| 250 | options: ServerOptions, | 170 | http.Server, |
| 251 | ): Effect.fn.Return<http.Server, unknown, Scope.Scope | ServerRuntimeServices> { | 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 | const { | 175 | const { |
| 253 | port, | ||
| 254 | host, | ||
| 255 | cwd, | ||
| 256 | autoBootstrapProjectFromCwd = false, | ||
| 257 | staticDir, | ||
| 258 | devUrl, | ||
| 259 | logWebSocketEvents: explicitLogWsEvents, | 176 | logWebSocketEvents: explicitLogWsEvents, |
| 260 | gitManager = new GitManager(), | 177 | gitManager = new GitManager(), |
| 261 | terminalManager = new TerminalManager(), | 178 | terminalManager = new TerminalManager(), |
| 262 | authToken, | ||
| 263 | open: openService = OpenDefaultService, | ||
| 264 | } = options; | 179 | } = options; |
| 180 | const autoBootstrapProjectFromCwd = options.autoBootstrapProjectFromCwd ?? mode === "web"; | ||
| 265 | 181 | ||
| 266 | const clients = new Set<WebSocket>(); | 182 | const clients = new Set<WebSocket>(); |
| 267 | const logger = createLogger("ws"); | 183 | const logger = createLogger("ws"); |
| ... | @@ -299,7 +215,7 @@ export const createServer = Effect.fn(function* ( | ... | @@ -299,7 +215,7 @@ export const createServer = Effect.fn(function* ( |
| 299 | const httpServer = http.createServer((req, res) => { | 215 | const httpServer = http.createServer((req, res) => { |
| 300 | // In dev mode, redirect to Vite dev server | 216 | // In dev mode, redirect to Vite dev server |
| 301 | if (devUrl) { | 217 | if (devUrl) { |
| 302 | res.writeHead(302, { Location: devUrl }); | 218 | res.writeHead(302, { Location: devUrl.href }); |
| 303 | res.end(); | 219 | res.end(); |
| 304 | return; | 220 | return; |
| 305 | } | 221 | } |
| ... | @@ -382,6 +298,7 @@ export const createServer = Effect.fn(function* ( | ... | @@ -382,6 +298,7 @@ export const createServer = Effect.fn(function* ( |
| 382 | const liveCheckpointStore = yield* CheckpointStore; | 298 | const liveCheckpointStore = yield* CheckpointStore; |
| 383 | const liveProviderService = yield* ProviderService; | 299 | const liveProviderService = yield* ProviderService; |
| 384 | const orchestrationReactor = yield* OrchestrationReactor; | 300 | const orchestrationReactor = yield* OrchestrationReactor; |
| 301 | const { openInEditor } = yield* Open; | ||
| 385 | 302 | ||
| 386 | const subscriptionsScope = yield* Scope.make("sequential"); | 303 | const subscriptionsScope = yield* Scope.make("sequential"); |
| 387 | yield* Effect.addFinalizer(() => | 304 | yield* Effect.addFinalizer(() => |
| ... | @@ -574,7 +491,7 @@ export const createServer = Effect.fn(function* ( | ... | @@ -574,7 +491,7 @@ export const createServer = Effect.fn(function* ( |
| 574 | 491 | ||
| 575 | case WS_METHODS.shellOpenInEditor: { | 492 | case WS_METHODS.shellOpenInEditor: { |
| 576 | const body = stripRequestTag(request.body); | 493 | const body = stripRequestTag(request.body); |
| 577 | return yield* openService.openInEditor(body); | 494 | return yield* openInEditor(body); |
| 578 | } | 495 | } |
| 579 | 496 | ||
| 580 | case WS_METHODS.gitStatus: { | 497 | case WS_METHODS.gitStatus: { |