[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:
+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();
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
import { buildAuthorizationHeadersFromAccessToken } from '../buildAuthorizationHeadersFromAccessToken';
|
||||
|
||||
describe('buildAuthorizationHeadersFromAccessToken', () => {
|
||||
it('should build a bearer authorization header from the access token', () => {
|
||||
expect(buildAuthorizationHeadersFromAccessToken('access-token')).toEqual({
|
||||
Authorization: 'Bearer access-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when no access token is provided', () => {
|
||||
expect(buildAuthorizationHeadersFromAccessToken(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when the access token is empty', () => {
|
||||
expect(buildAuthorizationHeadersFromAccessToken('')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
-286
@@ -1,286 +0,0 @@
|
||||
import { createHash, webcrypto } from 'node:crypto';
|
||||
import { TextEncoder as NodeTextEncoder } from 'node:util';
|
||||
|
||||
import { fetchComponentSource } from '@/remote/worker/utils/fetchComponentSource';
|
||||
|
||||
const COMPONENT_SOURCE = 'export default () => {};';
|
||||
|
||||
const computeSha256Hex = (content: string): string =>
|
||||
createHash('sha256').update(content).digest('hex');
|
||||
|
||||
const buildFingerprintedUrl = (checksum: string): string =>
|
||||
`https://api.twenty.com/rest/front-components/component-id/${checksum}.js`;
|
||||
|
||||
const FINGERPRINTED_URL = buildFingerprintedUrl(
|
||||
computeSha256Hex(COMPONENT_SOURCE),
|
||||
);
|
||||
const BARE_URL = 'https://api.twenty.com/rest/front-components/component-id';
|
||||
const LEGACY_MD5_URL = buildFingerprintedUrl(
|
||||
createHash('md5').update(COMPONENT_SOURCE).digest('hex'),
|
||||
);
|
||||
|
||||
const createFakeJsResponse = (body: string) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name === 'content-type' ? 'application/javascript' : null,
|
||||
},
|
||||
text: jest.fn(async () => body),
|
||||
json: jest.fn(async () => undefined),
|
||||
});
|
||||
|
||||
class FakeCache {
|
||||
private readonly store = new Map<string, string>();
|
||||
|
||||
match = jest.fn(async (key: string) => {
|
||||
const cached = this.store.get(key);
|
||||
|
||||
return isDefinedString(cached) ? { text: async () => cached } : undefined;
|
||||
});
|
||||
|
||||
put = jest.fn(
|
||||
async (key: string, response: { text: () => Promise<string> }) => {
|
||||
this.store.set(key, await response.text());
|
||||
},
|
||||
);
|
||||
|
||||
delete = jest.fn(async (key: string) => this.store.delete(key));
|
||||
}
|
||||
|
||||
const isDefinedString = (value: string | undefined): value is string =>
|
||||
value !== undefined;
|
||||
|
||||
const setupCaches = (cache: FakeCache) => {
|
||||
(globalThis as unknown as { caches: unknown }).caches = {
|
||||
open: jest.fn(async () => cache),
|
||||
};
|
||||
};
|
||||
|
||||
describe('fetchComponentSource', () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalCaches = (globalThis as unknown as { caches?: unknown }).caches;
|
||||
const originalResponse = (globalThis as unknown as { Response?: unknown })
|
||||
.Response;
|
||||
const originalCrypto = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
|
||||
const originalTextEncoder = (
|
||||
globalThis as unknown as { TextEncoder?: unknown }
|
||||
).TextEncoder;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as { Response: unknown }).Response = class {
|
||||
body: string;
|
||||
constructor(body: string) {
|
||||
this.body = body;
|
||||
}
|
||||
async text() {
|
||||
return this.body;
|
||||
}
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
value: webcrypto,
|
||||
configurable: true,
|
||||
});
|
||||
(globalThis as unknown as { TextEncoder: unknown }).TextEncoder =
|
||||
NodeTextEncoder;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
(globalThis as unknown as { caches?: unknown }).caches = originalCaches;
|
||||
(globalThis as unknown as { Response?: unknown }).Response =
|
||||
originalResponse;
|
||||
|
||||
if (originalCrypto !== undefined) {
|
||||
Object.defineProperty(globalThis, 'crypto', originalCrypto);
|
||||
}
|
||||
|
||||
(globalThis as unknown as { TextEncoder?: unknown }).TextEncoder =
|
||||
originalTextEncoder;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('fetches from the network and caches the result on a miss', async () => {
|
||||
const cache = new FakeCache();
|
||||
|
||||
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 });
|
||||
|
||||
expect(source).toBe(COMPONENT_SOURCE);
|
||||
expect(cache.put).toHaveBeenCalledTimes(1);
|
||||
expect(cache.put.mock.calls[0][0]).toBe(FINGERPRINTED_URL);
|
||||
});
|
||||
|
||||
it('serves a verified cache hit without hitting the network', async () => {
|
||||
const cache = new FakeCache();
|
||||
|
||||
await cache.put(FINGERPRINTED_URL, {
|
||||
text: async () => COMPONENT_SOURCE,
|
||||
});
|
||||
|
||||
setupCaches(cache);
|
||||
|
||||
const fetchMock = jest.fn();
|
||||
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const source = await fetchComponentSource({ url: FINGERPRINTED_URL });
|
||||
|
||||
expect(source).toBe(COMPONENT_SOURCE);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('evicts a poisoned cache entry and refetches from the network', async () => {
|
||||
const cache = new FakeCache();
|
||||
|
||||
await cache.put(FINGERPRINTED_URL, {
|
||||
text: async () => 'globalThis.injectedByAnotherComponent = true;',
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
expect(source).toBe(COMPONENT_SOURCE);
|
||||
expect(cache.delete).toHaveBeenCalledWith(FINGERPRINTED_URL);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to the network when the cached response body is unreadable', async () => {
|
||||
const cache = new FakeCache();
|
||||
|
||||
setupCaches(cache);
|
||||
|
||||
cache.match.mockResolvedValueOnce({
|
||||
text: async () => {
|
||||
throw new Error('body unreadable');
|
||||
},
|
||||
});
|
||||
|
||||
const fetchMock = jest.fn(async () =>
|
||||
createFakeJsResponse(COMPONENT_SOURCE),
|
||||
);
|
||||
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const source = await fetchComponentSource({ url: FINGERPRINTED_URL });
|
||||
|
||||
expect(source).toBe(COMPONENT_SOURCE);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not cache a network response whose checksum does not match the URL', async () => {
|
||||
const cache = new FakeCache();
|
||||
|
||||
setupCaches(cache);
|
||||
|
||||
const staleUrl = buildFingerprintedUrl(
|
||||
computeSha256Hex('some other build output'),
|
||||
);
|
||||
|
||||
const fetchMock = jest.fn(async () =>
|
||||
createFakeJsResponse(COMPONENT_SOURCE),
|
||||
);
|
||||
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const source = await fetchComponentSource({ url: staleUrl });
|
||||
|
||||
expect(source).toBe(COMPONENT_SOURCE);
|
||||
expect(cache.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never touches the cache for a non-fingerprinted URL', async () => {
|
||||
const cache = new FakeCache();
|
||||
|
||||
setupCaches(cache);
|
||||
|
||||
const fetchMock = jest.fn(async () =>
|
||||
createFakeJsResponse('bare url source'),
|
||||
);
|
||||
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const source = await fetchComponentSource({ url: BARE_URL });
|
||||
|
||||
expect(source).toBe('bare url source');
|
||||
expect(cache.match).not.toHaveBeenCalled();
|
||||
expect(cache.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never touches the cache for a legacy md5-fingerprinted URL', async () => {
|
||||
const cache = new FakeCache();
|
||||
|
||||
setupCaches(cache);
|
||||
|
||||
const fetchMock = jest.fn(async () =>
|
||||
createFakeJsResponse(COMPONENT_SOURCE),
|
||||
);
|
||||
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const source = await fetchComponentSource({ url: LEGACY_MD5_URL });
|
||||
|
||||
expect(source).toBe(COMPONENT_SOURCE);
|
||||
expect(cache.match).not.toHaveBeenCalled();
|
||||
expect(cache.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the network when CacheStorage is unavailable', async () => {
|
||||
(globalThis as unknown as { caches?: unknown }).caches = undefined;
|
||||
|
||||
const fetchMock = jest.fn(async () =>
|
||||
createFakeJsResponse(COMPONENT_SOURCE),
|
||||
);
|
||||
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const source = await fetchComponentSource({ url: FINGERPRINTED_URL });
|
||||
|
||||
expect(source).toBe(COMPONENT_SOURCE);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not trust the cache when WebCrypto is unavailable', async () => {
|
||||
const cache = new FakeCache();
|
||||
|
||||
await cache.put(FINGERPRINTED_URL, {
|
||||
text: async () => COMPONENT_SOURCE,
|
||||
});
|
||||
cache.put.mockClear();
|
||||
|
||||
setupCaches(cache);
|
||||
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const fetchMock = jest.fn(async () =>
|
||||
createFakeJsResponse(COMPONENT_SOURCE),
|
||||
);
|
||||
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const source = await fetchComponentSource({ url: FINGERPRINTED_URL });
|
||||
|
||||
expect(source).toBe(COMPONENT_SOURCE);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(cache.put).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
-153
@@ -1,153 +0,0 @@
|
||||
import { fetchComponentSourceFromNetwork } from '@/remote/worker/utils/fetchComponentSourceFromNetwork';
|
||||
|
||||
const COMPONENT_URL =
|
||||
'https://api.twenty.com/rest/front-components/component-id/checksum-abc.js';
|
||||
const CACHE_BUSTED_COMPONENT_URL = `${COMPONENT_URL}?cacheBust=v2`;
|
||||
const PRESIGNED_URL = 'https://s3.example.com/component.js?signed=abc';
|
||||
|
||||
type FakeResponseInit = {
|
||||
ok?: boolean;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
contentType?: string;
|
||||
body?: string;
|
||||
json?: unknown;
|
||||
};
|
||||
|
||||
const createFakeResponse = ({
|
||||
ok = true,
|
||||
status = 200,
|
||||
statusText = 'OK',
|
||||
contentType = 'application/javascript',
|
||||
body = '',
|
||||
json,
|
||||
}: FakeResponseInit) => ({
|
||||
ok,
|
||||
status,
|
||||
statusText,
|
||||
headers: {
|
||||
get: (name: string) => (name === 'content-type' ? contentType : null),
|
||||
},
|
||||
text: jest.fn(async () => body),
|
||||
json: jest.fn(async () => json),
|
||||
});
|
||||
|
||||
describe('fetchComponentSourceFromNetwork', () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns the raw body and forwards headers for a direct JS response', async () => {
|
||||
const fetchMock = jest.fn(async () =>
|
||||
createFakeResponse({ body: 'export default () => {};' }),
|
||||
);
|
||||
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const source = await fetchComponentSourceFromNetwork({
|
||||
url: COMPONENT_URL,
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
});
|
||||
|
||||
expect(source).toBe('export default () => {};');
|
||||
expect(fetchMock).toHaveBeenCalledWith(CACHE_BUSTED_COMPONENT_URL, {
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
});
|
||||
});
|
||||
|
||||
it('follows the JSON handoff and fetches the presigned URL header-less', async () => {
|
||||
const fetchMock = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
createFakeResponse({
|
||||
contentType: 'application/json',
|
||||
json: { url: PRESIGNED_URL },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createFakeResponse({ body: 'presigned bundle source' }),
|
||||
);
|
||||
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const source = await fetchComponentSourceFromNetwork({
|
||||
url: COMPONENT_URL,
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
});
|
||||
|
||||
expect(source).toBe('presigned bundle source');
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, CACHE_BUSTED_COMPONENT_URL, {
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
});
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, PRESIGNED_URL);
|
||||
});
|
||||
|
||||
it('preserves existing query parameters when appending the cache bust parameter', async () => {
|
||||
const fetchMock = jest.fn(async () =>
|
||||
createFakeResponse({ body: 'export default () => {};' }),
|
||||
);
|
||||
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await fetchComponentSourceFromNetwork({
|
||||
url: `${COMPONENT_URL}?token=abc`,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${COMPONENT_URL}?token=abc&cacheBust=v2`,
|
||||
{ headers: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the initial response is not ok', async () => {
|
||||
const fetchMock = jest.fn(async () =>
|
||||
createFakeResponse({ ok: false, status: 404, statusText: 'Not Found' }),
|
||||
);
|
||||
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
fetchComponentSourceFromNetwork({ url: COMPONENT_URL }),
|
||||
).rejects.toThrow(`Failed to fetch ${COMPONENT_URL}: 404 Not Found`);
|
||||
});
|
||||
|
||||
it('throws when the JSON handoff payload is invalid', async () => {
|
||||
const fetchMock = jest.fn(async () =>
|
||||
createFakeResponse({
|
||||
contentType: 'application/json',
|
||||
json: { notUrl: 'nope' },
|
||||
}),
|
||||
);
|
||||
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
fetchComponentSourceFromNetwork({ url: COMPONENT_URL }),
|
||||
).rejects.toThrow(
|
||||
`Invalid component source handoff response from ${COMPONENT_URL}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the presigned response is not ok', async () => {
|
||||
const fetchMock = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
createFakeResponse({
|
||||
contentType: 'application/json',
|
||||
json: { url: PRESIGNED_URL },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createFakeResponse({ ok: false, status: 403, statusText: 'Forbidden' }),
|
||||
);
|
||||
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
fetchComponentSourceFromNetwork({ url: COMPONENT_URL }),
|
||||
).rejects.toThrow('Failed to fetch presigned URL: 403 Forbidden');
|
||||
});
|
||||
});
|
||||
+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,
|
||||
}),
|
||||
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
export const buildAuthorizationHeadersFromAccessToken = (
|
||||
applicationAccessToken?: string,
|
||||
): Record<string, string> | undefined =>
|
||||
isNonEmptyString(applicationAccessToken)
|
||||
? { Authorization: `Bearer ${applicationAccessToken}` }
|
||||
: undefined;
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
const SHA256_FINGERPRINTED_URL_PATTERN = /\/([0-9a-f]{64})\.js$/;
|
||||
|
||||
export const extractComponentChecksumFromUrl = ({
|
||||
url,
|
||||
}: {
|
||||
url: string;
|
||||
}): string | undefined => {
|
||||
const match = url.match(SHA256_FINGERPRINTED_URL_PATTERN);
|
||||
|
||||
return match?.[1];
|
||||
};
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
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';
|
||||
|
||||
export const fetchComponentSource = async ({
|
||||
url,
|
||||
headers,
|
||||
}: {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): Promise<string> => {
|
||||
const expectedChecksum = extractComponentChecksumFromUrl({ url });
|
||||
|
||||
const cache = isDefined(expectedChecksum)
|
||||
? await frontComponentCacheStorageService.open()
|
||||
: undefined;
|
||||
|
||||
if (isDefined(cache) && isDefined(expectedChecksum)) {
|
||||
const cachedSource = await frontComponentCacheStorageService.read({
|
||||
cache,
|
||||
url,
|
||||
});
|
||||
|
||||
if (isDefined(cachedSource)) {
|
||||
const cachedSourceChecksum =
|
||||
await frontComponentCacheStorageService.computeChecksum({
|
||||
source: cachedSource,
|
||||
});
|
||||
|
||||
if (cachedSourceChecksum === expectedChecksum) {
|
||||
return cachedSource;
|
||||
}
|
||||
|
||||
// Poisoned or corrupt entry: any same-origin code can write to
|
||||
// CacheStorage, so a mismatch means the content cannot be trusted.
|
||||
frontComponentCacheStorageService.delete({ cache, url });
|
||||
}
|
||||
}
|
||||
|
||||
const source = await fetchComponentSourceFromNetwork({ url, headers });
|
||||
|
||||
if (isDefined(cache) && isDefined(expectedChecksum)) {
|
||||
const sourceChecksum =
|
||||
await frontComponentCacheStorageService.computeChecksum({ source });
|
||||
|
||||
if (sourceChecksum === expectedChecksum) {
|
||||
frontComponentCacheStorageService.write({ cache, url, source });
|
||||
}
|
||||
}
|
||||
|
||||
return source;
|
||||
};
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const componentSourceHandoffSchema = z.object({ url: z.url() });
|
||||
|
||||
// Changes the HTTP cache key so component responses cached before the
|
||||
// front-component cache fix are bypassed instead of served corrupted.
|
||||
const CACHE_BUST_QUERY_PARAMETER_NAME = 'cacheBust';
|
||||
const CACHE_BUST_QUERY_PARAMETER_VALUE = 'v2';
|
||||
|
||||
const appendCacheBustQueryParameter = (url: string): string => {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
parsedUrl.searchParams.set(
|
||||
CACHE_BUST_QUERY_PARAMETER_NAME,
|
||||
CACHE_BUST_QUERY_PARAMETER_VALUE,
|
||||
);
|
||||
|
||||
return parsedUrl.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchComponentSourceFromNetwork = async ({
|
||||
url,
|
||||
headers,
|
||||
}: {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): Promise<string> => {
|
||||
const response = await fetch(appendCacheBustQueryParameter(url), {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch ${url}: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
|
||||
if (!contentType.includes('application/json')) {
|
||||
return response.text();
|
||||
}
|
||||
|
||||
const handoff = componentSourceHandoffSchema.safeParse(await response.json());
|
||||
|
||||
if (!handoff.success) {
|
||||
throw new Error(`Invalid component source handoff response from ${url}`);
|
||||
}
|
||||
|
||||
const presignedResponse = await fetch(handoff.data.url);
|
||||
|
||||
if (!presignedResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch presigned URL: ${presignedResponse.status} ${presignedResponse.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return presignedResponse.text();
|
||||
};
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const FRONT_COMPONENT_SOURCE_CACHE_NAME = 'front-component-source-v1';
|
||||
|
||||
export const frontComponentCacheStorageService = {
|
||||
computeChecksum: async ({
|
||||
source,
|
||||
}: {
|
||||
source: string;
|
||||
}): Promise<string | undefined> => {
|
||||
if (typeof crypto === 'undefined' || !isDefined(crypto.subtle)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const digest = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
new TextEncoder().encode(source),
|
||||
);
|
||||
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
open: async (): Promise<Cache | undefined> => {
|
||||
if (typeof caches === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return await caches.open(FRONT_COMPONENT_SOURCE_CACHE_NAME);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
read: async ({
|
||||
cache,
|
||||
url,
|
||||
}: {
|
||||
cache: Cache;
|
||||
url: string;
|
||||
}): Promise<string | undefined> => {
|
||||
try {
|
||||
const cachedResponse = await cache.match(url);
|
||||
|
||||
return isDefined(cachedResponse)
|
||||
? await cachedResponse.text()
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
write: ({
|
||||
cache,
|
||||
url,
|
||||
source,
|
||||
}: {
|
||||
cache: Cache;
|
||||
url: string;
|
||||
source: string;
|
||||
}): void => {
|
||||
cache
|
||||
.put(
|
||||
url,
|
||||
new Response(source, {
|
||||
headers: { 'Content-Type': 'application/javascript' },
|
||||
}),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
},
|
||||
|
||||
delete: ({ cache, url }: { cache: Cache; url: string }): void => {
|
||||
cache.delete(url).catch(() => undefined);
|
||||
},
|
||||
};
|
||||
+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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user