Skip to content

Commit b9e26ae

Browse files
committed
fix(files): stop promising immutable caching for documents compiled against other files
A versioned serve URL (`?v=<updatedAt>`) 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.
1 parent 745cdbe commit b9e26ae

5 files changed

Lines changed: 137 additions & 31 deletions

File tree

‎apps/sim/app/api/files/serve/[...path]/route.test.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,59 @@ describe('File Serve API Route', () => {
439439
expect(mockVerifyFileAccess).not.toHaveBeenCalled()
440440
})
441441

442+
describe('versioned cache lifetime', () => {
443+
const principal = {
444+
kind: 'delegated' as const,
445+
serviceId: 'executor' as const,
446+
subjectUserId: 'test-user-id',
447+
workspaceId: 'test-workspace-id',
448+
delegationId: 'delegation-1',
449+
audience: 'sim:workspace-files',
450+
issuedAt: new Date('2026-08-01T00:00:00Z'),
451+
expiresAt: new Date('2026-08-01T01:00:00Z'),
452+
delegationContext: {
453+
kind: 'workflow_execution' as const,
454+
workflowId: 'workflow-1',
455+
},
456+
}
457+
458+
async function serveVersionedDoc(dependsOnReferencedFiles: boolean) {
459+
mockResolveStoredFileContext.mockResolvedValue('workspace')
460+
mockParseWorkspaceFileKey.mockReturnValue('test-workspace-id')
461+
mockAuthenticateWorkspaceFile.mockResolvedValue(principal)
462+
mockResolveServableDocBytes.mockResolvedValue({
463+
buffer: Buffer.from('compiled'),
464+
contentType: 'application/pdf',
465+
...(dependsOnReferencedFiles ? { dependsOnReferencedFiles: true } : {}),
466+
})
467+
468+
const req = new NextRequest(
469+
'http://localhost:3000/api/files/serve/workspace/test-workspace-id/report.pdf?v=1756684800000'
470+
)
471+
await GET(req, {
472+
params: Promise.resolve({ path: ['workspace', 'test-workspace-id', 'report.pdf'] }),
473+
})
474+
return mockCreateFileResponse.mock.calls.at(-1)?.[0]
475+
}
476+
477+
it('caches a versioned document immutably when its bytes derive from the stored source alone', async () => {
478+
expect(await serveVersionedDoc(false)).toEqual(
479+
expect.objectContaining({ cacheControl: 'private, max-age=31536000, immutable' })
480+
)
481+
})
482+
483+
it('keeps a versioned document revalidated when it was compiled against referenced files', async () => {
484+
/**
485+
* The URL carries the file's own `updatedAt`, which does not move when a
486+
* REFERENCED file changes — so an immutable lifetime would pin the stale
487+
* render in the browser cache until the document itself is edited.
488+
*/
489+
expect(await serveVersionedDoc(true)).toEqual(
490+
expect.objectContaining({ cacheControl: 'private, no-cache, must-revalidate' })
491+
)
492+
})
493+
})
494+
442495
it('serves a mothership chat attachment stored under a workspace key', async () => {
443496
/**
444497
* The attachment shares the `workspace/…` prefix but is recorded as

‎apps/sim/app/api/files/serve/[...path]/route.ts‎

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,22 @@ interface ServeOptions {
6464
raw: boolean
6565
/** `preview=1` — the caller renders these bytes rather than saving them. */
6666
preview: boolean
67-
/** `v=<updatedAt>` — the URL addresses content-immutable bytes. */
67+
/** `v=<updatedAt>` — the caller asserts the URL addresses one fixed content revision. */
6868
versioned: boolean
6969
}
7070

71+
interface ServableBytes {
72+
buffer: Buffer
73+
contentType: string
74+
/**
75+
* These bytes were resolved against OTHER files' current content — a sim page inlining its
76+
* images, or a document compiled against the files it references. The same storage key can
77+
* therefore serve different bytes over time, with nothing about this file changing, so the
78+
* response must stay revalidated even when the request carries a version.
79+
*/
80+
dependsOnReferencedFiles?: boolean
81+
}
82+
7183
/**
7284
* Resolves the bytes + content type to serve for a stored file.
7385
*
@@ -95,7 +107,7 @@ async function resolveServableBytes(params: {
95107
/** The stored record's content type, where the caller has the record. */
96108
fileType?: string
97109
signal: AbortSignal | undefined
98-
}): Promise<{ buffer: Buffer; contentType: string }> {
110+
}): Promise<ServableBytes> {
99111
// `raw` is the stored source, already bounded by the read that produced it, but it
100112
// goes through the same check so the ceiling holds for everything this returns
101113
// rather than for every branch someone remembered to cover.
@@ -120,7 +132,7 @@ async function resolveTransformedBytes(params: {
120132
filePrincipal?: Principal
121133
fileType?: string
122134
signal: AbortSignal | undefined
123-
}): Promise<{ buffer: Buffer; contentType: string }> {
135+
}): Promise<ServableBytes> {
124136
const {
125137
buffer,
126138
filename,
@@ -146,7 +158,8 @@ async function resolveTransformedBytes(params: {
146158
await renderSimPageDocumentWithAssets(text, { workspaceId }),
147159
'utf8'
148160
)
149-
return { buffer: rendered, contentType: 'text/html' }
161+
// Inlines the workspace images the page references, read at their CURRENT content.
162+
return { buffer: rendered, contentType: 'text/html', dependsOnReferencedFiles: true }
150163
}
151164
}
152165

@@ -184,18 +197,29 @@ const WORKSPACE_REVALIDATE_CACHE_CONTROL = 'private, no-cache, must-revalidate'
184197
const PUBLIC_ASSET_CACHE_CONTROL = 'public, max-age=31536000'
185198

186199
/**
187-
* Cache-Control for a served file. A versioned request (`?v=<updatedAt>`) addresses
188-
* content-immutable bytes — generated docs are content-addressed and the version
189-
* bumps on every edit — so the browser may cache it indefinitely; re-opens and
190-
* focus refetches then resolve from cache with no round trip. Unversioned workspace
191-
* reads stay revalidated because the same storage key is edited in place.
200+
* Cache-Control for a served file.
201+
*
202+
* A versioned request (`?v=<updatedAt>`) normally addresses content-immutable bytes: a workspace
203+
* file's content write stores the new bytes under a NEW storage key, so a given key's stored
204+
* source never changes and the browser may cache it indefinitely — re-opens and focus refetches
205+
* then resolve from cache with no round trip.
206+
*
207+
* That promise does NOT hold when the response was resolved against other files' current content
208+
* (`dependsOnReferencedFiles`): a document compiled against the files it references, or a sim page
209+
* inlining its images, recompiles per request, so the same key serves different bytes once a
210+
* referenced file changes — while this file's key and `updatedAt`, and therefore the whole URL,
211+
* stay put. Promising immutability there pins a stale render in the browser cache for a year, so
212+
* those responses stay revalidated whether or not the request carried a version.
192213
*/
193214
function resolveServeCacheControl(
194215
versioned: boolean,
195-
context: string | undefined
216+
context: string | undefined,
217+
dependsOnReferencedFiles: boolean | undefined
196218
): string | undefined {
197-
if (versioned) return IMMUTABLE_CACHE_CONTROL
198-
return context === 'workspace' ? WORKSPACE_REVALIDATE_CACHE_CONTROL : undefined
219+
if (versioned && !dependsOnReferencedFiles) return IMMUTABLE_CACHE_CONTROL
220+
return context === 'workspace' || dependsOnReferencedFiles
221+
? WORKSPACE_REVALIDATE_CACHE_CONTROL
222+
: undefined
199223
}
200224

201225
export const GET = withRouteHandler(
@@ -385,7 +409,11 @@ async function handleWorkspaceFile(
385409
buffer: resolved.buffer,
386410
contentType: resolved.contentType,
387411
filename: file.name,
388-
cacheControl: resolveServeCacheControl(options.versioned, 'workspace'),
412+
cacheControl: resolveServeCacheControl(
413+
options.versioned,
414+
'workspace',
415+
resolved.dependsOnReferencedFiles
416+
),
389417
})
390418
}
391419

