Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/features/workspaces/components/WorkspaceFileViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -47,9 +48,11 @@ export default function WorkspaceFileViewer({
const Viewer = descriptor ? workspaceFileViewers[descriptor.assetKind] : null;
const viewCapabilities = useWorkspaceViewCapabilities();
const viewerContent = Viewer ? (
<Suspense fallback={<WorkspaceFileViewerSkeleton />}>
<Viewer item={item} toolbarSlotId={toolbarSlotId} workspaceId={workspaceId} />
</Suspense>
<WorkspaceFileViewerErrorBoundary key={item.id} fileName={item.name}>
<Suspense fallback={<WorkspaceFileViewerSkeleton />}>
<Viewer item={item} toolbarSlotId={toolbarSlotId} workspaceId={workspaceId} />
</Suspense>
</WorkspaceFileViewerErrorBoundary>
) : (
<div className="flex h-full items-center justify-center bg-background">
<WorkspaceUnsupportedFilePlaceholder item={item} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex h-full min-h-0 flex-col items-center justify-center gap-3 bg-background px-4 text-center">
<div className="space-y-1">
<h2 className="font-medium text-foreground text-sm">Unable to load this file</h2>
<p className="text-muted-foreground text-xs">
Something went wrong while opening “{this.props.fileName}”. Try again in a moment.
</p>
</div>
<Button variant="outline" size="sm" onClick={() => this.setState({ error: null })}>
Retry
</Button>
</div>
);
}
}
8 changes: 6 additions & 2 deletions src/features/workspaces/kernel/workspace-kernel-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
DeleteWorkspaceKernelItemsResult,
ListWorkspaceKernelItemRelationsArgs,
MoveWorkspaceKernelItemsResult,
ReadWorkspaceKernelFileContentStreamResult,
ReadWorkspaceKernelFilePreviewResult,
ReadWorkspaceKernelFileProjectionArgs,
ReadWorkspaceKernelFileProjectionResult,
Expand Down Expand Up @@ -100,6 +101,9 @@ export interface WorkspaceKernelClient {
fileName: string;
sizeBytes: number;
}>;
readFileContentStream(input: {
itemId: string;
}): Promise<ReadWorkspaceKernelFileContentStreamResult>;
readFilePreview(input: { itemId: string }): Promise<ReadWorkspaceKernelFilePreviewResult | null>;
upsertFileProjection(input: UpsertWorkspaceKernelFileProjectionArgs): Promise<void>;
readFileProjection(
Expand All @@ -114,7 +118,7 @@ export interface WorkspaceKernelClient {
purgeForDeletion(): Promise<void>;
}

export async function readWorkspaceKernelFileContent(input: {
export async function readWorkspaceKernelFileContentStream(input: {
workspaceId: string;
userId: string;
itemId: string;
Expand All @@ -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();
}
Expand Down
32 changes: 32 additions & 0 deletions src/features/workspaces/kernel/workspace-kernel-file-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
CreateWorkspaceKernelFileFromUploadArgs,
ReadWorkspaceKernelFileContentArgs,
ReadWorkspaceKernelFileContentResult,
ReadWorkspaceKernelFileContentStreamResult,
ReadWorkspaceKernelFilePreviewResult,
ReadWorkspaceKernelFileProjectionArgs,
ReadWorkspaceKernelFileProjectionResult,
Expand Down Expand Up @@ -191,6 +192,37 @@ export class WorkspaceKernelFileCommands {
};
}

async readFileContentStream(
input: ReadWorkspaceKernelFileContentArgs,
): Promise<ReadWorkspaceKernelFileContentStreamResult> {
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<ReadWorkspaceKernelFilePreviewResult | null> {
Expand Down
7 changes: 7 additions & 0 deletions src/features/workspaces/kernel/workspace-kernel-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ export interface ReadWorkspaceKernelFileContentResult {
sizeBytes: number;
}

export interface ReadWorkspaceKernelFileContentStreamResult {
stream: ReadableStream<Uint8Array>;
contentType: string;
fileName: string;
sizeBytes: number | null;
}

export type WorkspaceKernelFileProjectionFormat = "pages" | "preview";

export type WorkspaceKernelFileProjectionStatus =
Expand Down
10 changes: 9 additions & 1 deletion src/features/workspaces/kernel/workspace-kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -149,6 +153,10 @@ export class WorkspaceKernel extends Agent<Cloudflare.Env> {
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);
}
Expand Down
24 changes: 13 additions & 11 deletions src/routes/api/v1/workspaces.$workspaceId.files.$itemId.content.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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(
Expand Down
Loading