From 41ef601a7aa101b3e4b29d8b89867426d70fb154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Fri, 3 Jul 2026 17:14:12 +0200 Subject: [PATCH] perf(twenty-server): cache BuiltFrontComponent and PublicAsset responses (#22523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #22510. Closes #22515 — extends `Cache-Control` to the two remaining app-asset folders that #22510 left `immutable: false` because they're path-addressed. Each now gets the directive that matches **how it is addressed**. ## BuiltFrontComponent → immutable I was wrong in the #22515 write-up to call this "stable URL, mutable bytes." The browser **already content-addresses it**: `FrontComponentRenderer` fetches `/rest/front-components/:id?checksum=${builtComponentChecksum}` (`getFrontComponentUrl`), so a rebuild changes the checksum → changes the URL → busts the cache. That makes `immutable` safe — no stale-code window — and needs no new versioning machinery. Wired the header into `FrontComponentController.getBuiltJs` (which passed no folder) and the front-component presign path. ## PublicAsset → bounded public cache Genuinely path-addressed and overwritten in place on every app (re)install/redeploy (upsert on `['path','workspaceId','applicationId']`), so it **cannot** be `immutable`. Instead: - **`public`** — the `/public-assets/...` endpoint is unauthenticated (`PublicEndpointGuard`), so the bytes are already world-readable; marking the response `public` lets a CDN (e.g. Cloudflare in front of the server) serve app/marketplace logos from the edge instead of hitting the origin on every render. Today these responses carry no `Cache-Control` at all. - **`max-age=3600`, not `immutable`** — a bounded window so an asset overwrite recovers within an hour. This one hour is the single judgement call here; tune it (or add `stale-while-revalidate`) to taste. ## Mechanism Generalized `FileFolderConfig.immutable` (boolean) into `cacheControl` (`string | null`) so a folder can carry its own directive instead of only opting into one hardcoded string. `setFileResponseHeaders` and the presign paths now read `cacheControl` directly. The immutable-folder set is unchanged; only BuiltFrontComponent (→ immutable) and PublicAsset (→ bounded public) move. ## Tests `setFileResponseHeaders` spec updated: BuiltFrontComponent now asserts immutable, PublicAsset asserts `public, max-age=3600`, and the remaining path-addressed folders (`AppTarball`, `Source`, `BuiltLogicFunction`, `Dependencies`) assert no `Cache-Control`. _Note: I bundled both folders into one PR since they share the config generalization — happy to split BuiltFrontComponent (safe/immutable) from PublicAsset (the `max-age` judgement call) if you'd rather review them separately._ https://claude.ai/code/session_01AKwhTxYFDhWhCZ4b7sf35W --- _Generated by [Claude Code](https://claude.ai/code/session_01AKwhTxYFDhWhCZ4b7sf35W)_ Review in cubic --- .../file/controllers/file.controller.ts | 2 +- .../file/interfaces/file-folder.interface.ts | 30 ++++++++-------- .../file/services/file.service.ts | 10 ++---- .../set-file-response-headers.utils.spec.ts | 34 ++++++++++++++----- .../utils/set-file-response-headers.utils.ts | 13 +++---- .../controllers/front-component.controller.ts | 7 +++- .../front-component.service.ts | 4 +++ 7 files changed, 62 insertions(+), 38 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/file/controllers/file.controller.ts b/packages/twenty-server/src/engine/core-modules/file/controllers/file.controller.ts index 24e549cc86..325bfb5c7d 100644 --- a/packages/twenty-server/src/engine/core-modules/file/controllers/file.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/file/controllers/file.controller.ts @@ -92,7 +92,7 @@ export class FileController { return res.redirect(fileResponse.presignedUrl); } - setFileResponseHeaders(res, fileResponse.mimeType); + setFileResponseHeaders(res, fileResponse.mimeType, FileFolder.PublicAsset); try { await pipeline(fileResponse.stream, res); diff --git a/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts b/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts index d2dbcdf07e..9df2b56095 100644 --- a/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts +++ b/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts @@ -8,62 +8,64 @@ registerEnumType(FileFolder, { export type FileFolderConfig = { ignoreExpirationToken: boolean; - immutable: boolean; + cacheControl: string | null; }; export const IMMUTABLE_FILE_CACHE_CONTROL = 'private, max-age=86400, immutable'; +export const PUBLIC_ASSET_CACHE_CONTROL = 'public, max-age=3600'; + export const fileFolderConfigs: Record = { [FileFolder.CorePicture]: { ignoreExpirationToken: true, - immutable: true, + cacheControl: IMMUTABLE_FILE_CACHE_CONTROL, }, [FileFolder.AgentChat]: { ignoreExpirationToken: false, - immutable: true, + cacheControl: IMMUTABLE_FILE_CACHE_CONTROL, }, [FileFolder.BuiltLogicFunction]: { ignoreExpirationToken: false, - immutable: false, + cacheControl: null, }, [FileFolder.BuiltFrontComponent]: { ignoreExpirationToken: false, - immutable: false, + cacheControl: IMMUTABLE_FILE_CACHE_CONTROL, }, [FileFolder.PublicAsset]: { ignoreExpirationToken: true, - immutable: false, + cacheControl: PUBLIC_ASSET_CACHE_CONTROL, }, [FileFolder.Source]: { ignoreExpirationToken: false, - immutable: false, + cacheControl: null, }, [FileFolder.FilesField]: { ignoreExpirationToken: false, - immutable: true, + cacheControl: IMMUTABLE_FILE_CACHE_CONTROL, }, [FileFolder.Dependencies]: { ignoreExpirationToken: false, - immutable: false, + cacheControl: null, }, [FileFolder.Workflow]: { ignoreExpirationToken: false, - immutable: true, + cacheControl: IMMUTABLE_FILE_CACHE_CONTROL, }, [FileFolder.EmailAttachment]: { ignoreExpirationToken: false, - immutable: true, + cacheControl: IMMUTABLE_FILE_CACHE_CONTROL, }, [FileFolder.AppTarball]: { ignoreExpirationToken: false, - immutable: false, + cacheControl: null, }, [FileFolder.GeneratedSdkClient]: { ignoreExpirationToken: false, - immutable: false, + cacheControl: null, }, [FileFolder.Dpa]: { ignoreExpirationToken: false, - immutable: true, + cacheControl: IMMUTABLE_FILE_CACHE_CONTROL, }, }; diff --git a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts index 314303d8b5..ab6c8fc597 100644 --- a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts @@ -13,10 +13,7 @@ import { FileStorageExceptionCode, } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; -import { - fileFolderConfigs, - IMMUTABLE_FILE_CACHE_CONTROL, -} from 'src/engine/core-modules/file/interfaces/file-folder.interface'; +import { fileFolderConfigs } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; import { type FileResponse } from 'src/engine/core-modules/file/types/file-response.type'; import { FILE_STATUS } from 'src/engine/core-modules/file/types/file-status.types'; import { getContentDisposition } from 'src/engine/core-modules/file/utils/get-content-disposition.utils'; @@ -217,9 +214,8 @@ export class FileService { ), responseContentType: mimeType, responseContentDisposition: getContentDisposition(mimeType), - responseCacheControl: fileFolderConfigs[fileFolder].immutable - ? IMMUTABLE_FILE_CACHE_CONTROL - : undefined, + responseCacheControl: + fileFolderConfigs[fileFolder].cacheControl ?? undefined, }); if (presignedUrl) { diff --git a/packages/twenty-server/src/engine/core-modules/file/utils/__tests__/set-file-response-headers.utils.spec.ts b/packages/twenty-server/src/engine/core-modules/file/utils/__tests__/set-file-response-headers.utils.spec.ts index a2db42b2b3..e95de967bc 100644 --- a/packages/twenty-server/src/engine/core-modules/file/utils/__tests__/set-file-response-headers.utils.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/file/utils/__tests__/set-file-response-headers.utils.spec.ts @@ -93,6 +93,7 @@ describe('setFileResponseHeaders', () => { FileFolder.AgentChat, FileFolder.EmailAttachment, FileFolder.Dpa, + FileFolder.BuiltFrontComponent, ])( 'should set an immutable Cache-Control for immutable folder %s', (fileFolder) => { @@ -107,21 +108,36 @@ describe('setFileResponseHeaders', () => { }, ); - it.each([ - FileFolder.PublicAsset, - FileFolder.AppTarball, - FileFolder.Source, - FileFolder.BuiltFrontComponent, - ])('should not set Cache-Control for mutable folder %s', (fileFolder) => { + it('should set a bounded public Cache-Control for the PublicAsset folder', () => { const res = createMockResponse(); - setFileResponseHeaders(res as any, 'image/png', fileFolder); + setFileResponseHeaders(res as any, 'image/png', FileFolder.PublicAsset); - expect(res.setHeader).not.toHaveBeenCalledWith( + expect(res.setHeader).toHaveBeenCalledWith( 'Cache-Control', - expect.anything(), + 'public, max-age=3600', ); }); + + it.each([ + FileFolder.AppTarball, + FileFolder.Source, + FileFolder.BuiltLogicFunction, + FileFolder.Dependencies, + FileFolder.GeneratedSdkClient, + ])( + 'should not set Cache-Control for non-cacheable folder %s', + (fileFolder) => { + const res = createMockResponse(); + + setFileResponseHeaders(res as any, 'image/png', fileFolder); + + expect(res.setHeader).not.toHaveBeenCalledWith( + 'Cache-Control', + expect.anything(), + ); + }, + ); }); describe('getContentDisposition', () => { diff --git a/packages/twenty-server/src/engine/core-modules/file/utils/set-file-response-headers.utils.ts b/packages/twenty-server/src/engine/core-modules/file/utils/set-file-response-headers.utils.ts index c87be51b17..0a5323be86 100644 --- a/packages/twenty-server/src/engine/core-modules/file/utils/set-file-response-headers.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/file/utils/set-file-response-headers.utils.ts @@ -3,10 +3,7 @@ import { type Response } from 'express'; import { FileFolder } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { - fileFolderConfigs, - IMMUTABLE_FILE_CACHE_CONTROL, -} from 'src/engine/core-modules/file/interfaces/file-folder.interface'; +import { fileFolderConfigs } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; import { getContentDisposition } from 'src/engine/core-modules/file/utils/get-content-disposition.utils'; export const setFileResponseHeaders = ( @@ -20,7 +17,11 @@ export const setFileResponseHeaders = ( res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Content-Disposition', getContentDisposition(contentType)); - if (isDefined(fileFolder) && fileFolderConfigs[fileFolder].immutable) { - res.setHeader('Cache-Control', IMMUTABLE_FILE_CACHE_CONTROL); + const cacheControl = isDefined(fileFolder) + ? fileFolderConfigs[fileFolder].cacheControl + : null; + + if (isDefined(cacheControl)) { + res.setHeader('Cache-Control', cacheControl); } }; diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/controllers/front-component.controller.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/controllers/front-component.controller.ts index c87e54d68c..410493cac7 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/controllers/front-component.controller.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/controllers/front-component.controller.ts @@ -11,6 +11,7 @@ import { import { pipeline } from 'stream/promises'; import { Response } from 'express'; +import { FileFolder } from 'twenty-shared/types'; import { FileStorageException, @@ -87,7 +88,11 @@ export class FrontComponentController { return res.redirect(fileResponse.presignedUrl); } - setFileResponseHeaders(res, fileResponse.mimeType); + setFileResponseHeaders( + res, + fileResponse.mimeType, + FileFolder.BuiltFrontComponent, + ); try { await pipeline(fileResponse.stream, res); diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts index 50c064d357..10d728a417 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts @@ -6,6 +6,7 @@ import { isDefined } from 'twenty-shared/utils'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { fileFolderConfigs } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; import { type FileResponse } from 'src/engine/core-modules/file/types/file-response.type'; import { getContentDisposition } from 'src/engine/core-modules/file/utils/get-content-disposition.utils'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -332,6 +333,9 @@ export class FrontComponentService { ), responseContentType: mimeType, responseContentDisposition: getContentDisposition(mimeType), + responseCacheControl: + fileFolderConfigs[FileFolder.BuiltFrontComponent].cacheControl ?? + undefined, }); if (presignedUrl) {