diff --git a/src/features/workspaces/components/WorkspaceFileViewer.tsx b/src/features/workspaces/components/WorkspaceFileViewer.tsx index 51855089..b2a1295f 100644 --- a/src/features/workspaces/components/WorkspaceFileViewer.tsx +++ b/src/features/workspaces/components/WorkspaceFileViewer.tsx @@ -2,6 +2,7 @@ import { type ComponentType, type LazyExoticComponent, lazy, Suspense } from "re import { ContextMenu, ContextMenuTrigger } from "#/components/ui/context-menu"; import { Spinner } from "#/components/ui/spinner"; +import WorkspaceFileViewerErrorBoundary from "#/features/workspaces/components/WorkspaceFileViewerErrorBoundary"; import { WorkspaceItemActionsContextMenuContent } from "#/features/workspaces/components/WorkspaceItemActionsMenu"; import { useWorkspaceViewCapabilities } from "#/features/workspaces/components/workspace-view-policy"; import { getWorkspaceItemDisplay } from "#/features/workspaces/model/item-display"; @@ -47,9 +48,11 @@ export default function WorkspaceFileViewer({ const Viewer = descriptor ? workspaceFileViewers[descriptor.assetKind] : null; const viewCapabilities = useWorkspaceViewCapabilities(); const viewerContent = Viewer ? ( - }> - - + + }> + + + ) : (
diff --git a/src/features/workspaces/components/WorkspaceFileViewerErrorBoundary.tsx b/src/features/workspaces/components/WorkspaceFileViewerErrorBoundary.tsx new file mode 100644 index 00000000..e5efaca5 --- /dev/null +++ b/src/features/workspaces/components/WorkspaceFileViewerErrorBoundary.tsx @@ -0,0 +1,61 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; + +import { Button } from "#/components/ui/button"; +import { capturePostHogClientException } from "#/integrations/posthog/provider"; + +interface WorkspaceFileViewerErrorBoundaryProps { + fileName: string; + children: ReactNode; +} + +interface WorkspaceFileViewerErrorBoundaryState { + error: Error | null; +} + +/** + * Contains file-viewer failures to the viewer pane instead of letting them bubble to the + * root error boundary and take the whole workspace page down. This is the safety net for a + * WorkspaceKernel Durable Object reset dropping a content request mid-flight. + */ +export default class WorkspaceFileViewerErrorBoundary extends Component< + WorkspaceFileViewerErrorBoundaryProps, + WorkspaceFileViewerErrorBoundaryState +> { + state: WorkspaceFileViewerErrorBoundaryState = { + error: null, + }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("Workspace file viewer failed", error, errorInfo); + capturePostHogClientException(error, { + component_stack: errorInfo.componentStack, + error_boundary: "WorkspaceFileViewerErrorBoundary", + }); + } + + render() { + const { error } = this.state; + + if (!error) { + return this.props.children; + } + + return ( +
+
+

Unable to load this file

+

+ Something went wrong while opening “{this.props.fileName}”. Try again in a moment. +

+
+ +
+ ); + } +} diff --git a/src/features/workspaces/kernel/workspace-kernel-access.ts b/src/features/workspaces/kernel/workspace-kernel-access.ts index 0d2c8f4c..b3f463e0 100644 --- a/src/features/workspaces/kernel/workspace-kernel-access.ts +++ b/src/features/workspaces/kernel/workspace-kernel-access.ts @@ -18,6 +18,7 @@ import type { DeleteWorkspaceKernelItemsResult, ListWorkspaceKernelItemRelationsArgs, MoveWorkspaceKernelItemsResult, + ReadWorkspaceKernelFileContentStreamResult, ReadWorkspaceKernelFilePreviewResult, ReadWorkspaceKernelFileProjectionArgs, ReadWorkspaceKernelFileProjectionResult, @@ -100,6 +101,9 @@ export interface WorkspaceKernelClient { fileName: string; sizeBytes: number; }>; + readFileContentStream(input: { + itemId: string; + }): Promise; readFilePreview(input: { itemId: string }): Promise; upsertFileProjection(input: UpsertWorkspaceKernelFileProjectionArgs): Promise; readFileProjection( @@ -114,7 +118,7 @@ export interface WorkspaceKernelClient { purgeForDeletion(): Promise; } -export async function readWorkspaceKernelFileContent(input: { +export async function readWorkspaceKernelFileContentStream(input: { workspaceId: string; userId: string; itemId: string; @@ -125,7 +129,7 @@ export async function readWorkspaceKernelFileContent(input: { await assertCanReadWorkspace(dbContext.db, input); const kernel = await getWorkspaceKernel(input.workspaceId); - return await kernel.readFileContent({ itemId: input.itemId }); + return await kernel.readFileContentStream({ itemId: input.itemId }); } finally { await dbContext.dispose(); } diff --git a/src/features/workspaces/kernel/workspace-kernel-file-commands.ts b/src/features/workspaces/kernel/workspace-kernel-file-commands.ts index e23f733c..e60c8415 100644 --- a/src/features/workspaces/kernel/workspace-kernel-file-commands.ts +++ b/src/features/workspaces/kernel/workspace-kernel-file-commands.ts @@ -17,6 +17,7 @@ import type { CreateWorkspaceKernelFileFromUploadArgs, ReadWorkspaceKernelFileContentArgs, ReadWorkspaceKernelFileContentResult, + ReadWorkspaceKernelFileContentStreamResult, ReadWorkspaceKernelFilePreviewResult, ReadWorkspaceKernelFileProjectionArgs, ReadWorkspaceKernelFileProjectionResult, @@ -191,6 +192,37 @@ export class WorkspaceKernelFileCommands { }; } + async readFileContentStream( + input: ReadWorkspaceKernelFileContentArgs, + ): Promise { + const row = this.store.assertActiveItem(input.itemId); + + if (row.type !== "file") { + throw new Error("Workspace item is not a file."); + } + + // Stream bytes straight from storage (R2 for large files) instead of buffering the + // whole blob into the Durable Object isolate, which trips Cloudflare's memory limit + // and resets the object on large files/PDFs. + const stream = await this.workspace.readFileStream(row.shell_path); + + if (!stream) { + throw new Error("Workspace file content was not found."); + } + + const item = this.store.requireItem(input.itemId); + const contentType = getMetadataString(item.metadataJson, "mimeType"); + const originalName = getMetadataString(item.metadataJson, "originalName"); + const sizeBytes = getMetadataNumber(item.metadataJson, "sizeBytes"); + + return { + stream, + contentType: contentType ?? "application/octet-stream", + fileName: originalName ?? item.name, + sizeBytes: sizeBytes ?? null, + }; + } + async readFilePreview( input: ReadWorkspaceKernelFileContentArgs, ): Promise { diff --git a/src/features/workspaces/kernel/workspace-kernel-types.ts b/src/features/workspaces/kernel/workspace-kernel-types.ts index 26a7c1f6..319c01bd 100644 --- a/src/features/workspaces/kernel/workspace-kernel-types.ts +++ b/src/features/workspaces/kernel/workspace-kernel-types.ts @@ -105,6 +105,13 @@ export interface ReadWorkspaceKernelFileContentResult { sizeBytes: number; } +export interface ReadWorkspaceKernelFileContentStreamResult { + stream: ReadableStream; + contentType: string; + fileName: string; + sizeBytes: number | null; +} + export type WorkspaceKernelFileProjectionFormat = "pages" | "preview"; export type WorkspaceKernelFileProjectionStatus = diff --git a/src/features/workspaces/kernel/workspace-kernel.ts b/src/features/workspaces/kernel/workspace-kernel.ts index cc45e3ef..7c929a5b 100644 --- a/src/features/workspaces/kernel/workspace-kernel.ts +++ b/src/features/workspaces/kernel/workspace-kernel.ts @@ -44,7 +44,11 @@ import type { WorkspaceRealtimeServerMessage, } from "#/features/workspaces/realtime/messages"; -const workspaceKernelInlineThresholdBytes = 1_500_000; +// Keep only small files inline in the Durable Object's SQLite. Anything larger spills to R2 so +// big files/PDFs never bloat a single SQLite row (base64 inflates ~33%), which was tripping +// Cloudflare's storage-operation timeout and resetting the object. Existing files keep whatever +// backend they were written with — this threshold only governs new writes. +const workspaceKernelInlineThresholdBytes = 128 * 1024; export { setWorkspaceKernelUserHeaders }; @@ -149,6 +153,10 @@ export class WorkspaceKernel extends Agent { return await this.fileCommands.readFileContent(input); } + async readFileContentStream(input: ReadWorkspaceKernelFileContentArgs) { + return await this.fileCommands.readFileContentStream(input); + } + async readFilePreview(input: ReadWorkspaceKernelFileContentArgs) { return await this.fileCommands.readFilePreview(input); } diff --git a/src/routes/api/v1/workspaces.$workspaceId.files.$itemId.content.ts b/src/routes/api/v1/workspaces.$workspaceId.files.$itemId.content.ts index 310b9ae8..4fafc76a 100644 --- a/src/routes/api/v1/workspaces.$workspaceId.files.$itemId.content.ts +++ b/src/routes/api/v1/workspaces.$workspaceId.files.$itemId.content.ts @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { readWorkspaceKernelFileContent } from "#/features/workspaces/kernel/workspace-kernel-access"; +import { readWorkspaceKernelFileContentStream } from "#/features/workspaces/kernel/workspace-kernel-access"; import { WorkspaceForbiddenError } from "#/features/workspaces/server/permissions"; import { apiError, getRequestId } from "#/lib/api/http"; import { getSessionFromRequest } from "#/lib/auth-queries.server"; @@ -20,22 +20,24 @@ async function handleWorkspaceFileContent(request: Request, workspaceId: string, ); } - const content = await readWorkspaceKernelFileContent({ + const content = await readWorkspaceKernelFileContentStream({ workspaceId, userId: session.user.id, itemId, }); - const body = new Uint8Array(content.bytes).buffer; - return new Response(body, { - headers: { - "cache-control": "private, max-age=60", - "content-disposition": `inline; filename="${sanitizeHeaderFileName(content.fileName)}"`, - "content-length": String(body.byteLength), - "content-type": content.contentType, - "x-request-id": requestId, - }, + const headers = new Headers({ + "cache-control": "private, max-age=60", + "content-disposition": `inline; filename="${sanitizeHeaderFileName(content.fileName)}"`, + "content-type": content.contentType, + "x-request-id": requestId, }); + + if (content.sizeBytes != null && content.sizeBytes >= 0) { + headers.set("content-length", String(content.sizeBytes)); + } + + return new Response(content.stream, { headers }); } catch (error) { if (error instanceof WorkspaceForbiddenError) { return apiError(