[Front-comp-renderer] Host componentSource caching (#22958)

## 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
<img width="1580" height="622" alt="image"
src="https://github.com/user-attachments/assets/06903abf-b313-4d15-8db4-80950d4bf5ba"
/>

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.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22958?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Paul Rastoin
2026-07-16 17:49:44 +02:00
committed by GitHub
parent 2a94736ece
commit 17d34a6fe3
13 changed files with 129 additions and 32 deletions
@@ -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();
@@ -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' },
);
});
@@ -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,
});
}
}
@@ -31,6 +31,7 @@ export const fetchComponentSourceFromNetwork = async ({
}): Promise<string> => {
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(
@@ -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);
},
};
@@ -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();
@@ -8,6 +8,7 @@ describe('renderFrontComponent', () => {
connection: {} as RemoteConnection,
renderContext: {
componentUrl: 'https://api.twenty.test/rest/front-components/id',
componentSource: 'export default () => {};',
},
hostFetch: null,
}),
@@ -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<FrontComponentModule> => {
@@ -28,11 +27,6 @@ export const loadFrontComponentModule = async ({
applicationAccessToken,
);
const componentSource = await fetchComponentSource({
url: componentUrl,
headers: authorizationHeaders,
});
const sdkModuleBlobUrls =
isDefined(sdkClientUrls) &&
containsSdkClientImportSpecifier(componentSource)
@@ -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,
});
@@ -2,6 +2,7 @@ import { type SdkClientUrls } from '@/types/SdkClientUrls';
export type HostToWorkerRenderContext = {
componentUrl: string;
componentSource: string;
applicationAccessToken?: string;
apiUrl?: string;
functionsBaseUrl?: string;