From 17d34a6fe3dcfcea992d93562cd992c60245449f Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:49:44 +0200 Subject: [PATCH] `[Front-comp-renderer]` Host `componentSource` caching (#22958) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context The front component source cache introduced in the sandbox refactor was a silent no-op: it ran inside the sandboxed worker (opaque-origin `allow-scripts` iframe), where the `caches` global does not exist. Every render re-fetched the component JS from the network — nothing ever appeared in Cache Storage. ## Change image Moves component source resolution and caching from the worker to the host, where Cache Storage works: - `fetchComponentSource`, `fetchComponentSourceFromNetwork`, `frontComponentCacheStorageService` and `extractComponentChecksumFromUrl` relocated from `remote/worker/utils/` to `host/utils/` (`buildAuthorizationHeadersFromAccessToken` to shared `utils/`, still used by the worker for SDK module fetches) - `FrontComponentWorkerEffect` resolves the source before `thread.imports.render(...)` (with a cancellation guard) and passes `componentSource` in the render payload - `loadFrontComponentModule` no longer fetches: it keeps only sandbox-side work (SDK import rewrite, blob URL creation, `import()`) - New: stale-entry eviction — writing a new checksummed entry deletes older entries of the same `front-components/{id}/` prefix ## Security invariant The host only fetches, hashes and caches the source string — it never executes it. Execution stays exclusively in the opaque-origin worker via blob URL import. SHA-256 checksum verification is kept on both cache read (poisoned-entry guard: any same-origin code can write to Cache Storage) and cache write. ## Out of scope SDK client module caching — follow-up tracked in [twentyhq/core-team-issues#2688](https://github.com/twentyhq/core-team-issues/issues/2688), requires content-addressed URLs (server-side checksum at SDK generation time, exposed via GraphQL and embedded in the `/rest/sdk-client/...` URL), then reuses this same host-side cache path. Review in cubic --- .../__tests__/fetchComponentSource.spec.ts | 46 ++++++++++++++++- .../fetchComponentSourceFromNetwork.spec.ts | 10 ++-- .../utils/extractComponentChecksumFromUrl.ts | 0 .../utils/fetchComponentSource.ts | 10 ++-- .../utils/fetchComponentSourceFromNetwork.ts | 5 +- .../frontComponentCacheStorageService.ts | 25 ++++++++++ .../components/FrontComponentWorkerEffect.tsx | 49 ++++++++++++++----- .../__tests__/renderFrontComponent.test.ts | 1 + .../worker/utils/loadFrontComponentModule.ts | 12 ++--- .../worker/utils/renderFrontComponent.ts | 2 +- .../src/types/HostToWorkerRenderContext.ts | 1 + ...uthorizationHeadersFromAccessToken.test.ts | 0 ...uildAuthorizationHeadersFromAccessToken.ts | 0 13 files changed, 129 insertions(+), 32 deletions(-) rename packages/twenty-front-component-renderer/src/{remote/worker => host}/utils/__tests__/fetchComponentSource.spec.ts (83%) rename packages/twenty-front-component-renderer/src/{remote/worker => host}/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts (93%) rename packages/twenty-front-component-renderer/src/{remote/worker => host}/utils/extractComponentChecksumFromUrl.ts (100%) rename packages/twenty-front-component-renderer/src/{remote/worker => host}/utils/fetchComponentSource.ts (77%) rename packages/twenty-front-component-renderer/src/{remote/worker => host}/utils/fetchComponentSourceFromNetwork.ts (93%) rename packages/twenty-front-component-renderer/src/{remote/worker => host}/utils/frontComponentCacheStorageService.ts (73%) rename packages/twenty-front-component-renderer/src/{remote/worker => }/utils/__tests__/buildAuthorizationHeadersFromAccessToken.test.ts (100%) rename packages/twenty-front-component-renderer/src/{remote/worker => }/utils/buildAuthorizationHeadersFromAccessToken.ts (100%) diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSource.spec.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/fetchComponentSource.spec.ts similarity index 83% rename from packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSource.spec.ts rename to packages/twenty-front-component-renderer/src/host/utils/__tests__/fetchComponentSource.spec.ts index 66ca456345..303da49c41 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSource.spec.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/fetchComponentSource.spec.ts @@ -1,7 +1,7 @@ import { createHash, webcrypto } from 'node:crypto'; import { TextEncoder as NodeTextEncoder } from 'node:util'; -import { fetchComponentSource } from '@/remote/worker/utils/fetchComponentSource'; +import { fetchComponentSource } from '@/host/utils/fetchComponentSource'; const COMPONENT_SOURCE = 'export default () => {};'; @@ -46,7 +46,13 @@ class FakeCache { }, ); - delete = jest.fn(async (key: string) => this.store.delete(key)); + delete = jest.fn(async (key: string | { url: string }) => + this.store.delete(typeof key === 'string' ? key : key.url), + ); + + keys = jest.fn(async () => + Array.from(this.store.keys()).map((key) => ({ url: key })), + ); } const isDefinedString = (value: string | undefined): value is string => @@ -120,6 +126,42 @@ describe('fetchComponentSource', () => { expect(cache.put.mock.calls[0][0]).toBe(FINGERPRINTED_URL); }); + it('evicts stale entries of the same component when caching a new build', async () => { + const cache = new FakeCache(); + + const staleUrl = buildFingerprintedUrl( + computeSha256Hex('previous build output'), + ); + const otherComponentUrl = + 'https://api.twenty.com/rest/front-components/other-component-id/0000000000000000000000000000000000000000000000000000000000000000.js'; + + await cache.put(staleUrl, { + text: async () => 'previous build output', + }); + await cache.put(otherComponentUrl, { + text: async () => 'other component source', + }); + cache.put.mockClear(); + + setupCaches(cache); + + const fetchMock = jest.fn(async () => + createFakeJsResponse(COMPONENT_SOURCE), + ); + + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const source = await fetchComponentSource({ url: FINGERPRINTED_URL }); + + // Eviction is fire-and-forget; flush pending promise callbacks + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(source).toBe(COMPONENT_SOURCE); + expect(cache.delete).toHaveBeenCalledWith({ url: staleUrl }); + expect(cache.delete).not.toHaveBeenCalledWith({ url: otherComponentUrl }); + expect(cache.delete).not.toHaveBeenCalledWith({ url: FINGERPRINTED_URL }); + }); + it('serves a verified cache hit without hitting the network', async () => { const cache = new FakeCache(); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts similarity index 93% rename from packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts rename to packages/twenty-front-component-renderer/src/host/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts index 5b4733d130..055e9a339f 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts @@ -1,4 +1,4 @@ -import { fetchComponentSourceFromNetwork } from '@/remote/worker/utils/fetchComponentSourceFromNetwork'; +import { fetchComponentSourceFromNetwork } from '@/host/utils/fetchComponentSourceFromNetwork'; const COMPONENT_URL = 'https://api.twenty.com/rest/front-components/component-id/checksum-abc.js'; @@ -55,6 +55,7 @@ describe('fetchComponentSourceFromNetwork', () => { expect(source).toBe('export default () => {};'); expect(fetchMock).toHaveBeenCalledWith(CACHE_BUSTED_COMPONENT_URL, { headers: { Authorization: 'Bearer token' }, + credentials: 'omit', }); }); @@ -81,8 +82,11 @@ describe('fetchComponentSourceFromNetwork', () => { expect(source).toBe('presigned bundle source'); expect(fetchMock).toHaveBeenNthCalledWith(1, CACHE_BUSTED_COMPONENT_URL, { headers: { Authorization: 'Bearer token' }, + credentials: 'omit', + }); + expect(fetchMock).toHaveBeenNthCalledWith(2, PRESIGNED_URL, { + credentials: 'omit', }); - expect(fetchMock).toHaveBeenNthCalledWith(2, PRESIGNED_URL); }); it('preserves existing query parameters when appending the cache bust parameter', async () => { @@ -98,7 +102,7 @@ describe('fetchComponentSourceFromNetwork', () => { expect(fetchMock).toHaveBeenCalledWith( `${COMPONENT_URL}?token=abc&cacheBust=v2`, - { headers: undefined }, + { headers: undefined, credentials: 'omit' }, ); }); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/extractComponentChecksumFromUrl.ts b/packages/twenty-front-component-renderer/src/host/utils/extractComponentChecksumFromUrl.ts similarity index 100% rename from packages/twenty-front-component-renderer/src/remote/worker/utils/extractComponentChecksumFromUrl.ts rename to packages/twenty-front-component-renderer/src/host/utils/extractComponentChecksumFromUrl.ts diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSource.ts b/packages/twenty-front-component-renderer/src/host/utils/fetchComponentSource.ts similarity index 77% rename from packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSource.ts rename to packages/twenty-front-component-renderer/src/host/utils/fetchComponentSource.ts index a2345c11d9..314696aa01 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSource.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/fetchComponentSource.ts @@ -1,8 +1,8 @@ import { isDefined } from 'twenty-shared/utils'; -import { extractComponentChecksumFromUrl } from '@/remote/worker/utils/extractComponentChecksumFromUrl'; -import { fetchComponentSourceFromNetwork } from '@/remote/worker/utils/fetchComponentSourceFromNetwork'; -import { frontComponentCacheStorageService } from '@/remote/worker/utils/frontComponentCacheStorageService'; +import { extractComponentChecksumFromUrl } from '@/host/utils/extractComponentChecksumFromUrl'; +import { fetchComponentSourceFromNetwork } from '@/host/utils/fetchComponentSourceFromNetwork'; +import { frontComponentCacheStorageService } from '@/host/utils/frontComponentCacheStorageService'; export const fetchComponentSource = async ({ url, @@ -47,6 +47,10 @@ export const fetchComponentSource = async ({ if (sourceChecksum === expectedChecksum) { frontComponentCacheStorageService.write({ cache, url, source }); + frontComponentCacheStorageService.evictStaleEntriesForComponent({ + cache, + url, + }); } } diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSourceFromNetwork.ts b/packages/twenty-front-component-renderer/src/host/utils/fetchComponentSourceFromNetwork.ts similarity index 93% rename from packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSourceFromNetwork.ts rename to packages/twenty-front-component-renderer/src/host/utils/fetchComponentSourceFromNetwork.ts index 1d32fecfb3..e90775efc1 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSourceFromNetwork.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/fetchComponentSourceFromNetwork.ts @@ -31,6 +31,7 @@ export const fetchComponentSourceFromNetwork = async ({ }): Promise => { const response = await fetch(appendCacheBustQueryParameter(url), { headers, + credentials: 'omit', }); if (!response.ok) { @@ -51,7 +52,9 @@ export const fetchComponentSourceFromNetwork = async ({ throw new Error(`Invalid component source handoff response from ${url}`); } - const presignedResponse = await fetch(handoff.data.url); + const presignedResponse = await fetch(handoff.data.url, { + credentials: 'omit', + }); if (!presignedResponse.ok) { throw new Error( diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/frontComponentCacheStorageService.ts b/packages/twenty-front-component-renderer/src/host/utils/frontComponentCacheStorageService.ts similarity index 73% rename from packages/twenty-front-component-renderer/src/remote/worker/utils/frontComponentCacheStorageService.ts rename to packages/twenty-front-component-renderer/src/host/utils/frontComponentCacheStorageService.ts index 242e976557..cf83e4d158 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/utils/frontComponentCacheStorageService.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/frontComponentCacheStorageService.ts @@ -78,4 +78,29 @@ export const frontComponentCacheStorageService = { delete: ({ cache, url }: { cache: Cache; url: string }): void => { cache.delete(url).catch(() => undefined); }, + + evictStaleEntriesForComponent: ({ + cache, + url, + }: { + cache: Cache; + url: string; + }): void => { + const componentUrlPrefix = url.slice(0, url.lastIndexOf('/') + 1); + + cache + .keys() + .then((cachedRequests) => { + const staleRequests = cachedRequests.filter( + (cachedRequest) => + cachedRequest.url !== url && + cachedRequest.url.startsWith(componentUrlPrefix), + ); + + for (const staleRequest of staleRequests) { + cache.delete(staleRequest).catch(() => undefined); + } + }) + .catch(() => undefined); + }, }; diff --git a/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx index 18c71c28ae..1b6d732d36 100644 --- a/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx +++ b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx @@ -5,11 +5,13 @@ import { useEffect, useRef } from 'react'; import { buildHostFetchPolicyFromFrontComponentUrls } from '@/host/utils/buildHostFetchPolicyFromFrontComponentUrls'; import { createFrontComponentHostThread } from '@/host/utils/createFrontComponentHostThread'; import { createHostFetchEnforcingPolicy } from '@/host/utils/createHostFetchEnforcingPolicy'; +import { fetchComponentSource } from '@/host/utils/fetchComponentSource'; import { FRONT_COMPONENT_SANDBOX_DOCUMENT } from '@/remote/sandbox/generated/frontComponentSandboxDocument'; import { createFrontComponentSandboxIframe } from '@/remote/sandbox/utils/createFrontComponentSandboxIframe'; import { createFrontComponentSandboxMessageHandler } from '@/remote/sandbox/utils/createFrontComponentSandboxMessageHandler'; import { type FrontComponentThread } from '@/types/FrontComponentThread'; import { type SdkClientUrls } from '@/types/SdkClientUrls'; +import { buildAuthorizationHeadersFromAccessToken } from '@/utils/buildAuthorizationHeadersFromAccessToken'; type FrontComponentWorkerEffectProps = { componentUrl: string; @@ -71,24 +73,45 @@ export const FrontComponentWorkerEffect = ({ setThread(thread); - thread.imports - .render(newReceiver.connection, { - componentUrl, - applicationAccessToken, - apiUrl, - functionsBaseUrl, - sdkClientUrls, - hostFetchOrigins: hostFetchPolicy.allowedOrigins, - applicationVariables, - }) - .catch((error: Error) => { - setError(error); - }); + let isCancelled = false; + + const resolveComponentSourceAndRender = async () => { + try { + const componentSource = await fetchComponentSource({ + url: componentUrl, + headers: buildAuthorizationHeadersFromAccessToken( + applicationAccessToken, + ), + }); + + if (isCancelled) { + return; + } + + await thread.imports.render(newReceiver.connection, { + componentUrl, + componentSource, + applicationAccessToken, + apiUrl, + functionsBaseUrl, + sdkClientUrls, + hostFetchOrigins: hostFetchPolicy.allowedOrigins, + applicationVariables, + }); + } catch (error) { + if (!isCancelled) { + setError(error instanceof Error ? error : new Error(String(error))); + } + } + }; + + resolveComponentSourceAndRender(); setReceiver(newReceiver); isInitializedRef.current = true; return () => { + isCancelled = true; window.removeEventListener('message', handleSandboxMessage); setThread(null); channel.port1.close(); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/renderFrontComponent.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/renderFrontComponent.test.ts index 04f16e2315..6f21166eca 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/renderFrontComponent.test.ts +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/renderFrontComponent.test.ts @@ -8,6 +8,7 @@ describe('renderFrontComponent', () => { connection: {} as RemoteConnection, renderContext: { componentUrl: 'https://api.twenty.test/rest/front-components/id', + componentSource: 'export default () => {};', }, hostFetch: null, }), diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/loadFrontComponentModule.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/loadFrontComponentModule.ts index cf086f55d4..b00ad1bfd4 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/utils/loadFrontComponentModule.ts +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/loadFrontComponentModule.ts @@ -1,16 +1,15 @@ import { isDefined } from 'twenty-shared/utils'; -import { buildAuthorizationHeadersFromAccessToken } from '@/remote/worker/utils/buildAuthorizationHeadersFromAccessToken'; import { containsSdkClientImportSpecifier } from '@/remote/worker/utils/containsSdkClientImportSpecifier'; import { createJavaScriptModuleBlobUrl } from '@/remote/worker/utils/createJavaScriptModuleBlobUrl'; -import { fetchComponentSource } from '@/remote/worker/utils/fetchComponentSource'; import { fetchSdkClientModulesAsBlobUrls } from '@/remote/worker/utils/fetchSdkClientModulesAsBlobUrls'; import { revokeSdkClientModuleBlobUrls } from '@/remote/worker/utils/revokeSdkClientModuleBlobUrls'; import { rewriteSdkClientImportsToBlobUrls } from '@/remote/worker/utils/rewriteSdkClientImportsToBlobUrls'; import { type SdkClientUrls } from '@/types/SdkClientUrls'; +import { buildAuthorizationHeadersFromAccessToken } from '@/utils/buildAuthorizationHeadersFromAccessToken'; type LoadFrontComponentModuleInput = { - componentUrl: string; + componentSource: string; sdkClientUrls?: SdkClientUrls; applicationAccessToken?: string; }; @@ -20,7 +19,7 @@ type FrontComponentModule = { }; export const loadFrontComponentModule = async ({ - componentUrl, + componentSource, sdkClientUrls, applicationAccessToken, }: LoadFrontComponentModuleInput): Promise => { @@ -28,11 +27,6 @@ export const loadFrontComponentModule = async ({ applicationAccessToken, ); - const componentSource = await fetchComponentSource({ - url: componentUrl, - headers: authorizationHeaders, - }); - const sdkModuleBlobUrls = isDefined(sdkClientUrls) && containsSdkClientImportSpecifier(componentSource) diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/renderFrontComponent.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/renderFrontComponent.ts index abe186f364..386ebe77b6 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/utils/renderFrontComponent.ts +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/renderFrontComponent.ts @@ -33,7 +33,7 @@ export const renderFrontComponent = async ({ setWorkerEnvironmentVariablesFromRenderContext(renderContext); const componentModule = await loadFrontComponentModule({ - componentUrl: renderContext.componentUrl, + componentSource: renderContext.componentSource, sdkClientUrls: renderContext.sdkClientUrls, applicationAccessToken: renderContext.applicationAccessToken, }); diff --git a/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts b/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts index 5d76dc09aa..873d06aa7a 100644 --- a/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts +++ b/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts @@ -2,6 +2,7 @@ import { type SdkClientUrls } from '@/types/SdkClientUrls'; export type HostToWorkerRenderContext = { componentUrl: string; + componentSource: string; applicationAccessToken?: string; apiUrl?: string; functionsBaseUrl?: string; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildAuthorizationHeadersFromAccessToken.test.ts b/packages/twenty-front-component-renderer/src/utils/__tests__/buildAuthorizationHeadersFromAccessToken.test.ts similarity index 100% rename from packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildAuthorizationHeadersFromAccessToken.test.ts rename to packages/twenty-front-component-renderer/src/utils/__tests__/buildAuthorizationHeadersFromAccessToken.test.ts diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/buildAuthorizationHeadersFromAccessToken.ts b/packages/twenty-front-component-renderer/src/utils/buildAuthorizationHeadersFromAccessToken.ts similarity index 100% rename from packages/twenty-front-component-renderer/src/remote/worker/utils/buildAuthorizationHeadersFromAccessToken.ts rename to packages/twenty-front-component-renderer/src/utils/buildAuthorizationHeadersFromAccessToken.ts