From 8e166848e94315c71cb2c94c3f53e964201f5085 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Mar 2026 18:52:47 -0700 Subject: [PATCH 01/47] add reference --- .reference/server/package.json | 27 ++ .../server/src/Migrations/001_TodoSchema.ts | 32 ++ .reference/server/src/bin.ts | 11 + .reference/server/src/cli.ts | 125 +++++++ .reference/server/src/client.ts | 58 ++++ .reference/server/src/config.ts | 14 + .reference/server/src/contracts.ts | 132 ++++++++ .reference/server/src/messages.ts | 73 +++++ .reference/server/src/migrations.ts | 41 +++ .reference/server/src/model-store.ts | 268 +++++++++++++++ .reference/server/src/server.ts | 155 +++++++++ .reference/server/test/cli-config.test.ts | 79 +++++ .reference/server/test/reset.test.ts | 90 +++++ .reference/server/test/server.test.ts | 310 ++++++++++++++++++ .reference/server/tsconfig.json | 4 + .reference/server/vitest.config.ts | 8 + 16 files changed, 1427 insertions(+) create mode 100644 .reference/server/package.json create mode 100644 .reference/server/src/Migrations/001_TodoSchema.ts create mode 100644 .reference/server/src/bin.ts create mode 100644 .reference/server/src/cli.ts create mode 100644 .reference/server/src/client.ts create mode 100644 .reference/server/src/config.ts create mode 100644 .reference/server/src/contracts.ts create mode 100644 .reference/server/src/messages.ts create mode 100644 .reference/server/src/migrations.ts create mode 100644 .reference/server/src/model-store.ts create mode 100644 .reference/server/src/server.ts create mode 100644 .reference/server/test/cli-config.test.ts create mode 100644 .reference/server/test/reset.test.ts create mode 100644 .reference/server/test/server.test.ts create mode 100644 .reference/server/tsconfig.json create mode 100644 .reference/server/vitest.config.ts diff --git a/.reference/server/package.json b/.reference/server/package.json new file mode 100644 index 0000000000..8df16b6766 --- /dev/null +++ b/.reference/server/package.json @@ -0,0 +1,27 @@ +{ + "name": "@effect-http-ws-cli/server", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + "./client": "./src/client.ts", + "./contracts": "./src/contracts.ts" + }, + "scripts": { + "dev": "node src/bin.ts", + "start": "node src/bin.ts", + "test": "vitest run", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@effect/platform-node": "catalog:", + "@effect/sql-sqlite-node": "catalog:", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "^24.10.0", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/.reference/server/src/Migrations/001_TodoSchema.ts b/.reference/server/src/Migrations/001_TodoSchema.ts new file mode 100644 index 0000000000..aa6c1418e7 --- /dev/null +++ b/.reference/server/src/Migrations/001_TodoSchema.ts @@ -0,0 +1,32 @@ +import * as Effect from "effect/Effect" +import * as SqlClient from "effect/unstable/sql/SqlClient" + +export default Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient + + yield* sql` + CREATE TABLE IF NOT EXISTS todos ( + id TEXT PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + completed INTEGER NOT NULL, + archived INTEGER NOT NULL, + revision INTEGER NOT NULL, + updated_at TEXT NOT NULL + ) + ` + + yield* sql` + CREATE TABLE IF NOT EXISTS todo_events ( + event_offset INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + todo_json TEXT NOT NULL, + change_json TEXT NOT NULL, + archived INTEGER NOT NULL + ) + ` + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_todo_events_archived_offset + ON todo_events (archived, event_offset) + ` +}) diff --git a/.reference/server/src/bin.ts b/.reference/server/src/bin.ts new file mode 100644 index 0000000000..647a33e519 --- /dev/null +++ b/.reference/server/src/bin.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node +import * as NodeRuntime from "@effect/platform-node/NodeRuntime" +import * as NodeServices from "@effect/platform-node/NodeServices" +import * as Effect from "effect/Effect" +import { cli } from "./cli.ts" +import { Command } from "effect/unstable/cli" + +Command.run(cli, { version: "0.1.0" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain +) diff --git a/.reference/server/src/cli.ts b/.reference/server/src/cli.ts new file mode 100644 index 0000000000..75806c2f4e --- /dev/null +++ b/.reference/server/src/cli.ts @@ -0,0 +1,125 @@ +import * as SqliteNode from "@effect/sql-sqlite-node" +import { Command, Flag } from "effect/unstable/cli" +import * as Config from "effect/Config" +import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" +import * as Option from "effect/Option" +import * as Path from "effect/Path" +import { fileURLToPath } from "node:url" +import { ServerConfig } from "./config.ts" +import { runMigrations } from "./migrations.ts" +import { runServer } from "./server.ts" +import type { ServerConfigData } from "./config.ts" + +const defaultAssetsDir = fileURLToPath(new URL("../../public", import.meta.url)) +const defaultDbFilename = fileURLToPath(new URL("../../todo.sqlite", import.meta.url)) + +const hostFlag = Flag.string("host").pipe( + Flag.withDescription("Host interface to bind"), + Flag.optional +) + +const portFlag = Flag.integer("port").pipe( + Flag.withDescription("Port to listen on"), + Flag.optional +) + +const assetsFlag = Flag.directory("assets").pipe( + Flag.withDescription("Directory of static assets"), + Flag.optional +) + +const dbFlag = Flag.string("db").pipe( + Flag.withDescription("SQLite database filename"), + Flag.optional +) + +const requestLoggingFlag = Flag.boolean("request-logging").pipe( + Flag.withDescription("Enable request logging"), + Flag.optional +) + +const frontendDevOriginFlag = Flag.string("frontend-dev-origin").pipe( + Flag.withDescription("Redirect frontend GET requests to a Vite dev server origin"), + Flag.optional +) + +const EnvServerConfig = Config.unwrap({ + host: Config.string("HOST").pipe(Config.withDefault("127.0.0.1")), + port: Config.port("PORT").pipe(Config.withDefault(8787)), + assetsDir: Config.string("ASSETS_DIR").pipe(Config.withDefault(defaultAssetsDir)), + dbFilename: Config.string("DB_FILENAME").pipe(Config.withDefault(defaultDbFilename)), + requestLogging: Config.boolean("REQUEST_LOGGING").pipe(Config.withDefault(true)), + frontendDevOrigin: Config.string("FRONTEND_DEV_ORIGIN").pipe( + Config.option, + Config.map(Option.getOrUndefined) + ) +}) + +export interface CliServerFlags { + readonly host: Option.Option + readonly port: Option.Option + readonly assets: Option.Option + readonly db: Option.Option + readonly requestLogging: Option.Option + readonly frontendDevOrigin: Option.Option +} + +export const resolveServerConfig = ( + flags: CliServerFlags +): Effect.Effect => + Effect.gen(function*() { + const env = yield* EnvServerConfig + return { + host: Option.getOrElse(flags.host, () => env.host), + port: Option.getOrElse(flags.port, () => env.port), + assetsDir: Option.getOrElse(flags.assets, () => env.assetsDir), + dbFilename: Option.getOrElse(flags.db, () => env.dbFilename), + requestLogging: Option.getOrElse(flags.requestLogging, () => env.requestLogging), + frontendDevOrigin: Option.getOrElse(flags.frontendDevOrigin, () => env.frontendDevOrigin) + } + }) + + +export const resetDatabase = (dbFilename: string) => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + if (dbFilename !== ":memory:") { + yield* fs.remove(path.resolve(dbFilename), { force: true }) + } + + const sqliteLayer = SqliteNode.SqliteClient.layer({ + filename: dbFilename + }) + + yield* runMigrations.pipe(Effect.provide(sqliteLayer)) + }) + +const commandFlags = { + host: hostFlag, + port: portFlag, + assets: assetsFlag, + db: dbFlag, + requestLogging: requestLoggingFlag, + frontendDevOrigin: frontendDevOriginFlag +} as const + +const rootCommand = Command.make("effect-http-ws-cli", commandFlags).pipe( + Command.withDescription("Run a unified Effect HTTP + WebSocket server"), + Command.withHandler((flags) => + Effect.flatMap(resolveServerConfig(flags), (config) => + runServer.pipe(Effect.provideService(ServerConfig, config)))), +) + +const resetCommand = Command.make("reset", commandFlags).pipe( + Command.withDescription("Delete the SQLite database file and rerun migrations"), + Command.withHandler((flags) => + Effect.flatMap(resolveServerConfig(flags), (config) => resetDatabase(config.dbFilename)) + ) +) + +export const cli = rootCommand.pipe( + Command.withSubcommands([resetCommand]) +) diff --git a/.reference/server/src/client.ts b/.reference/server/src/client.ts new file mode 100644 index 0000000000..4650807973 --- /dev/null +++ b/.reference/server/src/client.ts @@ -0,0 +1,58 @@ +import { NodeSocket } from "@effect/platform-node" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as RpcClient from "effect/unstable/rpc/RpcClient" +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization" +import * as Stream from "effect/Stream" +import { WsRpcGroup } from "./contracts.ts" + +export const wsRpcProtocolLayer = (wsUrl: string) => + RpcClient.layerProtocolSocket().pipe( + Layer.provide(NodeSocket.layerWebSocket(wsUrl)), + Layer.provide(RpcSerialization.layerJson) + ) + +export const makeWsRpcClient = RpcClient.make(WsRpcGroup) +type WsRpcClient = typeof makeWsRpcClient extends Effect.Effect ? Client : never + +export const withWsRpcClient = ( + wsUrl: string, + f: (client: WsRpcClient) => Effect.Effect +) => + makeWsRpcClient.pipe( + Effect.flatMap(f), + Effect.provide(wsRpcProtocolLayer(wsUrl)) + ) + +export const runClientExample = (wsUrl: string) => + Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function*() { + const echoed = yield* client.echo({ text: "hello from client" }) + const summed = yield* client.sum({ left: 20, right: 22 }) + const time = yield* client.time(undefined) + return { echoed, summed, time } + }) + ) + ) + +export const runSubscriptionExample = (wsUrl: string, modelId: string) => + Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function*() { + const snapshot = yield* client.listTodos({ includeArchived: true }) + const todo = snapshot.todos[0] + if (!todo) { + return [] + } + + yield* client.renameTodo({ id: todo.id, title: `${modelId}: first` }) + yield* client.completeTodo({ id: todo.id, completed: true }) + + return yield* client.subscribeTodos({ fromOffset: snapshot.offset, includeArchived: true }).pipe( + Stream.take(2), + Stream.runCollect + ) + }) + ) + ) diff --git a/.reference/server/src/config.ts b/.reference/server/src/config.ts new file mode 100644 index 0000000000..601135fefb --- /dev/null +++ b/.reference/server/src/config.ts @@ -0,0 +1,14 @@ +import * as ServiceMap from "effect/ServiceMap" + +export interface ServerConfigData { + readonly host: string + readonly port: number + readonly assetsDir: string + readonly dbFilename: string + readonly requestLogging: boolean + readonly frontendDevOrigin: string | undefined +} + +export class ServerConfig extends ServiceMap.Service()( + "effect-http-ws-cli/ServerConfig" +) {} diff --git a/.reference/server/src/contracts.ts b/.reference/server/src/contracts.ts new file mode 100644 index 0000000000..f2b2534f93 --- /dev/null +++ b/.reference/server/src/contracts.ts @@ -0,0 +1,132 @@ +import * as Schema from "effect/Schema" +import * as Rpc from "effect/unstable/rpc/Rpc" +import * as RpcGroup from "effect/unstable/rpc/RpcGroup" + +export const EchoPayload = Schema.Struct({ + text: Schema.String +}) + +export const EchoResult = Schema.Struct({ + text: Schema.String +}) + +export const SumPayload = Schema.Struct({ + left: Schema.Number, + right: Schema.Number +}) + +export const SumResult = Schema.Struct({ + total: Schema.Number +}) + +export const TimeResult = Schema.Struct({ + iso: Schema.String +}) + +export const Todo = Schema.Struct({ + id: Schema.String, + title: Schema.String, + completed: Schema.Boolean, + archived: Schema.Boolean, + revision: Schema.Number, + updatedAt: Schema.String +}) +export type Todo = Schema.Schema.Type + +export const TodoChange = Schema.Union([ + Schema.Struct({ + _tag: Schema.Literal("TodoCreated") + }), + Schema.Struct({ + _tag: Schema.Literal("TodoRenamed"), + title: Schema.String + }), + Schema.Struct({ + _tag: Schema.Literal("TodoCompleted"), + completed: Schema.Boolean + }), + Schema.Struct({ + _tag: Schema.Literal("TodoArchived"), + archived: Schema.Boolean + }) +]) +export type TodoChange = Schema.Schema.Type + +export const TodoEvent = Schema.Struct({ + offset: Schema.Number, + at: Schema.String, + todo: Todo, + change: TodoChange +}) +export type TodoEvent = Schema.Schema.Type + +export const TodoSnapshot = Schema.Struct({ + offset: Schema.Number, + todos: Schema.Array(Todo) +}) +export type TodoSnapshot = Schema.Schema.Type + +export const EchoRpc = Rpc.make("echo", { + payload: EchoPayload, + success: EchoResult +}) + +export const SumRpc = Rpc.make("sum", { + payload: SumPayload, + success: SumResult +}) + +export const TimeRpc = Rpc.make("time", { + success: TimeResult +}) + +export const ListTodosRpc = Rpc.make("listTodos", { + payload: Schema.Struct({ + includeArchived: Schema.Boolean + }), + success: TodoSnapshot +}) + +export const RenameTodoRpc = Rpc.make("renameTodo", { + payload: Schema.Struct({ + id: Schema.String, + title: Schema.String + }), + success: TodoEvent +}) + +export const CompleteTodoRpc = Rpc.make("completeTodo", { + payload: Schema.Struct({ + id: Schema.String, + completed: Schema.Boolean + }), + success: TodoEvent +}) + +export const ArchiveTodoRpc = Rpc.make("archiveTodo", { + payload: Schema.Struct({ + id: Schema.String, + archived: Schema.Boolean + }), + success: TodoEvent +}) + +export const SubscribeTodosRpc = Rpc.make("subscribeTodos", { + payload: Schema.Struct({ + fromOffset: Schema.Number, + includeArchived: Schema.Boolean + }), + success: TodoEvent, + stream: true +}) + +export const WsRpcGroup = RpcGroup.make( + EchoRpc, + SumRpc, + TimeRpc, + ListTodosRpc, + RenameTodoRpc, + CompleteTodoRpc, + ArchiveTodoRpc, + SubscribeTodosRpc +) diff --git a/.reference/server/src/messages.ts b/.reference/server/src/messages.ts new file mode 100644 index 0000000000..75a67efac2 --- /dev/null +++ b/.reference/server/src/messages.ts @@ -0,0 +1,73 @@ +import * as Schema from "effect/Schema" +import * as SchemaTransformation from "effect/SchemaTransformation" + +export const ClientMessage = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("echo"), + text: Schema.String + }), + Schema.Struct({ + kind: Schema.Literal("sum"), + left: Schema.Number, + right: Schema.Number + }), + Schema.Struct({ + kind: Schema.Literal("time") + }) +]) + +export type ClientMessage = Schema.Schema.Type + +export const ServerMessage = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("echo"), + text: Schema.String + }), + Schema.Struct({ + kind: Schema.Literal("sumResult"), + total: Schema.Number + }), + Schema.Struct({ + kind: Schema.Literal("time"), + iso: Schema.String + }), + Schema.Struct({ + kind: Schema.Literal("error"), + error: Schema.String + }) +]) + + +export type ServerMessage = Schema.Schema.Type + +export const decodeClientMessage = Schema.decodeUnknownEffect(ClientMessage) + +const Utf8StringFromUint8Array = Schema.Uint8Array.pipe( + Schema.decodeTo( + Schema.String, + SchemaTransformation.transform({ + decode: (bytes) => new TextDecoder().decode(bytes), + encode: (text) => new TextEncoder().encode(text) + }) + ) +) + +const ClientMessageFromWire = Schema.Union([ + Schema.String, + Utf8StringFromUint8Array +]).pipe( + Schema.decodeTo(Schema.fromJsonString(ClientMessage)) +) + +export const decodeWireClientMessage = Schema.decodeUnknownEffect(ClientMessageFromWire) + +export const routeClientMessage = (message: ClientMessage): ServerMessage => { + switch (message.kind) { + case "echo": + return { kind: "echo", text: message.text } + case "sum": + return { kind: "sumResult", total: message.left + message.right } + case "time": + return { kind: "time", iso: new Date().toISOString() } + } +} diff --git a/.reference/server/src/migrations.ts b/.reference/server/src/migrations.ts new file mode 100644 index 0000000000..65abf1a534 --- /dev/null +++ b/.reference/server/src/migrations.ts @@ -0,0 +1,41 @@ +/** + * MigrationsLive - Migration runner with inline loader + * + * Uses Migrator.make with fromRecord to define migrations inline. + * All migrations are statically imported - no dynamic file system loading. + * + * Migrations run automatically when the MigrationsLive layer is provided, + * ensuring the database schema is up-to-date before the application starts. + */ +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as ServiceMap from "effect/ServiceMap" +import * as Migrator from "effect/unstable/sql/Migrator" +import Migration0001 from "./Migrations/001_TodoSchema.ts" + +const loader = Migrator.fromRecord({ + "1_TodoSchema": Migration0001 +}) + +const run = Migrator.make({}) + +export const runMigrations = Effect.gen(function*() { + yield* Effect.log("Running migrations...") + yield* run({ loader }) + yield* Effect.log("Migrations ran successfully") +}) + +export interface MigrationsReadyApi { + readonly ready: true +} + +export class MigrationsReady extends ServiceMap.Service()( + "effect-http-ws-cli/MigrationsReady" +) {} + +export const MigrationsLive = Layer.effect( + MigrationsReady, + runMigrations.pipe( + Effect.as(MigrationsReady.of({ ready: true })) + ) +) diff --git a/.reference/server/src/model-store.ts b/.reference/server/src/model-store.ts new file mode 100644 index 0000000000..56bcc0c05a --- /dev/null +++ b/.reference/server/src/model-store.ts @@ -0,0 +1,268 @@ +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import * as PubSub from "effect/PubSub" +import * as Schema from "effect/Schema" +import * as ServiceMap from "effect/ServiceMap" +import * as Stream from "effect/Stream" +import * as SqlClient from "effect/unstable/sql/SqlClient" +import * as SqlSchema from "effect/unstable/sql/SqlSchema" +import type { Todo, TodoChange, TodoEvent, TodoSnapshot } from "./contracts.ts" +import { Todo as TodoSchema, TodoChange as TodoChangeSchema } from "./contracts.ts" +import { MigrationsReady } from "./migrations.ts" + +export interface TodoStoreApi { + readonly list: (input: { + readonly includeArchived: boolean + }) => Effect.Effect + readonly rename: (input: { + readonly id: string + readonly title: string + }) => Effect.Effect + readonly complete: (input: { + readonly id: string + readonly completed: boolean + }) => Effect.Effect + readonly archive: (input: { + readonly id: string + readonly archived: boolean + }) => Effect.Effect + readonly subscribe: (input: { + readonly fromOffset: number + readonly includeArchived: boolean + }) => Stream.Stream +} + +export class TodoStore extends ServiceMap.Service()( + "effect-http-ws-cli/TodoStore" +) {} + +const TodoRow = Schema.Struct({ + id: Schema.String, + title: Schema.String, + completed: Schema.BooleanFromBit, + archived: Schema.BooleanFromBit, + revision: Schema.Number, + updatedAt: Schema.String +}) + +const TodoEventRow = Schema.Struct({ + offset: Schema.Number, + at: Schema.String, + todo: Schema.fromJsonString(TodoSchema), + change: Schema.fromJsonString(TodoChangeSchema) +}) + +const EventInsertRequest = Schema.Struct({ + at: Schema.String, + todo: Schema.fromJsonString(TodoSchema), + change: Schema.fromJsonString(TodoChangeSchema), + archived: Schema.BooleanFromBit +}) + +const ListRequest = Schema.Struct({ + includeArchived: Schema.Boolean +}) + +const CatchupRequest = Schema.Struct({ + fromOffset: Schema.Number, + includeArchived: Schema.Boolean +}) + +const OffsetRow = Schema.Struct({ + offset: Schema.Number +}) + +const makeQueries = (sql: SqlClient.SqlClient) => { + const listTodoRows = SqlSchema.findAll({ + Request: ListRequest, + Result: TodoRow, + execute: (request) => + request.includeArchived + ? sql` + SELECT id, title, completed, archived, revision, updated_at AS updatedAt + FROM todos + ORDER BY id + ` + : sql` + SELECT id, title, completed, archived, revision, updated_at AS updatedAt + FROM todos + WHERE archived = 0 + ORDER BY id + ` + }) + + const findTodoById = SqlSchema.findOneOption({ + Request: Schema.String, + Result: TodoRow, + execute: (id) => sql` + SELECT id, title, completed, archived, revision, updated_at AS updatedAt + FROM todos + WHERE id = ${id} + ` + }) + + const upsertTodo = SqlSchema.void({ + Request: TodoRow, + execute: (todo) => sql` + INSERT INTO todos (id, title, completed, archived, revision, updated_at) + VALUES (${todo.id}, ${todo.title}, ${todo.completed}, ${todo.archived}, ${todo.revision}, ${todo.updatedAt}) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + completed = excluded.completed, + archived = excluded.archived, + revision = excluded.revision, + updated_at = excluded.updated_at + ` + }) + + const loadEventsSince = SqlSchema.findAll({ + Request: CatchupRequest, + Result: TodoEventRow, + execute: (request) => + request.includeArchived + ? sql` + SELECT event_offset AS "offset", at, todo_json AS todo, change_json AS change + FROM todo_events + WHERE event_offset > ${request.fromOffset} + ORDER BY event_offset + ` + : sql` + SELECT event_offset AS "offset", at, todo_json AS todo, change_json AS change + FROM todo_events + WHERE event_offset > ${request.fromOffset} AND archived = 0 + ORDER BY event_offset + ` + }) + + const insertTodoEvent = SqlSchema.findOne({ + Request: EventInsertRequest, + Result: TodoEventRow, + execute: (request) => sql` + INSERT INTO todo_events (at, todo_json, change_json, archived) + VALUES (${request.at}, ${request.todo}, ${request.change}, ${request.archived}) + RETURNING event_offset AS "offset", at, todo_json AS todo, change_json AS change + ` + }) + + const currentOffset = SqlSchema.findOne({ + Request: Schema.Undefined, + Result: OffsetRow, + execute: () => sql<{ readonly offset: number }>` + SELECT COALESCE(MAX(event_offset), 0) AS "offset" + FROM todo_events + ` + }) + + return { + listTodoRows, + findTodoById, + upsertTodo, + loadEventsSince, + insertTodoEvent, + currentOffset + } as const +} + +export const layerTodoStore = Layer.effect( + TodoStore, + Effect.gen(function*() { + yield* MigrationsReady + const eventsPubSub = yield* PubSub.unbounded() + + const append = ( + todoId: string, + change: TodoChange, + update: (todo: Todo) => Todo + ): Effect.Effect => + Effect.flatMap(Effect.service(SqlClient.SqlClient), (sql) => { + const queries = makeQueries(sql) + return sql.withTransaction( + queries.findTodoById(todoId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.die(`Todo not found: ${todoId}`), + onSome: (todo) => { + const updated = update(todo) + return queries.upsertTodo(updated).pipe( + Effect.flatMap(() => + queries.insertTodoEvent({ + at: updated.updatedAt, + todo: updated, + change, + archived: updated.archived + }) + ) + ) + } + }) + ) + ) + ) + }).pipe(Effect.tap((event) => PubSub.publish(eventsPubSub, event)), Effect.orDie) + + const visible = (todo: Todo, includeArchived: boolean) => includeArchived || !todo.archived + + const subscribe = (input: { + readonly fromOffset: number + readonly includeArchived: boolean + }): Stream.Stream => { + const catchup = Stream.fromIterableEffect( + Effect.flatMap(Effect.service(SqlClient.SqlClient), (sql) => makeQueries(sql).loadEventsSince(input)) + ).pipe(Stream.orDie) + + const live = Stream.fromPubSub(eventsPubSub).pipe( + Stream.filter((event) => visible(event.todo, input.includeArchived)) + ) + + return Stream.concat(catchup, live) + } + + return TodoStore.of({ + list: ({ includeArchived }) => + Effect.flatMap(Effect.service(SqlClient.SqlClient), (sql) => { + const queries = makeQueries(sql) + return Effect.all({ + todos: queries.listTodoRows({ includeArchived }), + offset: queries.currentOffset(undefined).pipe(Effect.map(({ offset }) => offset)) + }).pipe( + Effect.map(({ offset, todos }) => ({ offset, todos })) + ) + }).pipe(Effect.orDie), + rename: ({ id, title }) => + append( + id, + { _tag: "TodoRenamed", title }, + (todo) => ({ + ...todo, + title, + revision: todo.revision + 1, + updatedAt: new Date().toISOString() + }) + ), + complete: ({ id, completed }) => + append( + id, + { _tag: "TodoCompleted", completed }, + (todo) => ({ + ...todo, + completed, + revision: todo.revision + 1, + updatedAt: new Date().toISOString() + }) + ), + archive: ({ id, archived }) => + append( + id, + { _tag: "TodoArchived", archived }, + (todo) => ({ + ...todo, + archived, + revision: todo.revision + 1, + updatedAt: new Date().toISOString() + }) + ), + subscribe + }) + }) +) diff --git a/.reference/server/src/server.ts b/.reference/server/src/server.ts new file mode 100644 index 0000000000..0d1e7a16dd --- /dev/null +++ b/.reference/server/src/server.ts @@ -0,0 +1,155 @@ +import { NodeHttpServer } from "@effect/platform-node" +import * as SqliteNode from "@effect/sql-sqlite-node" +import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" +import * as Layer from "effect/Layer" +import * as Path from "effect/Path" +import * as Http from "node:http" +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse +} from "effect/unstable/http" +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization" +import * as RpcServer from "effect/unstable/rpc/RpcServer" +import * as Stream from "effect/Stream" +import { + ClientMessage, + routeClientMessage, + ServerMessage +} from "./messages.ts" +import { ServerConfig } from "./config.ts" +import { WsRpcGroup } from "./contracts.ts" +import { TodoStore, layerTodoStore } from "./model-store.ts" +import { MigrationsLive } from "./migrations.ts" + +const respondMessage = HttpServerResponse.schemaJson(ServerMessage) + +const messageDispatchRoute = HttpRouter.add( + "POST", + "/api/dispatch", + HttpServerRequest.schemaBodyJson(ClientMessage).pipe( + Effect.flatMap((message) => respondMessage(routeClientMessage(message))), + Effect.catchTag( + "SchemaError", + () => Effect.succeed(HttpServerResponse.jsonUnsafe({ kind: "error", error: "Invalid message schema" }, { status: 400 })) + ), + Effect.catchTag( + "HttpServerError", + () => Effect.succeed(HttpServerResponse.jsonUnsafe({ kind: "error", error: "Invalid request body" }, { status: 400 })) + ) + ) +) + +const websocketRpcRoute = RpcServer.layerHttp({ + group: WsRpcGroup, + path: "/ws", + protocol: "websocket" + }).pipe( + Layer.provide(WsRpcGroup.toLayer({ + echo: ({ text }) => Effect.succeed({ text }), + sum: ({ left, right }) => Effect.succeed({ total: left + right }), + time: () => Effect.sync(() => ({ iso: new Date().toISOString() })), + listTodos: ({ includeArchived }) => + Effect.flatMap(Effect.service(TodoStore), (store) => store.list({ includeArchived })), + renameTodo: ({ id, title }) => + Effect.flatMap(Effect.service(TodoStore), (store) => store.rename({ id, title })), + completeTodo: ({ id, completed }) => + Effect.flatMap(Effect.service(TodoStore), (store) => store.complete({ id, completed })), + archiveTodo: ({ id, archived }) => + Effect.flatMap(Effect.service(TodoStore), (store) => store.archive({ id, archived })), + subscribeTodos: ({ fromOffset, includeArchived }) => + Stream.unwrap( + Effect.map( + Effect.service(TodoStore), + (store) => store.subscribe({ fromOffset, includeArchived }) + ) + ) + })), + Layer.provide(layerTodoStore), + Layer.provide(RpcSerialization.layerJson) + ) + +const staticRoute = HttpRouter.add( + "GET", + "*", + (request) => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const config = yield* ServerConfig + const url = HttpServerRequest.toURL(request) + if (!url) { + return HttpServerResponse.text("Bad Request", { status: 400 }) + } + + if (config.frontendDevOrigin) { + return HttpServerResponse.redirect( + new URL(`${url.pathname}${url.search}`, config.frontendDevOrigin), + { + status: 307, + headers: { "cache-control": "no-store" } + } + ) + } + + const root = path.resolve(config.assetsDir) + const decodedPath = decodeURIComponent(url.pathname) + const target = decodedPath === "/" + ? "index.html" + : decodedPath.endsWith("/") + ? `${decodedPath.slice(1)}index.html` + : decodedPath.slice(1) + + const normalizedTarget = path.normalize(target) + const absoluteTarget = path.resolve(root, normalizedTarget) + const relativeToRoot = path.relative(root, absoluteTarget) + + if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) { + return HttpServerResponse.text("Forbidden", { status: 403 }) + } + + const exists = yield* fs.exists(absoluteTarget) + if (!exists) { + return HttpServerResponse.text("Not Found", { status: 404 }) + } + + return yield* HttpServerResponse.file(absoluteTarget) + }).pipe(Effect.catchCause(() => Effect.succeed(HttpServerResponse.text("Bad Request", { status: 400 })))) +) + +export const makeRoutesLayer = + Layer.mergeAll( + HttpRouter.add( + "GET", + "/health", + HttpServerResponse.json({ ok: true }) + ), + messageDispatchRoute, + websocketRpcRoute, + staticRoute + ) + +export const makeServerLayer = Layer.unwrap( + Effect.gen(function*() { + const config = yield* ServerConfig + const sqliteLayer = SqliteNode.SqliteClient.layer({ + filename: config.dbFilename + }) + const persistenceLayer = MigrationsLive.pipe( + Layer.provideMerge(sqliteLayer) + ) + + return HttpRouter.serve(makeRoutesLayer, { + disableLogger: !config.requestLogging + }).pipe( + Layer.provideMerge(persistenceLayer), + Layer.provide(NodeHttpServer.layer(Http.createServer, { + host: config.host, + port: config.port + })) + ) + }) +) + +export const runServer = Layer.launch(makeServerLayer) diff --git a/.reference/server/test/cli-config.test.ts b/.reference/server/test/cli-config.test.ts new file mode 100644 index 0000000000..90b75857a7 --- /dev/null +++ b/.reference/server/test/cli-config.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "@effect/vitest" +import * as ConfigProvider from "effect/ConfigProvider" +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import { resolveServerConfig } from "../src/cli.ts" + +describe("cli config resolution", () => { + it.effect("falls back to effect/config values when flags are omitted", () => + Effect.gen(function*() { + const resolved = yield* resolveServerConfig({ + host: Option.none(), + port: Option.none(), + assets: Option.none(), + db: Option.none(), + requestLogging: Option.none(), + frontendDevOrigin: Option.none() + }).pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ + env: { + HOST: "0.0.0.0", + PORT: "4001", + ASSETS_DIR: "public", + DB_FILENAME: "dev.sqlite", + REQUEST_LOGGING: "false", + FRONTEND_DEV_ORIGIN: "http://127.0.0.1:5173" + } + }) + ) + ) + + expect(resolved).toEqual({ + host: "0.0.0.0", + port: 4001, + assetsDir: "public", + dbFilename: "dev.sqlite", + requestLogging: false, + frontendDevOrigin: "http://127.0.0.1:5173" + }) + }) + ) + + it.effect("uses CLI flags when provided", () => + Effect.gen(function*() { + const resolved = yield* resolveServerConfig({ + host: Option.some("127.0.0.1"), + port: Option.some(8788), + assets: Option.some("public"), + db: Option.some("override.sqlite"), + requestLogging: Option.some(true), + frontendDevOrigin: Option.some("http://127.0.0.1:4173") + }).pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ + env: { + HOST: "0.0.0.0", + PORT: "4001", + ASSETS_DIR: "other", + DB_FILENAME: "ignored.sqlite", + REQUEST_LOGGING: "false", + FRONTEND_DEV_ORIGIN: "http://127.0.0.1:5173" + } + }) + ) + ) + + expect(resolved).toEqual({ + host: "127.0.0.1", + port: 8788, + assetsDir: "public", + dbFilename: "override.sqlite", + requestLogging: true, + frontendDevOrigin: "http://127.0.0.1:4173" + }) + }) + ) +}) diff --git a/.reference/server/test/reset.test.ts b/.reference/server/test/reset.test.ts new file mode 100644 index 0000000000..b180538e31 --- /dev/null +++ b/.reference/server/test/reset.test.ts @@ -0,0 +1,90 @@ +import * as NodeServices from "@effect/platform-node/NodeServices" +import * as SqliteNode from "@effect/sql-sqlite-node" +import { describe, expect, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as SqlClient from "effect/unstable/sql/SqlClient" +import * as FileSystem from "node:fs" +import * as OS from "node:os" +import * as NodePath from "node:path" +import { resetDatabase } from "../src/cli.ts" +import { MigrationsLive } from "../src/migrations.ts" + +const countRows = (dbFilename: string, tableName: string) => + Effect.flatMap(Effect.service(SqlClient.SqlClient), (sql) => + sql<{ readonly count: number }>`SELECT COUNT(*) AS count FROM ${sql(tableName)}`.pipe( + Effect.map((rows) => rows[0]?.count ?? 0) + ) + ).pipe( + Effect.provide(SqliteNode.SqliteClient.layer({ filename: dbFilename })) + ) + +const insertTodo = (dbFilename: string) => + Effect.scoped( + Effect.flatMap(Effect.service(SqlClient.SqlClient), (sql) => { + const now = new Date().toISOString() + const id = "todo-reset-test" + const title = "before-reset" + return sql.withTransaction( + sql` + INSERT INTO todos (id, title, completed, archived, revision, updated_at) + VALUES (${id}, ${title}, 0, 0, 1, ${now}) + `.pipe( + Effect.flatMap(() => + sql` + INSERT INTO todo_events (at, todo_json, change_json, archived) + VALUES ( + ${now}, + ${JSON.stringify({ + id, + title, + completed: false, + archived: false, + revision: 1, + updatedAt: now + })}, + ${JSON.stringify({ _tag: "TodoCreated" })}, + 0 + ) + ` + ) + ) + ) + }).pipe( + Effect.provide(MigrationsLive.pipe(Layer.provideMerge(SqliteNode.SqliteClient.layer({ filename: dbFilename })))) + ) + ) + +describe("reset command", () => { + it.effect("deletes the database and reruns migrations without reseeding todos", () => + Effect.gen(function*() { + const dbFilename = NodePath.join( + OS.tmpdir(), + `effect-http-ws-cli-reset-${Date.now()}-${Math.random().toString(16).slice(2)}.sqlite` + ) + + yield* insertTodo(dbFilename) + + const beforeCount = yield* countRows(dbFilename, "todos") + expect(beforeCount).toBe(1) + + yield* resetDatabase(dbFilename).pipe( + Effect.provide(NodeServices.layer) + ) + + const todoCount = yield* countRows(dbFilename, "todos") + const migrationCount = yield* countRows(dbFilename, "effect_sql_migrations") + + expect(todoCount).toBe(0) + expect(migrationCount).toBe(1) + + yield* Effect.sync(() => { + try { + FileSystem.rmSync(dbFilename, { force: true }) + } catch { + // ignore cleanup failures in tests + } + }) + }) + ) +}) diff --git a/.reference/server/test/server.test.ts b/.reference/server/test/server.test.ts new file mode 100644 index 0000000000..317e2a9058 --- /dev/null +++ b/.reference/server/test/server.test.ts @@ -0,0 +1,310 @@ +import { NodeHttpServer } from "@effect/platform-node" +import * as SqliteNode from "@effect/sql-sqlite-node" +import { describe, expect, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Stream from "effect/Stream" +import * as FileSystem from "node:fs" +import * as OS from "node:os" +import * as NodePath from "node:path" +import { + HttpBody, + HttpClient, + HttpClientResponse, + HttpServer, + HttpRouter +} from "effect/unstable/http" +import * as SqlClient from "effect/unstable/sql/SqlClient" +import { ServerConfig } from "../src/config.ts" +import { MigrationsLive } from "../src/migrations.ts" +import { ServerMessage } from "../src/messages.ts" +import { withWsRpcClient } from "../src/client.ts" +import { makeRoutesLayer } from "../src/server.ts" + +const testServerConfig = { + host: "127.0.0.1", + port: 0, + assetsDir: new URL("../../public", import.meta.url).pathname, + dbFilename: ":memory:", + requestLogging: false, + frontendDevOrigin: undefined +} + +const AppUnderTest = HttpRouter.serve( + makeRoutesLayer, + { + disableListenLog: true, + disableLogger: true + } +) + +const persistenceLayer = (dbFilename: string) => { + const sqliteLayer = SqliteNode.SqliteClient.layer({ filename: dbFilename }) + return MigrationsLive.pipe(Layer.provideMerge(sqliteLayer)) +} + +const appLayer = (dbFilename: string) => + AppUnderTest.pipe(Layer.provideMerge(persistenceLayer(dbFilename))) + +const insertTodo = (dbFilename: string, id: string, title: string) => + Effect.scoped( + Effect.flatMap(Effect.service(SqlClient.SqlClient), (sql) => { + const now = new Date().toISOString() + return sql.withTransaction( + sql` + INSERT INTO todos (id, title, completed, archived, revision, updated_at) + VALUES (${id}, ${title}, 0, 0, 1, ${now}) + `.pipe( + Effect.flatMap(() => + sql` + INSERT INTO todo_events (at, todo_json, change_json, archived) + VALUES ( + ${now}, + ${JSON.stringify({ + id, + title, + completed: false, + archived: false, + revision: 1, + updatedAt: now + })}, + ${JSON.stringify({ _tag: "TodoCreated" })}, + 0 + ) + ` + ) + ) + ) + }).pipe( + Effect.provide(persistenceLayer(dbFilename)) + ) + ) + +describe("server", () => { + it.effect("routes HTTP messages validated by Schema", () => + Effect.gen(function*() { + yield* Layer.build(appLayer(testServerConfig.dbFilename)).pipe( + Effect.provideService(ServerConfig, testServerConfig) + ) + const client = yield* HttpClient.HttpClient + const response = yield* client.post("/api/dispatch", { + body: HttpBody.jsonUnsafe({ kind: "sum", left: 20, right: 22 }) + }) + + const parsed = yield* HttpClientResponse.schemaBodyJson(ServerMessage)(response) + expect(parsed).toEqual({ kind: "sumResult", total: 42 }) + }).pipe(Effect.provide(NodeHttpServer.layerTest)) + ) + + it.effect("serves static files from local filesystem", () => + Effect.gen(function*() { + yield* Layer.build(appLayer(testServerConfig.dbFilename)).pipe( + Effect.provideService(ServerConfig, testServerConfig) + ) + const text = yield* HttpClient.get("/").pipe( + Effect.flatMap((response) => response.text) + ) + + expect(text).toContain("effect-http-ws-cli") + }).pipe(Effect.provide(NodeHttpServer.layerTest)) + ) + + it.effect("redirects frontend requests to the Vite dev server when enabled", () => + Effect.gen(function*() { + yield* Layer.build(appLayer(testServerConfig.dbFilename)).pipe( + Effect.provideService(ServerConfig, { + ...testServerConfig, + frontendDevOrigin: "http://127.0.0.1:5173" + }) + ) + + const server = yield* HttpServer.HttpServer + const address = server.address as HttpServer.TcpAddress + const response = yield* Effect.promise(() => + fetch(`http://127.0.0.1:${address.port}/todos?filter=active`, { + redirect: "manual" + }) + ) + + expect(response.status).toBe(307) + expect(response.headers.get("location")).toBe("http://127.0.0.1:5173/todos?filter=active") + }).pipe(Effect.provide(NodeHttpServer.layerTest)) + ) + + it.effect("routes WebSocket RPC messages with shared contracts", () => + Effect.gen(function*() { + yield* Layer.build(appLayer(testServerConfig.dbFilename)).pipe( + Effect.provideService(ServerConfig, testServerConfig) + ) + const server = yield* HttpServer.HttpServer + const address = server.address as HttpServer.TcpAddress + const wsUrl = `ws://127.0.0.1:${address.port}/ws` + + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client.echo({ text: "hello from ws" })) + ) + + expect(response).toEqual({ text: "hello from ws" }) + }).pipe(Effect.provide(NodeHttpServer.layerTest)) + ) + + it.effect("routes WebSocket RPC calls for multiple procedures", () => + Effect.gen(function*() { + yield* Layer.build(appLayer(testServerConfig.dbFilename)).pipe( + Effect.provideService(ServerConfig, testServerConfig) + ) + const server = yield* HttpServer.HttpServer + const address = server.address as HttpServer.TcpAddress + const wsUrl = `ws://127.0.0.1:${address.port}/ws` + + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.all({ + sum: client.sum({ left: 1, right: 2 }), + time: client.time(undefined) + }) + ) + ) + + expect(result.sum).toEqual({ total: 3 }) + expect(typeof result.time.iso).toBe("string") + }).pipe(Effect.provide(NodeHttpServer.layerTest)) + ) + + it.effect("lists todos and streams todo updates from offset", () => + Effect.gen(function*() { + const dbFilename = NodePath.join( + OS.tmpdir(), + `effect-http-ws-cli-stream-${Date.now()}-${Math.random().toString(16).slice(2)}.sqlite` + ) + + yield* insertTodo(dbFilename, "todo-stream-test", "stream-seed") + + const events = yield* Effect.scoped( + Effect.gen(function*() { + yield* Layer.build(appLayer(dbFilename)).pipe( + Effect.provideService(ServerConfig, { + ...testServerConfig, + dbFilename + }) + ) + const server = yield* HttpServer.HttpServer + const address = server.address as HttpServer.TcpAddress + const wsUrl = `ws://127.0.0.1:${address.port}/ws` + + return yield* withWsRpcClient(wsUrl, (client) => + Effect.gen(function*() { + const snapshot = yield* client.listTodos({ includeArchived: true }) + const firstTodo = snapshot.todos[0] + if (!firstTodo) { + return yield* Effect.die("Expected a todo fixture") + } + + yield* client.renameTodo({ id: firstTodo.id, title: "alpha" }) + yield* client.completeTodo({ id: firstTodo.id, completed: true }) + yield* client.archiveTodo({ id: firstTodo.id, archived: true }) + + return yield* client.subscribeTodos({ + fromOffset: snapshot.offset, + includeArchived: true + }).pipe( + Stream.take(3), + Stream.runCollect + ) + }) + ) + }) + ).pipe( + Effect.ensuring( + Effect.sync(() => { + try { + FileSystem.rmSync(dbFilename, { force: true }) + } catch { + // ignore cleanup failures in tests + } + }) + ) + ) + + expect(events).toHaveLength(3) + expect(events[0]?.change).toEqual({ _tag: "TodoRenamed", title: "alpha" }) + expect(events[1]?.change).toEqual({ _tag: "TodoCompleted", completed: true }) + expect(events[2]?.change).toEqual({ _tag: "TodoArchived", archived: true }) + expect(events[0]?.todo.title).toBe("alpha") + expect(events[1]?.todo.completed).toBe(true) + expect(events[2]?.todo.archived).toBe(true) + }).pipe(Effect.provide(NodeHttpServer.layerTest)) + ) + + it.effect("persists todos across server restarts when using a file database", () => + Effect.gen(function*() { + const dbFilename = NodePath.join( + OS.tmpdir(), + `effect-http-ws-cli-${Date.now()}-${Math.random().toString(16).slice(2)}.sqlite` + ) + + yield* insertTodo(dbFilename, "todo-persist-test", "persist-seed") + + const withServer = (f: (wsUrl: string) => Effect.Effect) => + Effect.scoped( + Effect.gen(function*() { + yield* Layer.build(appLayer(dbFilename)).pipe( + Effect.provideService(ServerConfig, { + ...testServerConfig, + dbFilename + }) + ) + + const server = yield* HttpServer.HttpServer + const address = server.address as HttpServer.TcpAddress + const wsUrl = `ws://127.0.0.1:${address.port}/ws` + return yield* f(wsUrl) + }) + ) + + const renamedTodoId = yield* withServer((wsUrl) => + Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function*() { + const snapshot = yield* client.listTodos({ includeArchived: true }) + const firstTodo = snapshot.todos[0] + if (!firstTodo) { + return yield* Effect.die("Expected a todo fixture") + } + + yield* client.renameTodo({ id: firstTodo.id, title: "persisted-title" }) + return firstTodo.id + }) + ) + ) + ) + + const persistedTitle = yield* withServer((wsUrl) => + Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function*() { + const snapshot = yield* client.listTodos({ includeArchived: true }) + const persisted = snapshot.todos.find((todo) => todo.id === renamedTodoId) + if (!persisted) { + return yield* Effect.die("Expected persisted todo") + } + return persisted.title + }) + ) + ) + ).pipe( + Effect.ensuring( + Effect.sync(() => { + try { + FileSystem.rmSync(dbFilename, { force: true }) + } catch { + // ignore cleanup failures in tests + } + }) + ) + ) + + expect(persistedTitle).toBe("persisted-title") + }).pipe(Effect.provide(NodeHttpServer.layerTest)) + ) +}) diff --git a/.reference/server/tsconfig.json b/.reference/server/tsconfig.json new file mode 100644 index 0000000000..de981b3bf8 --- /dev/null +++ b/.reference/server/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/.reference/server/vitest.config.ts b/.reference/server/vitest.config.ts new file mode 100644 index 0000000000..7ef18d4fc7 --- /dev/null +++ b/.reference/server/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + environment: "node" + } +}) From 98289a26da4e2b1cf4e50cd013ed9a790d3db5df Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Mar 2026 18:59:52 -0700 Subject: [PATCH 02/47] remove test file for now --- apps/server/src/wsServer.test.ts | 1835 ------------------------------ 1 file changed, 1835 deletions(-) delete mode 100644 apps/server/src/wsServer.test.ts diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts deleted file mode 100644 index 9c6adfeba9..0000000000 --- a/apps/server/src/wsServer.test.ts +++ /dev/null @@ -1,1835 +0,0 @@ -import * as Http from "node:http"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Effect, Exit, Layer, PlatformError, PubSub, Scope, Stream } from "effect"; -import { describe, expect, it, afterEach, vi } from "vitest"; -import { createServer } from "./wsServer"; -import WebSocket from "ws"; -import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config"; -import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; - -import { - DEFAULT_TERMINAL_ID, - EDITORS, - EventId, - ORCHESTRATION_WS_CHANNELS, - ORCHESTRATION_WS_METHODS, - ProviderItemId, - ThreadId, - TurnId, - WS_CHANNELS, - WS_METHODS, - type WebSocketResponse, - type ProviderRuntimeEvent, - type ServerProviderStatus, - type KeybindingsConfig, - type ResolvedKeybindingsConfig, - type WsPushChannel, - type WsPushMessage, - type WsPush, -} from "@t3tools/contracts"; -import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; -import type { - TerminalClearInput, - TerminalCloseInput, - TerminalEvent, - TerminalOpenInput, - TerminalResizeInput, - TerminalSessionSnapshot, - TerminalWriteInput, -} from "@t3tools/contracts"; -import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager"; -import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; -import { SqlClient, SqlError } from "effect/unstable/sql"; -import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; -import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth"; -import { Open, type OpenShape } from "./open"; -import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; -import type { GitCoreShape } from "./git/Services/GitCore.ts"; -import { GitCore } from "./git/Services/GitCore.ts"; -import { GitCommandError, GitManagerError } from "./git/Errors.ts"; -import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; - -const asEventId = (value: string): EventId => EventId.makeUnsafe(value); -const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); -const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); -const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); - -const defaultOpenService: OpenShape = { - openBrowser: () => Effect.void, - openInEditor: () => Effect.void, -}; - -const defaultProviderStatuses: ReadonlyArray = [ - { - provider: "codex", - status: "ready", - available: true, - authStatus: "authenticated", - checkedAt: "2026-01-01T00:00:00.000Z", - }, -]; - -const defaultProviderHealthService: ProviderHealthShape = { - getStatuses: Effect.succeed(defaultProviderStatuses), -}; - -class MockTerminalManager implements TerminalManagerShape { - private readonly sessions = new Map(); - private readonly listeners = new Set<(event: TerminalEvent) => void>(); - - private key(threadId: string, terminalId: string): string { - return `${threadId}\u0000${terminalId}`; - } - - emitEvent(event: TerminalEvent): void { - for (const listener of this.listeners) { - listener(event); - } - } - - subscriptionCount(): number { - return this.listeners.size; - } - - readonly open: TerminalManagerShape["open"] = (input: TerminalOpenInput) => - Effect.sync(() => { - const now = new Date().toISOString(); - const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; - const snapshot: TerminalSessionSnapshot = { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - status: "running", - pid: 4242, - history: "", - exitCode: null, - exitSignal: null, - updatedAt: now, - }; - this.sessions.set(this.key(input.threadId, terminalId), snapshot); - queueMicrotask(() => { - this.emitEvent({ - type: "started", - threadId: input.threadId, - terminalId, - createdAt: now, - snapshot, - }); - }); - return snapshot; - }); - - readonly write: TerminalManagerShape["write"] = (input: TerminalWriteInput) => - Effect.sync(() => { - const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; - const existing = this.sessions.get(this.key(input.threadId, terminalId)); - if (!existing) { - throw new Error(`Unknown terminal thread: ${input.threadId}`); - } - queueMicrotask(() => { - this.emitEvent({ - type: "output", - threadId: input.threadId, - terminalId, - createdAt: new Date().toISOString(), - data: input.data, - }); - }); - }); - - readonly resize: TerminalManagerShape["resize"] = (_input: TerminalResizeInput) => Effect.void; - - readonly clear: TerminalManagerShape["clear"] = (input: TerminalClearInput) => - Effect.sync(() => { - const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; - queueMicrotask(() => { - this.emitEvent({ - type: "cleared", - threadId: input.threadId, - terminalId, - createdAt: new Date().toISOString(), - }); - }); - }); - - readonly restart: TerminalManagerShape["restart"] = (input: TerminalOpenInput) => - Effect.sync(() => { - const now = new Date().toISOString(); - const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; - const snapshot: TerminalSessionSnapshot = { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - status: "running", - pid: 5252, - history: "", - exitCode: null, - exitSignal: null, - updatedAt: now, - }; - this.sessions.set(this.key(input.threadId, terminalId), snapshot); - queueMicrotask(() => { - this.emitEvent({ - type: "restarted", - threadId: input.threadId, - terminalId, - createdAt: now, - snapshot, - }); - }); - return snapshot; - }); - - readonly close: TerminalManagerShape["close"] = (input: TerminalCloseInput) => - Effect.sync(() => { - if (input.terminalId) { - this.sessions.delete(this.key(input.threadId, input.terminalId)); - return; - } - for (const key of this.sessions.keys()) { - if (key.startsWith(`${input.threadId}\u0000`)) { - this.sessions.delete(key); - } - } - }); - - readonly subscribe: TerminalManagerShape["subscribe"] = (listener) => - Effect.sync(() => { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - }); - - readonly dispose: TerminalManagerShape["dispose"] = Effect.void; -} - -// --------------------------------------------------------------------------- -// WebSocket test harness -// -// Incoming messages are split into two channels: -// - pushChannel: server push envelopes (type === "push") -// - responseChannel: request/response envelopes (have an "id" field) -// -// This means sendRequest never has to skip push messages and waitForPush -// never has to skip response messages, eliminating a class of ordering bugs. -// --------------------------------------------------------------------------- - -interface MessageChannel { - queue: T[]; - waiters: Array<{ - resolve: (value: T) => void; - reject: (error: Error) => void; - timeoutId: ReturnType | null; - }>; -} - -interface SocketChannels { - push: MessageChannel; - response: MessageChannel; -} - -const channelsBySocket = new WeakMap(); - -function enqueue(channel: MessageChannel, item: T) { - const waiter = channel.waiters.shift(); - if (waiter) { - if (waiter.timeoutId !== null) clearTimeout(waiter.timeoutId); - waiter.resolve(item); - return; - } - channel.queue.push(item); -} - -function dequeue(channel: MessageChannel, timeoutMs: number): Promise { - const queued = channel.queue.shift(); - if (queued !== undefined) { - return Promise.resolve(queued); - } - - return new Promise((resolve, reject) => { - const waiter = { - resolve, - reject, - timeoutId: setTimeout(() => { - const index = channel.waiters.indexOf(waiter); - if (index >= 0) channel.waiters.splice(index, 1); - reject(new Error(`Timed out waiting for WebSocket message after ${timeoutMs}ms`)); - }, timeoutMs) as ReturnType, - }; - channel.waiters.push(waiter); - }); -} - -function isWsPushEnvelope(message: unknown): message is WsPush { - if (typeof message !== "object" || message === null) return false; - if (!("type" in message) || !("channel" in message)) return false; - return (message as { type?: unknown }).type === "push"; -} - -function asWebSocketResponse(message: unknown): WebSocketResponse | null { - if (typeof message !== "object" || message === null) return null; - if (!("id" in message)) return null; - const id = (message as { id?: unknown }).id; - if (typeof id !== "string") return null; - return message as WebSocketResponse; -} - -function connectWsOnce(port: number, token?: string): Promise { - return new Promise((resolve, reject) => { - const query = token ? `?token=${encodeURIComponent(token)}` : ""; - const ws = new WebSocket(`ws://127.0.0.1:${port}/${query}`); - const channels: SocketChannels = { - push: { queue: [], waiters: [] }, - response: { queue: [], waiters: [] }, - }; - channelsBySocket.set(ws, channels); - - ws.on("message", (raw) => { - const parsed = JSON.parse(String(raw)); - if (isWsPushEnvelope(parsed)) { - enqueue(channels.push, parsed); - } else { - const response = asWebSocketResponse(parsed); - if (response) { - enqueue(channels.response, response); - } - } - }); - - ws.once("open", () => resolve(ws)); - ws.once("error", () => reject(new Error("WebSocket connection failed"))); - }); -} - -async function connectWs(port: number, token?: string, attempts = 5): Promise { - let lastError: unknown = new Error("WebSocket connection failed"); - - for (let attempt = 0; attempt < attempts; attempt += 1) { - try { - return await connectWsOnce(port, token); - } catch (error) { - lastError = error; - if (attempt < attempts - 1) { - await new Promise((resolve) => setTimeout(resolve, 25)); - } - } - } - - throw lastError; -} - -/** Connect and wait for the server.welcome push. Returns [ws, welcomeData]. */ -async function connectAndAwaitWelcome( - port: number, - token?: string, -): Promise<[WebSocket, WsPushMessage]> { - const ws = await connectWs(port, token); - const welcome = await waitForPush(ws, WS_CHANNELS.serverWelcome); - return [ws, welcome]; -} - -async function sendRequest( - ws: WebSocket, - method: string, - params?: unknown, -): Promise { - const channels = channelsBySocket.get(ws); - if (!channels) throw new Error("WebSocket not initialized"); - - const id = crypto.randomUUID(); - const body = - method === ORCHESTRATION_WS_METHODS.dispatchCommand - ? { _tag: method, command: params } - : params && typeof params === "object" && !Array.isArray(params) - ? { _tag: method, ...(params as Record) } - : { _tag: method }; - ws.send(JSON.stringify({ id, body })); - - // Response channel only contains responses — no push filtering needed - while (true) { - const response = await dequeue(channels.response, 60_000); - if (response.id === id || response.id === "unknown") { - return response; - } - } -} - -async function waitForPush( - ws: WebSocket, - channel: C, - predicate?: (push: WsPushMessage) => boolean, - maxMessages = 120, - idleTimeoutMs = 5_000, -): Promise> { - const channels = channelsBySocket.get(ws); - if (!channels) throw new Error("WebSocket not initialized"); - - for (let remaining = maxMessages; remaining > 0; remaining--) { - const push = await dequeue(channels.push, idleTimeoutMs); - if (push.channel !== channel) continue; - const typed = push as WsPushMessage; - if (!predicate || predicate(typed)) return typed; - } - throw new Error(`Timed out waiting for push on ${channel}`); -} - -async function rewriteKeybindingsAndWaitForPush( - ws: WebSocket, - keybindingsPath: string, - contents: string, - predicate: (push: WsPushMessage) => boolean, - attempts = 3, -): Promise> { - let lastError: unknown; - for (let attempt = 0; attempt < attempts; attempt++) { - fs.writeFileSync(keybindingsPath, contents, "utf8"); - try { - return await waitForPush(ws, WS_CHANNELS.serverConfigUpdated, predicate, 20, 3_000); - } catch (error) { - lastError = error; - } - } - throw lastError; -} - -async function requestPath( - port: number, - requestPath: string, -): Promise<{ statusCode: number; body: string }> { - return new Promise((resolve, reject) => { - const req = Http.request( - { - hostname: "127.0.0.1", - port, - path: requestPath, - method: "GET", - }, - (res) => { - const chunks: Buffer[] = []; - res.on("data", (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - res.on("end", () => { - resolve({ - statusCode: res.statusCode ?? 0, - body: Buffer.concat(chunks).toString("utf8"), - }); - }); - }, - ); - req.once("error", reject); - req.end(); - }); -} - -function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { - const resolved: Array = []; - for (const binding of bindings) { - const compiled = compileResolvedKeybindingRule(binding); - if (!compiled) { - throw new Error(`Unexpected invalid keybinding in test setup: ${binding.command}`); - } - resolved.push(compiled); - } - return resolved; -} - -const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]); -const VALID_EDITOR_IDS = new Set(EDITORS.map((editor) => editor.id)); - -function expectAvailableEditors(value: unknown): void { - expect(Array.isArray(value)).toBe(true); - for (const editorId of value as unknown[]) { - expect(typeof editorId).toBe("string"); - expect(VALID_EDITOR_IDS.has(editorId as (typeof EDITORS)[number]["id"])).toBe(true); - } -} - -function ensureParentDir(filePath: string): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); -} - -function deriveServerPathsSync(baseDir: string, devUrl: URL | undefined) { - return Effect.runSync( - deriveServerPaths(baseDir, devUrl).pipe(Effect.provide(NodeServices.layer)), - ); -} - -describe("WebSocket Server", () => { - let server: Http.Server | null = null; - let serverScope: Scope.Closeable | null = null; - const connections: WebSocket[] = []; - const tempDirs: string[] = []; - - function makeTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; - } - - async function createTestServer( - options: { - persistenceLayer?: Layer.Layer< - SqlClient.SqlClient, - SqlError.SqlError | MigrationError | PlatformError.PlatformError - >; - cwd?: string; - autoBootstrapProjectFromCwd?: boolean; - logWebSocketEvents?: boolean; - devUrl?: string; - authToken?: string; - baseDir?: string; - staticDir?: string; - providerLayer?: Layer.Layer; - providerHealth?: ProviderHealthShape; - open?: OpenShape; - gitManager?: GitManagerShape; - gitCore?: Pick; - terminalManager?: TerminalManagerShape; - } = {}, - ): Promise { - if (serverScope) { - throw new Error("Test server is already running"); - } - - const baseDir = options.baseDir ?? makeTempDir("t3code-ws-base-"); - const devUrl = options.devUrl ? new URL(options.devUrl) : undefined; - const derivedPaths = deriveServerPathsSync(baseDir, devUrl); - const scope = await Effect.runPromise(Scope.make("sequential")); - const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; - const providerLayer = options.providerLayer ?? makeServerProviderLayer(); - const providerHealthLayer = Layer.succeed( - ProviderHealth, - options.providerHealth ?? defaultProviderHealthService, - ); - const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); - const serverConfigLayer = Layer.succeed(ServerConfig, { - mode: "web", - port: 0, - host: undefined, - cwd: options.cwd ?? "/test/project", - baseDir, - ...derivedPaths, - staticDir: options.staticDir, - devUrl, - noBrowser: true, - authToken: options.authToken, - autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false, - logWebSocketEvents: options.logWebSocketEvents ?? Boolean(options.devUrl), - } satisfies ServerConfigShape); - const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); - const runtimeOverrides = Layer.mergeAll( - options.gitManager ? Layer.succeed(GitManager, options.gitManager) : Layer.empty, - options.gitCore - ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape) - : Layer.empty, - options.terminalManager - ? Layer.succeed(TerminalManager, options.terminalManager) - : Layer.empty, - ); - - const runtimeLayer = Layer.merge( - Layer.merge( - makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), - infrastructureLayer, - ), - runtimeOverrides, - ); - const dependenciesLayer = Layer.empty.pipe( - Layer.provideMerge(runtimeLayer), - Layer.provideMerge(providerHealthLayer), - Layer.provideMerge(openLayer), - Layer.provideMerge(serverConfigLayer), - Layer.provideMerge(AnalyticsService.layerTest), - Layer.provideMerge(NodeServices.layer), - ); - const runtimeServices = await Effect.runPromise( - Layer.build(dependenciesLayer).pipe(Scope.provide(scope)), - ); - - try { - const runtime = await Effect.runPromise( - createServer().pipe(Effect.provide(runtimeServices), Scope.provide(scope)), - ); - serverScope = scope; - return runtime; - } catch (error) { - await Effect.runPromise(Scope.close(scope, Exit.void)); - throw error; - } - } - - async function closeTestServer() { - if (!serverScope) return; - const scope = serverScope; - serverScope = null; - await Effect.runPromise(Scope.close(scope, Exit.void)); - } - - afterEach(async () => { - for (const ws of connections) { - ws.close(); - } - connections.length = 0; - await closeTestServer(); - server = null; - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } - vi.restoreAllMocks(); - }); - - it("sends welcome message on connect", async () => { - server = await createTestServer({ cwd: "/test/project" }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - expect(port).toBeGreaterThan(0); - - const [ws, welcome] = await connectAndAwaitWelcome(port); - connections.push(ws); - - expect(welcome.type).toBe("push"); - expect(welcome.data).toEqual({ - cwd: "/test/project", - projectName: "project", - }); - }); - - it("serves persisted attachments from stateDir", async () => { - const baseDir = makeTempDir("t3code-state-attachments-"); - const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined); - const attachmentPath = path.join(attachmentsDir, "thread-a", "message-a", "0.png"); - fs.mkdirSync(path.dirname(attachmentPath), { recursive: true }); - fs.writeFileSync(attachmentPath, Buffer.from("hello-attachment")); - - server = await createTestServer({ cwd: "/test/project", baseDir }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - expect(port).toBeGreaterThan(0); - - const response = await fetch(`http://127.0.0.1:${port}/attachments/thread-a/message-a/0.png`); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain("image/png"); - const bytes = Buffer.from(await response.arrayBuffer()); - expect(bytes).toEqual(Buffer.from("hello-attachment")); - }); - - it("serves persisted attachments for URL-encoded paths", async () => { - const baseDir = makeTempDir("t3code-state-attachments-encoded-"); - const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined); - const attachmentPath = path.join( - attachmentsDir, - "thread%20folder", - "message%20folder", - "file%20name.png", - ); - fs.mkdirSync(path.dirname(attachmentPath), { recursive: true }); - fs.writeFileSync(attachmentPath, Buffer.from("hello-encoded-attachment")); - - server = await createTestServer({ cwd: "/test/project", baseDir }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - expect(port).toBeGreaterThan(0); - - const response = await fetch( - `http://127.0.0.1:${port}/attachments/thread%20folder/message%20folder/file%20name.png`, - ); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain("image/png"); - const bytes = Buffer.from(await response.arrayBuffer()); - expect(bytes).toEqual(Buffer.from("hello-encoded-attachment")); - }); - - it("serves static index for root path", async () => { - const baseDir = makeTempDir("t3code-state-static-root-"); - const staticDir = makeTempDir("t3code-static-root-"); - fs.writeFileSync(path.join(staticDir, "index.html"), "

static-root

", "utf8"); - - server = await createTestServer({ cwd: "/test/project", baseDir, staticDir }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - expect(port).toBeGreaterThan(0); - - const response = await fetch(`http://127.0.0.1:${port}/`); - expect(response.status).toBe(200); - expect(await response.text()).toContain("static-root"); - }); - - it("rejects static path traversal attempts", async () => { - const baseDir = makeTempDir("t3code-state-static-traversal-"); - const staticDir = makeTempDir("t3code-static-traversal-"); - fs.writeFileSync(path.join(staticDir, "index.html"), "

safe

", "utf8"); - - server = await createTestServer({ cwd: "/test/project", baseDir, staticDir }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - expect(port).toBeGreaterThan(0); - - const response = await requestPath(port, "/..%2f..%2fetc/passwd"); - expect(response.statusCode).toBe(400); - expect(response.body).toBe("Invalid static file path"); - }); - - it("bootstraps the cwd project on startup when enabled", async () => { - server = await createTestServer({ - cwd: "/test/bootstrap-workspace", - autoBootstrapProjectFromCwd: true, - }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - expect(port).toBeGreaterThan(0); - - const [ws, welcome] = await connectAndAwaitWelcome(port); - connections.push(ws); - expect(welcome.data).toEqual( - expect.objectContaining({ - cwd: "/test/bootstrap-workspace", - projectName: "bootstrap-workspace", - bootstrapProjectId: expect.any(String), - bootstrapThreadId: expect.any(String), - }), - ); - - const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot); - expect(snapshotResponse.error).toBeUndefined(); - const snapshot = snapshotResponse.result as { - projects: Array<{ - id: string; - workspaceRoot: string; - title: string; - defaultModel: string | null; - }>; - threads: Array<{ - id: string; - projectId: string; - title: string; - model: string; - branch: string | null; - worktreePath: string | null; - }>; - }; - const bootstrapProjectId = (welcome.data as { bootstrapProjectId?: string }).bootstrapProjectId; - const bootstrapThreadId = (welcome.data as { bootstrapThreadId?: string }).bootstrapThreadId; - expect(bootstrapProjectId).toBeDefined(); - expect(bootstrapThreadId).toBeDefined(); - - expect(snapshot.projects).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: bootstrapProjectId, - workspaceRoot: "/test/bootstrap-workspace", - title: "bootstrap-workspace", - defaultModel: "gpt-5-codex", - }), - ]), - ); - expect(snapshot.threads).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: bootstrapThreadId, - projectId: bootstrapProjectId, - title: "New thread", - model: "gpt-5-codex", - branch: null, - worktreePath: null, - }), - ]), - ); - }); - - it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => { - const baseDir = makeTempDir("t3code-state-bootstrap-existing-"); - const { dbPath } = deriveServerPathsSync(baseDir, undefined); - const persistenceLayer = makeSqlitePersistenceLive(dbPath).pipe( - Layer.provide(NodeServices.layer), - ); - const cwd = "/test/bootstrap-existing"; - - server = await createTestServer({ - cwd, - baseDir, - persistenceLayer, - autoBootstrapProjectFromCwd: true, - }); - let addr = server.address(); - let port = typeof addr === "object" && addr !== null ? addr.port : 0; - expect(port).toBeGreaterThan(0); - - const [firstWs, firstWelcome] = await connectAndAwaitWelcome(port); - connections.push(firstWs); - const firstBootstrapProjectId = (firstWelcome.data as { bootstrapProjectId?: string }) - .bootstrapProjectId; - const firstBootstrapThreadId = (firstWelcome.data as { bootstrapThreadId?: string }) - .bootstrapThreadId; - expect(firstBootstrapProjectId).toBeDefined(); - expect(firstBootstrapThreadId).toBeDefined(); - - firstWs.close(); - await closeTestServer(); - server = null; - - server = await createTestServer({ - cwd, - baseDir, - persistenceLayer, - autoBootstrapProjectFromCwd: true, - }); - addr = server.address(); - port = typeof addr === "object" && addr !== null ? addr.port : 0; - expect(port).toBeGreaterThan(0); - - const [secondWs, secondWelcome] = await connectAndAwaitWelcome(port); - connections.push(secondWs); - expect(secondWelcome.data).toEqual( - expect.objectContaining({ - cwd, - projectName: "bootstrap-existing", - bootstrapProjectId: firstBootstrapProjectId, - bootstrapThreadId: firstBootstrapThreadId, - }), - ); - }); - - it("logs outbound websocket push events in dev mode", async () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => { - // Keep test output clean while verifying websocket logs. - }); - - server = await createTestServer({ - cwd: "/test/project", - devUrl: "http://localhost:5173", - }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - expect(port).toBeGreaterThan(0); - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - expect( - logSpy.mock.calls.some(([message]) => { - if (typeof message !== "string") return false; - return ( - message.includes("[ws]") && - message.includes("outgoing push") && - message.includes(`channel="${WS_CHANNELS.serverWelcome}"`) - ); - }), - ).toBe(true); - }); - - it("responds to server.getConfig", async () => { - const baseDir = makeTempDir("t3code-state-get-config-"); - const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); - ensureParentDir(keybindingsPath); - fs.writeFileSync(keybindingsPath, "[]", "utf8"); - - server = await createTestServer({ cwd: "/my/workspace", baseDir }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const response = await sendRequest(ws, WS_METHODS.serverGetConfig); - expect(response.error).toBeUndefined(); - expect(response.result).toEqual({ - cwd: "/my/workspace", - keybindingsConfigPath: keybindingsPath, - keybindings: DEFAULT_RESOLVED_KEYBINDINGS, - issues: [], - providers: defaultProviderStatuses, - availableEditors: expect.any(Array), - }); - expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); - }); - - it("bootstraps default keybindings file when missing", async () => { - const baseDir = makeTempDir("t3code-state-bootstrap-keybindings-"); - const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); - expect(fs.existsSync(keybindingsPath)).toBe(false); - - server = await createTestServer({ cwd: "/my/workspace", baseDir }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const response = await sendRequest(ws, WS_METHODS.serverGetConfig); - expect(response.error).toBeUndefined(); - expect(response.result).toEqual({ - cwd: "/my/workspace", - keybindingsConfigPath: keybindingsPath, - keybindings: DEFAULT_RESOLVED_KEYBINDINGS, - issues: [], - providers: defaultProviderStatuses, - availableEditors: expect.any(Array), - }); - expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); - - const persistedConfig = JSON.parse( - fs.readFileSync(keybindingsPath, "utf8"), - ) as KeybindingsConfig; - expect(persistedConfig).toEqual(DEFAULT_KEYBINDINGS); - }); - - it("falls back to defaults and reports malformed keybindings config issues", async () => { - const baseDir = makeTempDir("t3code-state-malformed-keybindings-"); - const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); - ensureParentDir(keybindingsPath); - fs.writeFileSync(keybindingsPath, "{ not-json", "utf8"); - - server = await createTestServer({ cwd: "/my/workspace", baseDir }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const response = await sendRequest(ws, WS_METHODS.serverGetConfig); - expect(response.error).toBeUndefined(); - expect(response.result).toEqual({ - cwd: "/my/workspace", - keybindingsConfigPath: keybindingsPath, - keybindings: DEFAULT_RESOLVED_KEYBINDINGS, - issues: [ - { - kind: "keybindings.malformed-config", - message: expect.stringContaining("expected JSON array"), - }, - ], - providers: defaultProviderStatuses, - availableEditors: expect.any(Array), - }); - expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); - expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json"); - }); - - it("ignores invalid keybinding entries but keeps valid entries and reports issues", async () => { - const baseDir = makeTempDir("t3code-state-partial-invalid-keybindings-"); - const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); - ensureParentDir(keybindingsPath); - fs.writeFileSync( - keybindingsPath, - JSON.stringify([ - { key: "mod+j", command: "terminal.toggle" }, - { key: "mod+shift+d+o", command: "terminal.new" }, - { key: "mod+x", command: "not-a-real-command" }, - ]), - "utf8", - ); - - server = await createTestServer({ cwd: "/my/workspace", baseDir }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const response = await sendRequest(ws, WS_METHODS.serverGetConfig); - expect(response.error).toBeUndefined(); - const result = response.result as { - cwd: string; - keybindingsConfigPath: string; - keybindings: ResolvedKeybindingsConfig; - issues: Array<{ kind: string; index?: number; message: string }>; - providers: ReadonlyArray; - availableEditors: unknown; - }; - expect(result.cwd).toBe("/my/workspace"); - expect(result.keybindingsConfigPath).toBe(keybindingsPath); - expect(result.issues).toEqual([ - { - kind: "keybindings.invalid-entry", - index: 1, - message: expect.any(String), - }, - { - kind: "keybindings.invalid-entry", - index: 2, - message: expect.any(String), - }, - ]); - expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length); - expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true); - expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true); - expect(result.providers).toEqual(defaultProviderStatuses); - expectAvailableEditors(result.availableEditors); - }); - - it("pushes server.configUpdated issues when keybindings file changes", async () => { - const baseDir = makeTempDir("t3code-state-keybindings-watch-"); - const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); - ensureParentDir(keybindingsPath); - fs.writeFileSync(keybindingsPath, "[]", "utf8"); - - server = await createTestServer({ cwd: "/my/workspace", baseDir }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const malformedPush = await rewriteKeybindingsAndWaitForPush( - ws, - keybindingsPath, - "{ not-json", - (push) => - Array.isArray(push.data.issues) && - Boolean(push.data.issues[0]) && - push.data.issues[0]!.kind === "keybindings.malformed-config", - ); - expect(malformedPush.data).toEqual({ - issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], - providers: defaultProviderStatuses, - }); - - const successPush = await rewriteKeybindingsAndWaitForPush( - ws, - keybindingsPath, - "[]", - (push) => Array.isArray(push.data.issues) && push.data.issues.length === 0, - ); - expect(successPush.data).toEqual({ issues: [], providers: defaultProviderStatuses }); - }); - - it("routes shell.openInEditor through the injected open service", async () => { - const openCalls: Array<{ cwd: string; editor: string }> = []; - const openService: OpenShape = { - openBrowser: () => Effect.void, - openInEditor: (input) => { - openCalls.push({ cwd: input.cwd, editor: input.editor }); - return Effect.void; - }, - }; - - server = await createTestServer({ cwd: "/my/workspace", open: openService }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const response = await sendRequest(ws, WS_METHODS.shellOpenInEditor, { - cwd: "/my/workspace", - editor: "cursor", - }); - expect(response.error).toBeUndefined(); - expect(openCalls).toEqual([{ cwd: "/my/workspace", editor: "cursor" }]); - }); - - it("reads keybindings from the configured state directory", async () => { - const baseDir = makeTempDir("t3code-state-keybindings-"); - const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); - ensureParentDir(keybindingsPath); - fs.writeFileSync( - keybindingsPath, - JSON.stringify([ - { key: "cmd+j", command: "terminal.toggle" }, - { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, - { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, - ]), - "utf8", - ); - server = await createTestServer({ cwd: "/my/workspace", baseDir }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const response = await sendRequest(ws, WS_METHODS.serverGetConfig); - expect(response.error).toBeUndefined(); - const persistedConfig = JSON.parse( - fs.readFileSync(keybindingsPath, "utf8"), - ) as KeybindingsConfig; - expect(response.result).toEqual({ - cwd: "/my/workspace", - keybindingsConfigPath: keybindingsPath, - keybindings: compileKeybindings(persistedConfig), - issues: [], - providers: defaultProviderStatuses, - availableEditors: expect.any(Array), - }); - expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); - }); - - it("upserts keybinding rules and updates cached server config", async () => { - const baseDir = makeTempDir("t3code-state-upsert-keybinding-"); - const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); - ensureParentDir(keybindingsPath); - fs.writeFileSync( - keybindingsPath, - JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), - "utf8", - ); - - server = await createTestServer({ cwd: "/my/workspace", baseDir }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const upsertResponse = await sendRequest(ws, WS_METHODS.serverUpsertKeybinding, { - key: "mod+shift+r", - command: "script.run-tests.run", - }); - expect(upsertResponse.error).toBeUndefined(); - const persistedConfig = JSON.parse( - fs.readFileSync(keybindingsPath, "utf8"), - ) as KeybindingsConfig; - const persistedCommands = new Set(persistedConfig.map((entry) => entry.command)); - for (const defaultRule of DEFAULT_KEYBINDINGS) { - expect(persistedCommands.has(defaultRule.command)).toBe(true); - } - expect(persistedCommands.has("script.run-tests.run")).toBe(true); - expect(upsertResponse.result).toEqual({ - keybindings: compileKeybindings(persistedConfig), - issues: [], - }); - - const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); - expect(configResponse.error).toBeUndefined(); - expect(configResponse.result).toEqual({ - cwd: "/my/workspace", - keybindingsConfigPath: keybindingsPath, - keybindings: compileKeybindings(persistedConfig), - issues: [], - providers: defaultProviderStatuses, - availableEditors: expect.any(Array), - }); - expectAvailableEditors( - (configResponse.result as { availableEditors: unknown }).availableEditors, - ); - }); - - it("returns error for unknown methods", async () => { - server = await createTestServer({ cwd: "/test" }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const response = await sendRequest(ws, "nonexistent.method"); - expect(response.error).toBeDefined(); - expect(response.error!.message).toContain("Invalid request format"); - }); - - it("returns error when requesting turn diff for unknown thread", async () => { - server = await createTestServer({ cwd: "/test" }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { - threadId: "thread-missing", - fromTurnCount: 1, - toTurnCount: 2, - }); - expect(response.result).toBeUndefined(); - expect(response.error?.message).toContain("Thread 'thread-missing' not found."); - }); - - it("returns error when requesting turn diff with an inverted range", async () => { - server = await createTestServer({ cwd: "/test" }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { - threadId: "thread-any", - fromTurnCount: 2, - toTurnCount: 1, - }); - expect(response.result).toBeUndefined(); - expect(response.error?.message).toContain( - "fromTurnCount must be less than or equal to toTurnCount", - ); - }); - - it("returns error when requesting full thread diff for unknown thread", async () => { - server = await createTestServer({ cwd: "/test" }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getFullThreadDiff, { - threadId: "thread-missing", - toTurnCount: 2, - }); - expect(response.result).toBeUndefined(); - expect(response.error?.message).toContain("Thread 'thread-missing' not found."); - }); - - it("returns retryable error when requested turn exceeds current checkpoint turn count", async () => { - server = await createTestServer({ cwd: "/test" }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const workspaceRoot = makeTempDir("t3code-ws-diff-project-"); - const createdAt = new Date().toISOString(); - const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { - type: "project.create", - commandId: "cmd-diff-project-create", - projectId: "project-diff", - title: "Diff Project", - workspaceRoot, - defaultModel: "gpt-5-codex", - createdAt, - }); - expect(createProjectResponse.error).toBeUndefined(); - const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { - type: "thread.create", - commandId: "cmd-diff-thread-create", - threadId: "thread-diff", - projectId: "project-diff", - title: "Diff Thread", - model: "gpt-5-codex", - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - createdAt, - }); - expect(createThreadResponse.error).toBeUndefined(); - - const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { - threadId: "thread-diff", - fromTurnCount: 0, - toTurnCount: 1, - }); - expect(response.result).toBeUndefined(); - expect(response.error?.message).toContain("exceeds current turn count"); - }); - - it("keeps orchestration domain push behavior for provider runtime events", async () => { - const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); - const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { - Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); - }; - const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; - const providerService: ProviderServiceShape = { - startSession: (threadId) => - Effect.succeed({ - provider: "codex", - status: "ready", - runtimeMode: "full-access", - threadId, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }), - sendTurn: ({ threadId }) => - Effect.succeed({ - threadId, - turnId: asTurnId("provider-turn-1"), - }), - interruptTurn: () => unsupported(), - respondToRequest: () => unsupported(), - respondToUserInput: () => unsupported(), - stopSession: () => unsupported(), - listSessions: () => Effect.succeed([]), - getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), - rollbackConversation: () => unsupported(), - streamEvents: Stream.fromPubSub(runtimeEventPubSub), - }; - const providerLayer = Layer.succeed(ProviderService, providerService); - - server = await createTestServer({ - cwd: "/test", - providerLayer, - }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const workspaceRoot = makeTempDir("t3code-ws-project-"); - const createdAt = new Date().toISOString(); - const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { - type: "project.create", - commandId: "cmd-ws-project-create", - projectId: "project-1", - title: "WS Project", - workspaceRoot, - defaultModel: "gpt-5-codex", - createdAt, - }); - expect(createProjectResponse.error).toBeUndefined(); - const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { - type: "thread.create", - commandId: "cmd-ws-runtime-thread-create", - threadId: "thread-1", - projectId: "project-1", - title: "Thread 1", - model: "gpt-5-codex", - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - createdAt, - }); - expect(createThreadResponse.error).toBeUndefined(); - - const startTurnResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { - type: "thread.turn.start", - commandId: "cmd-ws-runtime-turn-start", - threadId: "thread-1", - message: { - messageId: "msg-ws-runtime-1", - role: "user", - text: "hello", - attachments: [], - }, - assistantDeliveryMode: "streaming", - runtimeMode: "approval-required", - interactionMode: "default", - createdAt, - }); - expect(startTurnResponse.error).toBeUndefined(); - - await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => { - const event = push.data as { type?: string }; - return event.type === "thread.session-set"; - }); - - emitRuntimeEvent({ - type: "content.delta", - eventId: asEventId("evt-ws-runtime-message-delta"), - provider: "codex", - threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), - turnId: asTurnId("turn-1"), - itemId: asProviderItemId("item-1"), - payload: { - streamKind: "assistant_text", - delta: "hello from runtime", - }, - } as unknown as ProviderRuntimeEvent); - - const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => { - const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } }; - return ( - event.type === "thread.message-sent" && event.payload?.messageId === "assistant:item-1" - ); - }); - - const domainEvent = domainPush.data as { - type: string; - payload: { messageId: string; text: string }; - }; - expect(domainEvent.type).toBe("thread.message-sent"); - expect(domainEvent.payload.messageId).toBe("assistant:item-1"); - expect(domainEvent.payload.text).toBe("hello from runtime"); - }); - - it("routes terminal RPC methods and broadcasts terminal events", async () => { - const cwd = makeTempDir("t3code-ws-terminal-cwd-"); - const terminalManager = new MockTerminalManager(); - server = await createTestServer({ - cwd: "/test", - terminalManager, - }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const open = await sendRequest(ws, WS_METHODS.terminalOpen, { - threadId: "thread-1", - cwd, - cols: 100, - rows: 24, - }); - expect(open.error).toBeUndefined(); - expect((open.result as TerminalSessionSnapshot).threadId).toBe("thread-1"); - expect((open.result as TerminalSessionSnapshot).terminalId).toBe(DEFAULT_TERMINAL_ID); - - const write = await sendRequest(ws, WS_METHODS.terminalWrite, { - threadId: "thread-1", - data: "echo hello\n", - }); - expect(write.error).toBeUndefined(); - - const resize = await sendRequest(ws, WS_METHODS.terminalResize, { - threadId: "thread-1", - cols: 120, - rows: 30, - }); - expect(resize.error).toBeUndefined(); - - const clear = await sendRequest(ws, WS_METHODS.terminalClear, { - threadId: "thread-1", - }); - expect(clear.error).toBeUndefined(); - - const restart = await sendRequest(ws, WS_METHODS.terminalRestart, { - threadId: "thread-1", - cwd, - cols: 120, - rows: 30, - }); - expect(restart.error).toBeUndefined(); - - const close = await sendRequest(ws, WS_METHODS.terminalClose, { - threadId: "thread-1", - deleteHistory: true, - }); - expect(close.error).toBeUndefined(); - - const manualEvent: TerminalEvent = { - type: "output", - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - createdAt: new Date().toISOString(), - data: "manual test output\n", - }; - terminalManager.emitEvent(manualEvent); - - const push = await waitForPush( - ws, - WS_CHANNELS.terminalEvent, - (candidate) => (candidate.data as TerminalEvent).type === "output", - ); - expect(push.type).toBe("push"); - expect(push.channel).toBe(WS_CHANNELS.terminalEvent); - }); - - it("detaches terminal event listener on stop for injected manager", async () => { - const terminalManager = new MockTerminalManager(); - server = await createTestServer({ - cwd: "/test", - terminalManager, - }); - - expect(terminalManager.subscriptionCount()).toBe(1); - - await closeTestServer(); - server = null; - - expect(terminalManager.subscriptionCount()).toBe(0); - }); - - it("returns validation errors for invalid terminal open params", async () => { - server = await createTestServer({ cwd: "/test" }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const response = await sendRequest(ws, WS_METHODS.terminalOpen, { - threadId: "", - cwd: "", - cols: 1, - rows: 1, - }); - expect(response.error).toBeDefined(); - }); - - it("handles invalid JSON gracefully", async () => { - server = await createTestServer({ cwd: "/test" }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - // Send garbage - ws.send("not json at all"); - - // Error response goes to the response channel - const channels = channelsBySocket.get(ws)!; - let response: WebSocketResponse | null = null; - for (let attempt = 0; attempt < 5; attempt += 1) { - const message = await dequeue(channels.response, 5_000); - if (message.id === "unknown") { - response = message; - break; - } - if (message.error) { - response = message; - break; - } - } - expect(response).toBeDefined(); - expect(response!.error).toBeDefined(); - expect(response!.error!.message).toContain("Invalid request format"); - }); - - it("catches websocket message handler rejections and keeps the socket usable", async () => { - const unhandledRejections: unknown[] = []; - const onUnhandledRejection = (reason: unknown) => { - unhandledRejections.push(reason); - }; - process.on("unhandledRejection", onUnhandledRejection); - - const brokenOpenService: OpenShape = { - openBrowser: () => Effect.void, - openInEditor: () => - Effect.sync(() => BigInt(1)).pipe(Effect.map((result) => result as unknown as void)), - }; - - try { - server = await createTestServer({ cwd: "/test", open: brokenOpenService }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - ws.send( - JSON.stringify({ - id: "req-broken-open", - body: { - _tag: WS_METHODS.shellOpenInEditor, - cwd: "/tmp", - editor: "cursor", - }, - }), - ); - - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(unhandledRejections).toHaveLength(0); - - const workspace = makeTempDir("t3code-ws-handler-still-usable-"); - fs.writeFileSync(path.join(workspace, "file.txt"), "ok\n", "utf8"); - const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, { - cwd: workspace, - query: "file", - limit: 5, - }); - expect(response.error).toBeUndefined(); - expect(response.result).toEqual( - expect.objectContaining({ - entries: expect.arrayContaining([ - expect.objectContaining({ - path: "file.txt", - kind: "file", - }), - ]), - }), - ); - } finally { - process.off("unhandledRejection", onUnhandledRejection); - } - }); - - it("returns errors for removed projects CRUD methods", async () => { - server = await createTestServer({ cwd: "/test" }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const listResponse = await sendRequest(ws, WS_METHODS.projectsList); - expect(listResponse.result).toBeUndefined(); - expect(listResponse.error?.message).toContain("Invalid request format"); - - const addResponse = await sendRequest(ws, WS_METHODS.projectsAdd, { - cwd: "/tmp/project-a", - }); - expect(addResponse.result).toBeUndefined(); - expect(addResponse.error?.message).toContain("Invalid request format"); - - const removeResponse = await sendRequest(ws, WS_METHODS.projectsRemove, { - id: "project-a", - }); - expect(removeResponse.result).toBeUndefined(); - expect(removeResponse.error?.message).toContain("Invalid request format"); - }); - - it("supports projects.searchEntries", async () => { - const workspace = makeTempDir("t3code-ws-workspace-entries-"); - fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true }); - fs.writeFileSync( - path.join(workspace, "src", "components", "Composer.tsx"), - "export {};", - "utf8", - ); - fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8"); - fs.mkdirSync(path.join(workspace, ".git"), { recursive: true }); - fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8"); - - server = await createTestServer({ cwd: "/test" }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, { - cwd: workspace, - query: "comp", - limit: 10, - }); - expect(response.error).toBeUndefined(); - expect(response.result).toEqual({ - entries: expect.arrayContaining([ - expect.objectContaining({ path: "src/components", kind: "directory" }), - expect.objectContaining({ path: "src/components/Composer.tsx", kind: "file" }), - ]), - truncated: false, - }); - }); - - it("supports projects.writeFile within the workspace root", async () => { - const workspace = makeTempDir("t3code-ws-write-file-"); - - server = await createTestServer({ cwd: "/test" }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, { - cwd: workspace, - relativePath: "plans/effect-rpc.md", - contents: "# Plan\n\n- step 1\n", - }); - - expect(response.error).toBeUndefined(); - expect(response.result).toEqual({ - relativePath: "plans/effect-rpc.md", - }); - expect(fs.readFileSync(path.join(workspace, "plans", "effect-rpc.md"), "utf8")).toBe( - "# Plan\n\n- step 1\n", - ); - }); - - it("rejects projects.writeFile paths outside the workspace root", async () => { - const workspace = makeTempDir("t3code-ws-write-file-reject-"); - - server = await createTestServer({ cwd: "/test" }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, { - cwd: workspace, - relativePath: "../escape.md", - contents: "# no\n", - }); - - expect(response.result).toBeUndefined(); - expect(response.error?.message).toContain( - "Workspace file path must stay within the project root.", - ); - expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false); - }); - - it("routes git core methods over websocket", async () => { - const listBranches = vi.fn(() => - Effect.succeed({ - branches: [], - isRepo: false, - hasOriginRemote: false, - }), - ); - const initRepo = vi.fn(() => Effect.void); - const pullCurrentBranch = vi.fn(() => - Effect.fail( - new GitCommandError({ - operation: "GitCore.test.pullCurrentBranch", - detail: "No upstream configured", - command: "git pull", - cwd: "/repo/path", - }), - ), - ); - - server = await createTestServer({ - cwd: "/test", - gitCore: { - listBranches, - initRepo, - pullCurrentBranch, - }, - }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" }); - expect(listResponse.error).toBeUndefined(); - expect(listResponse.result).toEqual({ branches: [], isRepo: false, hasOriginRemote: false }); - expect(listBranches).toHaveBeenCalledWith({ cwd: "/repo/path" }); - - const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: "/repo/path" }); - expect(initResponse.error).toBeUndefined(); - expect(initRepo).toHaveBeenCalledWith({ cwd: "/repo/path" }); - - const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: "/repo/path" }); - expect(pullResponse.result).toBeUndefined(); - expect(pullResponse.error?.message).toContain("No upstream configured"); - expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path"); - }); - - it("supports git.status over websocket", async () => { - const statusResult = { - branch: "feature/test", - hasWorkingTreeChanges: true, - workingTree: { - files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }], - insertions: 7, - deletions: 2, - }, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - pr: null, - }; - - const status = vi.fn(() => Effect.succeed(statusResult)); - const runStackedAction = vi.fn(() => Effect.void as any); - const resolvePullRequest = vi.fn(() => Effect.void as any); - const preparePullRequestThread = vi.fn(() => Effect.void as any); - const gitManager: GitManagerShape = { - status, - resolvePullRequest, - preparePullRequestThread, - runStackedAction, - }; - - server = await createTestServer({ cwd: "/test", gitManager }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const response = await sendRequest(ws, WS_METHODS.gitStatus, { - cwd: "/test", - }); - expect(response.error).toBeUndefined(); - expect(response.result).toEqual(statusResult); - expect(status).toHaveBeenCalledWith({ cwd: "/test" }); - }); - - it("supports git pull request routing over websocket", async () => { - const resolvePullRequestResult = { - pullRequest: { - number: 42, - title: "PR thread flow", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseBranch: "main", - headBranch: "feature/pr-threads", - state: "open" as const, - }, - }; - const preparePullRequestThreadResult = { - ...resolvePullRequestResult, - branch: "feature/pr-threads", - worktreePath: "/tmp/pr-threads", - }; - - const gitManager: GitManagerShape = { - status: vi.fn(() => Effect.void as any), - resolvePullRequest: vi.fn(() => Effect.succeed(resolvePullRequestResult)), - preparePullRequestThread: vi.fn(() => Effect.succeed(preparePullRequestThreadResult)), - runStackedAction: vi.fn(() => Effect.void as any), - }; - - server = await createTestServer({ cwd: "/test", gitManager }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const resolveResponse = await sendRequest(ws, WS_METHODS.gitResolvePullRequest, { - cwd: "/test", - reference: "#42", - }); - expect(resolveResponse.error).toBeUndefined(); - expect(resolveResponse.result).toEqual(resolvePullRequestResult); - - const prepareResponse = await sendRequest(ws, WS_METHODS.gitPreparePullRequestThread, { - cwd: "/test", - reference: "42", - mode: "worktree", - }); - expect(prepareResponse.error).toBeUndefined(); - expect(prepareResponse.result).toEqual(preparePullRequestThreadResult); - expect(gitManager.resolvePullRequest).toHaveBeenCalledWith({ - cwd: "/test", - reference: "#42", - }); - expect(gitManager.preparePullRequestThread).toHaveBeenCalledWith({ - cwd: "/test", - reference: "42", - mode: "worktree", - }); - }); - - it("returns errors from git.runStackedAction", async () => { - const runStackedAction = vi.fn(() => - Effect.fail( - new GitManagerError({ - operation: "GitManager.test.runStackedAction", - detail: "Cannot push from detached HEAD.", - }), - ), - ); - const gitManager: GitManagerShape = { - status: vi.fn(() => Effect.void as any), - resolvePullRequest: vi.fn(() => Effect.void as any), - preparePullRequestThread: vi.fn(() => Effect.void as any), - runStackedAction, - }; - - server = await createTestServer({ cwd: "/test", gitManager }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [ws] = await connectAndAwaitWelcome(port); - connections.push(ws); - - const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, { - cwd: "/test", - action: "commit_push", - }); - expect(response.result).toBeUndefined(); - expect(response.error?.message).toContain("detached HEAD"); - expect(runStackedAction).toHaveBeenCalledWith({ - cwd: "/test", - action: "commit_push", - }); - }); - - it("rejects websocket connections without a valid auth token", async () => { - server = await createTestServer({ cwd: "/test", authToken: "secret-token" }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed"); - - const [authorizedWs] = await connectAndAwaitWelcome(port, "secret-token"); - connections.push(authorizedWs); - }); -}); From 7b78c42fa24132ac0227f604f484b87cdb0ac56b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Mar 2026 19:00:19 -0700 Subject: [PATCH 03/47] remove another test file that just trips the model up --- apps/server/src/main.test.ts | 300 ----------------------------------- 1 file changed, 300 deletions(-) delete mode 100644 apps/server/src/main.test.ts diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts deleted file mode 100644 index b1e5da0c87..0000000000 --- a/apps/server/src/main.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -import * as Http from "node:http"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it, vi } from "@effect/vitest"; -import type { OrchestrationReadModel } from "@t3tools/contracts"; -import * as ConfigProvider from "effect/ConfigProvider"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Command from "effect/unstable/cli/Command"; -import { FetchHttpClient } from "effect/unstable/http"; -import { beforeEach } from "vitest"; -import { NetService } from "@t3tools/shared/Net"; - -import { CliConfig, recordStartupHeartbeat, t3Cli, type CliConfigShape } from "./main"; -import { ServerConfig, type ServerConfigShape } from "./config"; -import { Open, type OpenShape } from "./open"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; -import { Server, type ServerShape } from "./wsServer"; - -const start = vi.fn(() => undefined); -const stop = vi.fn(() => undefined); -let resolvedConfig: ServerConfigShape | null = null; -const serverStart = Effect.acquireRelease( - Effect.gen(function* () { - resolvedConfig = yield* ServerConfig; - start(); - return {} as unknown as Http.Server; - }), - () => Effect.sync(() => stop()), -); -const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred)); - -// Shared service layer used by this CLI test suite. -const testLayer = Layer.mergeAll( - Layer.succeed(CliConfig, { - cwd: "/tmp/t3-test-workspace", - fixPath: Effect.void, - resolveStaticDir: Effect.undefined, - } satisfies CliConfigShape), - Layer.succeed(NetService, { - canListenOnHost: () => Effect.succeed(true), - isPortAvailableOnLoopback: () => Effect.succeed(true), - reserveLoopbackPort: () => Effect.succeed(0), - findAvailablePort, - }), - Layer.succeed(Server, { - start: serverStart, - stopSignal: Effect.void, - } satisfies ServerShape), - Layer.succeed(Open, { - openBrowser: (_target: string) => Effect.void, - openInEditor: () => Effect.void, - } satisfies OpenShape), - AnalyticsService.layerTest, - FetchHttpClient.layer, - NodeServices.layer, -); - -const runCli = ( - args: ReadonlyArray, - env: Record = { T3CODE_NO_BROWSER: "true" }, -) => { - return Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe( - Effect.provide( - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - ...env, - }, - }), - ), - ), - ); -}; - -beforeEach(() => { - vi.clearAllMocks(); - resolvedConfig = null; - start.mockImplementation(() => undefined); - stop.mockImplementation(() => undefined); - findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred)); -}); - -it.layer(testLayer)("server CLI command", (it) => { - it.effect("parses all CLI flags and wires scoped start/stop", () => - Effect.gen(function* () { - yield* runCli([ - "--mode", - "desktop", - "--port", - "4010", - "--host", - "0.0.0.0", - "--home-dir", - "/tmp/t3-cli-home", - "--dev-url", - "http://127.0.0.1:5173", - "--no-browser", - "--auth-token", - "auth-secret", - ]); - - assert.equal(start.mock.calls.length, 1); - assert.equal(resolvedConfig?.mode, "desktop"); - assert.equal(resolvedConfig?.port, 4010); - assert.equal(resolvedConfig?.host, "0.0.0.0"); - assert.equal(resolvedConfig?.baseDir, "/tmp/t3-cli-home"); - assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-home/dev"); - assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); - assert.equal(resolvedConfig?.noBrowser, true); - assert.equal(resolvedConfig?.authToken, "auth-secret"); - assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false); - assert.equal(resolvedConfig?.logWebSocketEvents, true); - assert.equal(stop.mock.calls.length, 1); - }), - ); - - it.effect("supports --token as an alias for --auth-token", () => - Effect.gen(function* () { - yield* runCli(["--token", "token-secret"]); - - assert.equal(start.mock.calls.length, 1); - assert.equal(resolvedConfig?.authToken, "token-secret"); - }), - ); - - it.effect("uses env fallbacks when flags are not provided", () => - Effect.gen(function* () { - yield* runCli([], { - T3CODE_MODE: "desktop", - T3CODE_PORT: "4999", - T3CODE_HOST: "100.88.10.4", - T3CODE_HOME: "/tmp/t3-env-home", - VITE_DEV_SERVER_URL: "http://localhost:5173", - T3CODE_NO_BROWSER: "true", - T3CODE_AUTH_TOKEN: "env-token", - }); - - assert.equal(start.mock.calls.length, 1); - assert.equal(resolvedConfig?.mode, "desktop"); - assert.equal(resolvedConfig?.port, 4999); - assert.equal(resolvedConfig?.host, "100.88.10.4"); - assert.equal(resolvedConfig?.baseDir, "/tmp/t3-env-home"); - assert.equal(resolvedConfig?.stateDir, "/tmp/t3-env-home/dev"); - assert.equal(resolvedConfig?.devUrl?.toString(), "http://localhost:5173/"); - assert.equal(resolvedConfig?.noBrowser, true); - assert.equal(resolvedConfig?.authToken, "env-token"); - assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false); - assert.equal(resolvedConfig?.logWebSocketEvents, true); - assert.equal(findAvailablePort.mock.calls.length, 0); - }), - ); - - it.effect("prefers --mode over T3CODE_MODE", () => - Effect.gen(function* () { - findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666)); - yield* runCli(["--mode", "web"], { - T3CODE_MODE: "desktop", - T3CODE_NO_BROWSER: "true", - }); - - assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]); - assert.equal(start.mock.calls.length, 1); - assert.equal(resolvedConfig?.mode, "web"); - assert.equal(resolvedConfig?.port, 4666); - assert.equal(resolvedConfig?.host, undefined); - }), - ); - - it.effect("prefers --no-browser over T3CODE_NO_BROWSER", () => - Effect.gen(function* () { - yield* runCli(["--no-browser"], { - T3CODE_NO_BROWSER: "false", - }); - - assert.equal(start.mock.calls.length, 1); - assert.equal(resolvedConfig?.noBrowser, true); - }), - ); - - it.effect("uses dynamic port discovery in web mode when port is omitted", () => - Effect.gen(function* () { - findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(5444)); - yield* runCli([]); - - assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]); - assert.equal(start.mock.calls.length, 1); - assert.equal(resolvedConfig?.port, 5444); - assert.equal(resolvedConfig?.mode, "web"); - }), - ); - - it.effect("uses fixed localhost defaults in desktop mode", () => - Effect.gen(function* () { - yield* runCli([], { - T3CODE_MODE: "desktop", - T3CODE_NO_BROWSER: "true", - }); - - assert.equal(findAvailablePort.mock.calls.length, 0); - assert.equal(start.mock.calls.length, 1); - assert.equal(resolvedConfig?.port, 3773); - assert.equal(resolvedConfig?.host, "127.0.0.1"); - assert.equal(resolvedConfig?.mode, "desktop"); - }), - ); - - it.effect("allows overriding desktop host with --host", () => - Effect.gen(function* () { - yield* runCli(["--host", "0.0.0.0"], { - T3CODE_MODE: "desktop", - T3CODE_NO_BROWSER: "true", - }); - - assert.equal(start.mock.calls.length, 1); - assert.equal(resolvedConfig?.mode, "desktop"); - assert.equal(resolvedConfig?.host, "0.0.0.0"); - }), - ); - - it.effect("supports CLI and env for bootstrap/log websocket toggles", () => - Effect.gen(function* () { - yield* runCli(["--auto-bootstrap-project-from-cwd"], { - T3CODE_MODE: "desktop", - T3CODE_LOG_WS_EVENTS: "false", - T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", - T3CODE_NO_BROWSER: "true", - }); - - assert.equal(start.mock.calls.length, 1); - assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, true); - assert.equal(resolvedConfig?.logWebSocketEvents, false); - }), - ); - - it.effect("records a startup heartbeat with thread/project counts", () => - Effect.gen(function* () { - const recordTelemetry = vi.fn( - (_event: string, _properties?: Readonly>) => Effect.void, - ); - const getSnapshot = vi.fn(() => - Effect.succeed({ - snapshotSequence: 2, - projects: [{} as OrchestrationReadModel["projects"][number]], - threads: [ - {} as OrchestrationReadModel["threads"][number], - {} as OrchestrationReadModel["threads"][number], - ], - updatedAt: new Date(1).toISOString(), - } satisfies OrchestrationReadModel), - ); - - yield* recordStartupHeartbeat.pipe( - Effect.provideService(ProjectionSnapshotQuery, { - getSnapshot, - }), - Effect.provideService(AnalyticsService, { - record: recordTelemetry, - flush: Effect.void, - }), - ); - - assert.deepEqual(recordTelemetry.mock.calls[0], [ - "server.boot.heartbeat", - { - threadCount: 2, - projectCount: 1, - }, - ]); - }), - ); - - it.effect("does not start server for invalid --mode values", () => - Effect.gen(function* () { - yield* runCli(["--mode", "invalid"]); - - assert.equal(start.mock.calls.length, 0); - assert.equal(stop.mock.calls.length, 0); - }), - ); - - it.effect("does not start server for invalid --dev-url values", () => - Effect.gen(function* () { - yield* runCli(["--dev-url", "not-a-url"]).pipe(Effect.catch(() => Effect.void)); - - assert.equal(start.mock.calls.length, 0); - assert.equal(stop.mock.calls.length, 0); - }), - ); - - it.effect("does not start server for out-of-range --port values", () => - Effect.gen(function* () { - yield* runCli(["--port", "70000"]); - - // effect/unstable/cli renders help/errors for parse failures and returns success. - assert.equal(start.mock.calls.length, 0); - assert.equal(stop.mock.calls.length, 0); - }), - ); -}); From 61a365a062300d0ff874edf4a303611012344d3a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Mar 2026 19:55:15 -0700 Subject: [PATCH 04/47] mirror setup --- .oxfmtrc.json | 1 + apps/server/package.json | 6 +- apps/server/src/bin.ts | 14 ++ apps/server/src/cli-config.test.ts | 118 ++++++++++ apps/server/src/cli.ts | 195 ++++++++++++++++ apps/server/src/index.ts | 23 -- apps/server/src/main.ts | 343 ----------------------------- apps/server/src/server.ts | 29 +++ apps/server/src/wsServer.ts | 92 +++++--- apps/server/tsdown.config.ts | 2 +- 10 files changed, 423 insertions(+), 400 deletions(-) create mode 100644 apps/server/src/bin.ts create mode 100644 apps/server/src/cli-config.test.ts create mode 100644 apps/server/src/cli.ts delete mode 100644 apps/server/src/index.ts delete mode 100644 apps/server/src/main.ts create mode 100644 apps/server/src/server.ts diff --git a/.oxfmtrc.json b/.oxfmtrc.json index ef2236d0f2..a3e32c9797 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,6 +1,7 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", "ignorePatterns": [ + ".reference", ".plans", "dist", "dist-electron", diff --git a/apps/server/package.json b/apps/server/package.json index 930eff7b23..879c2a27ca 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -8,16 +8,16 @@ "directory": "apps/server" }, "bin": { - "t3": "./dist/index.mjs" + "t3": "./dist/bin.mjs" }, "files": [ "dist" ], "type": "module", "scripts": { - "dev": "bun run src/index.ts", + "dev": "bun run src/bin.ts", "build": "node scripts/cli.ts build", - "start": "node dist/index.mjs", + "start": "node dist/bin.mjs", "prepare": "effect-language-service patch", "typecheck": "tsc --noEmit", "test": "vitest run" diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts new file mode 100644 index 0000000000..55bf4d04b1 --- /dev/null +++ b/apps/server/src/bin.ts @@ -0,0 +1,14 @@ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { Command } from "effect/unstable/cli"; + +import { NetService } from "@t3tools/shared/Net"; +import { cli } from "./cli"; +import { version } from "../package.json" with { type: "json" }; + +Command.run(cli, { version }).pipe( + Effect.provide(Layer.mergeAll(NetService.layer, NodeServices.layer)), + NodeRuntime.runMain, +); diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts new file mode 100644 index 0000000000..e309f98da7 --- /dev/null +++ b/apps/server/src/cli-config.test.ts @@ -0,0 +1,118 @@ +import os from "node:os"; + +import { expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect, Layer, Option, Path } from "effect"; + +import { NetService } from "@t3tools/shared/Net"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { resolveServerConfig } from "./cli"; + +it.layer(NodeServices.layer)("cli config resolution", (it) => { + it.effect("falls back to effect/config values when flags are omitted", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const stateDir = join(os.tmpdir(), "t3-cli-config-env-state"); + const resolved = yield* resolveServerConfig({ + mode: Option.none(), + port: Option.none(), + host: Option.none(), + stateDir: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + authToken: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + }).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_MODE: "desktop", + T3CODE_PORT: "4001", + T3CODE_HOST: "0.0.0.0", + T3CODE_STATE_DIR: stateDir, + VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", + T3CODE_NO_BROWSER: "true", + T3CODE_AUTH_TOKEN: "env-token", + T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", + T3CODE_LOG_WS_EVENTS: "true", + }, + }), + ), + NetService.layer, + ), + ), + ); + + expect(resolved).toEqual({ + mode: "desktop", + port: 4001, + cwd: process.cwd(), + keybindingsConfigPath: join(stateDir, "keybindings.json"), + host: "0.0.0.0", + stateDir, + staticDir: undefined, + devUrl: new URL("http://127.0.0.1:5173"), + noBrowser: true, + authToken: "env-token", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: true, + }); + }), + ); + + it.effect("uses CLI flags when provided", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const stateDir = join(os.tmpdir(), "t3-cli-config-flags-state"); + const resolved = yield* resolveServerConfig({ + mode: Option.some("web"), + port: Option.some(8788), + host: Option.some("127.0.0.1"), + stateDir: Option.some(stateDir), + devUrl: Option.some(new URL("http://127.0.0.1:4173")), + noBrowser: Option.some(true), + authToken: Option.some("flag-token"), + autoBootstrapProjectFromCwd: Option.some(true), + logWebSocketEvents: Option.some(true), + }).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_MODE: "desktop", + T3CODE_PORT: "4001", + T3CODE_HOST: "0.0.0.0", + T3CODE_STATE_DIR: join(os.tmpdir(), "ignored-state"), + VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", + T3CODE_NO_BROWSER: "false", + T3CODE_AUTH_TOKEN: "ignored-token", + T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", + T3CODE_LOG_WS_EVENTS: "false", + }, + }), + ), + NetService.layer, + ), + ), + ); + + expect(resolved).toEqual({ + mode: "web", + port: 8788, + cwd: process.cwd(), + keybindingsConfigPath: join(stateDir, "keybindings.json"), + host: "127.0.0.1", + stateDir, + staticDir: undefined, + devUrl: new URL("http://127.0.0.1:4173"), + noBrowser: true, + authToken: "flag-token", + autoBootstrapProjectFromCwd: true, + logWebSocketEvents: true, + }); + }), + ); +}); diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts new file mode 100644 index 0000000000..61af4cf3d3 --- /dev/null +++ b/apps/server/src/cli.ts @@ -0,0 +1,195 @@ +import { NetService } from "@t3tools/shared/Net"; +import { Config, Effect, Option, Path, Schema } from "effect"; +import { Command, Flag } from "effect/unstable/cli"; + +import { + DEFAULT_PORT, + resolveStaticDir, + ServerConfig, + type RuntimeMode, + type ServerConfigShape, +} from "./config"; +import { resolveStateDir } from "./os-jank"; +import { runServer } from "./server"; + +const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe( + Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), + Flag.optional, +); +const portFlag = Flag.integer("port").pipe( + Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), + Flag.withDescription("Port for the HTTP/WebSocket server."), + Flag.optional, +); +const hostFlag = Flag.string("host").pipe( + Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), + Flag.optional, +); +const stateDirFlag = Flag.string("state-dir").pipe( + Flag.withDescription("State directory path (equivalent to T3CODE_STATE_DIR)."), + Flag.optional, +); +const devUrlFlag = Flag.string("dev-url").pipe( + Flag.withSchema(Schema.URLFromString), + Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."), + Flag.optional, +); +const noBrowserFlag = Flag.boolean("no-browser").pipe( + Flag.withDescription("Disable automatic browser opening."), + Flag.optional, +); +const authTokenFlag = Flag.string("auth-token").pipe( + Flag.withDescription("Auth token required for WebSocket connections."), + Flag.withAlias("token"), + Flag.optional, +); +const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( + Flag.withDescription( + "Create a project for the current working directory on startup when missing.", + ), + Flag.optional, +); +const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( + Flag.withDescription( + "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).", + ), + Flag.withAlias("log-ws-events"), + Flag.optional, +); + +const EnvServerConfig = Config.all({ + mode: Config.string("T3CODE_MODE").pipe( + Config.option, + Config.map( + Option.match({ + onNone: () => "web", + onSome: (value) => (value === "desktop" ? "desktop" : "web"), + }), + ), + ), + port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), + host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), + stateDir: Config.string("T3CODE_STATE_DIR").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), + noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + authToken: Config.string("T3CODE_AUTH_TOKEN").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), +}); + +interface CliServerFlags { + readonly mode: Option.Option; + readonly port: Option.Option; + readonly host: Option.Option; + readonly stateDir: Option.Option; + readonly devUrl: Option.Option; + readonly noBrowser: Option.Option; + readonly authToken: Option.Option; + readonly autoBootstrapProjectFromCwd: Option.Option; + readonly logWebSocketEvents: Option.Option; +} + +const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => + Option.getOrElse(Option.filter(flag, Boolean), () => envValue); + +export const resolveServerConfig = (flags: CliServerFlags) => + Effect.gen(function* () { + const { findAvailablePort } = yield* NetService; + const env = yield* EnvServerConfig; + + const mode = Option.getOrElse(flags.mode, () => env.mode); + + const port = yield* Option.match(flags.port, { + onSome: (value) => Effect.succeed(value), + onNone: () => { + if (env.port) { + return Effect.succeed(env.port); + } + if (mode === "desktop") { + return Effect.succeed(DEFAULT_PORT); + } + return findAvailablePort(DEFAULT_PORT); + }, + }); + const stateDir = yield* resolveStateDir(Option.getOrUndefined(flags.stateDir) ?? env.stateDir); + const devUrl = Option.getOrElse(flags.devUrl, () => env.devUrl); + const noBrowser = resolveBooleanFlag(flags.noBrowser, env.noBrowser ?? mode === "desktop"); + const authToken = Option.getOrUndefined(flags.authToken) ?? env.authToken; + const autoBootstrapProjectFromCwd = resolveBooleanFlag( + flags.autoBootstrapProjectFromCwd, + env.autoBootstrapProjectFromCwd ?? mode === "web", + ); + const logWebSocketEvents = resolveBooleanFlag( + flags.logWebSocketEvents, + env.logWebSocketEvents ?? Boolean(devUrl), + ); + const staticDir = devUrl ? undefined : yield* resolveStaticDir(); + const { join } = yield* Path.Path; + const keybindingsConfigPath = join(stateDir, "keybindings.json"); + const host = + Option.getOrUndefined(flags.host) ?? + env.host ?? + (mode === "desktop" ? "127.0.0.1" : undefined); + + const config: ServerConfigShape = { + mode, + port, + cwd: process.cwd(), + keybindingsConfigPath, + host, + stateDir, + staticDir, + devUrl, + noBrowser, + authToken, + autoBootstrapProjectFromCwd, + logWebSocketEvents, + }; + + return config; + }); + +const commandFlags = { + mode: modeFlag, + port: portFlag, + host: hostFlag, + stateDir: stateDirFlag, + devUrl: devUrlFlag, + noBrowser: noBrowserFlag, + authToken: authTokenFlag, + autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, + logWebSocketEvents: logWebSocketEventsFlag, +} as const; + +const rootCommand = Command.make("t3", commandFlags).pipe( + Command.withDescription("Run the T3 Code server."), + Command.withHandler((flags) => + Effect.flatMap(resolveServerConfig(flags), (config) => + runServer.pipe(Effect.provideService(ServerConfig, config)), + ), + ), +); + +const resetCommand = Command.make("reset", commandFlags).pipe( + Command.withDescription("Reset the T3 Code server."), + Command.withHandler((flags) => + Effect.flatMap(resolveServerConfig(flags), (_config) => Effect.die("Not implemented")), + ), +); + +export const cli = rootCommand.pipe(Command.withSubcommands([resetCommand])); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts deleted file mode 100644 index 363a07ee38..0000000000 --- a/apps/server/src/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import { CliConfig, t3Cli } 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 "@t3tools/shared/Net"; -import { FetchHttpClient } from "effect/unstable/http"; - -const RuntimeLayer = Layer.empty.pipe( - Layer.provideMerge(CliConfig.layer), - Layer.provideMerge(ServerLive), - Layer.provideMerge(OpenLive), - Layer.provideMerge(NetService.layer), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(FetchHttpClient.layer), -); - -Command.run(t3Cli, { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts deleted file mode 100644 index 17bf7f32f7..0000000000 --- a/apps/server/src/main.ts +++ /dev/null @@ -1,343 +0,0 @@ -/** - * CliConfig - CLI/runtime bootstrap service definitions. - * - * Defines startup-only service contracts used while resolving process config - * and constructing server runtime layers. - * - * @module CliConfig - */ -import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect"; -import { Command, Flag } from "effect/unstable/cli"; -import { NetService } from "@t3tools/shared/Net"; -import { - DEFAULT_PORT, - deriveServerPaths, - resolveStaticDir, - ServerConfig, - type RuntimeMode, - type ServerConfigShape, -} from "./config"; -import { fixPath, resolveBaseDir } from "./os-jank"; -import { Open } from "./open"; -import * as SqlitePersistence from "./persistence/Layers/Sqlite"; -import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; -import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; -import { Server } from "./wsServer"; -import { ServerLoggerLive } from "./serverLogger"; -import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; - -export class StartupError extends Data.TaggedError("StartupError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -interface CliInput { - readonly mode: Option.Option; - readonly port: Option.Option; - readonly host: Option.Option; - readonly t3Home: Option.Option; - readonly devUrl: Option.Option; - readonly noBrowser: Option.Option; - readonly authToken: Option.Option; - readonly autoBootstrapProjectFromCwd: Option.Option; - readonly logWebSocketEvents: Option.Option; -} - -/** - * CliConfigShape - Startup helpers required while building server layers. - */ -export interface CliConfigShape { - /** - * Current process working directory. - */ - readonly cwd: string; - - /** - * Apply OS-specific PATH normalization. - */ - readonly fixPath: Effect.Effect; - - /** - * Resolve static web asset directory for server mode. - */ - readonly resolveStaticDir: Effect.Effect; -} - -/** - * CliConfig - Service tag for startup CLI/runtime helpers. - */ -export class CliConfig extends ServiceMap.Service()( - "t3/main/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", - onSome: (value) => (value === "desktop" ? "desktop" : "web"), - }), - ), - ), - port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), - host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), - t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), - devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), - noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), - authToken: Config.string("T3CODE_AUTH_TOKEN").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), - autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), - logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), -}); - -const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => - Option.getOrElse(Option.filter(flag, Boolean), () => envValue); - -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 mode = Option.getOrElse(input.mode, () => env.mode); - - const port = yield* Option.match(input.port, { - onSome: (value) => Effect.succeed(value), - onNone: () => { - if (env.port) { - return Effect.succeed(env.port); - } - if (mode === "desktop") { - return Effect.succeed(DEFAULT_PORT); - } - return findAvailablePort(DEFAULT_PORT); - }, - }); - - const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); - const baseDir = yield* resolveBaseDir(Option.getOrUndefined(input.t3Home) ?? env.t3Home); - const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); - const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); - const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; - const autoBootstrapProjectFromCwd = resolveBooleanFlag( - input.autoBootstrapProjectFromCwd, - env.autoBootstrapProjectFromCwd ?? mode === "web", - ); - const logWebSocketEvents = resolveBooleanFlag( - input.logWebSocketEvents, - env.logWebSocketEvents ?? Boolean(devUrl), - ); - const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; - const host = - Option.getOrUndefined(input.host) ?? - env.host ?? - (mode === "desktop" ? "127.0.0.1" : undefined); - - const config: ServerConfigShape = { - mode, - port, - cwd: cliConfig.cwd, - host, - baseDir, - ...derivedPaths, - staticDir, - devUrl, - noBrowser, - authToken, - autoBootstrapProjectFromCwd, - logWebSocketEvents, - } satisfies ServerConfigShape; - - return config; - }), - ); - -const LayerLive = (input: CliInput) => - Layer.empty.pipe( - Layer.provideMerge(makeServerRuntimeServicesLayer()), - Layer.provideMerge(makeServerProviderLayer()), - Layer.provideMerge(ProviderHealthLive), - Layer.provideMerge(SqlitePersistence.layerConfig), - Layer.provideMerge(ServerLoggerLive), - Layer.provideMerge(AnalyticsServiceLayerLive), - Layer.provideMerge(ServerConfigLive(input)), - ); - -const isWildcardHost = (host: string | undefined): boolean => - host === "0.0.0.0" || host === "::" || host === "[::]"; - -const formatHostForUrl = (host: string): string => - host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; - -export const recordStartupHeartbeat = Effect.gen(function* () { - const analytics = yield* AnalyticsService; - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - - const { threadCount, projectCount } = yield* projectionSnapshotQuery.getSnapshot().pipe( - Effect.map((snapshot) => ({ - threadCount: snapshot.threads.length, - projectCount: snapshot.projects.length, - })), - Effect.catch((cause) => - Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe( - Effect.as({ - threadCount: 0, - projectCount: 0, - }), - ), - ), - ); - - yield* analytics.record("server.boot.heartbeat", { - threadCount, - projectCount, - }); -}); - -const makeServerProgram = (input: CliInput) => - Effect.gen(function* () { - const cliConfig = yield* CliConfig; - const { start, stopSignal } = yield* Server; - const openDeps = yield* Open; - yield* cliConfig.fixPath; - - const config = yield* ServerConfig; - - 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.", - }, - ); - } - - yield* start; - yield* Effect.forkChild(recordStartupHeartbeat); - - const localUrl = `http://localhost:${config.port}`; - const bindUrl = - config.host && !isWildcardHost(config.host) - ? `http://${formatHostForUrl(config.host)}:${config.port}` - : localUrl; - const { authToken, devUrl, ...safeConfig } = config; - yield* Effect.logInfo("T3 Code running", { - ...safeConfig, - devUrl: devUrl?.toString(), - authEnabled: Boolean(authToken), - }); - - if (!config.noBrowser) { - const target = config.devUrl?.toString() ?? bindUrl; - yield* openDeps.openBrowser(target).pipe( - Effect.catch(() => - Effect.logInfo("browser auto-open unavailable", { - hint: `Open ${target} in your browser.`, - }), - ), - ); - } - - return yield* stopSignal; - }).pipe(Effect.provide(LayerLive(input))); - -/** - * These flags mirrors the environment variables and the config shape. - */ - -const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe( - Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), - Flag.optional, -); -const portFlag = Flag.integer("port").pipe( - Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), - Flag.withDescription("Port for the HTTP/WebSocket server."), - Flag.optional, -); -const hostFlag = Flag.string("host").pipe( - Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), - Flag.optional, -); -const t3HomeFlag = Flag.string("home-dir").pipe( - Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."), - Flag.optional, -); -const devUrlFlag = Flag.string("dev-url").pipe( - Flag.withSchema(Schema.URLFromString), - Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."), - Flag.optional, -); -const noBrowserFlag = Flag.boolean("no-browser").pipe( - Flag.withDescription("Disable automatic browser opening."), - Flag.optional, -); -const authTokenFlag = Flag.string("auth-token").pipe( - Flag.withDescription("Auth token required for WebSocket connections."), - Flag.withAlias("token"), - Flag.optional, -); -const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( - Flag.withDescription( - "Create a project for the current working directory on startup when missing.", - ), - Flag.optional, -); -const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( - Flag.withDescription( - "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).", - ), - Flag.withAlias("log-ws-events"), - Flag.optional, -); - -export const t3Cli = Command.make("t3", { - mode: modeFlag, - port: portFlag, - host: hostFlag, - t3Home: t3HomeFlag, - devUrl: devUrlFlag, - noBrowser: noBrowserFlag, - authToken: authTokenFlag, - autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, - logWebSocketEvents: logWebSocketEventsFlag, -}).pipe( - Command.withDescription("Run the T3 Code server."), - Command.withHandler((input) => Effect.scoped(makeServerProgram(input))), -); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts new file mode 100644 index 0000000000..95128b303c --- /dev/null +++ b/apps/server/src/server.ts @@ -0,0 +1,29 @@ +import { Effect, Layer } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; + +import { fixPath } from "./os-jank"; +import * as SqlitePersistence from "./persistence/Layers/Sqlite"; +import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; +import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; +import { ServerLoggerLive } from "./serverLogger"; +import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; +import { OpenLive } from "./open"; +import { ServerLayer } from "./wsServer"; + +export const makeServerLayer = Layer.unwrap( + Effect.gen(function* () { + yield* Effect.sync(fixPath); + return ServerLayer.pipe( + Layer.provideMerge(makeServerRuntimeServicesLayer()), + Layer.provideMerge(makeServerProviderLayer()), + Layer.provideMerge(ProviderHealthLive), + Layer.provideMerge(SqlitePersistence.layerConfig), + Layer.provideMerge(ServerLoggerLive), + Layer.provideMerge(AnalyticsServiceLayerLive), + Layer.provideMerge(OpenLive), + Layer.provideMerge(FetchHttpClient.layer), + ); + }), +); + +export const runServer = Layer.launch(makeServerLayer); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index e22c23988b..e9182b5655 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -39,7 +39,6 @@ import { Result, Schema, Scope, - ServiceMap, Stream, Struct, } from "effect"; @@ -79,30 +78,6 @@ import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; -/** - * ServerShape - Service API for server lifecycle control. - */ -export interface ServerShape { - /** - * Start HTTP and WebSocket listeners. - */ - readonly start: Effect.Effect< - http.Server, - ServerLifecycleError, - Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path - >; - - /** - * Wait for process shutdown signals. - */ - readonly stopSignal: Effect.Effect; -} - -/** - * Server - Service tag for HTTP/WebSocket lifecycle management. - */ -export class Server extends ServiceMap.Service()("t3/wsServer/Server") {} - const isServerNotRunningError = (error: Error): boolean => { const maybeCode = (error as NodeJS.ErrnoException).code; return ( @@ -157,6 +132,12 @@ function toPosixRelativePath(input: string): string { return input.replaceAll("\\", "/"); } +const isWildcardHost = (host: string | undefined): boolean => + host === "0.0.0.0" || host === "::" || host === "[::]"; + +const formatHostForUrl = (host: string): string => + host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + function resolveWorkspaceWritePath(params: { workspaceRoot: string; relativePath: string; @@ -231,6 +212,31 @@ class RouteRequestError extends Schema.TaggedErrorClass()("Ro message: Schema.String, }) {} +const recordStartupHeartbeat = Effect.gen(function* () { + const analytics = yield* AnalyticsService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + + const { threadCount, projectCount } = yield* projectionSnapshotQuery.getSnapshot().pipe( + Effect.map((snapshot) => ({ + threadCount: snapshot.threads.length, + projectCount: snapshot.projects.length, + })), + Effect.catch((cause) => + Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe( + Effect.as({ + threadCount: 0, + projectCount: 0, + }), + ), + ), + ); + + yield* analytics.record("server.boot.heartbeat", { + threadCount, + projectCount, + }); +}); + export const createServer = Effect.fn(function* (): Effect.fn.Return< http.Server, ServerLifecycleError, @@ -602,7 +608,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const projectionReadModelQuery = yield* ProjectionSnapshotQuery; const checkpointDiffQuery = yield* CheckpointDiffQuery; const orchestrationReactor = yield* OrchestrationReactor; - const { openInEditor } = yield* Open; + const { openBrowser, openInEditor } = yield* Open; const subscriptionsScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); @@ -700,6 +706,35 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ); yield* readiness.markHttpListening; + if (!devUrl && !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 localUrl = `http://localhost:${port}`; + const bindUrl = + host && !isWildcardHost(host) ? `http://${formatHostForUrl(host)}:${port}` : localUrl; + const { authToken: _authToken, devUrl: configDevUrl, ...safeConfig } = serverConfig; + yield* Effect.logInfo("T3 Code running", { + ...safeConfig, + devUrl: configDevUrl?.toString(), + authEnabled: Boolean(authToken), + }); + + if (!serverConfig.noBrowser) { + const target = configDevUrl?.toString() ?? bindUrl; + yield* openBrowser(target).pipe( + Effect.catch(() => + Effect.logInfo("browser auto-open unavailable", { + hint: `Open ${target} in your browser.`, + }), + ), + ); + } + + yield* recordStartupHeartbeat; + yield* Effect.addFinalizer(() => Effect.all([closeAllClients, closeWebSocketServer.pipe(Effect.ignoreCause({ log: true }))]), ); @@ -1000,7 +1035,4 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return httpServer; }); -export const ServerLive = Layer.succeed(Server, { - start: createServer(), - stopSignal: Effect.never, -} satisfies ServerShape); +export const ServerLayer = Layer.effectDiscard(createServer()); diff --git a/apps/server/tsdown.config.ts b/apps/server/tsdown.config.ts index f89bc7d3d7..f11dd37869 100644 --- a/apps/server/tsdown.config.ts +++ b/apps/server/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: ["src/index.ts"], + entry: ["src/bin.ts"], format: ["esm", "cjs"], checks: { legacyCjs: false, From ae2651f50f7761704ea6936110d983b6fc5250d8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Mar 2026 20:38:18 -0700 Subject: [PATCH 05/47] setup http router --- apps/server/src/cli.ts | 6 ++-- apps/server/src/httpRouter.ts | 23 ++++++++++++ apps/server/src/server.test.ts | 66 ++++++++++++++++++++++++++++++++++ apps/server/src/server.ts | 32 ++++++++--------- apps/server/src/wsServer.ts | 5 +++ 5 files changed, 110 insertions(+), 22 deletions(-) create mode 100644 apps/server/src/httpRouter.ts create mode 100644 apps/server/src/server.test.ts diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 61af4cf3d3..236b083bc0 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -185,11 +185,9 @@ const rootCommand = Command.make("t3", commandFlags).pipe( ), ); -const resetCommand = Command.make("reset", commandFlags).pipe( +const resetCommand = Command.make("reset", {}).pipe( Command.withDescription("Reset the T3 Code server."), - Command.withHandler((flags) => - Effect.flatMap(resolveServerConfig(flags), (_config) => Effect.die("Not implemented")), - ), + Command.withHandler(() => Effect.die("Not implemented")), ); export const cli = rootCommand.pipe(Command.withSubcommands([resetCommand])); diff --git a/apps/server/src/httpRouter.ts b/apps/server/src/httpRouter.ts new file mode 100644 index 0000000000..7bd914e3b7 --- /dev/null +++ b/apps/server/src/httpRouter.ts @@ -0,0 +1,23 @@ +import type http from "node:http"; + +import { Layer } from "effect"; +import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; + +const HEALTH_ROUTE_PATH = "/health"; + +const healthRouteLayer = HttpRouter.add( + "GET", + HEALTH_ROUTE_PATH, + HttpServerResponse.json({ ok: true }), +); + +export const makeRoutesLayer = Layer.mergeAll(healthRouteLayer); + +export function tryHandleHttpRouterRequest( + _request: http.IncomingMessage, + _response: http.ServerResponse, +): boolean { + // Legacy wsServer path remains in-place during migration. + // Runtime now serves HttpRouter directly from server.ts. + return false; +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts new file mode 100644 index 0000000000..d2f7d41a5c --- /dev/null +++ b/apps/server/src/server.test.ts @@ -0,0 +1,66 @@ +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path } from "effect"; +import { HttpClient, HttpRouter } from "effect/unstable/http"; + +import type { ServerConfigShape } from "./config"; +import { ServerConfig } from "./config"; +import { makeRoutesLayer } from "./httpRouter"; + +const AppUnderTest = HttpRouter.serve(makeRoutesLayer, { + disableListenLog: true, + disableLogger: true, +}); + +const buildWithTestConfig = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" }); + const testServerConfig: ServerConfigShape = { + mode: "web", + port: 0, + host: "127.0.0.1", + cwd: process.cwd(), + keybindingsConfigPath: path.join(stateDir, "keybindings.json"), + stateDir, + staticDir: undefined, + devUrl: undefined, + noBrowser: true, + authToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + }; + + yield* Layer.build(AppUnderTest).pipe(Effect.provideService(ServerConfig, testServerConfig)); +}); + +it.layer(NodeServices.layer)("server router seam", (it) => { + it.effect("routes GET /health through HttpRouter", () => + Effect.gen(function* () { + yield* buildWithTestConfig; + + const response = yield* HttpClient.get("/health"); + expect(response.status).toBe(200); + expect(yield* response.json).toEqual({ ok: true }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("returns 404 for non-health routes (seam preserves fallback ownership)", () => + Effect.gen(function* () { + yield* buildWithTestConfig; + + const response = yield* HttpClient.get("/"); + expect(response.status).toBe(404); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("does not claim websocket-style route paths", () => + Effect.gen(function* () { + yield* buildWithTestConfig; + + const response = yield* HttpClient.get("/ws?token=abc"); + expect(response.status).toBe(404); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 95128b303c..e7f153ac16 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,28 +1,24 @@ +import * as Net from "node:net"; +import * as Http from "node:http"; + +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import { Effect, Layer } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; +import { HttpRouter } from "effect/unstable/http"; +import { ServerConfig } from "./config"; +import { makeRoutesLayer } from "./httpRouter"; import { fixPath } from "./os-jank"; -import * as SqlitePersistence from "./persistence/Layers/Sqlite"; -import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; -import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; -import { ServerLoggerLive } from "./serverLogger"; -import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; -import { OpenLive } from "./open"; -import { ServerLayer } from "./wsServer"; export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { + const config = yield* ServerConfig; + const listenOptions: Net.ListenOptions = config.host + ? { host: config.host, port: config.port } + : { port: config.port }; yield* Effect.sync(fixPath); - return ServerLayer.pipe( - Layer.provideMerge(makeServerRuntimeServicesLayer()), - Layer.provideMerge(makeServerProviderLayer()), - Layer.provideMerge(ProviderHealthLive), - Layer.provideMerge(SqlitePersistence.layerConfig), - Layer.provideMerge(ServerLoggerLive), - Layer.provideMerge(AnalyticsServiceLayerLive), - Layer.provideMerge(OpenLive), - Layer.provideMerge(FetchHttpClient.layer), - ); + return HttpRouter.serve(makeRoutesLayer, { + disableLogger: !config.logWebSocketEvents, + }).pipe(Layer.provide(NodeHttpServer.layer(Http.createServer, listenOptions))); }), ); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index e9182b5655..8365f8b8a0 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -77,6 +77,7 @@ import { expandHomePath } from "./os-jank.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; +import { tryHandleHttpRouterRequest } from "./httpRouter"; const isServerNotRunningError = (error: Error): boolean => { const maybeCode = (error as NodeJS.ErrnoException).code; @@ -418,6 +419,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< // HTTP server — serves static files or redirects to Vite dev server const httpServer = http.createServer((req, res) => { + if (tryHandleHttpRouterRequest(req, res)) { + return; + } + const respond = ( statusCode: number, headers: Record, From 29fee97bae9e389aebc1394d3473867f8e88c8a6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Mar 2026 20:51:09 -0700 Subject: [PATCH 06/47] wire in http routes --- apps/server/src/http.ts | 166 +++++++++++++++++++++++++++++ apps/server/src/httpRouter.ts | 23 ---- apps/server/src/server.test.ts | 124 +++++++++++++++------- apps/server/src/server.ts | 8 +- apps/server/src/wsServer.ts | 185 ++------------------------------- 5 files changed, 267 insertions(+), 239 deletions(-) create mode 100644 apps/server/src/http.ts delete mode 100644 apps/server/src/httpRouter.ts diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts new file mode 100644 index 0000000000..d73e3fd92c --- /dev/null +++ b/apps/server/src/http.ts @@ -0,0 +1,166 @@ +import Mime from "@effect/platform-node/Mime"; +import { Effect, FileSystem, Path } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import { + ATTACHMENTS_ROUTE_PREFIX, + normalizeAttachmentRelativePath, + resolveAttachmentRelativePath, +} from "./attachmentPaths"; +import { resolveAttachmentPathById } from "./attachmentStore"; +import { ServerConfig } from "./config"; + +const HEALTH_ROUTE_PATH = "/health"; + +export const healthRouteLayer = HttpRouter.add( + "GET", + HEALTH_ROUTE_PATH, + HttpServerResponse.json({ ok: true }), +); + +export const attachmentsRouteLayer = HttpRouter.add( + "GET", + `${ATTACHMENTS_ROUTE_PREFIX}/*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (!url) { + return HttpServerResponse.text("Bad Request", { status: 400 }); + } + + const config = yield* ServerConfig; + const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); + const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); + if (!normalizedRelativePath) { + return HttpServerResponse.text("Invalid attachment path", { status: 400 }); + } + + const isIdLookup = + !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes("."); + const filePath = isIdLookup + ? resolveAttachmentPathById({ + stateDir: config.stateDir, + attachmentId: normalizedRelativePath, + }) + : resolveAttachmentRelativePath({ + stateDir: config.stateDir, + relativePath: normalizedRelativePath, + }); + if (!filePath) { + return HttpServerResponse.text(isIdLookup ? "Not Found" : "Invalid attachment path", { + status: isIdLookup ? 404 : 400, + }); + } + + const fileSystem = yield* FileSystem.FileSystem; + const fileInfo = yield* fileSystem + .stat(filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!fileInfo || fileInfo.type !== "File") { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + const contentType = Mime.getType(filePath) ?? "application/octet-stream"; + const data = yield* fileSystem + .readFile(filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!data) { + return HttpServerResponse.text("Internal Server Error", { status: 500 }); + } + + return HttpServerResponse.uint8Array(data, { + status: 200, + contentType, + headers: { + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); + }), +); + +export const staticAndDevRouteLayer = HttpRouter.add( + "GET", + "*", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (!url) { + return HttpServerResponse.text("Bad Request", { status: 400 }); + } + + const config = yield* ServerConfig; + if (config.devUrl) { + return HttpServerResponse.redirect(config.devUrl.href, { status: 302 }); + } + + if (!config.staticDir) { + return HttpServerResponse.text("No static directory configured and no dev URL set.", { + status: 503, + }); + } + + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const staticRoot = path.resolve(config.staticDir); + const staticRequestPath = url.pathname === "/" ? "/index.html" : url.pathname; + const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); + const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); + const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, ""); + const hasPathTraversalSegment = staticRelativePath.startsWith(".."); + if ( + staticRelativePath.length === 0 || + hasRawLeadingParentSegment || + hasPathTraversalSegment || + staticRelativePath.includes("\0") + ) { + return HttpServerResponse.text("Invalid static file path", { status: 400 }); + } + + const isWithinStaticRoot = (candidate: string) => + candidate === staticRoot || + candidate.startsWith(staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`); + + let filePath = path.resolve(staticRoot, staticRelativePath); + if (!isWithinStaticRoot(filePath)) { + return HttpServerResponse.text("Invalid static file path", { status: 400 }); + } + + const ext = path.extname(filePath); + if (!ext) { + filePath = path.resolve(filePath, "index.html"); + if (!isWithinStaticRoot(filePath)) { + return HttpServerResponse.text("Invalid static file path", { status: 400 }); + } + } + + const fileInfo = yield* fileSystem + .stat(filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!fileInfo || fileInfo.type !== "File") { + const indexPath = path.resolve(staticRoot, "index.html"); + const indexData = yield* fileSystem + .readFile(indexPath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!indexData) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + return HttpServerResponse.uint8Array(indexData, { + status: 200, + contentType: "text/html; charset=utf-8", + }); + } + + const contentType = Mime.getType(filePath) ?? "application/octet-stream"; + const data = yield* fileSystem + .readFile(filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!data) { + return HttpServerResponse.text("Internal Server Error", { status: 500 }); + } + + return HttpServerResponse.uint8Array(data, { + status: 200, + contentType, + }); + }), +); diff --git a/apps/server/src/httpRouter.ts b/apps/server/src/httpRouter.ts deleted file mode 100644 index 7bd914e3b7..0000000000 --- a/apps/server/src/httpRouter.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type http from "node:http"; - -import { Layer } from "effect"; -import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; - -const HEALTH_ROUTE_PATH = "/health"; - -const healthRouteLayer = HttpRouter.add( - "GET", - HEALTH_ROUTE_PATH, - HttpServerResponse.json({ ok: true }), -); - -export const makeRoutesLayer = Layer.mergeAll(healthRouteLayer); - -export function tryHandleHttpRouterRequest( - _request: http.IncomingMessage, - _response: http.ServerResponse, -): boolean { - // Legacy wsServer path remains in-place during migration. - // Runtime now serves HttpRouter directly from server.ts. - return false; -} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d2f7d41a5c..ca4b907402 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1,66 +1,118 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path } from "effect"; -import { HttpClient, HttpRouter } from "effect/unstable/http"; +import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; -import type { ServerConfigShape } from "./config"; -import { ServerConfig } from "./config"; -import { makeRoutesLayer } from "./httpRouter"; +import type { ServerConfigShape } from "./config.ts"; +import { ServerConfig } from "./config.ts"; +import { makeRoutesLayer } from "./server.ts"; +import { resolveAttachmentRelativePath } from "./attachmentPaths.ts"; const AppUnderTest = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, disableLogger: true, }); -const buildWithTestConfig = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const stateDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" }); - const testServerConfig: ServerConfigShape = { - mode: "web", - port: 0, - host: "127.0.0.1", - cwd: process.cwd(), - keybindingsConfigPath: path.join(stateDir, "keybindings.json"), - stateDir, - staticDir: undefined, - devUrl: undefined, - noBrowser: true, - authToken: undefined, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - }; - - yield* Layer.build(AppUnderTest).pipe(Effect.provideService(ServerConfig, testServerConfig)); -}); +const buildWithTestConfig = (overrides?: { staticDir?: string; devUrl?: URL }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" }); + const testServerConfig: ServerConfigShape = { + mode: "web", + port: 0, + host: "127.0.0.1", + cwd: process.cwd(), + keybindingsConfigPath: path.join(stateDir, "keybindings.json"), + stateDir, + staticDir: overrides?.staticDir, + devUrl: overrides?.devUrl, + noBrowser: true, + authToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + }; + + yield* Layer.build(AppUnderTest).pipe(Effect.provideService(ServerConfig, testServerConfig)); + return stateDir; + }); it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes GET /health through HttpRouter", () => Effect.gen(function* () { - yield* buildWithTestConfig; + yield* buildWithTestConfig(); const response = yield* HttpClient.get("/health"); - expect(response.status).toBe(200); - expect(yield* response.json).toEqual({ ok: true }); + assert.equal(response.status, 200); + assert.deepEqual(yield* response.json, { ok: true }); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("returns 404 for non-health routes (seam preserves fallback ownership)", () => + it.effect("serves static index content for GET / when staticDir is configured", () => Effect.gen(function* () { - yield* buildWithTestConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-static-" }); + const indexPath = path.join(staticDir, "index.html"); + yield* fileSystem.writeFileString(indexPath, "router-static-ok"); + + yield* buildWithTestConfig({ staticDir }); const response = yield* HttpClient.get("/"); - expect(response.status).toBe(404); + assert.equal(response.status, 200); + assert.include(yield* response.text, "router-static-ok"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("redirects to dev URL when configured", () => + Effect.gen(function* () { + yield* buildWithTestConfig({ + devUrl: new URL("http://127.0.0.1:5173"), + }); + + const server = yield* HttpServer.HttpServer; + const address = server.address as HttpServer.TcpAddress; + const response = yield* Effect.promise(() => + fetch(`http://127.0.0.1:${address.port}/foo/bar`, { + redirect: "manual", + }), + ); + assert.equal(response.status, 302); + assert.equal(response.headers.get("location"), "http://127.0.0.1:5173/"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("serves attachment files from state dir", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentId = "thread-11111111-1111-4111-8111-111111111111"; + + const stateDir = yield* buildWithTestConfig(); + const attachmentPath = resolveAttachmentRelativePath({ + stateDir, + relativePath: `${attachmentId}.bin`, + }); + assert.isNotNull(attachmentPath, "Attachment path should be resolvable"); + + yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); + yield* fileSystem.writeFileString(attachmentPath, "attachment-ok"); + + const response = yield* HttpClient.get(`/attachments/${attachmentId}`); + assert.equal(response.status, 200); + assert.equal(yield* response.text, "attachment-ok"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("does not claim websocket-style route paths", () => + it.effect("returns 404 for missing attachment id lookups", () => Effect.gen(function* () { - yield* buildWithTestConfig; + yield* buildWithTestConfig(); - const response = yield* HttpClient.get("/ws?token=abc"); - expect(response.status).toBe(404); + const response = yield* HttpClient.get( + "/attachments/missing-11111111-1111-4111-8111-111111111111", + ); + assert.equal(response.status, 404); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e7f153ac16..a40d8ac5c7 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -6,9 +6,15 @@ import { Effect, Layer } from "effect"; import { HttpRouter } from "effect/unstable/http"; import { ServerConfig } from "./config"; -import { makeRoutesLayer } from "./httpRouter"; +import { attachmentsRouteLayer, healthRouteLayer, staticAndDevRouteLayer } from "./http"; import { fixPath } from "./os-jank"; +export const makeRoutesLayer = Layer.mergeAll( + healthRouteLayer, + attachmentsRouteLayer, + staticAndDevRouteLayer, +); + export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig; diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 8365f8b8a0..a3d5147b37 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -9,7 +9,6 @@ import http from "node:http"; import type { Duplex } from "node:stream"; -import Mime from "@effect/platform-node/Mime"; import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, @@ -59,25 +58,14 @@ import { clamp } from "effect/Number"; import { Open, resolveAvailableEditors } from "./open"; import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore.ts"; -import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; -import { - ATTACHMENTS_ROUTE_PREFIX, - normalizeAttachmentRelativePath, - resolveAttachmentRelativePath, -} from "./attachmentPaths"; -import { - createAttachmentId, - resolveAttachmentPath, - resolveAttachmentPathById, -} from "./attachmentStore.ts"; +import { createAttachmentId, resolveAttachmentPath } from "./attachmentStore.ts"; import { parseBase64DataUrl } from "./imageMime.ts"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import { expandHomePath } from "./os-jank.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; -import { tryHandleHttpRouterRequest } from "./httpRouter"; const isServerNotRunningError = (error: Error): boolean => { const maybeCode = (error as NodeJS.ErrnoException).code; @@ -417,172 +405,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< } satisfies OrchestrationCommand; }); - // HTTP server — serves static files or redirects to Vite dev server - const httpServer = http.createServer((req, res) => { - if (tryHandleHttpRouterRequest(req, res)) { - return; - } - - const respond = ( - statusCode: number, - headers: Record, - body?: string | Uint8Array, - ) => { - res.writeHead(statusCode, headers); - res.end(body); - }; - - void Effect.runPromise( - Effect.gen(function* () { - const url = new URL(req.url ?? "/", `http://localhost:${port}`); - if (tryHandleProjectFaviconRequest(url, res)) { - return; - } - - if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { - const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); - const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); - if (!normalizedRelativePath) { - respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); - return; - } - - const isIdLookup = - !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes("."); - const filePath = isIdLookup - ? resolveAttachmentPathById({ - attachmentsDir: serverConfig.attachmentsDir, - attachmentId: normalizedRelativePath, - }) - : resolveAttachmentRelativePath({ - attachmentsDir: serverConfig.attachmentsDir, - relativePath: normalizedRelativePath, - }); - if (!filePath) { - respond( - isIdLookup ? 404 : 400, - { "Content-Type": "text/plain" }, - isIdLookup ? "Not Found" : "Invalid attachment path", - ); - return; - } - - const fileInfo = yield* fileSystem - .stat(filePath) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (!fileInfo || fileInfo.type !== "File") { - respond(404, { "Content-Type": "text/plain" }, "Not Found"); - return; - } - - const contentType = Mime.getType(filePath) ?? "application/octet-stream"; - res.writeHead(200, { - "Content-Type": contentType, - "Cache-Control": "public, max-age=31536000, immutable", - }); - const streamExit = yield* Stream.runForEach(fileSystem.stream(filePath), (chunk) => - Effect.sync(() => { - if (!res.destroyed) { - res.write(chunk); - } - }), - ).pipe(Effect.exit); - if (Exit.isFailure(streamExit)) { - if (!res.destroyed) { - res.destroy(); - } - return; - } - if (!res.writableEnded) { - res.end(); - } - return; - } - - // In dev mode, redirect to Vite dev server - if (devUrl) { - respond(302, { Location: devUrl.href }); - return; - } - - // Serve static files from the web app build - if (!staticDir) { - respond( - 503, - { "Content-Type": "text/plain" }, - "No static directory configured and no dev URL set.", - ); - return; - } - - const staticRoot = path.resolve(staticDir); - const staticRequestPath = url.pathname === "/" ? "/index.html" : url.pathname; - const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); - const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); - const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, ""); - const hasPathTraversalSegment = staticRelativePath.startsWith(".."); - if ( - staticRelativePath.length === 0 || - hasRawLeadingParentSegment || - hasPathTraversalSegment || - staticRelativePath.includes("\0") - ) { - respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); - return; - } - - const isWithinStaticRoot = (candidate: string) => - candidate === staticRoot || - candidate.startsWith( - staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`, - ); - - let filePath = path.resolve(staticRoot, staticRelativePath); - if (!isWithinStaticRoot(filePath)) { - respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); - return; - } - - const ext = path.extname(filePath); - if (!ext) { - filePath = path.resolve(filePath, "index.html"); - if (!isWithinStaticRoot(filePath)) { - respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); - return; - } - } - - const fileInfo = yield* fileSystem - .stat(filePath) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (!fileInfo || fileInfo.type !== "File") { - const indexPath = path.resolve(staticRoot, "index.html"); - const indexData = yield* fileSystem - .readFile(indexPath) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (!indexData) { - respond(404, { "Content-Type": "text/plain" }, "Not Found"); - return; - } - respond(200, { "Content-Type": "text/html; charset=utf-8" }, indexData); - return; - } - - const contentType = Mime.getType(filePath) ?? "application/octet-stream"; - const data = yield* fileSystem - .readFile(filePath) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (!data) { - respond(500, { "Content-Type": "text/plain" }, "Internal Server Error"); - return; - } - respond(200, { "Content-Type": contentType }, data); - }), - ).catch(() => { - if (!res.headersSent) { - respond(500, { "Content-Type": "text/plain" }, "Internal Server Error"); - } - }); + // HTTP behavior migrated to `httpRouter.ts` + `server.ts`. + // wsServer remains focused on WebSocket lifecycle during migration. + const httpServer = http.createServer((_req, res) => { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("HTTP routes moved to HttpRouter runtime."); }); // WebSocket server — upgrades from the HTTP server From 63c7ceee9e0f625d3de8f533eceb7857a959e8ca Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Mar 2026 08:40:47 -0700 Subject: [PATCH 07/47] start wiring in rpc --- apps/server/src/ws.ts | 34 ++++++++++++++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/keybindings.test.ts | 46 ++++++++-------------- packages/contracts/src/keybindings.ts | 15 ++++--- packages/contracts/src/wsRpc.ts | 11 ++++++ 5 files changed, 71 insertions(+), 36 deletions(-) create mode 100644 apps/server/src/ws.ts create mode 100644 packages/contracts/src/wsRpc.ts diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts new file mode 100644 index 0000000000..12cb59e506 --- /dev/null +++ b/apps/server/src/ws.ts @@ -0,0 +1,34 @@ +import { Effect, Layer } from "effect"; +import { WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; +import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; + +import { ServerConfig } from "./config"; +import { Keybindings } from "./keybindings"; +import { resolveAvailableEditors } from "./open"; +import { ProviderHealth } from "./provider/Services/ProviderHealth"; + +const WsRpcLayer = WsRpcGroup.toLayer({ + [WS_METHODS.serverGetConfig]: () => + Effect.gen(function* () { + const config = yield* ServerConfig; + const keybindings = yield* Keybindings; + const providerHealth = yield* ProviderHealth; + const keybindingsConfig = yield* keybindings.loadConfigState.pipe(Effect.orDie); + const providers = yield* providerHealth.getStatuses; + + return { + cwd: config.cwd, + keybindingsConfigPath: config.keybindingsConfigPath, + keybindings: keybindingsConfig.keybindings, + issues: keybindingsConfig.issues, + providers, + availableEditors: resolveAvailableEditors(), + }; + }), +}); + +export const websocketRpcRouteLayer = RpcServer.layerHttp({ + group: WsRpcGroup, + path: "/ws", + protocol: "websocket", +}).pipe(Layer.provide(WsRpcLayer), Layer.provide(RpcSerialization.layerJson)); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0f37a93515..7c47d02bd9 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -11,3 +11,4 @@ export * from "./git"; export * from "./orchestration"; export * from "./editor"; export * from "./project"; +export * from "./wsRpc"; diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 1b99362c53..09ac0d175b 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -9,39 +9,27 @@ import { ResolvedKeybindingsConfig, } from "./keybindings"; -const decode = ( - schema: S, - input: unknown, -): Effect.Effect, Schema.SchemaError, never> => - Schema.decodeUnknownEffect(schema as never)(input) as Effect.Effect< - Schema.Schema.Type, - Schema.SchemaError, - never - >; - -const decodeResolvedRule = Schema.decodeUnknownEffect(ResolvedKeybindingRule as never); - it.effect("parses keybinding rules", () => Effect.gen(function* () { - const parsed = yield* decode(KeybindingRule, { + const parsed = yield* Schema.decodeUnknownEffect(KeybindingRule)({ key: "mod+j", command: "terminal.toggle", }); assert.strictEqual(parsed.command, "terminal.toggle"); - const parsedClose = yield* decode(KeybindingRule, { + const parsedClose = yield* Schema.decodeUnknownEffect(KeybindingRule)({ key: "mod+w", command: "terminal.close", }); assert.strictEqual(parsedClose.command, "terminal.close"); - const parsedDiffToggle = yield* decode(KeybindingRule, { + const parsedDiffToggle = yield* Schema.decodeUnknownEffect(KeybindingRule)({ key: "mod+d", command: "diff.toggle", }); assert.strictEqual(parsedDiffToggle.command, "diff.toggle"); - const parsedLocal = yield* decode(KeybindingRule, { + const parsedLocal = yield* Schema.decodeUnknownEffect(KeybindingRule)({ key: "mod+shift+n", command: "chat.newLocal", }); @@ -50,20 +38,19 @@ it.effect("parses keybinding rules", () => ); it.effect("rejects invalid command values", () => + // oxlint-disable-next-line require-yield Effect.gen(function* () { - const result = yield* Effect.exit( - decode(KeybindingRule, { - key: "mod+j", - command: "script.Test.run", - }), - ); + const result = Schema.decodeUnknownExit(KeybindingRule)({ + key: "mod+j", + command: "script.Test.run", + }); assert.strictEqual(result._tag, "Failure"); }), ); it.effect("accepts dynamic script run commands", () => Effect.gen(function* () { - const parsed = yield* decode(KeybindingRule, { + const parsed = yield* Schema.decodeUnknownExit(KeybindingRule)({ key: "mod+r", command: "script.setup.run", }); @@ -73,7 +60,7 @@ it.effect("accepts dynamic script run commands", () => it.effect("parses keybindings array payload", () => Effect.gen(function* () { - const parsed = yield* decode(KeybindingsConfig, [ + const parsed = yield* Schema.decodeUnknownExit(KeybindingsConfig)([ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, ]); @@ -83,7 +70,7 @@ it.effect("parses keybindings array payload", () => it.effect("parses resolved keybinding rules", () => Effect.gen(function* () { - const parsed = yield* decode(ResolvedKeybindingRule, { + const parsed = yield* Schema.decodeUnknownExit(ResolvedKeybindingRule)({ command: "terminal.split", shortcut: { key: "d", @@ -108,7 +95,7 @@ it.effect("parses resolved keybinding rules", () => it.effect("parses resolved keybindings arrays", () => Effect.gen(function* () { - const parsed = yield* decode(ResolvedKeybindingsConfig, [ + const parsed = yield* Schema.decodeUnknownExit(ResolvedKeybindingsConfig)([ { command: "terminal.toggle", shortcut: { @@ -126,7 +113,7 @@ it.effect("parses resolved keybindings arrays", () => ); it.effect("drops unknown fields in resolved keybinding rules", () => - decodeResolvedRule({ + Schema.decodeUnknownExit(ResolvedKeybindingRule)({ command: "terminal.toggle", shortcut: { key: "j", @@ -139,9 +126,8 @@ it.effect("drops unknown fields in resolved keybinding rules", () => key: "mod+j", }).pipe( Effect.map((parsed) => { - const view = parsed as Record; - assert.strictEqual("key" in view, false); - assert.strictEqual(view.command, "terminal.toggle"); + assert.strictEqual("key" in parsed, false); + assert.strictEqual(parsed.command, "terminal.toggle"); }), ), ); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 48821b1824..7234f10c0d 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -64,24 +64,27 @@ export const KeybindingShortcut = Schema.Struct({ }); export type KeybindingShortcut = typeof KeybindingShortcut.Type; -export const KeybindingWhenNode: Schema.Schema = Schema.Union([ +const KeybindingWhenNodeRef = Schema.suspend( + (): Schema.Codec => KeybindingWhenNode, +); +export const KeybindingWhenNode = Schema.Union([ Schema.Struct({ type: Schema.Literal("identifier"), name: Schema.NonEmptyString, }), Schema.Struct({ type: Schema.Literal("not"), - node: Schema.suspend((): Schema.Schema => KeybindingWhenNode), + node: KeybindingWhenNodeRef, }), Schema.Struct({ type: Schema.Literal("and"), - left: Schema.suspend((): Schema.Schema => KeybindingWhenNode), - right: Schema.suspend((): Schema.Schema => KeybindingWhenNode), + left: KeybindingWhenNodeRef, + right: KeybindingWhenNodeRef, }), Schema.Struct({ type: Schema.Literal("or"), - left: Schema.suspend((): Schema.Schema => KeybindingWhenNode), - right: Schema.suspend((): Schema.Schema => KeybindingWhenNode), + left: KeybindingWhenNodeRef, + right: KeybindingWhenNodeRef, }), ]); export type KeybindingWhenNode = diff --git a/packages/contracts/src/wsRpc.ts b/packages/contracts/src/wsRpc.ts new file mode 100644 index 0000000000..b3e22d5592 --- /dev/null +++ b/packages/contracts/src/wsRpc.ts @@ -0,0 +1,11 @@ +import * as Rpc from "effect/unstable/rpc/Rpc"; +import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; + +import { ServerConfig } from "./server"; +import { WS_METHODS } from "./ws"; + +export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { + success: ServerConfig, +}); + +export const WsRpcGroup = RpcGroup.make(WsServerGetConfigRpc); From aa34e6658084c0027edceb6571fb2725047acccb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Mar 2026 09:12:18 -0700 Subject: [PATCH 08/47] add test --- apps/server/src/server.test.ts | 56 ++++++++++++++++++++++++++++++++-- apps/server/src/server.ts | 2 ++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index ca4b907402..04725e23a7 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1,18 +1,51 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import { Effect, FileSystem, Layer, Path, Stream } from "effect"; import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import type { ServerConfigShape } from "./config.ts"; import { ServerConfig } from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; import { resolveAttachmentRelativePath } from "./attachmentPaths.ts"; +import { Keybindings } from "./keybindings.ts"; +import { ProviderHealth } from "./provider/Services/ProviderHealth.ts"; + +const wsRpcTestDepsLayer = Layer.mergeAll( + Layer.mock(Keybindings)({ + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), + streamChanges: Stream.empty, + }), + Layer.mock(ProviderHealth)({ + getStatuses: Effect.succeed([]), + }), +); const AppUnderTest = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, disableLogger: true, -}); +}).pipe(Layer.provideMerge(wsRpcTestDepsLayer)); + +const wsRpcProtocolLayer = (wsUrl: string) => + RpcClient.layerProtocolSocket().pipe( + Layer.provide(NodeSocket.layerWebSocket(wsUrl)), + Layer.provide(RpcSerialization.layerJson), + ); + +const makeWsRpcClient = RpcClient.make(WsRpcGroup); +type WsRpcClient = + typeof makeWsRpcClient extends Effect.Effect ? Client : never; + +const withWsRpcClient = ( + wsUrl: string, + f: (client: WsRpcClient) => Effect.Effect, +) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl))); const buildWithTestConfig = (overrides?: { staticDir?: string; devUrl?: URL }) => Effect.gen(function* () { @@ -115,4 +148,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.status, 404); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + + it.effect("routes websocket rpc server.getConfig", () => + Effect.gen(function* () { + yield* buildWithTestConfig(); + + const server = yield* HttpServer.HttpServer; + const address = server.address as HttpServer.TcpAddress; + const wsUrl = `ws://127.0.0.1:${address.port}/ws`; + + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig](undefined)), + ); + + assert.equal(response.cwd, process.cwd()); + assert.deepEqual(response.keybindings, []); + assert.deepEqual(response.issues, []); + assert.deepEqual(response.providers, []); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index a40d8ac5c7..de4535a311 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -8,11 +8,13 @@ import { HttpRouter } from "effect/unstable/http"; import { ServerConfig } from "./config"; import { attachmentsRouteLayer, healthRouteLayer, staticAndDevRouteLayer } from "./http"; import { fixPath } from "./os-jank"; +import { websocketRpcRouteLayer } from "./ws"; export const makeRoutesLayer = Layer.mergeAll( healthRouteLayer, attachmentsRouteLayer, staticAndDevRouteLayer, + websocketRpcRouteLayer, ); export const makeServerLayer = Layer.unwrap( From 75e817000bb0ba67d642100a3a4711724bbe4aca Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Mar 2026 09:18:02 -0700 Subject: [PATCH 09/47] plan out remaining ports --- .plans/ws-rpc-endpoint-port-plan.md | 77 +++++++++++++++++++++++++++++ apps/server/src/server.test.ts | 2 +- 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 .plans/ws-rpc-endpoint-port-plan.md diff --git a/.plans/ws-rpc-endpoint-port-plan.md b/.plans/ws-rpc-endpoint-port-plan.md new file mode 100644 index 0000000000..91193cc4d8 --- /dev/null +++ b/.plans/ws-rpc-endpoint-port-plan.md @@ -0,0 +1,77 @@ +# WebSocket RPC Port Plan + +Incrementally migrate WebSocket request handling from `apps/server/src/wsServer.ts` switch-cases to Effect RPC routes in `apps/server/src/ws.ts` with shared contracts in `packages/contracts`. + +## Porting Strategy (High Level) + +1. **Contract-first** + - Define each RPC in shared contracts (`packages/contracts`) so server and client use one schema source. + - Keep endpoint names identical to `WS_METHODS` / orchestration method names to avoid client churn. + +2. **Single endpoint slices** + - Port one endpoint at a time into `WsRpcGroup` in `apps/server/src/ws.ts`. + - Preserve current behavior and error semantics; avoid broad refactors in the same slice. + +3. **Prove wiring with tests** + - Add/extend integration tests in `apps/server/src/server.test.ts` (reference style: boot layer, connect WS RPC client, invoke method, assert result). + - Prefer lightweight assertions that prove route wiring + core behavior. + - Implementation details are often tested in each service's own tests. Server test only needs to prove high level behavior and error semantics. + +4. **Keep old path as fallback until parity** + - Leave legacy handler path in `wsServer.ts` for unmigrated methods. + - After each endpoint is migrated and tested, remove only that endpoint branch from legacy switch. + +5. **Quality gates per slice** + - Run `bun run test` (targeted), then `bun fmt`, `bun lint`, `bun typecheck`. + - Only proceed to next endpoint when checks are green. + +## Ordered Endpoint Checklist + +Legend: `[x]` done, `[ ]` not started. + +### Phase 1: Server metadata (smallest surface) + +- [x] `server.getConfig` +- [ ] `server.upsertKeybinding` + +### Phase 2: Project + editor read/write (small inputs, bounded side effects) + +- [ ] `projects.searchEntries` +- [ ] `projects.writeFile` +- [ ] `shell.openInEditor` + +### Phase 3: Git operations (broader side effects) + +- [ ] `git.status` +- [ ] `git.listBranches` +- [ ] `git.pull` +- [ ] `git.runStackedAction` +- [ ] `git.resolvePullRequest` +- [ ] `git.preparePullRequestThread` +- [ ] `git.createWorktree` +- [ ] `git.removeWorktree` +- [ ] `git.createBranch` +- [ ] `git.checkout` +- [ ] `git.init` + +### Phase 4: Terminal lifecycle + IO (stateful and streaming-adjacent) + +- [ ] `terminal.open` +- [ ] `terminal.write` +- [ ] `terminal.resize` +- [ ] `terminal.clear` +- [ ] `terminal.restart` +- [ ] `terminal.close` + +### Phase 5: Orchestration RPC methods (domain-critical path) + +- [ ] `orchestration.getSnapshot` +- [ ] `orchestration.dispatchCommand` +- [ ] `orchestration.getTurnDiff` +- [ ] `orchestration.getFullThreadDiff` +- [ ] `orchestration.replayEvents` + +## Notes + +- This plan tracks request/response RPC methods only. +- Push/event channels (`terminal.event`, `server.welcome`, `server.configUpdated`, `orchestration.domainEvent`) stay in the existing event pipeline until a dedicated push-channel migration plan is created. diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 04725e23a7..4fdd20570f 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -158,7 +158,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = `ws://127.0.0.1:${address.port}/ws`; const response = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig](undefined)), + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]()), ); assert.equal(response.cwd, process.cwd()); From 9c3a40dca8b3a782d59b174f5f20ecbe1bc9c5b8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Mar 2026 09:51:37 -0700 Subject: [PATCH 10/47] wire up keybindings --- .plans/ws-rpc-endpoint-port-plan.md | 2 +- apps/server/src/keybindings.ts | 14 +- apps/server/src/server.test.ts | 205 ++++++++++++++++++-------- apps/server/src/ws.ts | 8 +- packages/contracts/src/keybindings.ts | 13 ++ packages/contracts/src/wsRpc.ts | 12 +- 6 files changed, 179 insertions(+), 75 deletions(-) diff --git a/.plans/ws-rpc-endpoint-port-plan.md b/.plans/ws-rpc-endpoint-port-plan.md index 91193cc4d8..8b00dba0e9 100644 --- a/.plans/ws-rpc-endpoint-port-plan.md +++ b/.plans/ws-rpc-endpoint-port-plan.md @@ -32,7 +32,7 @@ Legend: `[x]` done, `[ ]` not started. ### Phase 1: Server metadata (smallest surface) - [x] `server.getConfig` -- [ ] `server.upsertKeybinding` +- [x] `server.upsertKeybinding` ### Phase 2: Project + editor read/write (small inputs, bounded side effects) diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index bf58467825..9d22089f4d 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -9,6 +9,7 @@ import { KeybindingRule, KeybindingsConfig, + KeybindingsConfigError, KeybindingShortcut, KeybindingWhenNode, MAX_KEYBINDINGS_COUNT, @@ -43,18 +44,7 @@ import { import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "./config"; -export class KeybindingsConfigError extends Schema.TaggedErrorClass()( - "KeybindingsConfigParseError", - { - configPath: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect), - }, -) { - override get message(): string { - return `Unable to parse keybindings config at ${this.configPath}: ${this.detail}`; - } -} +export { KeybindingsConfigError }; type WhenToken = | { type: "identifier"; value: string } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 4fdd20570f..e0c37f1dd7 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1,8 +1,9 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; +import { KeybindingRule, ResolvedKeybindingRule, WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; +import { assertFailure } from "@effect/vitest/utils"; import { Effect, FileSystem, Layer, Path, Stream } from "effect"; import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; @@ -11,26 +12,59 @@ import type { ServerConfigShape } from "./config.ts"; import { ServerConfig } from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; import { resolveAttachmentRelativePath } from "./attachmentPaths.ts"; -import { Keybindings } from "./keybindings.ts"; -import { ProviderHealth } from "./provider/Services/ProviderHealth.ts"; - -const wsRpcTestDepsLayer = Layer.mergeAll( - Layer.mock(Keybindings)({ - loadConfigState: Effect.succeed({ - keybindings: [], - issues: [], - }), - streamChanges: Stream.empty, - }), - Layer.mock(ProviderHealth)({ - getStatuses: Effect.succeed([]), - }), -); - -const AppUnderTest = HttpRouter.serve(makeRoutesLayer, { - disableListenLog: true, - disableLogger: true, -}).pipe(Layer.provideMerge(wsRpcTestDepsLayer)); +import { Keybindings, KeybindingsConfigError, type KeybindingsShape } from "./keybindings.ts"; +import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth.ts"; + +const buildAppUnderTest = (options?: { + config?: Partial; + layers?: { + keybindings?: Partial; + providerHealth?: Partial; + }; +}) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempStateDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" }); + const stateDir = options?.config?.stateDir ?? tempStateDir; + const layerConfig = Layer.succeed(ServerConfig, { + mode: "web", + port: 0, + host: "127.0.0.1", + cwd: process.cwd(), + keybindingsConfigPath: path.join(stateDir, "keybindings.json"), + stateDir, + staticDir: undefined, + devUrl: undefined, + noBrowser: true, + authToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + ...options?.config, + }); + + const appLayer = HttpRouter.serve(makeRoutesLayer, { + disableListenLog: true, + disableLogger: true, + }).pipe( + Layer.provide( + Layer.mock(Keybindings)({ + streamChanges: Stream.empty, + ...options?.layers?.keybindings, + }), + ), + Layer.provide( + Layer.mock(ProviderHealth)({ + getStatuses: Effect.succeed([]), + ...options?.layers?.providerHealth, + }), + ), + Layer.provide(layerConfig), + ); + + yield* Layer.build(appLayer); + return stateDir; + }); const wsRpcProtocolLayer = (wsUrl: string) => RpcClient.layerProtocolSocket().pipe( @@ -47,34 +81,24 @@ const withWsRpcClient = ( f: (client: WsRpcClient) => Effect.Effect, ) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl))); -const buildWithTestConfig = (overrides?: { staticDir?: string; devUrl?: URL }) => +const getHttpServerUrl = (pathname = "") => Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const stateDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" }); - const testServerConfig: ServerConfigShape = { - mode: "web", - port: 0, - host: "127.0.0.1", - cwd: process.cwd(), - keybindingsConfigPath: path.join(stateDir, "keybindings.json"), - stateDir, - staticDir: overrides?.staticDir, - devUrl: overrides?.devUrl, - noBrowser: true, - authToken: undefined, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - }; + const server = yield* HttpServer.HttpServer; + const address = server.address as HttpServer.TcpAddress; + return `http://127.0.0.1:${address.port}${pathname}`; + }); - yield* Layer.build(AppUnderTest).pipe(Effect.provideService(ServerConfig, testServerConfig)); - return stateDir; +const getWsServerUrl = (pathname = "") => + Effect.gen(function* () { + const server = yield* HttpServer.HttpServer; + const address = server.address as HttpServer.TcpAddress; + return `ws://127.0.0.1:${address.port}${pathname}`; }); it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes GET /health through HttpRouter", () => Effect.gen(function* () { - yield* buildWithTestConfig(); + yield* buildAppUnderTest(); const response = yield* HttpClient.get("/health"); assert.equal(response.status, 200); @@ -90,7 +114,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const indexPath = path.join(staticDir, "index.html"); yield* fileSystem.writeFileString(indexPath, "router-static-ok"); - yield* buildWithTestConfig({ staticDir }); + yield* buildAppUnderTest({ config: { staticDir } }); const response = yield* HttpClient.get("/"); assert.equal(response.status, 200); @@ -100,17 +124,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("redirects to dev URL when configured", () => Effect.gen(function* () { - yield* buildWithTestConfig({ - devUrl: new URL("http://127.0.0.1:5173"), + yield* buildAppUnderTest({ + config: { devUrl: new URL("http://127.0.0.1:5173") }, }); - const server = yield* HttpServer.HttpServer; - const address = server.address as HttpServer.TcpAddress; - const response = yield* Effect.promise(() => - fetch(`http://127.0.0.1:${address.port}/foo/bar`, { - redirect: "manual", - }), - ); + const url = yield* getHttpServerUrl("/foo/bar"); + const response = yield* Effect.promise(() => fetch(url, { redirect: "manual" })); + assert.equal(response.status, 302); assert.equal(response.headers.get("location"), "http://127.0.0.1:5173/"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), @@ -122,7 +142,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const path = yield* Path.Path; const attachmentId = "thread-11111111-1111-4111-8111-111111111111"; - const stateDir = yield* buildWithTestConfig(); + const stateDir = yield* buildAppUnderTest(); const attachmentPath = resolveAttachmentRelativePath({ stateDir, relativePath: `${attachmentId}.bin`, @@ -140,7 +160,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("returns 404 for missing attachment id lookups", () => Effect.gen(function* () { - yield* buildWithTestConfig(); + yield* buildAppUnderTest(); const response = yield* HttpClient.get( "/attachments/missing-11111111-1111-4111-8111-111111111111", @@ -151,12 +171,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc server.getConfig", () => Effect.gen(function* () { - yield* buildWithTestConfig(); - - const server = yield* HttpServer.HttpServer; - const address = server.address as HttpServer.TcpAddress; - const wsUrl = `ws://127.0.0.1:${address.port}/ws`; + yield* buildAppUnderTest({ + layers: { + keybindings: { + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), + }, + }, + }); + const wsUrl = yield* getWsServerUrl("/ws"); const response = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]()), ); @@ -167,4 +193,65 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(response.providers, []); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + + it.effect("routes websocket rpc server.upsertKeybinding", () => + Effect.gen(function* () { + const rule: KeybindingRule = { + command: "terminal.toggle", + key: "ctrl+k", + }; + const resolved: ResolvedKeybindingRule = { + command: "terminal.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + modKey: true, + }, + }; + + yield* buildAppUnderTest({ + layers: { + keybindings: { + upsertKeybindingRule: () => Effect.succeed([resolved]), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverUpsertKeybinding](rule)), + ); + + assert.deepEqual(response.issues, []); + assert.deepEqual(response.keybindings, [resolved]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc server.getConfig errors", () => + Effect.gen(function* () { + const error = new KeybindingsConfigError({ + configPath: "/tmp/keybindings.json", + detail: "expected JSON array", + }); + yield* buildAppUnderTest({ + layers: { + keybindings: { + loadConfigState: Effect.fail(error), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]()).pipe( + Effect.result, + ), + ); + + assertFailure(result, error); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 12cb59e506..77cd86867c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -13,7 +13,7 @@ const WsRpcLayer = WsRpcGroup.toLayer({ const config = yield* ServerConfig; const keybindings = yield* Keybindings; const providerHealth = yield* ProviderHealth; - const keybindingsConfig = yield* keybindings.loadConfigState.pipe(Effect.orDie); + const keybindingsConfig = yield* keybindings.loadConfigState; const providers = yield* providerHealth.getStatuses; return { @@ -25,6 +25,12 @@ const WsRpcLayer = WsRpcGroup.toLayer({ availableEditors: resolveAvailableEditors(), }; }), + [WS_METHODS.serverUpsertKeybinding]: (rule) => + Effect.gen(function* () { + const keybindings = yield* Keybindings; + const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); + return { keybindings: keybindingsConfig, issues: [] }; + }), }); export const websocketRpcRouteLayer = RpcServer.layerHttp({ diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 7234f10c0d..baf92e3381 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -104,3 +104,16 @@ export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).ch Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), ); export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type; + +export class KeybindingsConfigError extends Schema.TaggedErrorClass()( + "KeybindingsConfigParseError", + { + configPath: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Unable to parse keybindings config at ${this.configPath}: ${this.detail}`; + } +} diff --git a/packages/contracts/src/wsRpc.ts b/packages/contracts/src/wsRpc.ts index b3e22d5592..46c1561465 100644 --- a/packages/contracts/src/wsRpc.ts +++ b/packages/contracts/src/wsRpc.ts @@ -1,11 +1,19 @@ import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; -import { ServerConfig } from "./server"; +import { KeybindingsConfigError } from "./keybindings"; +import { ServerConfig, ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; import { WS_METHODS } from "./ws"; export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { success: ServerConfig, + error: KeybindingsConfigError, }); -export const WsRpcGroup = RpcGroup.make(WsServerGetConfigRpc); +export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { + payload: ServerUpsertKeybindingInput, + success: ServerUpsertKeybindingResult, + error: KeybindingsConfigError, +}); + +export const WsRpcGroup = RpcGroup.make(WsServerGetConfigRpc, WsServerUpsertKeybindingRpc); From a5ebd0c12bffda5142b507d9301d165d454fe6e2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Mar 2026 10:01:17 -0700 Subject: [PATCH 11/47] searchEntries --- .plans/ws-rpc-endpoint-port-plan.md | 2 +- apps/server/src/server.test.ts | 55 ++++++++++++++++++++++++++++- apps/server/src/ws.ts | 12 ++++++- packages/contracts/src/project.ts | 8 +++++ packages/contracts/src/wsRpc.ts | 17 ++++++++- 5 files changed, 90 insertions(+), 4 deletions(-) diff --git a/.plans/ws-rpc-endpoint-port-plan.md b/.plans/ws-rpc-endpoint-port-plan.md index 8b00dba0e9..c6c3c4c91f 100644 --- a/.plans/ws-rpc-endpoint-port-plan.md +++ b/.plans/ws-rpc-endpoint-port-plan.md @@ -36,7 +36,7 @@ Legend: `[x]` done, `[ ]` not started. ### Phase 2: Project + editor read/write (small inputs, bounded side effects) -- [ ] `projects.searchEntries` +- [x] `projects.searchEntries` - [ ] `projects.writeFile` - [ ] `shell.openInEditor` diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e0c37f1dd7..5d32c1b923 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -3,7 +3,7 @@ import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { KeybindingRule, ResolvedKeybindingRule, WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; -import { assertFailure } from "@effect/vitest/utils"; +import { assertEquals, assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; import { Effect, FileSystem, Layer, Path, Stream } from "effect"; import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; @@ -254,4 +254,57 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assertFailure(result, error); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + + it.effect("routes websocket rpc projects.searchEntries", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-search-" }); + yield* fs.writeFileString( + path.join(workspaceDir, "needle-file.ts"), + "export const needle = 1;", + ); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsSearchEntries]({ + cwd: workspaceDir, + query: "needle", + limit: 10, + }), + ), + ); + + assert.isAtLeast(response.entries.length, 1); + assert.isTrue(response.entries.some((entry) => entry.path === "needle-file.ts")); + assert.equal(response.truncated, false); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc projects.searchEntries errors", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsSearchEntries]({ + cwd: "/definitely/not/a/real/workspace/path", + query: "needle", + limit: 10, + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "ProjectSearchEntriesError"); + assertInclude( + String(result.failure.cause), + "ENOENT: no such file or directory, scandir '/definitely/not/a/real/workspace/path'", + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 77cd86867c..c7e5bf9e83 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,11 +1,12 @@ import { Effect, Layer } from "effect"; -import { WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; +import { ProjectSearchEntriesError, WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import { ServerConfig } from "./config"; import { Keybindings } from "./keybindings"; import { resolveAvailableEditors } from "./open"; import { ProviderHealth } from "./provider/Services/ProviderHealth"; +import { searchWorkspaceEntries } from "./workspaceEntries"; const WsRpcLayer = WsRpcGroup.toLayer({ [WS_METHODS.serverGetConfig]: () => @@ -31,6 +32,15 @@ const WsRpcLayer = WsRpcGroup.toLayer({ const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); return { keybindings: keybindingsConfig, issues: [] }; }), + [WS_METHODS.projectsSearchEntries]: (input) => + Effect.tryPromise({ + try: () => searchWorkspaceEntries(input), + catch: (cause) => + new ProjectSearchEntriesError({ + message: `Failed to search workspace entries`, + cause, + }), + }), }); export const websocketRpcRouteLayer = RpcServer.layerHttp({ diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 0903253301..a42812e218 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -26,6 +26,14 @@ export const ProjectSearchEntriesResult = Schema.Struct({ }); export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; +export class ProjectSearchEntriesError extends Schema.TaggedErrorClass()( + "ProjectSearchEntriesError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} + export const ProjectWriteFileInput = Schema.Struct({ cwd: TrimmedNonEmptyString, relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)), diff --git a/packages/contracts/src/wsRpc.ts b/packages/contracts/src/wsRpc.ts index 46c1561465..a24386837c 100644 --- a/packages/contracts/src/wsRpc.ts +++ b/packages/contracts/src/wsRpc.ts @@ -2,6 +2,11 @@ import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; import { KeybindingsConfigError } from "./keybindings"; +import { + ProjectSearchEntriesError, + ProjectSearchEntriesInput, + ProjectSearchEntriesResult, +} from "./project"; import { ServerConfig, ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; import { WS_METHODS } from "./ws"; @@ -16,4 +21,14 @@ export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybi error: KeybindingsConfigError, }); -export const WsRpcGroup = RpcGroup.make(WsServerGetConfigRpc, WsServerUpsertKeybindingRpc); +export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { + payload: ProjectSearchEntriesInput, + success: ProjectSearchEntriesResult, + error: ProjectSearchEntriesError, +}); + +export const WsRpcGroup = RpcGroup.make( + WsServerGetConfigRpc, + WsServerUpsertKeybindingRpc, + WsProjectsSearchEntriesRpc, +); From 70f7adb1492f35bc76a0526c9de3452cbedeaf24 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Mar 2026 10:12:07 -0700 Subject: [PATCH 12/47] finihs phase 2 --- .plans/ws-rpc-endpoint-port-plan.md | 4 +- apps/server/src/open.ts | 9 +- apps/server/src/server.test.ts | 125 +++++++++++++++++++++++++++- apps/server/src/workspaceEntries.ts | 39 +++++++++ apps/server/src/ws.ts | 48 +++++++++-- packages/contracts/src/editor.ts | 5 ++ packages/contracts/src/project.ts | 8 ++ packages/contracts/src/wsRpc.ts | 17 ++++ 8 files changed, 240 insertions(+), 15 deletions(-) diff --git a/.plans/ws-rpc-endpoint-port-plan.md b/.plans/ws-rpc-endpoint-port-plan.md index c6c3c4c91f..1a6b755475 100644 --- a/.plans/ws-rpc-endpoint-port-plan.md +++ b/.plans/ws-rpc-endpoint-port-plan.md @@ -37,8 +37,8 @@ Legend: `[x]` done, `[ ]` not started. ### Phase 2: Project + editor read/write (small inputs, bounded side effects) - [x] `projects.searchEntries` -- [ ] `projects.writeFile` -- [ ] `shell.openInEditor` +- [x] `projects.writeFile` +- [x] `shell.openInEditor` ### Phase 3: Git operations (broader side effects) diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index e7238c04b2..3bd1dbcd41 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -10,17 +10,14 @@ import { spawn } from "node:child_process"; import { accessSync, constants, statSync } from "node:fs"; import { extname, join } from "node:path"; -import { EDITORS, type EditorId } from "@t3tools/contracts"; -import { ServiceMap, Schema, Effect, Layer } from "effect"; +import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts"; +import { ServiceMap, Effect, Layer } from "effect"; // ============================== // Definitions // ============================== -export class OpenError extends Schema.TaggedErrorClass()("OpenError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect), -}) {} +export { OpenError }; export interface OpenInEditorInput { readonly cwd: string; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 5d32c1b923..af371b3caf 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1,9 +1,15 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { KeybindingRule, ResolvedKeybindingRule, WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; +import { + KeybindingRule, + OpenError, + ResolvedKeybindingRule, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; -import { assertEquals, assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; +import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; import { Effect, FileSystem, Layer, Path, Stream } from "effect"; import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; @@ -13,6 +19,7 @@ import { ServerConfig } from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; import { resolveAttachmentRelativePath } from "./attachmentPaths.ts"; import { Keybindings, KeybindingsConfigError, type KeybindingsShape } from "./keybindings.ts"; +import { Open, type OpenShape } from "./open.ts"; import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth.ts"; const buildAppUnderTest = (options?: { @@ -20,6 +27,7 @@ const buildAppUnderTest = (options?: { layers?: { keybindings?: Partial; providerHealth?: Partial; + open?: Partial; }; }) => Effect.gen(function* () { @@ -59,6 +67,11 @@ const buildAppUnderTest = (options?: { ...options?.layers?.providerHealth, }), ), + Layer.provide( + Layer.mock(Open)({ + ...options?.layers?.open, + }), + ), Layer.provide(layerConfig), ); @@ -307,4 +320,112 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + + it.effect("routes websocket rpc projects.writeFile", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-write-" }); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsWriteFile]({ + cwd: workspaceDir, + relativePath: "nested/created.txt", + contents: "written-by-rpc", + }), + ), + ); + + assert.equal(response.relativePath, "nested/created.txt"); + const persisted = yield* fs.readFileString(path.join(workspaceDir, "nested", "created.txt")); + assert.equal(persisted, "written-by-rpc"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc projects.writeFile errors", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-write-" }); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsWriteFile]({ + cwd: workspaceDir, + relativePath: "../escape.txt", + contents: "nope", + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "ProjectWriteFileError"); + assert.equal( + result.failure.message, + "Workspace file path must stay within the project root.", + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc shell.openInEditor", () => + Effect.gen(function* () { + let openedInput: { + cwd: string; + editor: "cursor" | "vscode" | "zed" | "file-manager"; + } | null = null; + yield* buildAppUnderTest({ + layers: { + open: { + openInEditor: (input) => + Effect.sync(() => { + openedInput = input; + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.shellOpenInEditor]({ + cwd: "/tmp/project", + editor: "cursor", + }), + ), + ); + + assert.deepEqual(openedInput, { cwd: "/tmp/project", editor: "cursor" }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc shell.openInEditor errors", () => + Effect.gen(function* () { + const openError = new OpenError({ message: "Editor command not found: cursor" }); + yield* buildAppUnderTest({ + layers: { + open: { + openInEditor: () => Effect.fail(openError), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.shellOpenInEditor]({ + cwd: "/tmp/project", + editor: "cursor", + }), + ).pipe(Effect.result), + ); + + assertFailure(result, openError); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); }); diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts index 684b005e83..f79bb8e12e 100644 --- a/apps/server/src/workspaceEntries.ts +++ b/apps/server/src/workspaceEntries.ts @@ -7,7 +7,9 @@ import { ProjectEntry, ProjectSearchEntriesInput, ProjectSearchEntriesResult, + ProjectWriteFileError, } from "@t3tools/contracts"; +import { Effect, Path } from "effect"; const WORKSPACE_CACHE_TTL_MS = 15_000; const WORKSPACE_CACHE_MAX_KEYS = 4; @@ -563,3 +565,40 @@ export async function searchWorkspaceEntries( truncated: index.truncated || matchedEntryCount > limit, }; } + +function toPosixRelativePath(input: string): string { + return input.replaceAll("\\", "/"); +} + +export const resolveWorkspaceWritePath = Effect.fn(function* (params: { + workspaceRoot: string; + relativePath: string; +}) { + const path = yield* Path.Path; + + const normalizedInputPath = params.relativePath.trim(); + if (path.isAbsolute(normalizedInputPath)) { + return yield* new ProjectWriteFileError({ + message: "Workspace file path must be relative to the project root.", + }); + } + + const absolutePath = path.resolve(params.workspaceRoot, normalizedInputPath); + const relativeToRoot = toPosixRelativePath(path.relative(params.workspaceRoot, absolutePath)); + if ( + relativeToRoot.length === 0 || + relativeToRoot === "." || + relativeToRoot.startsWith("../") || + relativeToRoot === ".." || + path.isAbsolute(relativeToRoot) + ) { + return yield* new ProjectWriteFileError({ + message: "Workspace file path must stay within the project root.", + }); + } + + return { + absolutePath, + relativePath: relativeToRoot, + }; +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c7e5bf9e83..a2bf840b99 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,12 +1,17 @@ -import { Effect, Layer } from "effect"; -import { ProjectSearchEntriesError, WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; +import { Effect, FileSystem, Layer, Path } from "effect"; +import { + ProjectSearchEntriesError, + ProjectWriteFileError, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import { ServerConfig } from "./config"; import { Keybindings } from "./keybindings"; -import { resolveAvailableEditors } from "./open"; +import { Open, resolveAvailableEditors } from "./open"; import { ProviderHealth } from "./provider/Services/ProviderHealth"; -import { searchWorkspaceEntries } from "./workspaceEntries"; +import { resolveWorkspaceWritePath, searchWorkspaceEntries } from "./workspaceEntries"; const WsRpcLayer = WsRpcGroup.toLayer({ [WS_METHODS.serverGetConfig]: () => @@ -37,10 +42,43 @@ const WsRpcLayer = WsRpcGroup.toLayer({ try: () => searchWorkspaceEntries(input), catch: (cause) => new ProjectSearchEntriesError({ - message: `Failed to search workspace entries`, + message: "Failed to search workspace entries", cause, }), }), + [WS_METHODS.projectsWriteFile]: (input) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = yield* resolveWorkspaceWritePath({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + message: "Failed to prepare workspace path", + cause, + }), + ), + ); + yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + message: "Failed to write workspace file", + cause, + }), + ), + ); + return { relativePath: target.relativePath }; + }), + [WS_METHODS.shellOpenInEditor]: (input) => + Effect.gen(function* () { + const open = yield* Open; + return yield* open.openInEditor(input); + }), }); export const websocketRpcRouteLayer = RpcServer.layerHttp({ diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 0ebd4fe5ae..a25da64f40 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -17,3 +17,8 @@ export const OpenInEditorInput = Schema.Struct({ editor: EditorId, }); export type OpenInEditorInput = typeof OpenInEditorInput.Type; + +export class OpenError extends Schema.TaggedErrorClass()("OpenError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index a42812e218..2851120d1d 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -45,3 +45,11 @@ export const ProjectWriteFileResult = Schema.Struct({ relativePath: TrimmedNonEmptyString, }); export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type; + +export class ProjectWriteFileError extends Schema.TaggedErrorClass()( + "ProjectWriteFileError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} diff --git a/packages/contracts/src/wsRpc.ts b/packages/contracts/src/wsRpc.ts index a24386837c..8fb1a04e21 100644 --- a/packages/contracts/src/wsRpc.ts +++ b/packages/contracts/src/wsRpc.ts @@ -1,11 +1,15 @@ import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; +import { OpenError, OpenInEditorInput } from "./editor"; import { KeybindingsConfigError } from "./keybindings"; import { ProjectSearchEntriesError, ProjectSearchEntriesInput, ProjectSearchEntriesResult, + ProjectWriteFileError, + ProjectWriteFileInput, + ProjectWriteFileResult, } from "./project"; import { ServerConfig, ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; import { WS_METHODS } from "./ws"; @@ -27,8 +31,21 @@ export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntr error: ProjectSearchEntriesError, }); +export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { + payload: ProjectWriteFileInput, + success: ProjectWriteFileResult, + error: ProjectWriteFileError, +}); + +export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { + payload: OpenInEditorInput, + error: OpenError, +}); + export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerUpsertKeybindingRpc, WsProjectsSearchEntriesRpc, + WsProjectsWriteFileRpc, + WsShellOpenInEditorRpc, ); From 89fc5c1b19a649ce0b162ed0d9f4aadb3a2e7bec Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Mar 2026 10:20:06 -0700 Subject: [PATCH 13/47] phase 3 - git - complete --- .plans/ws-rpc-endpoint-port-plan.md | 22 +-- apps/server/src/git/Errors.ts | 74 +--------- apps/server/src/server.test.ts | 217 ++++++++++++++++++++++++++++ apps/server/src/ws.ts | 57 ++++++++ packages/contracts/src/git.ts | 38 +++++ packages/contracts/src/wsRpc.ts | 95 ++++++++++++ 6 files changed, 425 insertions(+), 78 deletions(-) diff --git a/.plans/ws-rpc-endpoint-port-plan.md b/.plans/ws-rpc-endpoint-port-plan.md index 1a6b755475..be52660f4b 100644 --- a/.plans/ws-rpc-endpoint-port-plan.md +++ b/.plans/ws-rpc-endpoint-port-plan.md @@ -42,17 +42,17 @@ Legend: `[x]` done, `[ ]` not started. ### Phase 3: Git operations (broader side effects) -- [ ] `git.status` -- [ ] `git.listBranches` -- [ ] `git.pull` -- [ ] `git.runStackedAction` -- [ ] `git.resolvePullRequest` -- [ ] `git.preparePullRequestThread` -- [ ] `git.createWorktree` -- [ ] `git.removeWorktree` -- [ ] `git.createBranch` -- [ ] `git.checkout` -- [ ] `git.init` +- [x] `git.status` +- [x] `git.listBranches` +- [x] `git.pull` +- [x] `git.runStackedAction` +- [x] `git.resolvePullRequest` +- [x] `git.preparePullRequestThread` +- [x] `git.createWorktree` +- [x] `git.removeWorktree` +- [x] `git.createBranch` +- [x] `git.checkout` +- [x] `git.init` ### Phase 4: Terminal lifecycle + IO (stateful and streaming-adjacent) diff --git a/apps/server/src/git/Errors.ts b/apps/server/src/git/Errors.ts index 15bf482f7b..4e1f763a9d 100644 --- a/apps/server/src/git/Errors.ts +++ b/apps/server/src/git/Errors.ts @@ -1,67 +1,7 @@ -import { Schema } from "effect"; - -/** - * GitCommandError - Git command execution failed. - */ -export class GitCommandError extends Schema.TaggedErrorClass()("GitCommandError", { - operation: Schema.String, - command: Schema.String, - cwd: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect), -}) { - override get message(): string { - return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; - } -} - -/** - * GitHubCliError - GitHub CLI execution or authentication failed. - */ -export class GitHubCliError extends Schema.TaggedErrorClass()("GitHubCliError", { - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect), -}) { - override get message(): string { - return `GitHub CLI failed in ${this.operation}: ${this.detail}`; - } -} - -/** - * TextGenerationError - Commit or PR text generation failed. - */ -export class TextGenerationError extends Schema.TaggedErrorClass()( - "TextGenerationError", - { - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect), - }, -) { - override get message(): string { - return `Text generation failed in ${this.operation}: ${this.detail}`; - } -} - -/** - * GitManagerError - Stacked Git workflow orchestration failed. - */ -export class GitManagerError extends Schema.TaggedErrorClass()("GitManagerError", { - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect), -}) { - override get message(): string { - return `Git manager failed in ${this.operation}: ${this.detail}`; - } -} - -/** - * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration. - */ -export type GitManagerServiceError = - | GitManagerError - | GitCommandError - | GitHubCliError - | TextGenerationError; +export { + GitCommandError, + GitHubCliError, + GitManagerError, + TextGenerationError, + type GitManagerServiceError, +} from "@t3tools/contracts"; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index af371b3caf..8b76ad15ad 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2,6 +2,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + GitCommandError, KeybindingRule, OpenError, ResolvedKeybindingRule, @@ -18,6 +19,8 @@ import type { ServerConfigShape } from "./config.ts"; import { ServerConfig } from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; import { resolveAttachmentRelativePath } from "./attachmentPaths.ts"; +import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts"; +import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; import { Keybindings, KeybindingsConfigError, type KeybindingsShape } from "./keybindings.ts"; import { Open, type OpenShape } from "./open.ts"; import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth.ts"; @@ -28,6 +31,8 @@ const buildAppUnderTest = (options?: { keybindings?: Partial; providerHealth?: Partial; open?: Partial; + gitCore?: Partial; + gitManager?: Partial; }; }) => Effect.gen(function* () { @@ -72,6 +77,16 @@ const buildAppUnderTest = (options?: { ...options?.layers?.open, }), ), + Layer.provide( + Layer.mock(GitCore)({ + ...options?.layers?.gitCore, + }), + ), + Layer.provide( + Layer.mock(GitManager)({ + ...options?.layers?.gitManager, + }), + ), Layer.provide(layerConfig), ); @@ -428,4 +443,206 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assertFailure(result, openError); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + + it.effect("routes websocket rpc git methods", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + gitManager: { + status: () => + Effect.succeed({ + branch: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + runStackedAction: () => + Effect.succeed({ + action: "commit", + branch: { status: "skipped_not_requested" }, + commit: { status: "created", commitSha: "abc123", subject: "feat: demo" }, + push: { status: "skipped_not_requested" }, + pr: { status: "skipped_not_requested" }, + }), + resolvePullRequest: () => + Effect.succeed({ + pullRequest: { + number: 1, + title: "Demo PR", + url: "https://example.com/pr/1", + baseBranch: "main", + headBranch: "feature/demo", + state: "open", + }, + }), + preparePullRequestThread: () => + Effect.succeed({ + pullRequest: { + number: 1, + title: "Demo PR", + url: "https://example.com/pr/1", + baseBranch: "main", + headBranch: "feature/demo", + state: "open", + }, + branch: "feature/demo", + worktreePath: null, + }), + }, + gitCore: { + pullCurrentBranch: () => + Effect.succeed({ + status: "pulled", + branch: "main", + upstreamBranch: "origin/main", + }), + listBranches: () => + Effect.succeed({ + branches: [ + { + name: "main", + current: true, + isDefault: true, + worktreePath: null, + }, + ], + isRepo: true, + hasOriginRemote: true, + }), + createWorktree: () => + Effect.succeed({ + worktree: { path: "/tmp/wt", branch: "feature/demo" }, + }), + removeWorktree: () => Effect.void, + createBranch: () => Effect.void, + checkoutBranch: () => Effect.void, + initRepo: () => Effect.void, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + + const status = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitStatus]({ cwd: "/tmp/repo" })), + ); + assert.equal(status.branch, "main"); + + const pull = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), + ); + assert.equal(pull.status, "pulled"); + + const stacked = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitRunStackedAction]({ cwd: "/tmp/repo", action: "commit" }), + ), + ); + assert.equal(stacked.action, "commit"); + + const resolvedPr = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitResolvePullRequest]({ + cwd: "/tmp/repo", + reference: "1", + }), + ), + ); + assert.equal(resolvedPr.pullRequest.number, 1); + + const prepared = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitPreparePullRequestThread]({ + cwd: "/tmp/repo", + reference: "1", + mode: "local", + }), + ), + ); + assert.equal(prepared.branch, "feature/demo"); + + const branches = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitListBranches]({ cwd: "/tmp/repo" }), + ), + ); + assert.equal(branches.branches[0]?.name, "main"); + + const worktree = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitCreateWorktree]({ + cwd: "/tmp/repo", + branch: "main", + path: null, + }), + ), + ); + assert.equal(worktree.worktree.branch, "feature/demo"); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitRemoveWorktree]({ + cwd: "/tmp/repo", + path: "/tmp/wt", + }), + ), + ); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitCreateBranch]({ + cwd: "/tmp/repo", + branch: "feature/new", + }), + ), + ); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitCheckout]({ + cwd: "/tmp/repo", + branch: "main", + }), + ), + ); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitInit]({ + cwd: "/tmp/repo", + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc git.pull errors", () => + Effect.gen(function* () { + const gitError = new GitCommandError({ + operation: "pull", + command: "git pull --ff-only", + cwd: "/tmp/repo", + detail: "upstream missing", + }); + yield* buildAppUnderTest({ + layers: { + gitCore: { + pullCurrentBranch: () => Effect.fail(gitError), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })).pipe( + Effect.result, + ), + ); + + assertFailure(result, gitError); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a2bf840b99..7593df3988 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -8,6 +8,8 @@ import { import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import { ServerConfig } from "./config"; +import { GitCore } from "./git/Services/GitCore"; +import { GitManager } from "./git/Services/GitManager"; import { Keybindings } from "./keybindings"; import { Open, resolveAvailableEditors } from "./open"; import { ProviderHealth } from "./provider/Services/ProviderHealth"; @@ -79,6 +81,61 @@ const WsRpcLayer = WsRpcGroup.toLayer({ const open = yield* Open; return yield* open.openInEditor(input); }), + [WS_METHODS.gitStatus]: (input) => + Effect.gen(function* () { + const gitManager = yield* GitManager; + return yield* gitManager.status(input); + }), + [WS_METHODS.gitPull]: (input) => + Effect.gen(function* () { + const git = yield* GitCore; + return yield* git.pullCurrentBranch(input.cwd); + }), + [WS_METHODS.gitRunStackedAction]: (input) => + Effect.gen(function* () { + const gitManager = yield* GitManager; + return yield* gitManager.runStackedAction(input); + }), + [WS_METHODS.gitResolvePullRequest]: (input) => + Effect.gen(function* () { + const gitManager = yield* GitManager; + return yield* gitManager.resolvePullRequest(input); + }), + [WS_METHODS.gitPreparePullRequestThread]: (input) => + Effect.gen(function* () { + const gitManager = yield* GitManager; + return yield* gitManager.preparePullRequestThread(input); + }), + [WS_METHODS.gitListBranches]: (input) => + Effect.gen(function* () { + const git = yield* GitCore; + return yield* git.listBranches(input); + }), + [WS_METHODS.gitCreateWorktree]: (input) => + Effect.gen(function* () { + const git = yield* GitCore; + return yield* git.createWorktree(input); + }), + [WS_METHODS.gitRemoveWorktree]: (input) => + Effect.gen(function* () { + const git = yield* GitCore; + return yield* git.removeWorktree(input); + }), + [WS_METHODS.gitCreateBranch]: (input) => + Effect.gen(function* () { + const git = yield* GitCore; + return yield* git.createBranch(input); + }), + [WS_METHODS.gitCheckout]: (input) => + Effect.gen(function* () { + const git = yield* GitCore; + return yield* Effect.scoped(git.checkoutBranch(input)); + }), + [WS_METHODS.gitInit]: (input) => + Effect.gen(function* () { + const git = yield* GitCore; + return yield* git.initRepo(input); + }), }); export const websocketRpcRouteLayer = RpcServer.layerHttp({ diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index e64ca13d72..0c84ab8c67 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -211,3 +211,41 @@ export const GitPullResult = Schema.Struct({ upstreamBranch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), }); export type GitPullResult = typeof GitPullResult.Type; + +// RPC / domain errors +export class GitCommandError extends Schema.TaggedErrorClass()("GitCommandError", { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +export class GitHubCliError extends Schema.TaggedErrorClass()("GitHubCliError", { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +export class TextGenerationError extends Schema.TaggedErrorClass()( + "TextGenerationError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export class GitManagerError extends Schema.TaggedErrorClass()("GitManagerError", { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +export const GitManagerServiceError = Schema.Union([ + GitManagerError, + GitCommandError, + GitHubCliError, + TextGenerationError, +]); +export type GitManagerServiceError = typeof GitManagerServiceError.Type; diff --git a/packages/contracts/src/wsRpc.ts b/packages/contracts/src/wsRpc.ts index 8fb1a04e21..e44c1408f4 100644 --- a/packages/contracts/src/wsRpc.ts +++ b/packages/contracts/src/wsRpc.ts @@ -2,6 +2,28 @@ import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; import { OpenError, OpenInEditorInput } from "./editor"; +import { + GitCheckoutInput, + GitCommandError, + GitCreateBranchInput, + GitCreateWorktreeInput, + GitCreateWorktreeResult, + GitInitInput, + GitListBranchesInput, + GitListBranchesResult, + GitManagerServiceError, + GitPreparePullRequestThreadInput, + GitPreparePullRequestThreadResult, + GitPullInput, + GitPullRequestRefInput, + GitPullResult, + GitRemoveWorktreeInput, + GitResolvePullRequestResult, + GitRunStackedActionInput, + GitRunStackedActionResult, + GitStatusInput, + GitStatusResult, +} from "./git"; import { KeybindingsConfigError } from "./keybindings"; import { ProjectSearchEntriesError, @@ -42,10 +64,83 @@ export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { error: OpenError, }); +export const WsGitStatusRpc = Rpc.make(WS_METHODS.gitStatus, { + payload: GitStatusInput, + success: GitStatusResult, + error: GitManagerServiceError, +}); + +export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, { + payload: GitPullInput, + success: GitPullResult, + error: GitCommandError, +}); + +export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { + payload: GitRunStackedActionInput, + success: GitRunStackedActionResult, + error: GitManagerServiceError, +}); + +export const WsGitResolvePullRequestRpc = Rpc.make(WS_METHODS.gitResolvePullRequest, { + payload: GitPullRequestRefInput, + success: GitResolvePullRequestResult, + error: GitManagerServiceError, +}); + +export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePullRequestThread, { + payload: GitPreparePullRequestThreadInput, + success: GitPreparePullRequestThreadResult, + error: GitManagerServiceError, +}); + +export const WsGitListBranchesRpc = Rpc.make(WS_METHODS.gitListBranches, { + payload: GitListBranchesInput, + success: GitListBranchesResult, + error: GitCommandError, +}); + +export const WsGitCreateWorktreeRpc = Rpc.make(WS_METHODS.gitCreateWorktree, { + payload: GitCreateWorktreeInput, + success: GitCreateWorktreeResult, + error: GitCommandError, +}); + +export const WsGitRemoveWorktreeRpc = Rpc.make(WS_METHODS.gitRemoveWorktree, { + payload: GitRemoveWorktreeInput, + error: GitCommandError, +}); + +export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, { + payload: GitCreateBranchInput, + error: GitCommandError, +}); + +export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, { + payload: GitCheckoutInput, + error: GitCommandError, +}); + +export const WsGitInitRpc = Rpc.make(WS_METHODS.gitInit, { + payload: GitInitInput, + error: GitCommandError, +}); + export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerUpsertKeybindingRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, + WsGitStatusRpc, + WsGitPullRpc, + WsGitRunStackedActionRpc, + WsGitResolvePullRequestRpc, + WsGitPreparePullRequestThreadRpc, + WsGitListBranchesRpc, + WsGitCreateWorktreeRpc, + WsGitRemoveWorktreeRpc, + WsGitCreateBranchRpc, + WsGitCheckoutRpc, + WsGitInitRpc, ); From 0ca441f7193a6a8df0d722e5b87fcbc62ada660f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Mar 2026 10:24:11 -0700 Subject: [PATCH 14/47] phase 4 complete --- .plans/ws-rpc-endpoint-port-plan.md | 12 +- apps/server/src/server.test.ts | 128 +++++++++++++++++++ apps/server/src/terminal/Services/Manager.ts | 9 +- apps/server/src/ws.ts | 31 +++++ packages/contracts/src/terminal.ts | 5 + packages/contracts/src/wsRpc.ts | 48 +++++++ 6 files changed, 221 insertions(+), 12 deletions(-) diff --git a/.plans/ws-rpc-endpoint-port-plan.md b/.plans/ws-rpc-endpoint-port-plan.md index be52660f4b..f0d4babea1 100644 --- a/.plans/ws-rpc-endpoint-port-plan.md +++ b/.plans/ws-rpc-endpoint-port-plan.md @@ -56,12 +56,12 @@ Legend: `[x]` done, `[ ]` not started. ### Phase 4: Terminal lifecycle + IO (stateful and streaming-adjacent) -- [ ] `terminal.open` -- [ ] `terminal.write` -- [ ] `terminal.resize` -- [ ] `terminal.clear` -- [ ] `terminal.restart` -- [ ] `terminal.close` +- [x] `terminal.open` +- [x] `terminal.write` +- [x] `terminal.resize` +- [x] `terminal.clear` +- [x] `terminal.restart` +- [x] `terminal.close` ### Phase 5: Orchestration RPC methods (domain-critical path) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 8b76ad15ad..b10761e25b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6,6 +6,7 @@ import { KeybindingRule, OpenError, ResolvedKeybindingRule, + TerminalError, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; @@ -24,6 +25,7 @@ import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; import { Keybindings, KeybindingsConfigError, type KeybindingsShape } from "./keybindings.ts"; import { Open, type OpenShape } from "./open.ts"; import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth.ts"; +import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; const buildAppUnderTest = (options?: { config?: Partial; @@ -33,6 +35,7 @@ const buildAppUnderTest = (options?: { open?: Partial; gitCore?: Partial; gitManager?: Partial; + terminalManager?: Partial; }; }) => Effect.gen(function* () { @@ -87,6 +90,11 @@ const buildAppUnderTest = (options?: { ...options?.layers?.gitManager, }), ), + Layer.provide( + Layer.mock(TerminalManager)({ + ...options?.layers?.terminalManager, + }), + ), Layer.provide(layerConfig), ); @@ -645,4 +653,124 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assertFailure(result, gitError); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + + it.effect("routes websocket rpc terminal methods", () => + Effect.gen(function* () { + const snapshot = { + threadId: "thread-1", + terminalId: "default", + cwd: "/tmp/project", + status: "running" as const, + pid: 1234, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: new Date().toISOString(), + }; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + open: () => Effect.succeed(snapshot), + write: () => Effect.void, + resize: () => Effect.void, + clear: () => Effect.void, + restart: () => Effect.succeed(snapshot), + close: () => Effect.void, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + + const opened = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.terminalOpen]({ + threadId: "thread-1", + terminalId: "default", + cwd: "/tmp/project", + }), + ), + ); + assert.equal(opened.terminalId, "default"); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.terminalWrite]({ + threadId: "thread-1", + terminalId: "default", + data: "echo hi\n", + }), + ), + ); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.terminalResize]({ + threadId: "thread-1", + terminalId: "default", + cols: 120, + rows: 40, + }), + ), + ); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.terminalClear]({ + threadId: "thread-1", + terminalId: "default", + }), + ), + ); + + const restarted = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.terminalRestart]({ + threadId: "thread-1", + terminalId: "default", + cwd: "/tmp/project", + cols: 120, + rows: 40, + }), + ), + ); + assert.equal(restarted.terminalId, "default"); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.terminalClose]({ + threadId: "thread-1", + terminalId: "default", + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc terminal.write errors", () => + Effect.gen(function* () { + const terminalError = new TerminalError({ message: "Terminal is not running" }); + yield* buildAppUnderTest({ + layers: { + terminalManager: { + write: () => Effect.fail(terminalError), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.terminalWrite]({ + threadId: "thread-1", + terminalId: "default", + data: "echo fail\n", + }), + ).pipe(Effect.result), + ); + + assertFailure(result, terminalError); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); }); diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index 8d8398c7ad..6c122f1af9 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -10,6 +10,7 @@ import { TerminalClearInput, TerminalCloseInput, TerminalEvent, + TerminalError, TerminalOpenInput, TerminalResizeInput, TerminalRestartInput, @@ -18,12 +19,8 @@ import { TerminalWriteInput, } from "@t3tools/contracts"; import { PtyProcess } from "./PTY"; -import { Effect, Schema, ServiceMap } from "effect"; - -export class TerminalError extends Schema.TaggedErrorClass()("TerminalError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect), -}) {} +import { Effect, ServiceMap } from "effect"; +export { TerminalError }; export interface TerminalSessionState { threadId: string; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7593df3988..bd399fce39 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -13,6 +13,7 @@ import { GitManager } from "./git/Services/GitManager"; import { Keybindings } from "./keybindings"; import { Open, resolveAvailableEditors } from "./open"; import { ProviderHealth } from "./provider/Services/ProviderHealth"; +import { TerminalManager } from "./terminal/Services/Manager"; import { resolveWorkspaceWritePath, searchWorkspaceEntries } from "./workspaceEntries"; const WsRpcLayer = WsRpcGroup.toLayer({ @@ -136,6 +137,36 @@ const WsRpcLayer = WsRpcGroup.toLayer({ const git = yield* GitCore; return yield* git.initRepo(input); }), + [WS_METHODS.terminalOpen]: (input) => + Effect.gen(function* () { + const terminalManager = yield* TerminalManager; + return yield* terminalManager.open(input); + }), + [WS_METHODS.terminalWrite]: (input) => + Effect.gen(function* () { + const terminalManager = yield* TerminalManager; + return yield* terminalManager.write(input); + }), + [WS_METHODS.terminalResize]: (input) => + Effect.gen(function* () { + const terminalManager = yield* TerminalManager; + return yield* terminalManager.resize(input); + }), + [WS_METHODS.terminalClear]: (input) => + Effect.gen(function* () { + const terminalManager = yield* TerminalManager; + return yield* terminalManager.clear(input); + }), + [WS_METHODS.terminalRestart]: (input) => + Effect.gen(function* () { + const terminalManager = yield* TerminalManager; + return yield* terminalManager.restart(input); + }), + [WS_METHODS.terminalClose]: (input) => + Effect.gen(function* () { + const terminalManager = yield* TerminalManager; + return yield* terminalManager.close(input); + }), }); export const websocketRpcRouteLayer = RpcServer.layerHttp({ diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index b0493d95c2..e51eaefaa4 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -149,3 +149,8 @@ export const TerminalEvent = Schema.Union([ TerminalActivityEvent, ]); export type TerminalEvent = typeof TerminalEvent.Type; + +export class TerminalError extends Schema.TaggedErrorClass()("TerminalError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} diff --git a/packages/contracts/src/wsRpc.ts b/packages/contracts/src/wsRpc.ts index e44c1408f4..2378f3dc41 100644 --- a/packages/contracts/src/wsRpc.ts +++ b/packages/contracts/src/wsRpc.ts @@ -34,6 +34,16 @@ import { ProjectWriteFileResult, } from "./project"; import { ServerConfig, ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; +import { + TerminalClearInput, + TerminalCloseInput, + TerminalError, + TerminalOpenInput, + TerminalResizeInput, + TerminalRestartInput, + TerminalSessionSnapshot, + TerminalWriteInput, +} from "./terminal"; import { WS_METHODS } from "./ws"; export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { @@ -126,6 +136,38 @@ export const WsGitInitRpc = Rpc.make(WS_METHODS.gitInit, { error: GitCommandError, }); +export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { + payload: TerminalOpenInput, + success: TerminalSessionSnapshot, + error: TerminalError, +}); + +export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, { + payload: TerminalWriteInput, + error: TerminalError, +}); + +export const WsTerminalResizeRpc = Rpc.make(WS_METHODS.terminalResize, { + payload: TerminalResizeInput, + error: TerminalError, +}); + +export const WsTerminalClearRpc = Rpc.make(WS_METHODS.terminalClear, { + payload: TerminalClearInput, + error: TerminalError, +}); + +export const WsTerminalRestartRpc = Rpc.make(WS_METHODS.terminalRestart, { + payload: TerminalRestartInput, + success: TerminalSessionSnapshot, + error: TerminalError, +}); + +export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { + payload: TerminalCloseInput, + error: TerminalError, +}); + export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerUpsertKeybindingRpc, @@ -143,4 +185,10 @@ export const WsRpcGroup = RpcGroup.make( WsGitCreateBranchRpc, WsGitCheckoutRpc, WsGitInitRpc, + WsTerminalOpenRpc, + WsTerminalWriteRpc, + WsTerminalResizeRpc, + WsTerminalClearRpc, + WsTerminalRestartRpc, + WsTerminalCloseRpc, ); From 9e3677e6b8236e90828032c5a00c169d4091618c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Mar 2026 10:37:10 -0700 Subject: [PATCH 15/47] phase 5 --- .plans/ws-rpc-endpoint-port-plan.md | 10 +- apps/server/src/orchestration/Normalizer.ts | 129 ++++++++++ apps/server/src/server.test.ts | 246 ++++++++++++++++++++ apps/server/src/ws.ts | 84 ++++++- packages/contracts/src/orchestration.ts | 40 ++++ packages/contracts/src/wsRpc.ts | 55 +++++ 6 files changed, 558 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/orchestration/Normalizer.ts diff --git a/.plans/ws-rpc-endpoint-port-plan.md b/.plans/ws-rpc-endpoint-port-plan.md index f0d4babea1..cb96c594d9 100644 --- a/.plans/ws-rpc-endpoint-port-plan.md +++ b/.plans/ws-rpc-endpoint-port-plan.md @@ -65,11 +65,11 @@ Legend: `[x]` done, `[ ]` not started. ### Phase 5: Orchestration RPC methods (domain-critical path) -- [ ] `orchestration.getSnapshot` -- [ ] `orchestration.dispatchCommand` -- [ ] `orchestration.getTurnDiff` -- [ ] `orchestration.getFullThreadDiff` -- [ ] `orchestration.replayEvents` +- [x] `orchestration.getSnapshot` +- [x] `orchestration.dispatchCommand` +- [x] `orchestration.getTurnDiff` +- [x] `orchestration.getFullThreadDiff` +- [x] `orchestration.replayEvents` ## Notes diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts new file mode 100644 index 0000000000..c433be4f0f --- /dev/null +++ b/apps/server/src/orchestration/Normalizer.ts @@ -0,0 +1,129 @@ +import { Effect, FileSystem, Path } from "effect"; +import { + type ClientOrchestrationCommand, + type OrchestrationCommand, + OrchestrationDispatchCommandError, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, +} from "@t3tools/contracts"; + +import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore"; +import { ServerConfig } from "../config"; +import { parseBase64DataUrl } from "../imageMime"; +import { expandHomePath } from "../os-jank"; + +export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + + const normalizeProjectWorkspaceRoot = (workspaceRoot: string) => + Effect.gen(function* () { + const normalizedWorkspaceRoot = path.resolve(yield* expandHomePath(workspaceRoot.trim())); + const workspaceStat = yield* fileSystem + .stat(normalizedWorkspaceRoot) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!workspaceStat) { + return yield* new OrchestrationDispatchCommandError({ + message: `Project directory does not exist: ${normalizedWorkspaceRoot}`, + }); + } + if (workspaceStat.type !== "Directory") { + return yield* new OrchestrationDispatchCommandError({ + message: `Project path is not a directory: ${normalizedWorkspaceRoot}`, + }); + } + return normalizedWorkspaceRoot; + }); + + if (command.type === "project.create") { + return { + ...command, + workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot), + } satisfies OrchestrationCommand; + } + + if (command.type === "project.meta.update" && command.workspaceRoot !== undefined) { + return { + ...command, + workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot), + } satisfies OrchestrationCommand; + } + + if (command.type !== "thread.turn.start") { + return command as OrchestrationCommand; + } + + const normalizedAttachments = yield* Effect.forEach( + command.message.attachments, + (attachment) => + Effect.gen(function* () { + const parsed = parseBase64DataUrl(attachment.dataUrl); + if (!parsed || !parsed.mimeType.startsWith("image/")) { + return yield* new OrchestrationDispatchCommandError({ + message: `Invalid image attachment payload for '${attachment.name}'.`, + }); + } + + const bytes = Buffer.from(parsed.base64, "base64"); + if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + return yield* new OrchestrationDispatchCommandError({ + message: `Image attachment '${attachment.name}' is empty or too large.`, + }); + } + + const attachmentId = createAttachmentId(command.threadId); + if (!attachmentId) { + return yield* new OrchestrationDispatchCommandError({ + message: "Failed to create a safe attachment id.", + }); + } + + const persistedAttachment = { + type: "image" as const, + id: attachmentId, + name: attachment.name, + mimeType: parsed.mimeType.toLowerCase(), + sizeBytes: bytes.byteLength, + }; + + const attachmentPath = resolveAttachmentPath({ + stateDir: serverConfig.stateDir, + attachment: persistedAttachment, + }); + if (!attachmentPath) { + return yield* new OrchestrationDispatchCommandError({ + message: `Failed to resolve persisted path for '${attachment.name}'.`, + }); + } + + yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe( + Effect.mapError( + () => + new OrchestrationDispatchCommandError({ + message: `Failed to create attachment directory for '${attachment.name}'.`, + }), + ), + ); + yield* fileSystem.writeFile(attachmentPath, bytes).pipe( + Effect.mapError( + () => + new OrchestrationDispatchCommandError({ + message: `Failed to persist attachment '${attachment.name}'.`, + }), + ), + ); + + return persistedAttachment; + }), + { concurrency: 1 }, + ); + + return { + ...command, + message: { + ...command.message, + attachments: normalizedAttachments, + }, + } satisfies OrchestrationCommand; + }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b10761e25b..5b8ef4e8e6 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2,11 +2,15 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + CommandId, GitCommandError, KeybindingRule, OpenError, + ORCHESTRATION_WS_METHODS, + ProjectId, ResolvedKeybindingRule, TerminalError, + ThreadId, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; @@ -20,13 +24,70 @@ import type { ServerConfigShape } from "./config.ts"; import { ServerConfig } from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; import { resolveAttachmentRelativePath } from "./attachmentPaths.ts"; +import { + CheckpointDiffQuery, + type CheckpointDiffQueryShape, +} from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts"; import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; import { Keybindings, KeybindingsConfigError, type KeybindingsShape } from "./keybindings.ts"; import { Open, type OpenShape } from "./open.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "./orchestration/Services/OrchestrationEngine.ts"; +import { + ProjectionSnapshotQuery, + type ProjectionSnapshotQueryShape, +} from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { PersistenceSqlError } from "./persistence/Errors.ts"; import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth.ts"; import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; +const defaultProjectId = ProjectId.makeUnsafe("project-default"); +const defaultThreadId = ThreadId.makeUnsafe("thread-default"); + +const makeDefaultOrchestrationReadModel = () => { + const now = new Date().toISOString(); + return { + snapshotSequence: 0, + updatedAt: now, + projects: [ + { + id: defaultProjectId, + title: "Default Project", + workspaceRoot: "/tmp/default-project", + defaultModel: "gpt-5-codex", + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + ], + threads: [ + { + id: defaultThreadId, + projectId: defaultProjectId, + title: "Default Thread", + model: "gpt-5-codex", + interactionMode: "default" as const, + runtimeMode: "full-access" as const, + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + latestTurn: null, + messages: [], + session: null, + activities: [], + proposedPlans: [], + checkpoints: [], + deletedAt: null, + }, + ], + }; +}; + const buildAppUnderTest = (options?: { config?: Partial; layers?: { @@ -36,6 +97,9 @@ const buildAppUnderTest = (options?: { gitCore?: Partial; gitManager?: Partial; terminalManager?: Partial; + orchestrationEngine?: Partial; + projectionSnapshotQuery?: Partial; + checkpointDiffQuery?: Partial; }; }) => Effect.gen(function* () { @@ -95,6 +159,40 @@ const buildAppUnderTest = (options?: { ...options?.layers?.terminalManager, }), ), + Layer.provide( + Layer.mock(OrchestrationEngineService)({ + getReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()), + readEvents: () => Stream.empty, + dispatch: () => Effect.succeed({ sequence: 0 }), + streamDomainEvents: Stream.empty, + ...options?.layers?.orchestrationEngine, + }), + ), + Layer.provide( + Layer.mock(ProjectionSnapshotQuery)({ + getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()), + ...options?.layers?.projectionSnapshotQuery, + }), + ), + Layer.provide( + Layer.mock(CheckpointDiffQuery)({ + getTurnDiff: () => + Effect.succeed({ + threadId: defaultThreadId, + fromTurnCount: 0, + toTurnCount: 0, + diff: "", + }), + getFullThreadDiff: () => + Effect.succeed({ + threadId: defaultThreadId, + fromTurnCount: 0, + toTurnCount: 0, + diff: "", + }), + ...options?.layers?.checkpointDiffQuery, + }), + ), Layer.provide(layerConfig), ); @@ -654,6 +752,154 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc orchestration methods", () => + Effect.gen(function* () { + const now = new Date().toISOString(); + const snapshot = { + snapshotSequence: 1, + updatedAt: now, + projects: [ + { + id: ProjectId.makeUnsafe("project-a"), + title: "Project A", + workspaceRoot: "/tmp/project-a", + defaultModel: "gpt-5-codex", + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + ], + threads: [ + { + id: ThreadId.makeUnsafe("thread-1"), + projectId: ProjectId.makeUnsafe("project-a"), + title: "Thread A", + model: "gpt-5-codex", + interactionMode: "default" as const, + runtimeMode: "full-access" as const, + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + latestTurn: null, + messages: [], + session: null, + activities: [], + proposedPlans: [], + checkpoints: [], + deletedAt: null, + }, + ], + }; + + yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getSnapshot: () => Effect.succeed(snapshot), + }, + orchestrationEngine: { + dispatch: () => Effect.succeed({ sequence: 7 }), + readEvents: () => Stream.empty, + }, + checkpointDiffQuery: { + getTurnDiff: () => + Effect.succeed({ + threadId: ThreadId.makeUnsafe("thread-1"), + fromTurnCount: 0, + toTurnCount: 1, + diff: "turn-diff", + }), + getFullThreadDiff: () => + Effect.succeed({ + threadId: ThreadId.makeUnsafe("thread-1"), + fromTurnCount: 0, + toTurnCount: 1, + diff: "full-diff", + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const snapshotResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})), + ); + assert.equal(snapshotResult.snapshotSequence, 1); + + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.session.stop", + commandId: CommandId.makeUnsafe("cmd-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + createdAt: now, + }), + ), + ); + assert.equal(dispatchResult.sequence, 7); + + const turnDiffResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.getTurnDiff]({ + threadId: ThreadId.makeUnsafe("thread-1"), + fromTurnCount: 0, + toTurnCount: 1, + }), + ), + ); + assert.equal(turnDiffResult.diff, "turn-diff"); + + const fullDiffResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.getFullThreadDiff]({ + threadId: ThreadId.makeUnsafe("thread-1"), + toTurnCount: 1, + }), + ), + ); + assert.equal(fullDiffResult.diff, "full-diff"); + + const replayResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.replayEvents]({ + fromSequenceExclusive: 0, + }), + ), + ); + assert.deepEqual(replayResult, []); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc orchestration.getSnapshot errors", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getSnapshot: () => + Effect.fail( + new PersistenceSqlError({ + operation: "ProjectionSnapshotQuery.getSnapshot", + detail: "projection unavailable", + }), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})).pipe( + Effect.result, + ), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "OrchestrationGetSnapshotError"); + assertInclude(result.failure.message, "Failed to load orchestration snapshot"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc terminal methods", () => Effect.gen(function* () { const snapshot = { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index bd399fce39..7b34971ab0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,22 +1,104 @@ -import { Effect, FileSystem, Layer, Path } from "effect"; +import { Effect, FileSystem, Layer, Path, Schema, Stream } from "effect"; import { + OrchestrationDispatchCommandError, + OrchestrationGetFullThreadDiffError, + OrchestrationGetSnapshotError, + OrchestrationGetTurnDiffError, + ORCHESTRATION_WS_METHODS, ProjectSearchEntriesError, ProjectWriteFileError, + OrchestrationReplayEventsError, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; +import { clamp } from "effect/Number"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; +import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore"; import { GitManager } from "./git/Services/GitManager"; import { Keybindings } from "./keybindings"; import { Open, resolveAvailableEditors } from "./open"; +import { normalizeDispatchCommand } from "./orchestration/Normalizer"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { ProviderHealth } from "./provider/Services/ProviderHealth"; import { TerminalManager } from "./terminal/Services/Manager"; import { resolveWorkspaceWritePath, searchWorkspaceEntries } from "./workspaceEntries"; const WsRpcLayer = WsRpcGroup.toLayer({ + [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => + Effect.gen(function* () { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + return yield* projectionSnapshotQuery.getSnapshot(); + }).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load orchestration snapshot", + cause, + }), + ), + ), + [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => + Effect.gen(function* () { + const orchestrationEngine = yield* OrchestrationEngineService; + const normalizedCommand = yield* normalizeDispatchCommand(command); + return yield* orchestrationEngine.dispatch(normalizedCommand); + }).pipe( + Effect.mapError((cause) => + Schema.is(OrchestrationDispatchCommandError)(cause) + ? cause + : new OrchestrationDispatchCommandError({ + message: "Failed to dispatch orchestration command", + cause, + }), + ), + ), + [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => + Effect.gen(function* () { + const checkpointDiffQuery = yield* CheckpointDiffQuery; + return yield* checkpointDiffQuery.getTurnDiff(input); + }).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetTurnDiffError({ + message: "Failed to load turn diff", + cause, + }), + ), + ), + [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => + Effect.gen(function* () { + const checkpointDiffQuery = yield* CheckpointDiffQuery; + return yield* checkpointDiffQuery.getFullThreadDiff(input); + }).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetFullThreadDiffError({ + message: "Failed to load full thread diff", + cause, + }), + ), + ), + [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => + Effect.gen(function* () { + const orchestrationEngine = yield* OrchestrationEngineService; + return yield* Stream.runCollect( + orchestrationEngine.readEvents( + clamp(input.fromSequenceExclusive, { maximum: Number.MAX_SAFE_INTEGER, minimum: 0 }), + ), + ).pipe(Effect.map((events) => Array.from(events))); + }).pipe( + Effect.mapError( + (cause) => + new OrchestrationReplayEventsError({ + message: "Failed to replay orchestration events", + cause, + }), + ), + ), [WS_METHODS.serverGetConfig]: () => Effect.gen(function* () { const config = yield* ServerConfig; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 3208adc8bb..606f45b287 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1005,3 +1005,43 @@ export const OrchestrationRpcSchemas = { output: OrchestrationReplayEventsResult, }, } as const; + +export class OrchestrationGetSnapshotError extends Schema.TaggedErrorClass()( + "OrchestrationGetSnapshotError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass()( + "OrchestrationDispatchCommandError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export class OrchestrationGetTurnDiffError extends Schema.TaggedErrorClass()( + "OrchestrationGetTurnDiffError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export class OrchestrationGetFullThreadDiffError extends Schema.TaggedErrorClass()( + "OrchestrationGetFullThreadDiffError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export class OrchestrationReplayEventsError extends Schema.TaggedErrorClass()( + "OrchestrationReplayEventsError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} diff --git a/packages/contracts/src/wsRpc.ts b/packages/contracts/src/wsRpc.ts index 2378f3dc41..dfeb06245c 100644 --- a/packages/contracts/src/wsRpc.ts +++ b/packages/contracts/src/wsRpc.ts @@ -25,6 +25,20 @@ import { GitStatusResult, } from "./git"; import { KeybindingsConfigError } from "./keybindings"; +import { + ClientOrchestrationCommand, + ORCHESTRATION_WS_METHODS, + OrchestrationDispatchCommandError, + OrchestrationGetFullThreadDiffError, + OrchestrationGetFullThreadDiffInput, + OrchestrationGetSnapshotError, + OrchestrationGetSnapshotInput, + OrchestrationGetTurnDiffError, + OrchestrationGetTurnDiffInput, + OrchestrationReplayEventsError, + OrchestrationReplayEventsInput, + OrchestrationRpcSchemas, +} from "./orchestration"; import { ProjectSearchEntriesError, ProjectSearchEntriesInput, @@ -168,6 +182,42 @@ export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { error: TerminalError, }); +export const WsOrchestrationGetSnapshotRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getSnapshot, { + payload: OrchestrationGetSnapshotInput, + success: OrchestrationRpcSchemas.getSnapshot.output, + error: OrchestrationGetSnapshotError, +}); + +export const WsOrchestrationDispatchCommandRpc = Rpc.make( + ORCHESTRATION_WS_METHODS.dispatchCommand, + { + payload: ClientOrchestrationCommand, + success: OrchestrationRpcSchemas.dispatchCommand.output, + error: OrchestrationDispatchCommandError, + }, +); + +export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, { + payload: OrchestrationGetTurnDiffInput, + success: OrchestrationRpcSchemas.getTurnDiff.output, + error: OrchestrationGetTurnDiffError, +}); + +export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make( + ORCHESTRATION_WS_METHODS.getFullThreadDiff, + { + payload: OrchestrationGetFullThreadDiffInput, + success: OrchestrationRpcSchemas.getFullThreadDiff.output, + error: OrchestrationGetFullThreadDiffError, + }, +); + +export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.replayEvents, { + payload: OrchestrationReplayEventsInput, + success: OrchestrationRpcSchemas.replayEvents.output, + error: OrchestrationReplayEventsError, +}); + export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerUpsertKeybindingRpc, @@ -191,4 +241,9 @@ export const WsRpcGroup = RpcGroup.make( WsTerminalClearRpc, WsTerminalRestartRpc, WsTerminalCloseRpc, + WsOrchestrationGetSnapshotRpc, + WsOrchestrationDispatchCommandRpc, + WsOrchestrationGetTurnDiffRpc, + WsOrchestrationGetFullThreadDiffRpc, + WsOrchestrationReplayEventsRpc, ); From d9845572b95454572d3f2245d7f4530ba827fbfc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Mar 2026 10:40:05 -0700 Subject: [PATCH 16/47] prune unused stuff from wsServer.ts --- apps/server/src/wsServer.ts | 394 +----------------------------------- 1 file changed, 8 insertions(+), 386 deletions(-) diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index a3d5147b37..ef02dad12b 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -12,57 +12,30 @@ import type { Duplex } from "node:stream"; import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, - type ClientOrchestrationCommand, - type OrchestrationCommand, ORCHESTRATION_WS_CHANNELS, - ORCHESTRATION_WS_METHODS, - PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, ProjectId, ThreadId, WS_CHANNELS, - WS_METHODS, WebSocketRequest, type WsResponse as WsResponseMessage, WsResponse, type WsPushEnvelopeBase, } from "@t3tools/contracts"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import { - Cause, - Effect, - Exit, - FileSystem, - Layer, - Path, - Ref, - Result, - Schema, - Scope, - Stream, - Struct, -} from "effect"; +import { Cause, Effect, Exit, Layer, Path, Ref, Result, Schema, Scope, Stream } from "effect"; import { WebSocketServer, type WebSocket } from "ws"; import { createLogger } from "./logger"; -import { GitManager } from "./git/Services/GitManager.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; import { Keybindings } from "./keybindings"; -import { searchWorkspaceEntries } from "./workspaceEntries"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; import { ProviderService } from "./provider/Services/ProviderService"; import { ProviderHealth } from "./provider/Services/ProviderHealth"; -import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; -import { clamp } from "effect/Number"; -import { Open, resolveAvailableEditors } from "./open"; +import { Open } from "./open"; import { ServerConfig } from "./config"; -import { GitCore } from "./git/Services/GitCore.ts"; - -import { createAttachmentId, resolveAttachmentPath } from "./attachmentStore.ts"; -import { parseBase64DataUrl } from "./imageMime.ts"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; -import { expandHomePath } from "./os-jank.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; @@ -117,73 +90,24 @@ function websocketRawToString(raw: unknown): string | null { return null; } -function toPosixRelativePath(input: string): string { - return input.replaceAll("\\", "/"); -} - const isWildcardHost = (host: string | undefined): boolean => host === "0.0.0.0" || host === "::" || host === "[::]"; const formatHostForUrl = (host: string): string => host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; -function resolveWorkspaceWritePath(params: { - workspaceRoot: string; - relativePath: string; - path: Path.Path; -}): Effect.Effect<{ absolutePath: string; relativePath: string }, RouteRequestError> { - const normalizedInputPath = params.relativePath.trim(); - if (params.path.isAbsolute(normalizedInputPath)) { - return Effect.fail( - new RouteRequestError({ - message: "Workspace file path must be relative to the project root.", - }), - ); - } - - const absolutePath = params.path.resolve(params.workspaceRoot, normalizedInputPath); - const relativeToRoot = toPosixRelativePath( - params.path.relative(params.workspaceRoot, absolutePath), - ); - if ( - relativeToRoot.length === 0 || - relativeToRoot === "." || - relativeToRoot.startsWith("../") || - relativeToRoot === ".." || - params.path.isAbsolute(relativeToRoot) - ) { - return Effect.fail( - new RouteRequestError({ - message: "Workspace file path must stay within the project root.", - }), - ); - } - - return Effect.succeed({ - absolutePath, - relativePath: relativeToRoot, - }); -} - -function stripRequestTag(body: T) { - return Struct.omit(body, ["_tag"]); -} - const encodeWsResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse)); const decodeWebSocketRequest = decodeJsonResult(WebSocketRequest); export type ServerCoreRuntimeServices = | OrchestrationEngineService | ProjectionSnapshotQuery - | CheckpointDiffQuery | OrchestrationReactor | ProviderService | ProviderHealth; export type ServerRuntimeServices = | ServerCoreRuntimeServices - | GitManager - | GitCore | TerminalManager | Keybindings | Open @@ -229,13 +153,12 @@ const recordStartupHeartbeat = Effect.gen(function* () { export const createServer = Effect.fn(function* (): Effect.fn.Return< http.Server, ServerLifecycleError, - Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path + Scope.Scope | ServerRuntimeServices | ServerConfig | Path.Path > { const serverConfig = yield* ServerConfig; const { port, cwd, - keybindingsConfigPath, staticDir, devUrl, authToken, @@ -243,14 +166,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< logWebSocketEvents, autoBootstrapProjectFromCwd, } = serverConfig; - const availableEditors = resolveAvailableEditors(); - const gitManager = yield* GitManager; const terminalManager = yield* TerminalManager; const keybindingsManager = yield* Keybindings; const providerHealth = yield* ProviderHealth; - const git = yield* GitCore; - const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe( @@ -291,120 +210,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ); yield* readiness.markKeybindingsReady; - const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { - readonly command: ClientOrchestrationCommand; - }) { - const normalizeProjectWorkspaceRoot = Effect.fnUntraced(function* (workspaceRoot: string) { - const normalizedWorkspaceRoot = path.resolve(yield* expandHomePath(workspaceRoot.trim())); - const workspaceStat = yield* fileSystem - .stat(normalizedWorkspaceRoot) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (!workspaceStat) { - return yield* new RouteRequestError({ - message: `Project directory does not exist: ${normalizedWorkspaceRoot}`, - }); - } - if (workspaceStat.type !== "Directory") { - return yield* new RouteRequestError({ - message: `Project path is not a directory: ${normalizedWorkspaceRoot}`, - }); - } - return normalizedWorkspaceRoot; - }); - - if (input.command.type === "project.create") { - return { - ...input.command, - workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot), - } satisfies OrchestrationCommand; - } - - if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) { - return { - ...input.command, - workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot), - } satisfies OrchestrationCommand; - } - - if (input.command.type !== "thread.turn.start") { - return input.command as OrchestrationCommand; - } - const turnStartCommand = input.command; - - const normalizedAttachments = yield* Effect.forEach( - turnStartCommand.message.attachments, - (attachment) => - Effect.gen(function* () { - const parsed = parseBase64DataUrl(attachment.dataUrl); - if (!parsed || !parsed.mimeType.startsWith("image/")) { - return yield* new RouteRequestError({ - message: `Invalid image attachment payload for '${attachment.name}'.`, - }); - } - - const bytes = Buffer.from(parsed.base64, "base64"); - if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { - return yield* new RouteRequestError({ - message: `Image attachment '${attachment.name}' is empty or too large.`, - }); - } - - const attachmentId = createAttachmentId(turnStartCommand.threadId); - if (!attachmentId) { - return yield* new RouteRequestError({ - message: "Failed to create a safe attachment id.", - }); - } - - const persistedAttachment = { - type: "image" as const, - id: attachmentId, - name: attachment.name, - mimeType: parsed.mimeType.toLowerCase(), - sizeBytes: bytes.byteLength, - }; - - const attachmentPath = resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment: persistedAttachment, - }); - if (!attachmentPath) { - return yield* new RouteRequestError({ - message: `Failed to resolve persisted path for '${attachment.name}'.`, - }); - } - - yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe( - Effect.mapError( - () => - new RouteRequestError({ - message: `Failed to create attachment directory for '${attachment.name}'.`, - }), - ), - ); - yield* fileSystem.writeFile(attachmentPath, bytes).pipe( - Effect.mapError( - () => - new RouteRequestError({ - message: `Failed to persist attachment '${attachment.name}'.`, - }), - ), - ); - - return persistedAttachment; - }), - { concurrency: 1 }, - ); - - return { - ...turnStartCommand, - message: { - ...turnStartCommand.message, - attachments: normalizedAttachments, - }, - } satisfies OrchestrationCommand; - }); - // HTTP behavior migrated to `httpRouter.ts` + `server.ts`. // wsServer remains focused on WebSocket lifecycle during migration. const httpServer = http.createServer((_req, res) => { @@ -438,9 +243,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const orchestrationEngine = yield* OrchestrationEngineService; const projectionReadModelQuery = yield* ProjectionSnapshotQuery; - const checkpointDiffQuery = yield* CheckpointDiffQuery; const orchestrationReactor = yield* OrchestrationReactor; - const { openBrowser, openInEditor } = yield* Open; + const { openBrowser } = yield* Open; const subscriptionsScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); @@ -523,7 +327,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< } const runtimeServices = yield* Effect.services< - ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path + ServerRuntimeServices | ServerConfig | Path.Path >(); const runPromise = Effect.runPromiseWith(runtimeServices); @@ -572,191 +376,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ); const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) { - switch (request.body._tag) { - case ORCHESTRATION_WS_METHODS.getSnapshot: - return yield* projectionReadModelQuery.getSnapshot(); - - case ORCHESTRATION_WS_METHODS.dispatchCommand: { - const { command } = request.body; - const normalizedCommand = yield* normalizeDispatchCommand({ command }); - return yield* orchestrationEngine.dispatch(normalizedCommand); - } - - case ORCHESTRATION_WS_METHODS.getTurnDiff: { - const body = stripRequestTag(request.body); - return yield* checkpointDiffQuery.getTurnDiff(body); - } - - case ORCHESTRATION_WS_METHODS.getFullThreadDiff: { - const body = stripRequestTag(request.body); - return yield* checkpointDiffQuery.getFullThreadDiff(body); - } - - case ORCHESTRATION_WS_METHODS.replayEvents: { - const { fromSequenceExclusive } = request.body; - return yield* Stream.runCollect( - orchestrationEngine.readEvents( - clamp(fromSequenceExclusive, { - maximum: Number.MAX_SAFE_INTEGER, - minimum: 0, - }), - ), - ).pipe(Effect.map((events) => Array.from(events))); - } - - case WS_METHODS.projectsSearchEntries: { - const body = stripRequestTag(request.body); - return yield* Effect.tryPromise({ - try: () => searchWorkspaceEntries(body), - catch: (cause) => - new RouteRequestError({ - message: `Failed to search workspace entries: ${String(cause)}`, - }), - }); - } - - case WS_METHODS.projectsWriteFile: { - const body = stripRequestTag(request.body); - const target = yield* resolveWorkspaceWritePath({ - workspaceRoot: body.cwd, - relativePath: body.relativePath, - path, - }); - yield* fileSystem - .makeDirectory(path.dirname(target.absolutePath), { recursive: true }) - .pipe( - Effect.mapError( - (cause) => - new RouteRequestError({ - message: `Failed to prepare workspace path: ${String(cause)}`, - }), - ), - ); - yield* fileSystem.writeFileString(target.absolutePath, body.contents).pipe( - Effect.mapError( - (cause) => - new RouteRequestError({ - message: `Failed to write workspace file: ${String(cause)}`, - }), - ), - ); - return { relativePath: target.relativePath }; - } - - case WS_METHODS.shellOpenInEditor: { - const body = stripRequestTag(request.body); - return yield* openInEditor(body); - } - - case WS_METHODS.gitStatus: { - const body = stripRequestTag(request.body); - return yield* gitManager.status(body); - } - - case WS_METHODS.gitPull: { - const body = stripRequestTag(request.body); - return yield* git.pullCurrentBranch(body.cwd); - } - - case WS_METHODS.gitRunStackedAction: { - const body = stripRequestTag(request.body); - return yield* gitManager.runStackedAction(body); - } - - case WS_METHODS.gitResolvePullRequest: { - const body = stripRequestTag(request.body); - return yield* gitManager.resolvePullRequest(body); - } - - case WS_METHODS.gitPreparePullRequestThread: { - const body = stripRequestTag(request.body); - return yield* gitManager.preparePullRequestThread(body); - } - - case WS_METHODS.gitListBranches: { - const body = stripRequestTag(request.body); - return yield* git.listBranches(body); - } - - case WS_METHODS.gitCreateWorktree: { - const body = stripRequestTag(request.body); - return yield* git.createWorktree(body); - } - - case WS_METHODS.gitRemoveWorktree: { - const body = stripRequestTag(request.body); - return yield* git.removeWorktree(body); - } - - case WS_METHODS.gitCreateBranch: { - const body = stripRequestTag(request.body); - return yield* git.createBranch(body); - } - - case WS_METHODS.gitCheckout: { - const body = stripRequestTag(request.body); - return yield* Effect.scoped(git.checkoutBranch(body)); - } - - case WS_METHODS.gitInit: { - const body = stripRequestTag(request.body); - return yield* git.initRepo(body); - } - - case WS_METHODS.terminalOpen: { - const body = stripRequestTag(request.body); - return yield* terminalManager.open(body); - } - - case WS_METHODS.terminalWrite: { - const body = stripRequestTag(request.body); - return yield* terminalManager.write(body); - } - - case WS_METHODS.terminalResize: { - const body = stripRequestTag(request.body); - return yield* terminalManager.resize(body); - } - - case WS_METHODS.terminalClear: { - const body = stripRequestTag(request.body); - return yield* terminalManager.clear(body); - } - - case WS_METHODS.terminalRestart: { - const body = stripRequestTag(request.body); - return yield* terminalManager.restart(body); - } - - case WS_METHODS.terminalClose: { - const body = stripRequestTag(request.body); - return yield* terminalManager.close(body); - } - - case WS_METHODS.serverGetConfig: - const keybindingsConfig = yield* keybindingsManager.loadConfigState; - return { - cwd, - keybindingsConfigPath, - keybindings: keybindingsConfig.keybindings, - issues: keybindingsConfig.issues, - providers: providerStatuses, - availableEditors, - }; - - case WS_METHODS.serverUpsertKeybinding: { - const body = stripRequestTag(request.body); - const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body); - return { keybindings: keybindingsConfig, issues: [] }; - } - - default: { - const _exhaustiveCheck: never = request.body; - return yield* new RouteRequestError({ - message: `Unknown method: ${String(_exhaustiveCheck)}`, - }); - } - } + return yield* new RouteRequestError({ + message: `WebSocket method '${request.body._tag}' is now handled by RpcServer at /ws`, + }); }); const handleMessage = Effect.fnUntraced(function* (ws: WebSocket, raw: unknown) { From 84c6b51f7c3a28a7f51a4f555b6de19250e176f6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Mar 2026 10:57:17 -0700 Subject: [PATCH 17/47] add schemas for streaming rpcs --- .plans/ws-rpc-endpoint-port-plan.md | 88 +++++++++++++++++++++++++++-- packages/contracts/src/ws.ts | 19 +++++++ packages/contracts/src/wsRpc.ts | 45 ++++++++++++++- 3 files changed, 146 insertions(+), 6 deletions(-) diff --git a/.plans/ws-rpc-endpoint-port-plan.md b/.plans/ws-rpc-endpoint-port-plan.md index cb96c594d9..3119627f2e 100644 --- a/.plans/ws-rpc-endpoint-port-plan.md +++ b/.plans/ws-rpc-endpoint-port-plan.md @@ -71,7 +71,87 @@ Legend: `[x]` done, `[ ]` not started. - [x] `orchestration.getFullThreadDiff` - [x] `orchestration.replayEvents` -## Notes - -- This plan tracks request/response RPC methods only. -- Push/event channels (`terminal.event`, `server.welcome`, `server.configUpdated`, `orchestration.domainEvent`) stay in the existing event pipeline until a dedicated push-channel migration plan is created. +### Phase 6: Streaming subscriptions via RPC (replace push-channel bridge) + +- [x] Define streaming RPC contracts for all server-driven event surfaces (reference pattern: `subscribeTodos`): + - [ ] `subscribeOrchestrationDomainEvents` + - [ ] `subscribeTerminalEvents` + - [ ] `subscribeServerConfigUpdates` + - [ ] `subscribeServerLifecycle` (welcome/readiness/bootstrap updates) +- [ ] Add stream payload schemas in `packages/contracts` with narrow tagged unions where needed. + - [ ] Include explicit event versioning strategy (`version` or schema evolution note). + - [ ] Ensure payload shape parity with existing `WS_CHANNELS` semantics. +- [ ] Implement streaming handlers in `apps/server/src/ws.ts` using `Effect.Stream`. + - [ ] Wire each stream to the correct source service/event bus. + - [ ] Preserve ordering guarantees where currently expected. + - [ ] Preserve filtering/scoping rules (thread/session/worktree as applicable). +- [ ] Prove one full vertical slice first (recommended: terminal events), then fan out. + - [ ] Contract + handler + client consumer. + - [ ] Integration test: subscribe, receive at least one item, unsubscribe/interrupt cleanly. +- [ ] Subscription lifecycle semantics (must match or improve current behavior): + - [ ] reconnect + resubscribe behavior + - [ ] duplicate subscription protection on reconnect + - [ ] cancellation/unsubscribe finalizers + - [ ] cleanup when socket closes unexpectedly +- [ ] Reliability semantics: + - [ ] document and enforce backpressure strategy (buffer cap, drop policy, or disconnect) + - [ ] clarify delivery semantics (best-effort vs at-least-once) for each stream + - [ ] add metrics/logging for dropped/failed deliveries +- [ ] Security/auth parity: + - [ ] apply same auth gating as request/response RPC path + - [ ] enforce per-stream permission checks +- [ ] After parity, remove legacy push-channel publish paths and old envelope code paths for migrated streams. + +### Phase 7: Server startup/runtime side effects (move lifecycle out of legacy wsServer) + +- [ ] Move startup orchestration from `wsServer.ts` into layer-based runtime composition. + - [ ] keybindings startup + default sync behavior + - [ ] orchestration reactor startup + - [ ] terminal stream subscription lifecycle + - [ ] orchestration stream subscription lifecycle +- [ ] Move startup UX/ops side effects: + - [ ] open-in-browser behavior + - [ ] startup heartbeat analytics + - [ ] startup logs payload parity + - [ ] optional auto-bootstrap project/thread from cwd +- [ ] Preserve readiness and failure semantics: + - [ ] readiness gates for required subsystems + - [ ] startup failure behavior and error messages + - [ ] startup ordering guarantees and retry policy (if any) +- [ ] Preserve shutdown semantics: + - [ ] finalizers/unsubscribe behavior + - [ ] ws server close behavior + - [ ] in-flight stream cancellation handling +- [ ] Add lifecycle-focused integration tests (startup happy path + failure path + shutdown cleanup). + +### Phase 8: Client migration (full surface) + +- [ ] Migrate web client transport in `apps/web/src/ws.ts` to consume RPC contracts directly. + - [ ] Decide transport approach (custom adapter vs Effect `RpcClient`) and lock one path. +- [ ] Request/response parity migration: + - [ ] replace legacy websocket envelope call helpers with typed RPC client calls + - [ ] ensure domain-specific error decoding/parsing parity +- [ ] Streaming parity migration: + - [ ] consume new streaming RPC subscriptions for all migrated channels + - [ ] implement reconnect + resubscribe strategy + - [ ] enforce unsubscribe on route/session teardown +- [ ] UX behavior parity: + - [ ] loading/connected/disconnected state transitions + - [ ] terminal/orchestration live updates timing and ordering + - [ ] welcome/bootstrap/config update behavior +- [ ] Client tests: + - [ ] integration coverage for request calls + - [ ] subscription lifecycle tests (connect, receive, reconnect, teardown) + +### Phase 9: Final cleanup + deprecation removal + +- [ ] Delete legacy `wsServer.ts` transport path once server+client parity is proven. +- [ ] Remove old shared protocol artifacts no longer needed: + - [ ] legacy `WS_CHANNELS` usage + - [ ] legacy ws envelope request/response codecs where obsolete + - [ ] dead helpers/services only used by legacy transport path +- [ ] Run parity audit checklist before deletion: + - [ ] every old method mapped to RPC equivalent + - [ ] every old push channel mapped to streaming RPC equivalent + - [ ] auth/error/ordering semantics verified +- [ ] Add migration note/changelog entry for downstream consumers (if any). \ No newline at end of file diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index ebb76138b8..955c34abbc 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -75,6 +75,12 @@ export const WS_METHODS = { // Server meta serverGetConfig: "server.getConfig", serverUpsertKeybinding: "server.upsertKeybinding", + + // Streaming subscriptions + subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents", + subscribeTerminalEvents: "subscribeTerminalEvents", + subscribeServerConfigUpdates: "subscribeServerConfigUpdates", + subscribeServerLifecycle: "subscribeServerLifecycle", } as const; // ── Push Event Channels ────────────────────────────────────────────── @@ -227,6 +233,19 @@ export const WsPushEnvelopeBase = Schema.Struct({ }); export type WsPushEnvelopeBase = typeof WsPushEnvelopeBase.Type; +export const SubscribeOrchestrationDomainEventsInput = Schema.Struct({}); +export type SubscribeOrchestrationDomainEventsInput = + typeof SubscribeOrchestrationDomainEventsInput.Type; + +export const SubscribeTerminalEventsInput = Schema.Struct({}); +export type SubscribeTerminalEventsInput = typeof SubscribeTerminalEventsInput.Type; + +export const SubscribeServerConfigUpdatesInput = Schema.Struct({}); +export type SubscribeServerConfigUpdatesInput = typeof SubscribeServerConfigUpdatesInput.Type; + +export const SubscribeServerLifecycleInput = Schema.Struct({}); +export type SubscribeServerLifecycleInput = typeof SubscribeServerLifecycleInput.Type; + // ── Union of all server → client messages ───────────────────────────── export const WsResponse = Schema.Union([WebSocketResponse, WsPush]); diff --git a/packages/contracts/src/wsRpc.ts b/packages/contracts/src/wsRpc.ts index dfeb06245c..69713959fc 100644 --- a/packages/contracts/src/wsRpc.ts +++ b/packages/contracts/src/wsRpc.ts @@ -27,6 +27,7 @@ import { import { KeybindingsConfigError } from "./keybindings"; import { ClientOrchestrationCommand, + OrchestrationEvent, ORCHESTRATION_WS_METHODS, OrchestrationDispatchCommandError, OrchestrationGetFullThreadDiffError, @@ -47,18 +48,31 @@ import { ProjectWriteFileInput, ProjectWriteFileResult, } from "./project"; -import { ServerConfig, ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; import { TerminalClearInput, TerminalCloseInput, TerminalError, + TerminalEvent, TerminalOpenInput, TerminalResizeInput, TerminalRestartInput, TerminalSessionSnapshot, TerminalWriteInput, } from "./terminal"; -import { WS_METHODS } from "./ws"; +import { + ServerConfigUpdatedPayload, + ServerConfig, + ServerUpsertKeybindingInput, + ServerUpsertKeybindingResult, +} from "./server"; +import { + SubscribeOrchestrationDomainEventsInput, + SubscribeServerConfigUpdatesInput, + SubscribeServerLifecycleInput, + SubscribeTerminalEventsInput, + WS_METHODS, + WsWelcomePayload, +} from "./ws"; export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { success: ServerConfig, @@ -218,6 +232,33 @@ export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS. error: OrchestrationReplayEventsError, }); +export const WsSubscribeOrchestrationDomainEventsRpc = Rpc.make( + WS_METHODS.subscribeOrchestrationDomainEvents, + { + payload: SubscribeOrchestrationDomainEventsInput, + success: OrchestrationEvent, + stream: true, + }, +); + +export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, { + payload: SubscribeTerminalEventsInput, + success: TerminalEvent, + stream: true, +}); + +export const WsSubscribeServerConfigUpdatesRpc = Rpc.make(WS_METHODS.subscribeServerConfigUpdates, { + payload: SubscribeServerConfigUpdatesInput, + success: ServerConfigUpdatedPayload, + stream: true, +}); + +export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, { + payload: SubscribeServerLifecycleInput, + success: WsWelcomePayload, + stream: true, +}); + export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerUpsertKeybindingRpc, From 95650de9f5e051da62fe571297cc99ed16f9bd9a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Mar 2026 13:35:56 -0700 Subject: [PATCH 18/47] streaming server config --- .plans/ws-rpc-endpoint-port-plan.md | 18 +++-- apps/server/src/server.test.ts | 119 +++++++++++++++++++--------- apps/server/src/server.ts | 25 +++++- apps/server/src/ws.ts | 77 +++++++++++++----- packages/contracts/src/server.ts | 38 +++++++++ packages/contracts/src/ws.ts | 4 + packages/contracts/src/wsRpc.ts | 20 ++--- 7 files changed, 227 insertions(+), 74 deletions(-) diff --git a/.plans/ws-rpc-endpoint-port-plan.md b/.plans/ws-rpc-endpoint-port-plan.md index 3119627f2e..6f8608345a 100644 --- a/.plans/ws-rpc-endpoint-port-plan.md +++ b/.plans/ws-rpc-endpoint-port-plan.md @@ -31,7 +31,7 @@ Legend: `[x]` done, `[ ]` not started. ### Phase 1: Server metadata (smallest surface) -- [x] `server.getConfig` +- [x] `server.getConfig` (now retired in favor of `subscribeServerConfig` snapshot-first stream) - [x] `server.upsertKeybinding` ### Phase 2: Project + editor read/write (small inputs, bounded side effects) @@ -75,19 +75,25 @@ Legend: `[x]` done, `[ ]` not started. - [x] Define streaming RPC contracts for all server-driven event surfaces (reference pattern: `subscribeTodos`): - [ ] `subscribeOrchestrationDomainEvents` - - [ ] `subscribeTerminalEvents` - - [ ] `subscribeServerConfigUpdates` + - [x] `subscribeTerminalEvents` + - [x] `subscribeServerConfig` (snapshot + keybindings updates + provider status heartbeat) - [ ] `subscribeServerLifecycle` (welcome/readiness/bootstrap updates) - [ ] Add stream payload schemas in `packages/contracts` with narrow tagged unions where needed. - [ ] Include explicit event versioning strategy (`version` or schema evolution note). - [ ] Ensure payload shape parity with existing `WS_CHANNELS` semantics. - [ ] Implement streaming handlers in `apps/server/src/ws.ts` using `Effect.Stream`. - - [ ] Wire each stream to the correct source service/event bus. + - [x] Wire first stream (`subscribeTerminalEvents`) to the correct source service/event bus. + - [x] Wire `subscribeServerConfig` to emit snapshot first, then live updates. - [ ] Preserve ordering guarantees where currently expected. - [ ] Preserve filtering/scoping rules (thread/session/worktree as applicable). - [ ] Prove one full vertical slice first (recommended: terminal events), then fan out. - - [ ] Contract + handler + client consumer. - - [ ] Integration test: subscribe, receive at least one item, unsubscribe/interrupt cleanly. + - [x] Contract + handler + client consumer. + - [x] Integration test: subscribe, receive at least one item, unsubscribe/interrupt cleanly. + - [x] Integration test: `subscribeServerConfig` emits initial snapshot and update event. + - [x] Integration test: provider-status heartbeat verified with Effect `TestClock.adjust`. +- [x] Remove superseded server-config RPCs that are now covered by stream semantics. + - [x] Remove `server.getConfig`. + - [x] Remove `subscribeServerConfigUpdates`. - [ ] Subscription lifecycle semantics (must match or improve current behavior): - [ ] reconnect + resubscribe behavior - [ ] duplicate subscription protection on reconnect diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 5b8ef4e8e6..029ba54a35 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -16,7 +16,8 @@ import { } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; -import { Effect, FileSystem, Layer, Path, Stream } from "effect"; +import { Deferred, Effect, Fiber, FileSystem, Layer, Path, Stream } from "effect"; +import { TestClock } from "effect/testing"; import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; @@ -30,7 +31,7 @@ import { } from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts"; import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; -import { Keybindings, KeybindingsConfigError, type KeybindingsShape } from "./keybindings.ts"; +import { Keybindings, type KeybindingsShape } from "./keybindings.ts"; import { Open, type OpenShape } from "./open.ts"; import { OrchestrationEngineService, @@ -303,31 +304,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("routes websocket rpc server.getConfig", () => - Effect.gen(function* () { - yield* buildAppUnderTest({ - layers: { - keybindings: { - loadConfigState: Effect.succeed({ - keybindings: [], - issues: [], - }), - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const response = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]()), - ); - - assert.equal(response.cwd, process.cwd()); - assert.deepEqual(response.keybindings, []); - assert.deepEqual(response.issues, []); - assert.deepEqual(response.providers, []); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("routes websocket rpc server.upsertKeybinding", () => Effect.gen(function* () { const rule: KeybindingRule = { @@ -364,31 +340,100 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("routes websocket rpc server.getConfig errors", () => + it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => Effect.gen(function* () { - const error = new KeybindingsConfigError({ - configPath: "/tmp/keybindings.json", - detail: "expected JSON array", - }); + const providers = [] as const; + const changeEvent = { + keybindings: [], + issues: [], + } as const; + yield* buildAppUnderTest({ layers: { keybindings: { - loadConfigState: Effect.fail(error), + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), + streamChanges: Stream.succeed(changeEvent), + }, + providerHealth: { + getStatuses: Effect.succeed(providers), }, }, }); const wsUrl = yield* getWsServerUrl("/ws"); - const result = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]()).pipe( - Effect.result, + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), ), ); - assertFailure(result, error); + const [first, second] = Array.from(events); + assert.equal(first?.type, "snapshot"); + if (first?.type === "snapshot") { + assert.deepEqual(first.config.keybindings, []); + assert.deepEqual(first.config.issues, []); + assert.deepEqual(first.config.providers, providers); + } + assert.deepEqual(second, { + type: "keybindingsUpdated", + payload: { issues: [] }, + }); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc subscribeServerConfig emits providerStatuses heartbeat", () => + Effect.gen(function* () { + const providers = [] as const; + + yield* buildAppUnderTest({ + layers: { + keybindings: { + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), + streamChanges: Stream.empty, + }, + providerHealth: { + getStatuses: Effect.succeed(providers), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + Effect.gen(function* () { + const snapshotReceived = yield* Deferred.make(); + const eventsFiber = yield* withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({}).pipe( + Stream.tap((event) => + event.type === "snapshot" + ? Deferred.succeed(snapshotReceived, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Stream.take(2), + Stream.runCollect, + ), + ).pipe(Effect.forkScoped); + + yield* Deferred.await(snapshotReceived); + yield* TestClock.adjust("10 seconds"); + return yield* Fiber.join(eventsFiber); + }), + ); + + const [first, second] = Array.from(events); + assert.equal(first?.type, "snapshot"); + assert.deepEqual(second, { + type: "providerStatuses", + payload: { providers }, + }); + }).pipe(Effect.provide(Layer.mergeAll(NodeHttpServer.layerTest, TestClock.layer()))), + ); + it.effect("routes websocket rpc projects.searchEntries", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index de4535a311..fa11dac273 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -8,7 +8,27 @@ import { HttpRouter } from "effect/unstable/http"; import { ServerConfig } from "./config"; import { attachmentsRouteLayer, healthRouteLayer, staticAndDevRouteLayer } from "./http"; import { fixPath } from "./os-jank"; +import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY"; +import { TerminalManagerLive } from "./terminal/Layers/Manager"; +import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY"; import { websocketRpcRouteLayer } from "./ws"; +import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; +import { KeybindingsLive } from "./keybindings"; + +const terminalManagerLayer = TerminalManagerLive.pipe( + Layer.provide( + typeof Bun !== "undefined" && process.platform !== "win32" + ? BunPtyAdapterLive + : NodePtyAdapterLive, + ), +); + +const runtimeServicesLayer = Layer.mergeAll( + terminalManagerLayer, + ProviderHealthLive, + KeybindingsLive, + /// other runtime services +); export const makeRoutesLayer = Layer.mergeAll( healthRouteLayer, @@ -26,7 +46,10 @@ export const makeServerLayer = Layer.unwrap( yield* Effect.sync(fixPath); return HttpRouter.serve(makeRoutesLayer, { disableLogger: !config.logWebSocketEvents, - }).pipe(Layer.provide(NodeHttpServer.layer(Http.createServer, listenOptions))); + }).pipe( + Layer.provide(runtimeServicesLayer), + Layer.provide(NodeHttpServer.layer(Http.createServer, listenOptions)), + ); }), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7b34971ab0..af246686df 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Layer, Path, Schema, Stream } from "effect"; +import { Effect, FileSystem, Layer, Path, Schema, Stream, PubSub } from "effect"; import { OrchestrationDispatchCommandError, OrchestrationGetFullThreadDiffError, @@ -8,6 +8,7 @@ import { ProjectSearchEntriesError, ProjectWriteFileError, OrchestrationReplayEventsError, + type TerminalEvent, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; @@ -99,23 +100,6 @@ const WsRpcLayer = WsRpcGroup.toLayer({ }), ), ), - [WS_METHODS.serverGetConfig]: () => - Effect.gen(function* () { - const config = yield* ServerConfig; - const keybindings = yield* Keybindings; - const providerHealth = yield* ProviderHealth; - const keybindingsConfig = yield* keybindings.loadConfigState; - const providers = yield* providerHealth.getStatuses; - - return { - cwd: config.cwd, - keybindingsConfigPath: config.keybindingsConfigPath, - keybindings: keybindingsConfig.keybindings, - issues: keybindingsConfig.issues, - providers, - availableEditors: resolveAvailableEditors(), - }; - }), [WS_METHODS.serverUpsertKeybinding]: (rule) => Effect.gen(function* () { const keybindings = yield* Keybindings; @@ -249,6 +233,63 @@ const WsRpcLayer = WsRpcGroup.toLayer({ const terminalManager = yield* TerminalManager; return yield* terminalManager.close(input); }), + [WS_METHODS.subscribeTerminalEvents]: (_input) => + Stream.unwrap( + Effect.gen(function* () { + const terminalManager = yield* TerminalManager; + const pubsub = yield* PubSub.unbounded(); + const unsubscribe = yield* terminalManager.subscribe((event) => { + PubSub.publishUnsafe(pubsub, event); + }); + return Stream.fromPubSub(pubsub).pipe(Stream.ensuring(Effect.sync(() => unsubscribe()))); + }), + ), + [WS_METHODS.subscribeServerConfig]: (_input) => + Stream.unwrap( + Effect.gen(function* () { + const keybindings = yield* Keybindings; + const providerHealth = yield* ProviderHealth; + const config = yield* ServerConfig; + const keybindingsConfig = yield* keybindings.loadConfigState; + const providers = yield* providerHealth.getStatuses; + + const keybindingsUpdates = keybindings.streamChanges.pipe( + Stream.mapEffect((event) => + Effect.succeed({ + type: "keybindingsUpdated" as const, + payload: { + issues: event.issues, + }, + }), + ), + ); + const providerStatuses = Stream.tick("10 seconds").pipe( + Stream.mapEffect(() => + Effect.gen(function* () { + const providers = yield* providerHealth.getStatuses; + return { + type: "providerStatuses" as const, + payload: { providers }, + }; + }), + ), + ); + return Stream.concat( + Stream.make({ + type: "snapshot" as const, + config: { + cwd: config.cwd, + keybindingsConfigPath: config.keybindingsConfigPath, + keybindings: keybindingsConfig.keybindings, + issues: keybindingsConfig.issues, + providers, + availableEditors: resolveAvailableEditors(), + }, + }), + Stream.merge(keybindingsUpdates, providerStatuses), + ); + }), + ), }); export const websocketRpcRouteLayer = RpcServer.layerHttp({ diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 96ea90c1f5..99ceb45bb9 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -69,3 +69,41 @@ export const ServerConfigUpdatedPayload = Schema.Struct({ providers: ServerProviderStatuses, }); export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type; + +export const ServerConfigKeybindingsUpdatedPayload = Schema.Struct({ + issues: ServerConfigIssues, +}); +export type ServerConfigKeybindingsUpdatedPayload = + typeof ServerConfigKeybindingsUpdatedPayload.Type; + +export const ServerConfigProviderStatusesPayload = Schema.Struct({ + providers: ServerProviderStatuses, +}); +export type ServerConfigProviderStatusesPayload = typeof ServerConfigProviderStatusesPayload.Type; + +export const ServerConfigStreamSnapshotEvent = Schema.Struct({ + type: Schema.Literal("snapshot"), + config: ServerConfig, +}); +export type ServerConfigStreamSnapshotEvent = typeof ServerConfigStreamSnapshotEvent.Type; + +export const ServerConfigStreamKeybindingsUpdatedEvent = Schema.Struct({ + type: Schema.Literal("keybindingsUpdated"), + payload: ServerConfigKeybindingsUpdatedPayload, +}); +export type ServerConfigStreamKeybindingsUpdatedEvent = + typeof ServerConfigStreamKeybindingsUpdatedEvent.Type; + +export const ServerConfigStreamProviderStatusesEvent = Schema.Struct({ + type: Schema.Literal("providerStatuses"), + payload: ServerConfigProviderStatusesPayload, +}); +export type ServerConfigStreamProviderStatusesEvent = + typeof ServerConfigStreamProviderStatusesEvent.Type; + +export const ServerConfigStreamEvent = Schema.Union([ + ServerConfigStreamSnapshotEvent, + ServerConfigStreamKeybindingsUpdatedEvent, + ServerConfigStreamProviderStatusesEvent, +]); +export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 955c34abbc..58c4375107 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -79,6 +79,7 @@ export const WS_METHODS = { // Streaming subscriptions subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents", subscribeTerminalEvents: "subscribeTerminalEvents", + subscribeServerConfig: "subscribeServerConfig", subscribeServerConfigUpdates: "subscribeServerConfigUpdates", subscribeServerLifecycle: "subscribeServerLifecycle", } as const; @@ -240,6 +241,9 @@ export type SubscribeOrchestrationDomainEventsInput = export const SubscribeTerminalEventsInput = Schema.Struct({}); export type SubscribeTerminalEventsInput = typeof SubscribeTerminalEventsInput.Type; +export const SubscribeServerConfigInput = Schema.Struct({}); +export type SubscribeServerConfigInput = typeof SubscribeServerConfigInput.Type; + export const SubscribeServerConfigUpdatesInput = Schema.Struct({}); export type SubscribeServerConfigUpdatesInput = typeof SubscribeServerConfigUpdatesInput.Type; diff --git a/packages/contracts/src/wsRpc.ts b/packages/contracts/src/wsRpc.ts index 69713959fc..558af9e0b2 100644 --- a/packages/contracts/src/wsRpc.ts +++ b/packages/contracts/src/wsRpc.ts @@ -60,25 +60,19 @@ import { TerminalWriteInput, } from "./terminal"; import { - ServerConfigUpdatedPayload, - ServerConfig, + ServerConfigStreamEvent, ServerUpsertKeybindingInput, ServerUpsertKeybindingResult, } from "./server"; import { SubscribeOrchestrationDomainEventsInput, - SubscribeServerConfigUpdatesInput, + SubscribeServerConfigInput, SubscribeServerLifecycleInput, SubscribeTerminalEventsInput, WS_METHODS, WsWelcomePayload, } from "./ws"; -export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { - success: ServerConfig, - error: KeybindingsConfigError, -}); - export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { payload: ServerUpsertKeybindingInput, success: ServerUpsertKeybindingResult, @@ -247,9 +241,10 @@ export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTermina stream: true, }); -export const WsSubscribeServerConfigUpdatesRpc = Rpc.make(WS_METHODS.subscribeServerConfigUpdates, { - payload: SubscribeServerConfigUpdatesInput, - success: ServerConfigUpdatedPayload, +export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, { + payload: SubscribeServerConfigInput, + success: ServerConfigStreamEvent, + error: KeybindingsConfigError, stream: true, }); @@ -260,7 +255,6 @@ export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServer }); export const WsRpcGroup = RpcGroup.make( - WsServerGetConfigRpc, WsServerUpsertKeybindingRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, @@ -282,6 +276,8 @@ export const WsRpcGroup = RpcGroup.make( WsTerminalClearRpc, WsTerminalRestartRpc, WsTerminalCloseRpc, + WsSubscribeTerminalEventsRpc, + WsSubscribeServerConfigRpc, WsOrchestrationGetSnapshotRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetTurnDiffRpc, From c0788c0841b1f4aaa410bbac8a9b8aae8f28c8f9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Mar 2026 12:45:42 -0700 Subject: [PATCH 19/47] remaining --- .plans/ws-rpc-endpoint-port-plan.md | 70 ++- apps/server/src/cli-config.test.ts | 54 +- apps/server/src/cli.test.ts | 37 ++ apps/server/src/cli.ts | 20 +- apps/server/src/config.ts | 4 +- apps/server/src/http.ts | 10 +- .../Layers/OrchestrationEngine.ts | 2 +- .../Layers/ProjectionPipeline.ts | 2 +- apps/server/src/server.test.ts | 200 ++++++- apps/server/src/server.ts | 158 ++++- apps/server/src/serverLayers.ts | 141 ----- apps/server/src/serverLifecycleEvents.test.ts | 42 ++ apps/server/src/serverLifecycleEvents.ts | 53 ++ apps/server/src/serverLogger.test.ts | 47 ++ apps/server/src/serverLogger.ts | 11 +- apps/server/src/serverRuntimeStartup.test.ts | 49 ++ apps/server/src/serverRuntimeStartup.ts | 313 ++++++++++ apps/server/src/ws.ts | 549 +++++++++--------- bun.lock | 21 +- package.json | 8 +- packages/contracts/src/server.ts | 43 +- packages/contracts/src/wsRpc.ts | 6 +- 22 files changed, 1356 insertions(+), 484 deletions(-) create mode 100644 apps/server/src/cli.test.ts delete mode 100644 apps/server/src/serverLayers.ts create mode 100644 apps/server/src/serverLifecycleEvents.test.ts create mode 100644 apps/server/src/serverLifecycleEvents.ts create mode 100644 apps/server/src/serverLogger.test.ts create mode 100644 apps/server/src/serverRuntimeStartup.test.ts create mode 100644 apps/server/src/serverRuntimeStartup.ts diff --git a/.plans/ws-rpc-endpoint-port-plan.md b/.plans/ws-rpc-endpoint-port-plan.md index 6f8608345a..623dc61972 100644 --- a/.plans/ws-rpc-endpoint-port-plan.md +++ b/.plans/ws-rpc-endpoint-port-plan.md @@ -25,6 +25,60 @@ Incrementally migrate WebSocket request handling from `apps/server/src/wsServer. - Run `bun run test` (targeted), then `bun fmt`, `bun lint`, `bun typecheck`. - Only proceed to next endpoint when checks are green. +## Runtime Parity Priorities + +Use this as the execution order for the remainder of the migration. + +### P0: Full-stack flow working (unblock app runtime) + +- [ ] Wire startup side effects into live runtime composition. + - [ ] Ensure `serverRuntimeStartupLayer` is provided by `makeServerLayer`. + - [ ] Verify startup runs keybindings sync/start, orchestration reactor startup, lifecycle publish, startup heartbeat, optional browser open. +- [x] Add WebSocket auth parity on `/ws` route. + - [x] Enforce token gate equivalent to old `wsServer` behavior when `authToken` is configured. + - [x] Add integration tests for authorized and unauthorized websocket connections. +- [ ] Migrate web app critical live transport paths to RPC streams. + - [ ] `subscribeServerLifecycle` + - [ ] `subscribeOrchestrationDomainEvents` + - [ ] `subscribeTerminalEvents` + - [ ] `subscribeServerConfig` +- [ ] Implement reconnect + resubscribe MVP in web transport. + - [ ] Recover stream subscriptions after websocket reconnect. + - [ ] Avoid duplicate active subscriptions after reconnect. +- [ ] Add one end-to-end smoke proving boot-to-interaction. + - [ ] Server starts and lifecycle welcome/ready is observed. + - [ ] Client can dispatch orchestration command and receive domain events. + - [ ] Terminal write + terminal stream updates are observed. + +### P1: Behavior parity and reliability semantics + +- [ ] Remove remaining required runtime behavior from legacy `wsServer.ts`. +- [ ] Readiness semantics parity. + - [ ] Define and enforce when runtime is considered "ready". + - [ ] Align lifecycle event timing with readiness expectations. +- [ ] Stream behavior parity on current subscriptions. + - [ ] Confirm `subscribeServerConfig` timing and event shape parity for UI expectations. + - [ ] Confirm multi-client terminal stream behavior under concurrent writes. +- [ ] Delivery semantics documentation per stream. + - [ ] Ordering guarantees. + - [ ] Replay/catch-up behavior. + - [ ] At-most-once/best-effort contract. + +### P2: Cleanup + hardening + +- [ ] Backpressure policy per stream. + - [ ] Define buffer caps. + - [ ] Define drop/disconnect policy. +- [ ] Observability for stream reliability. + - [ ] Metrics/logging for dropped deliveries and subscriber failures. + - [ ] Reconnect churn visibility. +- [ ] Security hardening parity. + - [ ] Per-stream permission checks. +- [ ] Delete deprecated legacy transport artifacts once parity is proven. + - [ ] legacy `WS_CHANNELS` usage in active web transport. + - [ ] old ws envelope request/response codecs where obsolete. + - [ ] dead helpers/services only used by legacy transport path. + ## Ordered Endpoint Checklist Legend: `[x]` done, `[ ]` not started. @@ -74,12 +128,12 @@ Legend: `[x]` done, `[ ]` not started. ### Phase 6: Streaming subscriptions via RPC (replace push-channel bridge) - [x] Define streaming RPC contracts for all server-driven event surfaces (reference pattern: `subscribeTodos`): - - [ ] `subscribeOrchestrationDomainEvents` + - [x] `subscribeOrchestrationDomainEvents` - [x] `subscribeTerminalEvents` - [x] `subscribeServerConfig` (snapshot + keybindings updates + provider status heartbeat) - - [ ] `subscribeServerLifecycle` (welcome/readiness/bootstrap updates) + - [x] `subscribeServerLifecycle` (welcome/readiness/bootstrap updates) - [ ] Add stream payload schemas in `packages/contracts` with narrow tagged unions where needed. - - [ ] Include explicit event versioning strategy (`version` or schema evolution note). + - [x] Include explicit event versioning strategy (`version` or schema evolution note). - [ ] Ensure payload shape parity with existing `WS_CHANNELS` semantics. - [ ] Implement streaming handlers in `apps/server/src/ws.ts` using `Effect.Stream`. - [x] Wire first stream (`subscribeTerminalEvents`) to the correct source service/event bus. @@ -111,15 +165,15 @@ Legend: `[x]` done, `[ ]` not started. ### Phase 7: Server startup/runtime side effects (move lifecycle out of legacy wsServer) - [ ] Move startup orchestration from `wsServer.ts` into layer-based runtime composition. - - [ ] keybindings startup + default sync behavior - - [ ] orchestration reactor startup + - [x] keybindings startup + default sync behavior + - [x] orchestration reactor startup - [ ] terminal stream subscription lifecycle - [ ] orchestration stream subscription lifecycle - [ ] Move startup UX/ops side effects: - - [ ] open-in-browser behavior - - [ ] startup heartbeat analytics + - [x] open-in-browser behavior + - [x] startup heartbeat analytics - [ ] startup logs payload parity - - [ ] optional auto-bootstrap project/thread from cwd + - [x] optional auto-bootstrap project/thread from cwd - [ ] Preserve readiness and failure semantics: - [ ] readiness gates for required subsystems - [ ] startup failure behavior and error messages diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts index e309f98da7..b11f62724a 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli-config.test.ts @@ -12,22 +12,26 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { Effect.gen(function* () { const { join } = yield* Path.Path; const stateDir = join(os.tmpdir(), "t3-cli-config-env-state"); - const resolved = yield* resolveServerConfig({ - mode: Option.none(), - port: Option.none(), - host: Option.none(), - stateDir: Option.none(), - devUrl: Option.none(), - noBrowser: Option.none(), - authToken: Option.none(), - autoBootstrapProjectFromCwd: Option.none(), - logWebSocketEvents: Option.none(), - }).pipe( + const resolved = yield* resolveServerConfig( + { + mode: Option.none(), + port: Option.none(), + host: Option.none(), + stateDir: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + authToken: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + }, + Option.none(), + ).pipe( Effect.provide( Layer.mergeAll( ConfigProvider.layer( ConfigProvider.fromEnv({ env: { + T3CODE_LOG_LEVEL: "Warn", T3CODE_MODE: "desktop", T3CODE_PORT: "4001", T3CODE_HOST: "0.0.0.0", @@ -46,6 +50,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ); expect(resolved).toEqual({ + logLevel: "Warn", mode: "desktop", port: 4001, cwd: process.cwd(), @@ -66,22 +71,26 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { Effect.gen(function* () { const { join } = yield* Path.Path; const stateDir = join(os.tmpdir(), "t3-cli-config-flags-state"); - const resolved = yield* resolveServerConfig({ - mode: Option.some("web"), - port: Option.some(8788), - host: Option.some("127.0.0.1"), - stateDir: Option.some(stateDir), - devUrl: Option.some(new URL("http://127.0.0.1:4173")), - noBrowser: Option.some(true), - authToken: Option.some("flag-token"), - autoBootstrapProjectFromCwd: Option.some(true), - logWebSocketEvents: Option.some(true), - }).pipe( + const resolved = yield* resolveServerConfig( + { + mode: Option.some("web"), + port: Option.some(8788), + host: Option.some("127.0.0.1"), + stateDir: Option.some(stateDir), + devUrl: Option.some(new URL("http://127.0.0.1:4173")), + noBrowser: Option.some(true), + authToken: Option.some("flag-token"), + autoBootstrapProjectFromCwd: Option.some(true), + logWebSocketEvents: Option.some(true), + }, + Option.some("Debug"), + ).pipe( Effect.provide( Layer.mergeAll( ConfigProvider.layer( ConfigProvider.fromEnv({ env: { + T3CODE_LOG_LEVEL: "Warn", T3CODE_MODE: "desktop", T3CODE_PORT: "4001", T3CODE_HOST: "0.0.0.0", @@ -100,6 +109,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ); expect(resolved).toEqual({ + logLevel: "Debug", mode: "web", port: 8788, cwd: process.cwd(), diff --git a/apps/server/src/cli.test.ts b/apps/server/src/cli.test.ts new file mode 100644 index 0000000000..bc44ab16a5 --- /dev/null +++ b/apps/server/src/cli.test.ts @@ -0,0 +1,37 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { NetService } from "@t3tools/shared/Net"; +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as CliError from "effect/unstable/cli/CliError"; +import { Command } from "effect/unstable/cli"; + +import { cli } from "./cli.ts"; + +const provideCliRuntime = (effect: Effect.Effect) => + effect.pipe(Effect.provide(Layer.mergeAll(NetService.layer, NodeServices.layer))); + +it.layer(NodeServices.layer)("cli log-level parsing", (it) => { + it.effect("accepts the built-in lowercase log-level flag values", () => + Command.runWith(cli, { version: "0.0.0" })(["--log-level", "debug", "--version"]).pipe( + provideCliRuntime, + ), + ); + + it.effect("rejects invalid log-level casing before launching the server", () => + Effect.gen(function* () { + const error = yield* Command.runWith(cli, { version: "0.0.0" })([ + "--log-level", + "Debug", + ]).pipe(provideCliRuntime, Effect.flip); + + if (!CliError.isCliError(error)) { + throw new Error(`Expected CliError, got ${String(error)}`); + } + if (error._tag !== "InvalidValue") { + throw new Error(`Expected InvalidValue, got ${error._tag}`); + } + assert.equal(error.option, "log-level"); + assert.equal(error.value, "Debug"); + }), + ); +}); diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 236b083bc0..bc67c1ae0e 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -1,6 +1,6 @@ import { NetService } from "@t3tools/shared/Net"; -import { Config, Effect, Option, Path, Schema } from "effect"; -import { Command, Flag } from "effect/unstable/cli"; +import { Config, Effect, LogLevel, Option, Path, Schema } from "effect"; +import { Command, Flag, GlobalFlag } from "effect/unstable/cli"; import { DEFAULT_PORT, @@ -58,6 +58,7 @@ const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( ); const EnvServerConfig = Config.all({ + logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), mode: Config.string("T3CODE_MODE").pipe( Config.option, Config.map( @@ -107,7 +108,10 @@ interface CliServerFlags { const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => Option.getOrElse(Option.filter(flag, Boolean), () => envValue); -export const resolveServerConfig = (flags: CliServerFlags) => +export const resolveServerConfig = ( + flags: CliServerFlags, + cliLogLevel: Option.Option, +) => Effect.gen(function* () { const { findAvailablePort } = yield* NetService; const env = yield* EnvServerConfig; @@ -145,8 +149,10 @@ export const resolveServerConfig = (flags: CliServerFlags) => Option.getOrUndefined(flags.host) ?? env.host ?? (mode === "desktop" ? "127.0.0.1" : undefined); + const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); const config: ServerConfigShape = { + logLevel, mode, port, cwd: process.cwd(), @@ -179,9 +185,11 @@ const commandFlags = { const rootCommand = Command.make("t3", commandFlags).pipe( Command.withDescription("Run the T3 Code server."), Command.withHandler((flags) => - Effect.flatMap(resolveServerConfig(flags), (config) => - runServer.pipe(Effect.provideService(ServerConfig, config)), - ), + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveServerConfig(flags, logLevel); + return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); + }), ), ); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 8553ce9667..2bfbf24c4b 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -6,7 +6,7 @@ * * @module ServerConfig */ -import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; +import { Effect, FileSystem, Layer, LogLevel, Path, ServiceMap } from "effect"; export const DEFAULT_PORT = 3773; @@ -33,6 +33,7 @@ export interface ServerDerivedPaths { * ServerConfigShape - Process/runtime configuration required by the server. */ export interface ServerConfigShape extends ServerDerivedPaths { + readonly logLevel: LogLevel.LogLevel; readonly mode: RuntimeMode; readonly port: number; readonly host: string | undefined; @@ -95,6 +96,7 @@ export class ServerConfig extends ServiceMap.Service - Effect.log("orchestration projection pipeline bootstrapped").pipe( + Effect.logDebug("orchestration projection pipeline bootstrapped").pipe( Effect.annotateLogs({ projectors: projectors.length }), ), ), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 029ba54a35..c74e0dbfb4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6,6 +6,7 @@ import { GitCommandError, KeybindingRule, OpenError, + type OrchestrationEvent, ORCHESTRATION_WS_METHODS, ProjectId, ResolvedKeybindingRule, @@ -43,6 +44,8 @@ import { } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth.ts"; +import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts"; +import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; const defaultProjectId = ProjectId.makeUnsafe("project-default"); @@ -101,6 +104,8 @@ const buildAppUnderTest = (options?: { orchestrationEngine?: Partial; projectionSnapshotQuery?: Partial; checkpointDiffQuery?: Partial; + serverLifecycleEvents?: Partial; + serverRuntimeStartup?: Partial; }; }) => Effect.gen(function* () { @@ -109,6 +114,7 @@ const buildAppUnderTest = (options?: { const tempStateDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" }); const stateDir = options?.config?.stateDir ?? tempStateDir; const layerConfig = Layer.succeed(ServerConfig, { + logLevel: "Info", mode: "web", port: 0, host: "127.0.0.1", @@ -122,7 +128,7 @@ const buildAppUnderTest = (options?: { autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, ...options?.config, - }); + } satisfies ServerConfigShape); const appLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, @@ -194,6 +200,22 @@ const buildAppUnderTest = (options?: { ...options?.layers?.checkpointDiffQuery, }), ), + Layer.provide( + Layer.mock(ServerLifecycleEvents)({ + publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }), + snapshot: Effect.succeed({ sequence: 0, events: [] }), + stream: Stream.empty, + ...options?.layers?.serverLifecycleEvents, + }), + ), + Layer.provide( + Layer.mock(ServerRuntimeStartup)({ + awaitCommandReady: Effect.void, + markHttpListening: Effect.void, + enqueueCommand: (effect) => effect, + ...options?.layers?.serverRuntimeStartup, + }), + ), Layer.provide(layerConfig), ); @@ -340,6 +362,70 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("rejects websocket rpc handshake when auth token is missing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-auth-required-" }); + yield* fs.writeFileString( + path.join(workspaceDir, "needle-file.ts"), + "export const needle = 1;", + ); + + yield* buildAppUnderTest({ + config: { + authToken: "secret-token", + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsSearchEntries]({ + cwd: workspaceDir, + query: "needle", + limit: 10, + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertInclude(String(result.failure), "SocketOpenError"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("accepts websocket rpc handshake when auth token is provided", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-auth-ok-" }); + yield* fs.writeFileString( + path.join(workspaceDir, "needle-file.ts"), + "export const needle = 1;", + ); + + yield* buildAppUnderTest({ + config: { + authToken: "secret-token", + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws?token=secret-token"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsSearchEntries]({ + cwd: workspaceDir, + query: "needle", + limit: 10, + }), + ), + ); + + assert.isAtLeast(response.entries.length, 1); + assert.equal(response.truncated, false); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => Effect.gen(function* () { const providers = [] as const; @@ -373,11 +459,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const [first, second] = Array.from(events); assert.equal(first?.type, "snapshot"); if (first?.type === "snapshot") { + assert.equal(first.version, 1); assert.deepEqual(first.config.keybindings, []); assert.deepEqual(first.config.issues, []); assert.deepEqual(first.config.providers, providers); } assert.deepEqual(second, { + version: 1, type: "keybindingsUpdated", payload: { issues: [] }, }); @@ -428,12 +516,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const [first, second] = Array.from(events); assert.equal(first?.type, "snapshot"); assert.deepEqual(second, { + version: 1, type: "providerStatuses", payload: { providers }, }); }).pipe(Effect.provide(Layer.mergeAll(NodeHttpServer.layerTest, TestClock.layer()))), ); + it.effect( + "routes websocket rpc subscribeServerLifecycle replays snapshot and streams updates", + () => + Effect.gen(function* () { + const lifecycleEvents = [ + { + version: 1 as const, + sequence: 1, + type: "welcome" as const, + payload: { + cwd: "/tmp/project", + projectName: "project", + }, + }, + ] as const; + const liveEvents = Stream.make({ + version: 1 as const, + sequence: 2, + type: "ready" as const, + payload: { at: new Date().toISOString() }, + }); + + yield* buildAppUnderTest({ + layers: { + serverLifecycleEvents: { + snapshot: Effect.succeed({ + sequence: 1, + events: lifecycleEvents, + }), + stream: liveEvents, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerLifecycle]({}).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + const [first, second] = Array.from(events); + assert.equal(first?.type, "welcome"); + assert.equal(first?.sequence, 1); + assert.equal(second?.type, "ready"); + assert.equal(second?.sequence, 2); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.searchEntries", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -916,6 +1054,66 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "routes websocket rpc subscribeOrchestrationDomainEvents with replay/live overlap resilience", + () => + Effect.gen(function* () { + const now = new Date().toISOString(); + const threadId = ThreadId.makeUnsafe("thread-1"); + let replayCursor: number | null = null; + const makeEvent = (sequence: number): OrchestrationEvent => + ({ + sequence, + eventId: `event-${sequence}`, + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.reverted", + payload: { + threadId, + turnCount: sequence, + }, + }) as OrchestrationEvent; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + getReadModel: () => + Effect.succeed({ + ...makeDefaultOrchestrationReadModel(), + snapshotSequence: 1, + }), + readEvents: (fromSequenceExclusive) => { + replayCursor = fromSequenceExclusive; + return Stream.make(makeEvent(2), makeEvent(3)); + }, + streamDomainEvents: Stream.make(makeEvent(3), makeEvent(4)), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( + Stream.take(3), + Stream.runCollect, + ), + ), + ); + + assert.equal(replayCursor, 1); + assert.deepEqual( + Array.from(events).map((event) => event.sequence), + [2, 3, 4], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc orchestration.getSnapshot errors", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index fa11dac273..6d6520b820 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,21 +1,111 @@ import * as Net from "node:net"; import * as Http from "node:http"; +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import { Effect, Layer } from "effect"; -import { HttpRouter } from "effect/unstable/http"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, Layer, Path } from "effect"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; import { ServerConfig } from "./config"; import { attachmentsRouteLayer, healthRouteLayer, staticAndDevRouteLayer } from "./http"; import { fixPath } from "./os-jank"; -import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY"; -import { TerminalManagerLive } from "./terminal/Layers/Manager"; -import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY"; import { websocketRpcRouteLayer } from "./ws"; import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; +import { OpenLive } from "./open"; +import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite"; +import { ServerLifecycleEventsLive } from "./serverLifecycleEvents"; +import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; +import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; +import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; +import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; +import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; +import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; +import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; +import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; +import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; +import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; +import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; +import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; +import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; +import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; +import { GitCoreLive } from "./git/Layers/GitCore"; +import { GitServiceLive } from "./git/Layers/GitService"; +import { GitHubCliLive } from "./git/Layers/GitHubCli"; +import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration"; +import { TerminalManagerLive } from "./terminal/Layers/Manager"; +import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY"; +import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY"; +import { GitManagerLive } from "./git/Layers/GitManager"; import { KeybindingsLive } from "./keybindings"; +import { ServerLoggerLive } from "./serverLogger"; +import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup"; +import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor"; +import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; +import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; +import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor"; +import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"; + +const ReactorLayerLive = Layer.empty.pipe( + Layer.provideMerge(OrchestrationReactorLive), + Layer.provideMerge(ProviderRuntimeIngestionLive), + Layer.provideMerge(ProviderCommandReactorLive), + Layer.provideMerge(CheckpointReactorLive), + Layer.provideMerge(RuntimeReceiptBusLive), +); + +const OrchestrationLayerLive = Layer.empty.pipe( + Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), + Layer.provideMerge(OrchestrationEngineLive), + Layer.provideMerge(OrchestrationProjectionPipelineLive), + Layer.provideMerge(OrchestrationEventStoreLive), + Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), +); + +const CheckpointingLayerLive = Layer.empty.pipe( + Layer.provideMerge(CheckpointDiffQueryLive), + Layer.provideMerge(CheckpointStoreLive), +); + +const ProviderLayerLive = Layer.unwrap( + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig; + const path = yield* Path.Path; + const providerLogsDir = path.join(stateDir, "logs", "provider"); + const providerEventLogPath = path.join(providerLogsDir, "events.log"); + const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { + stream: "native", + }); + const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { + stream: "canonical", + }); + const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntimeRepositoryLive), + ); + const codexAdapterLayer = makeCodexAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); + const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( + Layer.provide(codexAdapterLayer), + Layer.provideMerge(providerSessionDirectoryLayer), + ); + return makeProviderServiceLive( + canonicalEventLogger ? { canonicalEventLogger } : undefined, + ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); + }), +); + +const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); + +const GitLayerLive = Layer.empty.pipe( + Layer.provideMerge(GitManagerLive), + Layer.provideMerge(GitCoreLive), + Layer.provideMerge(GitServiceLive), + Layer.provideMerge(GitHubCliLive), + Layer.provideMerge(CodexTextGenerationLive), +); -const terminalManagerLayer = TerminalManagerLive.pipe( +const TerminalLayerLive = TerminalManagerLive.pipe( Layer.provide( typeof Bun !== "undefined" && process.platform !== "win32" ? BunPtyAdapterLive @@ -23,11 +113,24 @@ const terminalManagerLayer = TerminalManagerLive.pipe( ), ); -const runtimeServicesLayer = Layer.mergeAll( - terminalManagerLayer, - ProviderHealthLive, - KeybindingsLive, - /// other runtime services +const runtimeServicesLayer = Layer.empty.pipe( + Layer.provideMerge(ServerRuntimeStartupLive), + Layer.provideMerge(ReactorLayerLive), + + // Core Services + Layer.provideMerge(CheckpointingLayerLive), + Layer.provideMerge(OrchestrationLayerLive), + Layer.provideMerge(ProviderLayerLive), + Layer.provideMerge(GitLayerLive), + Layer.provideMerge(TerminalLayerLive), + Layer.provideMerge(PersistenceLayerLive), + Layer.provideMerge(KeybindingsLive), + + // Misc. + Layer.provideMerge(AnalyticsServiceLayerLive), + Layer.provideMerge(OpenLive), + Layer.provideMerge(ProviderHealthLive), + Layer.provideMerge(ServerLifecycleEventsLive), ); export const makeRoutesLayer = Layer.mergeAll( @@ -44,13 +147,34 @@ export const makeServerLayer = Layer.unwrap( ? { host: config.host, port: config.port } : { port: config.port }; yield* Effect.sync(fixPath); - return HttpRouter.serve(makeRoutesLayer, { - disableLogger: !config.logWebSocketEvents, - }).pipe( - Layer.provide(runtimeServicesLayer), - Layer.provide(NodeHttpServer.layer(Http.createServer, listenOptions)), + const httpListeningLayer = Layer.effectDiscard( + Effect.gen(function* () { + yield* HttpServer.HttpServer; + const startup = yield* ServerRuntimeStartup; + yield* startup.markHttpListening; + }), + ); + + const serverApplicationLayer = Layer.mergeAll( + HttpRouter.serve(makeRoutesLayer, { + disableLogger: !config.logWebSocketEvents, + }), + httpListeningLayer, + ); + + return serverApplicationLayer.pipe( + Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(NodeHttpServer.layer(Http.createServer, listenOptions)), + Layer.provide(ServerLoggerLive.pipe(Layer.provide(NodeServices.layer))), ); }), ); -export const runServer = Layer.launch(makeServerLayer); +// Important: Only `ServerConfig` should be provided by the CLI layer!!! Don't let other requirements leak into the launch layer. +export const runServer = Layer.launch(makeServerLayer) satisfies Effect.Effect< + never, + any, + ServerConfig +>; diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts deleted file mode 100644 index 1cd8edac26..0000000000 --- a/apps/server/src/serverLayers.ts +++ /dev/null @@ -1,141 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Effect, FileSystem, Layer, Path } from "effect"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; -import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; -import { ServerConfig } from "./config"; -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 { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; -import { ProviderUnsupportedError } from "./provider/Errors"; -import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; -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 { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; - -import { TerminalManagerLive } from "./terminal/Layers/Manager"; -import { KeybindingsLive } from "./keybindings"; -import { GitManagerLive } from "./git/Layers/GitManager"; -import { GitCoreLive } from "./git/Layers/GitCore"; -import { GitHubCliLive } from "./git/Layers/GitHubCli"; -import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration"; -import { PtyAdapter } from "./terminal/Services/PTY"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; - -type RuntimePtyAdapterLoader = { - layer: Layer.Layer; -}; - -const runtimePtyAdapterLoaders = { - bun: () => import("./terminal/Layers/BunPTY"), - node: () => import("./terminal/Layers/NodePTY"), -} satisfies Record Promise>; - -const makeRuntimePtyAdapterLayer = () => - Effect.gen(function* () { - const runtime = process.versions.bun !== undefined ? "bun" : "node"; - const loader = runtimePtyAdapterLoaders[runtime]; - const ptyAdapterModule = yield* Effect.promise(loader); - return ptyAdapterModule.layer; - }).pipe(Layer.unwrap); - -export function makeServerProviderLayer(): Layer.Layer< - ProviderService, - ProviderUnsupportedError, - SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | AnalyticsService -> { - return Effect.gen(function* () { - const { providerEventLogPath } = yield* ServerConfig; - const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { - stream: "native", - }); - const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { - stream: "canonical", - }); - const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntimeRepositoryLive), - ); - const codexAdapterLayer = makeCodexAdapterLive( - nativeEventLogger ? { nativeEventLogger } : undefined, - ); - const claudeAdapterLayer = makeClaudeAdapterLive( - nativeEventLogger ? { nativeEventLogger } : undefined, - ); - const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( - Layer.provide(codexAdapterLayer), - Layer.provide(claudeAdapterLayer), - Layer.provideMerge(providerSessionDirectoryLayer), - ); - return makeProviderServiceLive( - canonicalEventLogger ? { canonicalEventLogger } : undefined, - ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); - }).pipe(Layer.unwrap); -} - -export function makeServerRuntimeServicesLayer() { - const textGenerationLayer = CodexTextGenerationLive; - const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); - - const orchestrationLayer = OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationProjectionPipelineLive), - Layer.provide(OrchestrationEventStoreLive), - Layer.provide(OrchestrationCommandReceiptRepositoryLive), - ); - - const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe( - Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), - Layer.provideMerge(checkpointStoreLayer), - ); - - const runtimeServicesLayer = Layer.mergeAll( - orchestrationLayer, - OrchestrationProjectionSnapshotQueryLive, - checkpointStoreLayer, - checkpointDiffQueryLayer, - RuntimeReceiptBusLive, - ); - const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( - Layer.provideMerge(runtimeServicesLayer), - ); - const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( - Layer.provideMerge(runtimeServicesLayer), - Layer.provideMerge(GitCoreLive), - Layer.provideMerge(textGenerationLayer), - ); - const checkpointReactorLayer = CheckpointReactorLive.pipe( - Layer.provideMerge(runtimeServicesLayer), - ); - const orchestrationReactorLayer = OrchestrationReactorLive.pipe( - Layer.provideMerge(runtimeIngestionLayer), - Layer.provideMerge(providerCommandReactorLayer), - Layer.provideMerge(checkpointReactorLayer), - ); - - const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer())); - - const gitManagerLayer = GitManagerLive.pipe( - Layer.provideMerge(GitCoreLive), - Layer.provideMerge(GitHubCliLive), - Layer.provideMerge(textGenerationLayer), - ); - - return Layer.mergeAll( - orchestrationReactorLayer, - GitCoreLive, - gitManagerLayer, - terminalLayer, - KeybindingsLive, - ).pipe(Layer.provideMerge(NodeServices.layer)); -} diff --git a/apps/server/src/serverLifecycleEvents.test.ts b/apps/server/src/serverLifecycleEvents.test.ts new file mode 100644 index 0000000000..1cd8c25c03 --- /dev/null +++ b/apps/server/src/serverLifecycleEvents.test.ts @@ -0,0 +1,42 @@ +import { assert, it } from "@effect/vitest"; +import { assertTrue } from "@effect/vitest/utils"; +import { Effect, Option } from "effect"; + +import { ServerLifecycleEvents, ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; + +it.effect( + "publishes lifecycle events without subscribers and snapshots the latest welcome/ready", + () => + Effect.gen(function* () { + const lifecycleEvents = yield* ServerLifecycleEvents; + + const welcome = yield* lifecycleEvents + .publish({ + version: 1, + type: "welcome", + payload: { + cwd: "/tmp/project", + projectName: "project", + }, + }) + .pipe(Effect.timeoutOption("50 millis")); + assertTrue(Option.isSome(welcome)); + assert.equal(welcome.value.sequence, 1); + + const ready = yield* lifecycleEvents + .publish({ + version: 1, + type: "ready", + payload: { + at: new Date().toISOString(), + }, + }) + .pipe(Effect.timeoutOption("50 millis")); + assertTrue(Option.isSome(ready)); + assert.equal(ready.value.sequence, 2); + + const snapshot = yield* lifecycleEvents.snapshot; + assert.equal(snapshot.sequence, 2); + assert.deepEqual(snapshot.events.map((event) => event.type).toSorted(), ["ready", "welcome"]); + }).pipe(Effect.provide(ServerLifecycleEventsLive)), +); diff --git a/apps/server/src/serverLifecycleEvents.ts b/apps/server/src/serverLifecycleEvents.ts new file mode 100644 index 0000000000..4808a19d72 --- /dev/null +++ b/apps/server/src/serverLifecycleEvents.ts @@ -0,0 +1,53 @@ +import type { ServerLifecycleStreamEvent } from "@t3tools/contracts"; +import { Effect, Layer, PubSub, Ref, ServiceMap, Stream } from "effect"; + +type LifecycleEventInput = + | Omit, "sequence"> + | Omit, "sequence">; + +interface SnapshotState { + readonly sequence: number; + readonly events: ReadonlyArray; +} + +export interface ServerLifecycleEventsShape { + readonly publish: (event: LifecycleEventInput) => Effect.Effect; + readonly snapshot: Effect.Effect; + readonly stream: Stream.Stream; +} + +export class ServerLifecycleEvents extends ServiceMap.Service< + ServerLifecycleEvents, + ServerLifecycleEventsShape +>()("t3/serverLifecycleEvents") {} + +export const ServerLifecycleEventsLive = Layer.effect( + ServerLifecycleEvents, + Effect.gen(function* () { + const pubsub = yield* PubSub.unbounded(); + const state = yield* Ref.make({ + sequence: 0, + events: [], + }); + + return { + publish: (event) => + Ref.modify(state, (current) => { + const nextSequence = current.sequence + 1; + const nextEvent = { + ...event, + sequence: nextSequence, + } satisfies ServerLifecycleStreamEvent; + const nextEvents = + nextEvent.type === "welcome" + ? [nextEvent, ...current.events.filter((entry) => entry.type !== "welcome")] + : [nextEvent, ...current.events.filter((entry) => entry.type !== "ready")]; + return [nextEvent, { sequence: nextSequence, events: nextEvents }] as const; + }).pipe(Effect.tap((event) => PubSub.publish(pubsub, event))), + snapshot: Ref.get(state), + get stream() { + return Stream.fromPubSub(pubsub); + }, + } satisfies ServerLifecycleEventsShape; + }), +); diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts new file mode 100644 index 0000000000..e7efdc300a --- /dev/null +++ b/apps/server/src/serverLogger.test.ts @@ -0,0 +1,47 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, LogLevel, Path, References } from "effect"; + +import { ServerConfig } from "./config.ts"; +import { ServerLoggerLive } from "./serverLogger.ts"; + +it.layer(NodeServices.layer)("ServerLoggerLive", (it) => { + it.effect("provides the configured minimum log level and initializes log storage", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-logger-", + }); + const configLayer = Layer.succeed(ServerConfig, { + logLevel: "Warn", + mode: "web", + port: 0, + host: undefined, + cwd: process.cwd(), + keybindingsConfigPath: path.join(stateDir, "keybindings.json"), + stateDir, + staticDir: undefined, + devUrl: undefined, + noBrowser: true, + authToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + }); + + const result = yield* Effect.gen(function* () { + return { + minimumLogLevel: yield* References.MinimumLogLevel, + debugEnabled: yield* LogLevel.isEnabled("Debug"), + warnEnabled: yield* LogLevel.isEnabled("Warn"), + logDirExists: yield* fileSystem.exists(path.join(stateDir, "logs")), + }; + }).pipe(Effect.provide(ServerLoggerLive.pipe(Layer.provide(configLayer)))); + + assert.equal(result.minimumLogLevel, "Warn"); + assert.isFalse(result.debugEnabled); + assert.isTrue(result.warnEnabled); + assert.isTrue(result.logDirExists); + }), + ); +}); diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index 1b90babaad..c7392a0592 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -1,20 +1,23 @@ import fs from "node:fs"; -import { Effect, Logger } from "effect"; +import { Effect, Logger, References } from "effect"; import * as Layer from "effect/Layer"; import { ServerConfig } from "./config"; export const ServerLoggerLive = Effect.gen(function* () { - const { logsDir, serverLogPath } = yield* ServerConfig; + const config = yield* ServerConfig; + const { logsDir, serverLogPath } = config; yield* Effect.sync(() => { fs.mkdirSync(logsDir, { recursive: true }); }); const fileLogger = Logger.formatSimple.pipe(Logger.toFile(serverLogPath)); - - return Logger.layer([Logger.defaultLogger, fileLogger], { + const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel); + const loggerLayer = Logger.layer([Logger.consolePretty(), fileLogger], { mergeWithExisting: false, }); + + return Layer.mergeAll(loggerLayer, minimumLogLevelLayer); }).pipe(Layer.unwrap); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts new file mode 100644 index 0000000000..55700e3482 --- /dev/null +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -0,0 +1,49 @@ +import { assert, it } from "@effect/vitest"; +import { Deferred, Effect, Fiber, Ref } from "effect"; +import { TestClock } from "effect/testing"; + +import { makeCommandGate, ServerRuntimeStartupError } from "./serverRuntimeStartup.ts"; + +it.effect("enqueueCommand waits for readiness and then drains queued work", () => + Effect.scoped( + Effect.gen(function* () { + const executionCount = yield* Ref.make(0); + const commandGate = yield* makeCommandGate; + + const queuedCommandFiber = yield* commandGate + .enqueueCommand(Ref.updateAndGet(executionCount, (count) => count + 1)) + .pipe(Effect.forkScoped); + + yield* TestClock.adjust("50 millis"); + assert.equal(yield* Ref.get(executionCount), 0); + + yield* commandGate.signalCommandReady; + + const result = yield* Fiber.join(queuedCommandFiber); + assert.equal(result, 1); + assert.equal(yield* Ref.get(executionCount), 1); + }), + ), +); + +it.effect("enqueueCommand fails queued work when readiness fails", () => + Effect.scoped( + Effect.gen(function* () { + const commandGate = yield* makeCommandGate; + const failure = yield* Deferred.make(); + + const queuedCommandFiber = yield* commandGate + .enqueueCommand(Deferred.await(failure).pipe(Effect.as("should-not-run"))) + .pipe(Effect.forkScoped); + + yield* commandGate.failCommandReady( + new ServerRuntimeStartupError({ + message: "startup failed", + }), + ); + + const error = yield* Effect.flip(Fiber.join(queuedCommandFiber)); + assert.equal(error.message, "startup failed"); + }), + ), +); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts new file mode 100644 index 0000000000..90fb9fd95c --- /dev/null +++ b/apps/server/src/serverRuntimeStartup.ts @@ -0,0 +1,313 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + ProjectId, + ThreadId, +} from "@t3tools/contracts"; +import { Data, Deferred, Effect, Exit, Layer, Path, Queue, Ref, Scope, ServiceMap } from "effect"; + +import { ServerConfig } from "./config"; +import { Keybindings } from "./keybindings"; +import { Open } from "./open"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; +import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; +import { ServerLifecycleEvents } from "./serverLifecycleEvents"; +import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; + +const isWildcardHost = (host: string | undefined): boolean => + host === "0.0.0.0" || host === "::" || host === "[::]"; + +const formatHostForUrl = (host: string): string => + host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + +export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface ServerRuntimeStartupShape { + readonly awaitCommandReady: Effect.Effect; + readonly markHttpListening: Effect.Effect; + readonly enqueueCommand: ( + effect: Effect.Effect, + ) => Effect.Effect; +} + +export class ServerRuntimeStartup extends ServiceMap.Service< + ServerRuntimeStartup, + ServerRuntimeStartupShape +>()("t3/serverRuntimeStartup") {} + +interface QueuedCommand { + readonly run: Effect.Effect; +} + +type CommandReadinessState = "pending" | "ready" | ServerRuntimeStartupError; + +interface CommandGate { + readonly awaitCommandReady: Effect.Effect; + readonly signalCommandReady: Effect.Effect; + readonly failCommandReady: (error: ServerRuntimeStartupError) => Effect.Effect; + readonly enqueueCommand: ( + effect: Effect.Effect, + ) => Effect.Effect; +} + +const settleQueuedCommand = (deferred: Deferred.Deferred, exit: Exit.Exit) => + Exit.isSuccess(exit) + ? Deferred.succeed(deferred, exit.value) + : Deferred.failCause(deferred, exit.cause); + +export const makeCommandGate = Effect.gen(function* () { + const commandReady = yield* Deferred.make(); + const commandQueue = yield* Queue.unbounded(); + const commandReadinessState = yield* Ref.make("pending"); + + const commandWorker = Effect.forever( + Queue.take(commandQueue).pipe(Effect.flatMap((command) => command.run)), + ); + yield* Effect.forkScoped(commandWorker); + + return { + awaitCommandReady: Deferred.await(commandReady), + signalCommandReady: Effect.gen(function* () { + yield* Ref.set(commandReadinessState, "ready"); + yield* Deferred.succeed(commandReady, undefined).pipe(Effect.orDie); + }), + failCommandReady: (error) => + Effect.gen(function* () { + yield* Ref.set(commandReadinessState, error); + yield* Deferred.fail(commandReady, error).pipe(Effect.orDie); + }), + enqueueCommand: (effect: Effect.Effect) => + Effect.gen(function* () { + const readinessState = yield* Ref.get(commandReadinessState); + if (readinessState === "ready") { + return yield* effect; + } + if (readinessState !== "pending") { + return yield* readinessState; + } + + const result = yield* Deferred.make(); + yield* Queue.offer(commandQueue, { + run: Deferred.await(commandReady).pipe( + Effect.flatMap(() => effect), + Effect.exit, + Effect.flatMap((exit) => settleQueuedCommand(result, exit)), + ), + }); + return yield* Deferred.await(result); + }), + } satisfies CommandGate; +}); + +const recordStartupHeartbeat = Effect.gen(function* () { + const analytics = yield* AnalyticsService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + + const { threadCount, projectCount } = yield* projectionSnapshotQuery.getSnapshot().pipe( + Effect.map((snapshot) => ({ + threadCount: snapshot.threads.length, + projectCount: snapshot.projects.length, + })), + Effect.catch((cause) => + Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe( + Effect.as({ + threadCount: 0, + projectCount: 0, + }), + ), + ), + ); + + yield* analytics.record("server.boot.heartbeat", { + threadCount, + projectCount, + }); +}); + +const autoBootstrapWelcome = Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const projectionReadModelQuery = yield* ProjectionSnapshotQuery; + const orchestrationEngine = yield* OrchestrationEngineService; + const path = yield* Path.Path; + + let bootstrapProjectId: ProjectId | undefined; + let bootstrapThreadId: ThreadId | undefined; + + if (serverConfig.autoBootstrapProjectFromCwd) { + yield* Effect.gen(function* () { + const snapshot = yield* projectionReadModelQuery.getSnapshot(); + const existingProject = snapshot.projects.find( + (project) => project.workspaceRoot === serverConfig.cwd && project.deletedAt === null, + ); + let nextProjectId: ProjectId; + let nextProjectDefaultModel: string; + + if (!existingProject) { + const createdAt = new Date().toISOString(); + nextProjectId = ProjectId.makeUnsafe(crypto.randomUUID()); + const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project"; + nextProjectDefaultModel = "gpt-5-codex"; + yield* orchestrationEngine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe(crypto.randomUUID()), + projectId: nextProjectId, + title: bootstrapProjectTitle, + workspaceRoot: serverConfig.cwd, + defaultModel: nextProjectDefaultModel, + createdAt, + }); + } else { + nextProjectId = existingProject.id; + nextProjectDefaultModel = existingProject.defaultModel ?? "gpt-5-codex"; + } + + const existingThread = snapshot.threads.find( + (thread) => thread.projectId === nextProjectId && thread.deletedAt === null, + ); + if (!existingThread) { + const createdAt = new Date().toISOString(); + const createdThreadId = ThreadId.makeUnsafe(crypto.randomUUID()); + yield* orchestrationEngine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe(crypto.randomUUID()), + threadId: createdThreadId, + projectId: nextProjectId, + title: "New thread", + model: nextProjectDefaultModel, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }); + bootstrapProjectId = nextProjectId; + bootstrapThreadId = createdThreadId; + } else { + bootstrapProjectId = nextProjectId; + bootstrapThreadId = existingThread.id; + } + }); + } + + const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean); + const projectName = segments[segments.length - 1] ?? "project"; + + return { + cwd: serverConfig.cwd, + projectName, + ...(bootstrapProjectId ? { bootstrapProjectId } : {}), + ...(bootstrapThreadId ? { bootstrapThreadId } : {}), + } as const; +}); + +const maybeOpenBrowser = Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + if (serverConfig.noBrowser) { + return; + } + const { openBrowser } = yield* Open; + const localUrl = `http://localhost:${serverConfig.port}`; + const bindUrl = + serverConfig.host && !isWildcardHost(serverConfig.host) + ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}` + : localUrl; + const target = serverConfig.devUrl?.toString() ?? bindUrl; + + yield* openBrowser(target).pipe( + Effect.catch(() => + Effect.logInfo("browser auto-open unavailable", { + hint: `Open ${target} in your browser.`, + }), + ), + ); +}); + +const makeServerRuntimeStartup = Effect.gen(function* () { + const keybindings = yield* Keybindings; + const orchestrationReactor = yield* OrchestrationReactor; + const lifecycleEvents = yield* ServerLifecycleEvents; + + const commandGate = yield* makeCommandGate; + const httpListening = yield* Deferred.make(); + const reactorScope = yield* Scope.make("sequential"); + + yield* Effect.addFinalizer(() => Scope.close(reactorScope, Exit.void)); + + const startup = Effect.gen(function* () { + yield* Effect.logDebug("startup phase: starting keybindings runtime"); + yield* keybindings.start.pipe( + Effect.catch((error) => + Effect.logWarning("failed to start keybindings runtime", { + path: error.configPath, + detail: error.detail, + cause: error.cause, + }), + ), + Effect.forkScoped, + ); + + yield* Effect.logDebug("startup phase: starting orchestration reactors"); + yield* Scope.provide(orchestrationReactor.start, reactorScope); + + yield* Effect.logDebug("startup phase: preparing welcome payload"); + const welcome = yield* autoBootstrapWelcome; + yield* Effect.logDebug("startup phase: publishing welcome event", { + cwd: welcome.cwd, + projectName: welcome.projectName, + bootstrapProjectId: welcome.bootstrapProjectId, + bootstrapThreadId: welcome.bootstrapThreadId, + }); + yield* lifecycleEvents.publish({ + version: 1, + type: "welcome", + payload: welcome, + }); + }); + + yield* Effect.forkScoped( + Effect.gen(function* () { + const startupExit = yield* Effect.exit(startup); + if (Exit.isFailure(startupExit)) { + const error = new ServerRuntimeStartupError({ + message: "Server runtime startup failed before command readiness.", + cause: startupExit.cause, + }); + yield* Effect.logError("server runtime startup failed", { cause: startupExit.cause }); + yield* commandGate.failCommandReady(error); + return; + } + + yield* Effect.logInfo("Accepting commands"); + yield* commandGate.signalCommandReady; + yield* Effect.logDebug("startup phase: waiting for http listener"); + yield* Deferred.await(httpListening); + yield* Effect.logDebug("startup phase: publishing ready event"); + yield* lifecycleEvents.publish({ + version: 1, + type: "ready", + payload: { at: new Date().toISOString() }, + }); + + yield* Effect.logDebug("startup phase: recording startup heartbeat"); + yield* recordStartupHeartbeat; + yield* Effect.logDebug("startup phase: browser open check"); + yield* maybeOpenBrowser; + yield* Effect.logDebug("startup phase: complete"); + }), + ); + + return { + awaitCommandReady: commandGate.awaitCommandReady, + markHttpListening: Deferred.succeed(httpListening, undefined).pipe(Effect.orDie), + enqueueCommand: commandGate.enqueueCommand, + } satisfies ServerRuntimeStartupShape; +}); + +export const ServerRuntimeStartupLive = Layer.effect( + ServerRuntimeStartup, + makeServerRuntimeStartup, +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index af246686df..cd51322b19 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,6 +1,7 @@ -import { Effect, FileSystem, Layer, Path, Schema, Stream, PubSub } from "effect"; +import { Effect, FileSystem, Layer, Option, Path, Schema, Stream, PubSub, Ref } from "effect"; import { OrchestrationDispatchCommandError, + type OrchestrationEvent, OrchestrationGetFullThreadDiffError, OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, @@ -13,6 +14,7 @@ import { WsRpcGroup, } from "@t3tools/contracts"; import { clamp } from "effect/Number"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; @@ -25,275 +27,304 @@ import { normalizeDispatchCommand } from "./orchestration/Normalizer"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { ProviderHealth } from "./provider/Services/ProviderHealth"; +import { ServerLifecycleEvents } from "./serverLifecycleEvents"; +import { ServerRuntimeStartup } from "./serverRuntimeStartup"; import { TerminalManager } from "./terminal/Services/Manager"; import { resolveWorkspaceWritePath, searchWorkspaceEntries } from "./workspaceEntries"; -const WsRpcLayer = WsRpcGroup.toLayer({ - [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => - Effect.gen(function* () { - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - return yield* projectionSnapshotQuery.getSnapshot(); - }).pipe( - Effect.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: "Failed to load orchestration snapshot", - cause, +const WsRpcLayer = WsRpcGroup.toLayer( + Effect.gen(function* () { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const orchestrationEngine = yield* OrchestrationEngineService; + const checkpointDiffQuery = yield* CheckpointDiffQuery; + const keybindings = yield* Keybindings; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const open = yield* Open; + const gitManager = yield* GitManager; + const git = yield* GitCore; + const terminalManager = yield* TerminalManager; + const providerHealth = yield* ProviderHealth; + const config = yield* ServerConfig; + const lifecycleEvents = yield* ServerLifecycleEvents; + const startup = yield* ServerRuntimeStartup; + + return WsRpcGroup.of({ + [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => + projectionSnapshotQuery.getSnapshot().pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load orchestration snapshot", + cause, + }), + ), + ), + [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => + Effect.gen(function* () { + const normalizedCommand = yield* normalizeDispatchCommand(command); + return yield* startup.enqueueCommand(orchestrationEngine.dispatch(normalizedCommand)); + }).pipe( + Effect.mapError((cause) => + Schema.is(OrchestrationDispatchCommandError)(cause) + ? cause + : new OrchestrationDispatchCommandError({ + message: "Failed to dispatch orchestration command", + cause, + }), + ), + ), + [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => + checkpointDiffQuery.getTurnDiff(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetTurnDiffError({ + message: "Failed to load turn diff", + cause, + }), + ), + ), + [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => + checkpointDiffQuery.getFullThreadDiff(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetFullThreadDiffError({ + message: "Failed to load full thread diff", + cause, + }), + ), + ), + [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => + Stream.runCollect( + orchestrationEngine.readEvents( + clamp(input.fromSequenceExclusive, { maximum: Number.MAX_SAFE_INTEGER, minimum: 0 }), + ), + ).pipe( + Effect.map((events) => Array.from(events)), + Effect.mapError( + (cause) => + new OrchestrationReplayEventsError({ + message: "Failed to replay orchestration events", + cause, + }), + ), + ), + [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) => + Stream.unwrap( + Effect.gen(function* () { + const snapshot = yield* orchestrationEngine.getReadModel(); + const fromSequenceExclusive = snapshot.snapshotSequence; + const replayEvents: Array = yield* Stream.runCollect( + orchestrationEngine.readEvents(fromSequenceExclusive), + ).pipe( + Effect.map((events) => Array.from(events)), + Effect.catch(() => Effect.succeed([] as Array)), + ); + const replayStream = Stream.fromIterable(replayEvents); + const source = Stream.merge(replayStream, orchestrationEngine.streamDomainEvents); + type SequenceState = { + readonly nextSequence: number; + readonly pendingBySequence: Map; + }; + const state = yield* Ref.make({ + nextSequence: fromSequenceExclusive + 1, + pendingBySequence: new Map(), + }); + + return source.pipe( + Stream.mapEffect((event) => + Ref.modify( + state, + ({ + nextSequence, + pendingBySequence, + }): [Array, SequenceState] => { + if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { + return [[], { nextSequence, pendingBySequence }]; + } + + const updatedPending = new Map(pendingBySequence); + updatedPending.set(event.sequence, event); + + const emit: Array = []; + let expected = nextSequence; + for (;;) { + const expectedEvent = updatedPending.get(expected); + if (!expectedEvent) { + break; + } + emit.push(expectedEvent); + updatedPending.delete(expected); + expected += 1; + } + + return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; + }, + ), + ), + Stream.flatMap((events) => Stream.fromIterable(events)), + ); }), - ), - ), - [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => - Effect.gen(function* () { - const orchestrationEngine = yield* OrchestrationEngineService; - const normalizedCommand = yield* normalizeDispatchCommand(command); - return yield* orchestrationEngine.dispatch(normalizedCommand); - }).pipe( - Effect.mapError((cause) => - Schema.is(OrchestrationDispatchCommandError)(cause) - ? cause - : new OrchestrationDispatchCommandError({ - message: "Failed to dispatch orchestration command", + ), + [WS_METHODS.serverUpsertKeybinding]: (rule) => + Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); + return { keybindings: keybindingsConfig, issues: [] }; + }), + [WS_METHODS.projectsSearchEntries]: (input) => + Effect.tryPromise({ + try: () => searchWorkspaceEntries(input), + catch: (cause) => + new ProjectSearchEntriesError({ + message: "Failed to search workspace entries", cause, }), - ), - ), - [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => - Effect.gen(function* () { - const checkpointDiffQuery = yield* CheckpointDiffQuery; - return yield* checkpointDiffQuery.getTurnDiff(input); - }).pipe( - Effect.mapError( - (cause) => - new OrchestrationGetTurnDiffError({ - message: "Failed to load turn diff", - cause, - }), - ), - ), - [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => - Effect.gen(function* () { - const checkpointDiffQuery = yield* CheckpointDiffQuery; - return yield* checkpointDiffQuery.getFullThreadDiff(input); - }).pipe( - Effect.mapError( - (cause) => - new OrchestrationGetFullThreadDiffError({ - message: "Failed to load full thread diff", - cause, + }), + [WS_METHODS.projectsWriteFile]: (input) => + Effect.gen(function* () { + const target = yield* resolveWorkspaceWritePath({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + yield* fileSystem + .makeDirectory(path.dirname(target.absolutePath), { recursive: true }) + .pipe( + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + message: "Failed to prepare workspace path", + cause, + }), + ), + ); + yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + message: "Failed to write workspace file", + cause, + }), + ), + ); + return { relativePath: target.relativePath }; + }), + [WS_METHODS.shellOpenInEditor]: (input) => open.openInEditor(input), + [WS_METHODS.gitStatus]: (input) => gitManager.status(input), + [WS_METHODS.gitPull]: (input) => git.pullCurrentBranch(input.cwd), + [WS_METHODS.gitRunStackedAction]: (input) => gitManager.runStackedAction(input), + [WS_METHODS.gitResolvePullRequest]: (input) => gitManager.resolvePullRequest(input), + [WS_METHODS.gitPreparePullRequestThread]: (input) => + gitManager.preparePullRequestThread(input), + [WS_METHODS.gitListBranches]: (input) => git.listBranches(input), + [WS_METHODS.gitCreateWorktree]: (input) => git.createWorktree(input), + [WS_METHODS.gitRemoveWorktree]: (input) => git.removeWorktree(input), + [WS_METHODS.gitCreateBranch]: (input) => git.createBranch(input), + [WS_METHODS.gitCheckout]: (input) => Effect.scoped(git.checkoutBranch(input)), + [WS_METHODS.gitInit]: (input) => git.initRepo(input), + [WS_METHODS.terminalOpen]: (input) => terminalManager.open(input), + [WS_METHODS.terminalWrite]: (input) => terminalManager.write(input), + [WS_METHODS.terminalResize]: (input) => terminalManager.resize(input), + [WS_METHODS.terminalClear]: (input) => terminalManager.clear(input), + [WS_METHODS.terminalRestart]: (input) => terminalManager.restart(input), + [WS_METHODS.terminalClose]: (input) => terminalManager.close(input), + [WS_METHODS.subscribeTerminalEvents]: (_input) => + Stream.unwrap( + Effect.gen(function* () { + const pubsub = yield* PubSub.unbounded(); + const unsubscribe = yield* terminalManager.subscribe((event) => { + PubSub.publishUnsafe(pubsub, event); + }); + return Stream.fromPubSub(pubsub).pipe( + Stream.ensuring(Effect.sync(() => unsubscribe())), + ); }), - ), - ), - [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => - Effect.gen(function* () { - const orchestrationEngine = yield* OrchestrationEngineService; - return yield* Stream.runCollect( - orchestrationEngine.readEvents( - clamp(input.fromSequenceExclusive, { maximum: Number.MAX_SAFE_INTEGER, minimum: 0 }), ), - ).pipe(Effect.map((events) => Array.from(events))); - }).pipe( - Effect.mapError( - (cause) => - new OrchestrationReplayEventsError({ - message: "Failed to replay orchestration events", - cause, + [WS_METHODS.subscribeServerConfig]: (_input) => + Stream.unwrap( + Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.loadConfigState; + const providers = yield* providerHealth.getStatuses; + + const keybindingsUpdates = keybindings.streamChanges.pipe( + Stream.mapEffect((event) => + Effect.succeed({ + version: 1 as const, + type: "keybindingsUpdated" as const, + payload: { + issues: event.issues, + }, + }), + ), + ); + const providerStatuses = Stream.tick("10 seconds").pipe( + Stream.mapEffect(() => + Effect.gen(function* () { + const providers = yield* providerHealth.getStatuses; + return { + version: 1 as const, + type: "providerStatuses" as const, + payload: { providers }, + }; + }), + ), + ); + return Stream.concat( + Stream.make({ + version: 1 as const, + type: "snapshot" as const, + config: { + cwd: config.cwd, + keybindingsConfigPath: config.keybindingsConfigPath, + keybindings: keybindingsConfig.keybindings, + issues: keybindingsConfig.issues, + providers, + availableEditors: resolveAvailableEditors(), + }, + }), + Stream.merge(keybindingsUpdates, providerStatuses), + ); }), - ), - ), - [WS_METHODS.serverUpsertKeybinding]: (rule) => - Effect.gen(function* () { - const keybindings = yield* Keybindings; - const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); - return { keybindings: keybindingsConfig, issues: [] }; - }), - [WS_METHODS.projectsSearchEntries]: (input) => - Effect.tryPromise({ - try: () => searchWorkspaceEntries(input), - catch: (cause) => - new ProjectSearchEntriesError({ - message: "Failed to search workspace entries", - cause, - }), - }), - [WS_METHODS.projectsWriteFile]: (input) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const target = yield* resolveWorkspaceWritePath({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - }); - yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( - Effect.mapError( - (cause) => - new ProjectWriteFileError({ - message: "Failed to prepare workspace path", - cause, - }), ), - ); - yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( - Effect.mapError( - (cause) => - new ProjectWriteFileError({ - message: "Failed to write workspace file", - cause, - }), + [WS_METHODS.subscribeServerLifecycle]: (_input) => + Stream.unwrap( + Effect.gen(function* () { + const snapshot = yield* lifecycleEvents.snapshot; + const snapshotEvents = Array.from(snapshot.events).toSorted( + (left, right) => left.sequence - right.sequence, + ); + const liveEvents = lifecycleEvents.stream.pipe( + Stream.filter((event) => event.sequence > snapshot.sequence), + ); + return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents); + }), ), - ); - return { relativePath: target.relativePath }; - }), - [WS_METHODS.shellOpenInEditor]: (input) => - Effect.gen(function* () { - const open = yield* Open; - return yield* open.openInEditor(input); - }), - [WS_METHODS.gitStatus]: (input) => - Effect.gen(function* () { - const gitManager = yield* GitManager; - return yield* gitManager.status(input); - }), - [WS_METHODS.gitPull]: (input) => - Effect.gen(function* () { - const git = yield* GitCore; - return yield* git.pullCurrentBranch(input.cwd); - }), - [WS_METHODS.gitRunStackedAction]: (input) => - Effect.gen(function* () { - const gitManager = yield* GitManager; - return yield* gitManager.runStackedAction(input); - }), - [WS_METHODS.gitResolvePullRequest]: (input) => - Effect.gen(function* () { - const gitManager = yield* GitManager; - return yield* gitManager.resolvePullRequest(input); - }), - [WS_METHODS.gitPreparePullRequestThread]: (input) => - Effect.gen(function* () { - const gitManager = yield* GitManager; - return yield* gitManager.preparePullRequestThread(input); - }), - [WS_METHODS.gitListBranches]: (input) => - Effect.gen(function* () { - const git = yield* GitCore; - return yield* git.listBranches(input); - }), - [WS_METHODS.gitCreateWorktree]: (input) => - Effect.gen(function* () { - const git = yield* GitCore; - return yield* git.createWorktree(input); - }), - [WS_METHODS.gitRemoveWorktree]: (input) => - Effect.gen(function* () { - const git = yield* GitCore; - return yield* git.removeWorktree(input); - }), - [WS_METHODS.gitCreateBranch]: (input) => - Effect.gen(function* () { - const git = yield* GitCore; - return yield* git.createBranch(input); - }), - [WS_METHODS.gitCheckout]: (input) => - Effect.gen(function* () { - const git = yield* GitCore; - return yield* Effect.scoped(git.checkoutBranch(input)); - }), - [WS_METHODS.gitInit]: (input) => - Effect.gen(function* () { - const git = yield* GitCore; - return yield* git.initRepo(input); - }), - [WS_METHODS.terminalOpen]: (input) => - Effect.gen(function* () { - const terminalManager = yield* TerminalManager; - return yield* terminalManager.open(input); - }), - [WS_METHODS.terminalWrite]: (input) => - Effect.gen(function* () { - const terminalManager = yield* TerminalManager; - return yield* terminalManager.write(input); - }), - [WS_METHODS.terminalResize]: (input) => - Effect.gen(function* () { - const terminalManager = yield* TerminalManager; - return yield* terminalManager.resize(input); - }), - [WS_METHODS.terminalClear]: (input) => - Effect.gen(function* () { - const terminalManager = yield* TerminalManager; - return yield* terminalManager.clear(input); - }), - [WS_METHODS.terminalRestart]: (input) => - Effect.gen(function* () { - const terminalManager = yield* TerminalManager; - return yield* terminalManager.restart(input); - }), - [WS_METHODS.terminalClose]: (input) => - Effect.gen(function* () { - const terminalManager = yield* TerminalManager; - return yield* terminalManager.close(input); - }), - [WS_METHODS.subscribeTerminalEvents]: (_input) => - Stream.unwrap( - Effect.gen(function* () { - const terminalManager = yield* TerminalManager; - const pubsub = yield* PubSub.unbounded(); - const unsubscribe = yield* terminalManager.subscribe((event) => { - PubSub.publishUnsafe(pubsub, event); - }); - return Stream.fromPubSub(pubsub).pipe(Stream.ensuring(Effect.sync(() => unsubscribe()))); - }), - ), - [WS_METHODS.subscribeServerConfig]: (_input) => - Stream.unwrap( + }); + }), +); + +export const websocketRpcRouteLayer = Layer.unwrap( + Effect.gen(function* () { + const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup).pipe( + Effect.provide(Layer.mergeAll(WsRpcLayer, RpcSerialization.layerJson)), + ); + return HttpRouter.add( + "GET", + "/ws", Effect.gen(function* () { - const keybindings = yield* Keybindings; - const providerHealth = yield* ProviderHealth; + const request = yield* HttpServerRequest.HttpServerRequest; const config = yield* ServerConfig; - const keybindingsConfig = yield* keybindings.loadConfigState; - const providers = yield* providerHealth.getStatuses; - - const keybindingsUpdates = keybindings.streamChanges.pipe( - Stream.mapEffect((event) => - Effect.succeed({ - type: "keybindingsUpdated" as const, - payload: { - issues: event.issues, - }, - }), - ), - ); - const providerStatuses = Stream.tick("10 seconds").pipe( - Stream.mapEffect(() => - Effect.gen(function* () { - const providers = yield* providerHealth.getStatuses; - return { - type: "providerStatuses" as const, - payload: { providers }, - }; - }), - ), - ); - return Stream.concat( - Stream.make({ - type: "snapshot" as const, - config: { - cwd: config.cwd, - keybindingsConfigPath: config.keybindingsConfigPath, - keybindings: keybindingsConfig.keybindings, - issues: keybindingsConfig.issues, - providers, - availableEditors: resolveAvailableEditors(), - }, - }), - Stream.merge(keybindingsUpdates, providerStatuses), - ); + if (config.authToken) { + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return HttpServerResponse.text("Invalid WebSocket URL", { status: 400 }); + } + const token = url.value.searchParams.get("token"); + if (token !== config.authToken) { + return HttpServerResponse.text("Unauthorized WebSocket connection", { status: 401 }); + } + } + return yield* rpcWebSocketHttpEffect; }), - ), -}); - -export const websocketRpcRouteLayer = RpcServer.layerHttp({ - group: WsRpcGroup, - path: "/ws", - protocol: "websocket", -}).pipe(Layer.provide(WsRpcLayer), Layer.provide(RpcSerialization.layerJson)); + ); + }), +); diff --git a/bun.lock b/bun.lock index 4e1959c157..f46b46e3bd 100644 --- a/bun.lock +++ b/bun.lock @@ -45,7 +45,7 @@ "name": "t3", "version": "0.0.13", "bin": { - "t3": "./dist/index.mjs", + "t3": "./dist/bin.mjs", }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.77", @@ -173,12 +173,12 @@ }, "catalog": { "@effect/language-service": "0.75.1", - "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", - "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", - "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", + "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@d53a9a7", + "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@d53a9a7", + "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@d53a9a7", "@types/bun": "^1.3.9", "@types/node": "^24.10.13", - "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", + "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@d53a9a7", "tsdown": "^0.20.3", "typescript": "^5.7.3", "vitest": "^4.0.0", @@ -266,13 +266,13 @@ "@effect/language-service": ["@effect/language-service@0.75.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-g9xD2tAQgRFpYC2YgpZq02VeSL5fBbFJ0B/g1o+14NuNmwtaYJc7SjiLWAA9eyhJHosNrn6h1Ye+Kx6j5mN0AA=="], - "@effect/platform-node": ["@effect/platform-node@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@8881a9b606d84a6f5eb6615279138322984f5368", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.25", "ioredis": "^5.7.0" } }], + "@effect/platform-node": ["@effect/platform-node@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@d53a9a7", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@d53a9a758bd6321ffd97432769bd0e9fac486999", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.32", "ioredis": "^5.7.0" } }], - "@effect/platform-node-shared": ["@effect/platform-node-shared@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@8881a9b606d84a6f5eb6615279138322984f5368", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.25" } }], + "@effect/platform-node-shared": ["@effect/platform-node-shared@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@d53a9a758bd6321ffd97432769bd0e9fac486999", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.32" } }], - "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", { "peerDependencies": { "effect": "^4.0.0-beta.25" } }], + "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@d53a9a7", { "peerDependencies": { "effect": "^4.0.0-beta.32" } }], - "@effect/vitest": ["@effect/vitest@https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", { "peerDependencies": { "effect": "^4.0.0-beta.25", "vitest": "^3.0.0 || ^4.0.0" } }], + "@effect/vitest": ["@effect/vitest@https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@d53a9a7", { "peerDependencies": { "effect": "^4.0.0-beta.32", "vitest": "^3.0.0 || ^4.0.0" } }], "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], @@ -1014,7 +1014,7 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "effect": ["effect@https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }], + "effect": ["effect@https://pkg.pr.new/Effect-TS/effect-smol/effect@d53a9a7", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }], "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], @@ -1909,7 +1909,6 @@ "@effect/sql-sqlite-bun/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], "@effect/vitest/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], - "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], diff --git a/package.json b/package.json index 02e71cf097..2faaed248b 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,10 @@ "scripts" ], "catalog": { - "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", - "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", - "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", - "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", + "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@d53a9a7", + "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@d53a9a7", + "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@d53a9a7", + "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@d53a9a7", "@effect/language-service": "0.75.1", "@types/bun": "^1.3.9", "@types/node": "^24.10.13", diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 99ceb45bb9..8af97920e3 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,5 +1,11 @@ import { Schema } from "effect"; -import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; +import { + IsoDateTime, + NonNegativeInt, + ProjectId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas"; import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; import { EditorId } from "./editor"; import { ProviderKind } from "./orchestration"; @@ -82,12 +88,14 @@ export const ServerConfigProviderStatusesPayload = Schema.Struct({ export type ServerConfigProviderStatusesPayload = typeof ServerConfigProviderStatusesPayload.Type; export const ServerConfigStreamSnapshotEvent = Schema.Struct({ + version: Schema.Literal(1), type: Schema.Literal("snapshot"), config: ServerConfig, }); export type ServerConfigStreamSnapshotEvent = typeof ServerConfigStreamSnapshotEvent.Type; export const ServerConfigStreamKeybindingsUpdatedEvent = Schema.Struct({ + version: Schema.Literal(1), type: Schema.Literal("keybindingsUpdated"), payload: ServerConfigKeybindingsUpdatedPayload, }); @@ -95,6 +103,7 @@ export type ServerConfigStreamKeybindingsUpdatedEvent = typeof ServerConfigStreamKeybindingsUpdatedEvent.Type; export const ServerConfigStreamProviderStatusesEvent = Schema.Struct({ + version: Schema.Literal(1), type: Schema.Literal("providerStatuses"), payload: ServerConfigProviderStatusesPayload, }); @@ -107,3 +116,35 @@ export const ServerConfigStreamEvent = Schema.Union([ ServerConfigStreamProviderStatusesEvent, ]); export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; + +export const ServerLifecycleReadyPayload = Schema.Struct({ + at: IsoDateTime, +}); +export type ServerLifecycleReadyPayload = typeof ServerLifecycleReadyPayload.Type; + +export const ServerLifecycleStreamWelcomeEvent = Schema.Struct({ + version: Schema.Literal(1), + sequence: NonNegativeInt, + type: Schema.Literal("welcome"), + payload: Schema.Struct({ + cwd: TrimmedNonEmptyString, + projectName: TrimmedNonEmptyString, + bootstrapProjectId: Schema.optional(ProjectId), + bootstrapThreadId: Schema.optional(ThreadId), + }), +}); +export type ServerLifecycleStreamWelcomeEvent = typeof ServerLifecycleStreamWelcomeEvent.Type; + +export const ServerLifecycleStreamReadyEvent = Schema.Struct({ + version: Schema.Literal(1), + sequence: NonNegativeInt, + type: Schema.Literal("ready"), + payload: ServerLifecycleReadyPayload, +}); +export type ServerLifecycleStreamReadyEvent = typeof ServerLifecycleStreamReadyEvent.Type; + +export const ServerLifecycleStreamEvent = Schema.Union([ + ServerLifecycleStreamWelcomeEvent, + ServerLifecycleStreamReadyEvent, +]); +export type ServerLifecycleStreamEvent = typeof ServerLifecycleStreamEvent.Type; diff --git a/packages/contracts/src/wsRpc.ts b/packages/contracts/src/wsRpc.ts index 558af9e0b2..4b18b01671 100644 --- a/packages/contracts/src/wsRpc.ts +++ b/packages/contracts/src/wsRpc.ts @@ -61,6 +61,7 @@ import { } from "./terminal"; import { ServerConfigStreamEvent, + ServerLifecycleStreamEvent, ServerUpsertKeybindingInput, ServerUpsertKeybindingResult, } from "./server"; @@ -70,7 +71,6 @@ import { SubscribeServerLifecycleInput, SubscribeTerminalEventsInput, WS_METHODS, - WsWelcomePayload, } from "./ws"; export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { @@ -250,7 +250,7 @@ export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerCon export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, { payload: SubscribeServerLifecycleInput, - success: WsWelcomePayload, + success: ServerLifecycleStreamEvent, stream: true, }); @@ -276,8 +276,10 @@ export const WsRpcGroup = RpcGroup.make( WsTerminalClearRpc, WsTerminalRestartRpc, WsTerminalCloseRpc, + WsSubscribeOrchestrationDomainEventsRpc, WsSubscribeTerminalEventsRpc, WsSubscribeServerConfigRpc, + WsSubscribeServerLifecycleRpc, WsOrchestrationGetSnapshotRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetTurnDiffRpc, From 7d73b726576f638e18129ced66ee7a136655619f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Mar 2026 13:49:00 -0700 Subject: [PATCH 20/47] type --- apps/server/src/server.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c74e0dbfb4..f9a212fecc 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -14,6 +14,7 @@ import { ThreadId, WS_METHODS, WsRpcGroup, + EditorId, } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; @@ -679,10 +680,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc shell.openInEditor", () => Effect.gen(function* () { - let openedInput: { - cwd: string; - editor: "cursor" | "vscode" | "zed" | "file-manager"; - } | null = null; + let openedInput: { cwd: string; editor: EditorId } | null = null; yield* buildAppUnderTest({ layers: { open: { From 8ea82bc6d147c075a258807201a3531f1a89c573 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Mar 2026 22:40:41 -0700 Subject: [PATCH 21/47] client --- apps/server/package.json | 1 + apps/server/src/server.ts | 36 +++- apps/web/src/routes/__root.tsx | 8 +- apps/web/src/wsNativeApi.test.ts | 279 ++++++++++++++---------- apps/web/src/wsNativeApi.ts | 182 ++++++++++++---- apps/web/src/wsTransport.test.ts | 258 +++++++++++++++------- apps/web/src/wsTransport.ts | 360 ++++++++++--------------------- bun.lock | 22 +- package.json | 9 +- 9 files changed, 653 insertions(+), 502 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 879c2a27ca..63e2034a8d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -34,6 +34,7 @@ }, "devDependencies": { "@effect/language-service": "catalog:", + "@effect/platform-bun": "catalog:", "@effect/vitest": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 6d6520b820..f923699846 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,13 +1,14 @@ import * as Net from "node:net"; import * as Http from "node:http"; -import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; import { Effect, Layer, Path } from "effect"; -import { HttpRouter, HttpServer } from "effect/unstable/http"; +import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; -import { ServerConfig } from "./config"; +import { ServerConfig, ServerConfigShape } from "./config"; import { attachmentsRouteLayer, healthRouteLayer, staticAndDevRouteLayer } from "./http"; import { fixPath } from "./os-jank"; import { websocketRpcRouteLayer } from "./ws"; @@ -133,6 +134,20 @@ const runtimeServicesLayer = Layer.empty.pipe( Layer.provideMerge(ServerLifecycleEventsLive), ); +const HttpServerLive = + typeof Bun !== "undefined" + ? (listenOptions: ServerConfigShape) => + BunHttpServer.layer({ + port: listenOptions.port, + ...(listenOptions.host ? { hostname: listenOptions.host } : {}), + }) + : (listenOptions: ServerConfigShape) => + NodeHttpServer.layer(Http.createServer, { + host: listenOptions.host, + port: listenOptions.port, + }); +const ServicesLive = typeof Bun !== "undefined" ? BunServices.layer : NodeServices.layer; + export const makeRoutesLayer = Layer.mergeAll( healthRouteLayer, attachmentsRouteLayer, @@ -143,10 +158,9 @@ export const makeRoutesLayer = Layer.mergeAll( export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig; - const listenOptions: Net.ListenOptions = config.host - ? { host: config.host, port: config.port } - : { port: config.port }; - yield* Effect.sync(fixPath); + + fixPath(); + const httpListeningLayer = Layer.effectDiscard( Effect.gen(function* () { yield* HttpServer.HttpServer; @@ -164,10 +178,10 @@ export const makeServerLayer = Layer.unwrap( return serverApplicationLayer.pipe( Layer.provideMerge(runtimeServicesLayer), - Layer.provideMerge(NodeHttpClient.layerUndici), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(NodeHttpServer.layer(Http.createServer, listenOptions)), - Layer.provide(ServerLoggerLive.pipe(Layer.provide(NodeServices.layer))), + Layer.provideMerge(HttpServerLive(config)), + Layer.provide(ServerLoggerLive), + Layer.provideMerge(FetchHttpClient.layer), + Layer.provideMerge(ServicesLive), ); }), ); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 34f9c4b82f..387884e804 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -142,7 +142,9 @@ function EventRouter() { const pathnameRef = useRef(pathname); const handledBootstrapThreadIdRef = useRef(null); - pathnameRef.current = pathname; + useEffect(() => { + pathnameRef.current = pathname; + }, [pathname]); useEffect(() => { const api = readNativeApi(); @@ -259,9 +261,9 @@ function EventRouter() { // during subscribe. Skip the toast for that replay so effect re-runs // don't produce duplicate toasts. let subscribed = false; - const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { + const unsubServerConfigUpdated = onServerConfigUpdated((payload, source) => { void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); - if (!subscribed) return; + if (!subscribed || source !== "keybindingsUpdated") return; const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); if (!issue) { toastManager.add({ diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 2323380da0..eb769f84ea 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -2,18 +2,15 @@ import { CommandId, type ContextMenuItem, EventId, - ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, - type OrchestrationEvent, ProjectId, + type OrchestrationEvent, + type ServerConfig, + type ServerConfigStreamEvent, + type ServerLifecycleStreamEvent, + type ServerProviderStatus, ThreadId, - type WsPushChannel, - type WsPushData, - type WsPushMessage, - WS_CHANNELS, WS_METHODS, - type WsPush, - type ServerProviderStatus, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -25,26 +22,17 @@ const showContextMenuFallbackMock = position?: { x: number; y: number }, ) => Promise >(); -const channelListeners = new Map void>>(); -const latestPushByChannel = new Map(); +const streamListeners = new Map void>>(); const subscribeMock = vi.fn< - ( - channel: string, - listener: (message: WsPush) => void, - options?: { replayLatest?: boolean }, - ) => () => void ->((channel, listener, options) => { - const listeners = channelListeners.get(channel) ?? new Set<(message: WsPush) => void>(); + (method: string, params: unknown, listener: (event: unknown) => void) => () => void +>((method, _params, listener) => { + const listeners = streamListeners.get(method) ?? new Set<(event: unknown) => void>(); listeners.add(listener); - channelListeners.set(channel, listeners); - const latest = latestPushByChannel.get(channel); - if (latest && options?.replayLatest) { - listener(latest); - } + streamListeners.set(method, listeners); return () => { listeners.delete(listener); if (listeners.size === 0) { - channelListeners.delete(channel); + streamListeners.delete(method); } }; }); @@ -54,9 +42,7 @@ vi.mock("./wsTransport", () => { WsTransport: class MockWsTransport { request = requestMock; subscribe = subscribeMock; - getLatestPush(channel: string) { - return latestPushByChannel.get(channel) ?? null; - } + dispose() {} }, }; }); @@ -65,23 +51,24 @@ vi.mock("./contextMenuFallback", () => ({ showContextMenuFallback: showContextMenuFallbackMock, })); -let nextPushSequence = 1; - -function emitPush(channel: C, data: WsPushData): void { - const listeners = channelListeners.get(channel); - const message = { - type: "push" as const, - sequence: nextPushSequence++, - channel, - data, - } as WsPushMessage; - latestPushByChannel.set(channel, message); - if (!listeners) return; +function emitStreamEvent(method: string, event: unknown) { + const listeners = streamListeners.get(method); + if (!listeners) { + return; + } for (const listener of listeners) { - listener(message); + listener(event); } } +function emitLifecycleEvent(event: ServerLifecycleStreamEvent) { + emitStreamEvent(WS_METHODS.subscribeServerLifecycle, event); +} + +function emitServerConfigEvent(event: ServerConfigStreamEvent) { + emitStreamEvent(WS_METHODS.subscribeServerConfig, event); +} + function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unknown } { const testGlobal = globalThis as typeof globalThis & { window?: Window & typeof globalThis & { desktopBridge?: unknown }; @@ -102,14 +89,21 @@ const defaultProviders: ReadonlyArray = [ }, ]; +const baseServerConfig: ServerConfig = { + cwd: "/tmp/workspace", + keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", + keybindings: [], + issues: [], + providers: defaultProviders, + availableEditors: ["cursor"], +}; + beforeEach(() => { vi.resetModules(); requestMock.mockReset(); showContextMenuFallbackMock.mockReset(); subscribeMock.mockClear(); - channelListeners.clear(); - latestPushByChannel.clear(); - nextPushSequence = 1; + streamListeners.clear(); Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); }); @@ -118,38 +112,53 @@ afterEach(() => { }); describe("wsNativeApi", () => { - it("delivers and caches valid server.welcome payloads", async () => { + it("delivers and caches welcome lifecycle events", async () => { const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); createWsNativeApi(); const listener = vi.fn(); onServerWelcome(listener); - const payload = { cwd: "/tmp/workspace", projectName: "t3-code" }; - emitPush(WS_CHANNELS.serverWelcome, payload); + emitLifecycleEvent({ + version: 1, + sequence: 1, + type: "welcome", + payload: { cwd: "/tmp/workspace", projectName: "t3-code" }, + }); expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload)); + expect(listener).toHaveBeenCalledWith({ + cwd: "/tmp/workspace", + projectName: "t3-code", + }); const lateListener = vi.fn(); onServerWelcome(lateListener); expect(lateListener).toHaveBeenCalledTimes(1); - expect(lateListener).toHaveBeenCalledWith(expect.objectContaining(payload)); + expect(lateListener).toHaveBeenCalledWith({ + cwd: "/tmp/workspace", + projectName: "t3-code", + }); }); - it("preserves bootstrap ids from server.welcome payloads", async () => { + it("preserves bootstrap ids from welcome lifecycle events", async () => { const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); createWsNativeApi(); const listener = vi.fn(); onServerWelcome(listener); - emitPush(WS_CHANNELS.serverWelcome, { - cwd: "/tmp/workspace", - projectName: "t3-code", - bootstrapProjectId: ProjectId.makeUnsafe("project-1"), - bootstrapThreadId: ThreadId.makeUnsafe("thread-1"), + emitLifecycleEvent({ + version: 1, + sequence: 1, + type: "welcome", + payload: { + cwd: "/tmp/workspace", + projectName: "t3-code", + bootstrapProjectId: ProjectId.makeUnsafe("project-1"), + bootstrapThreadId: ThreadId.makeUnsafe("thread-1"), + }, }); expect(listener).toHaveBeenCalledTimes(1); @@ -163,77 +172,112 @@ describe("wsNativeApi", () => { ); }); - it("delivers successive server.welcome payloads to active listeners", async () => { - const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); - - createWsNativeApi(); - const listener = vi.fn(); - onServerWelcome(listener); - - emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/one", projectName: "one" }); - emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/workspace", projectName: "t3-code" }); - - expect(listener).toHaveBeenCalledTimes(2); - expect(listener).toHaveBeenLastCalledWith( - expect.objectContaining({ - cwd: "/tmp/workspace", - projectName: "t3-code", - }), - ); - }); - - it("delivers and caches valid server.configUpdated payloads", async () => { + it("delivers and caches current server config from the config stream snapshot", async () => { const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi"); - createWsNativeApi(); + const api = createWsNativeApi(); const listener = vi.fn(); onServerConfigUpdated(listener); - const payload = { - issues: [ - { - kind: "keybindings.invalid-entry", - index: 1, - message: "Entry at index 1 is invalid.", - }, - ], - providers: defaultProviders, - } as const; - emitPush(WS_CHANNELS.serverConfigUpdated, payload); + const pendingConfig = api.server.getConfig(); + emitServerConfigEvent({ + version: 1, + type: "snapshot", + config: baseServerConfig, + }); + await expect(pendingConfig).resolves.toEqual(baseServerConfig); expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(payload); + expect(listener).toHaveBeenCalledWith( + { + issues: [], + providers: defaultProviders, + }, + "snapshot", + ); const lateListener = vi.fn(); onServerConfigUpdated(lateListener); + expect(lateListener).toHaveBeenCalledTimes(1); - expect(lateListener).toHaveBeenCalledWith(payload); + expect(lateListener).toHaveBeenCalledWith( + { + issues: [], + providers: defaultProviders, + }, + "snapshot", + ); }); - it("delivers successive server.configUpdated payloads to active listeners", async () => { + it("merges config stream updates into the cached server config", async () => { const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi"); - createWsNativeApi(); + const api = createWsNativeApi(); const listener = vi.fn(); onServerConfigUpdated(listener); - emitPush(WS_CHANNELS.serverConfigUpdated, { - issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], - providers: defaultProviders, + emitServerConfigEvent({ + version: 1, + type: "snapshot", + config: baseServerConfig, }); - emitPush(WS_CHANNELS.serverConfigUpdated, { - issues: [], - providers: defaultProviders, + emitServerConfigEvent({ + version: 1, + type: "keybindingsUpdated", + payload: { + issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], + }, }); - expect(listener).toHaveBeenCalledTimes(2); - expect(listener).toHaveBeenLastCalledWith({ - issues: [], - providers: defaultProviders, + const nextProviders: ReadonlyArray = [ + { + provider: "codex", + status: "warning", + available: true, + authStatus: "authenticated", + checkedAt: "2026-01-02T00:00:00.000Z", + message: "rate limited", + }, + ]; + emitServerConfigEvent({ + version: 1, + type: "providerStatuses", + payload: { + providers: nextProviders, + }, }); + + await expect(api.server.getConfig()).resolves.toEqual({ + ...baseServerConfig, + issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], + providers: nextProviders, + }); + expect(listener).toHaveBeenNthCalledWith( + 1, + { + issues: [], + providers: defaultProviders, + }, + "snapshot", + ); + expect(listener).toHaveBeenNthCalledWith( + 2, + { + issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], + providers: defaultProviders, + }, + "keybindingsUpdated", + ); + expect(listener).toHaveBeenLastCalledWith( + { + issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], + providers: nextProviders, + }, + "providerStatuses", + ); }); - it("forwards valid terminal and orchestration events", async () => { + it("forwards terminal and orchestration stream events", async () => { const { createWsNativeApi } = await import("./wsNativeApi"); const api = createWsNativeApi(); @@ -250,7 +294,7 @@ describe("wsNativeApi", () => { type: "output", data: "hello", } as const; - emitPush(WS_CHANNELS.terminalEvent, terminalEvent); + emitStreamEvent(WS_METHODS.subscribeTerminalEvents, terminalEvent); const orchestrationEvent = { sequence: 1, @@ -273,7 +317,7 @@ describe("wsNativeApi", () => { updatedAt: "2026-02-24T00:00:00.000Z", }, } satisfies Extract; - emitPush(ORCHESTRATION_WS_CHANNELS.domainEvent, orchestrationEvent); + emitStreamEvent(WS_METHODS.subscribeOrchestrationDomainEvents, orchestrationEvent); expect(onTerminalEvent).toHaveBeenCalledTimes(1); expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent); @@ -281,8 +325,8 @@ describe("wsNativeApi", () => { expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent); }); - it("wraps orchestration dispatch commands in the command envelope", async () => { - requestMock.mockResolvedValue(undefined); + it("sends orchestration dispatch commands as the direct RPC payload", async () => { + requestMock.mockResolvedValue({ sequence: 1 }); const { createWsNativeApi } = await import("./wsNativeApi"); const api = createWsNativeApi(); @@ -297,12 +341,10 @@ describe("wsNativeApi", () => { } as const; await api.orchestration.dispatchCommand(command); - expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.dispatchCommand, { - command, - }); + expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.dispatchCommand, command); }); - it("forwards workspace file writes to the websocket project method", async () => { + it("forwards workspace file writes to the project RPC", async () => { requestMock.mockResolvedValue({ relativePath: "plan.md" }); const { createWsNativeApi } = await import("./wsNativeApi"); @@ -320,7 +362,7 @@ describe("wsNativeApi", () => { }); }); - it("forwards full-thread diff requests to the orchestration websocket method", async () => { + it("forwards full-thread diff requests to the orchestration RPC", async () => { requestMock.mockResolvedValue({ diff: "patch" }); const { createWsNativeApi } = await import("./wsNativeApi"); @@ -336,7 +378,22 @@ describe("wsNativeApi", () => { }); }); - it("forwards context menu metadata to desktop bridge", async () => { + it("uses the config snapshot promise for server.getConfig consumers", async () => { + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + const configPromise = api.server.getConfig(); + + emitServerConfigEvent({ + version: 1, + type: "snapshot", + config: baseServerConfig, + }); + + await expect(configPromise).resolves.toEqual(baseServerConfig); + }); + + it("forwards context menu metadata to the desktop bridge", async () => { const showContextMenu = vi.fn().mockResolvedValue("delete"); Object.defineProperty(getWindowForTest(), "desktopBridge", { configurable: true, @@ -365,7 +422,7 @@ describe("wsNativeApi", () => { ); }); - it("uses fallback context menu when desktop bridge is unavailable", async () => { + it("uses the fallback context menu when the desktop bridge is unavailable", async () => { showContextMenuFallbackMock.mockResolvedValue("delete"); Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index ddfffbde69..021a4d4b44 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -1,10 +1,11 @@ import { - ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, type ContextMenuItem, type NativeApi, - ServerConfigUpdatedPayload, - WS_CHANNELS, + type ServerConfig, + type ServerConfigStreamEvent, + type ServerConfigUpdatedPayload, + type ServerLifecycleStreamEvent, WS_METHODS, type WsWelcomePayload, } from "@t3tools/contracts"; @@ -14,22 +15,128 @@ import { WsTransport } from "./wsTransport"; let instance: { api: NativeApi; transport: WsTransport } | null = null; const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); -const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>(); +export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"]; + +interface ServerConfigUpdatedNotification { + readonly payload: ServerConfigUpdatedPayload; + readonly source: ServerConfigUpdateSource; +} + +const serverConfigUpdatedListeners = new Set< + (payload: ServerConfigUpdatedPayload, source: ServerConfigUpdateSource) => void +>(); +const pendingServerConfigResolvers = new Set<(config: ServerConfig) => void>(); + +let latestWelcomePayload: WsWelcomePayload | null = null; +let latestServerConfig: ServerConfig | null = null; +let latestServerConfigUpdated: ServerConfigUpdatedNotification | null = null; + +function emitWelcome(payload: WsWelcomePayload) { + latestWelcomePayload = payload; + for (const listener of welcomeListeners) { + try { + listener(payload); + } catch { + // Swallow listener errors. + } + } +} + +function resolveServerConfig(config: ServerConfig) { + latestServerConfig = config; + for (const resolve of pendingServerConfigResolvers) { + resolve(config); + } + pendingServerConfigResolvers.clear(); +} + +function emitServerConfigUpdated( + payload: ServerConfigUpdatedPayload, + source: ServerConfigUpdateSource, +) { + latestServerConfigUpdated = { payload, source }; + for (const listener of serverConfigUpdatedListeners) { + try { + listener(payload, source); + } catch { + // Swallow listener errors. + } + } +} + +function applyServerConfigEvent(event: ServerConfigStreamEvent) { + switch (event.type) { + case "snapshot": { + resolveServerConfig(event.config); + emitServerConfigUpdated( + { + issues: event.config.issues, + providers: event.config.providers, + }, + event.type, + ); + return; + } + case "keybindingsUpdated": { + if (!latestServerConfig) { + return; + } + const nextConfig = { + ...latestServerConfig, + issues: event.payload.issues, + } satisfies ServerConfig; + resolveServerConfig(nextConfig); + emitServerConfigUpdated( + { + issues: nextConfig.issues, + providers: nextConfig.providers, + }, + event.type, + ); + return; + } + case "providerStatuses": { + if (!latestServerConfig) { + return; + } + const nextConfig = { + ...latestServerConfig, + providers: event.payload.providers, + } satisfies ServerConfig; + resolveServerConfig(nextConfig); + emitServerConfigUpdated( + { + issues: nextConfig.issues, + providers: nextConfig.providers, + }, + event.type, + ); + return; + } + } +} + +function getServerConfigSnapshot(): Promise { + if (latestServerConfig) { + return Promise.resolve(latestServerConfig); + } + return new Promise((resolve) => { + pendingServerConfigResolvers.add(resolve); + }); +} /** * Subscribe to the server welcome message. If a welcome was already received * before this call, the listener fires synchronously with the cached payload. - * This avoids the race between WebSocket connect and React effect registration. */ export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): () => void { welcomeListeners.add(listener); - const latestWelcome = instance?.transport.getLatestPush(WS_CHANNELS.serverWelcome)?.data ?? null; - if (latestWelcome) { + if (latestWelcomePayload) { try { - listener(latestWelcome); + listener(latestWelcomePayload); } catch { - // Swallow listener errors + // Swallow listener errors. } } @@ -43,17 +150,15 @@ export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): * late subscribers to avoid missing config validation feedback. */ export function onServerConfigUpdated( - listener: (payload: ServerConfigUpdatedPayload) => void, + listener: (payload: ServerConfigUpdatedPayload, source: ServerConfigUpdateSource) => void, ): () => void { serverConfigUpdatedListeners.add(listener); - const latestConfig = - instance?.transport.getLatestPush(WS_CHANNELS.serverConfigUpdated)?.data ?? null; - if (latestConfig) { + if (latestServerConfigUpdated) { try { - listener(latestConfig); + listener(latestServerConfigUpdated.payload, latestServerConfigUpdated.source); } catch { - // Swallow listener errors + // Swallow listener errors. } } @@ -63,29 +168,23 @@ export function onServerConfigUpdated( } export function createWsNativeApi(): NativeApi { - if (instance) return instance.api; + if (instance) { + return instance.api; + } const transport = new WsTransport(); - transport.subscribe(WS_CHANNELS.serverWelcome, (message) => { - const payload = message.data; - for (const listener of welcomeListeners) { - try { - listener(payload); - } catch { - // Swallow listener errors + transport.subscribe( + WS_METHODS.subscribeServerLifecycle, + {}, + (event: ServerLifecycleStreamEvent) => { + if (event.type === "welcome") { + emitWelcome(event.payload); } - } - }); - transport.subscribe(WS_CHANNELS.serverConfigUpdated, (message) => { - const payload = message.data; - for (const listener of serverConfigUpdatedListeners) { - try { - listener(payload); - } catch { - // Swallow listener errors - } - } + }, + ); + transport.subscribe(WS_METHODS.subscribeServerConfig, {}, (event: ServerConfigStreamEvent) => { + applyServerConfigEvent(event); }); const api: NativeApi = { @@ -108,8 +207,7 @@ export function createWsNativeApi(): NativeApi { clear: (input) => transport.request(WS_METHODS.terminalClear, input), restart: (input) => transport.request(WS_METHODS.terminalRestart, input), close: (input) => transport.request(WS_METHODS.terminalClose, input), - onEvent: (callback) => - transport.subscribe(WS_CHANNELS.terminalEvent, (message) => callback(message.data)), + onEvent: (callback) => transport.subscribe(WS_METHODS.subscribeTerminalEvents, {}, callback), }, projects: { searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), @@ -127,8 +225,6 @@ export function createWsNativeApi(): NativeApi { return; } - // Some mobile browsers can return null here even when the tab opens. - // Avoid false negatives and let the browser handle popup policy. window.open(url, "_blank", "noopener,noreferrer"); }, }, @@ -158,22 +254,20 @@ export function createWsNativeApi(): NativeApi { }, }, server: { - getConfig: () => transport.request(WS_METHODS.serverGetConfig), + getConfig: () => getServerConfigSnapshot(), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), }, orchestration: { - getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), + getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot, {}), dispatchCommand: (command) => - transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, { command }), + transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, command), getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input), getFullThreadDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getFullThreadDiff, input), replayEvents: (fromSequenceExclusive) => transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }), onDomainEvent: (callback) => - transport.subscribe(ORCHESTRATION_WS_CHANNELS.domainEvent, (message) => - callback(message.data), - ), + transport.subscribe(WS_METHODS.subscribeOrchestrationDomainEvents, {}, callback), }, }; diff --git a/apps/web/src/wsTransport.test.ts b/apps/web/src/wsTransport.test.ts index d905bbcf9a..4b615d64ce 100644 --- a/apps/web/src/wsTransport.test.ts +++ b/apps/web/src/wsTransport.test.ts @@ -1,10 +1,11 @@ -import { WS_CHANNELS } from "@t3tools/contracts"; +import { WS_METHODS } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { WsTransport } from "./wsTransport"; type WsEventType = "open" | "message" | "close" | "error"; -type WsListener = (event?: { data?: unknown }) => void; +type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; +type WsListener = (event?: WsEvent) => void; const sockets: MockWebSocket[] = []; @@ -16,9 +17,11 @@ class MockWebSocket { readyState = MockWebSocket.CONNECTING; readonly sent: string[] = []; + readonly url: string; private readonly listeners = new Map>(); - constructor(_url: string) { + constructor(url: string) { + this.url = url; sockets.push(this); } @@ -28,25 +31,29 @@ class MockWebSocket { this.listeners.set(type, listeners); } + removeEventListener(type: WsEventType, listener: WsListener) { + this.listeners.get(type)?.delete(listener); + } + send(data: string) { this.sent.push(data); } - close() { + close(code = 1000, reason = "") { this.readyState = MockWebSocket.CLOSED; - this.emit("close"); + this.emit("close", { code, reason, type: "close" }); } open() { this.readyState = MockWebSocket.OPEN; - this.emit("open"); + this.emit("open", { type: "open" }); } serverMessage(data: unknown) { - this.emit("message", { data }); + this.emit("message", { data, type: "message" }); } - private emit(type: WsEventType, event?: { data?: unknown }) { + private emit(type: WsEventType, event?: WsEvent) { const listeners = this.listeners.get(type); if (!listeners) return; for (const listener of listeners) { @@ -65,13 +72,28 @@ function getSocket(): MockWebSocket { return socket; } +async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise { + const startedAt = Date.now(); + for (;;) { + try { + assertion(); + return; + } catch (error) { + if (Date.now() - startedAt >= timeoutMs) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } +} + beforeEach(() => { sockets.length = 0; Object.defineProperty(globalThis, "window", { configurable: true, value: { - location: { hostname: "localhost", port: "3020" }, + location: { hostname: "localhost", port: "3020", protocol: "ws:" }, desktopBridge: undefined, }, }); @@ -85,125 +107,205 @@ afterEach(() => { }); describe("WsTransport", () => { - it("routes valid push envelopes to channel listeners", () => { + it("normalizes root websocket urls to /ws and preserves query params", async () => { + const transport = new WsTransport("ws://localhost:3020/?token=secret-token"); + + await waitFor(() => { + expect(sockets).toHaveLength(1); + }); + + expect(getSocket().url).toBe("ws://localhost:3020/ws?token=secret-token"); + transport.dispose(); + }); + + it("sends unary RPC requests and resolves successful exits", async () => { const transport = new WsTransport("ws://localhost:3020"); + + const requestPromise = transport.request(WS_METHODS.serverUpsertKeybinding, { + command: "terminal.toggle", + key: "ctrl+k", + }); + + await waitFor(() => { + expect(sockets).toHaveLength(1); + }); + const socket = getSocket(); + expect(socket.sent).toHaveLength(0); socket.open(); - const listener = vi.fn(); - transport.subscribe(WS_CHANNELS.serverConfigUpdated, listener); + await waitFor(() => { + expect(socket.sent).toHaveLength(1); + }); + + const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { + _tag: string; + id: string; + payload: unknown; + tag: string; + }; + expect(requestMessage).toMatchObject({ + _tag: "Request", + tag: WS_METHODS.serverUpsertKeybinding, + payload: { + command: "terminal.toggle", + key: "ctrl+k", + }, + }); socket.serverMessage( JSON.stringify({ - type: "push", - sequence: 1, - channel: WS_CHANNELS.serverConfigUpdated, - data: { issues: [], providers: [] }, + _tag: "Exit", + requestId: requestMessage.id, + exit: { + _tag: "Success", + value: { + keybindings: [], + issues: [], + }, + }, }), ); - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith({ - type: "push", - sequence: 1, - channel: WS_CHANNELS.serverConfigUpdated, - data: { issues: [], providers: [] }, + await expect(requestPromise).resolves.toEqual({ + keybindings: [], + issues: [], }); transport.dispose(); }); - it("resolves pending requests for valid response envelopes", async () => { + it("delivers stream chunks to subscribers", async () => { const transport = new WsTransport("ws://localhost:3020"); + const listener = vi.fn(); + + const unsubscribe = transport.subscribe(WS_METHODS.subscribeServerLifecycle, {}, listener); + await waitFor(() => { + expect(sockets).toHaveLength(1); + }); + const socket = getSocket(); socket.open(); - const requestPromise = transport.request("projects.list"); - const sent = socket.sent.at(-1); - if (!sent) { - throw new Error("Expected request envelope to be sent"); - } + await waitFor(() => { + expect(socket.sent).toHaveLength(1); + }); + + const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string }; + expect(requestMessage.tag).toBe(WS_METHODS.subscribeServerLifecycle); + + const welcomeEvent = { + version: 1, + sequence: 1, + type: "welcome", + payload: { + cwd: "/tmp/workspace", + projectName: "workspace", + }, + }; - const requestEnvelope = JSON.parse(sent) as { id: string }; socket.serverMessage( JSON.stringify({ - id: requestEnvelope.id, - result: { projects: [] }, + _tag: "Chunk", + requestId: requestMessage.id, + values: [welcomeEvent], }), ); - await expect(requestPromise).resolves.toEqual({ projects: [] }); + await waitFor(() => { + expect(listener).toHaveBeenCalledWith(welcomeEvent); + }); + unsubscribe(); transport.dispose(); }); - it("drops malformed envelopes without crashing transport", () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + it("re-subscribes stream listeners after the stream exits", async () => { const transport = new WsTransport("ws://localhost:3020"); + const listener = vi.fn(); + + const unsubscribe = transport.subscribe(WS_METHODS.subscribeServerLifecycle, {}, listener); + await waitFor(() => { + expect(sockets).toHaveLength(1); + }); + const socket = getSocket(); socket.open(); - const listener = vi.fn(); - transport.subscribe(WS_CHANNELS.serverConfigUpdated, listener); + await waitFor(() => { + expect(socket.sent).toHaveLength(1); + }); - socket.serverMessage("{ invalid-json"); + const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; socket.serverMessage( JSON.stringify({ - type: "push", - sequence: 2, - channel: 42, - data: { bad: true }, + _tag: "Chunk", + requestId: firstRequest.id, + values: [ + { + version: 1, + sequence: 1, + type: "welcome", + payload: { + cwd: "/tmp/one", + projectName: "one", + }, + }, + ], }), ); socket.serverMessage( JSON.stringify({ - type: "push", - sequence: 3, - channel: WS_CHANNELS.serverConfigUpdated, - data: { issues: [], providers: [] }, + _tag: "Exit", + requestId: firstRequest.id, + exit: { + _tag: "Success", + value: null, + }, }), ); - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith({ - type: "push", - sequence: 3, - channel: WS_CHANNELS.serverConfigUpdated, - data: { issues: [], providers: [] }, + await waitFor(() => { + const nextRequest = socket.sent + .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) + .find((message) => message._tag === "Request" && message.id !== firstRequest.id); + expect(nextRequest).toBeDefined(); }); - expect(warnSpy).toHaveBeenCalledTimes(2); - expect(warnSpy).toHaveBeenNthCalledWith( - 1, - "Dropped inbound WebSocket envelope", - "SyntaxError: Expected property name or '}' in JSON at position 2 (line 1 column 3)", - ); - expect(warnSpy).toHaveBeenNthCalledWith( - 2, - "Dropped inbound WebSocket envelope", - expect.stringContaining('Expected "server.configUpdated"'), - ); - - transport.dispose(); - }); - - it("queues requests until the websocket opens", async () => { - const transport = new WsTransport("ws://localhost:3020"); - const socket = getSocket(); - const requestPromise = transport.request("projects.list"); - expect(socket.sent).toHaveLength(0); - - socket.open(); - expect(socket.sent).toHaveLength(1); - const requestEnvelope = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; + const secondRequest = socket.sent + .map((message) => JSON.parse(message) as { _tag?: string; id?: string; tag?: string }) + .find( + (message): message is { _tag: "Request"; id: string; tag: string } => + message._tag === "Request" && message.id !== firstRequest.id, + ); + if (!secondRequest) { + throw new Error("Expected a resubscribe request"); + } + expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle); + expect(secondRequest.id).not.toBe(firstRequest.id); + + const secondEvent = { + version: 1, + sequence: 2, + type: "welcome", + payload: { + cwd: "/tmp/two", + projectName: "two", + }, + }; socket.serverMessage( JSON.stringify({ - id: requestEnvelope.id, - result: { projects: [] }, + _tag: "Chunk", + requestId: secondRequest.id, + values: [secondEvent], }), ); - await expect(requestPromise).resolves.toEqual({ projects: [] }); + await waitFor(() => { + expect(listener).toHaveBeenLastCalledWith(secondEvent); + }); + + unsubscribe(); transport.dispose(); }); }); diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index 46c74d9090..da22135a2f 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -1,43 +1,23 @@ -import { - type WsPush, - type WsPushChannel, - type WsPushMessage, - WebSocketResponse, - type WsResponse as WsResponseMessage, - WsResponse as WsResponseSchema, -} from "@t3tools/contracts"; -import { decodeUnknownJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; -import { Result, Schema } from "effect"; +import { Data, Effect, Exit, Layer, ManagedRuntime, Scope, Stream } from "effect"; +import { WsRpcGroup } from "@t3tools/contracts"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import * as Socket from "effect/unstable/socket/Socket"; -type PushListener = (message: WsPushMessage) => void; +const makeWsRpcClient = RpcClient.make(WsRpcGroup); -interface PendingRequest { - resolve: (result: unknown) => void; - reject: (error: Error) => void; - timeout: ReturnType; -} +type RpcClientFactory = typeof makeWsRpcClient; +type WsRpcClient = RpcClientFactory extends Effect.Effect ? Client : never; +type WsRpcClientMethods = Record unknown>; interface SubscribeOptions { - readonly replayLatest?: boolean; + readonly retryDelayMs?: number; } -type TransportState = "connecting" | "open" | "reconnecting" | "closed" | "disposed"; - -const REQUEST_TIMEOUT_MS = 60_000; -const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000]; -const decodeWsResponse = decodeUnknownJsonResult(WsResponseSchema); -const isWebSocketResponseEnvelope = Schema.is(WebSocketResponse); - -const isWsPushMessage = (value: WsResponseMessage): value is WsPush => - "type" in value && value.type === "push"; +const DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS = 250; -interface WsRequestEnvelope { - id: string; - body: { - _tag: string; - [key: string]: unknown; - }; -} +class WsTransportStreamMethodError extends Data.TaggedError("WsTransportStreamMethodError")<{ + readonly method: string; +}> {} function asError(value: unknown, fallback: string): Error { if (value instanceof Error) { @@ -46,240 +26,136 @@ function asError(value: unknown, fallback: string): Error { return new Error(fallback); } +function formatErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + return String(error); +} + +function resolveWebSocketUrl(url?: string): string { + const bridgeUrl = window.desktopBridge?.getWsUrl(); + const envUrl = import.meta.env.VITE_WS_URL as string | undefined; + const rawUrl = + url ?? + (bridgeUrl && bridgeUrl.length > 0 + ? bridgeUrl + : envUrl && envUrl.length > 0 + ? envUrl + : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`); + + const parsedUrl = new URL(rawUrl); + if (parsedUrl.pathname === "/" || parsedUrl.pathname.length === 0) { + parsedUrl.pathname = "/ws"; + } + return parsedUrl.toString(); +} + export class WsTransport { - private ws: WebSocket | null = null; - private nextId = 1; - private readonly pending = new Map(); - private readonly listeners = new Map void>>(); - private readonly latestPushByChannel = new Map(); - private readonly outboundQueue: string[] = []; - private reconnectAttempt = 0; - private reconnectTimer: ReturnType | null = null; + private readonly runtime: ManagedRuntime.ManagedRuntime; + private readonly clientScope: Scope.Closeable; + private readonly clientPromise: Promise; private disposed = false; - private state: TransportState = "connecting"; - private readonly url: string; constructor(url?: string) { - const bridgeUrl = window.desktopBridge?.getWsUrl(); - const envUrl = import.meta.env.VITE_WS_URL as string | undefined; - this.url = - url ?? - (bridgeUrl && bridgeUrl.length > 0 - ? bridgeUrl - : envUrl && envUrl.length > 0 - ? envUrl - : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`); - this.connect(); + const resolvedUrl = resolveWebSocketUrl(url); + const runtimeLayer = RpcClient.layerProtocolSocket({ retryTransientErrors: true }).pipe( + Layer.provide( + Layer.mergeAll( + Socket.layerWebSocket(resolvedUrl).pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + ), + RpcSerialization.layerJson, + ), + ), + ); + + this.runtime = ManagedRuntime.make(runtimeLayer); + this.clientScope = Effect.runSync(Scope.make()); + this.clientPromise = this.runtime.runPromise(Scope.provide(this.clientScope)(makeWsRpcClient)); } async request(method: string, params?: unknown): Promise { + if (this.disposed) { + throw new Error("Transport disposed"); + } if (typeof method !== "string" || method.length === 0) { throw new Error("Request method is required"); } - const id = String(this.nextId++); - const body = params != null ? { ...params, _tag: method } : { _tag: method }; - const message: WsRequestEnvelope = { id, body }; - const encoded = JSON.stringify(message); - - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.pending.delete(id); - reject(new Error(`Request timed out: ${method}`)); - }, REQUEST_TIMEOUT_MS); - - this.pending.set(id, { - resolve: resolve as (result: unknown) => void, - reject, - timeout, - }); - - this.send(encoded); - }); + try { + const client = await this.clientPromise; + const handler = (client as WsRpcClientMethods)[method]; + if (typeof handler !== "function") { + throw new Error(`Unknown RPC method: ${method}`); + } + return (await Effect.runPromise( + Effect.suspend(() => handler(params ?? {}) as Effect.Effect), + )) as T; + } catch (error) { + throw asError(error, `Request failed: ${method}`); + } } - subscribe( - channel: C, - listener: PushListener, + subscribe( + method: string, + params: unknown, + listener: (value: T) => void, options?: SubscribeOptions, ): () => void { - let channelListeners = this.listeners.get(channel); - if (!channelListeners) { - channelListeners = new Set<(message: WsPush) => void>(); - this.listeners.set(channel, channelListeners); + if (this.disposed) { + return () => undefined; } - const wrappedListener = (message: WsPush) => { - listener(message as WsPushMessage); - }; - channelListeners.add(wrappedListener); - - if (options?.replayLatest) { - const latest = this.latestPushByChannel.get(channel); - if (latest) { - wrappedListener(latest); - } - } + let active = true; + const retryDelayMs = options?.retryDelayMs ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS; + const cancel = Effect.runCallback( + Effect.promise(() => this.clientPromise).pipe( + Effect.flatMap((client) => { + const handler = (client as WsRpcClientMethods)[method]; + if (typeof handler !== "function") { + return Effect.fail(new WsTransportStreamMethodError({ method })); + } + return Stream.runForEach(handler(params ?? {}) as Stream.Stream, (value) => + Effect.sync(() => { + if (!active) { + return; + } + try { + listener(value); + } catch { + // Swallow listener errors so the stream stays live. + } + }), + ); + }), + Effect.catch((error) => { + if (!active || this.disposed) { + return Effect.interrupt; + } + return Effect.sync(() => { + console.warn("WebSocket RPC subscription disconnected", { + method, + error: formatErrorMessage(error), + }); + }).pipe(Effect.andThen(Effect.sleep(`${retryDelayMs} millis`))); + }), + Effect.forever, + ), + ); return () => { - channelListeners?.delete(wrappedListener); - if (channelListeners?.size === 0) { - this.listeners.delete(channel); - } + active = false; + cancel(); }; } - getLatestPush(channel: C): WsPushMessage | null { - const latest = this.latestPushByChannel.get(channel); - return latest ? (latest as WsPushMessage) : null; - } - - getState(): TransportState { - return this.state; - } - dispose() { - this.disposed = true; - this.state = "disposed"; - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - for (const pending of this.pending.values()) { - clearTimeout(pending.timeout); - pending.reject(new Error("Transport disposed")); - } - this.pending.clear(); - this.outboundQueue.length = 0; - this.ws?.close(); - this.ws = null; - } - - private connect() { if (this.disposed) { return; } - - this.state = this.reconnectAttempt > 0 ? "reconnecting" : "connecting"; - const ws = new WebSocket(this.url); - - ws.addEventListener("open", () => { - this.ws = ws; - this.state = "open"; - this.reconnectAttempt = 0; - this.flushQueue(); - }); - - ws.addEventListener("message", (event) => { - this.handleMessage(event.data); - }); - - ws.addEventListener("close", () => { - if (this.ws === ws) { - this.ws = null; - } - if (this.disposed) { - this.state = "disposed"; - return; - } - this.state = "closed"; - this.scheduleReconnect(); - }); - - ws.addEventListener("error", (event) => { - // Log WebSocket errors for debugging (close event will follow) - console.warn("WebSocket connection error", { type: event.type, url: this.url }); - }); - } - - private handleMessage(raw: unknown) { - const result = decodeWsResponse(raw); - if (Result.isFailure(result)) { - console.warn("Dropped inbound WebSocket envelope", formatSchemaError(result.failure)); - return; - } - - const message = result.success; - if (isWsPushMessage(message)) { - this.latestPushByChannel.set(message.channel, message); - const channelListeners = this.listeners.get(message.channel); - if (channelListeners) { - for (const listener of channelListeners) { - try { - listener(message); - } catch { - // Swallow listener errors - } - } - } - return; - } - - if (!isWebSocketResponseEnvelope(message)) { - return; - } - - const pending = this.pending.get(message.id); - if (!pending) { - return; - } - - clearTimeout(pending.timeout); - this.pending.delete(message.id); - - if (message.error) { - pending.reject(new Error(message.error.message)); - return; - } - - pending.resolve(message.result); - } - - private send(encodedMessage: string) { - if (this.disposed) { - return; - } - - this.outboundQueue.push(encodedMessage); - try { - this.flushQueue(); - } catch { - // Swallow: flushQueue has queued the message for retry on reconnect - } - } - - private flushQueue() { - if (this.ws?.readyState !== WebSocket.OPEN) { - return; - } - - while (this.outboundQueue.length > 0) { - const message = this.outboundQueue.shift(); - if (!message) { - continue; - } - try { - this.ws.send(message); - } catch (error) { - this.outboundQueue.unshift(message); - throw asError(error, "Failed to send WebSocket request."); - } - } - } - - private scheduleReconnect() { - if (this.disposed || this.reconnectTimer !== null) { - return; - } - - const delay = - RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ?? - RECONNECT_DELAYS_MS[0]!; - - this.reconnectAttempt += 1; - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; - this.connect(); - }, delay); + this.disposed = true; + void Effect.runPromise(Scope.close(this.clientScope, Exit.void)); + void this.runtime.dispose(); } } diff --git a/bun.lock b/bun.lock index f46b46e3bd..7d563cd0b9 100644 --- a/bun.lock +++ b/bun.lock @@ -59,6 +59,7 @@ }, "devDependencies": { "@effect/language-service": "catalog:", + "@effect/platform-bun": "catalog:", "@effect/vitest": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", @@ -173,12 +174,13 @@ }, "catalog": { "@effect/language-service": "0.75.1", - "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@d53a9a7", - "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@d53a9a7", - "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@d53a9a7", + "@effect/platform-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-bun@88a7553", + "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@88a7553", + "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@88a7553", + "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@88a7553", "@types/bun": "^1.3.9", "@types/node": "^24.10.13", - "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@d53a9a7", + "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@88a7553", "tsdown": "^0.20.3", "typescript": "^5.7.3", "vitest": "^4.0.0", @@ -266,13 +268,15 @@ "@effect/language-service": ["@effect/language-service@0.75.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-g9xD2tAQgRFpYC2YgpZq02VeSL5fBbFJ0B/g1o+14NuNmwtaYJc7SjiLWAA9eyhJHosNrn6h1Ye+Kx6j5mN0AA=="], - "@effect/platform-node": ["@effect/platform-node@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@d53a9a7", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@d53a9a758bd6321ffd97432769bd0e9fac486999", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.32", "ioredis": "^5.7.0" } }], + "@effect/platform-bun": ["@effect/platform-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-bun@88a7553", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@88a7553085a8e6e9d456a496a6075e1e573e6d01" }, "peerDependencies": { "effect": "^4.0.0-beta.32" } }], - "@effect/platform-node-shared": ["@effect/platform-node-shared@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@d53a9a758bd6321ffd97432769bd0e9fac486999", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.32" } }], + "@effect/platform-node": ["@effect/platform-node@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@88a7553", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@88a7553085a8e6e9d456a496a6075e1e573e6d01", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.32", "ioredis": "^5.7.0" } }], - "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@d53a9a7", { "peerDependencies": { "effect": "^4.0.0-beta.32" } }], + "@effect/platform-node-shared": ["@effect/platform-node-shared@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@88a7553085a8e6e9d456a496a6075e1e573e6d01", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.32" } }], - "@effect/vitest": ["@effect/vitest@https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@d53a9a7", { "peerDependencies": { "effect": "^4.0.0-beta.32", "vitest": "^3.0.0 || ^4.0.0" } }], + "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@88a7553", { "peerDependencies": { "effect": "^4.0.0-beta.32" } }], + + "@effect/vitest": ["@effect/vitest@https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@88a7553", { "peerDependencies": { "effect": "^4.0.0-beta.32", "vitest": "^3.0.0 || ^4.0.0" } }], "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], @@ -1014,7 +1018,7 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "effect": ["effect@https://pkg.pr.new/Effect-TS/effect-smol/effect@d53a9a7", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }], + "effect": ["effect@https://pkg.pr.new/Effect-TS/effect-smol/effect@88a7553", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }], "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], diff --git a/package.json b/package.json index 2faaed248b..760bb8f1e4 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,11 @@ "scripts" ], "catalog": { - "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@d53a9a7", - "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@d53a9a7", - "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@d53a9a7", - "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@d53a9a7", + "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@88a7553", + "@effect/platform-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-bun@88a7553", + "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@88a7553", + "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@88a7553", + "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@88a7553", "@effect/language-service": "0.75.1", "@types/bun": "^1.3.9", "@types/node": "^24.10.13", From 80b42f586697ccd62e81fa8f591b2c1a9cd71a1f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 01:40:28 -0700 Subject: [PATCH 22/47] Derive server paths from base dir and unify platform layers - Replace state-dir config with T3CODE_HOME/base-dir derivation - Route attachments and provider logs through derived paths - Lazily load Bun/Node HTTP, PTY, and platform services --- apps/server/src/cli-config.test.ts | 23 ++-- apps/server/src/cli.ts | 27 ++--- apps/server/src/http.ts | 4 +- apps/server/src/orchestration/Normalizer.ts | 2 +- .../src/persistence/NodeSqliteClient.ts | 18 ++- apps/server/src/server.test.ts | 26 ++-- apps/server/src/server.ts | 113 +++++++++++------- apps/server/src/serverLogger.test.ts | 11 +- 8 files changed, 130 insertions(+), 94 deletions(-) diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts index b11f62724a..e91770db6c 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli-config.test.ts @@ -5,19 +5,21 @@ import { ConfigProvider, Effect, Layer, Option, Path } from "effect"; import { NetService } from "@t3tools/shared/Net"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { deriveServerPaths } from "./config"; import { resolveServerConfig } from "./cli"; it.layer(NodeServices.layer)("cli config resolution", (it) => { it.effect("falls back to effect/config values when flags are omitted", () => Effect.gen(function* () { const { join } = yield* Path.Path; - const stateDir = join(os.tmpdir(), "t3-cli-config-env-state"); + const baseDir = join(os.tmpdir(), "t3-cli-config-env-base"); + const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173")); const resolved = yield* resolveServerConfig( { mode: Option.none(), port: Option.none(), host: Option.none(), - stateDir: Option.none(), + baseDir: Option.none(), devUrl: Option.none(), noBrowser: Option.none(), authToken: Option.none(), @@ -35,7 +37,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_MODE: "desktop", T3CODE_PORT: "4001", T3CODE_HOST: "0.0.0.0", - T3CODE_STATE_DIR: stateDir, + T3CODE_HOME: baseDir, VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", T3CODE_NO_BROWSER: "true", T3CODE_AUTH_TOKEN: "env-token", @@ -54,9 +56,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { mode: "desktop", port: 4001, cwd: process.cwd(), - keybindingsConfigPath: join(stateDir, "keybindings.json"), + baseDir, + ...derivedPaths, host: "0.0.0.0", - stateDir, staticDir: undefined, devUrl: new URL("http://127.0.0.1:5173"), noBrowser: true, @@ -70,13 +72,14 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { it.effect("uses CLI flags when provided", () => Effect.gen(function* () { const { join } = yield* Path.Path; - const stateDir = join(os.tmpdir(), "t3-cli-config-flags-state"); + const baseDir = join(os.tmpdir(), "t3-cli-config-flags-base"); + const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); const resolved = yield* resolveServerConfig( { mode: Option.some("web"), port: Option.some(8788), host: Option.some("127.0.0.1"), - stateDir: Option.some(stateDir), + baseDir: Option.some(baseDir), devUrl: Option.some(new URL("http://127.0.0.1:4173")), noBrowser: Option.some(true), authToken: Option.some("flag-token"), @@ -94,7 +97,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_MODE: "desktop", T3CODE_PORT: "4001", T3CODE_HOST: "0.0.0.0", - T3CODE_STATE_DIR: join(os.tmpdir(), "ignored-state"), + T3CODE_HOME: join(os.tmpdir(), "ignored-base"), VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", T3CODE_NO_BROWSER: "false", T3CODE_AUTH_TOKEN: "ignored-token", @@ -113,9 +116,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { mode: "web", port: 8788, cwd: process.cwd(), - keybindingsConfigPath: join(stateDir, "keybindings.json"), + baseDir, + ...derivedPaths, host: "127.0.0.1", - stateDir, staticDir: undefined, devUrl: new URL("http://127.0.0.1:4173"), noBrowser: true, diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index bc67c1ae0e..0c2b3fbb29 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -1,15 +1,16 @@ import { NetService } from "@t3tools/shared/Net"; -import { Config, Effect, LogLevel, Option, Path, Schema } from "effect"; +import { Config, Effect, LogLevel, Option, Schema } from "effect"; import { Command, Flag, GlobalFlag } from "effect/unstable/cli"; import { DEFAULT_PORT, + deriveServerPaths, resolveStaticDir, ServerConfig, type RuntimeMode, type ServerConfigShape, } from "./config"; -import { resolveStateDir } from "./os-jank"; +import { resolveBaseDir } from "./os-jank"; import { runServer } from "./server"; const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe( @@ -25,8 +26,8 @@ const hostFlag = Flag.string("host").pipe( Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), Flag.optional, ); -const stateDirFlag = Flag.string("state-dir").pipe( - Flag.withDescription("State directory path (equivalent to T3CODE_STATE_DIR)."), +const baseDirFlag = Flag.string("base-dir").pipe( + Flag.withDescription("Base directory path (equivalent to T3CODE_HOME)."), Flag.optional, ); const devUrlFlag = Flag.string("dev-url").pipe( @@ -70,10 +71,7 @@ const EnvServerConfig = Config.all({ ), port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), - stateDir: Config.string("T3CODE_STATE_DIR").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), + t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( Config.option, @@ -97,7 +95,7 @@ interface CliServerFlags { readonly mode: Option.Option; readonly port: Option.Option; readonly host: Option.Option; - readonly stateDir: Option.Option; + readonly baseDir: Option.Option; readonly devUrl: Option.Option; readonly noBrowser: Option.Option; readonly authToken: Option.Option; @@ -130,8 +128,9 @@ export const resolveServerConfig = ( return findAvailablePort(DEFAULT_PORT); }, }); - const stateDir = yield* resolveStateDir(Option.getOrUndefined(flags.stateDir) ?? env.stateDir); const devUrl = Option.getOrElse(flags.devUrl, () => env.devUrl); + const baseDir = yield* resolveBaseDir(Option.getOrUndefined(flags.baseDir) ?? env.t3Home); + const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); const noBrowser = resolveBooleanFlag(flags.noBrowser, env.noBrowser ?? mode === "desktop"); const authToken = Option.getOrUndefined(flags.authToken) ?? env.authToken; const autoBootstrapProjectFromCwd = resolveBooleanFlag( @@ -143,8 +142,6 @@ export const resolveServerConfig = ( env.logWebSocketEvents ?? Boolean(devUrl), ); const staticDir = devUrl ? undefined : yield* resolveStaticDir(); - const { join } = yield* Path.Path; - const keybindingsConfigPath = join(stateDir, "keybindings.json"); const host = Option.getOrUndefined(flags.host) ?? env.host ?? @@ -156,9 +153,9 @@ export const resolveServerConfig = ( mode, port, cwd: process.cwd(), - keybindingsConfigPath, + baseDir, + ...derivedPaths, host, - stateDir, staticDir, devUrl, noBrowser, @@ -174,7 +171,7 @@ const commandFlags = { mode: modeFlag, port: portFlag, host: hostFlag, - stateDir: stateDirFlag, + baseDir: baseDirFlag, devUrl: devUrlFlag, noBrowser: noBrowserFlag, authToken: authTokenFlag, diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index ef5e271fb0..0f14924fd1 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -39,11 +39,11 @@ export const attachmentsRouteLayer = HttpRouter.add( !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes("."); const filePath = isIdLookup ? resolveAttachmentPathById({ - stateDir: config.stateDir, + attachmentsDir: config.attachmentsDir, attachmentId: normalizedRelativePath, }) : resolveAttachmentRelativePath({ - stateDir: config.stateDir, + attachmentsDir: config.attachmentsDir, relativePath: normalizedRelativePath, }); if (!filePath) { diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index c433be4f0f..7c9d68dc41 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -88,7 +88,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => }; const attachmentPath = resolveAttachmentPath({ - stateDir: serverConfig.stateDir, + attachmentsDir: serverConfig.attachmentsDir, attachment: persistedAttachment, }); if (!attachmentPath) { diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index 1d6e22d9b0..d070121ec4 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -20,7 +20,7 @@ import * as Stream from "effect/Stream"; import * as Reactivity from "effect/unstable/reactivity/Reactivity"; import * as Client from "effect/unstable/sql/SqlClient"; import type { Connection } from "effect/unstable/sql/SqlConnection"; -import { SqlError } from "effect/unstable/sql/SqlError"; +import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"; import * as Statement from "effect/unstable/sql/Statement"; const ATTR_DB_SYSTEM_NAME = "db.system.name"; @@ -109,7 +109,10 @@ const makeWithDatabase = ( lookup: (sql: string) => Effect.try({ try: () => db.prepare(sql), - catch: (cause) => new SqlError({ cause, message: "Failed to prepare statement" }), + catch: (cause) => + new SqlError({ + reason: classifySqliteError(cause, { message: "Failed to prepare statement" }), + }), }), }); @@ -127,7 +130,11 @@ const makeWithDatabase = ( const result = statement.run(...(params as any)); return Effect.succeed(raw ? (result as unknown as ReadonlyArray) : []); } catch (cause) { - return Effect.fail(new SqlError({ cause, message: "Failed to execute statement" })); + return Effect.fail( + new SqlError({ + reason: classifySqliteError(cause, { message: "Failed to execute statement" }), + }), + ); } }); @@ -150,7 +157,10 @@ const makeWithDatabase = ( statement.run(...(params as any)); return []; }, - catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }), + catch: (cause) => + new SqlError({ + reason: classifySqliteError(cause, { message: "Failed to execute statement" }), + }), }), (statement) => Effect.sync(() => { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index f9a212fecc..c284099ad1 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -24,7 +24,7 @@ import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import type { ServerConfigShape } from "./config.ts"; -import { ServerConfig } from "./config.ts"; +import { deriveServerPaths, ServerConfig } from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; import { resolveAttachmentRelativePath } from "./attachmentPaths.ts"; import { @@ -111,25 +111,27 @@ const buildAppUnderTest = (options?: { }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempStateDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" }); - const stateDir = options?.config?.stateDir ?? tempStateDir; - const layerConfig = Layer.succeed(ServerConfig, { + const tempBaseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" }); + const baseDir = options?.config?.baseDir ?? tempBaseDir; + const devUrl = options?.config?.devUrl; + const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); + const config = { logLevel: "Info", mode: "web", port: 0, host: "127.0.0.1", cwd: process.cwd(), - keybindingsConfigPath: path.join(stateDir, "keybindings.json"), - stateDir, + baseDir, + ...derivedPaths, staticDir: undefined, - devUrl: undefined, + devUrl, noBrowser: true, authToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, ...options?.config, - } satisfies ServerConfigShape); + } satisfies ServerConfigShape; + const layerConfig = Layer.succeed(ServerConfig, config); const appLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, @@ -221,7 +223,7 @@ const buildAppUnderTest = (options?: { ); yield* Layer.build(appLayer); - return stateDir; + return config; }); const wsRpcProtocolLayer = (wsUrl: string) => @@ -300,9 +302,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const path = yield* Path.Path; const attachmentId = "thread-11111111-1111-4111-8111-111111111111"; - const stateDir = yield* buildAppUnderTest(); + const config = yield* buildAppUnderTest(); const attachmentPath = resolveAttachmentRelativePath({ - stateDir, + attachmentsDir: config.attachmentsDir, relativePath: `${attachmentId}.bin`, }); assert.isNotNull(attachmentPath, "Attachment path should be resolvable"); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f923699846..16d67b9d15 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,14 +1,7 @@ -import * as Net from "node:net"; -import * as Http from "node:http"; - -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as BunServices from "@effect/platform-bun/BunServices"; -import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; -import { Effect, Layer, Path } from "effect"; +import { Effect, Layer } from "effect"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; -import { ServerConfig, ServerConfigShape } from "./config"; +import { ServerConfig } from "./config"; import { attachmentsRouteLayer, healthRouteLayer, staticAndDevRouteLayer } from "./http"; import { fixPath } from "./os-jank"; import { websocketRpcRouteLayer } from "./ws"; @@ -21,6 +14,7 @@ import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; +import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; @@ -31,12 +25,9 @@ import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQu import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; import { GitCoreLive } from "./git/Layers/GitCore"; -import { GitServiceLive } from "./git/Layers/GitService"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration"; import { TerminalManagerLive } from "./terminal/Layers/Manager"; -import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY"; -import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY"; import { GitManagerLive } from "./git/Layers/GitManager"; import { KeybindingsLive } from "./keybindings"; import { ServerLoggerLive } from "./serverLogger"; @@ -47,6 +38,54 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"; +const PtyAdapterLive = Layer.unwrap( + Effect.gen(function* () { + if (typeof Bun !== "undefined") { + const BunPTY = yield* Effect.promise(() => import("./terminal/Layers/BunPTY")); + return BunPTY.layer; + } else { + const NodePTY = yield* Effect.promise(() => import("./terminal/Layers/NodePTY")); + return NodePTY.layer; + } + }), +); + +const HttpServerLive = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig; + if (typeof Bun !== "undefined") { + const BunHttpServer = yield* Effect.promise( + () => import("@effect/platform-bun/BunHttpServer"), + ); + return BunHttpServer.layer({ + port: config.port, + ...(config.host ? { hostname: config.host } : {}), + }); + } else { + const [NodeHttpServer, NodeHttp] = yield* Effect.all([ + Effect.promise(() => import("@effect/platform-node/NodeHttpServer")), + Effect.promise(() => import("node:http")), + ]); + return NodeHttpServer.layer(NodeHttp.createServer, { + host: config.host, + port: config.port, + }); + } + }), +); + +const PlatformServicesLive = Layer.unwrap( + Effect.gen(function* () { + if (typeof Bun !== "undefined") { + const { layer } = yield* Effect.promise(() => import("@effect/platform-bun/BunServices")); + return layer; + } else { + const { layer } = yield* Effect.promise(() => import("@effect/platform-node/NodeServices")); + return layer; + } + }), +); + const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(OrchestrationReactorLive), Layer.provideMerge(ProviderRuntimeIngestionLive), @@ -70,10 +109,7 @@ const CheckpointingLayerLive = Layer.empty.pipe( const ProviderLayerLive = Layer.unwrap( Effect.gen(function* () { - const { stateDir } = yield* ServerConfig; - const path = yield* Path.Path; - const providerLogsDir = path.join(stateDir, "logs", "provider"); - const providerEventLogPath = path.join(providerLogsDir, "events.log"); + const { providerEventLogPath } = yield* ServerConfig; const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { stream: "native", }); @@ -86,8 +122,12 @@ const ProviderLayerLive = Layer.unwrap( const codexAdapterLayer = makeCodexAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const claudeAdapterLayer = makeClaudeAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), + Layer.provide(claudeAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( @@ -99,22 +139,19 @@ const ProviderLayerLive = Layer.unwrap( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); const GitLayerLive = Layer.empty.pipe( - Layer.provideMerge(GitManagerLive), + Layer.provideMerge( + GitManagerLive.pipe( + Layer.provideMerge(GitCoreLive), + Layer.provideMerge(GitHubCliLive), + Layer.provideMerge(CodexTextGenerationLive), + ), + ), Layer.provideMerge(GitCoreLive), - Layer.provideMerge(GitServiceLive), - Layer.provideMerge(GitHubCliLive), - Layer.provideMerge(CodexTextGenerationLive), ); -const TerminalLayerLive = TerminalManagerLive.pipe( - Layer.provide( - typeof Bun !== "undefined" && process.platform !== "win32" - ? BunPtyAdapterLive - : NodePtyAdapterLive, - ), -); +const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); -const runtimeServicesLayer = Layer.empty.pipe( +const RuntimeServicesLive = Layer.empty.pipe( Layer.provideMerge(ServerRuntimeStartupLive), Layer.provideMerge(ReactorLayerLive), @@ -134,20 +171,6 @@ const runtimeServicesLayer = Layer.empty.pipe( Layer.provideMerge(ServerLifecycleEventsLive), ); -const HttpServerLive = - typeof Bun !== "undefined" - ? (listenOptions: ServerConfigShape) => - BunHttpServer.layer({ - port: listenOptions.port, - ...(listenOptions.host ? { hostname: listenOptions.host } : {}), - }) - : (listenOptions: ServerConfigShape) => - NodeHttpServer.layer(Http.createServer, { - host: listenOptions.host, - port: listenOptions.port, - }); -const ServicesLive = typeof Bun !== "undefined" ? BunServices.layer : NodeServices.layer; - export const makeRoutesLayer = Layer.mergeAll( healthRouteLayer, attachmentsRouteLayer, @@ -177,11 +200,11 @@ export const makeServerLayer = Layer.unwrap( ); return serverApplicationLayer.pipe( - Layer.provideMerge(runtimeServicesLayer), - Layer.provideMerge(HttpServerLive(config)), + Layer.provideMerge(RuntimeServicesLive), + Layer.provideMerge(HttpServerLive), Layer.provide(ServerLoggerLive), Layer.provideMerge(FetchHttpClient.layer), - Layer.provideMerge(ServicesLive), + Layer.provideMerge(PlatformServicesLive), ); }), ); diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index e7efdc300a..113013909d 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -2,7 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { Effect, FileSystem, Layer, LogLevel, Path, References } from "effect"; -import { ServerConfig } from "./config.ts"; +import { deriveServerPaths, ServerConfig } from "./config.ts"; import { ServerLoggerLive } from "./serverLogger.ts"; it.layer(NodeServices.layer)("ServerLoggerLive", (it) => { @@ -10,17 +10,18 @@ it.layer(NodeServices.layer)("ServerLoggerLive", (it) => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const stateDir = yield* fileSystem.makeTempDirectoryScoped({ + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-server-logger-", }); + const derivedPaths = yield* deriveServerPaths(baseDir, undefined); const configLayer = Layer.succeed(ServerConfig, { logLevel: "Warn", mode: "web", port: 0, host: undefined, cwd: process.cwd(), - keybindingsConfigPath: path.join(stateDir, "keybindings.json"), - stateDir, + baseDir, + ...derivedPaths, staticDir: undefined, devUrl: undefined, noBrowser: true, @@ -34,7 +35,7 @@ it.layer(NodeServices.layer)("ServerLoggerLive", (it) => { minimumLogLevel: yield* References.MinimumLogLevel, debugEnabled: yield* LogLevel.isEnabled("Debug"), warnEnabled: yield* LogLevel.isEnabled("Warn"), - logDirExists: yield* fileSystem.exists(path.join(stateDir, "logs")), + logDirExists: yield* fileSystem.exists(path.join(baseDir, "userdata", "logs")), }; }).pipe(Effect.provide(ServerLoggerLive.pipe(Layer.provide(configLayer)))); From 6295ae66f2e70b349887ea85462222cc8a03601b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 01:46:22 -0700 Subject: [PATCH 23/47] kewl --- apps/server/src/orchestration/Layers/ProjectionPipeline.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 2659f12fc4..7cfcc16090 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -3,7 +3,6 @@ import { type ChatAttachment, type OrchestrationEvent, } from "@t3tools/contracts"; -import * as NodeServices from "@effect/platform-node/NodeServices"; import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -1245,7 +1244,6 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( OrchestrationProjectionPipeline, makeOrchestrationProjectionPipeline, ).pipe( - Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ProjectionProjectRepositoryLive), Layer.provideMerge(ProjectionThreadRepositoryLive), Layer.provideMerge(ProjectionThreadMessageRepositoryLive), From 864c936ee14b1b3101daf089e9ff065b3e54b445 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 01:53:19 -0700 Subject: [PATCH 24/47] rm reference --- .reference/server/package.json | 27 -- .../server/src/Migrations/001_TodoSchema.ts | 32 -- .reference/server/src/bin.ts | 11 - .reference/server/src/cli.ts | 125 ------- .reference/server/src/client.ts | 58 ---- .reference/server/src/config.ts | 14 - .reference/server/src/contracts.ts | 132 -------- .reference/server/src/messages.ts | 73 ----- .reference/server/src/migrations.ts | 41 --- .reference/server/src/model-store.ts | 268 --------------- .reference/server/src/server.ts | 155 --------- .reference/server/test/cli-config.test.ts | 79 ----- .reference/server/test/reset.test.ts | 90 ----- .reference/server/test/server.test.ts | 310 ------------------ .reference/server/tsconfig.json | 4 - .reference/server/vitest.config.ts | 8 - 16 files changed, 1427 deletions(-) delete mode 100644 .reference/server/package.json delete mode 100644 .reference/server/src/Migrations/001_TodoSchema.ts delete mode 100644 .reference/server/src/bin.ts delete mode 100644 .reference/server/src/cli.ts delete mode 100644 .reference/server/src/client.ts delete mode 100644 .reference/server/src/config.ts delete mode 100644 .reference/server/src/contracts.ts delete mode 100644 .reference/server/src/messages.ts delete mode 100644 .reference/server/src/migrations.ts delete mode 100644 .reference/server/src/model-store.ts delete mode 100644 .reference/server/src/server.ts delete mode 100644 .reference/server/test/cli-config.test.ts delete mode 100644 .reference/server/test/reset.test.ts delete mode 100644 .reference/server/test/server.test.ts delete mode 100644 .reference/server/tsconfig.json delete mode 100644 .reference/server/vitest.config.ts diff --git a/.reference/server/package.json b/.reference/server/package.json deleted file mode 100644 index 8df16b6766..0000000000 --- a/.reference/server/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@effect-http-ws-cli/server", - "version": "0.1.0", - "private": true, - "type": "module", - "exports": { - "./client": "./src/client.ts", - "./contracts": "./src/contracts.ts" - }, - "scripts": { - "dev": "node src/bin.ts", - "start": "node src/bin.ts", - "test": "vitest run", - "lint": "tsc --noEmit" - }, - "dependencies": { - "@effect/platform-node": "catalog:", - "@effect/sql-sqlite-node": "catalog:", - "effect": "catalog:" - }, - "devDependencies": { - "@effect/vitest": "catalog:", - "@types/node": "^24.10.0", - "typescript": "catalog:", - "vitest": "catalog:" - } -} diff --git a/.reference/server/src/Migrations/001_TodoSchema.ts b/.reference/server/src/Migrations/001_TodoSchema.ts deleted file mode 100644 index aa6c1418e7..0000000000 --- a/.reference/server/src/Migrations/001_TodoSchema.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as Effect from "effect/Effect" -import * as SqlClient from "effect/unstable/sql/SqlClient" - -export default Effect.gen(function*() { - const sql = yield* SqlClient.SqlClient - - yield* sql` - CREATE TABLE IF NOT EXISTS todos ( - id TEXT PRIMARY KEY NOT NULL, - title TEXT NOT NULL, - completed INTEGER NOT NULL, - archived INTEGER NOT NULL, - revision INTEGER NOT NULL, - updated_at TEXT NOT NULL - ) - ` - - yield* sql` - CREATE TABLE IF NOT EXISTS todo_events ( - event_offset INTEGER PRIMARY KEY AUTOINCREMENT, - at TEXT NOT NULL, - todo_json TEXT NOT NULL, - change_json TEXT NOT NULL, - archived INTEGER NOT NULL - ) - ` - - yield* sql` - CREATE INDEX IF NOT EXISTS idx_todo_events_archived_offset - ON todo_events (archived, event_offset) - ` -}) diff --git a/.reference/server/src/bin.ts b/.reference/server/src/bin.ts deleted file mode 100644 index 647a33e519..0000000000 --- a/.reference/server/src/bin.ts +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env node -import * as NodeRuntime from "@effect/platform-node/NodeRuntime" -import * as NodeServices from "@effect/platform-node/NodeServices" -import * as Effect from "effect/Effect" -import { cli } from "./cli.ts" -import { Command } from "effect/unstable/cli" - -Command.run(cli, { version: "0.1.0" }).pipe( - Effect.provide(NodeServices.layer), - NodeRuntime.runMain -) diff --git a/.reference/server/src/cli.ts b/.reference/server/src/cli.ts deleted file mode 100644 index 75806c2f4e..0000000000 --- a/.reference/server/src/cli.ts +++ /dev/null @@ -1,125 +0,0 @@ -import * as SqliteNode from "@effect/sql-sqlite-node" -import { Command, Flag } from "effect/unstable/cli" -import * as Config from "effect/Config" -import * as Effect from "effect/Effect" -import * as FileSystem from "effect/FileSystem" -import * as Option from "effect/Option" -import * as Path from "effect/Path" -import { fileURLToPath } from "node:url" -import { ServerConfig } from "./config.ts" -import { runMigrations } from "./migrations.ts" -import { runServer } from "./server.ts" -import type { ServerConfigData } from "./config.ts" - -const defaultAssetsDir = fileURLToPath(new URL("../../public", import.meta.url)) -const defaultDbFilename = fileURLToPath(new URL("../../todo.sqlite", import.meta.url)) - -const hostFlag = Flag.string("host").pipe( - Flag.withDescription("Host interface to bind"), - Flag.optional -) - -const portFlag = Flag.integer("port").pipe( - Flag.withDescription("Port to listen on"), - Flag.optional -) - -const assetsFlag = Flag.directory("assets").pipe( - Flag.withDescription("Directory of static assets"), - Flag.optional -) - -const dbFlag = Flag.string("db").pipe( - Flag.withDescription("SQLite database filename"), - Flag.optional -) - -const requestLoggingFlag = Flag.boolean("request-logging").pipe( - Flag.withDescription("Enable request logging"), - Flag.optional -) - -const frontendDevOriginFlag = Flag.string("frontend-dev-origin").pipe( - Flag.withDescription("Redirect frontend GET requests to a Vite dev server origin"), - Flag.optional -) - -const EnvServerConfig = Config.unwrap({ - host: Config.string("HOST").pipe(Config.withDefault("127.0.0.1")), - port: Config.port("PORT").pipe(Config.withDefault(8787)), - assetsDir: Config.string("ASSETS_DIR").pipe(Config.withDefault(defaultAssetsDir)), - dbFilename: Config.string("DB_FILENAME").pipe(Config.withDefault(defaultDbFilename)), - requestLogging: Config.boolean("REQUEST_LOGGING").pipe(Config.withDefault(true)), - frontendDevOrigin: Config.string("FRONTEND_DEV_ORIGIN").pipe( - Config.option, - Config.map(Option.getOrUndefined) - ) -}) - -export interface CliServerFlags { - readonly host: Option.Option - readonly port: Option.Option - readonly assets: Option.Option - readonly db: Option.Option - readonly requestLogging: Option.Option - readonly frontendDevOrigin: Option.Option -} - -export const resolveServerConfig = ( - flags: CliServerFlags -): Effect.Effect => - Effect.gen(function*() { - const env = yield* EnvServerConfig - return { - host: Option.getOrElse(flags.host, () => env.host), - port: Option.getOrElse(flags.port, () => env.port), - assetsDir: Option.getOrElse(flags.assets, () => env.assetsDir), - dbFilename: Option.getOrElse(flags.db, () => env.dbFilename), - requestLogging: Option.getOrElse(flags.requestLogging, () => env.requestLogging), - frontendDevOrigin: Option.getOrElse(flags.frontendDevOrigin, () => env.frontendDevOrigin) - } - }) - - -export const resetDatabase = (dbFilename: string) => - Effect.gen(function*() { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - - if (dbFilename !== ":memory:") { - yield* fs.remove(path.resolve(dbFilename), { force: true }) - } - - const sqliteLayer = SqliteNode.SqliteClient.layer({ - filename: dbFilename - }) - - yield* runMigrations.pipe(Effect.provide(sqliteLayer)) - }) - -const commandFlags = { - host: hostFlag, - port: portFlag, - assets: assetsFlag, - db: dbFlag, - requestLogging: requestLoggingFlag, - frontendDevOrigin: frontendDevOriginFlag -} as const - -const rootCommand = Command.make("effect-http-ws-cli", commandFlags).pipe( - Command.withDescription("Run a unified Effect HTTP + WebSocket server"), - Command.withHandler((flags) => - Effect.flatMap(resolveServerConfig(flags), (config) => - runServer.pipe(Effect.provideService(ServerConfig, config)))), -) - -const resetCommand = Command.make("reset", commandFlags).pipe( - Command.withDescription("Delete the SQLite database file and rerun migrations"), - Command.withHandler((flags) => - Effect.flatMap(resolveServerConfig(flags), (config) => resetDatabase(config.dbFilename)) - ) -) - -export const cli = rootCommand.pipe( - Command.withSubcommands([resetCommand]) -) diff --git a/.reference/server/src/client.ts b/.reference/server/src/client.ts deleted file mode 100644 index 4650807973..0000000000 --- a/.reference/server/src/client.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { NodeSocket } from "@effect/platform-node" -import * as Effect from "effect/Effect" -import * as Layer from "effect/Layer" -import * as RpcClient from "effect/unstable/rpc/RpcClient" -import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization" -import * as Stream from "effect/Stream" -import { WsRpcGroup } from "./contracts.ts" - -export const wsRpcProtocolLayer = (wsUrl: string) => - RpcClient.layerProtocolSocket().pipe( - Layer.provide(NodeSocket.layerWebSocket(wsUrl)), - Layer.provide(RpcSerialization.layerJson) - ) - -export const makeWsRpcClient = RpcClient.make(WsRpcGroup) -type WsRpcClient = typeof makeWsRpcClient extends Effect.Effect ? Client : never - -export const withWsRpcClient = ( - wsUrl: string, - f: (client: WsRpcClient) => Effect.Effect -) => - makeWsRpcClient.pipe( - Effect.flatMap(f), - Effect.provide(wsRpcProtocolLayer(wsUrl)) - ) - -export const runClientExample = (wsUrl: string) => - Effect.scoped( - withWsRpcClient(wsUrl, (client) => - Effect.gen(function*() { - const echoed = yield* client.echo({ text: "hello from client" }) - const summed = yield* client.sum({ left: 20, right: 22 }) - const time = yield* client.time(undefined) - return { echoed, summed, time } - }) - ) - ) - -export const runSubscriptionExample = (wsUrl: string, modelId: string) => - Effect.scoped( - withWsRpcClient(wsUrl, (client) => - Effect.gen(function*() { - const snapshot = yield* client.listTodos({ includeArchived: true }) - const todo = snapshot.todos[0] - if (!todo) { - return [] - } - - yield* client.renameTodo({ id: todo.id, title: `${modelId}: first` }) - yield* client.completeTodo({ id: todo.id, completed: true }) - - return yield* client.subscribeTodos({ fromOffset: snapshot.offset, includeArchived: true }).pipe( - Stream.take(2), - Stream.runCollect - ) - }) - ) - ) diff --git a/.reference/server/src/config.ts b/.reference/server/src/config.ts deleted file mode 100644 index 601135fefb..0000000000 --- a/.reference/server/src/config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as ServiceMap from "effect/ServiceMap" - -export interface ServerConfigData { - readonly host: string - readonly port: number - readonly assetsDir: string - readonly dbFilename: string - readonly requestLogging: boolean - readonly frontendDevOrigin: string | undefined -} - -export class ServerConfig extends ServiceMap.Service()( - "effect-http-ws-cli/ServerConfig" -) {} diff --git a/.reference/server/src/contracts.ts b/.reference/server/src/contracts.ts deleted file mode 100644 index f2b2534f93..0000000000 --- a/.reference/server/src/contracts.ts +++ /dev/null @@ -1,132 +0,0 @@ -import * as Schema from "effect/Schema" -import * as Rpc from "effect/unstable/rpc/Rpc" -import * as RpcGroup from "effect/unstable/rpc/RpcGroup" - -export const EchoPayload = Schema.Struct({ - text: Schema.String -}) - -export const EchoResult = Schema.Struct({ - text: Schema.String -}) - -export const SumPayload = Schema.Struct({ - left: Schema.Number, - right: Schema.Number -}) - -export const SumResult = Schema.Struct({ - total: Schema.Number -}) - -export const TimeResult = Schema.Struct({ - iso: Schema.String -}) - -export const Todo = Schema.Struct({ - id: Schema.String, - title: Schema.String, - completed: Schema.Boolean, - archived: Schema.Boolean, - revision: Schema.Number, - updatedAt: Schema.String -}) -export type Todo = Schema.Schema.Type - -export const TodoChange = Schema.Union([ - Schema.Struct({ - _tag: Schema.Literal("TodoCreated") - }), - Schema.Struct({ - _tag: Schema.Literal("TodoRenamed"), - title: Schema.String - }), - Schema.Struct({ - _tag: Schema.Literal("TodoCompleted"), - completed: Schema.Boolean - }), - Schema.Struct({ - _tag: Schema.Literal("TodoArchived"), - archived: Schema.Boolean - }) -]) -export type TodoChange = Schema.Schema.Type - -export const TodoEvent = Schema.Struct({ - offset: Schema.Number, - at: Schema.String, - todo: Todo, - change: TodoChange -}) -export type TodoEvent = Schema.Schema.Type - -export const TodoSnapshot = Schema.Struct({ - offset: Schema.Number, - todos: Schema.Array(Todo) -}) -export type TodoSnapshot = Schema.Schema.Type - -export const EchoRpc = Rpc.make("echo", { - payload: EchoPayload, - success: EchoResult -}) - -export const SumRpc = Rpc.make("sum", { - payload: SumPayload, - success: SumResult -}) - -export const TimeRpc = Rpc.make("time", { - success: TimeResult -}) - -export const ListTodosRpc = Rpc.make("listTodos", { - payload: Schema.Struct({ - includeArchived: Schema.Boolean - }), - success: TodoSnapshot -}) - -export const RenameTodoRpc = Rpc.make("renameTodo", { - payload: Schema.Struct({ - id: Schema.String, - title: Schema.String - }), - success: TodoEvent -}) - -export const CompleteTodoRpc = Rpc.make("completeTodo", { - payload: Schema.Struct({ - id: Schema.String, - completed: Schema.Boolean - }), - success: TodoEvent -}) - -export const ArchiveTodoRpc = Rpc.make("archiveTodo", { - payload: Schema.Struct({ - id: Schema.String, - archived: Schema.Boolean - }), - success: TodoEvent -}) - -export const SubscribeTodosRpc = Rpc.make("subscribeTodos", { - payload: Schema.Struct({ - fromOffset: Schema.Number, - includeArchived: Schema.Boolean - }), - success: TodoEvent, - stream: true -}) - -export const WsRpcGroup = RpcGroup.make( - EchoRpc, - SumRpc, - TimeRpc, - ListTodosRpc, - RenameTodoRpc, - CompleteTodoRpc, - ArchiveTodoRpc, - SubscribeTodosRpc -) diff --git a/.reference/server/src/messages.ts b/.reference/server/src/messages.ts deleted file mode 100644 index 75a67efac2..0000000000 --- a/.reference/server/src/messages.ts +++ /dev/null @@ -1,73 +0,0 @@ -import * as Schema from "effect/Schema" -import * as SchemaTransformation from "effect/SchemaTransformation" - -export const ClientMessage = Schema.Union([ - Schema.Struct({ - kind: Schema.Literal("echo"), - text: Schema.String - }), - Schema.Struct({ - kind: Schema.Literal("sum"), - left: Schema.Number, - right: Schema.Number - }), - Schema.Struct({ - kind: Schema.Literal("time") - }) -]) - -export type ClientMessage = Schema.Schema.Type - -export const ServerMessage = Schema.Union([ - Schema.Struct({ - kind: Schema.Literal("echo"), - text: Schema.String - }), - Schema.Struct({ - kind: Schema.Literal("sumResult"), - total: Schema.Number - }), - Schema.Struct({ - kind: Schema.Literal("time"), - iso: Schema.String - }), - Schema.Struct({ - kind: Schema.Literal("error"), - error: Schema.String - }) -]) - - -export type ServerMessage = Schema.Schema.Type - -export const decodeClientMessage = Schema.decodeUnknownEffect(ClientMessage) - -const Utf8StringFromUint8Array = Schema.Uint8Array.pipe( - Schema.decodeTo( - Schema.String, - SchemaTransformation.transform({ - decode: (bytes) => new TextDecoder().decode(bytes), - encode: (text) => new TextEncoder().encode(text) - }) - ) -) - -const ClientMessageFromWire = Schema.Union([ - Schema.String, - Utf8StringFromUint8Array -]).pipe( - Schema.decodeTo(Schema.fromJsonString(ClientMessage)) -) - -export const decodeWireClientMessage = Schema.decodeUnknownEffect(ClientMessageFromWire) - -export const routeClientMessage = (message: ClientMessage): ServerMessage => { - switch (message.kind) { - case "echo": - return { kind: "echo", text: message.text } - case "sum": - return { kind: "sumResult", total: message.left + message.right } - case "time": - return { kind: "time", iso: new Date().toISOString() } - } -} diff --git a/.reference/server/src/migrations.ts b/.reference/server/src/migrations.ts deleted file mode 100644 index 65abf1a534..0000000000 --- a/.reference/server/src/migrations.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * MigrationsLive - Migration runner with inline loader - * - * Uses Migrator.make with fromRecord to define migrations inline. - * All migrations are statically imported - no dynamic file system loading. - * - * Migrations run automatically when the MigrationsLive layer is provided, - * ensuring the database schema is up-to-date before the application starts. - */ -import * as Effect from "effect/Effect" -import * as Layer from "effect/Layer" -import * as ServiceMap from "effect/ServiceMap" -import * as Migrator from "effect/unstable/sql/Migrator" -import Migration0001 from "./Migrations/001_TodoSchema.ts" - -const loader = Migrator.fromRecord({ - "1_TodoSchema": Migration0001 -}) - -const run = Migrator.make({}) - -export const runMigrations = Effect.gen(function*() { - yield* Effect.log("Running migrations...") - yield* run({ loader }) - yield* Effect.log("Migrations ran successfully") -}) - -export interface MigrationsReadyApi { - readonly ready: true -} - -export class MigrationsReady extends ServiceMap.Service()( - "effect-http-ws-cli/MigrationsReady" -) {} - -export const MigrationsLive = Layer.effect( - MigrationsReady, - runMigrations.pipe( - Effect.as(MigrationsReady.of({ ready: true })) - ) -) diff --git a/.reference/server/src/model-store.ts b/.reference/server/src/model-store.ts deleted file mode 100644 index 56bcc0c05a..0000000000 --- a/.reference/server/src/model-store.ts +++ /dev/null @@ -1,268 +0,0 @@ -import * as Effect from "effect/Effect" -import * as Layer from "effect/Layer" -import * as Option from "effect/Option" -import * as PubSub from "effect/PubSub" -import * as Schema from "effect/Schema" -import * as ServiceMap from "effect/ServiceMap" -import * as Stream from "effect/Stream" -import * as SqlClient from "effect/unstable/sql/SqlClient" -import * as SqlSchema from "effect/unstable/sql/SqlSchema" -import type { Todo, TodoChange, TodoEvent, TodoSnapshot } from "./contracts.ts" -import { Todo as TodoSchema, TodoChange as TodoChangeSchema } from "./contracts.ts" -import { MigrationsReady } from "./migrations.ts" - -export interface TodoStoreApi { - readonly list: (input: { - readonly includeArchived: boolean - }) => Effect.Effect - readonly rename: (input: { - readonly id: string - readonly title: string - }) => Effect.Effect - readonly complete: (input: { - readonly id: string - readonly completed: boolean - }) => Effect.Effect - readonly archive: (input: { - readonly id: string - readonly archived: boolean - }) => Effect.Effect - readonly subscribe: (input: { - readonly fromOffset: number - readonly includeArchived: boolean - }) => Stream.Stream -} - -export class TodoStore extends ServiceMap.Service()( - "effect-http-ws-cli/TodoStore" -) {} - -const TodoRow = Schema.Struct({ - id: Schema.String, - title: Schema.String, - completed: Schema.BooleanFromBit, - archived: Schema.BooleanFromBit, - revision: Schema.Number, - updatedAt: Schema.String -}) - -const TodoEventRow = Schema.Struct({ - offset: Schema.Number, - at: Schema.String, - todo: Schema.fromJsonString(TodoSchema), - change: Schema.fromJsonString(TodoChangeSchema) -}) - -const EventInsertRequest = Schema.Struct({ - at: Schema.String, - todo: Schema.fromJsonString(TodoSchema), - change: Schema.fromJsonString(TodoChangeSchema), - archived: Schema.BooleanFromBit -}) - -const ListRequest = Schema.Struct({ - includeArchived: Schema.Boolean -}) - -const CatchupRequest = Schema.Struct({ - fromOffset: Schema.Number, - includeArchived: Schema.Boolean -}) - -const OffsetRow = Schema.Struct({ - offset: Schema.Number -}) - -const makeQueries = (sql: SqlClient.SqlClient) => { - const listTodoRows = SqlSchema.findAll({ - Request: ListRequest, - Result: TodoRow, - execute: (request) => - request.includeArchived - ? sql` - SELECT id, title, completed, archived, revision, updated_at AS updatedAt - FROM todos - ORDER BY id - ` - : sql` - SELECT id, title, completed, archived, revision, updated_at AS updatedAt - FROM todos - WHERE archived = 0 - ORDER BY id - ` - }) - - const findTodoById = SqlSchema.findOneOption({ - Request: Schema.String, - Result: TodoRow, - execute: (id) => sql` - SELECT id, title, completed, archived, revision, updated_at AS updatedAt - FROM todos - WHERE id = ${id} - ` - }) - - const upsertTodo = SqlSchema.void({ - Request: TodoRow, - execute: (todo) => sql` - INSERT INTO todos (id, title, completed, archived, revision, updated_at) - VALUES (${todo.id}, ${todo.title}, ${todo.completed}, ${todo.archived}, ${todo.revision}, ${todo.updatedAt}) - ON CONFLICT(id) DO UPDATE SET - title = excluded.title, - completed = excluded.completed, - archived = excluded.archived, - revision = excluded.revision, - updated_at = excluded.updated_at - ` - }) - - const loadEventsSince = SqlSchema.findAll({ - Request: CatchupRequest, - Result: TodoEventRow, - execute: (request) => - request.includeArchived - ? sql` - SELECT event_offset AS "offset", at, todo_json AS todo, change_json AS change - FROM todo_events - WHERE event_offset > ${request.fromOffset} - ORDER BY event_offset - ` - : sql` - SELECT event_offset AS "offset", at, todo_json AS todo, change_json AS change - FROM todo_events - WHERE event_offset > ${request.fromOffset} AND archived = 0 - ORDER BY event_offset - ` - }) - - const insertTodoEvent = SqlSchema.findOne({ - Request: EventInsertRequest, - Result: TodoEventRow, - execute: (request) => sql` - INSERT INTO todo_events (at, todo_json, change_json, archived) - VALUES (${request.at}, ${request.todo}, ${request.change}, ${request.archived}) - RETURNING event_offset AS "offset", at, todo_json AS todo, change_json AS change - ` - }) - - const currentOffset = SqlSchema.findOne({ - Request: Schema.Undefined, - Result: OffsetRow, - execute: () => sql<{ readonly offset: number }>` - SELECT COALESCE(MAX(event_offset), 0) AS "offset" - FROM todo_events - ` - }) - - return { - listTodoRows, - findTodoById, - upsertTodo, - loadEventsSince, - insertTodoEvent, - currentOffset - } as const -} - -export const layerTodoStore = Layer.effect( - TodoStore, - Effect.gen(function*() { - yield* MigrationsReady - const eventsPubSub = yield* PubSub.unbounded() - - const append = ( - todoId: string, - change: TodoChange, - update: (todo: Todo) => Todo - ): Effect.Effect => - Effect.flatMap(Effect.service(SqlClient.SqlClient), (sql) => { - const queries = makeQueries(sql) - return sql.withTransaction( - queries.findTodoById(todoId).pipe( - Effect.flatMap( - Option.match({ - onNone: () => Effect.die(`Todo not found: ${todoId}`), - onSome: (todo) => { - const updated = update(todo) - return queries.upsertTodo(updated).pipe( - Effect.flatMap(() => - queries.insertTodoEvent({ - at: updated.updatedAt, - todo: updated, - change, - archived: updated.archived - }) - ) - ) - } - }) - ) - ) - ) - }).pipe(Effect.tap((event) => PubSub.publish(eventsPubSub, event)), Effect.orDie) - - const visible = (todo: Todo, includeArchived: boolean) => includeArchived || !todo.archived - - const subscribe = (input: { - readonly fromOffset: number - readonly includeArchived: boolean - }): Stream.Stream => { - const catchup = Stream.fromIterableEffect( - Effect.flatMap(Effect.service(SqlClient.SqlClient), (sql) => makeQueries(sql).loadEventsSince(input)) - ).pipe(Stream.orDie) - - const live = Stream.fromPubSub(eventsPubSub).pipe( - Stream.filter((event) => visible(event.todo, input.includeArchived)) - ) - - return Stream.concat(catchup, live) - } - - return TodoStore.of({ - list: ({ includeArchived }) => - Effect.flatMap(Effect.service(SqlClient.SqlClient), (sql) => { - const queries = makeQueries(sql) - return Effect.all({ - todos: queries.listTodoRows({ includeArchived }), - offset: queries.currentOffset(undefined).pipe(Effect.map(({ offset }) => offset)) - }).pipe( - Effect.map(({ offset, todos }) => ({ offset, todos })) - ) - }).pipe(Effect.orDie), - rename: ({ id, title }) => - append( - id, - { _tag: "TodoRenamed", title }, - (todo) => ({ - ...todo, - title, - revision: todo.revision + 1, - updatedAt: new Date().toISOString() - }) - ), - complete: ({ id, completed }) => - append( - id, - { _tag: "TodoCompleted", completed }, - (todo) => ({ - ...todo, - completed, - revision: todo.revision + 1, - updatedAt: new Date().toISOString() - }) - ), - archive: ({ id, archived }) => - append( - id, - { _tag: "TodoArchived", archived }, - (todo) => ({ - ...todo, - archived, - revision: todo.revision + 1, - updatedAt: new Date().toISOString() - }) - ), - subscribe - }) - }) -) diff --git a/.reference/server/src/server.ts b/.reference/server/src/server.ts deleted file mode 100644 index 0d1e7a16dd..0000000000 --- a/.reference/server/src/server.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { NodeHttpServer } from "@effect/platform-node" -import * as SqliteNode from "@effect/sql-sqlite-node" -import * as Effect from "effect/Effect" -import * as FileSystem from "effect/FileSystem" -import * as Layer from "effect/Layer" -import * as Path from "effect/Path" -import * as Http from "node:http" -import { - HttpRouter, - HttpServerRequest, - HttpServerResponse -} from "effect/unstable/http" -import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization" -import * as RpcServer from "effect/unstable/rpc/RpcServer" -import * as Stream from "effect/Stream" -import { - ClientMessage, - routeClientMessage, - ServerMessage -} from "./messages.ts" -import { ServerConfig } from "./config.ts" -import { WsRpcGroup } from "./contracts.ts" -import { TodoStore, layerTodoStore } from "./model-store.ts" -import { MigrationsLive } from "./migrations.ts" - -const respondMessage = HttpServerResponse.schemaJson(ServerMessage) - -const messageDispatchRoute = HttpRouter.add( - "POST", - "/api/dispatch", - HttpServerRequest.schemaBodyJson(ClientMessage).pipe( - Effect.flatMap((message) => respondMessage(routeClientMessage(message))), - Effect.catchTag( - "SchemaError", - () => Effect.succeed(HttpServerResponse.jsonUnsafe({ kind: "error", error: "Invalid message schema" }, { status: 400 })) - ), - Effect.catchTag( - "HttpServerError", - () => Effect.succeed(HttpServerResponse.jsonUnsafe({ kind: "error", error: "Invalid request body" }, { status: 400 })) - ) - ) -) - -const websocketRpcRoute = RpcServer.layerHttp({ - group: WsRpcGroup, - path: "/ws", - protocol: "websocket" - }).pipe( - Layer.provide(WsRpcGroup.toLayer({ - echo: ({ text }) => Effect.succeed({ text }), - sum: ({ left, right }) => Effect.succeed({ total: left + right }), - time: () => Effect.sync(() => ({ iso: new Date().toISOString() })), - listTodos: ({ includeArchived }) => - Effect.flatMap(Effect.service(TodoStore), (store) => store.list({ includeArchived })), - renameTodo: ({ id, title }) => - Effect.flatMap(Effect.service(TodoStore), (store) => store.rename({ id, title })), - completeTodo: ({ id, completed }) => - Effect.flatMap(Effect.service(TodoStore), (store) => store.complete({ id, completed })), - archiveTodo: ({ id, archived }) => - Effect.flatMap(Effect.service(TodoStore), (store) => store.archive({ id, archived })), - subscribeTodos: ({ fromOffset, includeArchived }) => - Stream.unwrap( - Effect.map( - Effect.service(TodoStore), - (store) => store.subscribe({ fromOffset, includeArchived }) - ) - ) - })), - Layer.provide(layerTodoStore), - Layer.provide(RpcSerialization.layerJson) - ) - -const staticRoute = HttpRouter.add( - "GET", - "*", - (request) => - Effect.gen(function*() { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - const config = yield* ServerConfig - const url = HttpServerRequest.toURL(request) - if (!url) { - return HttpServerResponse.text("Bad Request", { status: 400 }) - } - - if (config.frontendDevOrigin) { - return HttpServerResponse.redirect( - new URL(`${url.pathname}${url.search}`, config.frontendDevOrigin), - { - status: 307, - headers: { "cache-control": "no-store" } - } - ) - } - - const root = path.resolve(config.assetsDir) - const decodedPath = decodeURIComponent(url.pathname) - const target = decodedPath === "/" - ? "index.html" - : decodedPath.endsWith("/") - ? `${decodedPath.slice(1)}index.html` - : decodedPath.slice(1) - - const normalizedTarget = path.normalize(target) - const absoluteTarget = path.resolve(root, normalizedTarget) - const relativeToRoot = path.relative(root, absoluteTarget) - - if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) { - return HttpServerResponse.text("Forbidden", { status: 403 }) - } - - const exists = yield* fs.exists(absoluteTarget) - if (!exists) { - return HttpServerResponse.text("Not Found", { status: 404 }) - } - - return yield* HttpServerResponse.file(absoluteTarget) - }).pipe(Effect.catchCause(() => Effect.succeed(HttpServerResponse.text("Bad Request", { status: 400 })))) -) - -export const makeRoutesLayer = - Layer.mergeAll( - HttpRouter.add( - "GET", - "/health", - HttpServerResponse.json({ ok: true }) - ), - messageDispatchRoute, - websocketRpcRoute, - staticRoute - ) - -export const makeServerLayer = Layer.unwrap( - Effect.gen(function*() { - const config = yield* ServerConfig - const sqliteLayer = SqliteNode.SqliteClient.layer({ - filename: config.dbFilename - }) - const persistenceLayer = MigrationsLive.pipe( - Layer.provideMerge(sqliteLayer) - ) - - return HttpRouter.serve(makeRoutesLayer, { - disableLogger: !config.requestLogging - }).pipe( - Layer.provideMerge(persistenceLayer), - Layer.provide(NodeHttpServer.layer(Http.createServer, { - host: config.host, - port: config.port - })) - ) - }) -) - -export const runServer = Layer.launch(makeServerLayer) diff --git a/.reference/server/test/cli-config.test.ts b/.reference/server/test/cli-config.test.ts deleted file mode 100644 index 90b75857a7..0000000000 --- a/.reference/server/test/cli-config.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it } from "@effect/vitest" -import * as ConfigProvider from "effect/ConfigProvider" -import * as Effect from "effect/Effect" -import * as Option from "effect/Option" -import { resolveServerConfig } from "../src/cli.ts" - -describe("cli config resolution", () => { - it.effect("falls back to effect/config values when flags are omitted", () => - Effect.gen(function*() { - const resolved = yield* resolveServerConfig({ - host: Option.none(), - port: Option.none(), - assets: Option.none(), - db: Option.none(), - requestLogging: Option.none(), - frontendDevOrigin: Option.none() - }).pipe( - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromEnv({ - env: { - HOST: "0.0.0.0", - PORT: "4001", - ASSETS_DIR: "public", - DB_FILENAME: "dev.sqlite", - REQUEST_LOGGING: "false", - FRONTEND_DEV_ORIGIN: "http://127.0.0.1:5173" - } - }) - ) - ) - - expect(resolved).toEqual({ - host: "0.0.0.0", - port: 4001, - assetsDir: "public", - dbFilename: "dev.sqlite", - requestLogging: false, - frontendDevOrigin: "http://127.0.0.1:5173" - }) - }) - ) - - it.effect("uses CLI flags when provided", () => - Effect.gen(function*() { - const resolved = yield* resolveServerConfig({ - host: Option.some("127.0.0.1"), - port: Option.some(8788), - assets: Option.some("public"), - db: Option.some("override.sqlite"), - requestLogging: Option.some(true), - frontendDevOrigin: Option.some("http://127.0.0.1:4173") - }).pipe( - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromEnv({ - env: { - HOST: "0.0.0.0", - PORT: "4001", - ASSETS_DIR: "other", - DB_FILENAME: "ignored.sqlite", - REQUEST_LOGGING: "false", - FRONTEND_DEV_ORIGIN: "http://127.0.0.1:5173" - } - }) - ) - ) - - expect(resolved).toEqual({ - host: "127.0.0.1", - port: 8788, - assetsDir: "public", - dbFilename: "override.sqlite", - requestLogging: true, - frontendDevOrigin: "http://127.0.0.1:4173" - }) - }) - ) -}) diff --git a/.reference/server/test/reset.test.ts b/.reference/server/test/reset.test.ts deleted file mode 100644 index b180538e31..0000000000 --- a/.reference/server/test/reset.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices" -import * as SqliteNode from "@effect/sql-sqlite-node" -import { describe, expect, it } from "@effect/vitest" -import * as Effect from "effect/Effect" -import * as Layer from "effect/Layer" -import * as SqlClient from "effect/unstable/sql/SqlClient" -import * as FileSystem from "node:fs" -import * as OS from "node:os" -import * as NodePath from "node:path" -import { resetDatabase } from "../src/cli.ts" -import { MigrationsLive } from "../src/migrations.ts" - -const countRows = (dbFilename: string, tableName: string) => - Effect.flatMap(Effect.service(SqlClient.SqlClient), (sql) => - sql<{ readonly count: number }>`SELECT COUNT(*) AS count FROM ${sql(tableName)}`.pipe( - Effect.map((rows) => rows[0]?.count ?? 0) - ) - ).pipe( - Effect.provide(SqliteNode.SqliteClient.layer({ filename: dbFilename })) - ) - -const insertTodo = (dbFilename: string) => - Effect.scoped( - Effect.flatMap(Effect.service(SqlClient.SqlClient), (sql) => { - const now = new Date().toISOString() - const id = "todo-reset-test" - const title = "before-reset" - return sql.withTransaction( - sql` - INSERT INTO todos (id, title, completed, archived, revision, updated_at) - VALUES (${id}, ${title}, 0, 0, 1, ${now}) - `.pipe( - Effect.flatMap(() => - sql` - INSERT INTO todo_events (at, todo_json, change_json, archived) - VALUES ( - ${now}, - ${JSON.stringify({ - id, - title, - completed: false, - archived: false, - revision: 1, - updatedAt: now - })}, - ${JSON.stringify({ _tag: "TodoCreated" })}, - 0 - ) - ` - ) - ) - ) - }).pipe( - Effect.provide(MigrationsLive.pipe(Layer.provideMerge(SqliteNode.SqliteClient.layer({ filename: dbFilename })))) - ) - ) - -describe("reset command", () => { - it.effect("deletes the database and reruns migrations without reseeding todos", () => - Effect.gen(function*() { - const dbFilename = NodePath.join( - OS.tmpdir(), - `effect-http-ws-cli-reset-${Date.now()}-${Math.random().toString(16).slice(2)}.sqlite` - ) - - yield* insertTodo(dbFilename) - - const beforeCount = yield* countRows(dbFilename, "todos") - expect(beforeCount).toBe(1) - - yield* resetDatabase(dbFilename).pipe( - Effect.provide(NodeServices.layer) - ) - - const todoCount = yield* countRows(dbFilename, "todos") - const migrationCount = yield* countRows(dbFilename, "effect_sql_migrations") - - expect(todoCount).toBe(0) - expect(migrationCount).toBe(1) - - yield* Effect.sync(() => { - try { - FileSystem.rmSync(dbFilename, { force: true }) - } catch { - // ignore cleanup failures in tests - } - }) - }) - ) -}) diff --git a/.reference/server/test/server.test.ts b/.reference/server/test/server.test.ts deleted file mode 100644 index 317e2a9058..0000000000 --- a/.reference/server/test/server.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { NodeHttpServer } from "@effect/platform-node" -import * as SqliteNode from "@effect/sql-sqlite-node" -import { describe, expect, it } from "@effect/vitest" -import * as Effect from "effect/Effect" -import * as Layer from "effect/Layer" -import * as Stream from "effect/Stream" -import * as FileSystem from "node:fs" -import * as OS from "node:os" -import * as NodePath from "node:path" -import { - HttpBody, - HttpClient, - HttpClientResponse, - HttpServer, - HttpRouter -} from "effect/unstable/http" -import * as SqlClient from "effect/unstable/sql/SqlClient" -import { ServerConfig } from "../src/config.ts" -import { MigrationsLive } from "../src/migrations.ts" -import { ServerMessage } from "../src/messages.ts" -import { withWsRpcClient } from "../src/client.ts" -import { makeRoutesLayer } from "../src/server.ts" - -const testServerConfig = { - host: "127.0.0.1", - port: 0, - assetsDir: new URL("../../public", import.meta.url).pathname, - dbFilename: ":memory:", - requestLogging: false, - frontendDevOrigin: undefined -} - -const AppUnderTest = HttpRouter.serve( - makeRoutesLayer, - { - disableListenLog: true, - disableLogger: true - } -) - -const persistenceLayer = (dbFilename: string) => { - const sqliteLayer = SqliteNode.SqliteClient.layer({ filename: dbFilename }) - return MigrationsLive.pipe(Layer.provideMerge(sqliteLayer)) -} - -const appLayer = (dbFilename: string) => - AppUnderTest.pipe(Layer.provideMerge(persistenceLayer(dbFilename))) - -const insertTodo = (dbFilename: string, id: string, title: string) => - Effect.scoped( - Effect.flatMap(Effect.service(SqlClient.SqlClient), (sql) => { - const now = new Date().toISOString() - return sql.withTransaction( - sql` - INSERT INTO todos (id, title, completed, archived, revision, updated_at) - VALUES (${id}, ${title}, 0, 0, 1, ${now}) - `.pipe( - Effect.flatMap(() => - sql` - INSERT INTO todo_events (at, todo_json, change_json, archived) - VALUES ( - ${now}, - ${JSON.stringify({ - id, - title, - completed: false, - archived: false, - revision: 1, - updatedAt: now - })}, - ${JSON.stringify({ _tag: "TodoCreated" })}, - 0 - ) - ` - ) - ) - ) - }).pipe( - Effect.provide(persistenceLayer(dbFilename)) - ) - ) - -describe("server", () => { - it.effect("routes HTTP messages validated by Schema", () => - Effect.gen(function*() { - yield* Layer.build(appLayer(testServerConfig.dbFilename)).pipe( - Effect.provideService(ServerConfig, testServerConfig) - ) - const client = yield* HttpClient.HttpClient - const response = yield* client.post("/api/dispatch", { - body: HttpBody.jsonUnsafe({ kind: "sum", left: 20, right: 22 }) - }) - - const parsed = yield* HttpClientResponse.schemaBodyJson(ServerMessage)(response) - expect(parsed).toEqual({ kind: "sumResult", total: 42 }) - }).pipe(Effect.provide(NodeHttpServer.layerTest)) - ) - - it.effect("serves static files from local filesystem", () => - Effect.gen(function*() { - yield* Layer.build(appLayer(testServerConfig.dbFilename)).pipe( - Effect.provideService(ServerConfig, testServerConfig) - ) - const text = yield* HttpClient.get("/").pipe( - Effect.flatMap((response) => response.text) - ) - - expect(text).toContain("effect-http-ws-cli") - }).pipe(Effect.provide(NodeHttpServer.layerTest)) - ) - - it.effect("redirects frontend requests to the Vite dev server when enabled", () => - Effect.gen(function*() { - yield* Layer.build(appLayer(testServerConfig.dbFilename)).pipe( - Effect.provideService(ServerConfig, { - ...testServerConfig, - frontendDevOrigin: "http://127.0.0.1:5173" - }) - ) - - const server = yield* HttpServer.HttpServer - const address = server.address as HttpServer.TcpAddress - const response = yield* Effect.promise(() => - fetch(`http://127.0.0.1:${address.port}/todos?filter=active`, { - redirect: "manual" - }) - ) - - expect(response.status).toBe(307) - expect(response.headers.get("location")).toBe("http://127.0.0.1:5173/todos?filter=active") - }).pipe(Effect.provide(NodeHttpServer.layerTest)) - ) - - it.effect("routes WebSocket RPC messages with shared contracts", () => - Effect.gen(function*() { - yield* Layer.build(appLayer(testServerConfig.dbFilename)).pipe( - Effect.provideService(ServerConfig, testServerConfig) - ) - const server = yield* HttpServer.HttpServer - const address = server.address as HttpServer.TcpAddress - const wsUrl = `ws://127.0.0.1:${address.port}/ws` - - const response = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client.echo({ text: "hello from ws" })) - ) - - expect(response).toEqual({ text: "hello from ws" }) - }).pipe(Effect.provide(NodeHttpServer.layerTest)) - ) - - it.effect("routes WebSocket RPC calls for multiple procedures", () => - Effect.gen(function*() { - yield* Layer.build(appLayer(testServerConfig.dbFilename)).pipe( - Effect.provideService(ServerConfig, testServerConfig) - ) - const server = yield* HttpServer.HttpServer - const address = server.address as HttpServer.TcpAddress - const wsUrl = `ws://127.0.0.1:${address.port}/ws` - - const result = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - Effect.all({ - sum: client.sum({ left: 1, right: 2 }), - time: client.time(undefined) - }) - ) - ) - - expect(result.sum).toEqual({ total: 3 }) - expect(typeof result.time.iso).toBe("string") - }).pipe(Effect.provide(NodeHttpServer.layerTest)) - ) - - it.effect("lists todos and streams todo updates from offset", () => - Effect.gen(function*() { - const dbFilename = NodePath.join( - OS.tmpdir(), - `effect-http-ws-cli-stream-${Date.now()}-${Math.random().toString(16).slice(2)}.sqlite` - ) - - yield* insertTodo(dbFilename, "todo-stream-test", "stream-seed") - - const events = yield* Effect.scoped( - Effect.gen(function*() { - yield* Layer.build(appLayer(dbFilename)).pipe( - Effect.provideService(ServerConfig, { - ...testServerConfig, - dbFilename - }) - ) - const server = yield* HttpServer.HttpServer - const address = server.address as HttpServer.TcpAddress - const wsUrl = `ws://127.0.0.1:${address.port}/ws` - - return yield* withWsRpcClient(wsUrl, (client) => - Effect.gen(function*() { - const snapshot = yield* client.listTodos({ includeArchived: true }) - const firstTodo = snapshot.todos[0] - if (!firstTodo) { - return yield* Effect.die("Expected a todo fixture") - } - - yield* client.renameTodo({ id: firstTodo.id, title: "alpha" }) - yield* client.completeTodo({ id: firstTodo.id, completed: true }) - yield* client.archiveTodo({ id: firstTodo.id, archived: true }) - - return yield* client.subscribeTodos({ - fromOffset: snapshot.offset, - includeArchived: true - }).pipe( - Stream.take(3), - Stream.runCollect - ) - }) - ) - }) - ).pipe( - Effect.ensuring( - Effect.sync(() => { - try { - FileSystem.rmSync(dbFilename, { force: true }) - } catch { - // ignore cleanup failures in tests - } - }) - ) - ) - - expect(events).toHaveLength(3) - expect(events[0]?.change).toEqual({ _tag: "TodoRenamed", title: "alpha" }) - expect(events[1]?.change).toEqual({ _tag: "TodoCompleted", completed: true }) - expect(events[2]?.change).toEqual({ _tag: "TodoArchived", archived: true }) - expect(events[0]?.todo.title).toBe("alpha") - expect(events[1]?.todo.completed).toBe(true) - expect(events[2]?.todo.archived).toBe(true) - }).pipe(Effect.provide(NodeHttpServer.layerTest)) - ) - - it.effect("persists todos across server restarts when using a file database", () => - Effect.gen(function*() { - const dbFilename = NodePath.join( - OS.tmpdir(), - `effect-http-ws-cli-${Date.now()}-${Math.random().toString(16).slice(2)}.sqlite` - ) - - yield* insertTodo(dbFilename, "todo-persist-test", "persist-seed") - - const withServer = (f: (wsUrl: string) => Effect.Effect) => - Effect.scoped( - Effect.gen(function*() { - yield* Layer.build(appLayer(dbFilename)).pipe( - Effect.provideService(ServerConfig, { - ...testServerConfig, - dbFilename - }) - ) - - const server = yield* HttpServer.HttpServer - const address = server.address as HttpServer.TcpAddress - const wsUrl = `ws://127.0.0.1:${address.port}/ws` - return yield* f(wsUrl) - }) - ) - - const renamedTodoId = yield* withServer((wsUrl) => - Effect.scoped( - withWsRpcClient(wsUrl, (client) => - Effect.gen(function*() { - const snapshot = yield* client.listTodos({ includeArchived: true }) - const firstTodo = snapshot.todos[0] - if (!firstTodo) { - return yield* Effect.die("Expected a todo fixture") - } - - yield* client.renameTodo({ id: firstTodo.id, title: "persisted-title" }) - return firstTodo.id - }) - ) - ) - ) - - const persistedTitle = yield* withServer((wsUrl) => - Effect.scoped( - withWsRpcClient(wsUrl, (client) => - Effect.gen(function*() { - const snapshot = yield* client.listTodos({ includeArchived: true }) - const persisted = snapshot.todos.find((todo) => todo.id === renamedTodoId) - if (!persisted) { - return yield* Effect.die("Expected persisted todo") - } - return persisted.title - }) - ) - ) - ).pipe( - Effect.ensuring( - Effect.sync(() => { - try { - FileSystem.rmSync(dbFilename, { force: true }) - } catch { - // ignore cleanup failures in tests - } - }) - ) - ) - - expect(persistedTitle).toBe("persisted-title") - }).pipe(Effect.provide(NodeHttpServer.layerTest)) - ) -}) diff --git a/.reference/server/tsconfig.json b/.reference/server/tsconfig.json deleted file mode 100644 index de981b3bf8..0000000000 --- a/.reference/server/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../tsconfig.json", - "include": ["src", "test", "vitest.config.ts"] -} diff --git a/.reference/server/vitest.config.ts b/.reference/server/vitest.config.ts deleted file mode 100644 index 7ef18d4fc7..0000000000 --- a/.reference/server/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config" - -export default defineConfig({ - test: { - include: ["test/**/*.test.ts"], - environment: "node" - } -}) From 35561a082a1be1e9f87cdc0c9bd111e31922637f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 01:53:41 -0700 Subject: [PATCH 25/47] stale plan --- .plans/ws-rpc-endpoint-port-plan.md | 217 ---------------------------- 1 file changed, 217 deletions(-) delete mode 100644 .plans/ws-rpc-endpoint-port-plan.md diff --git a/.plans/ws-rpc-endpoint-port-plan.md b/.plans/ws-rpc-endpoint-port-plan.md deleted file mode 100644 index 623dc61972..0000000000 --- a/.plans/ws-rpc-endpoint-port-plan.md +++ /dev/null @@ -1,217 +0,0 @@ -# WebSocket RPC Port Plan - -Incrementally migrate WebSocket request handling from `apps/server/src/wsServer.ts` switch-cases to Effect RPC routes in `apps/server/src/ws.ts` with shared contracts in `packages/contracts`. - -## Porting Strategy (High Level) - -1. **Contract-first** - - Define each RPC in shared contracts (`packages/contracts`) so server and client use one schema source. - - Keep endpoint names identical to `WS_METHODS` / orchestration method names to avoid client churn. - -2. **Single endpoint slices** - - Port one endpoint at a time into `WsRpcGroup` in `apps/server/src/ws.ts`. - - Preserve current behavior and error semantics; avoid broad refactors in the same slice. - -3. **Prove wiring with tests** - - Add/extend integration tests in `apps/server/src/server.test.ts` (reference style: boot layer, connect WS RPC client, invoke method, assert result). - - Prefer lightweight assertions that prove route wiring + core behavior. - - Implementation details are often tested in each service's own tests. Server test only needs to prove high level behavior and error semantics. - -4. **Keep old path as fallback until parity** - - Leave legacy handler path in `wsServer.ts` for unmigrated methods. - - After each endpoint is migrated and tested, remove only that endpoint branch from legacy switch. - -5. **Quality gates per slice** - - Run `bun run test` (targeted), then `bun fmt`, `bun lint`, `bun typecheck`. - - Only proceed to next endpoint when checks are green. - -## Runtime Parity Priorities - -Use this as the execution order for the remainder of the migration. - -### P0: Full-stack flow working (unblock app runtime) - -- [ ] Wire startup side effects into live runtime composition. - - [ ] Ensure `serverRuntimeStartupLayer` is provided by `makeServerLayer`. - - [ ] Verify startup runs keybindings sync/start, orchestration reactor startup, lifecycle publish, startup heartbeat, optional browser open. -- [x] Add WebSocket auth parity on `/ws` route. - - [x] Enforce token gate equivalent to old `wsServer` behavior when `authToken` is configured. - - [x] Add integration tests for authorized and unauthorized websocket connections. -- [ ] Migrate web app critical live transport paths to RPC streams. - - [ ] `subscribeServerLifecycle` - - [ ] `subscribeOrchestrationDomainEvents` - - [ ] `subscribeTerminalEvents` - - [ ] `subscribeServerConfig` -- [ ] Implement reconnect + resubscribe MVP in web transport. - - [ ] Recover stream subscriptions after websocket reconnect. - - [ ] Avoid duplicate active subscriptions after reconnect. -- [ ] Add one end-to-end smoke proving boot-to-interaction. - - [ ] Server starts and lifecycle welcome/ready is observed. - - [ ] Client can dispatch orchestration command and receive domain events. - - [ ] Terminal write + terminal stream updates are observed. - -### P1: Behavior parity and reliability semantics - -- [ ] Remove remaining required runtime behavior from legacy `wsServer.ts`. -- [ ] Readiness semantics parity. - - [ ] Define and enforce when runtime is considered "ready". - - [ ] Align lifecycle event timing with readiness expectations. -- [ ] Stream behavior parity on current subscriptions. - - [ ] Confirm `subscribeServerConfig` timing and event shape parity for UI expectations. - - [ ] Confirm multi-client terminal stream behavior under concurrent writes. -- [ ] Delivery semantics documentation per stream. - - [ ] Ordering guarantees. - - [ ] Replay/catch-up behavior. - - [ ] At-most-once/best-effort contract. - -### P2: Cleanup + hardening - -- [ ] Backpressure policy per stream. - - [ ] Define buffer caps. - - [ ] Define drop/disconnect policy. -- [ ] Observability for stream reliability. - - [ ] Metrics/logging for dropped deliveries and subscriber failures. - - [ ] Reconnect churn visibility. -- [ ] Security hardening parity. - - [ ] Per-stream permission checks. -- [ ] Delete deprecated legacy transport artifacts once parity is proven. - - [ ] legacy `WS_CHANNELS` usage in active web transport. - - [ ] old ws envelope request/response codecs where obsolete. - - [ ] dead helpers/services only used by legacy transport path. - -## Ordered Endpoint Checklist - -Legend: `[x]` done, `[ ]` not started. - -### Phase 1: Server metadata (smallest surface) - -- [x] `server.getConfig` (now retired in favor of `subscribeServerConfig` snapshot-first stream) -- [x] `server.upsertKeybinding` - -### Phase 2: Project + editor read/write (small inputs, bounded side effects) - -- [x] `projects.searchEntries` -- [x] `projects.writeFile` -- [x] `shell.openInEditor` - -### Phase 3: Git operations (broader side effects) - -- [x] `git.status` -- [x] `git.listBranches` -- [x] `git.pull` -- [x] `git.runStackedAction` -- [x] `git.resolvePullRequest` -- [x] `git.preparePullRequestThread` -- [x] `git.createWorktree` -- [x] `git.removeWorktree` -- [x] `git.createBranch` -- [x] `git.checkout` -- [x] `git.init` - -### Phase 4: Terminal lifecycle + IO (stateful and streaming-adjacent) - -- [x] `terminal.open` -- [x] `terminal.write` -- [x] `terminal.resize` -- [x] `terminal.clear` -- [x] `terminal.restart` -- [x] `terminal.close` - -### Phase 5: Orchestration RPC methods (domain-critical path) - -- [x] `orchestration.getSnapshot` -- [x] `orchestration.dispatchCommand` -- [x] `orchestration.getTurnDiff` -- [x] `orchestration.getFullThreadDiff` -- [x] `orchestration.replayEvents` - -### Phase 6: Streaming subscriptions via RPC (replace push-channel bridge) - -- [x] Define streaming RPC contracts for all server-driven event surfaces (reference pattern: `subscribeTodos`): - - [x] `subscribeOrchestrationDomainEvents` - - [x] `subscribeTerminalEvents` - - [x] `subscribeServerConfig` (snapshot + keybindings updates + provider status heartbeat) - - [x] `subscribeServerLifecycle` (welcome/readiness/bootstrap updates) -- [ ] Add stream payload schemas in `packages/contracts` with narrow tagged unions where needed. - - [x] Include explicit event versioning strategy (`version` or schema evolution note). - - [ ] Ensure payload shape parity with existing `WS_CHANNELS` semantics. -- [ ] Implement streaming handlers in `apps/server/src/ws.ts` using `Effect.Stream`. - - [x] Wire first stream (`subscribeTerminalEvents`) to the correct source service/event bus. - - [x] Wire `subscribeServerConfig` to emit snapshot first, then live updates. - - [ ] Preserve ordering guarantees where currently expected. - - [ ] Preserve filtering/scoping rules (thread/session/worktree as applicable). -- [ ] Prove one full vertical slice first (recommended: terminal events), then fan out. - - [x] Contract + handler + client consumer. - - [x] Integration test: subscribe, receive at least one item, unsubscribe/interrupt cleanly. - - [x] Integration test: `subscribeServerConfig` emits initial snapshot and update event. - - [x] Integration test: provider-status heartbeat verified with Effect `TestClock.adjust`. -- [x] Remove superseded server-config RPCs that are now covered by stream semantics. - - [x] Remove `server.getConfig`. - - [x] Remove `subscribeServerConfigUpdates`. -- [ ] Subscription lifecycle semantics (must match or improve current behavior): - - [ ] reconnect + resubscribe behavior - - [ ] duplicate subscription protection on reconnect - - [ ] cancellation/unsubscribe finalizers - - [ ] cleanup when socket closes unexpectedly -- [ ] Reliability semantics: - - [ ] document and enforce backpressure strategy (buffer cap, drop policy, or disconnect) - - [ ] clarify delivery semantics (best-effort vs at-least-once) for each stream - - [ ] add metrics/logging for dropped/failed deliveries -- [ ] Security/auth parity: - - [ ] apply same auth gating as request/response RPC path - - [ ] enforce per-stream permission checks -- [ ] After parity, remove legacy push-channel publish paths and old envelope code paths for migrated streams. - -### Phase 7: Server startup/runtime side effects (move lifecycle out of legacy wsServer) - -- [ ] Move startup orchestration from `wsServer.ts` into layer-based runtime composition. - - [x] keybindings startup + default sync behavior - - [x] orchestration reactor startup - - [ ] terminal stream subscription lifecycle - - [ ] orchestration stream subscription lifecycle -- [ ] Move startup UX/ops side effects: - - [x] open-in-browser behavior - - [x] startup heartbeat analytics - - [ ] startup logs payload parity - - [x] optional auto-bootstrap project/thread from cwd -- [ ] Preserve readiness and failure semantics: - - [ ] readiness gates for required subsystems - - [ ] startup failure behavior and error messages - - [ ] startup ordering guarantees and retry policy (if any) -- [ ] Preserve shutdown semantics: - - [ ] finalizers/unsubscribe behavior - - [ ] ws server close behavior - - [ ] in-flight stream cancellation handling -- [ ] Add lifecycle-focused integration tests (startup happy path + failure path + shutdown cleanup). - -### Phase 8: Client migration (full surface) - -- [ ] Migrate web client transport in `apps/web/src/ws.ts` to consume RPC contracts directly. - - [ ] Decide transport approach (custom adapter vs Effect `RpcClient`) and lock one path. -- [ ] Request/response parity migration: - - [ ] replace legacy websocket envelope call helpers with typed RPC client calls - - [ ] ensure domain-specific error decoding/parsing parity -- [ ] Streaming parity migration: - - [ ] consume new streaming RPC subscriptions for all migrated channels - - [ ] implement reconnect + resubscribe strategy - - [ ] enforce unsubscribe on route/session teardown -- [ ] UX behavior parity: - - [ ] loading/connected/disconnected state transitions - - [ ] terminal/orchestration live updates timing and ordering - - [ ] welcome/bootstrap/config update behavior -- [ ] Client tests: - - [ ] integration coverage for request calls - - [ ] subscription lifecycle tests (connect, receive, reconnect, teardown) - -### Phase 9: Final cleanup + deprecation removal - -- [ ] Delete legacy `wsServer.ts` transport path once server+client parity is proven. -- [ ] Remove old shared protocol artifacts no longer needed: - - [ ] legacy `WS_CHANNELS` usage - - [ ] legacy ws envelope request/response codecs where obsolete - - [ ] dead helpers/services only used by legacy transport path -- [ ] Run parity audit checklist before deletion: - - [ ] every old method mapped to RPC equivalent - - [ ] every old push channel mapped to streaming RPC equivalent - - [ ] auth/error/ordering semantics verified -- [ ] Add migration note/changelog entry for downstream consumers (if any). \ No newline at end of file From ee67778261d2390119199f95131f07a0eb3c7142 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Mar 2026 20:39:31 -0700 Subject: [PATCH 26/47] fix stm and sqlite --- .../src/persistence/NodeSqliteClient.ts | 21 ++++-- bun.lock | 22 +++--- package.json | 10 +-- packages/shared/src/DrainableWorker.ts | 73 +++++-------------- 4 files changed, 52 insertions(+), 74 deletions(-) diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index 0a73fca886..5577ac5b01 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -20,7 +20,7 @@ import * as Stream from "effect/Stream"; import * as Reactivity from "effect/unstable/reactivity/Reactivity"; import * as Client from "effect/unstable/sql/SqlClient"; import type { Connection } from "effect/unstable/sql/SqlConnection"; -import { SqlError } from "effect/unstable/sql/SqlError"; +import { SqlError, classifySqliteError } from "effect/unstable/sql/SqlError"; import * as Statement from "effect/unstable/sql/Statement"; const ATTR_DB_SYSTEM_NAME = "db.system.name"; @@ -29,7 +29,8 @@ export const TypeId: TypeId = "~local/sqlite-node/SqliteClient"; export type TypeId = "~local/sqlite-node/SqliteClient"; -const toSqlError = (cause: unknown, message: string) => new SqlError({ cause, message }); +const classifyError = (cause: unknown, message: string, operation: string) => + classifySqliteError(cause, { message, operation }); /** * SqliteClient - Effect service tag for the sqlite SQL client. @@ -111,7 +112,10 @@ const makeWithDatabase = ( lookup: (sql: string) => Effect.try({ try: () => db.prepare(sql), - catch: (cause) => toSqlError(cause, "Failed to prepare statement"), + catch: (cause) => + new SqlError({ + reason: classifyError(cause, "Failed to prepare statement", "prepare"), + }), }), }); @@ -129,7 +133,11 @@ const makeWithDatabase = ( const result = statement.run(...(params as any)); return Effect.succeed(raw ? (result as unknown as ReadonlyArray) : []); } catch (cause) { - return Effect.fail(toSqlError(cause, "Failed to execute statement")); + return Effect.fail( + new SqlError({ + reason: classifyError(cause, "Failed to execute statement", "execute"), + }), + ); } }); @@ -152,7 +160,10 @@ const makeWithDatabase = ( statement.run(...(params as any)); return []; }, - catch: (cause) => toSqlError(cause, "Failed to execute statement"), + catch: (cause) => + new SqlError({ + reason: classifyError(cause, "Failed to execute statement", "execute"), + }), }), (statement) => Effect.sync(() => { diff --git a/bun.lock b/bun.lock index d37aad45fc..60a037d14c 100644 --- a/bun.lock +++ b/bun.lock @@ -178,13 +178,13 @@ }, "catalog": { "@effect/language-service": "0.75.1", - "@effect/platform-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-bun@88a7553", - "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@88a7553", - "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@88a7553", - "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@88a7553", + "@effect/platform-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-bun@2ae33d0", + "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@2ae33d0", + "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@2ae33d0", + "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@2ae33d0", "@types/bun": "^1.3.9", "@types/node": "^24.10.13", - "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@88a7553", + "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@2ae33d0", "tsdown": "^0.20.3", "typescript": "^5.7.3", "vitest": "^4.0.0", @@ -274,15 +274,15 @@ "@effect/language-service": ["@effect/language-service@0.75.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-g9xD2tAQgRFpYC2YgpZq02VeSL5fBbFJ0B/g1o+14NuNmwtaYJc7SjiLWAA9eyhJHosNrn6h1Ye+Kx6j5mN0AA=="], - "@effect/platform-bun": ["@effect/platform-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-bun@88a7553", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@88a7553085a8e6e9d456a496a6075e1e573e6d01" }, "peerDependencies": { "effect": "^4.0.0-beta.32" } }], + "@effect/platform-bun": ["@effect/platform-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-bun@2ae33d0", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@2ae33d050914915f7cb9c25ab0a020901e08d596" }, "peerDependencies": { "effect": "^4.0.0-beta.42" } }], - "@effect/platform-node": ["@effect/platform-node@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@88a7553", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@88a7553085a8e6e9d456a496a6075e1e573e6d01", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.32", "ioredis": "^5.7.0" } }], + "@effect/platform-node": ["@effect/platform-node@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@2ae33d0", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@2ae33d050914915f7cb9c25ab0a020901e08d596", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42", "ioredis": "^5.7.0" } }], - "@effect/platform-node-shared": ["@effect/platform-node-shared@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@88a7553085a8e6e9d456a496a6075e1e573e6d01", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.32" } }], + "@effect/platform-node-shared": ["@effect/platform-node-shared@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@2ae33d050914915f7cb9c25ab0a020901e08d596", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42" } }], - "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@88a7553", { "peerDependencies": { "effect": "^4.0.0-beta.32" } }], + "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@2ae33d0", { "peerDependencies": { "effect": "^4.0.0-beta.42" } }], - "@effect/vitest": ["@effect/vitest@https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@88a7553", { "peerDependencies": { "effect": "^4.0.0-beta.32", "vitest": "^3.0.0 || ^4.0.0" } }], + "@effect/vitest": ["@effect/vitest@https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@2ae33d0", { "peerDependencies": { "effect": "^4.0.0-beta.42", "vitest": "^3.0.0 || ^4.0.0" } }], "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], @@ -1064,7 +1064,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@https://pkg.pr.new/Effect-TS/effect-smol/effect@88a7553", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }], + "effect": ["effect@https://pkg.pr.new/Effect-TS/effect-smol/effect@2ae33d0", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }], "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], diff --git a/package.json b/package.json index fd7cc70e1f..860ee011b0 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,11 @@ "scripts" ], "catalog": { - "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@88a7553", - "@effect/platform-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-bun@88a7553", - "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@88a7553", - "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@88a7553", - "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@88a7553", + "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@2ae33d0", + "@effect/platform-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-bun@2ae33d0", + "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@2ae33d0", + "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@2ae33d0", + "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@2ae33d0", "@effect/language-service": "0.75.1", "@types/bun": "^1.3.9", "@types/node": "^24.10.13", diff --git a/packages/shared/src/DrainableWorker.ts b/packages/shared/src/DrainableWorker.ts index d5de019002..55483f33e8 100644 --- a/packages/shared/src/DrainableWorker.ts +++ b/packages/shared/src/DrainableWorker.ts @@ -2,14 +2,14 @@ * DrainableWorker - A queue-based worker that exposes a `drain()` effect. * * Wraps the common `Queue.unbounded` + `Effect.forever` pattern and adds - * a signal that resolves when the queue is empty and the current item + * a signal that resolves when the queue is empty **and** the current item * has finished processing. This lets tests replace timing-sensitive * `Effect.sleep` calls with deterministic `drain()`. * * @module DrainableWorker */ -import { Deferred, Effect, Queue, Ref } from "effect"; import type { Scope } from "effect"; +import { Effect, TxQueue, TxRef } from "effect"; export interface DrainableWorker { /** @@ -39,63 +39,30 @@ export const makeDrainableWorker = ( process: (item: A) => Effect.Effect, ): Effect.Effect, never, Scope.Scope | R> => Effect.gen(function* () { - const queue = yield* Queue.unbounded(); - const initialIdle = yield* Deferred.make(); - yield* Deferred.succeed(initialIdle, undefined).pipe(Effect.orDie); - const state = yield* Ref.make({ - outstanding: 0, - idle: initialIdle, - }); + const queue = yield* Effect.acquireRelease(TxQueue.unbounded(), TxQueue.shutdown); + const outstanding = yield* TxRef.make(0); - yield* Effect.addFinalizer(() => Queue.shutdown(queue).pipe(Effect.asVoid)); - - const finishOne = Ref.modify(state, (current) => { - const remaining = Math.max(0, current.outstanding - 1); - return [ - remaining === 0 ? current.idle : null, - { - outstanding: remaining, - idle: current.idle, - }, - ] as const; - }).pipe( - Effect.flatMap((idle) => - idle === null ? Effect.void : Deferred.succeed(idle, undefined).pipe(Effect.orDie), - ), - ); - - yield* Effect.forkScoped( - Effect.forever( - Queue.take(queue).pipe( - Effect.flatMap((item) => process(item).pipe(Effect.ensuring(finishOne))), + yield* TxQueue.take(queue).pipe( + Effect.tap((a) => + Effect.ensuring( + process(a), + TxRef.update(outstanding, (n) => n - 1), ), ), + Effect.forever, + Effect.forkScoped, ); - const enqueue: DrainableWorker["enqueue"] = (item) => - Effect.gen(function* () { - const nextIdle = yield* Deferred.make(); - yield* Ref.update(state, (current) => - current.outstanding === 0 - ? { - outstanding: 1, - idle: nextIdle, - } - : { - outstanding: current.outstanding + 1, - idle: current.idle, - }, - ); - - const accepted = yield* Queue.offer(queue, item); - if (!accepted) { - yield* finishOne; - } - }); - - const drain: DrainableWorker["drain"] = Ref.get(state).pipe( - Effect.flatMap(({ idle }) => Deferred.await(idle)), + const drain: DrainableWorker["drain"] = TxRef.get(outstanding).pipe( + Effect.tap((n) => (n > 0 ? Effect.txRetry : Effect.void)), + Effect.tx, ); + const enqueue = (element: A): Effect.Effect => + TxQueue.offer(queue, element).pipe( + Effect.tap(() => TxRef.update(outstanding, (n) => n + 1)), + Effect.tx, + ); + return { enqueue, drain } satisfies DrainableWorker; }); From ec4226e4af9a6b98dde6f50dbafcd76abd1400a7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 10:34:27 -0700 Subject: [PATCH 27/47] mv helper --- apps/web/src/components/ProjectFavicon.tsx | 23 +++--------- apps/web/src/lib/utils.ts | 35 ++++++++++++++++++ apps/web/src/routes/__root.tsx | 8 ++--- apps/web/src/wsTransport.ts | 41 +++++++--------------- 4 files changed, 54 insertions(+), 53 deletions(-) diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index bc0118120f..ab9ec23332 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,25 +1,10 @@ import { FolderIcon } from "lucide-react"; import { useState } from "react"; +import { resolveServerUrl } from "~/lib/utils"; -function getServerHttpOrigin(): string { - const bridgeUrl = window.desktopBridge?.getWsUrl(); - const envUrl = import.meta.env.VITE_WS_URL as string | undefined; - const wsUrl = - bridgeUrl && bridgeUrl.length > 0 - ? bridgeUrl - : envUrl && envUrl.length > 0 - ? envUrl - : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`; - // Parse to extract just the origin, dropping path/query (e.g. ?token=…) - const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:"); - try { - return new URL(httpUrl).origin; - } catch { - return httpUrl; - } -} - -const serverHttpOrigin = getServerHttpOrigin(); +const serverHttpOrigin = resolveServerUrl({ + protocol: "http", +}); const loadedProjectFaviconSrcs = new Set(); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index b5834606b1..9f5e49153b 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -1,4 +1,5 @@ import { CommandId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { String, Predicate } from "effect"; import { type CxOptions, cx } from "class-variance-authority"; import { twMerge } from "tailwind-merge"; import * as Random from "effect/Random"; @@ -34,3 +35,37 @@ export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(randomUUID()); export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID()); export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID()); + +const isNonEmptyString = Predicate.compose(Predicate.isString, String.isNonEmpty); +const firstNonEmptyString = (...values: unknown[]): string => { + for (const value of values) { + if (isNonEmptyString(value)) { + return value; + } + } + throw new Error("No non-empty string provided"); +}; + +export const resolveServerUrl = (options?: { + url?: string | undefined; + protocol?: "http" | "https" | "ws" | "wss" | undefined; + pathname?: string | undefined; +}): string => { + const rawUrl = firstNonEmptyString( + options?.url, + window.desktopBridge?.getWsUrl(), + import.meta.env.VITE_WS_URL, + window.location.origin, + ); + + const parsedUrl = new URL(rawUrl); + if (options?.protocol) { + parsedUrl.protocol = options.protocol; + } + if (options?.pathname) { + parsedUrl.pathname = options.pathname; + } else { + parsedUrl.pathname = "/"; + } + return parsedUrl.toString(); +}; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index f90074c41f..198fb733b4 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -4,7 +4,7 @@ import { createRootRouteWithContext, type ErrorComponentProps, useNavigate, - useRouterState, + useLocation, } from "@tanstack/react-router"; import { useEffect, useRef } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; @@ -142,13 +142,11 @@ function EventRouter() { ); const queryClient = useQueryClient(); const navigate = useNavigate(); - const pathname = useRouterState({ select: (state) => state.location.pathname }); + const pathname = useLocation({ select: (loc) => loc.pathname }); const pathnameRef = useRef(pathname); const handledBootstrapThreadIdRef = useRef(null); - useEffect(() => { - pathnameRef.current = pathname; - }, [pathname]); + pathnameRef.current = pathname; useEffect(() => { const api = readNativeApi(); diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index c398bcb05a..ff9e63c90c 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -2,6 +2,7 @@ import { Data, Effect, Exit, Layer, ManagedRuntime, Scope, Stream } from "effect import { WsRpcGroup } from "@t3tools/contracts"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; +import { resolveServerUrl } from "./lib/utils"; const makeWsRpcClient = RpcClient.make(WsRpcGroup); @@ -37,24 +38,6 @@ function formatErrorMessage(error: unknown): string { return String(error); } -function resolveWebSocketUrl(url?: string): string { - const bridgeUrl = window.desktopBridge?.getWsUrl(); - const envUrl = import.meta.env.VITE_WS_URL as string | undefined; - const rawUrl = - url ?? - (bridgeUrl && bridgeUrl.length > 0 - ? bridgeUrl - : envUrl && envUrl.length > 0 - ? envUrl - : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`); - - const parsedUrl = new URL(rawUrl); - if (parsedUrl.pathname === "/" || parsedUrl.pathname.length === 0) { - parsedUrl.pathname = "/ws"; - } - return parsedUrl.toString(); -} - export class WsTransport { private readonly runtime: ManagedRuntime.ManagedRuntime; private readonly clientScope: Scope.Closeable; @@ -62,19 +45,19 @@ export class WsTransport { private disposed = false; constructor(url?: string) { - const resolvedUrl = resolveWebSocketUrl(url); - const runtimeLayer = RpcClient.layerProtocolSocket({ retryTransientErrors: true }).pipe( - Layer.provide( - Layer.mergeAll( - Socket.layerWebSocket(resolvedUrl).pipe( - Layer.provide(Socket.layerWebSocketConstructorGlobal), - ), - RpcSerialization.layerJson, - ), - ), + const resolvedUrl = resolveServerUrl({ + url, + protocol: "ws", + pathname: "/ws", + }); + const SocketLayer = Socket.layerWebSocket(resolvedUrl).pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + ); + const ProtocolLayer = RpcClient.layerProtocolSocket({ retryTransientErrors: true }).pipe( + Layer.provide(Layer.mergeAll(SocketLayer, RpcSerialization.layerJson)), ); - this.runtime = ManagedRuntime.make(runtimeLayer); + this.runtime = ManagedRuntime.make(ProtocolLayer); this.clientScope = Effect.runSync(Scope.make()); this.clientPromise = this.runtime.runPromise(Scope.provide(this.clientScope)(makeWsRpcClient)); } From dbdc2bff1b1fedf2f461cdf174cf6f024925658f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 11:13:02 -0700 Subject: [PATCH 28/47] Use callback queue for terminal event streaming - Replace the temporary PubSub bridge with `Stream.callback` - Wire terminal subscriptions through `Queue.offer` for simpler cleanup - Preserve unsubscribe handling with acquire/release --- apps/server/src/ws.ts | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2eb3b060e3..ad801f0c91 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,4 +1,15 @@ -import { Effect, FileSystem, Layer, Option, Path, PubSub, Ref, Schema, Stream } from "effect"; +import { + Effect, + FileSystem, + Layer, + Option, + Path, + PubSub, + Queue, + Ref, + Schema, + Stream, +} from "effect"; import { type GitActionProgressEvent, OrchestrationDispatchCommandError, @@ -277,18 +288,11 @@ const WsRpcLayer = WsRpcGroup.toLayer( [WS_METHODS.terminalRestart]: (input) => terminalManager.restart(input), [WS_METHODS.terminalClose]: (input) => terminalManager.close(input), [WS_METHODS.subscribeTerminalEvents]: (_input) => - Stream.unwrap( - Effect.gen(function* () { - const pubsub = yield* PubSub.unbounded(); - const unsubscribe = yield* terminalManager.subscribe((event) => - Effect.sync(() => { - PubSub.publishUnsafe(pubsub, event); - }), - ); - return Stream.fromPubSub(pubsub).pipe( - Stream.ensuring(Effect.sync(() => unsubscribe())), - ); - }), + Stream.callback((queue) => + Effect.acquireRelease( + terminalManager.subscribe((event) => Queue.offer(queue, event)), + (unsubscribe) => Effect.sync(unsubscribe), + ), ), [WS_METHODS.subscribeGitActionProgress]: (_input) => Stream.fromPubSub(gitActionProgressPubSub), From b65e74d081f4c059ec12b5c57ab42d89b553fda8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 14:38:16 -0700 Subject: [PATCH 29/47] Fix RPC browser test websocket mocks Co-authored-by: codex --- apps/web/src/components/ChatView.browser.tsx | 137 +++++++++++++----- .../components/KeybindingsToast.browser.tsx | 99 +++++++++---- apps/web/src/nativeApi.ts | 7 +- apps/web/src/wsNativeApi.ts | 15 ++ 4 files changed, 188 insertions(+), 70 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 20816d6935..803145c67c 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -4,15 +4,15 @@ import "../index.css"; import { EventId, ORCHESTRATION_WS_METHODS, - ORCHESTRATION_WS_CHANNELS, type MessageId, type OrchestrationEvent, type OrchestrationReadModel, type ProjectId, type ServerConfig, + type ServerConfigStreamEvent, + type ServerLifecycleStreamEvent, type ThreadId, type WsWelcomePayload, - WS_CHANNELS, WS_METHODS, OrchestrationSessionStatus, DEFAULT_SERVER_SETTINGS, @@ -31,6 +31,7 @@ import { removeInlineTerminalContextPlaceholder, } from "../lib/terminalContext"; import { isMacPlatform } from "../lib/utils"; +import { __resetNativeApiForTests } from "../nativeApi"; import { getRouter } from "../router"; import { useStore } from "../store"; import { estimateTimelineMessageHeight } from "./timelineHeight"; @@ -44,13 +45,17 @@ const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; interface WsRequestEnvelope { + _tag: "Request"; id: string; - body: { - _tag: string; - [key: string]: unknown; - }; + tag: string; + payload: unknown; } +type NormalizedWsRequestBody = { + _tag: string; + [key: string]: unknown; +}; + interface TestFixture { snapshot: OrchestrationReadModel; serverConfig: ServerConfig; @@ -58,10 +63,10 @@ interface TestFixture { } let fixture: TestFixture; -const wsRequests: WsRequestEnvelope["body"][] = []; -let customWsRpcResolver: ((body: WsRequestEnvelope["body"]) => unknown | undefined) | null = null; +const wsRequests: NormalizedWsRequestBody[] = []; +let customWsRpcResolver: ((body: NormalizedWsRequestBody) => unknown | undefined) | null = null; let wsClient: { send: (message: string) => void } | null = null; -let pushSequence = 1; +const streamRequestIds = new Map(); const wsLink = ws.link(/ws(s)?:\/\/.*/); interface ViewportSpec { @@ -372,17 +377,7 @@ function createThreadCreatedEvent(threadId: ThreadId, sequence: number): Orchest } function sendOrchestrationDomainEvent(event: OrchestrationEvent): void { - if (!wsClient) { - throw new Error("WebSocket client not connected"); - } - wsClient.send( - JSON.stringify({ - type: "push", - sequence: pushSequence++, - channel: ORCHESTRATION_WS_CHANNELS.domainEvent, - data: event, - }), - ); + sendStreamChunk(WS_METHODS.subscribeOrchestrationDomainEvents, event); } async function waitForWsClient(): Promise<{ send: (message: string) => void }> { @@ -513,7 +508,7 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } -function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { +function resolveWsRpc(body: NormalizedWsRequestBody): unknown { const customResult = customWsRpcResolver?.(body); if (customResult !== undefined) { return customResult; @@ -560,6 +555,9 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { truncated: false, }; } + if (tag === WS_METHODS.shellOpenInEditor) { + return null; + } if (tag === WS_METHODS.terminalOpen) { return { threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID, @@ -576,18 +574,38 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { return {}; } +function normalizeWsRequestBody(request: WsRequestEnvelope): NormalizedWsRequestBody { + const payload = + request.payload && typeof request.payload === "object" && !Array.isArray(request.payload) + ? request.payload + : {}; + return { + _tag: request.tag, + ...payload, + }; +} + +function sendStreamChunk(method: string, value: unknown) { + if (!wsClient) { + throw new Error("WebSocket client not connected"); + } + const requestId = streamRequestIds.get(method); + if (!requestId) { + throw new Error(`Missing stream subscription for ${method}`); + } + wsClient.send( + JSON.stringify({ + _tag: "Chunk", + requestId, + values: [value], + }), + ); +} + const worker = setupWorker( wsLink.addEventListener("connection", ({ client }) => { wsClient = client; - pushSequence = 1; - client.send( - JSON.stringify({ - type: "push", - sequence: pushSequence++, - channel: WS_CHANNELS.serverWelcome, - data: fixture.welcome, - }), - ); + streamRequestIds.clear(); client.addEventListener("message", (event) => { const rawData = event.data; if (typeof rawData !== "string") return; @@ -597,13 +615,42 @@ const worker = setupWorker( } catch { return; } - const method = request.body?._tag; - if (typeof method !== "string") return; - wsRequests.push(request.body); + if (request._tag !== "Request" || typeof request.tag !== "string") return; + if ( + request.tag === WS_METHODS.subscribeServerLifecycle || + request.tag === WS_METHODS.subscribeServerConfig || + request.tag === WS_METHODS.subscribeGitActionProgress || + request.tag === WS_METHODS.subscribeOrchestrationDomainEvents || + request.tag === WS_METHODS.subscribeTerminalEvents + ) { + streamRequestIds.set(request.tag, request.id); + if (request.tag === WS_METHODS.subscribeServerLifecycle) { + sendStreamChunk(request.tag, { + version: 1, + sequence: 1, + type: "welcome", + payload: fixture.welcome, + } satisfies ServerLifecycleStreamEvent); + } + if (request.tag === WS_METHODS.subscribeServerConfig) { + sendStreamChunk(request.tag, { + version: 1, + type: "snapshot", + config: fixture.serverConfig, + } satisfies ServerConfigStreamEvent); + } + return; + } + const body = normalizeWsRequestBody(request); + wsRequests.push(body); client.send( JSON.stringify({ - id: request.id, - result: resolveWsRpc(request.body), + _tag: "Exit", + requestId: request.id, + exit: { + _tag: "Success", + value: resolveWsRpc(body), + }, }), ); }); @@ -716,7 +763,7 @@ async function waitForInteractionModeButton( async function waitForServerConfigToApply(): Promise { await vi.waitFor( () => { - expect(wsRequests.some((request) => request._tag === WS_METHODS.serverGetConfig)).toBe(true); + expect(streamRequestIds.has(WS_METHODS.subscribeServerConfig)).toBe(true); }, { timeout: 8_000, interval: 16 }, ); @@ -854,7 +901,7 @@ async function mountChatView(options: { viewport: ViewportSpec; snapshot: OrchestrationReadModel; configureFixture?: (fixture: TestFixture) => void; - resolveRpc?: (body: WsRequestEnvelope["body"]) => unknown | undefined; + resolveRpc?: (body: NormalizedWsRequestBody) => unknown | undefined; }): Promise { fixture = buildFixture(options.snapshot); options.configureFixture?.(fixture); @@ -940,6 +987,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); beforeEach(async () => { + __resetNativeApiForTests(); await setViewport(DEFAULT_VIEWPORT); localStorage.clear(); document.body.innerHTML = ""; @@ -1149,6 +1197,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { + await waitForServerConfigToApply(); const openButton = await waitForElement( () => Array.from(document.querySelectorAll("button")).find( @@ -1156,6 +1205,9 @@ describe("ChatView timeline estimator parity (full app)", () => { ) as HTMLButtonElement | null, "Unable to find Open button.", ); + await vi.waitFor(() => { + expect(openButton.disabled).toBe(false); + }); openButton.click(); await vi.waitFor( @@ -1191,6 +1243,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { + await waitForServerConfigToApply(); const openButton = await waitForElement( () => Array.from(document.querySelectorAll("button")).find( @@ -1198,6 +1251,9 @@ describe("ChatView timeline estimator parity (full app)", () => { ) as HTMLButtonElement | null, "Unable to find Open button.", ); + await vi.waitFor(() => { + expect(openButton.disabled).toBe(false); + }); openButton.click(); await vi.waitFor( @@ -1233,6 +1289,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { + await waitForServerConfigToApply(); const menuButton = await waitForElement( () => document.querySelector('button[aria-label="Copy options"]'), "Unable to find Open picker button.", @@ -1281,7 +1338,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); it("falls back to the first installed editor when the stored favorite is unavailable", async () => { - localStorage.setItem("t3code:last-editor", "vscodium"); + localStorage.setItem("t3code:last-editor", JSON.stringify("vscodium")); setDraftThreadWithoutWorktree(); const mounted = await mountChatView({ @@ -1296,6 +1353,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { + await waitForServerConfigToApply(); const openButton = await waitForElement( () => Array.from(document.querySelectorAll("button")).find( @@ -1303,6 +1361,9 @@ describe("ChatView timeline estimator parity (full app)", () => { ) as HTMLButtonElement | null, "Unable to find Open button.", ); + await vi.waitFor(() => { + expect(openButton.disabled).toBe(false); + }); openButton.click(); await vi.waitFor( diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 224bd2f887..b6792e3c7d 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -6,9 +6,10 @@ import { type OrchestrationReadModel, type ProjectId, type ServerConfig, + type ServerConfigStreamEvent, + type ServerLifecycleStreamEvent, type ThreadId, type WsWelcomePayload, - WS_CHANNELS, WS_METHODS, } from "@t3tools/contracts"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; @@ -18,6 +19,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { render } from "vitest-browser-react"; import { useComposerDraftStore } from "../composerDraftStore"; +import { __resetNativeApiForTests } from "../nativeApi"; import { getRouter } from "../router"; import { useStore } from "../store"; @@ -31,9 +33,16 @@ interface TestFixture { welcome: WsWelcomePayload; } +interface WsRpcRequestEnvelope { + _tag: "Request"; + id: string; + tag: string; + payload: unknown; +} + let fixture: TestFixture; let wsClient: { send: (data: string) => void } | null = null; -let pushSequence = 1; +const streamRequestIds = new Map(); const wsLink = ws.link(/ws(s)?:\/\/.*/); @@ -177,33 +186,68 @@ function resolveWsRpc(tag: string): unknown { return {}; } +function sendStreamChunk(method: string, value: unknown) { + if (!wsClient) throw new Error("WebSocket client not connected"); + const requestId = streamRequestIds.get(method); + if (!requestId) { + throw new Error(`Missing stream subscription for ${method}`); + } + wsClient.send( + JSON.stringify({ + _tag: "Chunk", + requestId, + values: [value], + }), + ); +} + const worker = setupWorker( wsLink.addEventListener("connection", ({ client }) => { wsClient = client; - pushSequence = 1; - client.send( - JSON.stringify({ - type: "push", - sequence: pushSequence++, - channel: WS_CHANNELS.serverWelcome, - data: fixture.welcome, - }), - ); + streamRequestIds.clear(); client.addEventListener("message", (event) => { const rawData = event.data; if (typeof rawData !== "string") return; - let request: { id: string; body: { _tag: string; [key: string]: unknown } }; + let request: WsRpcRequestEnvelope; try { - request = JSON.parse(rawData); + request = JSON.parse(rawData) as WsRpcRequestEnvelope; } catch { return; } - const method = request.body?._tag; - if (typeof method !== "string") return; + if (request._tag !== "Request" || typeof request.tag !== "string") return; + if ( + request.tag === WS_METHODS.subscribeServerLifecycle || + request.tag === WS_METHODS.subscribeServerConfig || + request.tag === WS_METHODS.subscribeGitActionProgress || + request.tag === WS_METHODS.subscribeOrchestrationDomainEvents || + request.tag === WS_METHODS.subscribeTerminalEvents + ) { + streamRequestIds.set(request.tag, request.id); + if (request.tag === WS_METHODS.subscribeServerLifecycle) { + sendStreamChunk(request.tag, { + version: 1, + sequence: 1, + type: "welcome", + payload: fixture.welcome, + } satisfies ServerLifecycleStreamEvent); + } + if (request.tag === WS_METHODS.subscribeServerConfig) { + sendStreamChunk(request.tag, { + version: 1, + type: "snapshot", + config: fixture.serverConfig, + } satisfies ServerConfigStreamEvent); + } + return; + } client.send( JSON.stringify({ - id: request.id, - result: resolveWsRpc(method), + _tag: "Exit", + requestId: request.id, + exit: { + _tag: "Success", + value: resolveWsRpc(request.tag), + }, }), ); }); @@ -212,19 +256,12 @@ const worker = setupWorker( http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), ); -function sendServerConfigUpdatedPush(issues: Array<{ kind: string; message: string }>) { - if (!wsClient) throw new Error("WebSocket client not connected"); - wsClient.send( - JSON.stringify({ - type: "push", - sequence: pushSequence++, - channel: WS_CHANNELS.serverConfigUpdated, - data: { - issues, - providers: fixture.serverConfig.providers, - }, - }), - ); +function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) { + sendStreamChunk(WS_METHODS.subscribeServerConfig, { + version: 1, + type: "keybindingsUpdated", + payload: { issues }, + } satisfies ServerConfigStreamEvent); } function queryToastTitles(): string[] { @@ -312,9 +349,9 @@ describe("Keybindings update toast", () => { }); beforeEach(() => { + __resetNativeApiForTests(); localStorage.clear(); document.body.innerHTML = ""; - pushSequence = 1; useComposerDraftStore.setState({ draftsByThreadId: {}, draftThreadsByThreadId: {}, diff --git a/apps/web/src/nativeApi.ts b/apps/web/src/nativeApi.ts index 40443f67e0..9f528b6342 100644 --- a/apps/web/src/nativeApi.ts +++ b/apps/web/src/nativeApi.ts @@ -1,6 +1,6 @@ import type { NativeApi } from "@t3tools/contracts"; -import { createWsNativeApi } from "./wsNativeApi"; +import { __resetWsNativeApiForTests, createWsNativeApi } from "./wsNativeApi"; let cachedApi: NativeApi | undefined; @@ -24,3 +24,8 @@ export function ensureNativeApi(): NativeApi { } return api; } + +export function __resetNativeApiForTests() { + cachedApi = undefined; + __resetWsNativeApiForTests(); +} diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 951404f21a..11f01259a6 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -37,6 +37,21 @@ let latestServerConfig: ServerConfig | null = null; let latestServerConfigUpdated: ServerConfigUpdatedNotification | null = null; let latestProvidersUpdated: ServerProviderUpdatedPayload | null = null; +export function __resetWsNativeApiForTests() { + if (instance) { + instance.transport.dispose(); + instance = null; + } + welcomeListeners.clear(); + gitActionProgressListeners.clear(); + providersUpdatedListeners.clear(); + serverConfigUpdatedListeners.clear(); + latestWelcomePayload = null; + latestServerConfig = null; + latestServerConfigUpdated = null; + latestProvidersUpdated = null; +} + function emitWelcome(payload: WsWelcomePayload) { latestWelcomePayload = payload; for (const listener of welcomeListeners) { From 0f48b4f8a1e456c163bd3ccc37dd9147b489ce59 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 14:56:41 -0700 Subject: [PATCH 30/47] Use Effect RPC in browser test mocks Co-authored-by: codex --- apps/web/src/components/ChatView.browser.tsx | 148 +++++---------- .../components/KeybindingsToast.browser.tsx | 106 ++++------- apps/web/src/test/wsRpcHarness.ts | 169 ++++++++++++++++++ 3 files changed, 246 insertions(+), 177 deletions(-) create mode 100644 apps/web/src/test/wsRpcHarness.ts diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 803145c67c..327ebb2b07 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -9,8 +9,6 @@ import { type OrchestrationReadModel, type ProjectId, type ServerConfig, - type ServerConfigStreamEvent, - type ServerLifecycleStreamEvent, type ThreadId, type WsWelcomePayload, WS_METHODS, @@ -34,6 +32,7 @@ import { isMacPlatform } from "../lib/utils"; import { __resetNativeApiForTests } from "../nativeApi"; import { getRouter } from "../router"; import { useStore } from "../store"; +import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../test/wsRpcHarness"; import { estimateTimelineMessageHeight } from "./timelineHeight"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; @@ -44,18 +43,6 @@ const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; -interface WsRequestEnvelope { - _tag: "Request"; - id: string; - tag: string; - payload: unknown; -} - -type NormalizedWsRequestBody = { - _tag: string; - [key: string]: unknown; -}; - interface TestFixture { snapshot: OrchestrationReadModel; serverConfig: ServerConfig; @@ -63,10 +50,9 @@ interface TestFixture { } let fixture: TestFixture; -const wsRequests: NormalizedWsRequestBody[] = []; -let customWsRpcResolver: ((body: NormalizedWsRequestBody) => unknown | undefined) | null = null; -let wsClient: { send: (message: string) => void } | null = null; -const streamRequestIds = new Map(); +const rpcHarness = new BrowserWsRpcHarness(); +const wsRequests = rpcHarness.requests; +let customWsRpcResolver: ((body: NormalizedWsRpcRequestBody) => unknown | undefined) | null = null; const wsLink = ws.link(/ws(s)?:\/\/.*/); interface ViewportSpec { @@ -377,22 +363,20 @@ function createThreadCreatedEvent(threadId: ThreadId, sequence: number): Orchest } function sendOrchestrationDomainEvent(event: OrchestrationEvent): void { - sendStreamChunk(WS_METHODS.subscribeOrchestrationDomainEvents, event); + rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event); } -async function waitForWsClient(): Promise<{ send: (message: string) => void }> { - let client: { send: (message: string) => void } | null = null; +async function waitForWsClient(): Promise { await vi.waitFor( () => { - client = wsClient; - expect(client).toBeTruthy(); + expect( + wsRequests.some( + (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents, + ), + ).toBe(true); }, { timeout: 8_000, interval: 16 }, ); - if (!client) { - throw new Error("WebSocket client not connected"); - } - return client; } async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise { @@ -508,7 +492,7 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } -function resolveWsRpc(body: NormalizedWsRequestBody): unknown { +function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { const customResult = customWsRpcResolver?.(body); if (customResult !== undefined) { return customResult; @@ -574,85 +558,13 @@ function resolveWsRpc(body: NormalizedWsRequestBody): unknown { return {}; } -function normalizeWsRequestBody(request: WsRequestEnvelope): NormalizedWsRequestBody { - const payload = - request.payload && typeof request.payload === "object" && !Array.isArray(request.payload) - ? request.payload - : {}; - return { - _tag: request.tag, - ...payload, - }; -} - -function sendStreamChunk(method: string, value: unknown) { - if (!wsClient) { - throw new Error("WebSocket client not connected"); - } - const requestId = streamRequestIds.get(method); - if (!requestId) { - throw new Error(`Missing stream subscription for ${method}`); - } - wsClient.send( - JSON.stringify({ - _tag: "Chunk", - requestId, - values: [value], - }), - ); -} - const worker = setupWorker( wsLink.addEventListener("connection", ({ client }) => { - wsClient = client; - streamRequestIds.clear(); + void rpcHarness.connect(client); client.addEventListener("message", (event) => { const rawData = event.data; if (typeof rawData !== "string") return; - let request: WsRequestEnvelope; - try { - request = JSON.parse(rawData) as WsRequestEnvelope; - } catch { - return; - } - if (request._tag !== "Request" || typeof request.tag !== "string") return; - if ( - request.tag === WS_METHODS.subscribeServerLifecycle || - request.tag === WS_METHODS.subscribeServerConfig || - request.tag === WS_METHODS.subscribeGitActionProgress || - request.tag === WS_METHODS.subscribeOrchestrationDomainEvents || - request.tag === WS_METHODS.subscribeTerminalEvents - ) { - streamRequestIds.set(request.tag, request.id); - if (request.tag === WS_METHODS.subscribeServerLifecycle) { - sendStreamChunk(request.tag, { - version: 1, - sequence: 1, - type: "welcome", - payload: fixture.welcome, - } satisfies ServerLifecycleStreamEvent); - } - if (request.tag === WS_METHODS.subscribeServerConfig) { - sendStreamChunk(request.tag, { - version: 1, - type: "snapshot", - config: fixture.serverConfig, - } satisfies ServerConfigStreamEvent); - } - return; - } - const body = normalizeWsRequestBody(request); - wsRequests.push(body); - client.send( - JSON.stringify({ - _tag: "Exit", - requestId: request.id, - exit: { - _tag: "Success", - value: resolveWsRpc(body), - }, - }), - ); + void rpcHarness.onMessage(rawData); }); }), http.get("*/attachments/:attachmentId", () => @@ -763,7 +675,9 @@ async function waitForInteractionModeButton( async function waitForServerConfigToApply(): Promise { await vi.waitFor( () => { - expect(streamRequestIds.has(WS_METHODS.subscribeServerConfig)).toBe(true); + expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe( + true, + ); }, { timeout: 8_000, interval: 16 }, ); @@ -901,7 +815,7 @@ async function mountChatView(options: { viewport: ViewportSpec; snapshot: OrchestrationReadModel; configureFixture?: (fixture: TestFixture) => void; - resolveRpc?: (body: NormalizedWsRequestBody) => unknown | undefined; + resolveRpc?: (body: NormalizedWsRpcRequestBody) => unknown | undefined; }): Promise { fixture = buildFixture(options.snapshot); options.configureFixture?.(fixture); @@ -983,10 +897,36 @@ describe("ChatView timeline estimator parity (full app)", () => { }); afterAll(async () => { + await rpcHarness.disconnect(); await worker.stop(); }); beforeEach(async () => { + await rpcHarness.reset({ + resolveUnary: resolveWsRpc, + getInitialStreamValues: (request) => { + if (request._tag === WS_METHODS.subscribeServerLifecycle) { + return [ + { + version: 1, + sequence: 1, + type: "welcome", + payload: fixture.welcome, + }, + ]; + } + if (request._tag === WS_METHODS.subscribeServerConfig) { + return [ + { + version: 1, + type: "snapshot", + config: fixture.serverConfig, + }, + ]; + } + return []; + }, + }); __resetNativeApiForTests(); await setViewport(DEFAULT_VIEWPORT); localStorage.clear(); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index b6792e3c7d..88ab8b45c2 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -6,8 +6,6 @@ import { type OrchestrationReadModel, type ProjectId, type ServerConfig, - type ServerConfigStreamEvent, - type ServerLifecycleStreamEvent, type ThreadId, type WsWelcomePayload, WS_METHODS, @@ -22,6 +20,7 @@ import { useComposerDraftStore } from "../composerDraftStore"; import { __resetNativeApiForTests } from "../nativeApi"; import { getRouter } from "../router"; import { useStore } from "../store"; +import { BrowserWsRpcHarness } from "../test/wsRpcHarness"; const THREAD_ID = "thread-kb-toast-test" as ThreadId; const PROJECT_ID = "project-1" as ProjectId; @@ -33,16 +32,8 @@ interface TestFixture { welcome: WsWelcomePayload; } -interface WsRpcRequestEnvelope { - _tag: "Request"; - id: string; - tag: string; - payload: unknown; -} - let fixture: TestFixture; -let wsClient: { send: (data: string) => void } | null = null; -const streamRequestIds = new Map(); +const rpcHarness = new BrowserWsRpcHarness(); const wsLink = ws.link(/ws(s)?:\/\/.*/); @@ -186,70 +177,13 @@ function resolveWsRpc(tag: string): unknown { return {}; } -function sendStreamChunk(method: string, value: unknown) { - if (!wsClient) throw new Error("WebSocket client not connected"); - const requestId = streamRequestIds.get(method); - if (!requestId) { - throw new Error(`Missing stream subscription for ${method}`); - } - wsClient.send( - JSON.stringify({ - _tag: "Chunk", - requestId, - values: [value], - }), - ); -} - const worker = setupWorker( wsLink.addEventListener("connection", ({ client }) => { - wsClient = client; - streamRequestIds.clear(); + void rpcHarness.connect(client); client.addEventListener("message", (event) => { const rawData = event.data; if (typeof rawData !== "string") return; - let request: WsRpcRequestEnvelope; - try { - request = JSON.parse(rawData) as WsRpcRequestEnvelope; - } catch { - return; - } - if (request._tag !== "Request" || typeof request.tag !== "string") return; - if ( - request.tag === WS_METHODS.subscribeServerLifecycle || - request.tag === WS_METHODS.subscribeServerConfig || - request.tag === WS_METHODS.subscribeGitActionProgress || - request.tag === WS_METHODS.subscribeOrchestrationDomainEvents || - request.tag === WS_METHODS.subscribeTerminalEvents - ) { - streamRequestIds.set(request.tag, request.id); - if (request.tag === WS_METHODS.subscribeServerLifecycle) { - sendStreamChunk(request.tag, { - version: 1, - sequence: 1, - type: "welcome", - payload: fixture.welcome, - } satisfies ServerLifecycleStreamEvent); - } - if (request.tag === WS_METHODS.subscribeServerConfig) { - sendStreamChunk(request.tag, { - version: 1, - type: "snapshot", - config: fixture.serverConfig, - } satisfies ServerConfigStreamEvent); - } - return; - } - client.send( - JSON.stringify({ - _tag: "Exit", - requestId: request.id, - exit: { - _tag: "Success", - value: resolveWsRpc(request.tag), - }, - }), - ); + void rpcHarness.onMessage(rawData); }); }), http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), @@ -257,11 +191,11 @@ const worker = setupWorker( ); function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) { - sendStreamChunk(WS_METHODS.subscribeServerConfig, { + rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { version: 1, type: "keybindingsUpdated", payload: { issues }, - } satisfies ServerConfigStreamEvent); + }); } function queryToastTitles(): string[] { @@ -345,10 +279,36 @@ describe("Keybindings update toast", () => { }); afterAll(async () => { + await rpcHarness.disconnect(); await worker.stop(); }); - beforeEach(() => { + beforeEach(async () => { + await rpcHarness.reset({ + resolveUnary: (request) => resolveWsRpc(request._tag), + getInitialStreamValues: (request) => { + if (request._tag === WS_METHODS.subscribeServerLifecycle) { + return [ + { + version: 1, + sequence: 1, + type: "welcome", + payload: fixture.welcome, + }, + ]; + } + if (request._tag === WS_METHODS.subscribeServerConfig) { + return [ + { + version: 1, + type: "snapshot", + config: fixture.serverConfig, + }, + ]; + } + return []; + }, + }); __resetNativeApiForTests(); localStorage.clear(); document.body.innerHTML = ""; diff --git a/apps/web/src/test/wsRpcHarness.ts b/apps/web/src/test/wsRpcHarness.ts new file mode 100644 index 0000000000..cd066745a6 --- /dev/null +++ b/apps/web/src/test/wsRpcHarness.ts @@ -0,0 +1,169 @@ +import { Effect, Exit, PubSub, Scope, Stream } from "effect"; +import { WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; +import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; + +type RpcServerInstance = RpcServer.RpcServer; + +type BrowserWsClient = { + send: (data: string) => void; +}; + +export type NormalizedWsRpcRequestBody = { + _tag: string; + [key: string]: unknown; +}; + +type UnaryResolverResult = unknown | Promise; + +interface BrowserWsRpcHarnessOptions { + readonly resolveUnary?: (request: NormalizedWsRpcRequestBody) => UnaryResolverResult; + readonly getInitialStreamValues?: ( + request: NormalizedWsRpcRequestBody, + ) => ReadonlyArray | undefined; +} + +const STREAM_METHODS = new Set([ + WS_METHODS.subscribeOrchestrationDomainEvents, + WS_METHODS.subscribeTerminalEvents, + WS_METHODS.subscribeServerConfig, + WS_METHODS.subscribeServerLifecycle, + WS_METHODS.subscribeGitActionProgress, +]); + +const ALL_RPC_METHODS = Array.from(WsRpcGroup.requests.keys()); + +function normalizeRequest(tag: string, payload: unknown): NormalizedWsRpcRequestBody { + if (payload && typeof payload === "object" && !Array.isArray(payload)) { + return { + _tag: tag, + ...(payload as Record), + }; + } + return { _tag: tag, payload }; +} + +function asEffect(result: UnaryResolverResult): Effect.Effect { + if (result instanceof Promise) { + return Effect.promise(() => result); + } + return Effect.succeed(result); +} + +export class BrowserWsRpcHarness { + readonly requests: Array = []; + + private readonly parser = RpcSerialization.json.makeUnsafe(); + private client: BrowserWsClient | null = null; + private scope: Scope.Closeable | null = null; + private serverReady: Promise | null = null; + private resolveUnary: NonNullable = () => ({}); + private getInitialStreamValues: NonNullable< + BrowserWsRpcHarnessOptions["getInitialStreamValues"] + > = () => []; + private streamPubSubs = new Map>(); + + async reset(options?: BrowserWsRpcHarnessOptions): Promise { + await this.disconnect(); + this.requests.length = 0; + this.resolveUnary = options?.resolveUnary ?? (() => ({})); + this.getInitialStreamValues = options?.getInitialStreamValues ?? (() => []); + this.initializeStreamPubSubs(); + } + + connect(client: BrowserWsClient): void { + if (this.scope) { + void Effect.runPromise(Scope.close(this.scope, Exit.void)).catch(() => undefined); + } + if (this.streamPubSubs.size === 0) { + this.initializeStreamPubSubs(); + } + this.client = client; + this.scope = Effect.runSync(Scope.make()); + this.serverReady = Effect.runPromise( + Scope.provide(this.scope)( + RpcServer.makeNoSerialization(WsRpcGroup, this.makeServerOptions()), + ).pipe(Effect.provide(this.makeLayer())), + ) as Promise; + } + + async disconnect(): Promise { + if (this.scope) { + await Effect.runPromise(Scope.close(this.scope, Exit.void)).catch(() => undefined); + this.scope = null; + } + for (const pubsub of this.streamPubSubs.values()) { + Effect.runSync(PubSub.shutdown(pubsub)); + } + this.streamPubSubs.clear(); + this.serverReady = null; + this.client = null; + } + + private initializeStreamPubSubs(): void { + this.streamPubSubs = new Map( + Array.from(STREAM_METHODS, (method) => [method, Effect.runSync(PubSub.unbounded())]), + ); + } + + async onMessage(rawData: string): Promise { + const server = await this.serverReady; + if (!server) { + throw new Error("RPC test server is not connected"); + } + const messages = this.parser.decode(rawData); + for (const message of messages) { + await Effect.runPromise(server.write(0, message as never)); + } + } + + emitStreamValue(method: string, value: unknown): void { + const pubsub = this.streamPubSubs.get(method); + if (!pubsub) { + throw new Error(`No stream registered for ${method}`); + } + Effect.runSync(PubSub.publish(pubsub, value)); + } + + private makeLayer() { + const handlers: Record unknown> = {}; + for (const method of ALL_RPC_METHODS) { + handlers[method] = STREAM_METHODS.has(method) + ? (payload) => this.handleStream(method, payload) + : (payload) => this.handleUnary(method, payload); + } + return WsRpcGroup.toLayer(handlers as never); + } + + private makeServerOptions() { + return { + onFromServer: (response: unknown) => + Effect.sync(() => { + if (!this.client) { + return; + } + const encoded = this.parser.encode(response); + if (typeof encoded === "string") { + this.client.send(encoded); + } + }), + }; + } + + private handleUnary(method: string, payload: unknown) { + const request = normalizeRequest(method, payload); + this.requests.push(request); + return asEffect(this.resolveUnary(request)); + } + + private handleStream(method: string, payload: unknown) { + const request = normalizeRequest(method, payload); + this.requests.push(request); + const pubsub = this.streamPubSubs.get(method); + if (!pubsub) { + throw new Error(`No stream registered for ${method}`); + } + return Stream.fromIterable(this.getInitialStreamValues(request) ?? []).pipe( + Stream.concat(Stream.fromPubSub(pubsub)), + ); + } +} From 1cf79b534549c1d6420fc502012ff7917406bc58 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 15:08:30 -0700 Subject: [PATCH 31/47] bump --- bun.lock | 36 +++++++++++++----------------------- package.json | 12 ++++++------ 2 files changed, 19 insertions(+), 29 deletions(-) diff --git a/bun.lock b/bun.lock index f03298134f..af54ff7ad0 100644 --- a/bun.lock +++ b/bun.lock @@ -177,14 +177,14 @@ "vite": "^8.0.0", }, "catalog": { - "@effect/language-service": "0.75.1", - "@effect/platform-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-bun@2ae33d0", - "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@2ae33d0", - "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@2ae33d0", - "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@2ae33d0", + "@effect/language-service": "0.84.2", + "@effect/platform-bun": "4.0.0-beta.43", + "@effect/platform-node": "4.0.0-beta.43", + "@effect/sql-sqlite-bun": "4.0.0-beta.43", + "@effect/vitest": "4.0.0-beta.43", "@types/bun": "^1.3.9", "@types/node": "^24.10.13", - "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@2ae33d0", + "effect": "4.0.0-beta.43", "tsdown": "^0.20.3", "typescript": "^5.7.3", "vitest": "^4.0.0", @@ -272,17 +272,17 @@ "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], - "@effect/language-service": ["@effect/language-service@0.75.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-g9xD2tAQgRFpYC2YgpZq02VeSL5fBbFJ0B/g1o+14NuNmwtaYJc7SjiLWAA9eyhJHosNrn6h1Ye+Kx6j5mN0AA=="], + "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="], - "@effect/platform-bun": ["@effect/platform-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-bun@2ae33d0", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@2ae33d050914915f7cb9c25ab0a020901e08d596" }, "peerDependencies": { "effect": "^4.0.0-beta.42" } }], + "@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.43", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-nMZ9JsD6CzJNQ+5pDUFbPw7PSZdQdTQ092MbYrocVtvlf6qEFU/hji3ITvRIOX7eabyQ8AUyp55qFPQUeq+GIA=="], - "@effect/platform-node": ["@effect/platform-node@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@2ae33d0", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@2ae33d050914915f7cb9c25ab0a020901e08d596", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42", "ioredis": "^5.7.0" } }], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.43", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43", "ioredis": "^5.7.0" } }, "sha512-Uq6E1rjaIpjHauzjwoB2HzAg3battYt2Boy8XO50GoHiWCXKE6WapYZ0/AnaBx5v5qg2sOfqpuiLsUf9ZgxOkA=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@2ae33d050914915f7cb9c25ab0a020901e08d596", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42" } }], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.43", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-A9q0GEb61pYcQ06Dr6gXj1nKlDI3KHsar1sk3qb1ZY+kVSR64tBAylI8zGon23KY+NPtTUj/sEIToB7jc3Qt5w=="], - "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@2ae33d0", { "peerDependencies": { "effect": "^4.0.0-beta.42" } }], + "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.43", "", { "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-Ryb7x0rBjW5NHegbzvz9y2Yv4KpwZ1UiPk7/Rv+Mkux74Qkjdmotmrvy9P+M5A9TZyL2Av+GpaLTPJKsL0PG+Q=="], - "@effect/vitest": ["@effect/vitest@https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@2ae33d0", { "peerDependencies": { "effect": "^4.0.0-beta.42", "vitest": "^3.0.0 || ^4.0.0" } }], + "@effect/vitest": ["@effect/vitest@4.0.0-beta.43", "", { "peerDependencies": { "effect": "^4.0.0-beta.43", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-XN2LAwiUWPqbV2jrsYYRjrVydQ8MIgwr83MVImtUaOQco4vk43+8OHlXQMRN/u2HnGK29KT+O2yTMMBdk2Q6Sw=="], "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], @@ -1064,7 +1064,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@https://pkg.pr.new/Effect-TS/effect-smol/effect@2ae33d0", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }], + "effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="], "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], @@ -2042,16 +2042,6 @@ "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@effect/platform-bun/effect": ["effect@4.0.0-beta.42", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-c1UrRP+tLzyHb4Fepl8XBDJlLQLkrcMXrRBba441GQRxMbeQ/aIOSFcBwSda1iMJ5l9F0lYc3Bhe33/whrmavQ=="], - - "@effect/platform-node/effect": ["effect@4.0.0-beta.42", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-c1UrRP+tLzyHb4Fepl8XBDJlLQLkrcMXrRBba441GQRxMbeQ/aIOSFcBwSda1iMJ5l9F0lYc3Bhe33/whrmavQ=="], - - "@effect/platform-node-shared/effect": ["effect@4.0.0-beta.42", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-c1UrRP+tLzyHb4Fepl8XBDJlLQLkrcMXrRBba441GQRxMbeQ/aIOSFcBwSda1iMJ5l9F0lYc3Bhe33/whrmavQ=="], - - "@effect/sql-sqlite-bun/effect": ["effect@4.0.0-beta.42", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-c1UrRP+tLzyHb4Fepl8XBDJlLQLkrcMXrRBba441GQRxMbeQ/aIOSFcBwSda1iMJ5l9F0lYc3Bhe33/whrmavQ=="], - - "@effect/vitest/effect": ["effect@4.0.0-beta.42", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-c1UrRP+tLzyHb4Fepl8XBDJlLQLkrcMXrRBba441GQRxMbeQ/aIOSFcBwSda1iMJ5l9F0lYc3Bhe33/whrmavQ=="], - "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], diff --git a/package.json b/package.json index 860ee011b0..b5576c59ea 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,12 @@ "scripts" ], "catalog": { - "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@2ae33d0", - "@effect/platform-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-bun@2ae33d0", - "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@2ae33d0", - "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@2ae33d0", - "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@2ae33d0", - "@effect/language-service": "0.75.1", + "effect": "4.0.0-beta.43", + "@effect/platform-bun": "4.0.0-beta.43", + "@effect/platform-node": "4.0.0-beta.43", + "@effect/sql-sqlite-bun": "4.0.0-beta.43", + "@effect/vitest": "4.0.0-beta.43", + "@effect/language-service": "0.84.2", "@types/bun": "^1.3.9", "@types/node": "^24.10.13", "tsdown": "^0.20.3", From b6771ca1cb65957759d75dc1772a436fdc2d7e73 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 15:13:51 -0700 Subject: [PATCH 32/47] Move git error types to contracts - Import shared git error types directly from `@t3tools/contracts` - Remove the local git error re-export module --- apps/server/src/checkpointing/Errors.ts | 2 +- .../src/checkpointing/Layers/CheckpointStore.test.ts | 2 +- apps/server/src/checkpointing/Layers/CheckpointStore.ts | 2 +- apps/server/src/git/Errors.ts | 7 ------- apps/server/src/git/Layers/ClaudeTextGeneration.ts | 2 +- apps/server/src/git/Layers/CodexTextGeneration.test.ts | 2 +- apps/server/src/git/Layers/CodexTextGeneration.ts | 2 +- apps/server/src/git/Layers/GitCore.test.ts | 2 +- apps/server/src/git/Layers/GitCore.ts | 2 +- apps/server/src/git/Layers/GitHubCli.ts | 2 +- apps/server/src/git/Layers/GitManager.test.ts | 2 +- apps/server/src/git/Layers/GitManager.ts | 4 ++-- apps/server/src/git/Prompts.test.ts | 2 +- apps/server/src/git/Services/GitCore.ts | 2 +- apps/server/src/git/Services/GitHubCli.ts | 2 +- apps/server/src/git/Services/GitManager.ts | 2 +- apps/server/src/git/Services/TextGeneration.ts | 2 +- apps/server/src/git/Utils.ts | 2 +- .../orchestration/Layers/ProviderCommandReactor.test.ts | 2 +- 19 files changed, 19 insertions(+), 26 deletions(-) delete mode 100644 apps/server/src/git/Errors.ts diff --git a/apps/server/src/checkpointing/Errors.ts b/apps/server/src/checkpointing/Errors.ts index 782d0918e6..cb873559c1 100644 --- a/apps/server/src/checkpointing/Errors.ts +++ b/apps/server/src/checkpointing/Errors.ts @@ -1,6 +1,6 @@ import { Schema } from "effect"; import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; -import { GitCommandError } from "../git/Errors.ts"; +import { GitCommandError } from "@t3tools/contracts"; /** * CheckpointUnavailableError - Expected checkpoint does not exist. diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts index c430dbbde0..7df1c71c23 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts @@ -10,7 +10,7 @@ import { CheckpointStoreLive } from "./CheckpointStore.ts"; import { CheckpointStore } from "../Services/CheckpointStore.ts"; import { GitCoreLive } from "../../git/Layers/GitCore.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; -import { GitCommandError } from "../../git/Errors.ts"; +import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; import { ThreadId } from "@t3tools/contracts"; diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts index b20204780c..0a1d7abb9d 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -14,7 +14,7 @@ import { randomUUID } from "node:crypto"; import { Effect, Layer, FileSystem, Path } from "effect"; import { CheckpointInvariantError } from "../Errors.ts"; -import { GitCommandError } from "../../git/Errors.ts"; +import { GitCommandError } from "@t3tools/contracts"; import { GitCore } from "../../git/Services/GitCore.ts"; import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; import { CheckpointRef } from "@t3tools/contracts"; diff --git a/apps/server/src/git/Errors.ts b/apps/server/src/git/Errors.ts deleted file mode 100644 index 4e1f763a9d..0000000000 --- a/apps/server/src/git/Errors.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - GitCommandError, - GitHubCliError, - GitManagerError, - TextGenerationError, - type GitManagerServiceError, -} from "@t3tools/contracts"; diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 19ed40e65b..7d620be3a6 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -14,7 +14,7 @@ import { ClaudeModelSelection } from "@t3tools/contracts"; import { resolveApiModelId } from "@t3tools/shared/model"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { TextGenerationError } from "../Errors.ts"; +import { TextGenerationError } from "@t3tools/contracts"; import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; import { buildBranchNamePrompt, diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 21a97eec9c..a07505f025 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -5,7 +5,7 @@ import { expect } from "vitest"; import { ServerConfig } from "../../config.ts"; import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; -import { TextGenerationError } from "../Errors.ts"; +import { TextGenerationError } from "@t3tools/contracts"; import { TextGeneration } from "../Services/TextGeneration.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 8f0556ee34..9bd838773b 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -8,7 +8,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { TextGenerationError } from "../Errors.ts"; +import { TextGenerationError } from "@t3tools/contracts"; import { type BranchNameGenerationInput, type ThreadTitleGenerationResult, diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 547a69e7e1..6485f8fc4c 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -8,7 +8,7 @@ import { describe, expect, vi } from "vitest"; import { GitCoreLive, makeGitCore } from "./GitCore.ts"; import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; -import { GitCommandError } from "../Errors.ts"; +import { GitCommandError } from "@t3tools/contracts"; import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; import { ServerConfig } from "../../config.ts"; diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 64ed409508..6c1b88dadb 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -18,7 +18,7 @@ import { } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { GitCommandError } from "../Errors.ts"; +import { GitCommandError } from "@t3tools/contracts"; import { GitCore, type ExecuteGitProgress, diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index 80ce43659e..76d7d30a47 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -2,7 +2,7 @@ import { Effect, Layer, Schema } from "effect"; import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; import { runProcess } from "../../processRunner"; -import { GitHubCliError } from "../Errors.ts"; +import { GitHubCliError } from "@t3tools/contracts"; import { GitHubCli, type GitHubRepositoryCloneUrls, diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index db82ea4c72..e05fc30875 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -8,7 +8,7 @@ import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; import { expect } from "vitest"; import type { GitActionProgressEvent, ModelSelection } from "@t3tools/contracts"; -import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts"; +import { GitCommandError, GitHubCliError, TextGenerationError } from "@t3tools/contracts"; import { type GitManagerShape } from "../Services/GitManager.ts"; import { type GitHubCliShape, diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index dc082674b7..f8445cf09a 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -14,7 +14,7 @@ import { sanitizeFeatureBranchName, } from "@t3tools/shared/git"; -import { GitManagerError } from "../Errors.ts"; +import { GitManagerError } from "@t3tools/contracts"; import { GitManager, type GitActionProgressReporter, @@ -25,7 +25,7 @@ import { GitCore } from "../Services/GitCore.ts"; import { GitHubCli } from "../Services/GitHubCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import type { GitManagerServiceError } from "../Errors.ts"; +import type { GitManagerServiceError } from "@t3tools/contracts"; const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; diff --git a/apps/server/src/git/Prompts.test.ts b/apps/server/src/git/Prompts.test.ts index 7951e78b39..d8d079c0cf 100644 --- a/apps/server/src/git/Prompts.test.ts +++ b/apps/server/src/git/Prompts.test.ts @@ -7,7 +7,7 @@ import { buildThreadTitlePrompt, } from "./Prompts.ts"; import { normalizeCliError, sanitizeThreadTitle } from "./Utils.ts"; -import { TextGenerationError } from "./Errors.ts"; +import { TextGenerationError } from "@t3tools/contracts"; describe("buildCommitMessagePrompt", () => { it("includes staged patch and summary in the prompt", () => { diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index f1a4e065cd..f061ba7aca 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -22,7 +22,7 @@ import type { GitStatusResult, } from "@t3tools/contracts"; -import type { GitCommandError } from "../Errors.ts"; +import type { GitCommandError } from "@t3tools/contracts"; export interface ExecuteGitInput { readonly operation: string; diff --git a/apps/server/src/git/Services/GitHubCli.ts b/apps/server/src/git/Services/GitHubCli.ts index f10339af47..38afdd5f92 100644 --- a/apps/server/src/git/Services/GitHubCli.ts +++ b/apps/server/src/git/Services/GitHubCli.ts @@ -9,7 +9,7 @@ import { ServiceMap } from "effect"; import type { Effect } from "effect"; import type { ProcessRunResult } from "../../processRunner"; -import type { GitHubCliError } from "../Errors.ts"; +import type { GitHubCliError } from "@t3tools/contracts"; export interface GitHubPullRequestSummary { readonly number: number; diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts index 2e83b78c3b..a99e4d3bc4 100644 --- a/apps/server/src/git/Services/GitManager.ts +++ b/apps/server/src/git/Services/GitManager.ts @@ -19,7 +19,7 @@ import { } from "@t3tools/contracts"; import { ServiceMap } from "effect"; import type { Effect } from "effect"; -import type { GitManagerServiceError } from "../Errors.ts"; +import type { GitManagerServiceError } from "@t3tools/contracts"; export interface GitActionProgressReporter { readonly publish: (event: GitActionProgressEvent) => Effect.Effect; diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index 0df2fff62c..f4354c7a99 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -10,7 +10,7 @@ import { ServiceMap } from "effect"; import type { Effect } from "effect"; import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; -import type { TextGenerationError } from "../Errors.ts"; +import type { TextGenerationError } from "@t3tools/contracts"; /** Providers that support git text generation (commit messages, PR content, branch names). */ export type TextGenerationProvider = "codex" | "claudeAgent"; diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts index 8f0321fd52..4a7931c74b 100644 --- a/apps/server/src/git/Utils.ts +++ b/apps/server/src/git/Utils.ts @@ -5,7 +5,7 @@ */ import { Schema } from "effect"; -import { TextGenerationError } from "./Errors.ts"; +import { TextGenerationError } from "@t3tools/contracts"; import { existsSync } from "node:fs"; import { join } from "node:path"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ed7037695f..506d6d2864 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -17,7 +17,7 @@ import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effe import { afterEach, describe, expect, it, vi } from "vitest"; import { deriveServerPaths, ServerConfig } from "../../config.ts"; -import { TextGenerationError } from "../../git/Errors.ts"; +import { TextGenerationError } from "@t3tools/contracts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; From 849a39c6aac6caac33b819c76a6995f4607be634 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 15:23:59 -0700 Subject: [PATCH 33/47] rm reexport shim --- apps/server/src/keybindings.test.ts | 2 +- apps/server/src/keybindings.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 9cf0394142..8eda0ca85d 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -8,13 +8,13 @@ import { ServerConfig } from "./config"; import { DEFAULT_KEYBINDINGS, Keybindings, - KeybindingsConfigError, KeybindingsLive, ResolvedKeybindingFromConfig, compileResolvedKeybindingRule, compileResolvedKeybindingsConfig, parseKeybindingShortcut, } from "./keybindings"; +import { KeybindingsConfigError } from "@t3tools/contracts"; const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); const makeKeybindingsLayer = () => { diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 8e2951859d..086d795c0c 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -47,8 +47,6 @@ import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "./config"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; -export { KeybindingsConfigError }; - type WhenToken = | { type: "identifier"; value: string } | { type: "not" } From 1f1a3dd17dcb50600b7396159f255493d74c35fb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 17:44:19 -0700 Subject: [PATCH 34/47] Add bootstrap envelope config precedence - Read bootstrap settings from a file descriptor - Let flags override env, and env override bootstrap defaults - Cover bootstrap fallback and precedence in CLI config tests --- apps/server/src/bin.ts | 7 +- apps/server/src/cli-config.test.ts | 148 ++++++++++++++++++++++++- apps/server/src/cli.ts | 166 +++++++++++++++++++++++------ 3 files changed, 283 insertions(+), 38 deletions(-) diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 1eb6dd8342..56113de4d7 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -2,17 +2,12 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { Layer as LayerShape } from "effect/Layer"; import { Command } from "effect/unstable/cli"; import { NetService } from "@t3tools/shared/Net"; import { cli } from "./cli"; import { version } from "../package.json" with { type: "json" }; -const CliRuntimeLayer: LayerShape< - Layer.Success | Layer.Success, - never, - never -> = Layer.mergeAll(NodeServices.layer, NetService.layer); +const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); Command.run(cli, { version }).pipe(Effect.provide(CliRuntimeLayer), NodeRuntime.runMain); diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts index e91770db6c..f0e8a29c92 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli-config.test.ts @@ -1,7 +1,7 @@ import os from "node:os"; -import { expect, it } from "@effect/vitest"; -import { ConfigProvider, Effect, Layer, Option, Path } from "effect"; +import { assert, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; import { NetService } from "@t3tools/shared/Net"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -9,6 +9,14 @@ import { deriveServerPaths } from "./config"; import { resolveServerConfig } from "./cli"; it.layer(NodeServices.layer)("cli config resolution", (it) => { + const openBootstrapFd = Effect.fn(function* (payload: Record) { + const fs = yield* FileSystem.FileSystem; + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); + yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`); + const { fd } = yield* fs.open(filePath, { flag: "r" }); + return fd; + }); + it.effect("falls back to effect/config values when flags are omitted", () => Effect.gen(function* () { const { join } = yield* Path.Path; @@ -23,6 +31,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { devUrl: Option.none(), noBrowser: Option.none(), authToken: Option.none(), + bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), }, @@ -83,6 +92,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { devUrl: Option.some(new URL("http://127.0.0.1:4173")), noBrowser: Option.some(true), authToken: Option.some("flag-token"), + bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.some(true), logWebSocketEvents: Option.some(true), }, @@ -128,4 +138,138 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }); }), ); + + it.effect("uses bootstrap envelope values as fallbacks when flags and env are absent", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const baseDir = "/tmp/t3-bootstrap-home"; + const fd = yield* openBootstrapFd({ + mode: "desktop", + port: 4888, + host: "127.0.0.2", + t3Home: baseDir, + devUrl: "http://127.0.0.1:5173", + noBrowser: true, + authToken: "bootstrap-token", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: true, + }); + const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173")); + + const resolved = yield* resolveServerConfig( + { + mode: Option.none(), + port: Option.none(), + host: Option.none(), + baseDir: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + authToken: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_BOOTSTRAP_FD: String(fd), + }, + }), + ), + NetService.layer, + ), + ), + ); + + expect(resolved).toEqual({ + logLevel: "Info", + mode: "desktop", + port: 4888, + cwd: process.cwd(), + baseDir, + ...derivedPaths, + host: "127.0.0.2", + staticDir: undefined, + devUrl: new URL("http://127.0.0.1:5173"), + noBrowser: true, + authToken: "bootstrap-token", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: true, + }); + assert.equal(join(baseDir, "dev"), resolved.stateDir); + }), + ); + + it.effect("applies flag then env precedence over bootstrap envelope values", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const baseDir = join(os.tmpdir(), "t3-cli-config-env-wins"); + const fd = yield* openBootstrapFd({ + mode: "desktop", + port: 4888, + host: "127.0.0.2", + t3Home: "/tmp/t3-bootstrap-home", + devUrl: "http://127.0.0.1:5173", + noBrowser: false, + authToken: "bootstrap-token", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + }); + const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); + + const resolved = yield* resolveServerConfig( + { + mode: Option.none(), + port: Option.some(8788), + host: Option.some("127.0.0.1"), + baseDir: Option.none(), + devUrl: Option.some(new URL("http://127.0.0.1:4173")), + noBrowser: Option.none(), + authToken: Option.some("flag-token"), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + }, + Option.some("Debug"), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_MODE: "web", + T3CODE_BOOTSTRAP_FD: String(fd), + T3CODE_HOME: baseDir, + T3CODE_NO_BROWSER: "true", + T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true", + T3CODE_LOG_WS_EVENTS: "true", + }, + }), + ), + NetService.layer, + ), + ), + ); + + expect(resolved).toEqual({ + logLevel: "Debug", + mode: "web", + port: 8788, + cwd: process.cwd(), + baseDir, + ...derivedPaths, + host: "127.0.0.1", + staticDir: undefined, + devUrl: new URL("http://127.0.0.1:4173"), + noBrowser: true, + authToken: "flag-token", + autoBootstrapProjectFromCwd: true, + logWebSocketEvents: true, + }); + }), + ); }); diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 0c2b3fbb29..6930ef343f 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -10,15 +10,30 @@ import { type RuntimeMode, type ServerConfigShape, } from "./config"; +import { readBootstrapEnvelope } from "./bootstrap"; import { resolveBaseDir } from "./os-jank"; import { runServer } from "./server"; +const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); + +const BootstrapEnvelopeSchema = Schema.Struct({ + mode: Schema.optional(Schema.String), + port: Schema.optional(PortSchema), + host: Schema.optional(Schema.String), + t3Home: Schema.optional(Schema.String), + devUrl: Schema.optional(Schema.URLFromString), + noBrowser: Schema.optional(Schema.Boolean), + authToken: Schema.optional(Schema.String), + autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), + logWebSocketEvents: Schema.optional(Schema.Boolean), +}); + const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe( Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), Flag.optional, ); const portFlag = Flag.integer("port").pipe( - Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), + Flag.withSchema(PortSchema), Flag.withDescription("Port for the HTTP/WebSocket server."), Flag.optional, ); @@ -44,6 +59,11 @@ const authTokenFlag = Flag.string("auth-token").pipe( Flag.withAlias("token"), Flag.optional, ); +const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( + Flag.withSchema(Schema.Int), + Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), + Flag.optional, +); const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( Flag.withDescription( "Create a project for the current working directory on startup when missing.", @@ -62,12 +82,8 @@ const EnvServerConfig = Config.all({ logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), mode: Config.string("T3CODE_MODE").pipe( Config.option, - Config.map( - Option.match({ - onNone: () => "web", - onSome: (value) => (value === "desktop" ? "desktop" : "web"), - }), - ), + Config.map(Option.map((value) => (value === "desktop" ? "desktop" : "web"))), + Config.map(Option.getOrUndefined), ), port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), @@ -81,6 +97,10 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -99,6 +119,7 @@ interface CliServerFlags { readonly devUrl: Option.Option; readonly noBrowser: Option.Option; readonly authToken: Option.Option; + readonly bootstrapFd: Option.Option; readonly autoBootstrapProjectFromCwd: Option.Option; readonly logWebSocketEvents: Option.Option; } @@ -106,6 +127,14 @@ interface CliServerFlags { const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => Option.getOrElse(Option.filter(flag, Boolean), () => envValue); +const resolveOptionPrecedence = ( + ...values: ReadonlyArray> +): Option.Option => Option.firstSomeOf(values); + +const isValidPort = (value: number): boolean => value >= 1 && value <= 65_535; +const isRuntimeMode = (value: string): value is RuntimeMode => + value === "web" || value === "desktop"; + export const resolveServerConfig = ( flags: CliServerFlags, cliLogLevel: Option.Option, @@ -113,39 +142,115 @@ export const resolveServerConfig = ( Effect.gen(function* () { const { findAvailablePort } = yield* NetService; const env = yield* EnvServerConfig; + const bootstrapFd = Option.getOrUndefined(flags.bootstrapFd) ?? env.bootstrapFd; + const bootstrapEnvelope = + bootstrapFd !== undefined + ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) + : Option.none(); + + const mode: RuntimeMode = Option.getOrElse( + resolveOptionPrecedence( + flags.mode, + Option.fromUndefinedOr(env.mode), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.filter(Option.fromUndefinedOr(bootstrap.mode), isRuntimeMode), + ), + ), + () => "web", + ); - const mode = Option.getOrElse(flags.mode, () => env.mode); - - const port = yield* Option.match(flags.port, { - onSome: (value) => Effect.succeed(value), - onNone: () => { - if (env.port) { - return Effect.succeed(env.port); - } - if (mode === "desktop") { - return Effect.succeed(DEFAULT_PORT); - } - return findAvailablePort(DEFAULT_PORT); + const port = yield* Option.match( + resolveOptionPrecedence( + flags.port, + Option.fromUndefinedOr(env.port), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.filter(Option.fromUndefinedOr(bootstrap.port), isValidPort), + ), + ), + { + onSome: (value) => Effect.succeed(value), + onNone: () => { + if (mode === "desktop") { + return Effect.succeed(DEFAULT_PORT); + } + return findAvailablePort(DEFAULT_PORT); + }, }, - }); - const devUrl = Option.getOrElse(flags.devUrl, () => env.devUrl); - const baseDir = yield* resolveBaseDir(Option.getOrUndefined(flags.baseDir) ?? env.t3Home); + ); + const devUrl = Option.getOrElse( + resolveOptionPrecedence( + flags.devUrl, + Option.fromUndefinedOr(env.devUrl), + Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.devUrl)), + ), + () => undefined, + ); + const baseDir = yield* resolveBaseDir( + Option.getOrUndefined( + resolveOptionPrecedence( + flags.baseDir, + Option.fromUndefinedOr(env.t3Home), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.t3Home), + ), + ), + ), + ); const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); - const noBrowser = resolveBooleanFlag(flags.noBrowser, env.noBrowser ?? mode === "desktop"); - const authToken = Option.getOrUndefined(flags.authToken) ?? env.authToken; + const noBrowser = resolveBooleanFlag( + flags.noBrowser, + Option.getOrElse( + resolveOptionPrecedence( + Option.fromUndefinedOr(env.noBrowser), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.noBrowser), + ), + ), + () => mode === "desktop", + ), + ); + const authToken = Option.getOrUndefined( + resolveOptionPrecedence( + flags.authToken, + Option.fromUndefinedOr(env.authToken), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.authToken), + ), + ), + ); const autoBootstrapProjectFromCwd = resolveBooleanFlag( flags.autoBootstrapProjectFromCwd, - env.autoBootstrapProjectFromCwd ?? mode === "web", + Option.getOrElse( + resolveOptionPrecedence( + Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd), + ), + ), + () => mode === "web", + ), ); const logWebSocketEvents = resolveBooleanFlag( flags.logWebSocketEvents, - env.logWebSocketEvents ?? Boolean(devUrl), + Option.getOrElse( + resolveOptionPrecedence( + Option.fromUndefinedOr(env.logWebSocketEvents), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.logWebSocketEvents), + ), + ), + () => Boolean(devUrl), + ), ); const staticDir = devUrl ? undefined : yield* resolveStaticDir(); - const host = - Option.getOrUndefined(flags.host) ?? - env.host ?? - (mode === "desktop" ? "127.0.0.1" : undefined); + const host = Option.getOrElse( + resolveOptionPrecedence( + flags.host, + Option.fromUndefinedOr(env.host), + Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)), + ), + () => (mode === "desktop" ? "127.0.0.1" : undefined), + ); const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); const config: ServerConfigShape = { @@ -175,6 +280,7 @@ const commandFlags = { devUrl: devUrlFlag, noBrowser: noBrowserFlag, authToken: authTokenFlag, + bootstrapFd: bootstrapFdFlag, autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, logWebSocketEvents: logWebSocketEventsFlag, } as const; From f9b0f1416e0bfb00ebac1ac16dd40a82b1db0e6f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 17:53:22 -0700 Subject: [PATCH 35/47] kewl --- apps/server/src/serverSettings.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index b9bf7e91ef..0de374f6f0 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -32,6 +32,7 @@ import { PubSub, Ref, Schema, + SchemaIssue, Scope, ServiceMap, Stream, @@ -313,14 +314,16 @@ const makeServerSettings = Effect.gen(function* () { writeSemaphore.withPermits(1)( Effect.gen(function* () { const current = yield* getSettingsFromCache; - const decoded = Schema.decodeUnknownExit(ServerSettings)(deepMerge(current, patch)); - if (decoded._tag === "Failure") { - return yield* new ServerSettingsError({ - settingsPath: "", - detail: "failed to normalize server settings", - }); - } - const next = decoded.value; + const next = yield* Schema.decodeEffect(ServerSettings)(deepMerge(current, patch)).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath: "", + detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, + cause, + }), + ), + ); yield* writeSettingsAtomically(next); yield* Cache.set(settingsCache, cacheKey, next); yield* emitChange(next); From 7c692d4436429cacd7c251be6460e4f9f40ed41a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 18:08:20 -0700 Subject: [PATCH 36/47] Refactor ServerSettings imports and remove unused error handling - Removed the re-export of ServerSettingsError from serverSettings. - Updated imports in various files to source ServerSettingsError from @t3tools/contracts. - Simplified error handling in WebSocket methods by directly using serverSettings methods without mapping errors. --- .../src/provider/Layers/ClaudeProvider.ts | 3 ++- .../src/provider/Layers/CodexProvider.ts | 3 ++- .../src/provider/makeManagedServerProvider.ts | 2 +- apps/server/src/serverSettings.ts | 2 -- apps/server/src/ws.ts | 23 ++++--------------- 5 files changed, 9 insertions(+), 24 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index f2243ef1b0..158a9ef25a 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -24,7 +24,8 @@ import { } from "../providerSnapshot"; import { makeManagedServerProvider } from "../makeManagedServerProvider"; import { ClaudeProvider } from "../Services/ClaudeProvider"; -import { ServerSettingsError, ServerSettingsService } from "../../serverSettings"; +import { ServerSettingsService } from "../../serverSettings"; +import { ServerSettingsError } from "@t3tools/contracts"; const PROVIDER = "claudeAgent" as const; const BUILT_IN_MODELS: ReadonlyArray = [ diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 1fe98ea798..499d4c6ffd 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -46,7 +46,8 @@ import { } from "../codexAccount"; import { probeCodexAccount } from "../codexAppServer"; import { CodexProvider } from "../Services/CodexProvider"; -import { ServerSettingsError, ServerSettingsService } from "../../serverSettings"; +import { ServerSettingsService } from "../../serverSettings"; +import { ServerSettingsError } from "@t3tools/contracts"; const PROVIDER = "codex" as const; const OPENAI_AUTH_PROVIDERS = new Set(["openai"]); diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index e519e82af5..4c1d0878fc 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -3,7 +3,7 @@ import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect"; import * as Semaphore from "effect/Semaphore"; import type { ServerProviderShape } from "./Services/ServerProvider"; -import { ServerSettingsError } from "../serverSettings"; +import { ServerSettingsError } from "@t3tools/contracts"; export function makeManagedServerProvider(input: { readonly getSettings: Effect.Effect; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 0de374f6f0..7029017950 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -88,8 +88,6 @@ export class ServerSettingsService extends ServiceMap.Service< ); } -export { ServerSettingsError }; - const ServerSettingsJson = fromLenientJson(ServerSettings); const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent"]; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ad801f0c91..1ff1089728 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -21,7 +21,6 @@ import { ProjectSearchEntriesError, ProjectWriteFileError, OrchestrationReplayEventsError, - ServerSettingsError as ServerSettingsRpcError, type TerminalEvent, WS_METHODS, WsRpcGroup, @@ -42,10 +41,7 @@ import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnap import { ProviderRegistry } from "./provider/Services/ProviderRegistry"; import { ServerLifecycleEvents } from "./serverLifecycleEvents"; import { ServerRuntimeStartup } from "./serverRuntimeStartup"; -import { - ServerSettingsError as ServerSettingsServiceError, - ServerSettingsService, -} from "./serverSettings"; +import { ServerSettingsService } from "./serverSettings"; import { TerminalManager } from "./terminal/Services/Manager"; import { resolveWorkspaceWritePath, searchWorkspaceEntries } from "./workspaceEntries"; @@ -71,19 +67,10 @@ const WsRpcLayer = WsRpcGroup.toLayer( PubSub.shutdown, ); - const mapServerSettingsError = (cause: ServerSettingsServiceError) => - new ServerSettingsRpcError({ - settingsPath: cause.settingsPath, - detail: cause.detail, - ...(cause.cause ? { cause: cause.cause } : {}), - }); - const loadServerConfig = Effect.gen(function* () { const keybindingsConfig = yield* keybindings.loadConfigState; const providers = yield* providerRegistry.getProviders; - const settings = yield* serverSettings.getSettings.pipe( - Effect.mapError(mapServerSettingsError), - ); + const settings = yield* serverSettings.getSettings; return { cwd: config.cwd, @@ -221,10 +208,8 @@ const WsRpcLayer = WsRpcGroup.toLayer( const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); return { keybindings: keybindingsConfig, issues: [] }; }), - [WS_METHODS.serverGetSettings]: (_input) => - serverSettings.getSettings.pipe(Effect.mapError(mapServerSettingsError)), - [WS_METHODS.serverUpdateSettings]: ({ patch }) => - serverSettings.updateSettings(patch).pipe(Effect.mapError(mapServerSettingsError)), + [WS_METHODS.serverGetSettings]: (_input) => serverSettings.getSettings, + [WS_METHODS.serverUpdateSettings]: ({ patch }) => serverSettings.updateSettings(patch), [WS_METHODS.projectsSearchEntries]: (input) => Effect.tryPromise({ try: () => searchWorkspaceEntries(input), From 0ba3f29cd1c0118a6870e09d292cfde179e3870e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 18:32:41 -0700 Subject: [PATCH 37/47] tidy up --- apps/server/src/cli.ts | 21 ++++---------- apps/server/src/config.ts | 5 ++-- .../src/provider/Layers/ClaudeAdapter.ts | 29 +++++-------------- 3 files changed, 16 insertions(+), 39 deletions(-) diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 6930ef343f..59691a4802 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -7,7 +7,7 @@ import { deriveServerPaths, resolveStaticDir, ServerConfig, - type RuntimeMode, + RuntimeMode, type ServerConfigShape, } from "./config"; import { readBootstrapEnvelope } from "./bootstrap"; @@ -17,7 +17,7 @@ import { runServer } from "./server"; const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); const BootstrapEnvelopeSchema = Schema.Struct({ - mode: Schema.optional(Schema.String), + mode: Schema.optional(RuntimeMode), port: Schema.optional(PortSchema), host: Schema.optional(Schema.String), t3Home: Schema.optional(Schema.String), @@ -28,7 +28,7 @@ const BootstrapEnvelopeSchema = Schema.Struct({ logWebSocketEvents: Schema.optional(Schema.Boolean), }); -const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe( +const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe( Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), Flag.optional, ); @@ -80,9 +80,8 @@ const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( const EnvServerConfig = Config.all({ logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), - mode: Config.string("T3CODE_MODE").pipe( + mode: Config.schema(RuntimeMode, "T3CODE_MODE").pipe( Config.option, - Config.map(Option.map((value) => (value === "desktop" ? "desktop" : "web"))), Config.map(Option.getOrUndefined), ), port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), @@ -131,10 +130,6 @@ const resolveOptionPrecedence = ( ...values: ReadonlyArray> ): Option.Option => Option.firstSomeOf(values); -const isValidPort = (value: number): boolean => value >= 1 && value <= 65_535; -const isRuntimeMode = (value: string): value is RuntimeMode => - value === "web" || value === "desktop"; - export const resolveServerConfig = ( flags: CliServerFlags, cliLogLevel: Option.Option, @@ -152,9 +147,7 @@ export const resolveServerConfig = ( resolveOptionPrecedence( flags.mode, Option.fromUndefinedOr(env.mode), - Option.flatMap(bootstrapEnvelope, (bootstrap) => - Option.filter(Option.fromUndefinedOr(bootstrap.mode), isRuntimeMode), - ), + Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.mode)), ), () => "web", ); @@ -163,9 +156,7 @@ export const resolveServerConfig = ( resolveOptionPrecedence( flags.port, Option.fromUndefinedOr(env.port), - Option.flatMap(bootstrapEnvelope, (bootstrap) => - Option.filter(Option.fromUndefinedOr(bootstrap.port), isValidPort), - ), + Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.port)), ), { onSome: (value) => Effect.succeed(value), diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index e3e96784dc..d415fed02d 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -6,11 +6,12 @@ * * @module ServerConfig */ -import { Effect, FileSystem, Layer, LogLevel, Path, ServiceMap } from "effect"; +import { Effect, FileSystem, Layer, LogLevel, Path, Schema, ServiceMap } from "effect"; export const DEFAULT_PORT = 3773; -export type RuntimeMode = "web" | "desktop"; +export const RuntimeMode = Schema.Literals(["web", "desktop"]); +export type RuntimeMode = typeof RuntimeMode.Type; /** * ServerDerivedPaths - Derived paths from the base directory. diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 1df01d4b8f..d99e2ad203 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -503,27 +503,12 @@ const SUPPORTED_CLAUDE_IMAGE_MIME_TYPES = new Set([ "image/png", "image/webp", ]); -type ClaudeImageMimeType = "image/gif" | "image/jpeg" | "image/png" | "image/webp"; const CLAUDE_SETTING_SOURCES = [ "user", "project", "local", ] as const satisfies ReadonlyArray; -type ClaudeContentBlockParam = - | { - readonly type: "text"; - readonly text: string; - } - | { - readonly type: "image"; - readonly source: { - readonly type: "base64"; - readonly media_type: ClaudeImageMimeType; - readonly data: string; - }; - }; - function buildPromptText(input: ProviderSendTurnInput): string { const rawEffort = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null; @@ -540,7 +525,7 @@ function buildPromptText(input: ProviderSendTurnInput): string { } function buildUserMessage(input: { - readonly sdkContent: Array; + readonly sdkContent: Array>; }): SDKUserMessage { return { type: "user", @@ -548,15 +533,15 @@ function buildUserMessage(input: { parent_tool_use_id: null, message: { role: "user", - content: input.sdkContent, + content: input.sdkContent as unknown as SDKUserMessage["message"]["content"], }, - }; + } as SDKUserMessage; } function buildClaudeImageContentBlock(input: { - readonly mimeType: ClaudeImageMimeType; + readonly mimeType: string; readonly bytes: Uint8Array; -}): ClaudeContentBlockParam { +}): Record { return { type: "image", source: { @@ -575,7 +560,7 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( }, ) { const text = buildPromptText(input); - const sdkContent: Array = []; + const sdkContent: Array> = []; if (text.length > 0) { sdkContent.push({ type: "text", text }); @@ -620,7 +605,7 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( sdkContent.push( buildClaudeImageContentBlock({ - mimeType: attachment.mimeType as ClaudeImageMimeType, + mimeType: attachment.mimeType, bytes, }), ); From dd44ef04995a4ba054446c6a5f9b316282e52f7d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 18:39:01 -0700 Subject: [PATCH 38/47] rm --- apps/server/src/logger.ts | 103 -------------------------------- apps/server/src/serverLogger.ts | 11 +--- 2 files changed, 2 insertions(+), 112 deletions(-) delete mode 100644 apps/server/src/logger.ts diff --git a/apps/server/src/logger.ts b/apps/server/src/logger.ts deleted file mode 100644 index b9d18569cc..0000000000 --- a/apps/server/src/logger.ts +++ /dev/null @@ -1,103 +0,0 @@ -import util from "node:util"; - -type LogLevel = "info" | "warn" | "error" | "event"; - -type LogContext = Record; - -const ANSI = { - reset: "\u001b[0m", - dim: "\u001b[2m", - cyan: "\u001b[36m", - yellow: "\u001b[33m", - red: "\u001b[31m", - magenta: "\u001b[35m", -} as const; - -const LEVEL_LABEL: Record = { - info: "INFO", - warn: "WARN", - error: "ERROR", - event: "EVENT", -}; - -const LEVEL_COLOR: Record = { - info: ANSI.cyan, - warn: ANSI.yellow, - error: ANSI.red, - event: ANSI.magenta, -}; - -function useColors() { - return Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined; -} - -function colorize(value: string, color: string, enabled: boolean) { - return enabled ? `${color}${value}${ANSI.reset}` : value; -} - -function timeStamp() { - return new Date().toISOString().slice(11, 23); -} - -function formatValue(value: unknown) { - if (typeof value === "string") { - return JSON.stringify(value); - } - if ( - typeof value === "number" || - typeof value === "boolean" || - value === null || - value === undefined - ) { - return String(value); - } - return util.inspect(value, { - depth: 4, - breakLength: Infinity, - compact: true, - maxArrayLength: 25, - maxStringLength: 320, - }); -} - -function formatContext(context: LogContext | undefined) { - if (!context) return ""; - const entries = Object.entries(context).filter(([, value]) => value !== undefined); - if (entries.length === 0) return ""; - return entries.map(([key, value]) => `${key}=${formatValue(value)}`).join(" "); -} - -function write(level: LogLevel, scope: string, message: string, context?: LogContext) { - const colorEnabled = useColors(); - const ts = colorize(timeStamp(), ANSI.dim, colorEnabled); - const levelLabel = colorize(LEVEL_LABEL[level], LEVEL_COLOR[level], colorEnabled); - const contextText = formatContext(context); - const line = `${ts} ${levelLabel} [${scope}] ${message}${contextText ? ` ${contextText}` : ""}`; - - if (level === "warn") { - console.warn(line); - return; - } - if (level === "error") { - console.error(line); - return; - } - console.log(line); -} - -export function createLogger(scope: string) { - return { - info(message: string, context?: LogContext) { - write("info", scope, message, context); - }, - warn(message: string, context?: LogContext) { - write("warn", scope, message, context); - }, - error(message: string, context?: LogContext) { - write("error", scope, message, context); - }, - event(message: string, context?: LogContext) { - write("event", scope, message, context); - }, - }; -} diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index c7392a0592..aea53aacfb 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -1,17 +1,10 @@ -import fs from "node:fs"; - -import { Effect, Logger, References } from "effect"; -import * as Layer from "effect/Layer"; +import { Effect, Logger, References, Layer } from "effect"; import { ServerConfig } from "./config"; export const ServerLoggerLive = Effect.gen(function* () { const config = yield* ServerConfig; - const { logsDir, serverLogPath } = config; - - yield* Effect.sync(() => { - fs.mkdirSync(logsDir, { recursive: true }); - }); + const { serverLogPath } = config; const fileLogger = Logger.formatSimple.pipe(Logger.toFile(serverLogPath)); const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel); From 36561641592fa29f48290f705197ae5d377e4f7f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 18:42:50 -0700 Subject: [PATCH 39/47] rev bun a bit --- bun.lock | 522 +++++++++++++++++++++---------------------------------- 1 file changed, 198 insertions(+), 324 deletions(-) diff --git a/bun.lock b/bun.lock index 25fe6c6440..37773cb142 100644 --- a/bun.lock +++ b/bun.lock @@ -190,9 +190,7 @@ "vitest": "^4.0.0", }, "packages": { - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.87", "", { "dependencies": { "@anthropic-ai/sdk": "^0.74.0", "@modelcontextprotocol/sdk": "^1.27.1" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-WWmgBPxPhBOvNT0ujI8vPTI2lK+w5YEkEZ/y1mH0EDkK/0kBnxVJNhCtG5vnueiAViwLoUOFn66pbkDiivijdA=="], - - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.74.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-srbJV7JKsc5cQ6eVuFzjZO7UR3xEPJqPamHFIe29bs38Ij2IripoAhC0S5NslNbaFUYqBKypmmpzMTpqfHEUDw=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.77", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-t+R1BW3ahCFMNM7/8WJq7+Gw9KPA9Cl7UUK8fWPokJZ75cf/xwEd9MqB+MVNoQT45dJiom/wxybT7tqYPkCqyg=="], "@astrojs/check": ["@astrojs/check@0.9.8", "", { "dependencies": { "@astrojs/language-server": "^2.16.5", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-LDng8446QLS5ToKjRHd3bgUdirvemVVExV7nRyJfW2wV36xuv7vDxwy5NWN9zqeSEDgg0Tv84sP+T3yEq+Zlkw=="], @@ -200,11 +198,11 @@ "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.8.0", "", { "dependencies": { "picomatch": "^4.0.3" } }, "sha512-J56GrhEiV+4dmrGLPNOl2pZjpHXAndWVyiVDYGDuw6MWKpBSEMLdFxHzeM/6sqaknw9M+HFfHZAcvi3OfT3D/w=="], - "@astrojs/language-server": ["@astrojs/language-server@2.16.6", "", { "dependencies": { "@astrojs/compiler": "^2.13.1", "@astrojs/yaml2ts": "^0.2.3", "@jridgewell/sourcemap-codec": "^1.5.5", "@volar/kit": "~2.4.28", "@volar/language-core": "~2.4.28", "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.15", "volar-service-css": "0.0.70", "volar-service-emmet": "0.0.70", "volar-service-html": "0.0.70", "volar-service-prettier": "0.0.70", "volar-service-typescript": "0.0.70", "volar-service-typescript-twoslash-queries": "0.0.70", "volar-service-yaml": "0.0.70", "vscode-html-languageservice": "^5.6.2", "vscode-uri": "^3.1.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "bin/nodeServer.js" } }, "sha512-N990lu+HSFiG57owR0XBkr02BYMgiLCshLf+4QG4v6jjSWkBeQGnzqi+E1L08xFPPJ7eEeXnxPXGLaVv5pa4Ug=="], + "@astrojs/language-server": ["@astrojs/language-server@2.16.5", "", { "dependencies": { "@astrojs/compiler": "^2.13.1", "@astrojs/yaml2ts": "^0.2.3", "@jridgewell/sourcemap-codec": "^1.5.5", "@volar/kit": "~2.4.28", "@volar/language-core": "~2.4.28", "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.15", "volar-service-css": "0.0.70", "volar-service-emmet": "0.0.70", "volar-service-html": "0.0.70", "volar-service-prettier": "0.0.70", "volar-service-typescript": "0.0.70", "volar-service-typescript-twoslash-queries": "0.0.70", "volar-service-yaml": "0.0.70", "vscode-html-languageservice": "^5.6.2", "vscode-uri": "^3.1.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "bin/nodeServer.js" } }, "sha512-MEQvrbuiFDEo+LCO4vvYuTr3eZ4IluZ/n4BbUv77AWAJNEj/n0j7VqTvdL1rGloNTIKZTUd46p5RwYKsxQGY8w=="], - "@astrojs/markdown-remark": ["@astrojs/markdown-remark@7.1.0", "", { "dependencies": { "@astrojs/internal-helpers": "0.8.0", "@astrojs/prism": "4.0.1", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "retext-smartypants": "^6.2.0", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-P+HnCsu2js3BoTc8kFmu+E9gOcFeMdPris75g+Zl4sY8+bBRbSQV6xzcBDbZ27eE7yBGEGQoqjpChx+KJYIPYQ=="], + "@astrojs/markdown-remark": ["@astrojs/markdown-remark@7.0.0", "", { "dependencies": { "@astrojs/internal-helpers": "0.8.0", "@astrojs/prism": "4.0.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-jTAXHPy45L7o1ljH4jYV+ShtOHtyQUa1mGp3a5fJp1soX8lInuTJQ6ihmldHzVM4Q7QptU4SzIDIcKbBJO7sXQ=="], - "@astrojs/prism": ["@astrojs/prism@4.0.1", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-nksZQVjlferuWzhPsBpQ1JE5XuKAf1id1/9Hj4a9KG4+ofrlzxUUwX4YGQF/SuDiuiGKEnzopGOt38F3AnVWsQ=="], + "@astrojs/prism": ["@astrojs/prism@4.0.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-NndtNPpxaGinRpRytljGBvYHpTOwHycSZ/c+lQi5cHvkqqrHKWdkPEhImlODBNmbuB+vyQUNUDXyjzt66CihJg=="], "@astrojs/telemetry": ["@astrojs/telemetry@3.3.0", "", { "dependencies": { "ci-info": "^4.2.0", "debug": "^4.4.0", "dlv": "^1.1.3", "dset": "^3.1.4", "is-docker": "^3.0.0", "is-wsl": "^3.1.0", "which-pm-runs": "^1.1.0" } }, "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ=="], @@ -234,15 +232,15 @@ "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], - "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], - "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], - "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], @@ -300,9 +298,9 @@ "@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="], - "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], + "@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="], - "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], @@ -382,8 +380,6 @@ "@hapi/topo": ["@hapi/topo@6.0.2", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg=="], - "@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="], - "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -500,8 +496,6 @@ "@lexical/yjs": ["@lexical/yjs@0.41.0", "", { "dependencies": { "@lexical/offset": "0.41.0", "@lexical/selection": "0.41.0", "lexical": "0.41.0" }, "peerDependencies": { "yjs": ">=13.5.22" } }, "sha512-PaKTxSbVC4fpqUjQ7vUL9RkNF1PjL8TFl5jRe03PqoPYpE33buf3VXX6+cOUEfv9+uknSqLCPHoBS/4jN3a97w=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], - "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], @@ -516,7 +510,7 @@ "@mswjs/interceptors": ["@mswjs/interceptors@0.41.3", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], "@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], @@ -526,6 +520,8 @@ "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], + "@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="], + "@oxc-project/types": ["@oxc-project/types@0.112.0", "", {}, "sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.40.0", "", { "os": "android", "cpu": "arm" }, "sha512-S6zd5r1w/HmqR8t0CTnGjFTBLDq2QKORPwriCHxo4xFNuhmOTABGjPaNvCJJVnrKBLsohOeiDX3YqQfJPF+FXw=="], @@ -566,45 +562,45 @@ "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/Zmj0yTYSvmha6TG1QnoLqVT7ZMRDqXvFXXBQpIjteEwx9qvUYMBH2xbiOFhDeMUJkGwC3D6fdKsFtaqUvkwNA=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.57.0", "", { "os": "android", "cpu": "arm" }, "sha512-C7EiyfAJG4B70496eV543nKiq5cH0o/xIh/ufbjQz3SIvHhlDDsyn+mRFh+aW8KskTyUpyH2LGWL8p2oN6bl1A=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-IyfYPthZyiSKwAv/dLjeO18SaK8MxLI9Yss2JrRDyweQAkuL3LhEy7pwIwI7uA3KQc1Vdn20kdmj3q0oUIQL6A=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.57.0", "", { "os": "android", "cpu": "arm64" }, "sha512-9i80AresjZ/FZf5xK8tKFbhQnijD4s1eOZw6/FHUwD59HEZbVLRc2C88ADYJfLZrF5XofWDiRX/Ja9KefCLy7w=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.56.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Ga5zYrzH6vc/VFxhn6MmyUnYEfy9vRpwTIks99mY3j6Nz30yYpIkWryI0QKPCgvGUtDSXVLEaMum5nA+WrNOSg=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.57.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0eUfhRz5L2yKa9I8k3qpyl37XK3oBS5BvrgdVIx599WZK63P8sMbg+0s4IuxmIiZuBK68Ek+Z+gcKgeYf0otsg=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.56.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ogmbdJysnw/D4bDcpf1sPLpFThZ48lYp4aKYm10Z/6Nh1SON6NtnNhTNOlhEY296tDFItsZUz+2tgcSYqh8Eyw=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.57.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-UvrSuzBaYOue+QMAcuDITe0k/Vhj6KZGjfnI6x+NkxBTke/VoM7ZisaxgNY0LWuBkTnd1OmeQfEQdQ48fRjkQg=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.56.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-x8QE1h+RAtQ2g+3KPsP6Fk/tdz6zJQUv5c7fTrJxXV3GHOo+Ry5p/PsogU4U+iUZg0rj6hS+E4xi+mnwwlDCWQ=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.57.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-wtQq0dCoiw4bUwlsNVDJJ3pxJA218fOezpgtLKrbQqUtQJcM9yP8z+I9fu14aHg0uyAxIY+99toL6uBa2r7nxA=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.56.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6G+WMZvwJpMvY7my+/SHEjb7BTk/PFbePqLpmVmUJRIsJMy/UlyYqjpuh0RCgYYkPLcnXm1rUM04kbTk8yS1Yg=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.57.0", "", { "os": "linux", "cpu": "arm" }, "sha512-qxFWl2BBBFcT4djKa+OtMdnLgoHEJXpqjyGwz8OhW35ImoCwR5qtAGqApNYce5260FQqoAHW8S8eZTjiX67Tsg=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-YYHBsk/sl7fYwQOok+6W5lBPeUEvisznV/HZD2IfZmF3Bns6cPC3Z0vCtSEOaAWTjYWN3jVsdu55jMxKlsdlhg=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.57.0", "", { "os": "linux", "cpu": "arm" }, "sha512-SQoIsBU7J0bDW15/f0/RvxHfY3Y0+eB/caKBQtNFbuerTiA6JCYx9P1MrrFTwY2dTm/lMgTSgskvCEYk2AtG/Q=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+AZK8rOUr78y8WT6XkDb04IbMRqauNV+vgT6f8ZLOH8wnpQ9i7Nol0XLxAu+Cq7Sb+J9wC0j6Km5hG8rj47/yQ=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.57.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jqxYd1W6WMeozsCmqe9Rzbu3SRrGTyGDAipRlRggetyYbUksJqJKvUNTQtZR/KFoJPb+grnSm5SHhdWrywv3RQ=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-urse2SnugwJRojUkGSSeH2LPMaje5Q50yQtvtL9HFckiyeqXzoFwOAZqD5TR29R2lq7UHidfFDM9EGcchcbb8A=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.57.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-i66WyEPVEvq9bxRUCJ/MP5EBfnTDN3nhwEdFZFTO5MmLLvzngfWEG3NSdXQzTT3vk5B9i6C2XSIYBh+aG6uqyg=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-rkTZkBfJ4TYLjansjSzL6mgZOdN5IvUnSq3oNJSLwBcNvy3dlgQtpHPrRxrCEbbcp7oQ6If0tkNaqfOsphYZ9g=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.57.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-oMZDCwz4NobclZU3pH+V1/upVlJZiZvne4jQP+zhJwt+lmio4XXr4qG47CehvrW1Lx2YZiIHuxM2D4YpkG3KVA=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.56.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-uqL1kMH3u69/e1CH2EJhP3CP28jw2ExLsku4o8RVAZ7fySo9zOyI2fy9pVlTAp4voBLVgzndXi3SgtdyCTa2aA=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-uoBnjJ3MMEBbfnWC1jSFr7/nSCkcQYa72NYoNtLl1imshDnWSolYCjzb8LVCwYCCfLJXD+0gBLD7fyC14c0+0g=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-j0CcMBOgV6KsRaBdsebIeiy7hCjEvq2KdEsiULf2LZqAq0v1M1lWjelhCV57LxsqaIGChXFuFJ0RiFrSRHPhSg=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-BdrwD7haPZ8a9KrZhKJRSj6jwCor+Z8tHFZ3PT89Y3Jq5v3LfMfEePeAmD0LOTWpiTmzSzdmyw9ijneapiVHKQ=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-7VDOiL8cDG3DQ/CY3yKjbV1c4YPvc4vH8qW09Vv+5ukq3l/Kcyr6XGCd5NvxUmxqDb2vjMpM+eW/4JrEEsUetA=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.57.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-BNs+7ZNsRstVg2tpNxAXfMX/Iv5oZh204dVyb8Z37+/gCh+yZqNTlg6YwCLIMPSk5wLWIGOaQjT0GUOahKYImw=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.56.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-JGRpX0M+ikD3WpwJ7vKcHKV6Kg0dT52BW2Eu2BupXotYeqGXBrbY+QPkAyKO6MNgKozyTNaRh3r7g+VWgyAQYQ=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.57.0", "", { "os": "linux", "cpu": "x64" }, "sha512-AghS18w+XcENcAX0+BQGLiqjpqpaxKJa4cWWP0OWNLacs27vHBxu7TYkv9LUSGe5w8lOJHeMxcYfZNOAPqw2bg=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-dNaICPvtmuxFP/VbqdofrLqdS3bM/AKJN3LMJD52si44ea7Be1cBk6NpfIahaysG9Uo+L98QKddU9CD5L8UHnQ=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.57.0", "", { "os": "linux", "cpu": "x64" }, "sha512-E/FV3GB8phu/Rpkhz5T96hAiJlGzn91qX5yj5gU754P5cmVGXY1Jw/VSjDSlZBCY3VHjsVLdzgdkJaomEmcNOg=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pF1vOtM+GuXmbklM1hV8WMsn6tCNPvkUzklj/Ej98JhlanbmA2RB1BILgOpwSuCTRTIYx2MXssmEyQQ90QF5aA=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.57.0", "", { "os": "none", "cpu": "arm64" }, "sha512-xvZ2yZt0nUVfU14iuGv3V25jpr9pov5N0Wr28RXnHFxHCRxNDMtYPHV61gGLhN9IlXM96gI4pyYpLSJC5ClLCQ=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.56.0", "", { "os": "none", "cpu": "arm64" }, "sha512-bp8NQ4RE6fDIFLa4bdBiOA+TAvkNkg+rslR+AvvjlLTYXLy9/uKAYLQudaQouWihLD/hgkrXIKKzXi5IXOewwg=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.57.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z4D8Pd0AyHBKeazhdIXeUUy5sIS3Mo0veOlzlDECg6PhRRKgEsBJCCV1n+keUZtQ04OP+i7+itS3kOykUyNhDg=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.56.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-PxT4OJDfMOQBzo3OlzFb9gkoSD+n8qSBxyVq2wQSZIHFQYGEqIRTo9M0ZStvZm5fdhMqaVYpOnJvH2hUMEDk/g=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.57.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-StOZ9nFMVKvevicbQfql6Pouu9pgbeQnu60Fvhz2S6yfMaii+wnueLnqQ5I1JPgNF0Syew4voBlAaHD13wH6tw=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.56.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-PTRy6sIEPqy2x8PTP1baBNReN/BNEFmde0L+mYeHmjXE1Vlcc9+I5nsqENsB2yAm5wLkzPoTNCMY/7AnabT4/A=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6PuxhYgth8TuW0+ABPOIkGdBYw+qYGxgIdXPHSVpiCDm+hqTTWCmC739St1Xni0DJBt8HnSHTG67i1y6gr8qrA=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ=="], - "@pierre/diffs": ["@pierre/diffs@1.1.7", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-FWs2hHrjZPXmJl6ewnfFzOjNEM3aeSH1CB8ynZg4SOg95Wc5AxomeyJJhXf44PK9Cc+PNm1CgsJ1IvOdfgHyHA=="], + "@pierre/diffs": ["@pierre/diffs@1.1.0", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-wbxrzcmanJuHZb81iir09j42uU9AnKxXDtAuEQJbAnti5f2UfYdCQYejawuHZStFrlsMacCZLh/dDHmqvAaQCw=="], "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], @@ -628,9 +624,9 @@ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z03/wrqau9Bicfgb3Dbs6SYTHliELk2PM2LpG2nFd+cGupTMF5kanLEcj2vuuJLLhptNyS61rtk7SOZ+lPsTUA=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "ppc64" }, "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "s390x" }, "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA=="], "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.3", "", { "os": "linux", "cpu": "x64" }, "sha512-iSXXZsQp08CSilff/DCTFZHSVEpEwdicV3W8idHyrByrcsRDVh9sGC3sev6d8BygSGj3vt8GvUKBPCoyMA4tgQ=="], @@ -644,7 +640,7 @@ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.3", "", { "os": "win32", "cpu": "x64" }, "sha512-a4VUQZH7LxGbUJ3qJ/TzQG8HxdHvf+jOnqf7B7oFx1TEBm+j2KNL2zr5SQ7wHkNAcaPevF6gf9tQnVBnC4mD+A=="], - "@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.2", "", { "dependencies": { "picomatch": "^4.0.3" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-q9pE8+47bQNHb5eWVcE6oXppA+JTSwvnrhH53m0ZuHuK5MLvwsLoWrWzBTFQqQ06BVxz1gp0HblLsch8o6pvZw=="], + "@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.1", "", { "dependencies": { "picomatch": "^4.0.3" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-pHDVHqFv26JNC8I500JZ0H4h1kvSyiE3V9gjEO9pRAgD1KrIdJvcHCokV6f7gG7Rx4vMOD11V8VUOpqdyGbKBw=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], @@ -686,35 +682,35 @@ "@t3tools/web": ["@t3tools/web@workspace:apps/web"], - "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], - "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], @@ -722,43 +718,31 @@ "@tanstack/pacer": ["@tanstack/pacer@0.18.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/store": "^0.8.0" } }, "sha512-qhCRSFei0hokQr3xYcQXqxsRD/LKlgHCxHXtKHrQoImp4x2Zu6tUOpUGVH4y2qexIrzSu3aibQBNNfC3Eay6Mg=="], - "@tanstack/query-core": ["@tanstack/query-core@5.95.2", "", {}, "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ=="], + "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], "@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.4", "", { "dependencies": { "@tanstack/pacer": "0.18.0", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-coj8ULAuR0qFpjAKD44gTgRuZyjxU6Xu+IX5MwwYvr4e61OtZcJshaExoOBKpCGde0Edb12jDnzzj2Im13Qm9Q=="], - "@tanstack/react-query": ["@tanstack/react-query@5.95.2", "", { "dependencies": { "@tanstack/query-core": "5.95.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA=="], + "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], - "@tanstack/react-router": ["@tanstack/react-router@1.168.9", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.168.8", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-lHoQG6mAU4q5CFUD7fmUMWvy1apN6gcy+ba4pa68cC9BcjwPgSsHov1F2266v8EkBhEw1+utS1hlBLbKKq9r0w=="], + "@tanstack/react-router": ["@tanstack/react-router@1.167.3", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.167.3", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-1qbSy4r+O7IBdmPLlcKsjB041Gq2MMnIEAYSGIjaMZIL4duUIQnOWLw4jTfjKil/IJz/9rO5JcvrbxOG5UTSdg=="], "@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="], "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.23", "", { "dependencies": { "@tanstack/virtual-core": "3.13.23" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ=="], - "@tanstack/router-core": ["@tanstack/router-core@1.168.8", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-sTdYNMMAP1A1ToI0NJ7l6Cgz6r4gBrupZPZ6JXOE/504373gMtdnk3Zm+LXVTmY0GXJesUInuLrwWg6xSdWCHw=="], + "@tanstack/router-core": ["@tanstack/router-core@1.167.3", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-M/CxrTGKk1fsySJjd+Pzpbi3YLDz+cJSutDjSTMy12owWlOgHV/I6kzR0UxyaBlHraM6XgMHNA0XdgsS1fa4Nw=="], - "@tanstack/router-generator": ["@tanstack/router-generator@1.166.23", "", { "dependencies": { "@tanstack/router-core": "1.168.8", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-9aPxRZqrf06MxVBKbndZsyEIpxxkk2v3+7y6LCsksKWP+38+yLrAayNijsjWbzVW+wX6u1AeurkZjRFnzn3x8A=="], + "@tanstack/router-generator": ["@tanstack/router-generator@1.166.11", "", { "dependencies": { "@tanstack/router-core": "1.167.3", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.6", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-Q/49wxURbft1oNOvo/eVAWZq/lNLK3nBGlavqhLToAYXY6LCzfMtRlE/y3XPHzYC9pZc09u5jvBR1k1E4hyGDQ=="], - "@tanstack/router-plugin": ["@tanstack/router-plugin@1.167.11", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.168.8", "@tanstack/router-generator": "1.166.23", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.168.9", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"], "bin": { "intent": "bin/intent.js" } }, "sha512-bwIiMan5z1d/a/HDSTMJkwf8Gbo0ZLAqv+y5AU+N8w5LbLnrFfAJgkabWixA6DLlux8fq8ynwuJSOckuPeCO7w=="], + "@tanstack/router-plugin": ["@tanstack/router-plugin@1.166.12", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.167.3", "@tanstack/router-generator": "1.166.11", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.6", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.167.3", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-PYsnN6goK6zBaVo63UVKjofv69+HHMKRQXymwN55JYKguNnNR8OZ6E12icPb0Olc5uIpPiGz1YI2+rbpmNKGHA=="], "@tanstack/router-utils": ["@tanstack/router-utils@1.161.6", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw=="], - "@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], + "@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="], "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.23", "", {}, "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg=="], - "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="], - - "@turbo/darwin-64": ["@turbo/darwin-64@2.9.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-d1zTcIf6VWT7cdfjhi0X36C2PRsUi2HdEwYzVgkLHmuuYtL+1Y1Zu3JdlouoB/NjG2vX3q4NnKLMNhDOEweoIg=="], - - "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-AwJ4mA++Kpem33Lcov093hS1LrgqbKxqq5FCReoqsA8ayEG6eAJAo8ItDd9qQTdBiXxZH8GHCspLAMIe1t3Xyw=="], - - "@turbo/linux-64": ["@turbo/linux-64@2.9.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HT9SjKkjEw9uvlgly/qwCGEm4wOXOwQPSPS+wkg+/O1Qan3F1uU/0PFYzxl3m4lfuV3CP9wr2Dq5dPrUX+B9Ag=="], - - "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+4s5GZs3kjxc1KMhLBhoQy4UBkXjOhgidA9ipNllkA4JLivSqUCuOgU1Xbyp6vzYrsqHJ9vvwo/2mXgEtD6ZHg=="], - - "@turbo/windows-64": ["@turbo/windows-64@2.9.1", "", { "os": "win32", "cpu": "x64" }, "sha512-ZO7GCyQd5HV564XWHc9KysjanFfM3DmnWquyEByu+hQMq42g9OMU/fYOCfHS6Xj2aXkIg2FHJeRV+iAck2YrbQ=="], - - "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-BjX2fdz38mBb/H94JXrD5cJ+mEq8NmsCbYdC42JzQebJ0X8EdNgyFoEhOydPGViOmaRmhhdZnPZKKn6wahSpcA=="], + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.6", "", {}, "sha512-EGWs9yvJA821pUkwkiZLQW89CzUumHyJy8NKq229BubyoWXfDw1oWnTJYSS/hhbLiwP9+KpopjeF5wWwnCCyeQ=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], @@ -770,13 +754,13 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], - "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], @@ -818,23 +802,23 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], - "@vitest/browser": ["@vitest/browser@4.1.2", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.2" } }, "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ=="], + "@vitest/browser": ["@vitest/browser@4.1.0", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.0", "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.0.3", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.0" } }, "sha512-tG/iOrgbiHQks0ew7CdelUyNEHkv8NLrt+CqdTivIuoSnXvO7scWMn4Kqo78/UGY1NJ6Hv+vp8BvRnED/bjFdQ=="], - "@vitest/browser-playwright": ["@vitest/browser-playwright@4.1.2", "", { "dependencies": { "@vitest/browser": "4.1.2", "@vitest/mocker": "4.1.2", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "playwright": "*", "vitest": "4.1.2" } }, "sha512-N0Z2HzMLvMR6k/tWPTS6Q/DaRscrkax/f2f9DIbNQr+Cd1l4W4wTf/I6S983PAMr0tNqqoTL+xNkLh9M5vbkLg=="], + "@vitest/browser-playwright": ["@vitest/browser-playwright@4.1.0", "", { "dependencies": { "@vitest/browser": "4.1.0", "@vitest/mocker": "4.1.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "playwright": "*", "vitest": "4.1.0" } }, "sha512-2RU7pZELY9/aVMLmABNy1HeZ4FX23FXGY1jRuHLHgWa2zaAE49aNW2GLzebW+BmbTZIKKyFF1QXvk7DEWViUCQ=="], - "@vitest/expect": ["@vitest/expect@4.1.2", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ=="], + "@vitest/expect": ["@vitest/expect@4.1.0", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA=="], - "@vitest/mocker": ["@vitest/mocker@4.1.2", "", { "dependencies": { "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q=="], + "@vitest/mocker": ["@vitest/mocker@4.1.0", "", { "dependencies": { "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.2", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.0", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A=="], - "@vitest/runner": ["@vitest/runner@4.1.2", "", { "dependencies": { "@vitest/utils": "4.1.2", "pathe": "^2.0.3" } }, "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ=="], + "@vitest/runner": ["@vitest/runner@4.1.0", "", { "dependencies": { "@vitest/utils": "4.1.0", "pathe": "^2.0.3" } }, "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ=="], - "@vitest/snapshot": ["@vitest/snapshot@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg=="], - "@vitest/spy": ["@vitest/spy@4.1.2", "", {}, "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA=="], + "@vitest/spy": ["@vitest/spy@4.1.0", "", {}, "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw=="], - "@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="], + "@vitest/utils": ["@vitest/utils@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw=="], "@volar/kit": ["@volar/kit@2.4.28", "", { "dependencies": { "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "typescript": "*" } }, "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg=="], @@ -856,16 +840,12 @@ "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], - "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], - "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -886,11 +866,11 @@ "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], - "astro": ["astro@6.1.2", "", { "dependencies": { "@astrojs/compiler": "^3.0.1", "@astrojs/internal-helpers": "0.8.0", "@astrojs/markdown-remark": "7.1.0", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", "devalue": "^5.6.3", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.27.3", "flattie": "^1.1.1", "fontace": "~0.4.1", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "rehype": "^13.0.2", "semver": "^7.7.4", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "svgo": "^4.0.1", "tinyclip": "^0.1.12", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unist-util-visit": "^5.1.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^7.3.1", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "bin/astro.mjs" } }, "sha512-r3iIvmB6JvQxsdJLvapybKKq7Bojd1iQK6CCx5P55eRnXJIyUpHx/1UB/GdMm+em/lwaCUasxHCmIO0lCLV2uA=="], + "astro": ["astro@6.0.5", "", { "dependencies": { "@astrojs/compiler": "^3.0.0", "@astrojs/internal-helpers": "0.8.0", "@astrojs/markdown-remark": "7.0.0", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.0.1", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", "devalue": "^5.6.3", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.27.3", "flattie": "^1.1.1", "fontace": "~0.4.1", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "rehype": "^13.0.2", "semver": "^7.7.4", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyclip": "^0.1.6", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unist-util-visit": "^5.1.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^7.3.1", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "bin/astro.mjs" } }, "sha512-JnLCwaoCaRXIHuIB8yNztJrd7M3hXrHUMAoQmeXtEBKxRu/738REhaCZ1lapjrS9HlpHsWTu3JUXTERB/0PA7g=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="], + "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], @@ -900,14 +880,12 @@ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], @@ -920,12 +898,10 @@ "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], - "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], - "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], @@ -934,9 +910,7 @@ "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - - "caniuse-lite": ["caniuse-lite@1.0.30001782", "", {}, "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw=="], + "caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -978,22 +952,12 @@ "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], - "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], - - "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], - "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - - "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], @@ -1030,8 +994,6 @@ "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], @@ -1044,7 +1006,7 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], @@ -1062,13 +1024,11 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="], "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], - "electron-to-chromium": ["electron-to-chromium@1.5.329", "", {}, "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ=="], + "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="], "electron-updater": ["electron-updater@6.8.3", "", { "dependencies": { "builder-util-runtime": "9.5.1", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ=="], @@ -1078,11 +1038,9 @@ "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], - "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -1104,8 +1062,6 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], @@ -1114,20 +1070,10 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], - "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], - - "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], - "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], - "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - - "express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="], - "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], @@ -1144,8 +1090,6 @@ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="], @@ -1158,10 +1102,6 @@ "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], - - "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], @@ -1178,7 +1118,7 @@ "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], - "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="], + "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], @@ -1194,9 +1134,9 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "graphql": ["graphql@16.13.2", "", {}, "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="], + "graphql": ["graphql@16.13.1", "", {}, "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ=="], - "h3": ["h3@1.15.10", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg=="], + "h3": ["h3@1.15.6", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-oi15ESLW5LRthZ+qPCi5GNasY/gvynSKUQxgiovrY63bPAtG59wtM+LSrlcwvOHAXzGrXVLnI97brbkdPF9WoQ=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], @@ -1230,8 +1170,6 @@ "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], - "hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="], - "hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="], "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], @@ -1242,25 +1180,15 @@ "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], - "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="], - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], - "ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="], - - "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], - - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "ioredis": ["ioredis@5.10.0", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA=="], "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], @@ -1290,21 +1218,15 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "isbot": ["isbot@5.1.36", "", {}, "sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "joi": ["joi@18.1.2", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.1.0" } }, "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA=="], - - "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "joi": ["joi@18.0.2", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.0.0" } }, "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -1314,12 +1236,8 @@ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], - "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], @@ -1428,10 +1346,6 @@ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], - "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - - "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -1516,8 +1430,6 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], @@ -1540,10 +1452,6 @@ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], @@ -1552,8 +1460,6 @@ "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], @@ -1566,7 +1472,7 @@ "oxfmt": ["oxfmt@0.40.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.40.0", "@oxfmt/binding-android-arm64": "0.40.0", "@oxfmt/binding-darwin-arm64": "0.40.0", "@oxfmt/binding-darwin-x64": "0.40.0", "@oxfmt/binding-freebsd-x64": "0.40.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.40.0", "@oxfmt/binding-linux-arm-musleabihf": "0.40.0", "@oxfmt/binding-linux-arm64-gnu": "0.40.0", "@oxfmt/binding-linux-arm64-musl": "0.40.0", "@oxfmt/binding-linux-ppc64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-musl": "0.40.0", "@oxfmt/binding-linux-s390x-gnu": "0.40.0", "@oxfmt/binding-linux-x64-gnu": "0.40.0", "@oxfmt/binding-linux-x64-musl": "0.40.0", "@oxfmt/binding-openharmony-arm64": "0.40.0", "@oxfmt/binding-win32-arm64-msvc": "0.40.0", "@oxfmt/binding-win32-ia32-msvc": "0.40.0", "@oxfmt/binding-win32-x64-msvc": "0.40.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-g0C3I7xUj4b4DcagevM9kgH6+pUHytikxUcn3/VUkvzTNaaXBeyZqb7IBsHwojeXm4mTBEC/aBjBTMVUkZwWUQ=="], - "oxlint": ["oxlint@1.57.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.57.0", "@oxlint/binding-android-arm64": "1.57.0", "@oxlint/binding-darwin-arm64": "1.57.0", "@oxlint/binding-darwin-x64": "1.57.0", "@oxlint/binding-freebsd-x64": "1.57.0", "@oxlint/binding-linux-arm-gnueabihf": "1.57.0", "@oxlint/binding-linux-arm-musleabihf": "1.57.0", "@oxlint/binding-linux-arm64-gnu": "1.57.0", "@oxlint/binding-linux-arm64-musl": "1.57.0", "@oxlint/binding-linux-ppc64-gnu": "1.57.0", "@oxlint/binding-linux-riscv64-gnu": "1.57.0", "@oxlint/binding-linux-riscv64-musl": "1.57.0", "@oxlint/binding-linux-s390x-gnu": "1.57.0", "@oxlint/binding-linux-x64-gnu": "1.57.0", "@oxlint/binding-linux-x64-musl": "1.57.0", "@oxlint/binding-openharmony-arm64": "1.57.0", "@oxlint/binding-win32-arm64-msvc": "1.57.0", "@oxlint/binding-win32-ia32-msvc": "1.57.0", "@oxlint/binding-win32-x64-msvc": "1.57.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-DGFsuBX5MFZX9yiDdtKjTrYPq45CZ8Fft6qCltJITYZxfwYjVdGf/6wycGYTACloauwIPxUnYhBVeZbHvleGhw=="], + "oxlint": ["oxlint@1.56.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.56.0", "@oxlint/binding-android-arm64": "1.56.0", "@oxlint/binding-darwin-arm64": "1.56.0", "@oxlint/binding-darwin-x64": "1.56.0", "@oxlint/binding-freebsd-x64": "1.56.0", "@oxlint/binding-linux-arm-gnueabihf": "1.56.0", "@oxlint/binding-linux-arm-musleabihf": "1.56.0", "@oxlint/binding-linux-arm64-gnu": "1.56.0", "@oxlint/binding-linux-arm64-musl": "1.56.0", "@oxlint/binding-linux-ppc64-gnu": "1.56.0", "@oxlint/binding-linux-riscv64-gnu": "1.56.0", "@oxlint/binding-linux-riscv64-musl": "1.56.0", "@oxlint/binding-linux-s390x-gnu": "1.56.0", "@oxlint/binding-linux-x64-gnu": "1.56.0", "@oxlint/binding-linux-x64-musl": "1.56.0", "@oxlint/binding-openharmony-arm64": "1.56.0", "@oxlint/binding-win32-arm64-msvc": "1.56.0", "@oxlint/binding-win32-ia32-msvc": "1.56.0", "@oxlint/binding-win32-x64-msvc": "1.56.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-Q+5Mj5PVaH/R6/fhMMFzw4dT+KPB+kQW4kaL8FOIq7tfhlnEVp6+3lcWqFruuTNlUo9srZUW3qH7Id4pskeR6g=="], "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], @@ -1584,12 +1490,8 @@ "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], - "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -1600,9 +1502,7 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - - "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], @@ -1620,15 +1520,11 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - - "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], - "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], - - "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + "pure-rand": ["pure-rand@8.1.0", "", {}, "sha512-53B3MB8wetRdD6JZ4W/0gDKaOvKwuXrEmV1auQc0hASWge8rieKV4PCCVNVbJ+i24miiubb4c/B+dg8Ho0ikYw=="], "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], @@ -1636,10 +1532,6 @@ "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - - "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], @@ -1710,15 +1602,11 @@ "rolldown-plugin-dts": ["rolldown-plugin-dts@0.22.5", "", { "dependencies": { "@babel/generator": "8.0.0-rc.2", "@babel/helper-validator-identifier": "8.0.0-rc.2", "@babel/parser": "8.0.0-rc.2", "@babel/types": "8.0.0-rc.2", "ast-kit": "^3.0.0-beta.1", "birpc": "^4.0.0", "dts-resolver": "^2.1.3", "get-tsconfig": "^4.13.6", "obug": "^2.1.1" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": ">=7.0.0-dev.20250601.1", "rolldown": "^1.0.0-rc.3", "typescript": "^5.0.0 || ^6.0.0-beta", "vue-tsc": "~3.2.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-M/HXfM4cboo+jONx9Z0X+CUf3B5tCi7ni+kR5fUW50Fp9AlZk0oVLesibGWgCXDKFp5lpgQ9yhKoImUFjl3VZw=="], - "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], - "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - - "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], + "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -1726,34 +1614,16 @@ "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], - "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], "seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="], "seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="], - "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], - - "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], - - "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], - - "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -1762,7 +1632,7 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + "smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="], "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], @@ -1804,9 +1674,9 @@ "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], - "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], + "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], - "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], @@ -1814,6 +1684,8 @@ "tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="], + "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyclip": ["tinyclip@0.1.12", "", {}, "sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA=="], @@ -1826,14 +1698,12 @@ "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], - "tldts": ["tldts@7.0.27", "", { "dependencies": { "tldts-core": "^7.0.27" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg=="], + "tldts": ["tldts@7.0.26", "", { "dependencies": { "tldts-core": "^7.0.26" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ=="], - "tldts-core": ["tldts-core@7.0.27", "", {}, "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg=="], + "tldts-core": ["tldts-core@7.0.26", "", {}, "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], - "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], @@ -1846,8 +1716,6 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], - "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], - "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], "tsdown": ["tsdown@0.20.3", "", { "dependencies": { "ansis": "^4.2.0", "cac": "^6.7.14", "defu": "^6.1.4", "empathic": "^2.0.0", "hookable": "^6.0.1", "import-without-cache": "^0.2.5", "obug": "^2.1.1", "picomatch": "^4.0.3", "rolldown": "1.0.0-rc.3", "rolldown-plugin-dts": "^0.22.1", "semver": "^7.7.3", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tree-kill": "^1.2.2", "unconfig-core": "^7.4.2", "unrun": "^0.2.27" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@vitejs/devtools": "*", "publint": "^0.3.0", "typescript": "^5.0.0", "unplugin-lightningcss": "^0.4.0", "unplugin-unused": "^0.5.0" }, "optionalPeers": ["@arethetypeswrong/core", "@vitejs/devtools", "publint", "typescript", "unplugin-lightningcss", "unplugin-unused"], "bin": { "tsdown": "dist/run.mjs" } }, "sha512-qWOUXSbe4jN8JZEgrkc/uhJpC8VN2QpNu3eZkBWwNuTEjc/Ik1kcc54ycfcQ5QPRHeu9OQXaLfCI3o7pEJgB2w=="], @@ -1856,11 +1724,21 @@ "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], - "turbo": ["turbo@2.9.1", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.1", "@turbo/darwin-arm64": "2.9.1", "@turbo/linux-64": "2.9.1", "@turbo/linux-arm64": "2.9.1", "@turbo/windows-64": "2.9.1", "@turbo/windows-arm64": "2.9.1" }, "bin": { "turbo": "bin/turbo" } }, "sha512-TO9du8MwLTAKoXcGezekh9cPJabJUb0+8KxtpMR6kXdRASrmJ8qXf2GkVbCREgzbMQakzfNcux9cZtxheDY4RQ=="], + "turbo": ["turbo@2.8.17", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.17", "turbo-darwin-arm64": "2.8.17", "turbo-linux-64": "2.8.17", "turbo-linux-arm64": "2.8.17", "turbo-windows-64": "2.8.17", "turbo-windows-arm64": "2.8.17" }, "bin": { "turbo": "bin/turbo" } }, "sha512-YwPsNSqU2f/RXU/+Kcb7cPkPZARxom4+me7LKEdN5jsvy2tpfze3zDZ4EiGrJnvOm9Avu9rK0aaYsP7qZ3iz7A=="], + + "turbo-darwin-64": ["turbo-darwin-64@2.8.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZFkv2hv7zHpAPEXBF6ouRRXshllOavYc+jjcrYyVHvxVTTwJWsBZwJ/gpPzmOKGvkSjsEyDO5V6aqqtZzwVF+Q=="], + + "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5DXqhQUt24ycEryXDfMNKEkW5TBHs+QmU23a2qxXwwFDaJsWcPo2obEhBxxdEPOv7qmotjad+09RGeWCcJ9JDw=="], - "type-fest": ["type-fest@5.5.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="], + "turbo-linux-64": ["turbo-linux-64@2.8.17", "", { "os": "linux", "cpu": "x64" }, "sha512-KLUbz6w7F73D/Ihh51hVagrKR0/CTsPEbRkvXLXvoND014XJ4BCrQUqSxlQ4/hu+nqp1v5WlM85/h3ldeyujuA=="], - "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "turbo-linux-arm64": ["turbo-linux-arm64@2.8.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-pJK67XcNJH40lTAjFu7s/rUlobgVXyB3A3lDoq+/JccB3hf+SysmkpR4Itlc93s8LEaFAI4mamhFuTV17Z6wOg=="], + + "turbo-windows-64": ["turbo-windows-64@2.8.17", "", { "os": "win32", "cpu": "x64" }, "sha512-EijeQ6zszDMmGZLP2vT2RXTs/GVi9rM0zv2/G4rNu2SSRSGFapgZdxgW4b5zUYLVaSkzmkpWlGfPfj76SW9yUg=="], + + "turbo-windows-arm64": ["turbo-windows-arm64@2.8.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-crpfeMPkfECd4V1PQ/hMoiyVcOy04+bWedu/if89S15WhOalHZ2BYUi6DOJhZrszY+mTT99OwpOsj4wNfb/GHQ=="], + + "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], "typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="], @@ -1876,7 +1754,7 @@ "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], - "undici": ["undici@7.24.6", "", {}, "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA=="], + "undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], @@ -1904,13 +1782,11 @@ "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], - "unrun": ["unrun@0.2.34", "", { "dependencies": { "rolldown": "1.0.0-rc.12" }, "peerDependencies": { "synckit": "^0.11.11" }, "optionalPeers": ["synckit"], "bin": { "unrun": "dist/cli.mjs" } }, "sha512-LyaghRBR++r7svhDK6tnDz2XaYHWdneBOA0jbS8wnRsHerI9MFljX4fIiTgbbNbEVzZ0C9P1OjWLLe1OqoaaEw=="], + "unrun": ["unrun@0.2.32", "", { "dependencies": { "rolldown": "1.0.0-rc.9" }, "peerDependencies": { "synckit": "^0.11.11" }, "optionalPeers": ["synckit"], "bin": { "unrun": "dist/cli.mjs" } }, "sha512-opd3z6791rf281JdByf0RdRQrpcc7WyzqittqIXodM/5meNWdTwrVxeyzbaCp4/Rgls/um14oUaif1gomO8YGg=="], - "unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], + "unstorage": ["unstorage@1.17.4", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.5", "lru-cache": "^11.2.0", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw=="], "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="], @@ -1920,19 +1796,17 @@ "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], - "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "vite": ["vite@8.0.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ=="], + "vite": ["vite@8.0.0", "", { "dependencies": { "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.9", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.0.0-alpha.31", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q=="], "vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="], - "vitest": ["vitest@4.1.2", "", { "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", "@vitest/pretty-format": "4.1.2", "@vitest/runner": "4.1.2", "@vitest/snapshot": "4.1.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.2", "@vitest/browser-preview": "4.1.2", "@vitest/browser-webdriverio": "4.1.2", "@vitest/ui": "4.1.2", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg=="], + "vitest": ["vitest@4.1.0", "", { "dependencies": { "@vitest/expect": "4.1.0", "@vitest/mocker": "4.1.0", "@vitest/pretty-format": "4.1.0", "@vitest/runner": "4.1.0", "@vitest/snapshot": "4.1.0", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.0", "@vitest/browser-preview": "4.1.0", "@vitest/browser-webdriverio": "4.1.0", "@vitest/ui": "4.1.0", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw=="], "vitest-browser-react": ["vitest-browser-react@2.1.0", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "vitest": "^4.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/cOVQ+dZojhavfsbHjcfzB3zrUxG39HIbGdvK9vSBdGc8b8HRu5Bql0p8aXtKw4sb8/E8n5XEncQxvqHtfjjag=="], @@ -1976,8 +1850,6 @@ "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], @@ -1986,7 +1858,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], @@ -1996,7 +1868,7 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], "yaml-language-server": ["yaml-language-server@1.20.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA=="], @@ -2014,8 +1886,6 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], @@ -2048,11 +1918,9 @@ "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], - "@pierre/diffs/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], - "@rolldown/plugin-babel/rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="], + "@rolldown/plugin-babel/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], @@ -2060,19 +1928,25 @@ "@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], + "@tailwindcss/node/lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], + "@tanstack/pacer/@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], + + "@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.9.2", "", { "dependencies": { "@tanstack/store": "0.9.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ=="], + + "@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -2082,9 +1956,7 @@ "@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - - "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "ast-kit/@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="], @@ -2094,10 +1966,6 @@ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - - "express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "h3/cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -2112,23 +1980,17 @@ "rolldown-plugin-dts/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="], - "router/path-to-regexp": ["path-to-regexp@8.4.0", "", {}, "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg=="], - - "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - - "unrun/rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="], + "unrun/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "vite/rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="], + "vite/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], @@ -2138,7 +2000,7 @@ "yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.3", "", {}, "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA=="], + "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], @@ -2156,120 +2018,132 @@ "@pierre/diffs/shiki/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], - "@rolldown/plugin-babel/rolldown/@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="], + "@rolldown/plugin-babel/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm" }, "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm" }, "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.12", "", { "os": "none", "cpu": "arm64" }, "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.9", "", { "os": "none", "cpu": "arm64" }, "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.12", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.9", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA=="], - "@rolldown/plugin-babel/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "x64" }, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="], + "@rolldown/plugin-babel/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "x64" }, "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ=="], - "@rolldown/plugin-babel/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="], + "@rolldown/plugin-babel/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="], - "@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], + "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], - "@tanstack/router-plugin/chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], - "@tanstack/router-plugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], - "accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], - "ast-kit/@babel/parser/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="], + "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], - "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], - "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], - "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.3", "", {}, "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA=="], + "@tanstack/router-plugin/chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "@tanstack/router-plugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "ast-kit/@babel/parser/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="], + + "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], - "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], - "unrun/rolldown/@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="], + "unrun/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], - "unrun/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="], + "unrun/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="], - "unrun/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="], + "unrun/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ=="], - "unrun/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw=="], + "unrun/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg=="], - "unrun/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q=="], + "unrun/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q=="], - "unrun/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm" }, "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q=="], + "unrun/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm" }, "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ=="], - "unrun/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg=="], + "unrun/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg=="], - "unrun/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw=="], + "unrun/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg=="], - "unrun/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg=="], + "unrun/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg=="], - "unrun/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig=="], + "unrun/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA=="], - "unrun/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.12", "", { "os": "none", "cpu": "arm64" }, "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA=="], + "unrun/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.9", "", { "os": "none", "cpu": "arm64" }, "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog=="], - "unrun/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.12", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg=="], + "unrun/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.9", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g=="], - "unrun/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q=="], + "unrun/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA=="], - "unrun/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "x64" }, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="], + "unrun/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "x64" }, "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ=="], - "unrun/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="], + "unrun/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="], "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], - "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="], + "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], - "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="], + "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="], - "vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="], + "vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ=="], - "vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw=="], + "vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg=="], - "vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q=="], + "vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q=="], - "vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm" }, "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q=="], + "vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm" }, "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ=="], - "vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg=="], + "vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg=="], - "vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw=="], + "vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg=="], - "vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg=="], + "vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg=="], - "vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig=="], + "vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA=="], - "vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.12", "", { "os": "none", "cpu": "arm64" }, "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA=="], + "vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.9", "", { "os": "none", "cpu": "arm64" }, "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog=="], - "vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.12", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg=="], + "vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.9", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g=="], - "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q=="], + "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA=="], - "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "x64" }, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="], + "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "x64" }, "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ=="], - "vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="], + "vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="], - "@tanstack/router-plugin/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "@tanstack/router-plugin/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "ast-kit/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.3", "", {}, "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA=="], + "ast-kit/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], } } From fe8ba9684d81b57924465a5e226b5e0de41c0285 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 18:43:55 -0700 Subject: [PATCH 40/47] cool --- apps/server/package.json | 2 +- bun.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index fee7b901d3..05b2b74a47 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.77", + "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@pierre/diffs": "^1.1.0-beta.16", @@ -34,7 +35,6 @@ }, "devDependencies": { "@effect/language-service": "catalog:", - "@effect/platform-bun": "catalog:", "@effect/vitest": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/bun.lock b/bun.lock index 37773cb142..ce777ac064 100644 --- a/bun.lock +++ b/bun.lock @@ -49,6 +49,7 @@ }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.77", + "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@pierre/diffs": "^1.1.0-beta.16", @@ -59,7 +60,6 @@ }, "devDependencies": { "@effect/language-service": "catalog:", - "@effect/platform-bun": "catalog:", "@effect/vitest": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", From a3a4a2233b58ed414feb20d6a2e6bd7f19b69a4a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 19:28:39 -0700 Subject: [PATCH 41/47] Refactor websocket native API around typed RPC client - Split transport calls into a typed RPC client - Move cached server/config event state into Effect atoms - Update websocket tests for the new client API --- apps/web/package.json | 1 + apps/web/src/wsNativeApi.test.ts | 100 +++++++-- apps/web/src/wsNativeApi.ts | 346 +++++++------------------------ apps/web/src/wsNativeApiState.ts | 189 +++++++++++++++++ apps/web/src/wsRpcClient.ts | 179 ++++++++++++++++ apps/web/src/wsTransport.test.ts | 35 ++-- apps/web/src/wsTransport.ts | 62 ++---- bun.lock | 4 + package.json | 1 + 9 files changed, 580 insertions(+), 337 deletions(-) create mode 100644 apps/web/src/wsNativeApiState.ts create mode 100644 apps/web/src/wsRpcClient.ts diff --git a/apps/web/package.json b/apps/web/package.json index bd0fb9e0ac..499943c3f0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,6 +19,7 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@effect/atom-react": "catalog:", "@formkit/auto-animate": "^0.9.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 142eaca14d..e9d15a2bea 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -26,9 +26,8 @@ const showContextMenuFallbackMock = ) => Promise >(); const streamListeners = new Map void>>(); -const subscribeMock = vi.fn< - (method: string, params: unknown, listener: (event: unknown) => void) => () => void ->((method, _params, listener) => { + +function registerStreamListener(method: string, listener: (event: unknown) => void) { const listeners = streamListeners.get(method) ?? new Set<(event: unknown) => void>(); listeners.add(listener); streamListeners.set(method, listeners); @@ -38,12 +37,91 @@ const subscribeMock = vi.fn< streamListeners.delete(method); } }; +} + +const unaryMethodClient = { + [WS_METHODS.serverGetConfig]: (payload: unknown) => + requestMock(WS_METHODS.serverGetConfig, payload), + [WS_METHODS.serverRefreshProviders]: (payload: unknown) => + requestMock(WS_METHODS.serverRefreshProviders, payload), + [WS_METHODS.serverUpsertKeybinding]: (payload: unknown) => + requestMock(WS_METHODS.serverUpsertKeybinding, payload), + [WS_METHODS.serverGetSettings]: (payload: unknown) => + requestMock(WS_METHODS.serverGetSettings, payload), + [WS_METHODS.serverUpdateSettings]: (payload: unknown) => + requestMock(WS_METHODS.serverUpdateSettings, payload), + [WS_METHODS.projectsSearchEntries]: (payload: unknown) => + requestMock(WS_METHODS.projectsSearchEntries, payload), + [WS_METHODS.projectsWriteFile]: (payload: unknown) => + requestMock(WS_METHODS.projectsWriteFile, payload), + [WS_METHODS.shellOpenInEditor]: (payload: unknown) => + requestMock(WS_METHODS.shellOpenInEditor, payload), + [WS_METHODS.gitPull]: (payload: unknown) => requestMock(WS_METHODS.gitPull, payload), + [WS_METHODS.gitStatus]: (payload: unknown) => requestMock(WS_METHODS.gitStatus, payload), + [WS_METHODS.gitRunStackedAction]: (payload: unknown) => + requestMock(WS_METHODS.gitRunStackedAction, payload), + [WS_METHODS.gitResolvePullRequest]: (payload: unknown) => + requestMock(WS_METHODS.gitResolvePullRequest, payload), + [WS_METHODS.gitPreparePullRequestThread]: (payload: unknown) => + requestMock(WS_METHODS.gitPreparePullRequestThread, payload), + [WS_METHODS.gitListBranches]: (payload: unknown) => + requestMock(WS_METHODS.gitListBranches, payload), + [WS_METHODS.gitCreateWorktree]: (payload: unknown) => + requestMock(WS_METHODS.gitCreateWorktree, payload), + [WS_METHODS.gitRemoveWorktree]: (payload: unknown) => + requestMock(WS_METHODS.gitRemoveWorktree, payload), + [WS_METHODS.gitCreateBranch]: (payload: unknown) => + requestMock(WS_METHODS.gitCreateBranch, payload), + [WS_METHODS.gitCheckout]: (payload: unknown) => requestMock(WS_METHODS.gitCheckout, payload), + [WS_METHODS.gitInit]: (payload: unknown) => requestMock(WS_METHODS.gitInit, payload), + [WS_METHODS.terminalOpen]: (payload: unknown) => requestMock(WS_METHODS.terminalOpen, payload), + [WS_METHODS.terminalWrite]: (payload: unknown) => requestMock(WS_METHODS.terminalWrite, payload), + [WS_METHODS.terminalResize]: (payload: unknown) => + requestMock(WS_METHODS.terminalResize, payload), + [WS_METHODS.terminalClear]: (payload: unknown) => requestMock(WS_METHODS.terminalClear, payload), + [WS_METHODS.terminalRestart]: (payload: unknown) => + requestMock(WS_METHODS.terminalRestart, payload), + [WS_METHODS.terminalClose]: (payload: unknown) => requestMock(WS_METHODS.terminalClose, payload), + [ORCHESTRATION_WS_METHODS.getSnapshot]: (payload: unknown) => + requestMock(ORCHESTRATION_WS_METHODS.getSnapshot, payload), + [ORCHESTRATION_WS_METHODS.dispatchCommand]: (payload: unknown) => + requestMock(ORCHESTRATION_WS_METHODS.dispatchCommand, payload), + [ORCHESTRATION_WS_METHODS.getTurnDiff]: (payload: unknown) => + requestMock(ORCHESTRATION_WS_METHODS.getTurnDiff, payload), + [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (payload: unknown) => + requestMock(ORCHESTRATION_WS_METHODS.getFullThreadDiff, payload), + [ORCHESTRATION_WS_METHODS.replayEvents]: (payload: unknown) => + requestMock(ORCHESTRATION_WS_METHODS.replayEvents, payload), +}; + +const streamMethodClient = { + [WS_METHODS.subscribeServerLifecycle]: (_payload: unknown) => WS_METHODS.subscribeServerLifecycle, + [WS_METHODS.subscribeServerConfig]: (_payload: unknown) => WS_METHODS.subscribeServerConfig, + [WS_METHODS.subscribeGitActionProgress]: (_payload: unknown) => + WS_METHODS.subscribeGitActionProgress, + [WS_METHODS.subscribeTerminalEvents]: (_payload: unknown) => WS_METHODS.subscribeTerminalEvents, + [WS_METHODS.subscribeOrchestrationDomainEvents]: (_payload: unknown) => + WS_METHODS.subscribeOrchestrationDomainEvents, +}; + +const subscribeMock = vi.fn< + ( + connect: (client: typeof streamMethodClient) => unknown, + listener: (event: unknown) => void, + ) => () => void +>((connect, listener) => { + const method = connect(streamMethodClient); + if (typeof method !== "string") { + throw new Error("Expected mocked stream method tag"); + } + return registerStreamListener(method, listener); }); vi.mock("./wsTransport", () => { return { WsTransport: class MockWsTransport { - request = requestMock; + request = (execute: (client: typeof unaryMethodClient) => Promise) => + execute(unaryMethodClient); subscribe = subscribeMock; dispose() {} }, @@ -468,15 +546,11 @@ describe("wsNativeApi", () => { action: "commit", }); - expect(requestMock).toHaveBeenCalledWith( - WS_METHODS.gitRunStackedAction, - { - actionId: "action-1", - cwd: "/repo", - action: "commit", - }, - { timeoutMs: null }, - ); + expect(requestMock).toHaveBeenCalledWith(WS_METHODS.gitRunStackedAction, { + actionId: "action-1", + cwd: "/repo", + action: "commit", + }); }); it("forwards full-thread diff requests to the orchestration RPC", async () => { diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 11f01259a6..2020e9857b 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -1,165 +1,39 @@ import { type GitActionProgressEvent, - ORCHESTRATION_WS_METHODS, type ContextMenuItem, type NativeApi, - type ServerConfig, - type ServerConfigStreamEvent, - type ServerConfigUpdatedPayload, - type ServerLifecycleStreamEvent, type ServerProviderUpdatedPayload, - type ServerSettings, - WS_METHODS, type WsWelcomePayload, } from "@t3tools/contracts"; import { showContextMenuFallback } from "./contextMenuFallback"; -import { WsTransport } from "./wsTransport"; +import { createWsRpcClient, type WsRpcClient } from "./wsRpcClient"; +import { ServerConfigUpdateSource, WsNativeApiState } from "./wsNativeApiState"; -let instance: { api: NativeApi; transport: WsTransport } | null = null; -const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); -const gitActionProgressListeners = new Set<(payload: GitActionProgressEvent) => void>(); -const providersUpdatedListeners = new Set<(payload: ServerProviderUpdatedPayload) => void>(); - -export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"]; - -interface ServerConfigUpdatedNotification { - readonly payload: ServerConfigUpdatedPayload; - readonly source: ServerConfigUpdateSource; -} - -const serverConfigUpdatedListeners = new Set< - (payload: ServerConfigUpdatedPayload, source: ServerConfigUpdateSource) => void ->(); - -let latestWelcomePayload: WsWelcomePayload | null = null; -let latestServerConfig: ServerConfig | null = null; -let latestServerConfigUpdated: ServerConfigUpdatedNotification | null = null; -let latestProvidersUpdated: ServerProviderUpdatedPayload | null = null; +let instance: { api: NativeApi; rpcClient: WsRpcClient; cleanups: Array<() => void> } | null = null; +let state = new WsNativeApiState(); export function __resetWsNativeApiForTests() { if (instance) { - instance.transport.dispose(); - instance = null; - } - welcomeListeners.clear(); - gitActionProgressListeners.clear(); - providersUpdatedListeners.clear(); - serverConfigUpdatedListeners.clear(); - latestWelcomePayload = null; - latestServerConfig = null; - latestServerConfigUpdated = null; - latestProvidersUpdated = null; -} - -function emitWelcome(payload: WsWelcomePayload) { - latestWelcomePayload = payload; - for (const listener of welcomeListeners) { - try { - listener(payload); - } catch { - // Swallow listener errors. - } - } -} - -function emitProvidersUpdated(payload: ServerProviderUpdatedPayload) { - latestProvidersUpdated = payload; - for (const listener of providersUpdatedListeners) { - try { - listener(payload); - } catch { - // Swallow listener errors. - } - } -} - -function resolveServerConfig(config: ServerConfig) { - latestServerConfig = config; -} - -function emitServerConfigUpdated( - payload: ServerConfigUpdatedPayload, - source: ServerConfigUpdateSource, -) { - latestServerConfigUpdated = { payload, source }; - for (const listener of serverConfigUpdatedListeners) { - try { - listener(payload, source); - } catch { - // Swallow listener errors. - } - } -} - -function toServerConfigUpdatedPayload(config: ServerConfig): ServerConfigUpdatedPayload { - return { - issues: config.issues, - providers: config.providers, - settings: config.settings, - }; -} - -function applyServerConfigEvent(event: ServerConfigStreamEvent) { - switch (event.type) { - case "snapshot": { - resolveServerConfig(event.config); - emitProvidersUpdated({ providers: event.config.providers }); - emitServerConfigUpdated(toServerConfigUpdatedPayload(event.config), event.type); - return; - } - case "keybindingsUpdated": { - if (!latestServerConfig) { - return; - } - const nextConfig = { - ...latestServerConfig, - issues: event.payload.issues, - } satisfies ServerConfig; - resolveServerConfig(nextConfig); - emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), event.type); - return; - } - case "providerStatuses": { - if (!latestServerConfig) { - return; - } - const nextConfig = { - ...latestServerConfig, - providers: event.payload.providers, - } satisfies ServerConfig; - resolveServerConfig(nextConfig); - emitProvidersUpdated({ providers: nextConfig.providers }); - emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), event.type); - return; - } - case "settingsUpdated": { - if (!latestServerConfig) { - return; - } - const nextConfig = { - ...latestServerConfig, - settings: event.payload.settings, - } satisfies ServerConfig; - resolveServerConfig(nextConfig); - emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), event.type); - return; + for (const cleanup of instance.cleanups) { + cleanup(); } + instance.rpcClient.dispose(); + instance = null; } + state.dispose(); + state = new WsNativeApiState(); } -async function getServerConfigSnapshot(transport: WsTransport): Promise { +async function getServerConfigSnapshot(rpcClient: WsRpcClient) { + const latestServerConfig = state.getServerConfig(); if (latestServerConfig) { return latestServerConfig; } - const config = await transport.request(WS_METHODS.serverGetConfig, {}); - if (!latestServerConfig) { - resolveServerConfig(config); - emitProvidersUpdated({ providers: config.providers }); - emitServerConfigUpdated(toServerConfigUpdatedPayload(config), "snapshot"); - } - return latestServerConfig ?? config; + const config = await rpcClient.server.getConfig(); + state.setServerConfigSnapshot(config); + return state.getServerConfig() ?? config; } /** @@ -167,19 +41,7 @@ async function getServerConfigSnapshot(transport: WsTransport): Promise void): () => void { - welcomeListeners.add(listener); - - if (latestWelcomePayload) { - try { - listener(latestWelcomePayload); - } catch { - // Swallow listener errors. - } - } - - return () => { - welcomeListeners.delete(listener); - }; + return state.onWelcome(listener); } /** @@ -187,39 +49,18 @@ export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): * late subscribers to avoid missing config validation feedback. */ export function onServerConfigUpdated( - listener: (payload: ServerConfigUpdatedPayload, source: ServerConfigUpdateSource) => void, + listener: ( + payload: import("@t3tools/contracts").ServerConfigUpdatedPayload, + source: ServerConfigUpdateSource, + ) => void, ): () => void { - serverConfigUpdatedListeners.add(listener); - - if (latestServerConfigUpdated) { - try { - listener(latestServerConfigUpdated.payload, latestServerConfigUpdated.source); - } catch { - // Swallow listener errors. - } - } - - return () => { - serverConfigUpdatedListeners.delete(listener); - }; + return state.onServerConfigUpdated(listener); } export function onServerProvidersUpdated( listener: (payload: ServerProviderUpdatedPayload) => void, ): () => void { - providersUpdatedListeners.add(listener); - - if (latestProvidersUpdated) { - try { - listener(latestProvidersUpdated); - } catch { - // Swallow listener errors. - } - } - - return () => { - providersUpdatedListeners.delete(listener); - }; + return state.onProvidersUpdated(listener); } export function createWsNativeApi(): NativeApi { @@ -227,33 +68,20 @@ export function createWsNativeApi(): NativeApi { return instance.api; } - const transport = new WsTransport(); - - transport.subscribe( - WS_METHODS.subscribeServerLifecycle, - {}, - (event: ServerLifecycleStreamEvent) => { + const rpcClient = createWsRpcClient(); + const cleanups = [ + rpcClient.server.subscribeLifecycle((event) => { if (event.type === "welcome") { - emitWelcome(event.payload); - } - }, - ); - transport.subscribe(WS_METHODS.subscribeServerConfig, {}, (event: ServerConfigStreamEvent) => { - applyServerConfigEvent(event); - }); - transport.subscribe( - WS_METHODS.subscribeGitActionProgress, - {}, - (event: GitActionProgressEvent) => { - for (const listener of gitActionProgressListeners) { - try { - listener(event); - } catch { - // Swallow listener errors. - } + state.emitWelcome(event.payload); } - }, - ); + }), + rpcClient.server.subscribeConfig((event) => { + state.applyServerConfigEvent(event); + }), + rpcClient.git.subscribeActionProgress((event: GitActionProgressEvent) => { + state.emitGitActionProgress(event); + }), + ]; const api: NativeApi = { dialogs: { @@ -269,21 +97,20 @@ export function createWsNativeApi(): NativeApi { }, }, terminal: { - open: (input) => transport.request(WS_METHODS.terminalOpen, input), - write: (input) => transport.request(WS_METHODS.terminalWrite, input), - resize: (input) => transport.request(WS_METHODS.terminalResize, input), - clear: (input) => transport.request(WS_METHODS.terminalClear, input), - restart: (input) => transport.request(WS_METHODS.terminalRestart, input), - close: (input) => transport.request(WS_METHODS.terminalClose, input), - onEvent: (callback) => transport.subscribe(WS_METHODS.subscribeTerminalEvents, {}, callback), + open: (input) => rpcClient.terminal.open(input as never), + write: (input) => rpcClient.terminal.write(input as never), + resize: (input) => rpcClient.terminal.resize(input as never), + clear: (input) => rpcClient.terminal.clear(input as never), + restart: (input) => rpcClient.terminal.restart(input as never), + close: (input) => rpcClient.terminal.close(input as never), + onEvent: (callback) => rpcClient.terminal.onEvent(callback), }, projects: { - searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), - writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input), + searchEntries: rpcClient.projects.searchEntries, + writeFile: rpcClient.projects.writeFile, }, shell: { - openInEditor: (cwd, editor) => - transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }), + openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }), openExternal: async (url) => { if (window.desktopBridge) { const opened = await window.desktopBridge.openExternal(url); @@ -297,25 +124,18 @@ export function createWsNativeApi(): NativeApi { }, }, git: { - pull: (input) => transport.request(WS_METHODS.gitPull, input), - status: (input) => transport.request(WS_METHODS.gitStatus, input), - runStackedAction: (input) => - transport.request(WS_METHODS.gitRunStackedAction, input, { timeoutMs: null }), - listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), - createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), - removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input), - createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input), - checkout: (input) => transport.request(WS_METHODS.gitCheckout, input), - init: (input) => transport.request(WS_METHODS.gitInit, input), - resolvePullRequest: (input) => transport.request(WS_METHODS.gitResolvePullRequest, input), - preparePullRequestThread: (input) => - transport.request(WS_METHODS.gitPreparePullRequestThread, input), - onActionProgress: (callback) => { - gitActionProgressListeners.add(callback); - return () => { - gitActionProgressListeners.delete(callback); - }; - }, + pull: rpcClient.git.pull, + status: rpcClient.git.status, + runStackedAction: rpcClient.git.runStackedAction, + listBranches: rpcClient.git.listBranches, + createWorktree: rpcClient.git.createWorktree, + removeWorktree: rpcClient.git.removeWorktree, + createBranch: rpcClient.git.createBranch, + checkout: rpcClient.git.checkout, + init: rpcClient.git.init, + resolvePullRequest: rpcClient.git.resolvePullRequest, + preparePullRequestThread: rpcClient.git.preparePullRequestThread, + onActionProgress: (callback) => state.onGitActionProgress(callback), }, contextMenu: { show: async ( @@ -329,47 +149,33 @@ export function createWsNativeApi(): NativeApi { }, }, server: { - getConfig: () => getServerConfigSnapshot(transport), + getConfig: () => getServerConfigSnapshot(rpcClient), refreshProviders: () => - transport - .request(WS_METHODS.serverRefreshProviders, {}) - .then((payload) => { - emitProvidersUpdated(payload); - applyServerConfigEvent({ - version: 1, - type: "providerStatuses", - payload, - }); - return payload; - }), - upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), - getSettings: () => transport.request(WS_METHODS.serverGetSettings, {}), + rpcClient.server.refreshProviders().then((payload) => { + state.applyProvidersUpdated(payload); + return payload; + }), + upsertKeybinding: rpcClient.server.upsertKeybinding, + getSettings: rpcClient.server.getSettings, updateSettings: (patch) => - transport - .request(WS_METHODS.serverUpdateSettings, { patch }) - .then((settings) => { - applyServerConfigEvent({ - version: 1, - type: "settingsUpdated", - payload: { settings }, - }); - return settings; - }), + rpcClient.server.updateSettings(patch).then((settings) => { + state.applySettingsUpdated(settings); + return settings; + }), }, orchestration: { - getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot, {}), - dispatchCommand: (command) => - transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, command), - getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input), - getFullThreadDiff: (input) => - transport.request(ORCHESTRATION_WS_METHODS.getFullThreadDiff, input), + getSnapshot: rpcClient.orchestration.getSnapshot, + dispatchCommand: rpcClient.orchestration.dispatchCommand, + getTurnDiff: rpcClient.orchestration.getTurnDiff, + getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, replayEvents: (fromSequenceExclusive) => - transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }), - onDomainEvent: (callback) => - transport.subscribe(WS_METHODS.subscribeOrchestrationDomainEvents, {}, callback), + rpcClient.orchestration + .replayEvents({ fromSequenceExclusive }) + .then((events) => [...events]), + onDomainEvent: (callback) => rpcClient.orchestration.onDomainEvent(callback), }, }; - instance = { api, transport }; + instance = { api, rpcClient, cleanups }; return api; } diff --git a/apps/web/src/wsNativeApiState.ts b/apps/web/src/wsNativeApiState.ts new file mode 100644 index 0000000000..58bf65ae1f --- /dev/null +++ b/apps/web/src/wsNativeApiState.ts @@ -0,0 +1,189 @@ +import { + type GitActionProgressEvent, + type ServerConfig, + type ServerConfigStreamEvent, + type ServerConfigUpdatedPayload, + type ServerProviderUpdatedPayload, + type ServerSettings, + type WsWelcomePayload, +} from "@t3tools/contracts"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; + +export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"]; + +interface ServerConfigUpdatedNotification { + readonly payload: ServerConfigUpdatedPayload; + readonly source: ServerConfigUpdateSource; +} + +interface GitActionProgressNotification { + readonly event: GitActionProgressEvent; +} + +function makeStateAtom(label: string, initialValue: A) { + return Atom.make(initialValue).pipe(Atom.keepAlive, Atom.withLabel(label)); +} + +function toServerConfigUpdatedPayload(config: ServerConfig): ServerConfigUpdatedPayload { + return { + issues: config.issues, + providers: config.providers, + settings: config.settings, + }; +} + +export class WsNativeApiState { + private readonly registry = AtomRegistry.make(); + private readonly welcomeAtom = makeStateAtom("ws-server-welcome", null); + private readonly serverConfigAtom = makeStateAtom("ws-server-config", null); + private readonly serverConfigUpdatedAtom = makeStateAtom( + "ws-server-config-updated", + null, + ); + private readonly providersUpdatedAtom = makeStateAtom( + "ws-server-providers-updated", + null, + ); + private readonly gitActionProgressAtom = makeStateAtom( + "ws-git-action-progress", + null, + ); + + dispose() { + this.registry.dispose(); + } + + getServerConfig(): ServerConfig | null { + return this.registry.get(this.serverConfigAtom); + } + + setServerConfigSnapshot(config: ServerConfig): void { + this.resolveServerConfig(config); + this.emitProvidersUpdated({ providers: config.providers }); + this.emitServerConfigUpdated(toServerConfigUpdatedPayload(config), "snapshot"); + } + + applyServerConfigEvent(event: ServerConfigStreamEvent): void { + switch (event.type) { + case "snapshot": { + this.setServerConfigSnapshot(event.config); + return; + } + case "keybindingsUpdated": { + const latestServerConfig = this.getServerConfig(); + if (!latestServerConfig) { + return; + } + const nextConfig = { + ...latestServerConfig, + issues: event.payload.issues, + } satisfies ServerConfig; + this.resolveServerConfig(nextConfig); + this.emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), event.type); + return; + } + case "providerStatuses": { + this.applyProvidersUpdated(event.payload); + return; + } + case "settingsUpdated": { + this.applySettingsUpdated(event.payload.settings); + return; + } + } + } + + applyProvidersUpdated(payload: ServerProviderUpdatedPayload): void { + const latestServerConfig = this.getServerConfig(); + this.emitProvidersUpdated(payload); + + if (!latestServerConfig) { + return; + } + + const nextConfig = { + ...latestServerConfig, + providers: payload.providers, + } satisfies ServerConfig; + this.resolveServerConfig(nextConfig); + this.emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "providerStatuses"); + } + + applySettingsUpdated(settings: ServerSettings): void { + const latestServerConfig = this.getServerConfig(); + if (!latestServerConfig) { + return; + } + + const nextConfig = { + ...latestServerConfig, + settings, + } satisfies ServerConfig; + this.resolveServerConfig(nextConfig); + this.emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "settingsUpdated"); + } + + emitWelcome(payload: WsWelcomePayload): void { + this.registry.set(this.welcomeAtom, payload); + } + + emitGitActionProgress(event: GitActionProgressEvent): void { + this.registry.set(this.gitActionProgressAtom, { event }); + } + + onWelcome(listener: (payload: WsWelcomePayload) => void): () => void { + return this.subscribeLatest(this.welcomeAtom, listener); + } + + onServerConfigUpdated( + listener: (payload: ServerConfigUpdatedPayload, source: ServerConfigUpdateSource) => void, + ): () => void { + return this.subscribeLatest(this.serverConfigUpdatedAtom, (notification) => { + listener(notification.payload, notification.source); + }); + } + + onProvidersUpdated(listener: (payload: ServerProviderUpdatedPayload) => void): () => void { + return this.subscribeLatest(this.providersUpdatedAtom, listener); + } + + onGitActionProgress(listener: (event: GitActionProgressEvent) => void): () => void { + return this.registry.subscribe(this.gitActionProgressAtom, (notification) => { + if (!notification) { + return; + } + listener(notification.event); + }); + } + + private resolveServerConfig(config: ServerConfig): void { + this.registry.set(this.serverConfigAtom, config); + } + + private emitProvidersUpdated(payload: ServerProviderUpdatedPayload): void { + this.registry.set(this.providersUpdatedAtom, payload); + } + + private emitServerConfigUpdated( + payload: ServerConfigUpdatedPayload, + source: ServerConfigUpdateSource, + ): void { + this.registry.set(this.serverConfigUpdatedAtom, { payload, source }); + } + + private subscribeLatest( + atom: Atom.Atom, + listener: (value: NonNullable) => void, + ): () => void { + return this.registry.subscribe( + atom, + (value) => { + if (value === null) { + return; + } + listener(value as NonNullable); + }, + { immediate: true }, + ); + } +} diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts new file mode 100644 index 0000000000..3befbca02a --- /dev/null +++ b/apps/web/src/wsRpcClient.ts @@ -0,0 +1,179 @@ +import { + type GitActionProgressEvent, + type NativeApi, + ORCHESTRATION_WS_METHODS, + type ServerSettingsPatch, + WS_METHODS, +} from "@t3tools/contracts"; +import { Effect, Stream } from "effect"; + +import { type WsRpcProtocolClient, WsTransport } from "./wsTransport"; + +type RpcTag = keyof WsRpcProtocolClient & string; +type RpcMethod = WsRpcProtocolClient[TTag]; +type RpcInput = Parameters>[0]; + +type RpcUnaryMethod = + RpcMethod extends (input: any, options?: any) => Effect.Effect + ? (input: RpcInput) => Promise + : never; + +type RpcUnaryNoArgMethod = + RpcMethod extends (input: any, options?: any) => Effect.Effect + ? () => Promise + : never; + +type RpcStreamMethod = + RpcMethod extends (input: any, options?: any) => Stream.Stream + ? (listener: (event: TEvent) => void) => () => void + : never; + +export interface WsRpcClient { + readonly dispose: () => void; + readonly terminal: { + readonly open: RpcUnaryMethod; + readonly write: RpcUnaryMethod; + readonly resize: RpcUnaryMethod; + readonly clear: RpcUnaryMethod; + readonly restart: RpcUnaryMethod; + readonly close: RpcUnaryMethod; + readonly onEvent: RpcStreamMethod; + }; + readonly projects: { + readonly searchEntries: RpcUnaryMethod; + readonly writeFile: RpcUnaryMethod; + }; + readonly shell: { + readonly openInEditor: (input: { + readonly cwd: Parameters[0]; + readonly editor: Parameters[1]; + }) => ReturnType; + }; + readonly git: { + readonly pull: RpcUnaryMethod; + readonly status: RpcUnaryMethod; + readonly runStackedAction: RpcUnaryMethod; + readonly listBranches: RpcUnaryMethod; + readonly createWorktree: RpcUnaryMethod; + readonly removeWorktree: RpcUnaryMethod; + readonly createBranch: RpcUnaryMethod; + readonly checkout: RpcUnaryMethod; + readonly init: RpcUnaryMethod; + readonly resolvePullRequest: RpcUnaryMethod; + readonly preparePullRequestThread: RpcUnaryMethod< + typeof WS_METHODS.gitPreparePullRequestThread + >; + readonly onActionProgress: RpcStreamMethod; + readonly subscribeActionProgress: ( + listener: (event: GitActionProgressEvent) => void, + ) => () => void; + }; + readonly server: { + readonly getConfig: RpcUnaryNoArgMethod; + readonly refreshProviders: RpcUnaryNoArgMethod; + readonly upsertKeybinding: RpcUnaryMethod; + readonly getSettings: RpcUnaryNoArgMethod; + readonly updateSettings: ( + patch: ServerSettingsPatch, + ) => ReturnType>; + readonly subscribeConfig: RpcStreamMethod; + readonly subscribeLifecycle: RpcStreamMethod; + }; + readonly orchestration: { + readonly getSnapshot: RpcUnaryNoArgMethod; + readonly dispatchCommand: RpcUnaryMethod; + readonly getTurnDiff: RpcUnaryMethod; + readonly getFullThreadDiff: RpcUnaryMethod; + readonly replayEvents: RpcUnaryMethod; + readonly onDomainEvent: RpcStreamMethod; + }; +} + +export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { + return { + dispose: () => transport.dispose(), + terminal: { + open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)), + write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)), + resize: (input) => transport.request((client) => client[WS_METHODS.terminalResize](input)), + clear: (input) => transport.request((client) => client[WS_METHODS.terminalClear](input)), + restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)), + close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)), + onEvent: (listener) => + transport.subscribe((client) => client[WS_METHODS.subscribeTerminalEvents]({}), listener), + }, + projects: { + searchEntries: (input) => + transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), + writeFile: (input) => + transport.request((client) => client[WS_METHODS.projectsWriteFile](input)), + }, + shell: { + openInEditor: (input) => + transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), + }, + git: { + pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)), + status: (input) => transport.request((client) => client[WS_METHODS.gitStatus](input)), + runStackedAction: (input) => + transport.request((client) => client[WS_METHODS.gitRunStackedAction](input)), + listBranches: (input) => + transport.request((client) => client[WS_METHODS.gitListBranches](input)), + createWorktree: (input) => + transport.request((client) => client[WS_METHODS.gitCreateWorktree](input)), + removeWorktree: (input) => + transport.request((client) => client[WS_METHODS.gitRemoveWorktree](input)), + createBranch: (input) => + transport.request((client) => client[WS_METHODS.gitCreateBranch](input)), + checkout: (input) => transport.request((client) => client[WS_METHODS.gitCheckout](input)), + init: (input) => transport.request((client) => client[WS_METHODS.gitInit](input)), + resolvePullRequest: (input) => + transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)), + preparePullRequestThread: (input) => + transport.request((client) => client[WS_METHODS.gitPreparePullRequestThread](input)), + onActionProgress: (listener) => + transport.subscribe( + (client) => client[WS_METHODS.subscribeGitActionProgress]({}), + listener, + ), + subscribeActionProgress: (listener) => + transport.subscribe( + (client) => client[WS_METHODS.subscribeGitActionProgress]({}), + listener, + ), + }, + server: { + getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), + refreshProviders: () => + transport.request((client) => client[WS_METHODS.serverRefreshProviders]({})), + upsertKeybinding: (input) => + transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), + getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), + updateSettings: (patch) => + transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), + subscribeConfig: (listener) => + transport.subscribe((client) => client[WS_METHODS.subscribeServerConfig]({}), listener), + subscribeLifecycle: (listener) => + transport.subscribe((client) => client[WS_METHODS.subscribeServerLifecycle]({}), listener), + }, + orchestration: { + getSnapshot: () => + transport.request((client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})), + dispatchCommand: (input) => + transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), + getTurnDiff: (input) => + transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)), + getFullThreadDiff: (input) => + transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)), + replayEvents: (input) => + transport + .request((client) => client[ORCHESTRATION_WS_METHODS.replayEvents](input)) + .then((events) => [...events]), + onDomainEvent: (listener) => + transport.subscribe( + (client) => client[WS_METHODS.subscribeOrchestrationDomainEvents]({}), + listener, + ), + }, + }; +} diff --git a/apps/web/src/wsTransport.test.ts b/apps/web/src/wsTransport.test.ts index e21e336c8b..993be6d7c0 100644 --- a/apps/web/src/wsTransport.test.ts +++ b/apps/web/src/wsTransport.test.ts @@ -2,6 +2,7 @@ import { WS_METHODS } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { WsTransport } from "./wsTransport"; +import { Option } from "effect"; type WsEventType = "open" | "message" | "close" | "error"; type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; @@ -121,10 +122,12 @@ describe("WsTransport", () => { it("sends unary RPC requests and resolves successful exits", async () => { const transport = new WsTransport("ws://localhost:3020"); - const requestPromise = transport.request(WS_METHODS.serverUpsertKeybinding, { - command: "terminal.toggle", - key: "ctrl+k", - }); + const requestPromise = transport.request((client) => + client[WS_METHODS.serverUpsertKeybinding]({ + command: "terminal.toggle", + key: "ctrl+k", + }), + ); await waitFor(() => { expect(sockets).toHaveLength(1); @@ -178,7 +181,10 @@ describe("WsTransport", () => { const transport = new WsTransport("ws://localhost:3020"); const listener = vi.fn(); - const unsubscribe = transport.subscribe(WS_METHODS.subscribeServerLifecycle, {}, listener); + const unsubscribe = transport.subscribe( + (client) => client[WS_METHODS.subscribeServerLifecycle]({}), + listener, + ); await waitFor(() => { expect(sockets).toHaveLength(1); }); @@ -223,7 +229,10 @@ describe("WsTransport", () => { const transport = new WsTransport("ws://localhost:3020"); const listener = vi.fn(); - const unsubscribe = transport.subscribe(WS_METHODS.subscribeServerLifecycle, {}, listener); + const unsubscribe = transport.subscribe( + (client) => client[WS_METHODS.subscribeServerLifecycle]({}), + listener, + ); await waitFor(() => { expect(sockets).toHaveLength(1); }); @@ -317,13 +326,13 @@ describe("WsTransport", () => { socket.open(); const requestPromise = transport.request( - WS_METHODS.gitRunStackedAction, - { - actionId: "action-1", - cwd: "/repo", - action: "commit", - }, - { timeoutMs: null }, + (client) => + client[WS_METHODS.gitRunStackedAction]({ + actionId: "action-1", + cwd: "/repo", + action: "commit", + }), + { timeout: Option.none() }, ); await waitFor(() => { diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index ff9e63c90c..7f89d647da 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -1,4 +1,4 @@ -import { Data, Effect, Exit, Layer, ManagedRuntime, Scope, Stream } from "effect"; +import { Duration, Effect, Exit, Layer, ManagedRuntime, Option, Scope, Stream } from "effect"; import { WsRpcGroup } from "@t3tools/contracts"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; @@ -7,22 +7,18 @@ import { resolveServerUrl } from "./lib/utils"; const makeWsRpcClient = RpcClient.make(WsRpcGroup); type RpcClientFactory = typeof makeWsRpcClient; -type WsRpcClient = RpcClientFactory extends Effect.Effect ? Client : never; -type WsRpcClientMethods = Record unknown>; +export type WsRpcProtocolClient = + RpcClientFactory extends Effect.Effect ? Client : never; interface SubscribeOptions { - readonly retryDelayMs?: number; + readonly retryDelay?: Duration.Input; } interface RequestOptions { - readonly timeoutMs?: number | null; + readonly timeout?: Option.Option; } -const DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS = 250; - -class WsTransportStreamMethodError extends Data.TaggedError("WsTransportStreamMethodError")<{ - readonly method: string; -}> {} +const DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS = Duration.millis(250); function asError(value: unknown, fallback: string): Error { if (value instanceof Error) { @@ -41,7 +37,7 @@ function formatErrorMessage(error: unknown): string { export class WsTransport { private readonly runtime: ManagedRuntime.ManagedRuntime; private readonly clientScope: Scope.Closeable; - private readonly clientPromise: Promise; + private readonly clientPromise: Promise; private disposed = false; constructor(url?: string) { @@ -62,36 +58,25 @@ export class WsTransport { this.clientPromise = this.runtime.runPromise(Scope.provide(this.clientScope)(makeWsRpcClient)); } - async request( - method: string, - params?: unknown, + async request( + execute: (client: WsRpcProtocolClient) => Effect.Effect, _options?: RequestOptions, - ): Promise { + ): Promise { if (this.disposed) { throw new Error("Transport disposed"); } - if (typeof method !== "string" || method.length === 0) { - throw new Error("Request method is required"); - } try { const client = await this.clientPromise; - const handler = (client as WsRpcClientMethods)[method]; - if (typeof handler !== "function") { - throw new Error(`Unknown RPC method: ${method}`); - } - return (await Effect.runPromise( - Effect.suspend(() => handler(params ?? {}) as Effect.Effect), - )) as T; + return await Effect.runPromise(Effect.suspend(() => execute(client))); } catch (error) { - throw asError(error, `Request failed: ${method}`); + throw asError(error, "Request failed"); } } - subscribe( - method: string, - params: unknown, - listener: (value: T) => void, + subscribe( + connect: (client: WsRpcProtocolClient) => Stream.Stream, + listener: (value: TValue) => void, options?: SubscribeOptions, ): () => void { if (this.disposed) { @@ -99,15 +84,11 @@ export class WsTransport { } let active = true; - const retryDelayMs = options?.retryDelayMs ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS; + const retryDelayMs = options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS; const cancel = Effect.runCallback( Effect.promise(() => this.clientPromise).pipe( - Effect.flatMap((client) => { - const handler = (client as WsRpcClientMethods)[method]; - if (typeof handler !== "function") { - return Effect.fail(new WsTransportStreamMethodError({ method })); - } - return Stream.runForEach(handler(params ?? {}) as Stream.Stream, (value) => + Effect.flatMap((client) => + Stream.runForEach(connect(client), (value) => Effect.sync(() => { if (!active) { return; @@ -118,18 +99,17 @@ export class WsTransport { // Swallow listener errors so the stream stays live. } }), - ); - }), + ), + ), Effect.catch((error) => { if (!active || this.disposed) { return Effect.interrupt; } return Effect.sync(() => { console.warn("WebSocket RPC subscription disconnected", { - method, error: formatErrorMessage(error), }); - }).pipe(Effect.andThen(Effect.sleep(`${retryDelayMs} millis`))); + }).pipe(Effect.andThen(Effect.sleep(retryDelayMs))); }), Effect.forever, ), diff --git a/bun.lock b/bun.lock index ce777ac064..33d4d1603c 100644 --- a/bun.lock +++ b/bun.lock @@ -81,6 +81,7 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@effect/atom-react": "catalog:", "@formkit/auto-animate": "^0.9.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", @@ -177,6 +178,7 @@ "vite": "^8.0.0", }, "catalog": { + "@effect/atom-react": "4.0.0-beta.43", "@effect/language-service": "0.84.2", "@effect/platform-bun": "4.0.0-beta.43", "@effect/platform-node": "4.0.0-beta.43", @@ -270,6 +272,8 @@ "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@effect/atom-react": ["@effect/atom-react@4.0.0-beta.43", "", { "peerDependencies": { "effect": "^4.0.0-beta.43", "react": "^19.2.4", "scheduler": "*" } }, "sha512-xSrRbGXuo4d0g4ph66TQST1GNSjtQZrZj8V7OiAQFuzMYcZ0kwRIPUUFwOBtbWHK43/zNENdNWOnlXh/iYM1dw=="], + "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="], "@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.43", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-nMZ9JsD6CzJNQ+5pDUFbPw7PSZdQdTQ092MbYrocVtvlf6qEFU/hji3ITvRIOX7eabyQ8AUyp55qFPQUeq+GIA=="], diff --git a/package.json b/package.json index b5576c59ea..a26a359c03 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ ], "catalog": { "effect": "4.0.0-beta.43", + "@effect/atom-react": "4.0.0-beta.43", "@effect/platform-bun": "4.0.0-beta.43", "@effect/platform-node": "4.0.0-beta.43", "@effect/sql-sqlite-bun": "4.0.0-beta.43", From 6cb183fc6547967f46fe4b1ff06748909b7744fb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 20:24:43 -0700 Subject: [PATCH 42/47] Move server config state to ws native atoms - Replace React Query server config reads with ws-backed atoms - Route welcome and config update events through native API state - Update settings and tests for the new subscription flow --- apps/web/src/components/ChatView.tsx | 21 +- apps/web/src/components/Sidebar.tsx | 12 +- .../components/settings/SettingsPanels.tsx | 21 +- apps/web/src/hooks/useSettings.ts | 27 +- apps/web/src/lib/serverReactQuery.ts | 24 -- apps/web/src/routes/__root.tsx | 209 ++++++++------ apps/web/src/routes/_chat.tsx | 9 +- apps/web/src/wsNativeApi.test.ts | 229 +++++++-------- apps/web/src/wsNativeApi.ts | 44 +-- apps/web/src/wsNativeApiAtoms.tsx | 112 ++++++++ apps/web/src/wsNativeApiState.ts | 270 +++++++++--------- 11 files changed, 534 insertions(+), 444 deletions(-) delete mode 100644 apps/web/src/lib/serverReactQuery.ts create mode 100644 apps/web/src/wsNativeApiAtoms.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7562f845e2..7f83c94cba 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -11,11 +11,9 @@ import { type ProviderApprovalDecision, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, - type ResolvedKeybindingsConfig, type ServerProvider, type ThreadId, type TurnId, - type EditorId, type KeybindingCommand, OrchestrationThreadActivity, ProviderInteractionMode, @@ -30,7 +28,6 @@ import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; -import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { @@ -190,15 +187,18 @@ import { waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { + useServerAvailableEditors, + useServerConfig, + useServerKeybindings, +} from "~/wsNativeApiAtoms"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; -const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; -const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; @@ -785,8 +785,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const lockedProvider: ProviderKind | null = hasThreadStarted ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) : null; - const serverConfigQuery = useQuery(serverConfigQueryOptions()); - const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDERS; + const serverConfig = useServerConfig(); + const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const unlockedSelectedProvider = resolveSelectableProvider( providerStatuses, selectedProviderByThreadId ?? threadProvider ?? "codex", @@ -1171,8 +1171,8 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); - const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; - const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; + const keybindings = useServerKeybindings(); + const availableEditors = useServerAvailableEditors(); const modelOptionsByProvider = useMemo( () => ({ codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], @@ -1667,10 +1667,9 @@ export default function ChatView({ threadId }: ChatViewProps) { if (isElectron && keybindingRule) { await api.server.upsertKeybinding(keybindingRule); - await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); } }, - [queryClient], + [], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput) => { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index efa5124288..ea70fe4e7d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -43,9 +43,8 @@ import { ProjectId, ThreadId, type GitStatusResult, - type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; -import { useQueries, useQuery } from "@tanstack/react-query"; +import { useQueries } from "@tanstack/react-query"; import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { type SidebarProjectSortOrder, @@ -67,7 +66,6 @@ import { } from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { gitStatusQueryOptions } from "../lib/gitReactQuery"; -import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -125,9 +123,8 @@ import { import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; +import { useServerKeybindings } from "../wsNativeApiAtoms"; import type { Project, Thread } from "../types"; - -const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -469,10 +466,7 @@ export default function Sidebar() { strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), }); - const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ - ...serverConfigQueryOptions(), - select: (config) => config.keybindings, - }); + const keybindings = useServerKeybindings(); const [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index b7fde0c5f6..aaa37d221a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -9,7 +9,7 @@ import { Undo2Icon, XIcon, } from "lucide-react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PROVIDER_DISPLAY_NAMES, @@ -40,7 +40,6 @@ import { setDesktopUpdateStateQueryData, useDesktopUpdateState, } from "../../lib/desktopUpdateReactQuery"; -import { serverConfigQueryOptions, serverQueryKeys } from "../../lib/serverReactQuery"; import { MAX_CUSTOM_MODEL_LENGTH, getCustomModelOptionsByProvider, @@ -59,6 +58,11 @@ import { Switch } from "../ui/switch"; import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { ProjectFavicon } from "../ProjectFavicon"; +import { + useServerAvailableEditors, + useServerKeybindingsConfigPath, + useServerProviders, +} from "../../wsNativeApiAtoms"; const THEME_OPTIONS = [ { @@ -81,8 +85,6 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; -const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; - type InstallProviderSettings = { provider: ProviderKind; title: string; @@ -516,7 +518,6 @@ export function GeneralSettingsPanel() { const { theme, setTheme } = useTheme(); const settings = useSettings(); const { updateSettings } = useUpdateSettings(); - const serverConfigQuery = useQuery(serverConfigQueryOptions()); const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); const [openKeybindingsError, setOpenKeybindingsError] = useState(null); const [openProviderDetails, setOpenProviderDetails] = useState>({ @@ -542,7 +543,6 @@ export function GeneralSettingsPanel() { >({}); const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); const refreshingRef = useRef(false); - const queryClient = useQueryClient(); const modelListRefs = useRef>>({}); const refreshProviders = useCallback(() => { if (refreshingRef.current) return; @@ -550,7 +550,6 @@ export function GeneralSettingsPanel() { setIsRefreshingProviders(true); void ensureNativeApi() .server.refreshProviders() - .then(() => queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() })) .catch((error: unknown) => { console.warn("Failed to refresh providers", error); }) @@ -558,11 +557,11 @@ export function GeneralSettingsPanel() { refreshingRef.current = false; setIsRefreshingProviders(false); }); - }, [queryClient]); + }, []); - const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; - const availableEditors = serverConfigQuery.data?.availableEditors; - const serverProviders = serverConfigQuery.data?.providers ?? EMPTY_SERVER_PROVIDERS; + const keybindingsConfigPath = useServerKeybindingsConfigPath(); + const availableEditors = useServerAvailableEditors(); + const serverProviders = useServerProviders(); const codexHomePath = settings.providers.codex.homePath; const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 3f804bc48b..fd71f2a314 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -10,15 +10,12 @@ * store. */ import { useCallback, useMemo } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ServerSettings, ServerSettingsPatch, - ServerConfig, ModelSelection, ThreadEnvMode, } from "@t3tools/contracts"; -import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; import { type ClientSettings, ClientSettingsSchema, @@ -29,13 +26,14 @@ import { TimestampFormat, UnifiedSettings, } from "@t3tools/contracts/settings"; -import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { ensureNativeApi } from "~/nativeApi"; import { useLocalStorage } from "./useLocalStorage"; import { normalizeCustomModelSlugs } from "~/modelSelection"; import { Predicate, Schema, Struct } from "effect"; import { DeepMutable } from "effect/Types"; import { deepMerge } from "@t3tools/shared/Struct"; +import { useServerSettings } from "~/wsNativeApiAtoms"; +import { applySettingsUpdated, getServerConfig } from "~/wsNativeApiState"; const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; const OLD_SETTINGS_KEY = "t3code:app-settings:v1"; @@ -73,7 +71,7 @@ function splitPatch(patch: Partial): { export function useSettings( selector?: (s: UnifiedSettings) => T, ): T { - const { data: serverConfig } = useQuery(serverConfigQueryOptions()); + const serverSettings = useServerSettings(); const [clientSettings] = useLocalStorage( CLIENT_SETTINGS_STORAGE_KEY, DEFAULT_CLIENT_SETTINGS, @@ -82,10 +80,10 @@ export function useSettings( const merged = useMemo( () => ({ - ...(serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS), + ...serverSettings, ...clientSettings, }), - [serverConfig?.settings, clientSettings], + [clientSettings, serverSettings], ); return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]); @@ -98,7 +96,6 @@ export function useSettings( * persisted via RPC. Client keys go straight to localStorage. */ export function useUpdateSettings() { - const queryClient = useQueryClient(); const [, setClientSettings] = useLocalStorage( CLIENT_SETTINGS_STORAGE_KEY, DEFAULT_CLIENT_SETTINGS, @@ -110,14 +107,10 @@ export function useUpdateSettings() { const { serverPatch, clientPatch } = splitPatch(patch); if (Object.keys(serverPatch).length > 0) { - // Optimistic update of the React Query cache - queryClient.setQueryData(serverQueryKeys.config(), (old) => { - if (!old) return old; - return { - ...old, - settings: deepMerge(old.settings, serverPatch), - }; - }); + const currentServerConfig = getServerConfig(); + if (currentServerConfig) { + applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch)); + } // Fire-and-forget RPC — push will reconcile on success void ensureNativeApi().server.updateSettings(serverPatch); } @@ -126,7 +119,7 @@ export function useUpdateSettings() { setClientSettings((prev) => ({ ...prev, ...clientPatch })); } }, - [queryClient, setClientSettings], + [setClientSettings], ); const resetSettings = useCallback(() => { diff --git a/apps/web/src/lib/serverReactQuery.ts b/apps/web/src/lib/serverReactQuery.ts deleted file mode 100644 index 37029a3a3e..0000000000 --- a/apps/web/src/lib/serverReactQuery.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { queryOptions } from "@tanstack/react-query"; -import { ensureNativeApi } from "~/nativeApi"; - -export const serverQueryKeys = { - all: ["server"] as const, - config: () => ["server", "config"] as const, -}; - -/** - * Server config query options. - * - * `staleTime` is kept short so that push-driven `invalidateQueries` calls in - * the EventRouter always trigger a refetch, and so the query re-fetches when - * the component re-mounts (e.g. navigating away from settings and back). - */ -export function serverConfigQueryOptions() { - return queryOptions({ - queryKey: serverQueryKeys.config(), - queryFn: async () => { - const api = ensureNativeApi(); - return api.server.getConfig(); - }, - }); -} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index c78954ba83..1d03f1f11d 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,4 +1,4 @@ -import { OrchestrationEvent, ThreadId } from "@t3tools/contracts"; +import { OrchestrationEvent, ThreadId, type WsWelcomePayload } from "@t3tools/contracts"; import { Outlet, createRootRouteWithContext, @@ -6,7 +6,7 @@ import { useNavigate, useLocation, } from "@tanstack/react-router"; -import { useEffect, useRef } from "react"; +import { useEffect, useEffectEvent, useRef } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { Throttler } from "@tanstack/react-pacer"; @@ -15,7 +15,6 @@ import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; -import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { clearPromotedDraftThread, @@ -26,13 +25,18 @@ import { useStore } from "../store"; import { useUiStateStore } from "../uiStateStore"; import { useTerminalStateStore } from "../terminalStateStore"; import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; -import { onServerConfigUpdated, onServerProvidersUpdated, onServerWelcome } from "../wsNativeApi"; import { migrateLocalSettingsToServer } from "../hooks/useSettings"; import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects"; import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery"; +import { + useServerConfig, + useServerConfigUpdatedSubscription, + useServerWelcomeSubscription, + WsNativeApiAtomsProvider, +} from "../wsNativeApiAtoms"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -58,15 +62,17 @@ function RootRouteView() { } return ( - - - - - - - - - + + + + + + + + + + + ); } @@ -157,13 +163,104 @@ function EventRouter() { const pathname = useLocation({ select: (loc) => loc.pathname }); const pathnameRef = useRef(pathname); const handledBootstrapThreadIdRef = useRef(null); + const handledConfigReplayRef = useRef(false); + const disposedRef = useRef(false); + const bootstrapFromSnapshotRef = useRef<() => Promise>(async () => undefined); + const serverConfig = useServerConfig(); pathnameRef.current = pathname; + const handleWelcome = useEffectEvent((payload: WsWelcomePayload) => { + migrateLocalSettingsToServer(); + void (async () => { + await bootstrapFromSnapshotRef.current(); + if (disposedRef.current) { + return; + } + + if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { + return; + } + setProjectExpanded(payload.bootstrapProjectId, true); + + if (pathnameRef.current !== "/") { + return; + } + if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) { + return; + } + await navigate({ + to: "/$threadId", + params: { threadId: payload.bootstrapThreadId }, + replace: true, + }); + handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; + })().catch(() => undefined); + }); + + const handleServerConfigUpdated = useEffectEvent( + ({ + payload, + source, + }: { + readonly payload: import("@t3tools/contracts").ServerConfigUpdatedPayload; + readonly source: import("../wsNativeApiState").ServerConfigUpdateSource; + }) => { + const isReplay = !handledConfigReplayRef.current; + handledConfigReplayRef.current = true; + if (isReplay || source !== "keybindingsUpdated") { + return; + } + + const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); + if (!issue) { + toastManager.add({ + type: "success", + title: "Keybindings updated", + description: "Keybindings configuration reloaded successfully.", + }); + return; + } + + toastManager.add({ + type: "warning", + title: "Invalid keybindings configuration", + description: issue.message, + actionProps: { + children: "Open keybindings.json", + onClick: () => { + const api = readNativeApi(); + if (!api) { + return; + } + + void Promise.resolve(serverConfig ?? api.server.getConfig()) + .then((config) => { + const editor = resolveAndPersistPreferredEditor(config.availableEditors); + if (!editor) { + throw new Error("No available editors found."); + } + return api.shell.openInEditor(config.keybindingsConfigPath, editor); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to open keybindings file", + description: + error instanceof Error ? error.message : "Unknown error opening file.", + }); + }); + }, + }, + }); + }, + ); + useEffect(() => { const api = readNativeApi(); if (!api) return; let disposed = false; + disposedRef.current = false; const recovery = createOrchestrationRecoveryCoordinator(); let needsProviderInvalidation = false; @@ -299,11 +396,11 @@ function EventRouter() { const bootstrapFromSnapshot = async (): Promise => { await runSnapshotRecovery("bootstrap"); }; + bootstrapFromSnapshotRef.current = bootstrapFromSnapshot; const fallbackToSnapshotRecovery = async (): Promise => { await runSnapshotRecovery("replay-failed"); }; - const unsubDomainEvent = api.orchestration.onDomainEvent((event) => { const action = recovery.classifyDomainEvent(event.sequence); if (action === "apply") { @@ -327,92 +424,13 @@ function EventRouter() { hasRunningSubprocess, ); }); - const unsubWelcome = onServerWelcome((payload) => { - // Migrate old localStorage settings to server on first connect - migrateLocalSettingsToServer(); - void (async () => { - await bootstrapFromSnapshot(); - if (disposed) { - return; - } - - if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { - return; - } - setProjectExpanded(payload.bootstrapProjectId, true); - - if (pathnameRef.current !== "/") { - return; - } - if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) { - return; - } - await navigate({ - to: "/$threadId", - params: { threadId: payload.bootstrapThreadId }, - replace: true, - }); - handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; - })().catch(() => undefined); - }); - // onServerConfigUpdated replays the latest cached value synchronously - // during subscribe. Skip the toast for that replay so effect re-runs - // don't produce duplicate toasts. - let subscribed = false; - const unsubServerConfigUpdated = onServerConfigUpdated((payload, source) => { - void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); - if (!subscribed || source !== "keybindingsUpdated") return; - const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); - if (!issue) { - toastManager.add({ - type: "success", - title: "Keybindings updated", - description: "Keybindings configuration reloaded successfully.", - }); - return; - } - - toastManager.add({ - type: "warning", - title: "Invalid keybindings configuration", - description: issue.message, - actionProps: { - children: "Open keybindings.json", - onClick: () => { - void queryClient - .ensureQueryData(serverConfigQueryOptions()) - .then((config) => { - const editor = resolveAndPersistPreferredEditor(config.availableEditors); - if (!editor) { - throw new Error("No available editors found."); - } - return api.shell.openInEditor(config.keybindingsConfigPath, editor); - }) - .catch((error) => { - toastManager.add({ - type: "error", - title: "Unable to open keybindings file", - description: - error instanceof Error ? error.message : "Unknown error opening file.", - }); - }); - }, - }, - }); - }); - const unsubProvidersUpdated = onServerProvidersUpdated(() => { - void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); - }); - subscribed = true; return () => { disposed = true; + disposedRef.current = true; needsProviderInvalidation = false; queryInvalidationThrottler.cancel(); unsubDomainEvent(); unsubTerminalEvent(); - unsubWelcome(); - unsubServerConfigUpdated(); - unsubProvidersUpdated(); }; }, [ applyOrchestrationEvents, @@ -427,6 +445,9 @@ function EventRouter() { syncThreads, ]); + useServerWelcomeSubscription(handleWelcome); + useServerConfigUpdatedSubscription(handleServerConfigUpdated); + return null; } diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 245ed9c576..9d3efe9561 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,26 +1,21 @@ -import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; -import { useQuery } from "@tanstack/react-query"; import { Outlet, createFileRoute } from "@tanstack/react-router"; import { useEffect } from "react"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { resolveShortcutCommand } from "../keybindings"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; import { useSettings } from "~/hooks/useSettings"; - -const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; +import { useServerKeybindings } from "~/wsNativeApiAtoms"; function ChatRouteGlobalShortcuts() { const clearSelection = useThreadSelectionStore((state) => state.clearSelection); const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size); const { activeDraftThread, activeThread, defaultProjectId, handleNewThread, routeThreadId } = useHandleNewThread(); - const serverConfigQuery = useQuery(serverConfigQueryOptions()); - const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; + const keybindings = useServerKeybindings(); const terminalOpen = useTerminalStateStore((state) => routeThreadId ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index e9d15a2bea..a6d22b9f3c 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -3,21 +3,19 @@ import { DEFAULT_SERVER_SETTINGS, type DesktopBridge, EventId, - ORCHESTRATION_WS_METHODS, ProjectId, type OrchestrationEvent, type ServerConfig, type ServerConfigStreamEvent, type ServerLifecycleStreamEvent, type ServerProvider, + type TerminalEvent, ThreadId, - WS_METHODS, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ContextMenuItem } from "@t3tools/contracts"; -const requestMock = vi.fn<(...args: Array) => Promise>(); const showContextMenuFallbackMock = vi.fn< ( @@ -25,106 +23,87 @@ const showContextMenuFallbackMock = position?: { x: number; y: number }, ) => Promise >(); -const streamListeners = new Map void>>(); -function registerStreamListener(method: string, listener: (event: unknown) => void) { - const listeners = streamListeners.get(method) ?? new Set<(event: unknown) => void>(); +function registerListener(listeners: Set<(event: T) => void>, listener: (event: T) => void) { listeners.add(listener); - streamListeners.set(method, listeners); return () => { listeners.delete(listener); - if (listeners.size === 0) { - streamListeners.delete(method); - } }; } -const unaryMethodClient = { - [WS_METHODS.serverGetConfig]: (payload: unknown) => - requestMock(WS_METHODS.serverGetConfig, payload), - [WS_METHODS.serverRefreshProviders]: (payload: unknown) => - requestMock(WS_METHODS.serverRefreshProviders, payload), - [WS_METHODS.serverUpsertKeybinding]: (payload: unknown) => - requestMock(WS_METHODS.serverUpsertKeybinding, payload), - [WS_METHODS.serverGetSettings]: (payload: unknown) => - requestMock(WS_METHODS.serverGetSettings, payload), - [WS_METHODS.serverUpdateSettings]: (payload: unknown) => - requestMock(WS_METHODS.serverUpdateSettings, payload), - [WS_METHODS.projectsSearchEntries]: (payload: unknown) => - requestMock(WS_METHODS.projectsSearchEntries, payload), - [WS_METHODS.projectsWriteFile]: (payload: unknown) => - requestMock(WS_METHODS.projectsWriteFile, payload), - [WS_METHODS.shellOpenInEditor]: (payload: unknown) => - requestMock(WS_METHODS.shellOpenInEditor, payload), - [WS_METHODS.gitPull]: (payload: unknown) => requestMock(WS_METHODS.gitPull, payload), - [WS_METHODS.gitStatus]: (payload: unknown) => requestMock(WS_METHODS.gitStatus, payload), - [WS_METHODS.gitRunStackedAction]: (payload: unknown) => - requestMock(WS_METHODS.gitRunStackedAction, payload), - [WS_METHODS.gitResolvePullRequest]: (payload: unknown) => - requestMock(WS_METHODS.gitResolvePullRequest, payload), - [WS_METHODS.gitPreparePullRequestThread]: (payload: unknown) => - requestMock(WS_METHODS.gitPreparePullRequestThread, payload), - [WS_METHODS.gitListBranches]: (payload: unknown) => - requestMock(WS_METHODS.gitListBranches, payload), - [WS_METHODS.gitCreateWorktree]: (payload: unknown) => - requestMock(WS_METHODS.gitCreateWorktree, payload), - [WS_METHODS.gitRemoveWorktree]: (payload: unknown) => - requestMock(WS_METHODS.gitRemoveWorktree, payload), - [WS_METHODS.gitCreateBranch]: (payload: unknown) => - requestMock(WS_METHODS.gitCreateBranch, payload), - [WS_METHODS.gitCheckout]: (payload: unknown) => requestMock(WS_METHODS.gitCheckout, payload), - [WS_METHODS.gitInit]: (payload: unknown) => requestMock(WS_METHODS.gitInit, payload), - [WS_METHODS.terminalOpen]: (payload: unknown) => requestMock(WS_METHODS.terminalOpen, payload), - [WS_METHODS.terminalWrite]: (payload: unknown) => requestMock(WS_METHODS.terminalWrite, payload), - [WS_METHODS.terminalResize]: (payload: unknown) => - requestMock(WS_METHODS.terminalResize, payload), - [WS_METHODS.terminalClear]: (payload: unknown) => requestMock(WS_METHODS.terminalClear, payload), - [WS_METHODS.terminalRestart]: (payload: unknown) => - requestMock(WS_METHODS.terminalRestart, payload), - [WS_METHODS.terminalClose]: (payload: unknown) => requestMock(WS_METHODS.terminalClose, payload), - [ORCHESTRATION_WS_METHODS.getSnapshot]: (payload: unknown) => - requestMock(ORCHESTRATION_WS_METHODS.getSnapshot, payload), - [ORCHESTRATION_WS_METHODS.dispatchCommand]: (payload: unknown) => - requestMock(ORCHESTRATION_WS_METHODS.dispatchCommand, payload), - [ORCHESTRATION_WS_METHODS.getTurnDiff]: (payload: unknown) => - requestMock(ORCHESTRATION_WS_METHODS.getTurnDiff, payload), - [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (payload: unknown) => - requestMock(ORCHESTRATION_WS_METHODS.getFullThreadDiff, payload), - [ORCHESTRATION_WS_METHODS.replayEvents]: (payload: unknown) => - requestMock(ORCHESTRATION_WS_METHODS.replayEvents, payload), -}; - -const streamMethodClient = { - [WS_METHODS.subscribeServerLifecycle]: (_payload: unknown) => WS_METHODS.subscribeServerLifecycle, - [WS_METHODS.subscribeServerConfig]: (_payload: unknown) => WS_METHODS.subscribeServerConfig, - [WS_METHODS.subscribeGitActionProgress]: (_payload: unknown) => - WS_METHODS.subscribeGitActionProgress, - [WS_METHODS.subscribeTerminalEvents]: (_payload: unknown) => WS_METHODS.subscribeTerminalEvents, - [WS_METHODS.subscribeOrchestrationDomainEvents]: (_payload: unknown) => - WS_METHODS.subscribeOrchestrationDomainEvents, +const lifecycleListeners = new Set<(event: ServerLifecycleStreamEvent) => void>(); +const configListeners = new Set<(event: ServerConfigStreamEvent) => void>(); +const gitProgressListeners = new Set<(event: unknown) => void>(); +const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); +const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>(); + +const rpcClientMock = { + dispose: vi.fn(), + terminal: { + open: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + clear: vi.fn(), + restart: vi.fn(), + close: vi.fn(), + onEvent: vi.fn((listener: (event: TerminalEvent) => void) => + registerListener(terminalEventListeners, listener), + ), + }, + projects: { + searchEntries: vi.fn(), + writeFile: vi.fn(), + }, + shell: { + openInEditor: vi.fn(), + }, + git: { + pull: vi.fn(), + status: vi.fn(), + runStackedAction: vi.fn(), + listBranches: vi.fn(), + createWorktree: vi.fn(), + removeWorktree: vi.fn(), + createBranch: vi.fn(), + checkout: vi.fn(), + init: vi.fn(), + resolvePullRequest: vi.fn(), + preparePullRequestThread: vi.fn(), + onActionProgress: vi.fn((listener: (event: unknown) => void) => + registerListener(gitProgressListeners, listener), + ), + subscribeActionProgress: vi.fn((listener: (event: unknown) => void) => + registerListener(gitProgressListeners, listener), + ), + }, + server: { + getConfig: vi.fn(), + refreshProviders: vi.fn(), + upsertKeybinding: vi.fn(), + getSettings: vi.fn(), + updateSettings: vi.fn(), + subscribeConfig: vi.fn((listener: (event: ServerConfigStreamEvent) => void) => + registerListener(configListeners, listener), + ), + subscribeLifecycle: vi.fn((listener: (event: ServerLifecycleStreamEvent) => void) => + registerListener(lifecycleListeners, listener), + ), + }, + orchestration: { + getSnapshot: vi.fn(), + dispatchCommand: vi.fn(), + getTurnDiff: vi.fn(), + getFullThreadDiff: vi.fn(), + replayEvents: vi.fn(), + onDomainEvent: vi.fn((listener: (event: OrchestrationEvent) => void) => + registerListener(orchestrationEventListeners, listener), + ), + }, }; -const subscribeMock = vi.fn< - ( - connect: (client: typeof streamMethodClient) => unknown, - listener: (event: unknown) => void, - ) => () => void ->((connect, listener) => { - const method = connect(streamMethodClient); - if (typeof method !== "string") { - throw new Error("Expected mocked stream method tag"); - } - return registerStreamListener(method, listener); -}); - -vi.mock("./wsTransport", () => { +vi.mock("./wsRpcClient", () => { return { - WsTransport: class MockWsTransport { - request = (execute: (client: typeof unaryMethodClient) => Promise) => - execute(unaryMethodClient); - subscribe = subscribeMock; - dispose() {} - }, + createWsRpcClient: () => rpcClientMock, }; }); @@ -132,22 +111,18 @@ vi.mock("./contextMenuFallback", () => ({ showContextMenuFallback: showContextMenuFallbackMock, })); -function emitStreamEvent(method: string, event: unknown) { - const listeners = streamListeners.get(method); - if (!listeners) { - return; - } +function emitEvent(listeners: Set<(event: T) => void>, event: T) { for (const listener of listeners) { listener(event); } } function emitLifecycleEvent(event: ServerLifecycleStreamEvent) { - emitStreamEvent(WS_METHODS.subscribeServerLifecycle, event); + emitEvent(lifecycleListeners, event); } function emitServerConfigEvent(event: ServerConfigStreamEvent) { - emitStreamEvent(WS_METHODS.subscribeServerConfig, event); + emitEvent(configListeners, event); } function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unknown } { @@ -211,10 +186,13 @@ const baseServerConfig: ServerConfig = { beforeEach(() => { vi.resetModules(); - requestMock.mockReset(); + vi.clearAllMocks(); showContextMenuFallbackMock.mockReset(); - subscribeMock.mockClear(); - streamListeners.clear(); + lifecycleListeners.clear(); + configListeners.clear(); + gitProgressListeners.clear(); + terminalEventListeners.clear(); + orchestrationEventListeners.clear(); Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); }); @@ -225,6 +203,7 @@ afterEach(() => { describe("wsNativeApi", () => { it("delivers and caches welcome lifecycle events", async () => { const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); + const { wsNativeApiRegistry, wsWelcomeAtom } = await import("./wsNativeApiState"); createWsNativeApi(); const listener = vi.fn(); @@ -251,6 +230,10 @@ describe("wsNativeApi", () => { cwd: "/tmp/workspace", projectName: "t3-code", }); + expect(wsNativeApiRegistry.get(wsWelcomeAtom)).toEqual({ + cwd: "/tmp/workspace", + projectName: "t3-code", + }); }); it("preserves bootstrap ids from welcome lifecycle events", async () => { @@ -285,6 +268,7 @@ describe("wsNativeApi", () => { it("delivers and caches current server config from the config stream snapshot", async () => { const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi"); + const { serverConfigAtom, wsNativeApiRegistry } = await import("./wsNativeApiState"); const api = createWsNativeApi(); const listener = vi.fn(); @@ -306,10 +290,11 @@ describe("wsNativeApi", () => { }, "snapshot", ); + expect(wsNativeApiRegistry.get(serverConfigAtom)).toEqual(baseServerConfig); }); it("falls back to server.getConfig before the stream cache is populated", async () => { - requestMock.mockResolvedValueOnce(baseServerConfig); + rpcClientMock.server.getConfig.mockResolvedValueOnce(baseServerConfig); const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi"); const api = createWsNativeApi(); @@ -317,7 +302,7 @@ describe("wsNativeApi", () => { onServerConfigUpdated(listener); await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig); - expect(requestMock).toHaveBeenCalledWith(WS_METHODS.serverGetConfig, {}); + expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith(); expect(listener).toHaveBeenCalledWith( { issues: [], @@ -331,6 +316,7 @@ describe("wsNativeApi", () => { it("merges config stream updates into the cached server config", async () => { const { createWsNativeApi, onServerConfigUpdated, onServerProvidersUpdated } = await import("./wsNativeApi"); + const { providersUpdatedAtom, wsNativeApiRegistry } = await import("./wsNativeApiState"); const api = createWsNativeApi(); const configListener = vi.fn(); @@ -425,6 +411,7 @@ describe("wsNativeApi", () => { "settingsUpdated", ); expect(providersListener).toHaveBeenLastCalledWith({ providers: nextProviders }); + expect(wsNativeApiRegistry.get(providersUpdatedAtom)).toEqual({ providers: nextProviders }); }); it("forwards terminal, orchestration, and git progress stream events", async () => { @@ -446,7 +433,7 @@ describe("wsNativeApi", () => { type: "output", data: "hello", } as const; - emitStreamEvent(WS_METHODS.subscribeTerminalEvents, terminalEvent); + emitEvent(terminalEventListeners, terminalEvent); const orchestrationEvent = { sequence: 1, @@ -472,7 +459,7 @@ describe("wsNativeApi", () => { updatedAt: "2026-02-24T00:00:00.000Z", }, } satisfies Extract; - emitStreamEvent(WS_METHODS.subscribeOrchestrationDomainEvents, orchestrationEvent); + emitEvent(orchestrationEventListeners, orchestrationEvent); const progressEvent = { actionId: "action-1", @@ -482,7 +469,7 @@ describe("wsNativeApi", () => { phase: "commit", label: "Committing...", } as const; - emitStreamEvent(WS_METHODS.subscribeGitActionProgress, progressEvent); + emitEvent(gitProgressListeners, progressEvent); expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent); expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent); @@ -490,7 +477,7 @@ describe("wsNativeApi", () => { }); it("sends orchestration dispatch commands as the direct RPC payload", async () => { - requestMock.mockResolvedValue({ sequence: 1 }); + rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 }); const { createWsNativeApi } = await import("./wsNativeApi"); const api = createWsNativeApi(); @@ -508,11 +495,11 @@ describe("wsNativeApi", () => { } as const; await api.orchestration.dispatchCommand(command); - expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.dispatchCommand, command); + expect(rpcClientMock.orchestration.dispatchCommand).toHaveBeenCalledWith(command); }); it("forwards workspace file writes to the project RPC", async () => { - requestMock.mockResolvedValue({ relativePath: "plan.md" }); + rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" }); const { createWsNativeApi } = await import("./wsNativeApi"); const api = createWsNativeApi(); @@ -522,7 +509,7 @@ describe("wsNativeApi", () => { contents: "# Plan\n", }); - expect(requestMock).toHaveBeenCalledWith(WS_METHODS.projectsWriteFile, { + expect(rpcClientMock.projects.writeFile).toHaveBeenCalledWith({ cwd: "/tmp/project", relativePath: "plan.md", contents: "# Plan\n", @@ -530,7 +517,7 @@ describe("wsNativeApi", () => { }); it("uses no client timeout for git.runStackedAction", async () => { - requestMock.mockResolvedValue({ + rpcClientMock.git.runStackedAction.mockResolvedValue({ action: "commit", branch: { status: "skipped_not_requested" }, commit: { status: "created", commitSha: "abc1234", subject: "Test" }, @@ -546,7 +533,7 @@ describe("wsNativeApi", () => { action: "commit", }); - expect(requestMock).toHaveBeenCalledWith(WS_METHODS.gitRunStackedAction, { + expect(rpcClientMock.git.runStackedAction).toHaveBeenCalledWith({ actionId: "action-1", cwd: "/repo", action: "commit", @@ -554,7 +541,7 @@ describe("wsNativeApi", () => { }); it("forwards full-thread diff requests to the orchestration RPC", async () => { - requestMock.mockResolvedValue({ diff: "patch" }); + rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" }); const { createWsNativeApi } = await import("./wsNativeApi"); const api = createWsNativeApi(); @@ -563,7 +550,7 @@ describe("wsNativeApi", () => { toTurnCount: 1, }); - expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.getFullThreadDiff, { + expect(rpcClientMock.orchestration.getFullThreadDiff).toHaveBeenCalledWith({ threadId: "thread-1", toTurnCount: 1, }); @@ -576,7 +563,7 @@ describe("wsNativeApi", () => { checkedAt: "2026-01-03T00:00:00.000Z", }, ]; - requestMock.mockResolvedValue({ providers: nextProviders }); + rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders }); const { createWsNativeApi, onServerProvidersUpdated } = await import("./wsNativeApi"); const api = createWsNativeApi(); @@ -590,7 +577,7 @@ describe("wsNativeApi", () => { onServerProvidersUpdated(listener); await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders }); - expect(requestMock).toHaveBeenCalledWith(WS_METHODS.serverRefreshProviders, {}); + expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(); expect(listener).toHaveBeenLastCalledWith({ providers: nextProviders }); await expect(api.server.getConfig()).resolves.toEqual({ ...baseServerConfig, @@ -603,7 +590,7 @@ describe("wsNativeApi", () => { ...DEFAULT_SERVER_SETTINGS, enableAssistantStreaming: true, }; - requestMock.mockResolvedValue(nextSettings); + rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings); const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi"); const api = createWsNativeApi(); @@ -619,8 +606,8 @@ describe("wsNativeApi", () => { await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual( nextSettings, ); - expect(requestMock).toHaveBeenCalledWith(WS_METHODS.serverUpdateSettings, { - patch: { enableAssistantStreaming: true }, + expect(rpcClientMock.server.updateSettings).toHaveBeenCalledWith({ + enableAssistantStreaming: true, }); await expect(api.server.getConfig()).resolves.toEqual({ ...baseServerConfig, diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 2020e9857b..0f8bf6319e 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -8,10 +8,23 @@ import { import { showContextMenuFallback } from "./contextMenuFallback"; import { createWsRpcClient, type WsRpcClient } from "./wsRpcClient"; -import { ServerConfigUpdateSource, WsNativeApiState } from "./wsNativeApiState"; +import { + applyProvidersUpdated, + applyServerConfigEvent, + applySettingsUpdated, + emitGitActionProgress, + emitWelcome, + getServerConfig, + onGitActionProgress, + onProvidersUpdated, + onServerConfigUpdated as onServerConfigUpdatedState, + onWelcome, + resetWsNativeApiStateForTests, + ServerConfigUpdateSource, + setServerConfigSnapshot, +} from "./wsNativeApiState"; let instance: { api: NativeApi; rpcClient: WsRpcClient; cleanups: Array<() => void> } | null = null; -let state = new WsNativeApiState(); export function __resetWsNativeApiForTests() { if (instance) { @@ -21,19 +34,18 @@ export function __resetWsNativeApiForTests() { instance.rpcClient.dispose(); instance = null; } - state.dispose(); - state = new WsNativeApiState(); + resetWsNativeApiStateForTests(); } async function getServerConfigSnapshot(rpcClient: WsRpcClient) { - const latestServerConfig = state.getServerConfig(); + const latestServerConfig = getServerConfig(); if (latestServerConfig) { return latestServerConfig; } const config = await rpcClient.server.getConfig(); - state.setServerConfigSnapshot(config); - return state.getServerConfig() ?? config; + setServerConfigSnapshot(config); + return getServerConfig() ?? config; } /** @@ -41,7 +53,7 @@ async function getServerConfigSnapshot(rpcClient: WsRpcClient) { * before this call, the listener fires synchronously with the cached payload. */ export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): () => void { - return state.onWelcome(listener); + return onWelcome(listener); } /** @@ -54,13 +66,13 @@ export function onServerConfigUpdated( source: ServerConfigUpdateSource, ) => void, ): () => void { - return state.onServerConfigUpdated(listener); + return onServerConfigUpdatedState(listener); } export function onServerProvidersUpdated( listener: (payload: ServerProviderUpdatedPayload) => void, ): () => void { - return state.onProvidersUpdated(listener); + return onProvidersUpdated(listener); } export function createWsNativeApi(): NativeApi { @@ -72,14 +84,14 @@ export function createWsNativeApi(): NativeApi { const cleanups = [ rpcClient.server.subscribeLifecycle((event) => { if (event.type === "welcome") { - state.emitWelcome(event.payload); + emitWelcome(event.payload); } }), rpcClient.server.subscribeConfig((event) => { - state.applyServerConfigEvent(event); + applyServerConfigEvent(event); }), rpcClient.git.subscribeActionProgress((event: GitActionProgressEvent) => { - state.emitGitActionProgress(event); + emitGitActionProgress(event); }), ]; @@ -135,7 +147,7 @@ export function createWsNativeApi(): NativeApi { init: rpcClient.git.init, resolvePullRequest: rpcClient.git.resolvePullRequest, preparePullRequestThread: rpcClient.git.preparePullRequestThread, - onActionProgress: (callback) => state.onGitActionProgress(callback), + onActionProgress: (callback) => onGitActionProgress(callback), }, contextMenu: { show: async ( @@ -152,14 +164,14 @@ export function createWsNativeApi(): NativeApi { getConfig: () => getServerConfigSnapshot(rpcClient), refreshProviders: () => rpcClient.server.refreshProviders().then((payload) => { - state.applyProvidersUpdated(payload); + applyProvidersUpdated(payload); return payload; }), upsertKeybinding: rpcClient.server.upsertKeybinding, getSettings: rpcClient.server.getSettings, updateSettings: (patch) => rpcClient.server.updateSettings(patch).then((settings) => { - state.applySettingsUpdated(settings); + applySettingsUpdated(settings); return settings; }), }, diff --git a/apps/web/src/wsNativeApiAtoms.tsx b/apps/web/src/wsNativeApiAtoms.tsx new file mode 100644 index 0000000000..07b350eaea --- /dev/null +++ b/apps/web/src/wsNativeApiAtoms.tsx @@ -0,0 +1,112 @@ +import { RegistryContext, useAtomSubscribe, useAtomValue } from "@effect/atom-react"; +import { + DEFAULT_SERVER_SETTINGS, + type EditorId, + type ResolvedKeybindingsConfig, + type ServerConfig, + type ServerProvider, + type ServerSettings, + type WsWelcomePayload, +} from "@t3tools/contracts"; +import { type ReactNode, useEffect } from "react"; + +import { readNativeApi } from "./nativeApi"; +import { + serverConfigAtom, + serverConfigUpdatedAtom, + type ServerConfigUpdatedNotification, + wsNativeApiRegistry, + wsWelcomeAtom, +} from "./wsNativeApiState"; + +const EMPTY_AVAILABLE_EDITORS: ReadonlyArray = []; +const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; +const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; + +const selectAvailableEditors = (config: ServerConfig | null): ReadonlyArray => + config?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; +const selectKeybindings = (config: ServerConfig | null) => config?.keybindings ?? EMPTY_KEYBINDINGS; +const selectKeybindingsConfigPath = (config: ServerConfig | null) => + config?.keybindingsConfigPath ?? null; +const selectProviders = (config: ServerConfig | null) => + config?.providers ?? EMPTY_SERVER_PROVIDERS; +const selectSettings = (config: ServerConfig | null): ServerSettings => + config?.settings ?? DEFAULT_SERVER_SETTINGS; + +function useLatestAtomSubscription( + atom: import("effect/unstable/reactivity/Atom").Atom, + listener: (value: NonNullable) => void, +) { + useAtomSubscribe( + atom, + (value) => { + if (value === null) { + return; + } + listener(value as NonNullable); + }, + { immediate: true }, + ); +} + +function WsNativeApiAtomsBootstrap() { + const serverConfig = useServerConfig(); + + useEffect(() => { + if (serverConfig !== null) { + return; + } + + const api = readNativeApi(); + if (!api) { + return; + } + + void api.server.getConfig().catch(() => undefined); + }, [serverConfig]); + + return null; +} + +export function WsNativeApiAtomsProvider({ children }: { readonly children: ReactNode }) { + return ( + + + {children} + + ); +} + +export function useServerConfig(): ServerConfig | null { + return useAtomValue(serverConfigAtom); +} + +export function useServerSettings(): ServerSettings { + return useAtomValue(serverConfigAtom, selectSettings); +} + +export function useServerProviders(): ReadonlyArray { + return useAtomValue(serverConfigAtom, selectProviders); +} + +export function useServerKeybindings(): ResolvedKeybindingsConfig { + return useAtomValue(serverConfigAtom, selectKeybindings); +} + +export function useServerAvailableEditors(): ReadonlyArray { + return useAtomValue(serverConfigAtom, selectAvailableEditors); +} + +export function useServerKeybindingsConfigPath(): string | null { + return useAtomValue(serverConfigAtom, selectKeybindingsConfigPath); +} + +export function useServerWelcomeSubscription(listener: (payload: WsWelcomePayload) => void): void { + useLatestAtomSubscription(wsWelcomeAtom, listener); +} + +export function useServerConfigUpdatedSubscription( + listener: (notification: ServerConfigUpdatedNotification) => void, +): void { + useLatestAtomSubscription(serverConfigUpdatedAtom, listener); +} diff --git a/apps/web/src/wsNativeApiState.ts b/apps/web/src/wsNativeApiState.ts index 58bf65ae1f..f3b5cddfa3 100644 --- a/apps/web/src/wsNativeApiState.ts +++ b/apps/web/src/wsNativeApiState.ts @@ -11,12 +11,12 @@ import { Atom, AtomRegistry } from "effect/unstable/reactivity"; export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"]; -interface ServerConfigUpdatedNotification { +export interface ServerConfigUpdatedNotification { readonly payload: ServerConfigUpdatedPayload; readonly source: ServerConfigUpdateSource; } -interface GitActionProgressNotification { +export interface GitActionProgressNotification { readonly event: GitActionProgressEvent; } @@ -32,158 +32,160 @@ function toServerConfigUpdatedPayload(config: ServerConfig): ServerConfigUpdated }; } -export class WsNativeApiState { - private readonly registry = AtomRegistry.make(); - private readonly welcomeAtom = makeStateAtom("ws-server-welcome", null); - private readonly serverConfigAtom = makeStateAtom("ws-server-config", null); - private readonly serverConfigUpdatedAtom = makeStateAtom( - "ws-server-config-updated", - null, - ); - private readonly providersUpdatedAtom = makeStateAtom( - "ws-server-providers-updated", - null, - ); - private readonly gitActionProgressAtom = makeStateAtom( - "ws-git-action-progress", - null, - ); - - dispose() { - this.registry.dispose(); - } - - getServerConfig(): ServerConfig | null { - return this.registry.get(this.serverConfigAtom); - } +export let wsNativeApiRegistry = AtomRegistry.make(); + +export const wsWelcomeAtom = makeStateAtom("ws-server-welcome", null); +export const serverConfigAtom = makeStateAtom("ws-server-config", null); +export const serverConfigUpdatedAtom = makeStateAtom( + "ws-server-config-updated", + null, +); +export const providersUpdatedAtom = makeStateAtom( + "ws-server-providers-updated", + null, +); +export const gitActionProgressAtom = makeStateAtom( + "ws-git-action-progress", + null, +); + +export function getServerConfig(): ServerConfig | null { + return wsNativeApiRegistry.get(serverConfigAtom); +} - setServerConfigSnapshot(config: ServerConfig): void { - this.resolveServerConfig(config); - this.emitProvidersUpdated({ providers: config.providers }); - this.emitServerConfigUpdated(toServerConfigUpdatedPayload(config), "snapshot"); - } +export function setServerConfigSnapshot(config: ServerConfig): void { + resolveServerConfig(config); + emitProvidersUpdated({ providers: config.providers }); + emitServerConfigUpdated(toServerConfigUpdatedPayload(config), "snapshot"); +} - applyServerConfigEvent(event: ServerConfigStreamEvent): void { - switch (event.type) { - case "snapshot": { - this.setServerConfigSnapshot(event.config); - return; - } - case "keybindingsUpdated": { - const latestServerConfig = this.getServerConfig(); - if (!latestServerConfig) { - return; - } - const nextConfig = { - ...latestServerConfig, - issues: event.payload.issues, - } satisfies ServerConfig; - this.resolveServerConfig(nextConfig); - this.emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), event.type); - return; - } - case "providerStatuses": { - this.applyProvidersUpdated(event.payload); - return; - } - case "settingsUpdated": { - this.applySettingsUpdated(event.payload.settings); +export function applyServerConfigEvent(event: ServerConfigStreamEvent): void { + switch (event.type) { + case "snapshot": { + setServerConfigSnapshot(event.config); + return; + } + case "keybindingsUpdated": { + const latestServerConfig = getServerConfig(); + if (!latestServerConfig) { return; } + const nextConfig = { + ...latestServerConfig, + issues: event.payload.issues, + } satisfies ServerConfig; + resolveServerConfig(nextConfig); + emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), event.type); + return; } - } - - applyProvidersUpdated(payload: ServerProviderUpdatedPayload): void { - const latestServerConfig = this.getServerConfig(); - this.emitProvidersUpdated(payload); - - if (!latestServerConfig) { + case "providerStatuses": { + applyProvidersUpdated(event.payload); return; } - - const nextConfig = { - ...latestServerConfig, - providers: payload.providers, - } satisfies ServerConfig; - this.resolveServerConfig(nextConfig); - this.emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "providerStatuses"); - } - - applySettingsUpdated(settings: ServerSettings): void { - const latestServerConfig = this.getServerConfig(); - if (!latestServerConfig) { + case "settingsUpdated": { + applySettingsUpdated(event.payload.settings); return; } - - const nextConfig = { - ...latestServerConfig, - settings, - } satisfies ServerConfig; - this.resolveServerConfig(nextConfig); - this.emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "settingsUpdated"); } +} - emitWelcome(payload: WsWelcomePayload): void { - this.registry.set(this.welcomeAtom, payload); - } +export function applyProvidersUpdated(payload: ServerProviderUpdatedPayload): void { + const latestServerConfig = getServerConfig(); + emitProvidersUpdated(payload); - emitGitActionProgress(event: GitActionProgressEvent): void { - this.registry.set(this.gitActionProgressAtom, { event }); + if (!latestServerConfig) { + return; } - onWelcome(listener: (payload: WsWelcomePayload) => void): () => void { - return this.subscribeLatest(this.welcomeAtom, listener); - } + const nextConfig = { + ...latestServerConfig, + providers: payload.providers, + } satisfies ServerConfig; + resolveServerConfig(nextConfig); + emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "providerStatuses"); +} - onServerConfigUpdated( - listener: (payload: ServerConfigUpdatedPayload, source: ServerConfigUpdateSource) => void, - ): () => void { - return this.subscribeLatest(this.serverConfigUpdatedAtom, (notification) => { - listener(notification.payload, notification.source); - }); +export function applySettingsUpdated(settings: ServerSettings): void { + const latestServerConfig = getServerConfig(); + if (!latestServerConfig) { + return; } - onProvidersUpdated(listener: (payload: ServerProviderUpdatedPayload) => void): () => void { - return this.subscribeLatest(this.providersUpdatedAtom, listener); - } + const nextConfig = { + ...latestServerConfig, + settings, + } satisfies ServerConfig; + resolveServerConfig(nextConfig); + emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "settingsUpdated"); +} - onGitActionProgress(listener: (event: GitActionProgressEvent) => void): () => void { - return this.registry.subscribe(this.gitActionProgressAtom, (notification) => { - if (!notification) { - return; - } - listener(notification.event); - }); - } +export function emitWelcome(payload: WsWelcomePayload): void { + wsNativeApiRegistry.set(wsWelcomeAtom, payload); +} - private resolveServerConfig(config: ServerConfig): void { - this.registry.set(this.serverConfigAtom, config); - } +export function emitGitActionProgress(event: GitActionProgressEvent): void { + wsNativeApiRegistry.set(gitActionProgressAtom, { event }); +} - private emitProvidersUpdated(payload: ServerProviderUpdatedPayload): void { - this.registry.set(this.providersUpdatedAtom, payload); - } +export function onWelcome(listener: (payload: WsWelcomePayload) => void): () => void { + return subscribeLatest(wsWelcomeAtom, listener); +} - private emitServerConfigUpdated( - payload: ServerConfigUpdatedPayload, - source: ServerConfigUpdateSource, - ): void { - this.registry.set(this.serverConfigUpdatedAtom, { payload, source }); - } +export function onServerConfigUpdated( + listener: (payload: ServerConfigUpdatedPayload, source: ServerConfigUpdateSource) => void, +): () => void { + return subscribeLatest(serverConfigUpdatedAtom, (notification) => { + listener(notification.payload, notification.source); + }); +} - private subscribeLatest( - atom: Atom.Atom, - listener: (value: NonNullable) => void, - ): () => void { - return this.registry.subscribe( - atom, - (value) => { - if (value === null) { - return; - } - listener(value as NonNullable); - }, - { immediate: true }, - ); - } +export function onProvidersUpdated( + listener: (payload: ServerProviderUpdatedPayload) => void, +): () => void { + return subscribeLatest(providersUpdatedAtom, listener); +} + +export function onGitActionProgress(listener: (event: GitActionProgressEvent) => void): () => void { + return wsNativeApiRegistry.subscribe(gitActionProgressAtom, (notification) => { + if (!notification) { + return; + } + listener(notification.event); + }); +} + +export function resetWsNativeApiStateForTests() { + wsNativeApiRegistry.dispose(); + wsNativeApiRegistry = AtomRegistry.make(); +} + +function resolveServerConfig(config: ServerConfig): void { + wsNativeApiRegistry.set(serverConfigAtom, config); +} + +function emitProvidersUpdated(payload: ServerProviderUpdatedPayload): void { + wsNativeApiRegistry.set(providersUpdatedAtom, payload); +} + +function emitServerConfigUpdated( + payload: ServerConfigUpdatedPayload, + source: ServerConfigUpdateSource, +): void { + wsNativeApiRegistry.set(serverConfigUpdatedAtom, { payload, source }); +} + +function subscribeLatest( + atom: Atom.Atom, + listener: (value: NonNullable) => void, +): () => void { + return wsNativeApiRegistry.subscribe( + atom, + (value) => { + if (value === null) { + return; + } + listener(value as NonNullable); + }, + { immediate: true }, + ); } From b636da5e1f72ac1b8725276c371a458d2699a8f2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 20:31:24 -0700 Subject: [PATCH 43/47] runtime --- apps/web/src/wsTransport.ts | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index 7f89d647da..3dc51171dd 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -20,13 +20,6 @@ interface RequestOptions { const DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS = Duration.millis(250); -function asError(value: unknown, fallback: string): Error { - if (value instanceof Error) { - return value; - } - return new Error(fallback); -} - function formatErrorMessage(error: unknown): string { if (error instanceof Error && error.message.trim().length > 0) { return error.message; @@ -54,7 +47,7 @@ export class WsTransport { ); this.runtime = ManagedRuntime.make(ProtocolLayer); - this.clientScope = Effect.runSync(Scope.make()); + this.clientScope = this.runtime.runSync(Scope.make()); this.clientPromise = this.runtime.runPromise(Scope.provide(this.clientScope)(makeWsRpcClient)); } @@ -66,12 +59,8 @@ export class WsTransport { throw new Error("Transport disposed"); } - try { - const client = await this.clientPromise; - return await Effect.runPromise(Effect.suspend(() => execute(client))); - } catch (error) { - throw asError(error, "Request failed"); - } + const client = await this.clientPromise; + return await this.runtime.runPromise(Effect.suspend(() => execute(client))); } subscribe( @@ -85,7 +74,7 @@ export class WsTransport { let active = true; const retryDelayMs = options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS; - const cancel = Effect.runCallback( + const cancel = this.runtime.runCallback( Effect.promise(() => this.clientPromise).pipe( Effect.flatMap((client) => Stream.runForEach(connect(client), (value) => From 5ea99a60f44f84d025d173e34f0fd61eb3396f48 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 20:38:45 -0700 Subject: [PATCH 44/47] make dir --- apps/server/src/serverLogger.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index 113013909d..7981257392 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -14,6 +14,7 @@ it.layer(NodeServices.layer)("ServerLoggerLive", (it) => { prefix: "t3-server-logger-", }); const derivedPaths = yield* deriveServerPaths(baseDir, undefined); + yield* fileSystem.makeDirectory(derivedPaths.logsDir, { recursive: true }); const configLayer = Layer.succeed(ServerConfig, { logLevel: "Warn", mode: "web", From 7d7c587a39cc9f0d049d1d906f374c45ecb91601 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 20:40:36 -0700 Subject: [PATCH 45/47] rm unnecessary test --- apps/server/src/serverLogger.test.ts | 49 ---------------------------- 1 file changed, 49 deletions(-) delete mode 100644 apps/server/src/serverLogger.test.ts diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts deleted file mode 100644 index 7981257392..0000000000 --- a/apps/server/src/serverLogger.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, LogLevel, Path, References } from "effect"; - -import { deriveServerPaths, ServerConfig } from "./config.ts"; -import { ServerLoggerLive } from "./serverLogger.ts"; - -it.layer(NodeServices.layer)("ServerLoggerLive", (it) => { - it.effect("provides the configured minimum log level and initializes log storage", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-logger-", - }); - const derivedPaths = yield* deriveServerPaths(baseDir, undefined); - yield* fileSystem.makeDirectory(derivedPaths.logsDir, { recursive: true }); - const configLayer = Layer.succeed(ServerConfig, { - logLevel: "Warn", - mode: "web", - port: 0, - host: undefined, - cwd: process.cwd(), - baseDir, - ...derivedPaths, - staticDir: undefined, - devUrl: undefined, - noBrowser: true, - authToken: undefined, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - }); - - const result = yield* Effect.gen(function* () { - return { - minimumLogLevel: yield* References.MinimumLogLevel, - debugEnabled: yield* LogLevel.isEnabled("Debug"), - warnEnabled: yield* LogLevel.isEnabled("Warn"), - logDirExists: yield* fileSystem.exists(path.join(baseDir, "userdata", "logs")), - }; - }).pipe(Effect.provide(ServerLoggerLive.pipe(Layer.provide(configLayer)))); - - assert.equal(result.minimumLogLevel, "Warn"); - assert.isFalse(result.debugEnabled); - assert.isTrue(result.warnEnabled); - assert.isTrue(result.logDirExists); - }), - ); -}); From d7ec9d65adf44c443a37b1135930f42f9072c6c2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 21:35:46 -0700 Subject: [PATCH 46/47] Unify project favicon routing and server setup - move project favicon resolution into the Effect HTTP router - create derived runtime directories during config resolution - update desktop/server entrypoints and web URL helpers --- apps/desktop/scripts/dev-electron.mjs | 4 +- apps/desktop/src/main.ts | 2 +- apps/server/src/cli-config.test.ts | 43 +++++ apps/server/src/cli.ts | 2 + apps/server/src/config.ts | 25 ++- apps/server/src/http.ts | 44 +++++ apps/server/src/projectFavicon.test.ts | 78 +++++++++ apps/server/src/projectFavicon.ts | 125 ++++++++++++++ apps/server/src/projectFaviconRoute.test.ts | 171 -------------------- apps/server/src/projectFaviconRoute.ts | 171 -------------------- apps/server/src/server.test.ts | 25 +++ apps/server/src/server.ts | 8 +- apps/web/src/components/ProjectFavicon.tsx | 10 +- apps/web/src/lib/utils.ts | 4 + 14 files changed, 357 insertions(+), 355 deletions(-) create mode 100644 apps/server/src/projectFavicon.test.ts create mode 100644 apps/server/src/projectFavicon.ts delete mode 100644 apps/server/src/projectFaviconRoute.test.ts delete mode 100644 apps/server/src/projectFaviconRoute.ts diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 12d4753509..6693d698e7 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -10,11 +10,11 @@ const devServerUrl = `http://localhost:${port}`; const requiredFiles = [ "dist-electron/main.js", "dist-electron/preload.js", - "../server/dist/index.mjs", + "../server/dist/bin.mjs", ]; const watchedDirectories = [ { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) }, - { directory: "../server/dist", files: new Set(["index.mjs"]) }, + { directory: "../server/dist", files: new Set(["bin.mjs"]) }, ]; const forcedShutdownTimeoutMs = 1_500; const restartDebounceMs = 120; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f1086e9c29..79f9a3d867 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -390,7 +390,7 @@ function resolveAboutCommitHash(): string | null { } function resolveBackendEntry(): string { - return Path.join(resolveAppRoot(), "apps/server/dist/index.mjs"); + return Path.join(resolveAppRoot(), "apps/server/dist/bin.mjs"); } function resolveBackendCwd(): string { diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts index f0e8a29c92..27bc60b1be 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli-config.test.ts @@ -204,6 +204,49 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); + it.effect("creates derived runtime directories during config resolution", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-dirs-" }); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("desktop"), + port: Option.some(4888), + host: Option.none(), + baseDir: Option.some(baseDir), + devUrl: Option.some(new URL("http://127.0.0.1:5173")), + noBrowser: Option.none(), + authToken: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + NetService.layer, + ), + ), + ); + + for (const directory of [ + resolved.stateDir, + resolved.logsDir, + resolved.providerLogsDir, + resolved.terminalLogsDir, + resolved.attachmentsDir, + resolved.worktreesDir, + path.dirname(resolved.serverLogPath), + ]) { + expect(yield* fs.exists(directory)).toBe(true); + } + }), + ); + it.effect("applies flag then env precedence over bootstrap envelope values", () => Effect.gen(function* () { const { join } = yield* Path.Path; diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 59691a4802..3588a4b1f8 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -5,6 +5,7 @@ import { Command, Flag, GlobalFlag } from "effect/unstable/cli"; import { DEFAULT_PORT, deriveServerPaths, + ensureServerDirectories, resolveStaticDir, ServerConfig, RuntimeMode, @@ -188,6 +189,7 @@ export const resolveServerConfig = ( ), ); const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); + yield* ensureServerDirectories(derivedPaths); const noBrowser = resolveBooleanFlag( flags.noBrowser, Option.getOrElse( diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index d415fed02d..21cd6f3150 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -75,6 +75,26 @@ export const deriveServerPaths = Effect.fn(function* ( }; }); +export const ensureServerDirectories = Effect.fn(function* (derivedPaths: ServerDerivedPaths) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + yield* Effect.all( + [ + fs.makeDirectory(derivedPaths.stateDir, { recursive: true }), + fs.makeDirectory(derivedPaths.logsDir, { recursive: true }), + fs.makeDirectory(derivedPaths.providerLogsDir, { recursive: true }), + fs.makeDirectory(derivedPaths.terminalLogsDir, { recursive: true }), + fs.makeDirectory(derivedPaths.attachmentsDir, { recursive: true }), + fs.makeDirectory(derivedPaths.worktreesDir, { recursive: true }), + fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }), + fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }), + fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }), + ], + { concurrency: "unbounded" }, + ); +}); + /** * ServerConfig - Service tag for server runtime configuration. */ @@ -93,10 +113,7 @@ export class ServerConfig extends ServiceMap.Service resolveProjectFaviconFilePath(projectCwd)); + if (!faviconFilePath) { + return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, { + status: 200, + contentType: "image/svg+xml", + headers: { + "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL, + }, + }); + } + + return yield* HttpServerResponse.file(faviconFilePath, { + status: 200, + headers: { + "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL, + }, + }).pipe( + Effect.catch(() => + Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), + ), + ); + }), +); + export const staticAndDevRouteLayer = HttpRouter.add( "GET", "*", diff --git a/apps/server/src/projectFavicon.test.ts b/apps/server/src/projectFavicon.test.ts new file mode 100644 index 0000000000..87e553f4f0 --- /dev/null +++ b/apps/server/src/projectFavicon.test.ts @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; +import { FALLBACK_PROJECT_FAVICON_SVG, resolveProjectFaviconFilePath } from "./projectFavicon"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +describe("resolveProjectFaviconFilePath", () => { + afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("finds a well-known favicon file from the project root", async () => { + const projectDir = makeTempDir("t3code-favicon-route-root-"); + const faviconPath = path.join(projectDir, "favicon.svg"); + fs.writeFileSync(faviconPath, "favicon", "utf8"); + + await expect(resolveProjectFaviconFilePath(projectDir)).resolves.toBe(faviconPath); + }); + + it("resolves icon href from source files when no well-known favicon exists", async () => { + const projectDir = makeTempDir("t3code-favicon-route-source-"); + const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); + fs.mkdirSync(path.dirname(iconPath), { recursive: true }); + fs.writeFileSync( + path.join(projectDir, "index.html"), + '', + ); + fs.writeFileSync(iconPath, "brand", "utf8"); + + await expect(resolveProjectFaviconFilePath(projectDir)).resolves.toBe(iconPath); + }); + + it("resolves icon link when href appears before rel in HTML", async () => { + const projectDir = makeTempDir("t3code-favicon-route-html-order-"); + const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); + fs.mkdirSync(path.dirname(iconPath), { recursive: true }); + fs.writeFileSync( + path.join(projectDir, "index.html"), + '', + ); + fs.writeFileSync(iconPath, "brand-html-order", "utf8"); + + await expect(resolveProjectFaviconFilePath(projectDir)).resolves.toBe(iconPath); + }); + + it("resolves object-style icon metadata when href appears before rel", async () => { + const projectDir = makeTempDir("t3code-favicon-route-obj-order-"); + const iconPath = path.join(projectDir, "public", "brand", "obj.svg"); + fs.mkdirSync(path.dirname(iconPath), { recursive: true }); + fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); + fs.writeFileSync( + path.join(projectDir, "src", "root.tsx"), + 'const links = [{ href: "/brand/obj.svg", rel: "icon" }];', + "utf8", + ); + fs.writeFileSync(iconPath, "brand-obj-order", "utf8"); + + await expect(resolveProjectFaviconFilePath(projectDir)).resolves.toBe(iconPath); + }); + + it("returns null when no project icon exists so the route can use the inline fallback", async () => { + const projectDir = makeTempDir("t3code-favicon-route-fallback-"); + + await expect(resolveProjectFaviconFilePath(projectDir)).resolves.toBeNull(); + expect(FALLBACK_PROJECT_FAVICON_SVG).toContain('data-fallback="project-favicon"'); + }); +}); diff --git a/apps/server/src/projectFavicon.ts b/apps/server/src/projectFavicon.ts new file mode 100644 index 0000000000..1122ea0814 --- /dev/null +++ b/apps/server/src/projectFavicon.ts @@ -0,0 +1,125 @@ +import fs from "node:fs"; +import path from "node:path"; + +export const FALLBACK_PROJECT_FAVICON_SVG = ``; +export const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; + +// Well-known favicon paths checked in order. +const FAVICON_CANDIDATES = [ + "favicon.svg", + "favicon.ico", + "favicon.png", + "public/favicon.svg", + "public/favicon.ico", + "public/favicon.png", + "app/favicon.ico", + "app/favicon.png", + "app/icon.svg", + "app/icon.png", + "app/icon.ico", + "src/favicon.ico", + "src/favicon.svg", + "src/app/favicon.ico", + "src/app/icon.svg", + "src/app/icon.png", + "assets/icon.svg", + "assets/icon.png", + "assets/logo.svg", + "assets/logo.png", +]; + +// Files that may contain a or icon metadata declaration. +const ICON_SOURCE_FILES = [ + "index.html", + "public/index.html", + "app/routes/__root.tsx", + "src/routes/__root.tsx", + "app/root.tsx", + "src/root.tsx", + "src/index.html", +]; + +// Matches tags or object-like icon metadata where rel/href can appear in any order. +const LINK_ICON_HTML_RE = + /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; +const LINK_ICON_OBJ_RE = + /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; + +function extractIconHref(source: string): string | null { + const htmlMatch = source.match(LINK_ICON_HTML_RE); + if (htmlMatch?.[1]) return htmlMatch[1]; + const objMatch = source.match(LINK_ICON_OBJ_RE); + if (objMatch?.[1]) return objMatch[1]; + return null; +} + +function resolveIconHref(projectCwd: string, href: string): string[] { + const clean = href.replace(/^\//, ""); + return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; +} + +function isPathWithinProject(projectCwd: string, candidatePath: string): boolean { + const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +async function statFile(filePath: string): Promise { + try { + return await fs.promises.stat(filePath); + } catch { + return null; + } +} + +async function resolveFirstExistingFilePath( + projectCwd: string, + candidatePaths: ReadonlyArray, +): Promise { + for (const candidatePath of candidatePaths) { + if (!isPathWithinProject(projectCwd, candidatePath)) { + continue; + } + + const stats = await statFile(candidatePath); + if (stats?.isFile()) { + return candidatePath; + } + } + + return null; +} + +export async function resolveProjectFaviconFilePath(projectCwd: string): Promise { + for (const relativeCandidate of FAVICON_CANDIDATES) { + const candidatePath = path.join(projectCwd, relativeCandidate); + const resolvedPath = await resolveFirstExistingFilePath(projectCwd, [candidatePath]); + if (resolvedPath) { + return resolvedPath; + } + } + + for (const sourceFileRelativePath of ICON_SOURCE_FILES) { + const sourceFilePath = path.join(projectCwd, sourceFileRelativePath); + let content: string; + try { + content = await fs.promises.readFile(sourceFilePath, "utf8"); + } catch { + continue; + } + + const href = extractIconHref(content); + if (!href) { + continue; + } + + const resolvedPath = await resolveFirstExistingFilePath( + projectCwd, + resolveIconHref(projectCwd, href), + ); + if (resolvedPath) { + return resolvedPath; + } + } + + return null; +} diff --git a/apps/server/src/projectFaviconRoute.test.ts b/apps/server/src/projectFaviconRoute.test.ts deleted file mode 100644 index a346e513eb..0000000000 --- a/apps/server/src/projectFaviconRoute.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import fs from "node:fs"; -import http from "node:http"; -import os from "node:os"; -import path from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; -import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; - -interface HttpResponse { - statusCode: number; - contentType: string | null; - body: string; -} - -const tempDirs: string[] = []; - -function makeTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -async function withRouteServer(run: (baseUrl: string) => Promise): Promise { - const server = http.createServer((req, res) => { - const url = new URL(req.url ?? "/", "http://127.0.0.1"); - if (tryHandleProjectFaviconRequest(url, res)) { - return; - } - res.writeHead(404, { "Content-Type": "text/plain" }); - res.end("Not Found"); - }); - - await new Promise((resolve, reject) => { - server.listen(0, "127.0.0.1", (error?: Error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - - const address = server.address(); - if (typeof address !== "object" || address === null) { - throw new Error("Expected server address to be an object"); - } - const baseUrl = `http://127.0.0.1:${address.port}`; - - try { - await run(baseUrl); - } finally { - await new Promise((resolve, reject) => { - server.close((error?: Error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - } -} - -async function request(baseUrl: string, pathname: string): Promise { - const response = await fetch(`${baseUrl}${pathname}`); - return { - statusCode: response.status, - contentType: response.headers.get("content-type"), - body: await response.text(), - }; -} - -describe("tryHandleProjectFaviconRequest", () => { - afterEach(() => { - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it("returns 400 when cwd is missing", async () => { - await withRouteServer(async (baseUrl) => { - const response = await request(baseUrl, "/api/project-favicon"); - expect(response.statusCode).toBe(400); - expect(response.body).toBe("Missing cwd parameter"); - }); - }); - - it("serves a well-known favicon file from the project root", async () => { - const projectDir = makeTempDir("t3code-favicon-route-root-"); - fs.writeFileSync(path.join(projectDir, "favicon.svg"), "favicon", "utf8"); - - await withRouteServer(async (baseUrl) => { - const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; - const response = await request(baseUrl, pathname); - expect(response.statusCode).toBe(200); - expect(response.contentType).toContain("image/svg+xml"); - expect(response.body).toBe("favicon"); - }); - }); - - it("resolves icon href from source files when no well-known favicon exists", async () => { - const projectDir = makeTempDir("t3code-favicon-route-source-"); - const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); - fs.mkdirSync(path.dirname(iconPath), { recursive: true }); - fs.writeFileSync( - path.join(projectDir, "index.html"), - '', - ); - fs.writeFileSync(iconPath, "brand", "utf8"); - - await withRouteServer(async (baseUrl) => { - const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; - const response = await request(baseUrl, pathname); - expect(response.statusCode).toBe(200); - expect(response.contentType).toContain("image/svg+xml"); - expect(response.body).toBe("brand"); - }); - }); - - it("resolves icon link when href appears before rel in HTML", async () => { - const projectDir = makeTempDir("t3code-favicon-route-html-order-"); - const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); - fs.mkdirSync(path.dirname(iconPath), { recursive: true }); - fs.writeFileSync( - path.join(projectDir, "index.html"), - '', - ); - fs.writeFileSync(iconPath, "brand-html-order", "utf8"); - - await withRouteServer(async (baseUrl) => { - const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; - const response = await request(baseUrl, pathname); - expect(response.statusCode).toBe(200); - expect(response.contentType).toContain("image/svg+xml"); - expect(response.body).toBe("brand-html-order"); - }); - }); - - it("resolves object-style icon metadata when href appears before rel", async () => { - const projectDir = makeTempDir("t3code-favicon-route-obj-order-"); - const iconPath = path.join(projectDir, "public", "brand", "obj.svg"); - fs.mkdirSync(path.dirname(iconPath), { recursive: true }); - fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); - fs.writeFileSync( - path.join(projectDir, "src", "root.tsx"), - 'const links = [{ href: "/brand/obj.svg", rel: "icon" }];', - "utf8", - ); - fs.writeFileSync(iconPath, "brand-obj-order", "utf8"); - - await withRouteServer(async (baseUrl) => { - const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; - const response = await request(baseUrl, pathname); - expect(response.statusCode).toBe(200); - expect(response.contentType).toContain("image/svg+xml"); - expect(response.body).toBe("brand-obj-order"); - }); - }); - - it("serves a fallback favicon when no icon exists", async () => { - const projectDir = makeTempDir("t3code-favicon-route-fallback-"); - - await withRouteServer(async (baseUrl) => { - const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; - const response = await request(baseUrl, pathname); - expect(response.statusCode).toBe(200); - expect(response.contentType).toContain("image/svg+xml"); - expect(response.body).toContain('data-fallback="project-favicon"'); - }); - }); -}); diff --git a/apps/server/src/projectFaviconRoute.ts b/apps/server/src/projectFaviconRoute.ts deleted file mode 100644 index cf234ad894..0000000000 --- a/apps/server/src/projectFaviconRoute.ts +++ /dev/null @@ -1,171 +0,0 @@ -import fs from "node:fs"; -import http from "node:http"; -import path from "node:path"; - -const FAVICON_MIME_TYPES: Record = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".svg": "image/svg+xml", - ".ico": "image/x-icon", -}; - -const FALLBACK_FAVICON_SVG = ``; - -// Well-known favicon paths checked in order. -const FAVICON_CANDIDATES = [ - "favicon.svg", - "favicon.ico", - "favicon.png", - "public/favicon.svg", - "public/favicon.ico", - "public/favicon.png", - "app/favicon.ico", - "app/favicon.png", - "app/icon.svg", - "app/icon.png", - "app/icon.ico", - "src/favicon.ico", - "src/favicon.svg", - "src/app/favicon.ico", - "src/app/icon.svg", - "src/app/icon.png", - "assets/icon.svg", - "assets/icon.png", - "assets/logo.svg", - "assets/logo.png", -]; - -// Files that may contain a or icon metadata declaration. -const ICON_SOURCE_FILES = [ - "index.html", - "public/index.html", - "app/routes/__root.tsx", - "src/routes/__root.tsx", - "app/root.tsx", - "src/root.tsx", - "src/index.html", -]; - -// Matches tags or object-like icon metadata where rel/href can appear in any order. -const LINK_ICON_HTML_RE = - /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; -const LINK_ICON_OBJ_RE = - /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; - -function extractIconHref(source: string): string | null { - const htmlMatch = source.match(LINK_ICON_HTML_RE); - if (htmlMatch?.[1]) return htmlMatch[1]; - const objMatch = source.match(LINK_ICON_OBJ_RE); - if (objMatch?.[1]) return objMatch[1]; - return null; -} - -function resolveIconHref(projectCwd: string, href: string): string[] { - const clean = href.replace(/^\//, ""); - return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; -} - -function isPathWithinProject(projectCwd: string, candidatePath: string): boolean { - const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath)); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -function serveFaviconFile(filePath: string, res: http.ServerResponse): void { - const ext = path.extname(filePath).toLowerCase(); - const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream"; - fs.readFile(filePath, (readErr, data) => { - if (readErr) { - res.writeHead(500, { "Content-Type": "text/plain" }); - res.end("Read error"); - return; - } - res.writeHead(200, { - "Content-Type": contentType, - "Cache-Control": "public, max-age=3600", - }); - res.end(data); - }); -} - -function serveFallbackFavicon(res: http.ServerResponse): void { - res.writeHead(200, { - "Content-Type": "image/svg+xml", - "Cache-Control": "public, max-age=3600", - }); - res.end(FALLBACK_FAVICON_SVG); -} - -export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerResponse): boolean { - if (url.pathname !== "/api/project-favicon") { - return false; - } - - const projectCwd = url.searchParams.get("cwd"); - if (!projectCwd) { - res.writeHead(400, { "Content-Type": "text/plain" }); - res.end("Missing cwd parameter"); - return true; - } - - const tryResolvedPaths = (paths: string[], index: number, onExhausted: () => void): void => { - if (index >= paths.length) { - onExhausted(); - return; - } - const candidate = paths[index]!; - if (!isPathWithinProject(projectCwd, candidate)) { - tryResolvedPaths(paths, index + 1, onExhausted); - return; - } - fs.stat(candidate, (err, stats) => { - if (err || !stats?.isFile()) { - tryResolvedPaths(paths, index + 1, onExhausted); - return; - } - serveFaviconFile(candidate, res); - }); - }; - - const trySourceFiles = (index: number): void => { - if (index >= ICON_SOURCE_FILES.length) { - serveFallbackFavicon(res); - return; - } - const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!); - fs.readFile(sourceFile, "utf8", (err, content) => { - if (err) { - trySourceFiles(index + 1); - return; - } - const href = extractIconHref(content); - if (!href) { - trySourceFiles(index + 1); - return; - } - const candidates = resolveIconHref(projectCwd, href); - tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1)); - }); - }; - - const tryCandidates = (index: number): void => { - if (index >= FAVICON_CANDIDATES.length) { - trySourceFiles(0); - return; - } - const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!); - if (!isPathWithinProject(projectCwd, candidate)) { - tryCandidates(index + 1); - return; - } - fs.stat(candidate, (err, stats) => { - if (err || !stats?.isFile()) { - tryCandidates(index + 1); - return; - } - serveFaviconFile(candidate, res); - }); - }; - - tryCandidates(0); - return true; -} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 1526fb9ee0..cab5224081 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -318,6 +318,31 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("serves project favicon requests before the dev URL redirect", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-router-project-favicon-", + }); + yield* fileSystem.writeFileString( + path.join(projectDir, "favicon.svg"), + "router-project-favicon", + ); + + yield* buildAppUnderTest({ + config: { devUrl: new URL("http://127.0.0.1:5173") }, + }); + + const response = yield* HttpClient.get( + `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, + ); + + assert.equal(response.status, 200); + assert.equal(yield* response.text, "router-project-favicon"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("serves attachment files from state dir", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 08687f113f..8be5182647 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -2,7 +2,12 @@ import { Effect, Layer } from "effect"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import { ServerConfig } from "./config"; -import { attachmentsRouteLayer, healthRouteLayer, staticAndDevRouteLayer } from "./http"; +import { + attachmentsRouteLayer, + healthRouteLayer, + projectFaviconRouteLayer, + staticAndDevRouteLayer, +} from "./http"; import { fixPath } from "./os-jank"; import { websocketRpcRouteLayer } from "./ws"; import { OpenLive } from "./open"; @@ -176,6 +181,7 @@ const RuntimeServicesLive = Layer.empty.pipe( export const makeRoutesLayer = Layer.mergeAll( healthRouteLayer, attachmentsRouteLayer, + projectFaviconRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index ab9ec23332..58426f50ba 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -2,14 +2,14 @@ import { FolderIcon } from "lucide-react"; import { useState } from "react"; import { resolveServerUrl } from "~/lib/utils"; -const serverHttpOrigin = resolveServerUrl({ - protocol: "http", -}); - const loadedProjectFaviconSrcs = new Set(); export function ProjectFavicon({ cwd, className }: { cwd: string; className?: string }) { - const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(cwd)}`; + const src = resolveServerUrl({ + protocol: "http", + pathname: "/api/project-favicon", + searchParams: { cwd }, + }); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", ); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 9f5e49153b..e48f815461 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -50,6 +50,7 @@ export const resolveServerUrl = (options?: { url?: string | undefined; protocol?: "http" | "https" | "ws" | "wss" | undefined; pathname?: string | undefined; + searchParams?: Record | undefined; }): string => { const rawUrl = firstNonEmptyString( options?.url, @@ -67,5 +68,8 @@ export const resolveServerUrl = (options?: { } else { parsedUrl.pathname = "/"; } + if (options?.searchParams) { + parsedUrl.search = new URLSearchParams(options.searchParams).toString(); + } return parsedUrl.toString(); }; From f306805d72e34aa1fa6de3913878adf046da1e17 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 31 Mar 2026 05:29:44 +0000 Subject: [PATCH 47/47] Restore rich error message context in git error classes The message getters for GitCommandError, GitHubCliError, TextGenerationError, and GitManagerError in contracts were returning only this.detail, losing the operation, command, and cwd context that the original local definitions included. Restore the formatted messages so that logging and error reporting surfaces full debugging context. Applied via @cursor push command --- packages/contracts/src/git.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 29b08ff0ad..65504fabc1 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -232,7 +232,7 @@ export class GitCommandError extends Schema.TaggedErrorClass()( cause: Schema.optional(Schema.Defect), }) { override get message(): string { - return this.detail; + return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; } } @@ -242,7 +242,7 @@ export class GitHubCliError extends Schema.TaggedErrorClass()("G cause: Schema.optional(Schema.Defect), }) { override get message(): string { - return this.detail; + return `GitHub CLI failed in ${this.operation}: ${this.detail}`; } } @@ -255,7 +255,7 @@ export class TextGenerationError extends Schema.TaggedErrorClass()( cause: Schema.optional(Schema.Defect), }) { override get message(): string { - return this.detail; + return `Git manager failed in ${this.operation}: ${this.detail}`; } }