From b9e26aeb85377cd532fc5328722635acc3d1beb7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 21 Sep 2026 23:12:42 -0700 Subject: [PATCH 1/5] fix(files): stop promising immutable caching for documents compiled against other files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A versioned serve URL (`?v=`) was always answered with a one-year `immutable` Cache-Control. That holds for a stored source — a content write rotates the storage key, so a given key's bytes never change — but not for a response the route resolves against OTHER files: a document compiled against the files it references, or a sim page inlining its images, recompiles on every request. Those bytes change when a referenced file changes, while this file's key and `updatedAt` stay put, so the whole URL is unchanged and the browser served a stale render from cache until the document itself was edited. The resolver now reports when it read referenced content, and the route withholds the immutable lifetime for exactly those responses, keeping it for stored sources and self-contained artifacts. Also corrects three comments that claimed generated docs are edited in place under the same storage key. That stopped being true in #5545 (2026-07-13), which made every content write allocate a new key; the caching rule above was reasoned from the stale claim. --- .../api/files/serve/[...path]/route.test.ts | 53 ++++++++++++++ .../app/api/files/serve/[...path]/route.ts | 70 ++++++++++++++----- apps/sim/hooks/queries/workspace-files.ts | 24 +++---- .../copilot/tools/server/files/doc-compile.ts | 19 ++++- .../tools/server/files/doc-servable.test.ts | 2 + 5 files changed, 137 insertions(+), 31 deletions(-) diff --git a/apps/sim/app/api/files/serve/[...path]/route.test.ts b/apps/sim/app/api/files/serve/[...path]/route.test.ts index 38c3cfcf73d..6b215d76449 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.test.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.test.ts @@ -439,6 +439,59 @@ describe('File Serve API Route', () => { expect(mockVerifyFileAccess).not.toHaveBeenCalled() }) + describe('versioned cache lifetime', () => { + const principal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + subjectUserId: 'test-user-id', + workspaceId: 'test-workspace-id', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-08-01T00:00:00Z'), + expiresAt: new Date('2026-08-01T01:00:00Z'), + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: 'workflow-1', + }, + } + + async function serveVersionedDoc(dependsOnReferencedFiles: boolean) { + mockResolveStoredFileContext.mockResolvedValue('workspace') + mockParseWorkspaceFileKey.mockReturnValue('test-workspace-id') + mockAuthenticateWorkspaceFile.mockResolvedValue(principal) + mockResolveServableDocBytes.mockResolvedValue({ + buffer: Buffer.from('compiled'), + contentType: 'application/pdf', + ...(dependsOnReferencedFiles ? { dependsOnReferencedFiles: true } : {}), + }) + + const req = new NextRequest( + 'http://localhost:3000/api/files/serve/workspace/test-workspace-id/report.pdf?v=1756684800000' + ) + await GET(req, { + params: Promise.resolve({ path: ['workspace', 'test-workspace-id', 'report.pdf'] }), + }) + return mockCreateFileResponse.mock.calls.at(-1)?.[0] + } + + it('caches a versioned document immutably when its bytes derive from the stored source alone', async () => { + expect(await serveVersionedDoc(false)).toEqual( + expect.objectContaining({ cacheControl: 'private, max-age=31536000, immutable' }) + ) + }) + + it('keeps a versioned document revalidated when it was compiled against referenced files', async () => { + /** + * The URL carries the file's own `updatedAt`, which does not move when a + * REFERENCED file changes — so an immutable lifetime would pin the stale + * render in the browser cache until the document itself is edited. + */ + expect(await serveVersionedDoc(true)).toEqual( + expect.objectContaining({ cacheControl: 'private, no-cache, must-revalidate' }) + ) + }) + }) + it('serves a mothership chat attachment stored under a workspace key', async () => { /** * The attachment shares the `workspace/…` prefix but is recorded as diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 1bd88eeffd9..fe717f2d189 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -64,10 +64,22 @@ interface ServeOptions { raw: boolean /** `preview=1` — the caller renders these bytes rather than saving them. */ preview: boolean - /** `v=` — the URL addresses content-immutable bytes. */ + /** `v=` — the caller asserts the URL addresses one fixed content revision. */ versioned: boolean } +interface ServableBytes { + buffer: Buffer + contentType: string + /** + * These bytes were resolved against OTHER files' current content — a sim page inlining its + * images, or a document compiled against the files it references. The same storage key can + * therefore serve different bytes over time, with nothing about this file changing, so the + * response must stay revalidated even when the request carries a version. + */ + dependsOnReferencedFiles?: boolean +} + /** * Resolves the bytes + content type to serve for a stored file. * @@ -95,7 +107,7 @@ async function resolveServableBytes(params: { /** The stored record's content type, where the caller has the record. */ fileType?: string signal: AbortSignal | undefined -}): Promise<{ buffer: Buffer; contentType: string }> { +}): Promise { // `raw` is the stored source, already bounded by the read that produced it, but it // goes through the same check so the ceiling holds for everything this returns // rather than for every branch someone remembered to cover. @@ -120,7 +132,7 @@ async function resolveTransformedBytes(params: { filePrincipal?: Principal fileType?: string signal: AbortSignal | undefined -}): Promise<{ buffer: Buffer; contentType: string }> { +}): Promise { const { buffer, filename, @@ -146,7 +158,8 @@ async function resolveTransformedBytes(params: { await renderSimPageDocumentWithAssets(text, { workspaceId }), 'utf8' ) - return { buffer: rendered, contentType: 'text/html' } + // Inlines the workspace images the page references, read at their CURRENT content. + return { buffer: rendered, contentType: 'text/html', dependsOnReferencedFiles: true } } } @@ -184,18 +197,29 @@ const WORKSPACE_REVALIDATE_CACHE_CONTROL = 'private, no-cache, must-revalidate' const PUBLIC_ASSET_CACHE_CONTROL = 'public, max-age=31536000' /** - * Cache-Control for a served file. A versioned request (`?v=`) addresses - * content-immutable bytes — generated docs are content-addressed and the version - * bumps on every edit — so the browser may cache it indefinitely; re-opens and - * focus refetches then resolve from cache with no round trip. Unversioned workspace - * reads stay revalidated because the same storage key is edited in place. + * Cache-Control for a served file. + * + * A versioned request (`?v=`) normally addresses content-immutable bytes: a workspace + * file's content write stores the new bytes under a NEW storage key, so a given key's stored + * source never changes and the browser may cache it indefinitely — re-opens and focus refetches + * then resolve from cache with no round trip. + * + * That promise does NOT hold when the response was resolved against other files' current content + * (`dependsOnReferencedFiles`): a document compiled against the files it references, or a sim page + * inlining its images, recompiles per request, so the same key serves different bytes once a + * referenced file changes — while this file's key and `updatedAt`, and therefore the whole URL, + * stay put. Promising immutability there pins a stale render in the browser cache for a year, so + * those responses stay revalidated whether or not the request carried a version. */ function resolveServeCacheControl( versioned: boolean, - context: string | undefined + context: string | undefined, + dependsOnReferencedFiles: boolean | undefined ): string | undefined { - if (versioned) return IMMUTABLE_CACHE_CONTROL - return context === 'workspace' ? WORKSPACE_REVALIDATE_CACHE_CONTROL : undefined + if (versioned && !dependsOnReferencedFiles) return IMMUTABLE_CACHE_CONTROL + return context === 'workspace' || dependsOnReferencedFiles + ? WORKSPACE_REVALIDATE_CACHE_CONTROL + : undefined } export const GET = withRouteHandler( @@ -385,7 +409,11 @@ async function handleWorkspaceFile( buffer: resolved.buffer, contentType: resolved.contentType, filename: file.name, - cacheControl: resolveServeCacheControl(options.versioned, 'workspace'), + cacheControl: resolveServeCacheControl( + options.versioned, + 'workspace', + resolved.dependsOnReferencedFiles + ), }) } @@ -427,7 +455,11 @@ async function handleLocalFile( const segment = filename.split('/').pop() || filename const displayName = stripStorageKeyPrefix(segment) const workspaceId = getWorkspaceIdForCompile(filename) - const { buffer: fileBuffer, contentType } = await resolveServableBytes({ + const { + buffer: fileBuffer, + contentType, + dependsOnReferencedFiles, + } = await resolveServableBytes({ buffer: rawBuffer, filename: displayName, storageKey: filename, @@ -443,7 +475,7 @@ async function handleLocalFile( buffer: fileBuffer, contentType, filename: displayName, - cacheControl: resolveServeCacheControl(options.versioned, context), + cacheControl: resolveServeCacheControl(options.versioned, context, dependsOnReferencedFiles), }) } catch (error) { logServeFailure('Error reading local file:', error) @@ -494,7 +526,11 @@ async function handleCloudProxy( const segment = cloudKey.split('/').pop() || 'download' const displayName = stripStorageKeyPrefix(segment) const workspaceId = getWorkspaceIdForCompile(cloudKey) - const { buffer: fileBuffer, contentType } = await resolveServableBytes({ + const { + buffer: fileBuffer, + contentType, + dependsOnReferencedFiles, + } = await resolveServableBytes({ buffer: rawBuffer, filename: displayName, storageKey: cloudKey, @@ -515,7 +551,7 @@ async function handleCloudProxy( buffer: fileBuffer, contentType, filename: displayName, - cacheControl: resolveServeCacheControl(options.versioned, context), + cacheControl: resolveServeCacheControl(options.versioned, context, dependsOnReferencedFiles), }) } catch (error) { logServeFailure('Error downloading from cloud storage:', error) diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index c451938dd4e..9d918e79a80 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -373,13 +373,14 @@ export class DocNotReadyError extends Error { /** * Fetch compiled/binary file content via the serve URL. * - * A `version` (the file record's `updatedAt`) makes the URL content-immutable: the - * serve route marks versioned responses `immutable`, so the browser HTTP cache - * resolves re-opens and focus refetches with no round trip. Generated docs are - * edited in place (same storage key), so an unversioned caller cannot assume - * immutability and instead busts + bypasses the cache to always read fresh. A 409 - * means a generated doc is still compiling — surfaced as {@link DocNotReadyError} - * so the query keeps polling. + * A `version` (the file record's `updatedAt`) lets the serve route mark the response + * `immutable`, so the browser HTTP cache resolves re-opens and focus refetches with no + * round trip. The route withholds that promise for bytes it resolved against OTHER + * files — a doc compiled against its references, a page inlining its images — which the + * same key can serve differently over time. An unversioned caller makes no immutability + * claim at all and busts + bypasses the cache to always read fresh. A 409 means a + * generated doc is still compiling — surfaced as {@link DocNotReadyError} so the query + * keeps polling. */ async function fetchWorkspaceFileBinary( url: string, @@ -401,11 +402,10 @@ async function fetchWorkspaceFileBinary( * storage key (e.g. after a file is re-uploaded) correctly busts the cache. * * `options.version` is a content version (the record's `updatedAt`) folded into the - * query key. Generated docs are edited IN PLACE — `edit_content` keeps the SAME - * storage key — so without a version the cache is never busted and the open - * preview keeps showing the stale binary after a regenerate. Versioning the key - * makes the preview refetch whenever the file's content changes (and on first - * open, keyed to the current content rather than a stale cached entry). + * query key, and it is what lets the response be cached as immutable. A content write + * rotates the storage key, so `key` alone would already re-key the query; `version` + * additionally covers a recompile that leaves the key alone, and keys the first open to + * the current content rather than a stale cached entry. */ export function useWorkspaceFileBinary( workspaceId: string, diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts index 41900a665ab..46bcb536e0e 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts @@ -147,6 +147,13 @@ export interface CompiledDocResult { buffer: Buffer contentType: string contributingFiles?: readonly WorkspaceFileSecretProvenanceIdentity[] + /** + * The artifact was resolved against OTHER files' current content, so these bytes are not a + * function of this file's stored source alone: the same storage key compiles to different bytes + * once a referenced file changes, with nothing about this file changing. A caller that assigns + * the response a cache lifetime must not promise immutability for it. + */ + dependsOnReferencedFiles?: boolean } function referencedImageIdentities( @@ -912,12 +919,20 @@ export async function resolveServableDocBytes(args: { const published = await loadCompiledDocByExt(workspaceId, source, extNoDot, { allowPublishedReferencedArtifact: true, }) - if (published) return published + if (published) return { ...published, dependsOnReferencedFiles: true } throw new Error( 'Referenced document resolution requires an authorized workspace file principal' ) } - return compileDoc({ source, fileName, workspaceId, filePrincipal, ownerKey, signal }) + const compiled = await compileDoc({ + source, + fileName, + workspaceId, + filePrincipal, + ownerKey, + signal, + }) + return { ...compiled, dependsOnReferencedFiles: true } } const stored = await loadCompiledDocByExt(workspaceId, source, extNoDot, { allowLegacyReferencedArtifact: true, diff --git a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts b/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts index 87432887d62..5b90f5787b3 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts @@ -168,6 +168,7 @@ describe('resolveServableDocBytes', () => { expect(result).toEqual({ buffer: Buffer.from('%PDF-rebuilt'), contentType: 'application/pdf', + dependsOnReferencedFiles: true, contributingFiles: [ { fileId: 'reference-1', @@ -273,6 +274,7 @@ describe('resolveServableDocBytes', () => { ).resolves.toEqual({ buffer: publishedArtifact, contentType: 'application/pdf', + dependsOnReferencedFiles: true, }) expect(mockReadWorkspaceFileMetadata).not.toHaveBeenCalled() expect(mockReadWorkspaceFileContent).not.toHaveBeenCalled() From 53fad9d59c9d89879f9f77ca52833cd70c03aa60 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 21 Sep 2026 23:15:58 -0700 Subject: [PATCH 2/5] improvement(files): make serve cacheability a declared, required property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An optional boolean let a branch added to the resolver inherit the cacheable default by saying nothing — the exact failure this change exists to prevent. Cacheability is now a required field every branch must declare, so forgetting it fails the build rather than silently promising a year of immutability. --- .../app/api/files/serve/[...path]/route.ts | 72 ++++++++++++------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index fe717f2d189..8463386564f 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -68,16 +68,25 @@ interface ServeOptions { versioned: boolean } +/** + * Whether a resolved response is a function of the stored object alone. + * + * `stored-bytes` may be cached for the life of the storage key: a content write stores the new + * bytes under a NEW key, so a given key's response never changes. `derived-from-referenced-files` + * may not — a sim page inlining its images, or a document compiled against the files it + * references, is re-resolved per request and changes when a referenced file changes, while this + * file's own key and `updatedAt` stay put. + * + * Declared rather than inferred, and REQUIRED, so a branch added to the resolver cannot inherit + * the cacheable default by saying nothing — the same reason the transfer ceiling is asserted where + * the branches converge rather than inside each one. + */ +type ServableCacheability = 'stored-bytes' | 'derived-from-referenced-files' + interface ServableBytes { buffer: Buffer contentType: string - /** - * These bytes were resolved against OTHER files' current content — a sim page inlining its - * images, or a document compiled against the files it references. The same storage key can - * therefore serve different bytes over time, with nothing about this file changing, so the - * response must stay revalidated even when the request carries a version. - */ - dependsOnReferencedFiles?: boolean + cacheability: ServableCacheability } /** @@ -111,8 +120,12 @@ async function resolveServableBytes(params: { // `raw` is the stored source, already bounded by the read that produced it, but it // goes through the same check so the ceiling holds for everything this returns // rather than for every branch someone remembered to cover. - const resolved = params.options.raw - ? { buffer: params.buffer, contentType: getContentType(params.filename) } + const resolved: ServableBytes = params.options.raw + ? { + buffer: params.buffer, + contentType: getContentType(params.filename), + cacheability: 'stored-bytes', + } : await resolveTransformedBytes(params) assertKnownSizeWithinLimit( resolved.buffer.length, @@ -159,7 +172,11 @@ async function resolveTransformedBytes(params: { 'utf8' ) // Inlines the workspace images the page references, read at their CURRENT content. - return { buffer: rendered, contentType: 'text/html', dependsOnReferencedFiles: true } + return { + buffer: rendered, + contentType: 'text/html', + cacheability: 'derived-from-referenced-files', + } } } @@ -167,10 +184,11 @@ async function resolveTransformedBytes(params: { // Images resolve independently of the document path: a HEIF has no compiled-source // concept, so it never reaches the doc branch. const image = await resolveServableImageBytes(buffer, storageKey) - if (image) return image + // Transcoded from THIS file's stored bytes, so it lives and dies with the storage key. + if (image) return { ...image, cacheability: 'stored-bytes' } } - return resolveServableDocBytes({ + const doc = await resolveServableDocBytes({ rawBuffer: buffer, fileName: filename, workspaceId, @@ -178,6 +196,11 @@ async function resolveTransformedBytes(params: { ownerKey, signal, }) + return { + buffer: doc.buffer, + contentType: doc.contentType, + cacheability: doc.dependsOnReferencedFiles ? 'derived-from-referenced-files' : 'stored-bytes', + } } const STORAGE_KEY_PREFIX_RE = /^\d{13}-[a-z0-9]{7}-/ @@ -205,7 +228,7 @@ const PUBLIC_ASSET_CACHE_CONTROL = 'public, max-age=31536000' * then resolve from cache with no round trip. * * That promise does NOT hold when the response was resolved against other files' current content - * (`dependsOnReferencedFiles`): a document compiled against the files it references, or a sim page + * (`derived-from-referenced-files`): a document compiled against the files it references, or a page * inlining its images, recompiles per request, so the same key serves different bytes once a * referenced file changes — while this file's key and `updatedAt`, and therefore the whole URL, * stay put. Promising immutability there pins a stale render in the browser cache for a year, so @@ -214,12 +237,11 @@ const PUBLIC_ASSET_CACHE_CONTROL = 'public, max-age=31536000' function resolveServeCacheControl( versioned: boolean, context: string | undefined, - dependsOnReferencedFiles: boolean | undefined + cacheability: ServableCacheability ): string | undefined { - if (versioned && !dependsOnReferencedFiles) return IMMUTABLE_CACHE_CONTROL - return context === 'workspace' || dependsOnReferencedFiles - ? WORKSPACE_REVALIDATE_CACHE_CONTROL - : undefined + const derived = cacheability === 'derived-from-referenced-files' + if (versioned && !derived) return IMMUTABLE_CACHE_CONTROL + return context === 'workspace' || derived ? WORKSPACE_REVALIDATE_CACHE_CONTROL : undefined } export const GET = withRouteHandler( @@ -409,11 +431,7 @@ async function handleWorkspaceFile( buffer: resolved.buffer, contentType: resolved.contentType, filename: file.name, - cacheControl: resolveServeCacheControl( - options.versioned, - 'workspace', - resolved.dependsOnReferencedFiles - ), + cacheControl: resolveServeCacheControl(options.versioned, 'workspace', resolved.cacheability), }) } @@ -458,7 +476,7 @@ async function handleLocalFile( const { buffer: fileBuffer, contentType, - dependsOnReferencedFiles, + cacheability, } = await resolveServableBytes({ buffer: rawBuffer, filename: displayName, @@ -475,7 +493,7 @@ async function handleLocalFile( buffer: fileBuffer, contentType, filename: displayName, - cacheControl: resolveServeCacheControl(options.versioned, context, dependsOnReferencedFiles), + cacheControl: resolveServeCacheControl(options.versioned, context, cacheability), }) } catch (error) { logServeFailure('Error reading local file:', error) @@ -529,7 +547,7 @@ async function handleCloudProxy( const { buffer: fileBuffer, contentType, - dependsOnReferencedFiles, + cacheability, } = await resolveServableBytes({ buffer: rawBuffer, filename: displayName, @@ -551,7 +569,7 @@ async function handleCloudProxy( buffer: fileBuffer, contentType, filename: displayName, - cacheControl: resolveServeCacheControl(options.versioned, context, dependsOnReferencedFiles), + cacheControl: resolveServeCacheControl(options.versioned, context, cacheability), }) } catch (error) { logServeFailure('Error downloading from cloud storage:', error) From 6ff296974c19ab96531e6ccf849470abaea38dff Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 21 Sep 2026 23:22:51 -0700 Subject: [PATCH 3/5] improvement(files): answer a file revalidation with 304 instead of the whole body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A response the browser is told to revalidate carried no validator, so every check re-sent the entire file. That is the cost a document compiled against other files now pays on each window focus: it cannot be given a cache lifetime, because its bytes really may have changed, so the only way to make the check cheap is to let the client prove what it already holds. Authorized serves now carry an ETag — the digest of the bytes about to be sent, which is exact by construction however those bytes were produced — and answer 304 to a matching If-None-Match. Matching is weak, per RFC 9110, so a cache that stored a weak validator still revalidates. Kept out of createFileResponse deliberately: digesting costs a pass over the buffer, up to the 100MB transfer ceiling, and a response served as immutable is never revalidated, so it would pay that pass and never collect. Public assets and the assistant-image path are unchanged. --- .../api/files/serve/[...path]/route.test.ts | 8 +++ .../app/api/files/serve/[...path]/route.ts | 49 +++++++++------ apps/sim/app/api/files/utils.test.ts | 59 +++++++++++++++++++ apps/sim/app/api/files/utils.ts | 51 ++++++++++++++++ 4 files changed, 149 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/api/files/serve/[...path]/route.test.ts b/apps/sim/app/api/files/serve/[...path]/route.test.ts index 6b215d76449..4d126559bbc 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.test.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.test.ts @@ -36,6 +36,7 @@ const { mockFindLocalFile, mockReadLocalFileWithinLimit, mockCreateFileResponse, + mockCreateConditionalFileResponse, mockCreateErrorResponse, FileNotFoundError, serveLogger, @@ -64,6 +65,7 @@ const { mockFindLocalFile: vi.fn(), mockReadLocalFileWithinLimit: vi.fn(), mockCreateFileResponse: vi.fn(), + mockCreateConditionalFileResponse: vi.fn(), mockCreateErrorResponse: vi.fn(), FileNotFoundError: FileNotFoundErrorClass, } @@ -129,6 +131,7 @@ vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({ vi.mock('@/app/api/files/utils', () => ({ FileNotFoundError, createFileResponse: mockCreateFileResponse, + createConditionalFileResponse: mockCreateConditionalFileResponse, createErrorResponse: mockCreateErrorResponse, getContentType: mockGetContentType, extractStorageKey: vi.fn().mockImplementation((path: string) => path.split('/').pop()), @@ -192,6 +195,11 @@ describe('File Serve API Route', () => { }) } ) + // Delegates so the existing assertions on the response payload — including its + // Cache-Control — read the same call list whichever helper the route reached for. + mockCreateConditionalFileResponse.mockImplementation((file: unknown) => + mockCreateFileResponse(file) + ) mockCreateErrorResponse.mockImplementation((error: Error) => { return new Response(JSON.stringify({ error: error.name, message: error.message }), { status: error.name === 'FileNotFoundError' ? 404 : 500, diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 8463386564f..8c4b8733a62 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -30,6 +30,7 @@ import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/pa import { renderSimPageDocumentWithAssets } from '@/lib/workspace-files/page-document.server' import { type KnowledgeFileAccess, verifyFileAccess } from '@/app/api/files/authorization' import { + createConditionalFileResponse, createErrorResponse, createFileResponse, FileNotFoundError, @@ -66,6 +67,8 @@ interface ServeOptions { preview: boolean /** `v=` — the caller asserts the URL addresses one fixed content revision. */ versioned: boolean + /** The request's `If-None-Match`, so a revalidation can be answered 304 instead of re-sending. */ + ifNoneMatch: string | null } /** @@ -327,6 +330,7 @@ export const GET = withRouteHandler( raw: query.raw === '1', preview: query.preview === '1', versioned: query.v != null, + ifNoneMatch: request.headers.get('if-none-match'), } if (workspacePrincipal) { @@ -427,12 +431,15 @@ async function handleWorkspaceFile( workspaceId, size: resolved.buffer.length, }) - return createFileResponse({ - buffer: resolved.buffer, - contentType: resolved.contentType, - filename: file.name, - cacheControl: resolveServeCacheControl(options.versioned, 'workspace', resolved.cacheability), - }) + return createConditionalFileResponse( + { + buffer: resolved.buffer, + contentType: resolved.contentType, + filename: file.name, + cacheControl: resolveServeCacheControl(options.versioned, 'workspace', resolved.cacheability), + }, + options.ifNoneMatch + ) } async function handleLocalFile( @@ -489,12 +496,15 @@ async function handleLocalFile( logger.info('Local file served', { userId, filename, size: fileBuffer.length }) - return createFileResponse({ - buffer: fileBuffer, - contentType, - filename: displayName, - cacheControl: resolveServeCacheControl(options.versioned, context, cacheability), - }) + return createConditionalFileResponse( + { + buffer: fileBuffer, + contentType, + filename: displayName, + cacheControl: resolveServeCacheControl(options.versioned, context, cacheability), + }, + options.ifNoneMatch + ) } catch (error) { logServeFailure('Error reading local file:', error) throw error @@ -565,12 +575,15 @@ async function handleCloudProxy( context, }) - return createFileResponse({ - buffer: fileBuffer, - contentType, - filename: displayName, - cacheControl: resolveServeCacheControl(options.versioned, context, cacheability), - }) + return createConditionalFileResponse( + { + buffer: fileBuffer, + contentType, + filename: displayName, + cacheControl: resolveServeCacheControl(options.versioned, context, cacheability), + }, + options.ifNoneMatch + ) } catch (error) { logServeFailure('Error downloading from cloud storage:', error) throw error diff --git a/apps/sim/app/api/files/utils.test.ts b/apps/sim/app/api/files/utils.test.ts index 7a01716b04e..bb745372d76 100644 --- a/apps/sim/app/api/files/utils.test.ts +++ b/apps/sim/app/api/files/utils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + createConditionalFileResponse, createFileResponse, encodeFilenameForHeader, extractFilename, @@ -508,3 +509,61 @@ describe('findLocalFile - Path Traversal Security Tests', () => { ) }) }) + +describe('createConditionalFileResponse', () => { + const file = { + buffer: Buffer.from('compiled-document-bytes'), + contentType: 'application/pdf', + filename: 'report.pdf', + cacheControl: 'private, no-cache, must-revalidate', + } + + function etagOf(ifNoneMatch: string | null = null): string { + return createConditionalFileResponse(file, ifNoneMatch).headers.get('ETag') as string + } + + it('sends the body with a strong validator when the client holds nothing', () => { + const response = createConditionalFileResponse(file, null) + + expect(response.status).toBe(200) + expect(response.headers.get('ETag')).toMatch(/^"[A-Za-z0-9_-]+"$/) + expect(response.headers.get('Cache-Control')).toBe('private, no-cache, must-revalidate') + }) + + it('answers 304 without a body when the client already holds these bytes', async () => { + const response = createConditionalFileResponse(file, etagOf()) + + expect(response.status).toBe(304) + expect(await response.text()).toBe('') + // Repeated so the stored response is refreshed with this request's lifetime. + expect(response.headers.get('Cache-Control')).toBe('private, no-cache, must-revalidate') + }) + + it('sends the body when the client holds a validator for different bytes', () => { + const stale = createConditionalFileResponse( + { ...file, buffer: Buffer.from('an-earlier-render') }, + null + ).headers.get('ETag') as string + + expect(createConditionalFileResponse(file, stale).status).toBe(200) + }) + + it('matches weakly, so a cache that stored a weak validator still revalidates', () => { + expect(createConditionalFileResponse(file, `W/${etagOf()}`).status).toBe(304) + }) + + it('matches one entry out of a list, and the wildcard', () => { + expect(createConditionalFileResponse(file, `"other", ${etagOf()}`).status).toBe(304) + expect(createConditionalFileResponse(file, '*').status).toBe(304) + }) + + it('gives bytes that differ only in one byte different validators', () => { + const a = etagOf() + const b = createConditionalFileResponse( + { ...file, buffer: Buffer.from('compiled-document-byteS') }, + null + ).headers.get('ETag') + + expect(a).not.toBe(b) + }) +}) diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index c7bd642db8e..0bd29f54288 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { @@ -267,6 +268,56 @@ export function createFileResponse(file: FileResponse): NextResponse { return new NextResponse(file.buffer as BodyInit, { status: 200, headers }) } +/** + * Whether an `If-None-Match` header claims the client already holds `etag`. + * + * Compared weakly, per RFC 9110: a cache that stored the response under a weak validator sends + * `W/"…"` back, and that still identifies the same bytes for a GET. + */ +function ifNoneMatchHolds(header: string | null, etag: string): boolean { + if (!header) return false + if (header.trim() === '*') return true + return header.split(',').some((candidate) => candidate.trim().replace(/^W\//, '').trim() === etag) +} + +/** + * A file response carrying a strong validator, answering 304 when the client already holds + * exactly these bytes. + * + * For responses the browser is told to revalidate, the alternative is re-sending the whole body on + * every check — and a document resolved against other files is re-resolved per request precisely + * BECAUSE its bytes may have changed, so it cannot be given a cache lifetime instead. The + * validator is the digest of the bytes about to be sent, which makes it exact by construction: it + * cannot claim freshness for a body that differs, however the body was produced. + * + * This is deliberately NOT folded into {@link createFileResponse}. Digesting costs a pass over the + * buffer — up to the full transfer ceiling — which is worth it only where a 304 can actually be + * returned. A response already served as immutable is never revalidated, so it would pay the pass + * and never collect. + */ +export function createConditionalFileResponse( + file: FileResponse, + ifNoneMatch: string | null +): NextResponse { + const etag = `"${createHash('sha256').update(file.buffer).digest('base64url')}"` + + if (ifNoneMatchHolds(ifNoneMatch, etag)) { + // A 304 repeats the headers that govern caching, so the stored response is refreshed with the + // lifetime this request would have granted it rather than keeping the one it was stored with. + return new NextResponse(null, { + status: 304, + headers: { + ETag: etag, + 'Cache-Control': file.cacheControl || 'private, no-cache', + }, + }) + } + + const response = createFileResponse(file) + response.headers.set('ETag', etag) + return response +} + export function createErrorResponse(error: Error, status = 500): NextResponse { const statusCode = error instanceof FileNotFoundError From 9f5b7112effe79206caf43ceca566994618b8152 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 21 Sep 2026 23:30:59 -0700 Subject: [PATCH 4/5] fix(files): report a reference dependency from the isolated-VM compile path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The isolated-VM fallback returns before the static reference scan, so a document it compiled never reported one — yet that path reads workspace files live through its broker, which is what `onWorkspaceFileAccess` records. A versioned request for such a document therefore still took a one-year immutable lifetime, and changing a referenced file left the browser serving a stale render. Fixed at the root rather than in that one branch: the flag is now required on CompiledDocResult, so every compile path must declare it and a new one cannot inherit a cacheable-forever answer by staying silent. Each site reports the union of what the source references statically and what the compile actually touched — neither alone is sufficient, since a failed read records no access and the broker reaches files the static scan cannot see. Making it required immediately surfaced a second case: the process-local compile cache is shared with compiles that carried a workspace, so a cached entry can hold contributor identities even when the reading call passes none. That branch now reads the cached identities instead of assuming independence. --- .../tools/server/files/doc-compile.test.ts | 2 + .../copilot/tools/server/files/doc-compile.ts | 63 ++++++++++++++----- .../tools/server/files/doc-servable.test.ts | 54 +++++++++++++++- 3 files changed, 102 insertions(+), 17 deletions(-) diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.test.ts b/apps/sim/lib/copilot/tools/server/files/doc-compile.test.ts index 0c2c5a0025e..a0a23033053 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compile.test.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compile.test.ts @@ -169,6 +169,7 @@ describe('collectReferencedFileIds', () => { ).resolves.toEqual({ buffer: Buffer.from('%PDF-built'), contentType: 'application/pdf', + dependsOnReferencedFiles: true, contributingFiles: [ { fileId: ID, @@ -219,6 +220,7 @@ describe('collectReferencedFileIds', () => { ).resolves.toEqual({ buffer: Buffer.from('%PDF-cached'), contentType: 'application/pdf', + dependsOnReferencedFiles: true, contributingFiles: [ { fileId: ID, diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts index 46bcb536e0e..1941d2b4353 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts @@ -152,8 +152,24 @@ export interface CompiledDocResult { * function of this file's stored source alone: the same storage key compiles to different bytes * once a referenced file changes, with nothing about this file changing. A caller that assigns * the response a cache lifetime must not promise immutability for it. + * + * Required, so a compile path added or changed later cannot omit it and silently inherit a + * cacheable-forever answer — which is exactly what the isolated-VM fallback did while this was + * optional, despite reading workspace files live through its broker. */ - dependsOnReferencedFiles?: boolean + dependsOnReferencedFiles: boolean +} + +/** + * Whether a compile result must be treated as resolved against other files. + * + * The union of what the source ASKS for and what the compile actually TOUCHED. Neither alone is + * sufficient: a statically detectable reference whose read failed records no access, and the + * isolated-VM broker reaches files the static scan cannot see. Either one means these bytes can + * change while this file's own stored source does not. + */ +function touchesReferencedFiles(source: string, accessedCount: number): boolean { + return accessedCount > 0 || collectReferencedFileIds(source).size > 0 } function referencedImageIdentities( @@ -574,6 +590,7 @@ async function buildCompiledDoc( return { buffer, contentType: fmt.contentType, + dependsOnReferencedFiles: touchesReferencedFiles(args.source, contributingFiles.length), ...(contributingFiles.length > 0 ? { contributingFiles } : {}), } } @@ -664,6 +681,10 @@ async function compileDocInLegacySandbox( return { buffer: cached.buffer, contentType: fmt.contentType, + dependsOnReferencedFiles: touchesReferencedFiles( + args.source, + cached.contributingFiles?.length ?? 0 + ), ...(cached.contributingFiles && cached.contributingFiles.length > 0 ? { contributingFiles: cached.contributingFiles } : {}), @@ -686,6 +707,7 @@ async function compileDocInLegacySandbox( return { buffer, contentType: fmt.contentType, + dependsOnReferencedFiles: touchesReferencedFiles(args.source, contributingFiles.size), ...(contributingFiles.size > 0 ? { contributingFiles: [...contributingFiles.values()] } : {}), } } @@ -729,6 +751,7 @@ export async function compileDoc(args: CompileArgs): Promise return { buffer: existing, contentType: fmt.contentType, + dependsOnReferencedFiles: touchesReferencedFiles(source, contributingFiles.length), ...(contributingFiles.length > 0 ? { contributingFiles } : {}), } } @@ -898,11 +921,19 @@ export async function resolveServableDocBytes(args: { // explicitly alongside the table-driven formats. const magic = format?.magic ?? (extNoDot === 'xlsx' ? ZIP_MAGIC : undefined) if (magic && bufferStartsWith(rawBuffer, magic)) { - return { buffer: rawBuffer, contentType: getContentType(fileName) } + return { + buffer: rawBuffer, + contentType: getContentType(fileName), + dependsOnReferencedFiles: false, + } } if (!format && extNoDot !== 'xlsx') { - return { buffer: rawBuffer, contentType: getContentType(fileName) } + return { + buffer: rawBuffer, + contentType: getContentType(fileName), + dependsOnReferencedFiles: false, + } } const source = rawBuffer.toString('utf-8') @@ -924,21 +955,15 @@ export async function resolveServableDocBytes(args: { 'Referenced document resolution requires an authorized workspace file principal' ) } - const compiled = await compileDoc({ - source, - fileName, - workspaceId, - filePrincipal, - ownerKey, - signal, - }) - return { ...compiled, dependsOnReferencedFiles: true } + return compileDoc({ source, fileName, workspaceId, filePrincipal, ownerKey, signal }) } const stored = await loadCompiledDocByExt(workspaceId, source, extNoDot, { allowLegacyReferencedArtifact: true, filePrincipal, }) - if (stored) return stored + // Reached only where the source references nothing, so the artifact is keyed by the source + // alone and cannot change while that source does not. + if (stored) return { ...stored, dependsOnReferencedFiles: false } throw new DocCompileUserError('Document is still being generated', { pending: true }) } @@ -952,6 +977,12 @@ export async function resolveServableDocBytes(args: { return { buffer: cached.buffer, contentType: format.contentType, + // The process-local cache is shared with compiles that DID carry a workspace, so a cached + // entry can hold contributor identities even though this call passed none. + dependsOnReferencedFiles: touchesReferencedFiles( + source, + cached.contributingFiles?.length ?? 0 + ), ...(cached.contributingFiles && cached.contributingFiles.length > 0 ? { contributingFiles: cached.contributingFiles } : {}), @@ -964,5 +995,9 @@ export async function resolveServableDocBytes(args: { { ownerKey, signal } ) compiledCacheSet(cacheKey, compiled) - return { buffer: compiled, contentType: format.contentType } + return { + buffer: compiled, + contentType: format.contentType, + dependsOnReferencedFiles: touchesReferencedFiles(source, 0), + } } diff --git a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts b/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts index 5b90f5787b3..288ee02cdff 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts @@ -379,11 +379,49 @@ describe('resolveServableDocBytes', () => { fileName: 'report.pdf', workspaceId: WORKSPACE_ID, }) - ).resolves.toEqual({ buffer: compiled, contentType: 'application/pdf' }) + ).resolves.toEqual({ + buffer: compiled, + contentType: 'application/pdf', + // The isolated-VM path, but this source references nothing and the compile touched + // nothing — so it stays cacheable. The flag tracks dependency, not which backend ran. + dependsOnReferencedFiles: false, + }) expect(mockLoadCompiledDoc).not.toHaveBeenCalled() expect(mockStoreCompiledDoc).not.toHaveBeenCalled() }) + it('reports a reference dependency when the isolated-VM broker reads a workspace file', async () => { + /** + * The isolated-VM fallback returns before the static reference scan, so nothing about the + * SOURCE marks it as dependent — only the broker access does. Missing that is what let a + * versioned request take a one-year immutable lifetime for bytes that change when the + * referenced file changes. + */ + const compiled = Buffer.from('%PDF-broker-read') + const contributor = { + fileId: 'broker-reference-1', + key: 'workspace/workspace-1/broker-reference-1.png', + context: 'workspace' as const, + contentUpdatedAt: new Date('2026-08-07T01:00:00.000Z'), + } + setEnvFlags({ isDocSandboxEnabled: false }) + mockRunSandboxTask.mockImplementationOnce((...args: unknown[]) => { + const options = args[2] as { + onWorkspaceFileAccess?: (identity: typeof contributor) => void + } + options.onWorkspaceFileAccess?.(contributor) + return Promise.resolve(compiled) + }) + + const result = await resolveServableDocBytes({ + rawBuffer: Buffer.from('const viaBroker = true'), + fileName: 'report.pdf', + workspaceId: WORKSPACE_ID, + }) + + expect(result.dependsOnReferencedFiles).toBe(true) + }) + it('preserves contributor identities when a servable document hits the local compile cache', async () => { const source = 'const cacheContributor = true' const compiled = Buffer.from('%PDF-cached-with-contributor') @@ -413,6 +451,7 @@ describe('resolveServableDocBytes', () => { ).resolves.toEqual({ buffer: compiled, contentType: 'application/pdf', + dependsOnReferencedFiles: true, contributingFiles: [contributor], }) @@ -425,6 +464,7 @@ describe('resolveServableDocBytes', () => { ).resolves.toEqual({ buffer: compiled, contentType: 'application/pdf', + dependsOnReferencedFiles: true, contributingFiles: [contributor], }) expect(mockRunSandboxTask).toHaveBeenCalledTimes(1) @@ -443,7 +483,11 @@ describe('resolveServableDocBytes', () => { workspaceId: WORKSPACE_ID, }) - expect(result).toEqual({ buffer: compiled, contentType: 'application/pdf' }) + expect(result).toEqual({ + buffer: compiled, + contentType: 'application/pdf', + dependsOnReferencedFiles: true, + }) expect(mockExecuteInSandbox).not.toHaveBeenCalled() expect(mockRunSandboxTask).toHaveBeenCalledWith( 'pdf-generate', @@ -467,7 +511,11 @@ describe('resolveServableDocBytes', () => { fileName: 'report.pdf', workspaceId: WORKSPACE_ID, }) - ).resolves.toEqual({ buffer: compiled, contentType: 'application/pdf' }) + ).resolves.toEqual({ + buffer: compiled, + contentType: 'application/pdf', + dependsOnReferencedFiles: true, + }) expect(mockRunSandboxTask).toHaveBeenCalledTimes(1) expect(mockReadWorkspaceFileMetadata).not.toHaveBeenCalled() expect(mockStoreCompiledDoc).not.toHaveBeenCalled() From c6660759986948ebb101a521b8fa43d2eb7878b3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 21 Sep 2026 23:39:07 -0700 Subject: [PATCH 5/5] improvement(files): compute a validator only where a response can be revalidated Both reviewers caught the same contradiction: the digest was documented as worth paying only where a 304 can be collected, then applied to every authorized serve including immutable ones, which are never revalidated. One place now decides, so an immutable response takes the plain path and spends no pass over its buffer. Also drops a redundant translation. The resolver was converting the compiler's boolean into a string union and the cache rule was converting it straight back; the producer's own required boolean now travels end to end, which is one fact in one shape and keeps the same build-time guarantee that a new branch must declare it. --- .../api/files/serve/[...path]/route.test.ts | 11 +++ .../app/api/files/serve/[...path]/route.ts | 84 ++++++++++++------- 2 files changed, 63 insertions(+), 32 deletions(-) diff --git a/apps/sim/app/api/files/serve/[...path]/route.test.ts b/apps/sim/app/api/files/serve/[...path]/route.test.ts index 4d126559bbc..4920d4f55d2 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.test.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.test.ts @@ -488,6 +488,17 @@ describe('File Serve API Route', () => { ) }) + it('spends no validator on an immutable response, which is never revalidated', async () => { + await serveVersionedDoc(false) + expect(mockCreateConditionalFileResponse).not.toHaveBeenCalled() + expect(mockCreateFileResponse).toHaveBeenCalled() + }) + + it('attaches a validator to a revalidated response so the next check can be answered 304', async () => { + await serveVersionedDoc(true) + expect(mockCreateConditionalFileResponse).toHaveBeenCalled() + }) + it('keeps a versioned document revalidated when it was compiled against referenced files', async () => { /** * The URL carries the file's own `updatedAt`, which does not move when a diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 8c4b8733a62..e946266ec2e 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -34,6 +34,7 @@ import { createErrorResponse, createFileResponse, FileNotFoundError, + type FileResponse, findLocalFile, getContentType, readLocalFileWithinLimit, @@ -71,25 +72,19 @@ interface ServeOptions { ifNoneMatch: string | null } -/** - * Whether a resolved response is a function of the stored object alone. - * - * `stored-bytes` may be cached for the life of the storage key: a content write stores the new - * bytes under a NEW key, so a given key's response never changes. `derived-from-referenced-files` - * may not — a sim page inlining its images, or a document compiled against the files it - * references, is re-resolved per request and changes when a referenced file changes, while this - * file's own key and `updatedAt` stay put. - * - * Declared rather than inferred, and REQUIRED, so a branch added to the resolver cannot inherit - * the cacheable default by saying nothing — the same reason the transfer ceiling is asserted where - * the branches converge rather than inside each one. - */ -type ServableCacheability = 'stored-bytes' | 'derived-from-referenced-files' - interface ServableBytes { buffer: Buffer contentType: string - cacheability: ServableCacheability + /** + * These bytes were resolved against OTHER files' current content — a page inlining its images, + * or a document compiled against the files it references — so the same storage key can serve + * different bytes over time while this file's own key and `updatedAt` stay put. + * + * Required, so a branch added to the resolver cannot inherit the cacheable answer by saying + * nothing — the same reason the transfer ceiling is asserted where the branches converge rather + * than inside each one. + */ + dependsOnReferencedFiles: boolean } /** @@ -127,7 +122,7 @@ async function resolveServableBytes(params: { ? { buffer: params.buffer, contentType: getContentType(params.filename), - cacheability: 'stored-bytes', + dependsOnReferencedFiles: false, } : await resolveTransformedBytes(params) assertKnownSizeWithinLimit( @@ -178,7 +173,7 @@ async function resolveTransformedBytes(params: { return { buffer: rendered, contentType: 'text/html', - cacheability: 'derived-from-referenced-files', + dependsOnReferencedFiles: true, } } } @@ -188,7 +183,7 @@ async function resolveTransformedBytes(params: { // concept, so it never reaches the doc branch. const image = await resolveServableImageBytes(buffer, storageKey) // Transcoded from THIS file's stored bytes, so it lives and dies with the storage key. - if (image) return { ...image, cacheability: 'stored-bytes' } + if (image) return { ...image, dependsOnReferencedFiles: false } } const doc = await resolveServableDocBytes({ @@ -202,7 +197,7 @@ async function resolveTransformedBytes(params: { return { buffer: doc.buffer, contentType: doc.contentType, - cacheability: doc.dependsOnReferencedFiles ? 'derived-from-referenced-files' : 'stored-bytes', + dependsOnReferencedFiles: doc.dependsOnReferencedFiles, } } @@ -240,11 +235,24 @@ const PUBLIC_ASSET_CACHE_CONTROL = 'public, max-age=31536000' function resolveServeCacheControl( versioned: boolean, context: string | undefined, - cacheability: ServableCacheability + dependsOnReferencedFiles: boolean ): string | undefined { - const derived = cacheability === 'derived-from-referenced-files' - if (versioned && !derived) return IMMUTABLE_CACHE_CONTROL - return context === 'workspace' || derived ? WORKSPACE_REVALIDATE_CACHE_CONTROL : undefined + if (versioned && !dependsOnReferencedFiles) return IMMUTABLE_CACHE_CONTROL + return context === 'workspace' || dependsOnReferencedFiles + ? WORKSPACE_REVALIDATE_CACHE_CONTROL + : undefined +} + +/** + * Sends a resolved file, attaching a validator only where the client may actually revalidate. + * + * An immutable response is never revalidated, so digesting its buffer — a pass over up to the + * whole transfer ceiling — would cost the compute and never collect a single 304. + */ +function serveResolvedFile(file: FileResponse, ifNoneMatch: string | null): NextResponse { + return file.cacheControl === IMMUTABLE_CACHE_CONTROL + ? createFileResponse(file) + : createConditionalFileResponse(file, ifNoneMatch) } export const GET = withRouteHandler( @@ -431,12 +439,16 @@ async function handleWorkspaceFile( workspaceId, size: resolved.buffer.length, }) - return createConditionalFileResponse( + return serveResolvedFile( { buffer: resolved.buffer, contentType: resolved.contentType, filename: file.name, - cacheControl: resolveServeCacheControl(options.versioned, 'workspace', resolved.cacheability), + cacheControl: resolveServeCacheControl( + options.versioned, + 'workspace', + resolved.dependsOnReferencedFiles + ), }, options.ifNoneMatch ) @@ -483,7 +495,7 @@ async function handleLocalFile( const { buffer: fileBuffer, contentType, - cacheability, + dependsOnReferencedFiles, } = await resolveServableBytes({ buffer: rawBuffer, filename: displayName, @@ -496,12 +508,16 @@ async function handleLocalFile( logger.info('Local file served', { userId, filename, size: fileBuffer.length }) - return createConditionalFileResponse( + return serveResolvedFile( { buffer: fileBuffer, contentType, filename: displayName, - cacheControl: resolveServeCacheControl(options.versioned, context, cacheability), + cacheControl: resolveServeCacheControl( + options.versioned, + context, + dependsOnReferencedFiles + ), }, options.ifNoneMatch ) @@ -557,7 +573,7 @@ async function handleCloudProxy( const { buffer: fileBuffer, contentType, - cacheability, + dependsOnReferencedFiles, } = await resolveServableBytes({ buffer: rawBuffer, filename: displayName, @@ -575,12 +591,16 @@ async function handleCloudProxy( context, }) - return createConditionalFileResponse( + return serveResolvedFile( { buffer: fileBuffer, contentType, filename: displayName, - cacheControl: resolveServeCacheControl(options.versioned, context, cacheability), + cacheControl: resolveServeCacheControl( + options.versioned, + context, + dependsOnReferencedFiles + ), }, options.ifNoneMatch )