@@ -427,7 +455,11 @@ async function handleLocalFile(
427455
const segment = filename.split('/').pop() || filename
428456
const displayName = stripStorageKeyPrefix(segment)
429457
const workspaceId = getWorkspaceIdForCompile(filename)
430-
const { buffer: fileBuffer, contentType } = await resolveServableBytes({
458+
const {
459+
buffer: fileBuffer,
460+
contentType,
461+
dependsOnReferencedFiles,
462+
} = await resolveServableBytes({
431463
buffer: rawBuffer,
432464
filename: displayName,
433465
storageKey: filename,
@@ -443,7 +475,7 @@ async function handleLocalFile(
443475
buffer: fileBuffer,
444476
contentType,
445477
filename: displayName,
446-
cacheControl: resolveServeCacheControl(options.versioned, context),
478+
cacheControl: resolveServeCacheControl(options.versioned, context, dependsOnReferencedFiles),
447479
})
448480
} catch (error) {
449481
logServeFailure('Error reading local file:', error)
@@ -494,7 +526,11 @@ async function handleCloudProxy(
494526
const segment = cloudKey.split('/').pop() || 'download'
495527
const displayName = stripStorageKeyPrefix(segment)
496528
const workspaceId = getWorkspaceIdForCompile(cloudKey)
497-
const { buffer: fileBuffer, contentType } = await resolveServableBytes({
529+
const {
530+
buffer: fileBuffer,
531+
contentType,
532+
dependsOnReferencedFiles,
533+
} = await resolveServableBytes({
498534
buffer: rawBuffer,
499535
filename: displayName,
500536
storageKey: cloudKey,
@@ -515,7 +551,7 @@ async function handleCloudProxy(
515551
buffer: fileBuffer,
516552
contentType,
517553
filename: displayName,
518-
cacheControl: resolveServeCacheControl(options.versioned, context),
554+
cacheControl: resolveServeCacheControl(options.versioned, context, dependsOnReferencedFiles),
519555
})
520556
} catch (error) {
521557
logServeFailure('Error downloading from cloud storage:', error)

‎apps/sim/hooks/queries/workspace-files.ts‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -373,13 +373,14 @@ export class DocNotReadyError extends Error {
373373
/**
374374
* Fetch compiled/binary file content via the serve URL.
375375
*
376-
* A `version` (the file record's `updatedAt`) makes the URL content-immutable: the
377-
* serve route marks versioned responses `immutable`, so the browser HTTP cache
378-
* resolves re-opens and focus refetches with no round trip. Generated docs are
379-
* edited in place (same storage key), so an unversioned caller cannot assume
380-
* immutability and instead busts + bypasses the cache to always read fresh. A 409
381-
* means a generated doc is still compiling — surfaced as {@link DocNotReadyError}
382-
* so the query keeps polling.
376+
* A `version` (the file record's `updatedAt`) lets the serve route mark the response
377+
* `immutable`, so the browser HTTP cache resolves re-opens and focus refetches with no
378+
* round trip. The route withholds that promise for bytes it resolved against OTHER
379+
* files — a doc compiled against its references, a page inlining its images — which the
380+
* same key can serve differently over time. An unversioned caller makes no immutability
381+
* claim at all and busts + bypasses the cache to always read fresh. A 409 means a
382+
* generated doc is still compiling — surfaced as {@link DocNotReadyError} so the query
383+
* keeps polling.
383384
*/
384385
async function fetchWorkspaceFileBinary(
385386
url: string,
@@ -401,11 +402,10 @@ async function fetchWorkspaceFileBinary(
401402
* storage key (e.g. after a file is re-uploaded) correctly busts the cache.
402403
*
403404
* `options.version` is a content version (the record's `updatedAt`) folded into the
404-
* query key. Generated docs are edited IN PLACE — `edit_content` keeps the SAME
405-
* storage key — so without a version the cache is never busted and the open
406-
* preview keeps showing the stale binary after a regenerate. Versioning the key
407-
* makes the preview refetch whenever the file's content changes (and on first
408-
* open, keyed to the current content rather than a stale cached entry).
405+
* query key, and it is what lets the response be cached as immutable. A content write
406+
* rotates the storage key, so `key` alone would already re-key the query; `version`
407+
* additionally covers a recompile that leaves the key alone, and keys the first open to
408+
* the current content rather than a stale cached entry.
409409
*/
410410
export function useWorkspaceFileBinary(
411411
workspaceId: string,

‎apps/sim/lib/copilot/tools/server/files/doc-compile.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,13 @@ export interface CompiledDocResult {
147147
buffer: Buffer
148148
contentType: string
149149
contributingFiles?: readonly WorkspaceFileSecretProvenanceIdentity[]
150+
/**
151+
* The artifact was resolved against OTHER files' current content, so these bytes are not a
152+
* function of this file's stored source alone: the same storage key compiles to different bytes
153+
* once a referenced file changes, with nothing about this file changing. A caller that assigns
154+
* the response a cache lifetime must not promise immutability for it.
155+
*/
156+
dependsOnReferencedFiles?: boolean
150157
}
151158

152159
function referencedImageIdentities(
@@ -912,12 +919,20 @@ export async function resolveServableDocBytes(args: {
912919
const published = await loadCompiledDocByExt(workspaceId, source, extNoDot, {
913920
allowPublishedReferencedArtifact: true,
914921
})
915-
if (published) return published
922+
if (published) return { ...published, dependsOnReferencedFiles: true }
916923
throw new Error(
917924
'Referenced document resolution requires an authorized workspace file principal'
918925
)
919926
}
920-
return compileDoc({ source, fileName, workspaceId, filePrincipal, ownerKey, signal })
927+
const compiled = await compileDoc({
928+
source,
929+
fileName,
930+
workspaceId,
931+
filePrincipal,
932+
ownerKey,
933+
signal,
934+
})
935+
return { ...compiled, dependsOnReferencedFiles: true }
921936
}
922937
const stored = await loadCompiledDocByExt(workspaceId, source, extNoDot, {
923938
allowLegacyReferencedArtifact: true,

‎apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ describe('resolveServableDocBytes', () => {
168168
expect(result).toEqual({
169169
buffer: Buffer.from('%PDF-rebuilt'),
170170
contentType: 'application/pdf',
171+
dependsOnReferencedFiles: true,
171172
contributingFiles: [
172173
{
173174
fileId: 'reference-1',
@@ -273,6 +274,7 @@ describe('resolveServableDocBytes', () => {
273274
).resolves.toEqual({
274275
buffer: publishedArtifact,
275276
contentType: 'application/pdf',
277+
dependsOnReferencedFiles: true,
276278
})
277279
expect(mockReadWorkspaceFileMetadata).not.toHaveBeenCalled()
278280
expect(mockReadWorkspaceFileContent).not.toHaveBeenCalled()

0 commit comments

Comments
 (0)