[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:
+44
-2
@@ -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();
|
||||
|
||||
+7
-3
@@ -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' },
|
||||
);
|
||||
});
|
||||
|
||||
+7
-3
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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(
|
||||
+25
@@ -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);
|
||||
},
|
||||
};
|
||||
+36
-13
@@ -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();
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ describe('renderFrontComponent', () => {
|
||||
connection: {} as RemoteConnection,
|
||||
renderContext: {
|
||||
componentUrl: 'https://api.twenty.test/rest/front-components/id',
|
||||
componentSource: 'export default () => {};',
|
||||
},
|
||||
hostFetch: null,
|
||||
}),
|
||||
|
||||
+3
-9
@@ -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)
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user