diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 83fcb579579dbf1df400e70214f949db9761aaff..78307f263d3617f0ac5597d949b07d2ba6ce9c5f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -32,6 +32,7 @@ import { } from "./ProjectionPipeline.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; +import { ServerConfig, type ServerConfigShape } from "../../config.ts"; const projectionLayer = it.layer( OrchestrationProjectionPipelineLive.pipe( @@ -158,6 +159,94 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { }), ); + it.effect("materializes message image attachments into stateDir and stores URL references", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-")); + + const serverConfig = { + mode: "web", + port: 0, + host: undefined, + cwd: "/tmp/project-attachments", + keybindingsConfigPath: path.join(stateDir, "keybindings.json"), + stateDir, + staticDir: undefined, + devUrl: undefined, + noBrowser: true, + authToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + } satisfies ServerConfigShape; + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-attachments"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-attachments"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-attachments"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-attachments"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-attachments"), + messageId: MessageId.makeUnsafe("message-attachments"), + role: "user", + text: "Inspect this", + attachments: [ + { + type: "image", + name: "example.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "data:image/png;base64,SGVsbG8=", + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig)); + + const rows = yield* sql<{ + readonly attachmentsJson: string | null; + }>` + SELECT + attachments_json AS "attachmentsJson" + FROM projection_thread_messages + WHERE message_id = 'message-attachments' + `; + assert.equal(rows.length, 1); + assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ + { + type: "image", + name: "example.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "/attachments/thread-attachments/message-attachments/0.png", + }, + ]); + + const attachmentPath = path.join( + stateDir, + "attachments", + "thread-attachments", + "message-attachments", + "0.png", + ); + assert.equal(fs.existsSync(attachmentPath), true); + assert.deepEqual(fs.readFileSync(attachmentPath), Buffer.from("SGVsbG8=", "base64")); + fs.rmSync(stateDir, { recursive: true, force: true }); + }), + ); + it.effect("resumes from projector last_applied_sequence without replaying older events", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 262d4545a4a1e0322e16a1a2a9a6fdb320558d61..229f38fc66130d64603d5a48496e251cc7942ecd 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,5 +1,10 @@ -import { ApprovalRequestId, type OrchestrationEvent } from "@t3tools/contracts"; -import { Effect, Layer, Option, Stream } from "effect"; +import { + ApprovalRequestId, + 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"; import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; @@ -27,6 +32,7 @@ import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; +import { ServerConfig } from "../../config.ts"; import { OrchestrationProjectionPipeline, type OrchestrationProjectionPipelineShape, @@ -51,6 +57,107 @@ interface ProjectorDefinition { readonly apply: (event: OrchestrationEvent) => Effect.Effect; } +const ATTACHMENTS_ROUTE_PREFIX = "/attachments"; +const ATTACHMENTS_STATE_SUBDIRECTORY = "attachments"; +const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "image/svg+xml": ".svg", + "image/bmp": ".bmp", + "image/tiff": ".tiff", + "image/heic": ".heic", +}; + +function parseBase64DataUrl( + dataUrl: string, +): { readonly mimeType: string; readonly base64: string } | null { + const match = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(dataUrl.trim()); + if (!match) { + return null; + } + const mimeType = match[1]?.trim().toLowerCase(); + const base64 = match[2]?.replace(/\s+/g, ""); + if (!mimeType || !base64) { + return null; + } + return { mimeType, base64 }; +} + +function inferImageExtension(attachment: Extract): string { + const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[attachment.mimeType.toLowerCase()]; + if (fromMime) { + return fromMime; + } + const fromName = attachment.name.includes(".") + ? `.${attachment.name.split(".").slice(-1)[0]?.toLowerCase() ?? ""}` + : ""; + if (/^\.[a-z0-9]{1,12}$/.test(fromName)) { + return fromName; + } + return ".bin"; +} + +const materializeAttachmentsForProjection = Effect.fn(function* (input: { + readonly threadId: string; + readonly messageId: string; + readonly attachments: ReadonlyArray; +}) { + if (input.attachments.length === 0) { + return [] as ReadonlyArray; + } + + const serverConfig = yield* Effect.serviceOption(ServerConfig); + if (Option.isNone(serverConfig)) { + return input.attachments; + } + + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentsRootDir = path.join(serverConfig.value.stateDir, ATTACHMENTS_STATE_SUBDIRECTORY); + const threadSegment = encodeURIComponent(input.threadId); + const messageSegment = encodeURIComponent(input.messageId); + + return yield* Effect.forEach( + input.attachments, + (attachment, index) => + Effect.gen(function* () { + if (attachment.type !== "image" || !attachment.dataUrl.startsWith("data:")) { + return attachment; + } + + const parsed = parseBase64DataUrl(attachment.dataUrl); + if (!parsed) { + return attachment; + } + + const bytes = Buffer.from(parsed.base64, "base64"); + if (bytes.byteLength === 0) { + return attachment; + } + + const fileName = `${index}${inferImageExtension(attachment)}`; + const relativePath = `${threadSegment}/${messageSegment}/${fileName}`; + const absolutePath = path.join(attachmentsRootDir, threadSegment, messageSegment, fileName); + yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true }); + const alreadyExists = yield* fileSystem + .exists(absolutePath) + .pipe(Effect.catch(() => Effect.succeed(false))); + if (!alreadyExists) { + yield* fileSystem.writeFile(absolutePath, bytes); + } + + return { + ...attachment, + dataUrl: `${ATTACHMENTS_ROUTE_PREFIX}/${relativePath}`, + } satisfies ChatAttachment; + }), + { concurrency: 1 }, + ); +}); + function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { if (typeof payload !== "object" || payload === null) { return null; @@ -175,6 +282,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { const projectionTurnRepository = yield* ProjectionTurnRepository; const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const applyProjectsProjection: ProjectorDefinition["apply"] = (event) => Effect.gen(function* () { switch (event.type) { @@ -367,12 +477,33 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { : existingMessage && event.payload.text.length === 0 ? existingMessage.text : event.payload.text; + const nextAttachments = + event.payload.attachments !== undefined + ? yield* materializeAttachmentsForProjection({ + threadId: event.payload.threadId, + messageId: event.payload.messageId, + attachments: event.payload.attachments, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.catch((cause) => + Effect.logWarning("failed to persist message attachments", { + threadId: event.payload.threadId, + messageId: event.payload.messageId, + cause, + }).pipe(Effect.as(event.payload.attachments)), + ), + ) + : existingMessage?.attachments; yield* projectionThreadMessageRepository.upsert({ messageId: event.payload.messageId, threadId: event.payload.threadId, turnId: event.payload.turnId, role: event.payload.role, text: nextText, + ...(nextAttachments && nextAttachments.length > 0 + ? { attachments: [...nextAttachments] } + : {}), isStreaming: event.payload.streaming, createdAt: existingMessage?.createdAt ?? event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -901,6 +1032,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( OrchestrationProjectionPipeline, makeOrchestrationProjectionPipeline, ).pipe( + Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ProjectionProjectRepositoryLive), Layer.provideMerge(ProjectionThreadRepositoryLive), Layer.provideMerge(ProjectionThreadMessageRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3b0798e20eaf9897189a0173b08fdb48bf7d5035..5d5d77c4823b3663f31564148db77d5e56acc5e6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1,4 +1,5 @@ import { + ChatAttachment, IsoDateTime, MessageId, OrchestrationCheckpointFile, @@ -43,11 +44,11 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( }), ); const ProjectionThreadDbRowSchema = ProjectionThread; -const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( - Struct.assign({ - isStreaming: Schema.Number, - }), -); +const ProjectionThreadMessageDbRowSchema = Schema.Struct({ + ...ProjectionThreadMessage.fields, + isStreaming: Schema.Number, + attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), +}); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( Struct.assign({ payload: Schema.fromJsonString(Schema.Unknown), @@ -171,6 +172,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { turn_id AS "turnId", role, text, + attachments_json AS "attachments", is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" @@ -376,6 +378,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.messageId, role: row.role, text: row.text, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), turnId: row.turnId, streaming: row.isStreaming === 1, createdAt: row.createdAt, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 13a671e536b4f83671f456df3172a157d114db31..2993d9b2c96d3417e05e41ec43a15a716c5c3ba2 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -1,6 +1,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Schema, Struct } from "effect"; +import { Effect, Layer, Schema } from "effect"; +import { ChatAttachment } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { @@ -11,11 +12,11 @@ import { ProjectionThreadMessage, } from "../Services/ProjectionThreadMessages.ts"; -const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( - Struct.assign({ - isStreaming: Schema.Number, - }), -); +const ProjectionThreadMessageDbRowSchema = Schema.Struct({ + ...ProjectionThreadMessage.fields, + isStreaming: Schema.Number, + attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), +}); const makeProjectionThreadMessageRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -30,6 +31,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { turn_id, role, text, + attachments_json, is_streaming, created_at, updated_at @@ -40,6 +42,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { ${row.turnId}, ${row.role}, ${row.text}, + ${row.attachments !== undefined ? JSON.stringify(row.attachments) : null}, ${row.isStreaming ? 1 : 0}, ${row.createdAt}, ${row.updatedAt} @@ -50,6 +53,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { turn_id = excluded.turn_id, role = excluded.role, text = excluded.text, + attachments_json = excluded.attachments_json, is_streaming = excluded.is_streaming, created_at = excluded.created_at, updated_at = excluded.updated_at @@ -67,6 +71,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { turn_id AS "turnId", role, text, + attachments_json AS "attachments", is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" @@ -97,8 +102,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { ), Effect.map((rows) => rows.map((row) => ({ - ...row, + messageId: row.messageId, + threadId: row.threadId, + turnId: row.turnId, + role: row.role, + text: row.text, isStreaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), })), ), ); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 12873ca29d2a0d72395882f9dd7b7dd76dfab88c..3df831d0c76970909035382aefd422ce9cd576f4 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -18,6 +18,7 @@ import Migration0003 from "./Migrations/003_CheckpointDiffBlobs.ts"; import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts"; import Migration0005 from "./Migrations/005_Projections.ts"; import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts"; +import Migration0007 from "./Migrations/007_ProjectionThreadMessageAttachments.ts"; /** * Migration loader with all migrations defined inline. @@ -36,6 +37,7 @@ const loader = Migrator.fromRecord({ "4_ProviderSessionRuntime": Migration0004, "5_Projections": Migration0005, "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006, + "7_ProjectionThreadMessageAttachments": Migration0007, }); /** diff --git a/apps/server/src/persistence/Migrations/007_ProjectionThreadMessageAttachments.ts b/apps/server/src/persistence/Migrations/007_ProjectionThreadMessageAttachments.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2cbe5b2b041d21dc4aa9536a27136ba686c5a7d --- /dev/null +++ b/apps/server/src/persistence/Migrations/007_ProjectionThreadMessageAttachments.ts @@ -0,0 +1,11 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_thread_messages + ADD COLUMN attachments_json TEXT + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index f358ca573963117cebde3bc8e004a887ecc797f4..00b1d399c6fecf9b66cda587d285e5b914303761 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -7,6 +7,7 @@ * @module ProjectionThreadMessageRepository */ import { + ChatAttachment, OrchestrationMessageRole, MessageId, ThreadId, @@ -24,6 +25,7 @@ export const ProjectionThreadMessage = Schema.Struct({ turnId: Schema.NullOr(TurnId), role: OrchestrationMessageRole, text: Schema.String, + attachments: Schema.optional(Schema.Array(ChatAttachment)), isStreaming: Schema.Boolean, createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 494c455165bfe5651ed17ba9985d6edc32f4ce39..84113f00c98e84ea89d89fde4f9a54edee58251c 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -438,6 +438,24 @@ describe("WebSocket Server", () => { }); }); + it("serves persisted attachments from stateDir", async () => { + const stateDir = makeTempDir("t3code-state-attachments-"); + const attachmentPath = path.join(stateDir, "attachments", "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", stateDir }); + 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("bootstraps the cwd project on startup when enabled", async () => { server = await createTestServer({ cwd: "/test/bootstrap-workspace", diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index ceb370ab6814557070598d14f1ba626b7d569e14..5cf4769b5c932a3da0b0d0827e8db2ba80622484 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -6,11 +6,10 @@ * * @module Server */ -import fs from "node:fs"; import http from "node:http"; -import path from "node:path"; import type { Duplex } from "node:stream"; +import Mime from "@effect/platform-node/Mime"; import { CommandId, ORCHESTRATION_WS_CHANNELS, @@ -24,8 +23,22 @@ import { WsPush, WsResponse, } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import { Cause, Effect, Exit, Layer, Ref, Schema, Scope, ServiceMap, Stream, Struct } from "effect"; +import { + Cause, + Effect, + Exit, + FileSystem, + Layer, + Path, + Ref, + Schema, + Scope, + ServiceMap, + Stream, + Struct, +} from "effect"; import { WebSocketServer, type WebSocket } from "ws"; import { createLogger } from "./logger"; @@ -44,20 +57,7 @@ import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore.ts"; import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; -const MIME_TYPES: Record = { - ".html": "text/html; charset=utf-8", - ".js": "application/javascript; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".json": "application/json; charset=utf-8", - ".png": "image/png", - ".jpg": "image/jpeg", - ".svg": "image/svg+xml", - ".ico": "image/x-icon", - ".woff": "font/woff", - ".woff2": "font/woff2", - ".ttf": "font/ttf", - ".map": "application/json", -}; +const ATTACHMENTS_ROUTE_PREFIX = "/attachments/"; /** * ServerShape - Service API for server lifecycle control. @@ -69,7 +69,7 @@ export interface ServerShape { readonly start: Effect.Effect< http.Server, ServerLifecycleError, - Scope.Scope | ServerRuntimeServices | ServerConfig + Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path >; /** @@ -168,7 +168,7 @@ class RouteRequestError extends Schema.TaggedErrorClass()("Ro export const createServer = Effect.fn(function* (): Effect.fn.Return< http.Server, ServerLifecycleError, - Scope.Scope | ServerRuntimeServices | ServerConfig + Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path > { const serverConfig = yield* ServerConfig; const { @@ -187,6 +187,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const terminalManager = yield* TerminalManager; const keybindingsManager = yield* Keybindings; const git = yield* GitCore; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe( Effect.catch((error) => @@ -233,62 +235,122 @@ 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) => { - const url = new URL(req.url ?? "/", `http://localhost:${port}`); - - if (tryHandleProjectFaviconRequest(url, res)) { - return; - } - - // In dev mode, redirect to Vite dev server - if (devUrl) { - res.writeHead(302, { Location: devUrl.href }); - res.end(); - return; - } - - // Serve static files from the web app build - if (!staticDir) { - res.writeHead(503, { "Content-Type": "text/plain" }); - res.end("No static directory configured and no dev URL set."); - return; - } - - let filePath = path.join(staticDir, url.pathname); - - // SPA fallback: if no file extension and not found, serve index.html - const ext = path.extname(filePath); - if (!ext) { - filePath = path.join(filePath, "index.html"); - } - - fs.stat(filePath, (err, stats) => { - if (err || !stats?.isFile()) { - // SPA fallback - const indexPath = path.join(staticDir, "index.html"); - fs.readFile(indexPath, (readErr, data) => { - if (readErr) { - res.writeHead(404, { "Content-Type": "text/plain" }); - res.end("Not Found"); + 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 attachmentsRoot = path.resolve(path.join(serverConfig.stateDir, "attachments")); + const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); + const normalizedRelativePath = path.normalize(rawRelativePath).replace(/^[/\\]+/, ""); + + if ( + normalizedRelativePath.length === 0 || + normalizedRelativePath.startsWith("..") || + normalizedRelativePath.includes("\0") + ) { + respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); + return; + } + + const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath)); + if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) { + respond(400, { "Content-Type": "text/plain" }, "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 data = yield* fileSystem + .readFile(filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!data) { + respond(500, { "Content-Type": "text/plain" }, "Internal Server Error"); + return; + } + + const contentType = Mime.getType(filePath) ?? "application/octet-stream"; + respond( + 200, + { + "Content-Type": contentType, + "Cache-Control": "public, max-age=31536000, immutable", + }, + data, + ); + 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; + } + + let filePath = path.join(staticDir, url.pathname); + const ext = path.extname(filePath); + if (!ext) { + filePath = path.join(filePath, "index.html"); + } + + const fileInfo = yield* fileSystem + .stat(filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!fileInfo || fileInfo.type !== "File") { + const indexPath = path.join(staticDir, "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; } - res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - res.end(data); - }); - 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"); } - - const fileExt = path.extname(filePath); - const contentType = MIME_TYPES[fileExt] ?? "application/octet-stream"; - - fs.readFile(filePath, (readErr, data) => { - if (readErr) { - res.writeHead(500, { "Content-Type": "text/plain" }); - res.end("Internal Server Error"); - return; - } - res.writeHead(200, { "Content-Type": contentType }); - res.end(data); - }); }); }); @@ -692,6 +754,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); export const ServerLive = Layer.succeed(Server, { - start: createServer(), + start: createServer().pipe(Effect.provide(NodeServices.layer)), stopSignal: Effect.never, } satisfies ServerShape); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index bce86d0e66aba76bf8b7bab24bc605f084cedd0a..848c98fd016deda823e0c88a7b51eedfc2c17368 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -192,6 +192,38 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" { return providerName === "claudeCode" ? "claudeCode" : "codex"; } +function resolveWsHttpOrigin(): string { + if (typeof window === "undefined") { + return ""; + } + const bridgeWsUrl = window.desktopBridge?.getWsUrl?.(); + const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined; + const wsCandidate = + typeof bridgeWsUrl === "string" && bridgeWsUrl.length > 0 + ? bridgeWsUrl + : typeof envWsUrl === "string" && envWsUrl.length > 0 + ? envWsUrl + : null; + if (!wsCandidate) { + return window.location.origin; + } + + try { + const wsUrl = new URL(wsCandidate); + const protocol = wsUrl.protocol === "wss:" ? "https:" : "http:"; + return `${protocol}//${wsUrl.host}`; + } catch { + return window.location.origin; + } +} + +function toAttachmentPreviewUrl(rawUrl: string): string { + if (rawUrl.startsWith("/")) { + return `${resolveWsHttpOrigin()}${rawUrl}`; + } + return rawUrl; +} + function normalizeTerminalIds(terminalIds: string[]): string[] { const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT); @@ -456,7 +488,7 @@ export function reducer(state: AppState, action: Action): AppState { name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, - previewUrl: attachment.dataUrl, + previewUrl: toAttachmentPreviewUrl(attachment.dataUrl), })); const normalizedMessage: ChatMessage = { id: message.id,