Run front components in a sandboxed opaque-origin iframe (#22588)

Front components run untrusted third-party React in a Web Worker. That
worker previously shared the host origin, so it could reach
origin-scoped storage (the metadata-store IndexedDB, the
`twenty-sign-out` BroadcastChannel), cookies, and same-origin resources.

This runs the worker inside a `sandbox="allow-scripts"` (no
`allow-same-origin`) iframe, giving it an opaque origin where the
browser denies localStorage, cookies, IndexedDB, and BroadcastChannel
outright. The worker is kept inside the iframe (rather than a bare
iframe) so untrusted code always runs off the main thread; the
remote-dom render path is unchanged.

- **Transport:** host ↔ iframe ↔ worker over a re-transferred
`MessagePort` (`ThreadMessagePort`); a small bootstrap script is inlined
into the iframe via `srcdoc` (bundled at build time by a prebuild step)
and relays the port to the worker it spawns. Messages across the
boundary use a typed discriminated union with a single parse/guard.
- **Network:** under the opaque origin, direct fetches to the Twenty API
would be `Origin: null`, so the component source and SDK modules are
fetched through an allowlisted, credential-omitting `hostFetch` bridge
and blobbed inside the worker. The allowlist is single-sourced on the
host (http(s) origins only) and carried in the render context. The
bridge is mandatory (rendering fails closed if it is missing), refuses
redirects except for GET/HEAD to the known file-storage URLs, and caps
response body size.
- **SDK loading:** SDK client modules now load inside the worker through
the bridge, replacing the host-side SDK-blob state/effect/provider with
a pure `getSdkClientUrls` URL builder.
- **Isolation tests:** a unit test locks the sandbox attribute
(`allow-scripts`, never `allow-same-origin`); a browser test asserts the
worker actually gets an opaque origin with storage denied, probing
cookies by writing one rather than reading an empty jar.

Also adds a "List Companies" seed front component that queries workspace
data via the SDK client (exercising the bridge end-to-end),
single-sources the command-menu confirmation-modal result event name and
detail type in `twenty-shared` (previously a hand-synced duplicate), and
decomposes the renderer (bridge, sandbox, worker orchestration) into
small single-purpose utils with unit tests.

## How it works

```mermaid
sequenceDiagram
    autonumber
    participant Host as Host window (twenty-front · host origin)
    participant Frame as Sandboxed iframe (allow-scripts · opaque origin)
    participant Worker as Worker (untrusted component · opaque origin)
    participant API as Twenty API (host origin)

    rect rgb(238,242,248)
    Note over Host,Worker: 1 — Boot handshake
    Host->>Frame: create iframe sandbox="allow-scripts", srcdoc = inlined bootstrap script
    Host->>Host: MessageChannel + ThreadMessagePort(port1)<br/>exports = host API + hostFetch
    Frame-->>Host: READY
    Host->>Frame: INIT + transfer port2
    Frame->>Worker: spawn inlined Worker + re-transfer port2
    Worker->>Worker: ThreadMessagePort(port)<br/>exports = render / updateContext
    Note over Host,Worker: Port now entangles Host ↔ Worker directly
    end

    rect rgb(246,240,248)
    Note over Host,Worker: 2 — Render
    Host->>Worker: render(connection, { componentUrl, sdkClientUrls, hostFetchOrigins, token })
    Worker->>Worker: override globalThis.fetch<br/>(Twenty origins → hostFetch)
    end

    rect rgb(248,244,238)
    Note over Worker,API: 3 — Network via hostFetch bridge (opaque Origin:null cannot reach the API directly)
    Worker->>Host: hostFetch(componentUrl, Bearer)
    Host->>Host: origin allowlist + credentials:'omit'
    Host->>API: fetch(componentUrl)
    API-->>Host: source
    Host-->>Worker: { status, headers, body }
    Worker->>Host: hostFetch(sdkClientUrls.core / .metadata)
    Host-->>Worker: SDK module sources
    Worker->>Worker: blob each source in its own opaque origin → import() → run untrusted React
    end

    rect rgb(238,248,242)
    Note over Worker,Host: 4 — Render mirror
    Worker->>Host: remote-dom mutations (RemoteConnection)
    Host->>Host: RemoteReceiver → RemoteRootRenderer → host DOM
    end

    Note over Worker: Opaque origin ⇒ browser denies localStorage,<br/>cookies, IndexedDB, BroadcastChannel
```
This commit is contained in:
Raphaël Bosi
2026-07-10 15:10:30 +02:00
committed by GitHub
parent 95b672e61e
commit 60f5964c64
119 changed files with 3498 additions and 577 deletions
@@ -4,7 +4,7 @@ description: Build React components that render inside Twenty's UI with sandboxe
icon: "window-maximize"
---
Front components are React components that render directly inside Twenty's UI. They run in an **isolated Web Worker** using Remote DOM — your code is sandboxed but renders natively in the page, not in an iframe.
Front components are React components that render directly inside Twenty's UI. They run in an **isolated Web Worker** using Remote DOM — your code executes inside a sandboxed, opaque-origin iframe, yet its UI still renders natively in the page rather than being confined to that iframe.
## Where front components can be used
@@ -196,7 +196,7 @@ export default defineFrontComponent({
## Calling a logic function
Front components run browser-side in a sandboxed Web Worker, while [logic functions](/developers/extend/apps/logic/logic-functions) run server-side. There is no direct in-process call between the two — instead, a front component reaches a logic function over HTTP.
Front components run browser-side in a Web Worker sandboxed inside an opaque-origin iframe, while [logic functions](/developers/extend/apps/logic/logic-functions) run server-side. There is no direct in-process call between the two — instead, a front component reaches a logic function over HTTP.
A logic function declared with `httpRouteTriggerSettings` is reachable over HTTP at its route path. Twenty injects the base URL your functions are served from into the worker as `TWENTY_FUNCTIONS_URL`, together with the `TWENTY_APP_ACCESS_TOKEN` that authenticates the call. There is no dedicated SDK client for invoking your own functions yet, so call them with a plain `fetch`:
@@ -3,3 +3,5 @@ storybook-static
src/__stories__/example-sources-built/*
!src/__stories__/example-sources-built/bundle-sizes.json.d.ts
src/__stories__/example-sources-built-preact
src/remote/sandbox/generated/*
!src/remote/sandbox/generated/frontComponentSandboxDocument.d.ts
@@ -9,7 +9,7 @@
"executor": "nx:run-commands",
"cache": true,
"inputs": ["production", "^production"],
"dependsOn": ["^build"],
"dependsOn": ["^build", "sandbox:prebuild"],
"outputs": ["{projectRoot}/dist"],
"options": {
"cwd": "{projectRoot}",
@@ -47,11 +47,46 @@
}
}
},
"sandbox:prebuild": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"generate-remote-dom-elements",
{
"target": "build:sdk",
"projects": "twenty-sdk"
},
{
"target": "build:individual",
"projects": "twenty-ui"
},
{
"target": "build:individual",
"projects": "twenty-shared"
}
],
"inputs": [
"{projectRoot}/scripts/front-component-sandbox/**/*",
"{projectRoot}/src/remote/**/*",
"!{projectRoot}/src/remote/sandbox/generated/**/*",
"{projectRoot}/src/constants/**/*",
"{projectRoot}/src/polyfills/**/*",
"{projectRoot}/src/types/**/*"
],
"outputs": [
"{projectRoot}/src/remote/sandbox/generated/frontComponentSandboxDocument.ts"
],
"options": {
"cwd": "packages/twenty-front-component-renderer",
"command": "tsx -r tsconfig-paths/register scripts/front-component-sandbox/build-sandbox-document.ts"
}
},
"storybook:prebuild": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"generate-remote-dom-elements",
"sandbox:prebuild",
{
"target": "build:sdk",
"projects": "twenty-sdk"
@@ -0,0 +1,88 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { build, type Rollup } from 'vite';
const dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(dirname, '../..');
const sandboxBootstrapEntryPath = path.resolve(
projectRoot,
'src/remote/sandbox/sandbox-bootstrap.ts',
);
const generatedFile = path.resolve(
projectRoot,
'src/remote/sandbox/generated/frontComponentSandboxDocument.ts',
);
const replaceProcessEnvReferences = (code: string): string =>
code
.replace(/process\.env\.NODE_ENV/g, JSON.stringify('production'))
.replace(/process\.env/g, '{}');
const escapeClosingScriptTags = (code: string): string =>
code.replace(/<\/script/gi, '<\\/script');
const buildSandboxDocument = async (): Promise<void> => {
const buildResult = await build({
configFile: false,
root: projectRoot,
resolve: {
alias: {
'@/': path.resolve(projectRoot, 'src') + '/',
},
},
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
},
build: {
write: false,
lib: {
entry: sandboxBootstrapEntryPath,
formats: ['iife'],
name: 'frontComponentSandboxBootstrap',
fileName: () => 'sandbox-bootstrap.js',
},
rollupOptions: {
plugins: [
{
name: 'define-process-env',
transform: replaceProcessEnvReferences,
},
],
},
},
logLevel: 'warn',
});
const rollupOutputs = (
Array.isArray(buildResult) ? buildResult : [buildResult]
).filter((item): item is Rollup.RollupOutput => 'output' in item);
const sandboxBootstrapChunk = rollupOutputs
.flatMap((result) => result.output)
.find((item): item is Rollup.OutputChunk => item.type === 'chunk');
if (sandboxBootstrapChunk === undefined) {
throw new Error(
'Failed to build the front component sandbox bootstrap bundle',
);
}
const sandboxBootstrapScriptTag = `<script>${escapeClosingScriptTags(sandboxBootstrapChunk.code)}</script>`;
const sandboxDocument = `<!doctype html><html><head><meta charset="utf-8" /></head><body>${sandboxBootstrapScriptTag}</body></html>`;
const generatedSource = `export const FRONT_COMPONENT_SANDBOX_DOCUMENT = ${JSON.stringify(
sandboxDocument,
)};\n`;
fs.mkdirSync(path.dirname(generatedFile), { recursive: true });
fs.writeFileSync(generatedFile, generatedSource, 'utf8');
};
buildSandboxDocument().catch((error: unknown) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,25 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect } from 'storybook/test';
import { runFrontComponentSandboxIsolationProbe } from '@/__stories__/utils/runFrontComponentSandboxIsolationProbe';
const meta: Meta = {
title: 'FrontComponent/Security',
render: () => <div data-testid="front-component-sandbox-isolation" />,
};
export default meta;
type Story = StoryObj;
export const WorkerRunsInOpaqueOriginWithoutStorageAccess: Story = {
play: async () => {
const report = await runFrontComponentSandboxIsolationProbe();
expect(report.iframeOrigin).toBe('null');
expect(report.workerOrigin).toBe('null');
expect(report.localStorageDenied).toBe(true);
expect(report.cookiesDenied).toBe(true);
expect(report.indexedDbDenied).toBe(true);
expect(report.workerIndexedDbDenied).toBe(true);
},
};
@@ -0,0 +1,165 @@
import { isDefined } from 'twenty-shared/utils';
import { createFrontComponentSandboxIframe } from '@/remote/sandbox/utils/createFrontComponentSandboxIframe';
type FrontComponentSandboxIsolationReport = {
iframeOrigin: string;
workerOrigin: string;
localStorageDenied: boolean;
cookiesDenied: boolean;
indexedDbDenied: boolean;
workerIndexedDbDenied: boolean;
};
const SANDBOX_ISOLATION_PROBE_REPORT_MESSAGE_TYPE =
'front-component-sandbox-isolation-report';
const SANDBOX_ISOLATION_PROBE_TIMEOUT_MS = 15000;
const SANDBOX_ISOLATION_PROBE_DATABASE_NAME =
'front-component-sandbox-isolation-probe';
type WorkerProbeReport = {
workerOrigin: string;
workerIndexedDbDenied: boolean;
};
const runWorkerStorageIsolationProbe = (databaseName: string): void => {
const report = { workerOrigin: self.origin, workerIndexedDbDenied: false };
try {
indexedDB.open(databaseName);
} catch {
report.workerIndexedDbDenied = true;
}
self.postMessage(report);
};
const runSandboxIframeIsolationProbe = (
workerProbeSource: string,
reportMessageType: string,
databaseName: string,
): void => {
const report = {
iframeOrigin: self.origin,
workerOrigin: '',
localStorageDenied: false,
cookiesDenied: false,
indexedDbDenied: false,
workerIndexedDbDenied: false,
};
try {
window.localStorage.getItem('probe');
} catch {
report.localStorageDenied = true;
}
try {
document.cookie = 'front_component_sandbox_isolation_probe=1';
report.cookiesDenied = !document.cookie.includes(
'front_component_sandbox_isolation_probe=1',
);
document.cookie = 'front_component_sandbox_isolation_probe=1;max-age=0';
} catch {
report.cookiesDenied = true;
}
try {
indexedDB.open(databaseName);
} catch {
report.indexedDbDenied = true;
}
const workerUrl = URL.createObjectURL(
new Blob([workerProbeSource], { type: 'application/javascript' }),
);
const worker = new Worker(workerUrl);
worker.onmessage = (event: MessageEvent<WorkerProbeReport>) => {
report.workerOrigin = event.data.workerOrigin;
report.workerIndexedDbDenied = event.data.workerIndexedDbDenied;
parent.postMessage({ type: reportMessageType, report }, '*');
};
};
const serializeProbeFunctionInvocation = (
probeFunction: (...probeArguments: string[]) => void,
...probeArguments: string[]
): string => {
const serializedArguments = probeArguments
.map((probeArgument) => JSON.stringify(probeArgument))
.join(', ');
return `(${probeFunction.toString()})(${serializedArguments});`;
};
const buildSandboxIsolationProbeDocument = (): string => {
const workerProbeSource = serializeProbeFunctionInvocation(
runWorkerStorageIsolationProbe,
SANDBOX_ISOLATION_PROBE_DATABASE_NAME,
);
const sandboxProbeScript = serializeProbeFunctionInvocation(
runSandboxIframeIsolationProbe,
workerProbeSource,
SANDBOX_ISOLATION_PROBE_REPORT_MESSAGE_TYPE,
SANDBOX_ISOLATION_PROBE_DATABASE_NAME,
);
return `<!doctype html><html><body><script>${sandboxProbeScript}</script></body></html>`;
};
export const runFrontComponentSandboxIsolationProbe =
(): Promise<FrontComponentSandboxIsolationReport> => {
const sandboxIframe = createFrontComponentSandboxIframe(
buildSandboxIsolationProbeDocument(),
);
return new Promise<FrontComponentSandboxIsolationReport>(
(resolve, reject) => {
const abortController = new AbortController();
const removeSandbox = () => {
abortController.abort();
sandboxIframe.remove();
};
const timeoutId = setTimeout(() => {
removeSandbox();
reject(
new Error('Front component sandbox isolation probe timed out'),
);
}, SANDBOX_ISOLATION_PROBE_TIMEOUT_MS);
window.addEventListener(
'message',
(event: MessageEvent) => {
if (event.source !== sandboxIframe.contentWindow) {
return;
}
const data = event.data as {
type?: string;
report?: FrontComponentSandboxIsolationReport;
} | null;
if (
data?.type !== SANDBOX_ISOLATION_PROBE_REPORT_MESSAGE_TYPE ||
!isDefined(data.report)
) {
return;
}
clearTimeout(timeoutId);
removeSandbox();
resolve(data.report);
},
{ signal: abortController.signal },
);
document.body.append(sandboxIframe);
},
);
};
@@ -1,28 +1,6 @@
import {
type CloseSidePanelFunction,
type CopyToClipboardFunction,
type EnqueueSnackbarFunction,
type NavigateFunction,
type OpenCommandConfirmationModalFunction,
type OpenSidePanelPageFunction,
type RequestAccessTokenRefreshFunction,
type UnmountFrontComponentFunction,
type UpdateProgressFunction,
} from 'twenty-sdk/front-component';
import { FRONT_COMPONENT_HOST_COMMUNICATION_API_KEY } from 'twenty-sdk/front-component-renderer';
type FrontComponentHostCommunicationApiStore = {
navigate?: NavigateFunction;
requestAccessTokenRefresh?: RequestAccessTokenRefreshFunction;
openSidePanelPage?: OpenSidePanelPageFunction;
openCommandConfirmationModal?: OpenCommandConfirmationModalFunction;
unmountFrontComponent?: UnmountFrontComponentFunction;
enqueueSnackbar?: EnqueueSnackbarFunction;
closeSidePanel?: CloseSidePanelFunction;
updateProgress?: UpdateProgressFunction;
copyToClipboard?: CopyToClipboardFunction;
};
import { type FrontComponentHostCommunicationApiStore } from '@/types/FrontComponentHostCommunicationApiStore';
(globalThis as Record<string, unknown>)[
FRONT_COMPONENT_HOST_COMMUNICATION_API_KEY
@@ -0,0 +1,31 @@
import { useTheme } from 'twenty-ui/theme-constants';
type FrontComponentErrorBoxProps = {
error: Error;
};
export const FrontComponentErrorBox = ({
error,
}: FrontComponentErrorBoxProps) => {
const theme = useTheme();
return (
<div
style={{
padding: `${theme.spacing[3]} ${theme.spacing[4]}`,
backgroundColor: theme.background.danger,
border: `1px solid ${theme.border.color.danger}`,
borderRadius: theme.border.radius.md,
color: theme.font.color.danger,
fontFamily: 'monospace',
fontSize: theme.font.size.xs,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: '200px',
overflow: 'auto',
}}
>
<strong>FrontComponent error:</strong> {error.message}
</div>
);
};
@@ -1,17 +1,17 @@
import { FrontComponentConfirmationModalResultEffect } from '@/remote/components/FrontComponentConfirmationModalResultEffect';
import { FrontComponentErrorEffect } from '@/remote/components/FrontComponentErrorEffect';
import { FrontComponentInitializeHostCommunicationApiEffect } from '@/remote/components/FrontComponentInitializeHostCommunicationApiEffect';
import { FrontComponentUpdateContextEffect } from '@/remote/components/FrontComponentUpdateContextEffect';
import { FrontComponentUpdateHostCommunicationApiEffect } from '@/remote/components/FrontComponentUpdateHostCommunicationApiEffect';
import { type FrontComponentHostCommunicationApi } from '@/types/FrontComponentHostCommunicationApi';
import { type SdkClientUrls } from '@/types/HostToWorkerRenderContext';
import { type WorkerExports } from '@/types/WorkerExports';
import { type FrontComponentThread } from '@/types/FrontComponentThread';
import { type SdkClientUrls } from '@/types/SdkClientUrls';
import { type FrontComponentExecutionContext } from 'twenty-sdk/front-component';
import { type ThreadWebWorker } from '@quilted/threads';
import {
type RemoteReceiver,
RemoteRootRenderer,
} from '@remote-dom/react/host';
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { isDefined } from 'twenty-shared/utils';
@@ -19,11 +19,12 @@ import { ThemeProvider } from 'twenty-ui/theme-constants';
import { FrontComponentWorkerEffect } from '../../remote/components/FrontComponentWorkerEffect';
import { componentRegistry } from '../generated/host-component-registry';
import { createFallbackComponentRegistry } from '../utils/createFallbackComponentRegistry';
import { FrontComponentErrorBox } from './FrontComponentErrorBox';
const fallbackComponentRegistry =
createFallbackComponentRegistry(componentRegistry);
type FrontComponentContentProps = {
type FrontComponentRendererProps = {
componentUrl: string;
applicationAccessToken?: string;
apiUrl?: string;
@@ -47,18 +48,15 @@ export const FrontComponentRenderer = ({
frontComponentHostCommunicationApi,
onError,
colorScheme,
}: FrontComponentContentProps) => {
}: FrontComponentRendererProps) => {
const [receiver, setReceiver] = useState<RemoteReceiver | null>(null);
const [thread, setThread] = useState<ThreadWebWorker<
WorkerExports,
FrontComponentHostCommunicationApi
> | null>(null);
const [thread, setThread] = useState<FrontComponentThread | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isExecutionContextInitialized, setIsExecutionContextInitialized] =
useState(false);
const MemoizedFrontComponentWorkerEffect = useMemo(() => {
return (
return (
<>
<FrontComponentWorkerEffect
componentUrl={componentUrl}
applicationAccessToken={applicationAccessToken}
@@ -66,50 +64,16 @@ export const FrontComponentRenderer = ({
functionsBaseUrl={functionsBaseUrl}
sdkClientUrls={sdkClientUrls}
applicationVariables={applicationVariables}
frontComponentId={executionContext.frontComponentId}
setReceiver={setReceiver}
setThread={setThread}
setError={setError}
/>
);
}, [
componentUrl,
setError,
setReceiver,
setThread,
applicationAccessToken,
apiUrl,
functionsBaseUrl,
sdkClientUrls,
applicationVariables,
executionContext.frontComponentId,
]);
return (
<>
{MemoizedFrontComponentWorkerEffect}
{isDefined(error) && (
<>
<ThemeProvider colorScheme={colorScheme}>
<FrontComponentErrorEffect error={error} onError={onError} />
<div
style={{
padding: '12px 16px',
backgroundColor: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: '6px',
color: '#991b1b',
fontFamily: 'monospace',
fontSize: '13px',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: '200px',
overflow: 'auto',
}}
>
<strong>FrontComponent error:</strong> {error.message}
</div>
</>
<FrontComponentErrorBox error={error} />
</ThemeProvider>
)}
{isDefined(thread) && (
@@ -128,6 +92,11 @@ export const FrontComponentRenderer = ({
setIsExecutionContextInitialized(true)
}
/>
<FrontComponentConfirmationModalResultEffect
thread={thread}
frontComponentId={executionContext.frontComponentId}
onError={setError}
/>
</>
)}
@@ -0,0 +1,16 @@
import { type FrontComponentHostCommunicationApi } from '@/types/FrontComponentHostCommunicationApi';
const noopAsync = async () => {};
export const FRONT_COMPONENT_HOST_COMMUNICATION_API_NOOP: FrontComponentHostCommunicationApi =
{
navigate: noopAsync,
requestAccessTokenRefresh: async () => '',
openSidePanelPage: noopAsync,
openCommandConfirmationModal: noopAsync,
unmountFrontComponent: noopAsync,
enqueueSnackbar: noopAsync,
closeSidePanel: noopAsync,
updateProgress: noopAsync,
copyToClipboard: noopAsync,
};
@@ -0,0 +1 @@
export const MAX_HOST_FETCH_RESPONSE_BODY_BYTES = 50 * 1024 * 1024;
@@ -0,0 +1,81 @@
import { buildHostFetchPolicyFromFrontComponentUrls } from '../buildHostFetchPolicyFromFrontComponentUrls';
describe('buildHostFetchPolicyFromFrontComponentUrls', () => {
it('should derive allowed origins from the api, functions and component urls', () => {
const hostFetchPolicy = buildHostFetchPolicyFromFrontComponentUrls({
componentUrl:
'https://components.twenty.test/rest/front-components/component-id',
apiUrl: 'https://api.twenty.test/graphql',
functionsBaseUrl: 'https://functions.twenty.test/base',
});
expect(hostFetchPolicy.allowedOrigins).toEqual([
'https://api.twenty.test',
'https://functions.twenty.test',
'https://components.twenty.test',
]);
});
it('should drop undefined urls', () => {
const hostFetchPolicy = buildHostFetchPolicyFromFrontComponentUrls({
componentUrl: 'https://api.twenty.test/rest/front-components/id',
});
expect(hostFetchPolicy.allowedOrigins).toEqual(['https://api.twenty.test']);
});
it('should drop malformed urls', () => {
const hostFetchPolicy = buildHostFetchPolicyFromFrontComponentUrls({
componentUrl: 'https://api.twenty.test/rest/front-components/id',
apiUrl: 'not a url',
});
expect(hostFetchPolicy.allowedOrigins).toEqual(['https://api.twenty.test']);
});
it('should drop urls with non http schemes', () => {
const hostFetchPolicy = buildHostFetchPolicyFromFrontComponentUrls({
componentUrl: 'https://api.twenty.test/rest/front-components/id',
apiUrl: 'data:text/html,<script>alert(1)</script>',
functionsBaseUrl: 'file:///etc/passwd',
});
expect(hostFetchPolicy.allowedOrigins).toEqual(['https://api.twenty.test']);
});
it('should deduplicate identical origins', () => {
const hostFetchPolicy = buildHostFetchPolicyFromFrontComponentUrls({
componentUrl: 'https://api.twenty.test/rest/front-components/id',
apiUrl: 'https://api.twenty.test/graphql',
functionsBaseUrl: 'https://api.twenty.test/functions',
});
expect(hostFetchPolicy.allowedOrigins).toEqual(['https://api.twenty.test']);
});
it('should mark the component and sdk client urls as file storage redirectable', () => {
const hostFetchPolicy = buildHostFetchPolicyFromFrontComponentUrls({
componentUrl: 'https://api.twenty.test/rest/front-components/id',
sdkClientUrls: {
core: 'https://api.twenty.test/sdk-client/application-id/core',
metadata: 'https://api.twenty.test/sdk-client/application-id/metadata',
},
});
expect(hostFetchPolicy.fileStorageRedirectableUrls).toEqual([
'https://api.twenty.test/rest/front-components/id',
'https://api.twenty.test/sdk-client/application-id/core',
'https://api.twenty.test/sdk-client/application-id/metadata',
]);
});
it('should mark only the component url as redirectable when sdk client urls are undefined', () => {
const hostFetchPolicy = buildHostFetchPolicyFromFrontComponentUrls({
componentUrl: 'https://api.twenty.test/rest/front-components/id',
});
expect(hostFetchPolicy.fileStorageRedirectableUrls).toEqual([
'https://api.twenty.test/rest/front-components/id',
]);
});
});
@@ -0,0 +1,155 @@
import { CustomError } from 'twenty-shared/utils';
import { createHostFetchEnforcingPolicy } from '../createHostFetchEnforcingPolicy';
const originalFetch = globalThis.fetch;
const createFakeResponse = () => ({
ok: true,
status: 200,
statusText: 'OK',
headers: new Map([['content-type', 'application/json']]),
text: async () => 'response-body',
});
describe('createHostFetchEnforcingPolicy', () => {
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('should reject requests to origins that are not allowlisted', async () => {
const fetchSpy = jest.fn(async () => createFakeResponse());
globalThis.fetch = fetchSpy as unknown as typeof fetch;
const hostFetch = createHostFetchEnforcingPolicy({
allowedOrigins: ['https://api.twenty.test'],
fileStorageRedirectableUrls: [],
});
await expect(hostFetch({ url: 'https://evil.test/steal' })).rejects.toThrow(
'disallowed origin',
);
await expect(
hostFetch({ url: 'https://evil.test/steal' }),
).rejects.toMatchObject({
code: 'FRONT_COMPONENT_HOST_FETCH_BLOCKED_ORIGIN',
});
await expect(
hostFetch({ url: 'https://evil.test/steal' }),
).rejects.toBeInstanceOf(CustomError);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('should uppercase lowercase methods and default to GET', async () => {
const fetchSpy = jest.fn(async () => createFakeResponse());
globalThis.fetch = fetchSpy as unknown as typeof fetch;
const hostFetch = createHostFetchEnforcingPolicy({
allowedOrigins: ['https://api.twenty.test'],
fileStorageRedirectableUrls: [],
});
await hostFetch({ url: 'https://api.twenty.test/graphql', method: 'post' });
await hostFetch({ url: 'https://api.twenty.test/graphql' });
expect(fetchSpy).toHaveBeenNthCalledWith(
1,
'https://api.twenty.test/graphql',
expect.objectContaining({ method: 'POST' }),
);
expect(fetchSpy).toHaveBeenNthCalledWith(
2,
'https://api.twenty.test/graphql',
expect.objectContaining({ method: 'GET' }),
);
});
it('should forward allowlisted requests without ambient credentials', async () => {
const fetchSpy = jest.fn(async () => createFakeResponse());
globalThis.fetch = fetchSpy as unknown as typeof fetch;
const hostFetch = createHostFetchEnforcingPolicy({
allowedOrigins: ['https://api.twenty.test'],
fileStorageRedirectableUrls: [],
});
const result = await hostFetch({
url: 'https://api.twenty.test/graphql',
method: 'POST',
headers: { authorization: 'Bearer token' },
body: '{"query":"{ me }"}',
});
expect(fetchSpy).toHaveBeenCalledWith(
'https://api.twenty.test/graphql',
expect.objectContaining({ method: 'POST', credentials: 'omit' }),
);
expect(result.status).toBe(200);
expect(result.body).toBe('response-body');
expect(result.headers['content-type']).toBe('application/json');
});
it('should refuse redirects by default', async () => {
const fetchSpy = jest.fn(async () => createFakeResponse());
globalThis.fetch = fetchSpy as unknown as typeof fetch;
const hostFetch = createHostFetchEnforcingPolicy({
allowedOrigins: ['https://api.twenty.test'],
fileStorageRedirectableUrls: [],
});
await hostFetch({
url: 'https://api.twenty.test/graphql',
method: 'POST',
});
await hostFetch({
url: 'https://api.twenty.test/rest/some-endpoint',
});
expect(fetchSpy).toHaveBeenCalledWith(
'https://api.twenty.test/graphql',
expect.objectContaining({ redirect: 'error' }),
);
expect(fetchSpy).toHaveBeenCalledWith(
'https://api.twenty.test/rest/some-endpoint',
expect.objectContaining({ redirect: 'error' }),
);
});
it('should follow file storage redirects only for GET requests to redirectable urls', async () => {
const fetchSpy = jest.fn(async () => createFakeResponse());
globalThis.fetch = fetchSpy as unknown as typeof fetch;
const componentUrl =
'https://api.twenty.test/rest/front-components/component-id';
const hostFetch = createHostFetchEnforcingPolicy({
allowedOrigins: ['https://api.twenty.test'],
fileStorageRedirectableUrls: [componentUrl],
});
await hostFetch({ url: componentUrl });
await hostFetch({ url: componentUrl, method: 'HEAD' });
await hostFetch({ url: componentUrl, method: 'POST' });
expect(fetchSpy).toHaveBeenCalledWith(
componentUrl,
expect.objectContaining({ method: 'GET', redirect: 'follow' }),
);
expect(fetchSpy).toHaveBeenCalledWith(
componentUrl,
expect.objectContaining({ method: 'HEAD', redirect: 'follow' }),
);
expect(fetchSpy).toHaveBeenCalledWith(
componentUrl,
expect.objectContaining({ method: 'POST', redirect: 'error' }),
);
});
it('should reject malformed request urls', async () => {
const hostFetch = createHostFetchEnforcingPolicy({
allowedOrigins: ['https://api.twenty.test'],
fileStorageRedirectableUrls: [],
});
await expect(hostFetch({ url: 'not a url' })).rejects.toThrow(
'disallowed origin',
);
});
});
@@ -0,0 +1,44 @@
import { getUniqueHttpOriginsFromUrls } from '../getUniqueHttpOriginsFromUrls';
describe('getUniqueHttpOriginsFromUrls', () => {
it('should reduce urls to their origins', () => {
expect(
getUniqueHttpOriginsFromUrls([
'https://api.twenty.test/graphql',
'http://functions.twenty.test/base/path',
]),
).toEqual(['https://api.twenty.test', 'http://functions.twenty.test']);
});
it('should deduplicate identical origins', () => {
expect(
getUniqueHttpOriginsFromUrls([
'https://api.twenty.test/graphql',
'https://api.twenty.test/rest/front-components/id',
]),
).toEqual(['https://api.twenty.test']);
});
it('should drop undefined urls', () => {
expect(
getUniqueHttpOriginsFromUrls([undefined, 'https://api.twenty.test']),
).toEqual(['https://api.twenty.test']);
});
it('should drop malformed urls', () => {
expect(
getUniqueHttpOriginsFromUrls(['not a url', 'https://api.twenty.test']),
).toEqual(['https://api.twenty.test']);
});
it('should drop urls with non http schemes', () => {
expect(
getUniqueHttpOriginsFromUrls([
'data:text/html,<script>alert(1)</script>',
'file:///etc/passwd',
'blob:https://api.twenty.test/id',
'https://api.twenty.test',
]),
).toEqual(['https://api.twenty.test']);
});
});
@@ -0,0 +1,43 @@
import { resolveHostFetchRedirectMode } from '../resolveHostFetchRedirectMode';
const componentUrl = 'https://api.twenty.test/rest/front-components/id';
const fileStorageRedirectableUrls = new Set([componentUrl]);
describe('resolveHostFetchRedirectMode', () => {
it('should follow redirects for GET and HEAD requests to redirectable urls', () => {
expect(
resolveHostFetchRedirectMode(
'GET',
componentUrl,
fileStorageRedirectableUrls,
),
).toBe('follow');
expect(
resolveHostFetchRedirectMode(
'HEAD',
componentUrl,
fileStorageRedirectableUrls,
),
).toBe('follow');
});
it('should refuse redirects for mutating requests to redirectable urls', () => {
expect(
resolveHostFetchRedirectMode(
'POST',
componentUrl,
fileStorageRedirectableUrls,
),
).toBe('error');
});
it('should refuse redirects for GET requests to non redirectable urls', () => {
expect(
resolveHostFetchRedirectMode(
'GET',
'https://api.twenty.test/graphql',
fileStorageRedirectableUrls,
),
).toBe('error');
});
});
@@ -0,0 +1,42 @@
import { serializeResponseToHostFetchResult } from '../serializeResponseToHostFetchResult';
describe('serializeResponseToHostFetchResult', () => {
it('should serialize the status, headers and body of a response', async () => {
const response = {
status: 201,
statusText: 'Created',
headers: new Headers({
'content-type': 'application/json',
'x-schema-version': '42',
}),
text: async () => 'response-body',
} as unknown as Response;
await expect(serializeResponseToHostFetchResult(response)).resolves.toEqual(
{
status: 201,
statusText: 'Created',
headers: {
'content-type': 'application/json',
'x-schema-version': '42',
},
body: 'response-body',
},
);
});
it('should reject responses whose declared content length exceeds the body size limit', async () => {
const response = {
status: 200,
statusText: 'OK',
headers: new Headers({ 'content-length': String(200 * 1024 * 1024) }),
text: async () => '',
} as unknown as Response;
await expect(
serializeResponseToHostFetchResult(response),
).rejects.toMatchObject({
code: 'FRONT_COMPONENT_HOST_FETCH_RESPONSE_TOO_LARGE',
});
});
});
@@ -0,0 +1,33 @@
import { isDefined } from 'twenty-shared/utils';
import { getUniqueHttpOriginsFromUrls } from '@/host/utils/getUniqueHttpOriginsFromUrls';
import { type HostFetchPolicy } from '@/types/HostFetchPolicy';
import { type SdkClientUrls } from '@/types/SdkClientUrls';
type BuildHostFetchPolicyInput = {
componentUrl: string;
apiUrl?: string;
functionsBaseUrl?: string;
sdkClientUrls?: SdkClientUrls;
};
export const buildHostFetchPolicyFromFrontComponentUrls = ({
componentUrl,
apiUrl,
functionsBaseUrl,
sdkClientUrls,
}: BuildHostFetchPolicyInput): HostFetchPolicy => {
const allowedOrigins = getUniqueHttpOriginsFromUrls([
apiUrl,
functionsBaseUrl,
componentUrl,
]);
const fileStorageRedirectableUrls = [
componentUrl,
sdkClientUrls?.core,
sdkClientUrls?.metadata,
].filter(isDefined);
return { allowedOrigins, fileStorageRedirectableUrls };
};
@@ -0,0 +1,26 @@
import { ThreadMessagePort } from '@quilted/threads';
import { FRONT_COMPONENT_HOST_COMMUNICATION_API_NOOP } from '@/host/constants/FrontComponentHostCommunicationApiNoop';
import { type FrontComponentHostThreadExports } from '@/types/FrontComponentHostThreadExports';
import { type FrontComponentThread } from '@/types/FrontComponentThread';
import { type HostFetchFunction } from '@/types/HostFetchFunction';
import { type WorkerExports } from '@/types/WorkerExports';
export const createFrontComponentHostThread = (
hostMessagePort: MessagePort,
hostFetch: HostFetchFunction,
): FrontComponentThread => {
const thread = new ThreadMessagePort<
WorkerExports,
FrontComponentHostThreadExports
>(hostMessagePort, {
exports: {
...FRONT_COMPONENT_HOST_COMMUNICATION_API_NOOP,
hostFetch,
},
});
hostMessagePort.start();
return thread;
};
@@ -0,0 +1,47 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CustomError, getURLSafely, isDefined } from 'twenty-shared/utils';
import { resolveHostFetchRedirectMode } from '@/host/utils/resolveHostFetchRedirectMode';
import { serializeResponseToHostFetchResult } from '@/host/utils/serializeResponseToHostFetchResult';
import { type HostFetchFunction } from '@/types/HostFetchFunction';
import { type HostFetchInput } from '@/types/HostFetchInput';
import { type HostFetchPolicy } from '@/types/HostFetchPolicy';
import { type HostFetchResult } from '@/types/HostFetchResult';
export const createHostFetchEnforcingPolicy = (
hostFetchPolicy: HostFetchPolicy,
): HostFetchFunction => {
const allowedOriginSet = new Set(hostFetchPolicy.allowedOrigins);
const fileStorageRedirectableUrlSet = new Set(
hostFetchPolicy.fileStorageRedirectableUrls,
);
return async (input: HostFetchInput): Promise<HostFetchResult> => {
const requestOrigin = getURLSafely(input.url)?.origin;
if (!isDefined(requestOrigin) || !allowedOriginSet.has(requestOrigin)) {
throw new CustomError(
`Front component host fetch blocked for disallowed origin: ${input.url}`,
'FRONT_COMPONENT_HOST_FETCH_BLOCKED_ORIGIN',
);
}
const requestMethod = isNonEmptyString(input.method)
? input.method.toUpperCase()
: 'GET';
const response = await fetch(input.url, {
method: requestMethod,
headers: input.headers,
body: input.body,
credentials: 'omit',
redirect: resolveHostFetchRedirectMode(
requestMethod,
input.url,
fileStorageRedirectableUrlSet,
),
});
return serializeResponseToHostFetchResult(response);
};
};
@@ -0,0 +1,14 @@
import { getURLSafely, isDefined } from 'twenty-shared/utils';
export const getUniqueHttpOriginsFromUrls = (
urls: (string | undefined)[],
): string[] => [
...new Set(
urls
.filter(isDefined)
.map((url) => getURLSafely(url))
.filter(isDefined)
.filter((url) => url.protocol === 'http:' || url.protocol === 'https:')
.map((url) => url.origin),
),
];
@@ -0,0 +1,11 @@
export const resolveHostFetchRedirectMode = (
requestMethod: string,
requestUrl: string,
fileStorageRedirectableUrls: Set<string>,
): RequestRedirect => {
const isReadOnlyMethod = requestMethod === 'GET' || requestMethod === 'HEAD';
return isReadOnlyMethod && fileStorageRedirectableUrls.has(requestUrl)
? 'follow'
: 'error';
};
@@ -0,0 +1,39 @@
import { CustomError } from 'twenty-shared/utils';
import { MAX_HOST_FETCH_RESPONSE_BODY_BYTES } from '@/host/constants/MaxHostFetchResponseBodyBytes';
import { type HostFetchResult } from '@/types/HostFetchResult';
const responseTooLargeError = (): CustomError =>
new CustomError(
`Front component host fetch response exceeds the ${MAX_HOST_FETCH_RESPONSE_BODY_BYTES} bytes limit`,
'FRONT_COMPONENT_HOST_FETCH_RESPONSE_TOO_LARGE',
);
export const serializeResponseToHostFetchResult = async (
response: Response,
): Promise<HostFetchResult> => {
const contentLength = Number(response.headers.get('content-length') ?? 0);
if (contentLength > MAX_HOST_FETCH_RESPONSE_BODY_BYTES) {
throw responseTooLargeError();
}
const body = await response.text();
if (new Blob([body]).size > MAX_HOST_FETCH_RESPONSE_BODY_BYTES) {
throw responseTooLargeError();
}
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
return {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
body,
};
};
@@ -4,6 +4,7 @@ export {
type SetEditableFocused,
} from './host/contexts/FrontComponentInputFocusContext';
export { componentRegistry } from './host/generated/host-component-registry';
export { FrontComponentConfirmationModalResultEffect } from './remote/components/FrontComponentConfirmationModalResultEffect';
export { FrontComponentErrorEffect } from './remote/components/FrontComponentErrorEffect';
export { FrontComponentInitializeHostCommunicationApiEffect } from './remote/components/FrontComponentInitializeHostCommunicationApiEffect';
export { FrontComponentUpdateContextEffect } from './remote/components/FrontComponentUpdateContextEffect';
@@ -128,14 +129,12 @@ export type {
HtmlTextareaProperties,
HtmlThProperties,
} from './remote/generated/remote-elements';
export { createRemoteWorker } from './remote/worker/utils/createRemoteWorker';
export { createFrontComponentRemoteWorker } from './remote/worker/utils/createFrontComponentRemoteWorker';
export { installStyleBridge } from './polyfills/installStyleBridge';
export { exposeGlobals } from './remote/utils/exposeGlobals';
export type { FrontComponentExecutionContext } from 'twenty-sdk/front-component';
export type { FrontComponentHostCommunicationApi } from './types/FrontComponentHostCommunicationApi';
export type {
HostToWorkerRenderContext,
SdkClientUrls,
} from './types/HostToWorkerRenderContext';
export type { HostToWorkerRenderContext } from './types/HostToWorkerRenderContext';
export type { SdkClientUrls } from './types/SdkClientUrls';
export type { PropertySchema } from './constants/PropertySchema';
export type { WorkerExports } from './types/WorkerExports';
@@ -0,0 +1,50 @@
import { useEffect } from 'react';
import { COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME } from 'twenty-shared/constants';
import { type CommandMenuConfirmationModalResultBrowserEventDetail } from 'twenty-shared/types';
import { type FrontComponentThread } from '@/types/FrontComponentThread';
type FrontComponentConfirmationModalResultEffectProps = {
thread: FrontComponentThread;
frontComponentId: string;
onError: (error: Error) => void;
};
export const FrontComponentConfirmationModalResultEffect = ({
thread,
frontComponentId,
onError,
}: FrontComponentConfirmationModalResultEffectProps) => {
useEffect(() => {
const handleConfirmationModalResult = (
event: CustomEvent<CommandMenuConfirmationModalResultBrowserEventDetail>,
) => {
const { caller, confirmationResult } = event.detail;
if (
caller.type !== 'frontComponent' ||
caller.frontComponentId !== frontComponentId
) {
return;
}
thread.imports
.onConfirmationModalResult(confirmationResult)
.catch(onError);
};
window.addEventListener(
COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME,
handleConfirmationModalResult as EventListener,
);
return () => {
window.removeEventListener(
COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME,
handleConfirmationModalResult as EventListener,
);
};
}, [thread, frontComponentId, onError]);
return null;
};
@@ -1,10 +1,8 @@
import { type FrontComponentHostCommunicationApi } from '@/types/FrontComponentHostCommunicationApi';
import { type WorkerExports } from '@/types/WorkerExports';
import { type ThreadWebWorker } from '@quilted/threads';
import { type FrontComponentThread } from '@/types/FrontComponentThread';
import { useEffect } from 'react';
type FrontComponentInitializeHostCommunicationApiEffectProps = {
thread: ThreadWebWorker<WorkerExports, FrontComponentHostCommunicationApi>;
thread: FrontComponentThread;
};
export const FrontComponentInitializeHostCommunicationApiEffect = ({
@@ -1,11 +1,9 @@
import { type FrontComponentHostCommunicationApi } from '@/types/FrontComponentHostCommunicationApi';
import { type WorkerExports } from '@/types/WorkerExports';
import { type FrontComponentThread } from '@/types/FrontComponentThread';
import { type FrontComponentExecutionContext } from 'twenty-sdk/front-component';
import { type ThreadWebWorker } from '@quilted/threads';
import { useEffect } from 'react';
type FrontComponentUpdateContextEffectProps = {
thread: ThreadWebWorker<WorkerExports, FrontComponentHostCommunicationApi>;
thread: FrontComponentThread;
executionContext: FrontComponentExecutionContext;
onExecutionContextInitialized: () => void;
};
@@ -1,10 +1,9 @@
import { type FrontComponentHostCommunicationApi } from '@/types/FrontComponentHostCommunicationApi';
import { type WorkerExports } from '@/types/WorkerExports';
import { type ThreadWebWorker } from '@quilted/threads';
import { type FrontComponentThread } from '@/types/FrontComponentThread';
import { useEffect } from 'react';
type FrontComponentUpdateHostCommunicationApiEffectProps = {
thread: ThreadWebWorker<WorkerExports, FrontComponentHostCommunicationApi>;
thread: FrontComponentThread;
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
};
@@ -1,36 +1,15 @@
import { ThreadWebWorker, release, retain } from '@quilted/threads';
import { release, retain } from '@quilted/threads';
import { RemoteReceiver } from '@remote-dom/core/receivers';
import { useEffect, useRef } from 'react';
import { type CommandConfirmationModalResult } from 'twenty-sdk/front-component';
import { type ConfirmationModalCaller } from 'twenty-shared/types';
import { type FrontComponentHostCommunicationApi } from '../../types/FrontComponentHostCommunicationApi';
import { type SdkClientUrls } from '../../types/HostToWorkerRenderContext';
import { type WorkerExports } from '../../types/WorkerExports';
import { createRemoteWorker } from '../worker/utils/createRemoteWorker';
// Must match COMMAND_MENU_ITEM_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME in twenty-front
const COMMAND_MENU_ITEM_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME =
'command-menu-item-confirmation-modal-result';
type CommandMenuItemConfirmationModalResultBrowserEventDetail = {
caller: ConfirmationModalCaller;
confirmationResult: CommandConfirmationModalResult;
};
const noopAsync = async () => {};
const HOST_COMMUNICATION_API_NOOP_INITIALIZATION: FrontComponentHostCommunicationApi =
{
navigate: noopAsync,
requestAccessTokenRefresh: async () => '',
openSidePanelPage: noopAsync,
openCommandConfirmationModal: noopAsync,
unmountFrontComponent: noopAsync,
enqueueSnackbar: noopAsync,
closeSidePanel: noopAsync,
updateProgress: noopAsync,
copyToClipboard: noopAsync,
};
import { buildHostFetchPolicyFromFrontComponentUrls } from '@/host/utils/buildHostFetchPolicyFromFrontComponentUrls';
import { createFrontComponentHostThread } from '@/host/utils/createFrontComponentHostThread';
import { createHostFetchEnforcingPolicy } from '@/host/utils/createHostFetchEnforcingPolicy';
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';
type FrontComponentWorkerEffectProps = {
componentUrl: string;
@@ -39,14 +18,8 @@ type FrontComponentWorkerEffectProps = {
functionsBaseUrl?: string;
sdkClientUrls?: SdkClientUrls;
applicationVariables?: Record<string, string>;
frontComponentId: string;
setReceiver: React.Dispatch<React.SetStateAction<RemoteReceiver | null>>;
setThread: React.Dispatch<
React.SetStateAction<ThreadWebWorker<
WorkerExports,
FrontComponentHostCommunicationApi
> | null>
>;
setThread: React.Dispatch<React.SetStateAction<FrontComponentThread | null>>;
setError: React.Dispatch<React.SetStateAction<Error | null>>;
};
@@ -57,7 +30,6 @@ export const FrontComponentWorkerEffect = ({
functionsBaseUrl,
sdkClientUrls,
applicationVariables,
frontComponentId,
setReceiver,
setThread,
setError,
@@ -71,52 +43,31 @@ export const FrontComponentWorkerEffect = ({
const newReceiver = new RemoteReceiver({ retain, release });
const worker = createRemoteWorker();
const sandboxIframe = createFrontComponentSandboxIframe(
FRONT_COMPONENT_SANDBOX_DOCUMENT,
);
document.body.append(sandboxIframe);
worker.onerror = (event: ErrorEvent) => {
const workerError =
event.error ?? new Error(event.message || 'Unknown worker error');
const channel = new MessageChannel();
console.error('[FrontComponentRenderer] Worker error:', workerError);
setError(workerError);
};
const thread = new ThreadWebWorker<
WorkerExports,
FrontComponentHostCommunicationApi
>(worker, {
exports: { ...HOST_COMMUNICATION_API_NOOP_INITIALIZATION },
const hostFetchPolicy = buildHostFetchPolicyFromFrontComponentUrls({
componentUrl,
apiUrl,
functionsBaseUrl,
sdkClientUrls,
});
const handleCommandMenuItemConfirmationModalResultBrowserEvent = (
event: CustomEvent<CommandMenuItemConfirmationModalResultBrowserEventDetail>,
) => {
const commandMenuItemConfirmationModalResultBrowserEventDetail =
event.detail;
const hostFetch = createHostFetchEnforcingPolicy(hostFetchPolicy);
const caller =
commandMenuItemConfirmationModalResultBrowserEventDetail.caller;
const thread = createFrontComponentHostThread(channel.port1, hostFetch);
if (
caller.type !== 'frontComponent' ||
caller.frontComponentId !== frontComponentId
) {
return;
}
const handleSandboxMessage = createFrontComponentSandboxMessageHandler({
sandboxIframe,
workerMessagePort: channel.port2,
onSandboxError: setError,
});
thread.imports
.onConfirmationModalResult(
commandMenuItemConfirmationModalResultBrowserEventDetail.confirmationResult,
)
.catch((error: Error) => {
setError(error);
});
};
window.addEventListener(
COMMAND_MENU_ITEM_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME,
handleCommandMenuItemConfirmationModalResultBrowserEvent as EventListener,
);
window.addEventListener('message', handleSandboxMessage);
setThread(thread);
@@ -127,6 +78,7 @@ export const FrontComponentWorkerEffect = ({
apiUrl,
functionsBaseUrl,
sdkClientUrls,
hostFetchOrigins: hostFetchPolicy.allowedOrigins,
applicationVariables,
})
.catch((error: Error) => {
@@ -137,12 +89,10 @@ export const FrontComponentWorkerEffect = ({
isInitializedRef.current = true;
return () => {
window.removeEventListener(
COMMAND_MENU_ITEM_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME,
handleCommandMenuItemConfirmationModalResultBrowserEvent as EventListener,
);
window.removeEventListener('message', handleSandboxMessage);
setThread(null);
worker.terminate();
channel.port1.close();
sandboxIframe.remove();
isInitializedRef.current = false;
};
}, [
@@ -152,7 +102,6 @@ export const FrontComponentWorkerEffect = ({
functionsBaseUrl,
sdkClientUrls,
applicationVariables,
frontComponentId,
setError,
setReceiver,
setThread,
@@ -0,0 +1,5 @@
export const FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE = {
READY: 'front-component-sandbox-ready',
INIT: 'front-component-sandbox-init',
ERROR: 'front-component-sandbox-error',
} as const;
@@ -0,0 +1 @@
export declare const FRONT_COMPONENT_SANDBOX_DOCUMENT: string;
@@ -0,0 +1,62 @@
import { isDefined } from 'twenty-shared/utils';
import { FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE } from '@/remote/sandbox/constants/FrontComponentSandboxMessageType';
import { type FrontComponentSandboxMessage } from '@/remote/sandbox/types/FrontComponentSandboxMessage';
import { createSandboxErrorMessageFromWorkerErrorEvent } from '@/remote/sandbox/utils/createSandboxErrorMessageFromWorkerErrorEvent';
import { createWorkerSpawnErrorSandboxMessage } from '@/remote/sandbox/utils/createWorkerSpawnErrorSandboxMessage';
import { parseFrontComponentSandboxMessage } from '@/remote/sandbox/utils/parseFrontComponentSandboxMessage';
import { createFrontComponentRemoteWorker } from '@/remote/worker/utils/createFrontComponentRemoteWorker';
let worker: Worker | null = null;
const postSandboxMessageToHostWindow = (
message: FrontComponentSandboxMessage,
): void => {
window.parent.postMessage(message, '*');
};
window.addEventListener('message', (event) => {
const sandboxMessage = parseFrontComponentSandboxMessage(event.data);
if (
isDefined(worker) ||
sandboxMessage?.type !== FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.INIT
) {
return;
}
const [hostPort] = event.ports;
if (!isDefined(hostPort)) {
return;
}
let spawnedWorker: Worker;
try {
spawnedWorker = createFrontComponentRemoteWorker();
} catch (error) {
postSandboxMessageToHostWindow(createWorkerSpawnErrorSandboxMessage(error));
return;
}
worker = spawnedWorker;
spawnedWorker.addEventListener('error', (errorEvent) => {
postSandboxMessageToHostWindow(
createSandboxErrorMessageFromWorkerErrorEvent(errorEvent),
);
});
spawnedWorker.postMessage(
{
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.INIT,
} satisfies FrontComponentSandboxMessage,
[hostPort],
);
});
postSandboxMessageToHostWindow({
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.READY,
});
@@ -0,0 +1,12 @@
import { type FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE } from '@/remote/sandbox/constants/FrontComponentSandboxMessageType';
export type FrontComponentSandboxMessage =
| { type: typeof FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.READY }
| { type: typeof FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.INIT }
| {
type: typeof FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR;
message: string;
filename?: string;
lineno?: number;
colno?: number;
};
@@ -0,0 +1,38 @@
import { createFrontComponentSandboxIframe } from '../createFrontComponentSandboxIframe';
const SANDBOX_DOCUMENT =
'<!doctype html><html><body><script></script></body></html>';
const toSandboxTokenSet = (iframe: HTMLIFrameElement): Set<string> =>
new Set((iframe.getAttribute('sandbox') ?? '').split(/\s+/).filter(Boolean));
describe('createFrontComponentSandboxIframe', () => {
it('should sandbox the host iframe with only allow-scripts', () => {
const iframe = createFrontComponentSandboxIframe(SANDBOX_DOCUMENT);
const tokens = toSandboxTokenSet(iframe);
expect(tokens.has('allow-scripts')).toBe(true);
expect(tokens.size).toBe(1);
});
it('should never grant allow-same-origin so the worker keeps an opaque origin', () => {
const iframe = createFrontComponentSandboxIframe(SANDBOX_DOCUMENT);
const tokens = toSandboxTokenSet(iframe);
expect(tokens.has('allow-same-origin')).toBe(false);
});
it('should keep the sandbox host frame hidden and non-interactive', () => {
const iframe = createFrontComponentSandboxIframe(SANDBOX_DOCUMENT);
expect(iframe.getAttribute('aria-hidden')).toBe('true');
expect(iframe.style.display).toBe('none');
});
it('should inline the sandbox document through srcdoc rather than a cross-origin url', () => {
const iframe = createFrontComponentSandboxIframe(SANDBOX_DOCUMENT);
expect(iframe.srcdoc).toBe(SANDBOX_DOCUMENT);
expect(iframe.getAttribute('src')).toBeNull();
});
});
@@ -0,0 +1,112 @@
import { CustomError } from 'twenty-shared/utils';
import { FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE } from '@/remote/sandbox/constants/FrontComponentSandboxMessageType';
import { createFrontComponentSandboxMessageHandler } from '../createFrontComponentSandboxMessageHandler';
const createHandlerHarness = () => {
const contentWindow = { postMessage: jest.fn() };
const sandboxIframe = { contentWindow } as unknown as HTMLIFrameElement;
const workerMessagePort = {} as MessagePort;
const onSandboxError = jest.fn();
const handleSandboxMessage = createFrontComponentSandboxMessageHandler({
sandboxIframe,
workerMessagePort,
onSandboxError,
});
const dispatchSandboxMessage = (data: unknown) =>
handleSandboxMessage({
source: contentWindow,
data,
} as unknown as MessageEvent);
return {
contentWindow,
workerMessagePort,
onSandboxError,
handleSandboxMessage,
dispatchSandboxMessage,
};
};
describe('createFrontComponentSandboxMessageHandler', () => {
it('should post INIT with the worker port when READY arrives from the sandbox', () => {
const { contentWindow, workerMessagePort, dispatchSandboxMessage } =
createHandlerHarness();
dispatchSandboxMessage({
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.READY,
});
expect(contentWindow.postMessage).toHaveBeenCalledWith(
{ type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.INIT },
'*',
[workerMessagePort],
);
});
it('should transfer the worker port only once when duplicate READY messages arrive', () => {
const { contentWindow, dispatchSandboxMessage } = createHandlerHarness();
dispatchSandboxMessage({
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.READY,
});
dispatchSandboxMessage({
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.READY,
});
expect(contentWindow.postMessage).toHaveBeenCalledTimes(1);
});
it('should call onSandboxError with a coded error when an ERROR message arrives', () => {
const { onSandboxError, dispatchSandboxMessage } = createHandlerHarness();
dispatchSandboxMessage({
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR,
message: 'worker exploded',
});
expect(onSandboxError).toHaveBeenCalledTimes(1);
const [error] = onSandboxError.mock.calls[0];
expect(error).toBeInstanceOf(CustomError);
expect(error.message).toBe('worker exploded');
expect(error.code).toBe('FRONT_COMPONENT_WORKER_ERROR');
});
it('should fall back to the unknown worker error message when ERROR has no message', () => {
const { onSandboxError, dispatchSandboxMessage } = createHandlerHarness();
dispatchSandboxMessage({
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR,
});
const [error] = onSandboxError.mock.calls[0];
expect(error.message).toBe('Unknown front component worker error');
});
it('should ignore events whose source is not the sandbox content window', () => {
const { contentWindow, onSandboxError, handleSandboxMessage } =
createHandlerHarness();
handleSandboxMessage({
source: {},
data: { type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.READY },
} as unknown as MessageEvent);
expect(contentWindow.postMessage).not.toHaveBeenCalled();
expect(onSandboxError).not.toHaveBeenCalled();
});
it('should ignore junk message data', () => {
const { contentWindow, onSandboxError, dispatchSandboxMessage } =
createHandlerHarness();
dispatchSandboxMessage(null);
dispatchSandboxMessage('unrelated');
dispatchSandboxMessage({ type: 'unrelated-message' });
expect(contentWindow.postMessage).not.toHaveBeenCalled();
expect(onSandboxError).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,21 @@
import { FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE } from '@/remote/sandbox/constants/FrontComponentSandboxMessageType';
import { createSandboxErrorMessageFromWorkerErrorEvent } from '../createSandboxErrorMessageFromWorkerErrorEvent';
describe('createSandboxErrorMessageFromWorkerErrorEvent', () => {
it('should copy the message and source location from the error event', () => {
const errorEvent = {
message: 'worker exploded',
filename: 'blob:worker.js',
lineno: 12,
colno: 3,
} as ErrorEvent;
expect(createSandboxErrorMessageFromWorkerErrorEvent(errorEvent)).toEqual({
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR,
message: 'worker exploded',
filename: 'blob:worker.js',
lineno: 12,
colno: 3,
});
});
});
@@ -0,0 +1,27 @@
import { FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE } from '@/remote/sandbox/constants/FrontComponentSandboxMessageType';
import { createWorkerSpawnErrorSandboxMessage } from '../createWorkerSpawnErrorSandboxMessage';
describe('createWorkerSpawnErrorSandboxMessage', () => {
it('should use the error message when a spawn Error is provided', () => {
expect(
createWorkerSpawnErrorSandboxMessage(new Error('worker blocked by CSP')),
).toEqual({
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR,
message: 'worker blocked by CSP',
});
});
it('should fall back to the spawn failure message when the thrown value is not an Error', () => {
expect(createWorkerSpawnErrorSandboxMessage('exploded')).toEqual({
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR,
message: 'Failed to spawn the front component worker',
});
});
it('should fall back to the spawn failure message when the Error message is empty', () => {
expect(createWorkerSpawnErrorSandboxMessage(new Error(''))).toEqual({
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR,
message: 'Failed to spawn the front component worker',
});
});
});
@@ -0,0 +1,73 @@
import { FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE } from '@/remote/sandbox/constants/FrontComponentSandboxMessageType';
import { parseFrontComponentSandboxMessage } from '../parseFrontComponentSandboxMessage';
describe('parseFrontComponentSandboxMessage', () => {
it('should parse a READY message', () => {
expect(
parseFrontComponentSandboxMessage({
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.READY,
}),
).toEqual({ type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.READY });
});
it('should parse an INIT message', () => {
expect(
parseFrontComponentSandboxMessage({
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.INIT,
}),
).toEqual({ type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.INIT });
});
it('should parse an ERROR message with its details', () => {
expect(
parseFrontComponentSandboxMessage({
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR,
message: 'worker exploded',
filename: 'blob:worker.js',
lineno: 12,
colno: 3,
}),
).toEqual({
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR,
message: 'worker exploded',
filename: 'blob:worker.js',
lineno: 12,
colno: 3,
});
});
it('should parse an ERROR message without a message text', () => {
expect(
parseFrontComponentSandboxMessage({
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR,
}),
).toEqual({
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR,
message: '',
filename: undefined,
lineno: undefined,
colno: undefined,
});
});
it('should return null when data is null', () => {
expect(parseFrontComponentSandboxMessage(null)).toBeNull();
});
it('should return null when data is a primitive', () => {
expect(
parseFrontComponentSandboxMessage('front-component-sandbox-ready'),
).toBeNull();
expect(parseFrontComponentSandboxMessage(42)).toBeNull();
});
it('should return null when the type is missing', () => {
expect(parseFrontComponentSandboxMessage({ message: 'hello' })).toBeNull();
});
it('should return null when the type is unknown', () => {
expect(
parseFrontComponentSandboxMessage({ type: 'unrelated-message' }),
).toBeNull();
});
});
@@ -0,0 +1,16 @@
const FRONT_COMPONENT_SANDBOX_IFRAME_PERMISSIONS = 'allow-scripts';
export const createFrontComponentSandboxIframe = (
sandboxDocument: string,
): HTMLIFrameElement => {
const sandboxIframe = document.createElement('iframe');
sandboxIframe.setAttribute(
'sandbox',
FRONT_COMPONENT_SANDBOX_IFRAME_PERMISSIONS,
);
sandboxIframe.setAttribute('aria-hidden', 'true');
sandboxIframe.style.display = 'none';
sandboxIframe.srcdoc = sandboxDocument;
return sandboxIframe;
};
@@ -0,0 +1,64 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CustomError, isDefined } from 'twenty-shared/utils';
import { FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE } from '@/remote/sandbox/constants/FrontComponentSandboxMessageType';
import { type FrontComponentSandboxMessage } from '@/remote/sandbox/types/FrontComponentSandboxMessage';
import { parseFrontComponentSandboxMessage } from '@/remote/sandbox/utils/parseFrontComponentSandboxMessage';
const UNKNOWN_WORKER_ERROR_MESSAGE = 'Unknown front component worker error';
type FrontComponentSandboxMessageHandlerConfig = {
sandboxIframe: HTMLIFrameElement;
workerMessagePort: MessagePort;
onSandboxError: (error: Error) => void;
};
export const createFrontComponentSandboxMessageHandler = ({
sandboxIframe,
workerMessagePort,
onSandboxError,
}: FrontComponentSandboxMessageHandlerConfig): ((
event: MessageEvent,
) => void) => {
let hasTransferredWorkerPort = false;
return (event: MessageEvent) => {
if (event.source !== sandboxIframe.contentWindow) {
return;
}
const sandboxMessage = parseFrontComponentSandboxMessage(event.data);
if (!isDefined(sandboxMessage)) {
return;
}
if (sandboxMessage.type === FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.READY) {
if (hasTransferredWorkerPort) {
return;
}
hasTransferredWorkerPort = true;
sandboxIframe.contentWindow?.postMessage(
{
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.INIT,
} satisfies FrontComponentSandboxMessage,
'*',
[workerMessagePort],
);
return;
}
if (sandboxMessage.type === FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR) {
onSandboxError(
new CustomError(
isNonEmptyString(sandboxMessage.message)
? sandboxMessage.message
: UNKNOWN_WORKER_ERROR_MESSAGE,
'FRONT_COMPONENT_WORKER_ERROR',
),
);
}
};
};
@@ -0,0 +1,12 @@
import { FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE } from '@/remote/sandbox/constants/FrontComponentSandboxMessageType';
import { type FrontComponentSandboxMessage } from '@/remote/sandbox/types/FrontComponentSandboxMessage';
export const createSandboxErrorMessageFromWorkerErrorEvent = (
errorEvent: ErrorEvent,
): FrontComponentSandboxMessage => ({
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR,
message: errorEvent.message,
filename: errorEvent.filename,
lineno: errorEvent.lineno,
colno: errorEvent.colno,
});
@@ -0,0 +1,17 @@
import { isNonEmptyString } from '@sniptt/guards';
import { FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE } from '@/remote/sandbox/constants/FrontComponentSandboxMessageType';
import { type FrontComponentSandboxMessage } from '@/remote/sandbox/types/FrontComponentSandboxMessage';
const WORKER_SPAWN_FAILURE_MESSAGE =
'Failed to spawn the front component worker';
export const createWorkerSpawnErrorSandboxMessage = (
error: unknown,
): FrontComponentSandboxMessage => ({
type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR,
message:
error instanceof Error && isNonEmptyString(error.message)
? error.message
: WORKER_SPAWN_FAILURE_MESSAGE,
});
@@ -0,0 +1,40 @@
import { isNumber, isObject, isString } from '@sniptt/guards';
import { FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE } from '@/remote/sandbox/constants/FrontComponentSandboxMessageType';
import { type FrontComponentSandboxMessage } from '@/remote/sandbox/types/FrontComponentSandboxMessage';
export const parseFrontComponentSandboxMessage = (
data: unknown,
): FrontComponentSandboxMessage | null => {
if (!isObject(data) || !('type' in data)) {
return null;
}
const { type } = data as { type: unknown };
if (
type === FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.READY ||
type === FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.INIT
) {
return { type };
}
if (type === FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR) {
const { message, filename, lineno, colno } = data as {
message?: unknown;
filename?: unknown;
lineno?: unknown;
colno?: unknown;
};
return {
type,
message: isString(message) ? message : '',
filename: isString(filename) ? filename : undefined,
lineno: isNumber(lineno) ? lineno : undefined,
colno: isNumber(colno) ? colno : undefined,
};
}
return null;
};
@@ -0,0 +1,4 @@
export const SDK_CLIENT_IMPORT_SPECIFIERS = [
'twenty-client-sdk/core',
'twenty-client-sdk/metadata',
] as const;
@@ -3,33 +3,23 @@ import '@remote-dom/react/polyfill';
import '../generated/remote-elements';
import { ThreadWebWorker } from '@quilted/threads';
import {
BatchingRemoteConnection,
type RemoteConnection,
type RemoteRootElement,
} from '@remote-dom/core/elements';
import { ThreadMessagePort } from '@quilted/threads';
import { isDefined } from 'twenty-shared/utils';
import { installStyleBridge } from '@/polyfills/installStyleBridge';
import { installStylePropertyOnRemoteElements } from '@/remote/utils/installStylePropertyOnRemoteElements';
import { patchRemoteElementAttributes } from '@/remote/utils/patchRemoteElementAttributes';
import { fetchComponentSource } from './utils/fetchComponentSource';
import { installErrorEventBridge } from './utils/installErrorEventBridge';
import { type FrontComponentExecutionContext } from 'twenty-sdk/front-component';
import { frontComponentHostCommunicationApi } from '@/constants/frontComponentHostCommunicationApi';
import { HTML_TAG_TO_CUSTOM_ELEMENT_TAG } from '@/constants/HtmlTagToRemoteComponent';
import { setFrontComponentExecutionContext } from './utils/setFrontComponentExecutionContext';
import { type FrontComponentHostCommunicationApi } from '../../types/FrontComponentHostCommunicationApi';
import { type HostToWorkerRenderContext } from '../../types/HostToWorkerRenderContext';
import { type WorkerExports } from '../../types/WorkerExports';
import { exposeGlobals } from '../utils/exposeGlobals';
import {
createOpenCommandConfirmationModalAdapter,
handleCommandConfirmationModalResult,
} from './utils/createCommandConfirmationModalBridge';
import { setWorkerEnv } from './utils/setWorkerEnv';
import { exposeGlobals } from '@/remote/utils/exposeGlobals';
import { installStylePropertyOnRemoteElements } from '@/remote/utils/installStylePropertyOnRemoteElements';
import { patchRemoteElementAttributes } from '@/remote/utils/patchRemoteElementAttributes';
import { buildFrontComponentHostCommunicationApiFromThreadImports } from '@/remote/worker/utils/buildFrontComponentHostCommunicationApiFromThreadImports';
import { handleCommandConfirmationModalResult } from '@/remote/worker/utils/createCommandConfirmationModalBridge';
import { installErrorEventBridge } from '@/remote/worker/utils/installErrorEventBridge';
import { renderFrontComponent } from '@/remote/worker/utils/renderFrontComponent';
import { setFrontComponentExecutionContext } from '@/remote/worker/utils/setFrontComponentExecutionContext';
import { type FrontComponentHostThread } from '@/types/FrontComponentHostThread';
import { type FrontComponentHostThreadExports } from '@/types/FrontComponentHostThreadExports';
import { type WorkerExports } from '@/types/WorkerExports';
installStylePropertyOnRemoteElements();
patchRemoteElementAttributes();
@@ -39,142 +29,49 @@ exposeGlobals({
__HTML_TAG_TO_CUSTOM_ELEMENT_TAG__: HTML_TAG_TO_CUSTOM_ELEMENT_TAG,
});
const SDK_IMPORT_SPECIFIERS = [
'twenty-client-sdk/core',
'twenty-client-sdk/metadata',
] as const;
let hostThread: FrontComponentHostThread | null = null;
// Rewrites bare SDK import specifiers to the blob URLs provided by the host.
const rewriteSdkImports = (
source: string,
sdkClientUrls: { core: string; metadata: string },
): string => {
const specifierToBlobUrl: Record<string, string> = {
'twenty-client-sdk/core': sdkClientUrls.core,
'twenty-client-sdk/metadata': sdkClientUrls.metadata,
};
let rewritten = source;
for (const [specifier, blobUrl] of Object.entries(specifierToBlobUrl)) {
rewritten = rewritten
.split(`"${specifier}"`)
.join(`"${blobUrl}"`)
.split(`'${specifier}'`)
.join(`'${blobUrl}'`);
}
return rewritten;
};
const render: WorkerExports['render'] = async (
connection: RemoteConnection,
renderContext: HostToWorkerRenderContext,
) => {
const batchedConnection = new BatchingRemoteConnection(connection);
const root = document.createElement('remote-root') as RemoteRootElement;
const renderContainer = document.createElement('remote-fragment');
root.connect(batchedConnection);
root.append(renderContainer);
document.body.append(root);
installStyleBridge(root);
if (isDefined(renderContext.applicationVariables)) {
setWorkerEnv({
applicationVariables: JSON.stringify(renderContext.applicationVariables),
const workerExports: WorkerExports = {
render: async (connection, renderContext) => {
await renderFrontComponent({
connection,
renderContext,
hostFetch: hostThread?.imports.hostFetch ?? null,
});
}
},
initializeHostCommunicationApi: async () => {
if (!isDefined(hostThread)) {
return;
}
// System variables are set after application variables so they cannot be overridden
if (isDefined(renderContext.apiUrl)) {
setWorkerEnv({
TWENTY_API_URL: renderContext.apiUrl,
});
}
if (isDefined(renderContext.functionsBaseUrl)) {
setWorkerEnv({
TWENTY_FUNCTIONS_URL: renderContext.functionsBaseUrl,
});
}
if (isDefined(renderContext.applicationAccessToken)) {
setWorkerEnv({
TWENTY_APP_ACCESS_TOKEN: renderContext.applicationAccessToken,
});
}
const authHeaders = isDefined(renderContext.applicationAccessToken)
? { Authorization: `Bearer ${renderContext.applicationAccessToken}` }
: undefined;
const componentSource = await fetchComponentSource({
url: renderContext.componentUrl,
headers: authHeaders,
});
const hasSdkImports =
isDefined(renderContext.sdkClientUrls) &&
SDK_IMPORT_SPECIFIERS.some((specifier) =>
componentSource.includes(specifier),
Object.assign(
frontComponentHostCommunicationApi,
buildFrontComponentHostCommunicationApiFromThreadImports(
hostThread.imports,
),
);
},
updateContext: async (context) => {
setFrontComponentExecutionContext(context);
},
onConfirmationModalResult: async (result) => {
await handleCommandConfirmationModalResult(result);
},
};
const finalSource = hasSdkImports
? rewriteSdkImports(componentSource, renderContext.sdkClientUrls!)
: componentSource;
self.addEventListener('message', (event) => {
const [transferredPort] = event.ports;
const componentBlob = new Blob([finalSource], {
type: 'application/javascript',
if (isDefined(hostThread) || !isDefined(transferredPort)) {
return;
}
hostThread = new ThreadMessagePort<
FrontComponentHostThreadExports,
WorkerExports
>(transferredPort, {
exports: workerExports,
});
const importUrl = URL.createObjectURL(componentBlob);
try {
/* @vite-ignore */
const componentModule = await import(importUrl);
componentModule.default(renderContainer);
} finally {
URL.revokeObjectURL(importUrl);
}
};
const initializeHostCommunicationApi: WorkerExports['initializeHostCommunicationApi'] =
async () => {
const hostApi =
ThreadWebWorker.self.import<FrontComponentHostCommunicationApi>();
frontComponentHostCommunicationApi.navigate = hostApi.navigate;
frontComponentHostCommunicationApi.requestAccessTokenRefresh =
hostApi.requestAccessTokenRefresh;
frontComponentHostCommunicationApi.openSidePanelPage =
hostApi.openSidePanelPage;
frontComponentHostCommunicationApi.openCommandConfirmationModal =
createOpenCommandConfirmationModalAdapter(hostApi);
frontComponentHostCommunicationApi.unmountFrontComponent =
hostApi.unmountFrontComponent;
frontComponentHostCommunicationApi.enqueueSnackbar =
hostApi.enqueueSnackbar;
frontComponentHostCommunicationApi.closeSidePanel = hostApi.closeSidePanel;
frontComponentHostCommunicationApi.updateProgress = hostApi.updateProgress;
frontComponentHostCommunicationApi.copyToClipboard =
hostApi.copyToClipboard;
};
const onConfirmationModalResult: WorkerExports['onConfirmationModalResult'] =
async (result) => {
await handleCommandConfirmationModalResult(result);
};
const updateContext: WorkerExports['updateContext'] = async (
context: FrontComponentExecutionContext,
) => {
setFrontComponentExecutionContext(context);
};
ThreadWebWorker.self.export({
render,
initializeHostCommunicationApi,
onConfirmationModalResult,
updateContext,
transferredPort.start();
});
@@ -0,0 +1,17 @@
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();
});
});
@@ -0,0 +1,91 @@
import { type FrontComponentHostThreadExports } from '@/types/FrontComponentHostThreadExports';
import { buildFrontComponentHostCommunicationApiFromThreadImports } from '../buildFrontComponentHostCommunicationApiFromThreadImports';
import { handleCommandConfirmationModalResult } from '../createCommandConfirmationModalBridge';
const createHostThreadImportsStub = () =>
({
navigate: jest.fn(),
requestAccessTokenRefresh: jest.fn(),
openSidePanelPage: jest.fn(),
openCommandConfirmationModal: jest.fn(async () => {}),
unmountFrontComponent: jest.fn(),
enqueueSnackbar: jest.fn(),
closeSidePanel: jest.fn(),
updateProgress: jest.fn(),
copyToClipboard: jest.fn(),
hostFetch: jest.fn(),
}) as unknown as FrontComponentHostThreadExports;
describe('buildFrontComponentHostCommunicationApiFromThreadImports', () => {
afterEach(async () => {
await handleCommandConfirmationModalResult('cancel');
});
it('should map every host api member onto the communication api', () => {
const hostThreadImports = createHostThreadImportsStub();
const hostCommunicationApi =
buildFrontComponentHostCommunicationApiFromThreadImports(
hostThreadImports,
);
expect(Object.keys(hostCommunicationApi).sort()).toEqual([
'closeSidePanel',
'copyToClipboard',
'enqueueSnackbar',
'navigate',
'openCommandConfirmationModal',
'openSidePanelPage',
'requestAccessTokenRefresh',
'unmountFrontComponent',
'updateProgress',
]);
expect(hostCommunicationApi.navigate).toBe(hostThreadImports.navigate);
expect(hostCommunicationApi.requestAccessTokenRefresh).toBe(
hostThreadImports.requestAccessTokenRefresh,
);
expect(hostCommunicationApi.openSidePanelPage).toBe(
hostThreadImports.openSidePanelPage,
);
expect(hostCommunicationApi.unmountFrontComponent).toBe(
hostThreadImports.unmountFrontComponent,
);
expect(hostCommunicationApi.enqueueSnackbar).toBe(
hostThreadImports.enqueueSnackbar,
);
expect(hostCommunicationApi.closeSidePanel).toBe(
hostThreadImports.closeSidePanel,
);
expect(hostCommunicationApi.updateProgress).toBe(
hostThreadImports.updateProgress,
);
expect(hostCommunicationApi.copyToClipboard).toBe(
hostThreadImports.copyToClipboard,
);
});
it('should wrap openCommandConfirmationModal with the confirmation modal adapter', async () => {
const hostThreadImports = createHostThreadImportsStub();
const hostCommunicationApi =
buildFrontComponentHostCommunicationApiFromThreadImports(
hostThreadImports,
);
expect(hostCommunicationApi.openCommandConfirmationModal).not.toBe(
hostThreadImports.openCommandConfirmationModal,
);
const confirmationResultPromise =
hostCommunicationApi.openCommandConfirmationModal(
{} as Parameters<
typeof hostCommunicationApi.openCommandConfirmationModal
>[0],
);
expect(hostThreadImports.openCommandConfirmationModal).toHaveBeenCalled();
await handleCommandConfirmationModalResult('confirm');
await expect(confirmationResultPromise).resolves.toBe('confirm');
});
});
@@ -0,0 +1,62 @@
import { buildHostFetchInputFromFetchRequestArguments } from '../buildHostFetchInputFromFetchRequestArguments';
describe('buildHostFetchInputFromFetchRequestArguments', () => {
it('should assemble the url, method, headers and body into a host fetch input', async () => {
await expect(
buildHostFetchInputFromFetchRequestArguments(
'https://api.twenty.test/graphql',
{
method: 'POST',
headers: { authorization: 'Bearer token' },
body: '{"query":"{ me }"}',
},
),
).resolves.toEqual({
url: 'https://api.twenty.test/graphql',
method: 'POST',
headers: { authorization: 'Bearer token' },
body: '{"query":"{ me }"}',
});
});
it('should default the content type when the body is URLSearchParams', async () => {
const hostFetchInput = await buildHostFetchInputFromFetchRequestArguments(
'https://api.twenty.test/track',
{ method: 'POST', body: new URLSearchParams({ event: 'clicked' }) },
);
expect(hostFetchInput.headers).toEqual({
'content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
});
expect(hostFetchInput.body).toBe('event=clicked');
});
it('should keep an explicit content type when the body is URLSearchParams', async () => {
const hostFetchInput = await buildHostFetchInputFromFetchRequestArguments(
'https://api.twenty.test/track',
{
method: 'POST',
headers: { 'content-type': 'application/custom' },
body: new URLSearchParams({ event: 'clicked' }),
},
);
expect(hostFetchInput.headers).toEqual({
'content-type': 'application/custom',
});
});
it('should default to a GET request without headers or body', async () => {
await expect(
buildHostFetchInputFromFetchRequestArguments(
'https://api.twenty.test/graphql',
undefined,
),
).resolves.toEqual({
url: 'https://api.twenty.test/graphql',
method: 'GET',
headers: {},
body: undefined,
});
});
});
@@ -0,0 +1,81 @@
import { buildResponseFromHostFetchResult } from '../buildResponseFromHostFetchResult';
class StubResponse {
body: string | null;
status: number;
statusText: string;
headers: Headers;
constructor(
body: string | null,
init?: {
status?: number;
statusText?: string;
headers?: Record<string, string>;
},
) {
this.body = body;
this.status = init?.status ?? 200;
this.statusText = init?.statusText ?? '';
this.headers = new Headers(init?.headers);
}
get ok() {
return this.status >= 200 && this.status < 300;
}
async text() {
return this.body ?? '';
}
}
const originalResponse = (globalThis as { Response?: unknown }).Response;
describe('buildResponseFromHostFetchResult', () => {
beforeAll(() => {
(globalThis as { Response?: unknown }).Response = StubResponse;
});
afterAll(() => {
(globalThis as { Response?: unknown }).Response = originalResponse;
});
it('should rebuild a response carrying the host fetch result fields', async () => {
const response = buildResponseFromHostFetchResult({
status: 201,
statusText: 'Created',
headers: { 'content-type': 'application/json' },
body: 'proxied',
});
expect(response.ok).toBe(true);
expect(response.status).toBe(201);
expect(response.statusText).toBe('Created');
expect(response.headers.get('content-type')).toBe('application/json');
await expect(response.text()).resolves.toBe('proxied');
});
it('should mark error statuses as not ok', () => {
const response = buildResponseFromHostFetchResult({
status: 403,
statusText: 'Forbidden',
headers: {},
body: '',
});
expect(response.ok).toBe(false);
});
it('should drop the body for null body statuses', () => {
for (const status of [204, 205, 304]) {
const response = buildResponseFromHostFetchResult({
status,
statusText: '',
headers: {},
body: '',
});
expect(response.body).toBeNull();
}
});
});
@@ -0,0 +1,90 @@
import {
createOpenCommandConfirmationModalAdapter,
handleCommandConfirmationModalResult,
} from '../createCommandConfirmationModalBridge';
type OpenModalAdapter = ReturnType<
typeof createOpenCommandConfirmationModalAdapter
>;
const modalParams = {} as Parameters<OpenModalAdapter>[0];
describe('createCommandConfirmationModalBridge', () => {
afterEach(async () => {
await handleCommandConfirmationModalResult('cancel');
});
it('should resolve the pending promise when the confirmation result arrives', async () => {
const openCommandConfirmationModal =
createOpenCommandConfirmationModalAdapter({
openCommandConfirmationModal: jest.fn(async () => {}),
});
const confirmationResultPromise = openCommandConfirmationModal(modalParams);
await handleCommandConfirmationModalResult('confirm');
await expect(confirmationResultPromise).resolves.toBe('confirm');
});
it('should reject with a coded error when a modal is already pending', async () => {
const openCommandConfirmationModal =
createOpenCommandConfirmationModalAdapter({
openCommandConfirmationModal: jest.fn(async () => {}),
});
const firstConfirmationResultPromise =
openCommandConfirmationModal(modalParams);
await expect(
openCommandConfirmationModal(modalParams),
).rejects.toMatchObject({
code: 'FRONT_COMPONENT_CONFIRMATION_MODAL_ALREADY_PENDING',
});
await handleCommandConfirmationModalResult('cancel');
await expect(firstConfirmationResultPromise).resolves.toBe('cancel');
});
it('should reject and clear the pending state when the host call fails', async () => {
const openCommandConfirmationModal =
createOpenCommandConfirmationModalAdapter({
openCommandConfirmationModal: jest.fn(async () => {
throw new Error('host modal failed');
}),
});
await expect(openCommandConfirmationModal(modalParams)).rejects.toThrow(
'host modal failed',
);
const retriedConfirmationResultPromise =
openCommandConfirmationModal(modalParams);
await handleCommandConfirmationModalResult('confirm');
await expect(retriedConfirmationResultPromise).resolves.toBe('confirm');
});
it('should allow opening a new modal after the previous one resolved', async () => {
const openCommandConfirmationModal =
createOpenCommandConfirmationModalAdapter({
openCommandConfirmationModal: jest.fn(async () => {}),
});
const firstConfirmationResultPromise =
openCommandConfirmationModal(modalParams);
await handleCommandConfirmationModalResult('confirm');
await expect(firstConfirmationResultPromise).resolves.toBe('confirm');
const secondConfirmationResultPromise =
openCommandConfirmationModal(modalParams);
await handleCommandConfirmationModalResult('cancel');
await expect(secondConfirmationResultPromise).resolves.toBe('cancel');
});
it('should ignore confirmation results when no modal is pending', async () => {
await expect(
handleCommandConfirmationModalResult('confirm'),
).resolves.toBeUndefined();
});
});
@@ -0,0 +1,28 @@
import { createJavaScriptModuleBlobUrl } from '../createJavaScriptModuleBlobUrl';
const originalCreateObjectUrl = URL.createObjectURL;
describe('createJavaScriptModuleBlobUrl', () => {
afterEach(() => {
URL.createObjectURL = originalCreateObjectUrl;
});
it('should return the object url created for the module source', () => {
URL.createObjectURL = jest.fn(() => 'blob:mock-url');
expect(createJavaScriptModuleBlobUrl('export default () => {};')).toBe(
'blob:mock-url',
);
});
it('should create a javascript blob from the source', () => {
const createObjectUrlSpy = jest.fn(() => 'blob:mock-url');
URL.createObjectURL = createObjectUrlSpy;
createJavaScriptModuleBlobUrl('export default () => {};');
const [blob] = createObjectUrlSpy.mock.calls[0] as unknown as [Blob];
expect(blob).toBeInstanceOf(Blob);
expect(blob.type).toBe('application/javascript');
});
});
@@ -0,0 +1,80 @@
import { fetchJavaScriptModuleSourceText } from '../fetchJavaScriptModuleSourceText';
const originalFetch = globalThis.fetch;
describe('fetchJavaScriptModuleSourceText', () => {
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('should return the response text when the response is ok', async () => {
globalThis.fetch = jest.fn(async () => ({
ok: true,
text: async () => 'module source',
})) as unknown as typeof fetch;
await expect(
fetchJavaScriptModuleSourceText('https://api.twenty.test/component.js'),
).resolves.toBe('module source');
});
it('should forward headers to fetch', async () => {
const fetchSpy = jest.fn(async () => ({
ok: true,
text: async () => '',
}));
globalThis.fetch = fetchSpy as unknown as typeof fetch;
await fetchJavaScriptModuleSourceText(
'https://api.twenty.test/component.js',
{
Authorization: 'Bearer token',
},
);
expect(fetchSpy).toHaveBeenCalledWith(
'https://api.twenty.test/component.js',
{ headers: { Authorization: 'Bearer token' } },
);
});
it('should reject with a coded error when the response is not ok', async () => {
globalThis.fetch = jest.fn(async () => ({
ok: false,
status: 404,
statusText: 'Not Found',
})) as unknown as typeof fetch;
await expect(
fetchJavaScriptModuleSourceText('https://api.twenty.test/component.js'),
).rejects.toMatchObject({ code: 'FRONT_COMPONENT_MODULE_FETCH_FAILED' });
});
it('should wrap fetch rejections in a coded error', async () => {
globalThis.fetch = jest.fn(async () => {
throw new TypeError('Failed to fetch');
}) as unknown as typeof fetch;
await expect(
fetchJavaScriptModuleSourceText('https://api.twenty.test/component.js'),
).rejects.toMatchObject({
code: 'FRONT_COMPONENT_MODULE_FETCH_FAILED',
message:
'Failed to fetch front component module https://api.twenty.test/component.js: Failed to fetch',
});
});
it('should include the url and status in the error message', async () => {
globalThis.fetch = jest.fn(async () => ({
ok: false,
status: 403,
statusText: 'Forbidden',
})) as unknown as typeof fetch;
await expect(
fetchJavaScriptModuleSourceText('https://api.twenty.test/component.js'),
).rejects.toThrow(
'Failed to fetch front component module https://api.twenty.test/component.js: 403 Forbidden',
);
});
});
@@ -0,0 +1,99 @@
import { fetchSdkClientModulesAsBlobUrls } from '../fetchSdkClientModulesAsBlobUrls';
const originalFetch = globalThis.fetch;
const originalCreateObjectUrl = URL.createObjectURL;
const originalRevokeObjectUrl = URL.revokeObjectURL;
const sdkClientUrls = {
core: 'https://api.twenty.test/sdk-client/application-id/core',
metadata: 'https://api.twenty.test/sdk-client/application-id/metadata',
};
describe('fetchSdkClientModulesAsBlobUrls', () => {
afterEach(() => {
globalThis.fetch = originalFetch;
URL.createObjectURL = originalCreateObjectUrl;
URL.revokeObjectURL = originalRevokeObjectUrl;
});
it('should fetch both sdk modules and return their blob urls', async () => {
const fetchSpy = jest.fn(async (url: string) => ({
ok: true,
text: async () => `source of ${url}`,
}));
globalThis.fetch = fetchSpy as unknown as typeof fetch;
URL.createObjectURL = jest
.fn()
.mockReturnValueOnce('blob:core-url')
.mockReturnValueOnce('blob:metadata-url');
await expect(
fetchSdkClientModulesAsBlobUrls(sdkClientUrls),
).resolves.toEqual({
core: 'blob:core-url',
metadata: 'blob:metadata-url',
});
expect(fetchSpy).toHaveBeenCalledWith(sdkClientUrls.core, {
headers: undefined,
});
expect(fetchSpy).toHaveBeenCalledWith(sdkClientUrls.metadata, {
headers: undefined,
});
});
it('should forward headers to both module fetches', async () => {
const fetchSpy = jest.fn(async () => ({
ok: true,
text: async () => '',
}));
globalThis.fetch = fetchSpy as unknown as typeof fetch;
URL.createObjectURL = jest.fn(() => 'blob:mock-url');
await fetchSdkClientModulesAsBlobUrls(sdkClientUrls, {
Authorization: 'Bearer token',
});
expect(fetchSpy).toHaveBeenCalledWith(sdkClientUrls.core, {
headers: { Authorization: 'Bearer token' },
});
expect(fetchSpy).toHaveBeenCalledWith(sdkClientUrls.metadata, {
headers: { Authorization: 'Bearer token' },
});
});
it('should propagate the fetch error when one module fails to load', async () => {
globalThis.fetch = jest.fn(async (url: string) => ({
ok: url !== sdkClientUrls.metadata,
status: 500,
statusText: 'Internal Server Error',
text: async () => '',
})) as unknown as typeof fetch;
URL.createObjectURL = jest.fn(() => 'blob:mock-url');
URL.revokeObjectURL = jest.fn();
await expect(
fetchSdkClientModulesAsBlobUrls(sdkClientUrls),
).rejects.toMatchObject({
code: 'FRONT_COMPONENT_MODULE_FETCH_FAILED',
});
});
it('should revoke the created blob url when the other module fails to load', async () => {
globalThis.fetch = jest.fn(async (url: string) => ({
ok: url !== sdkClientUrls.metadata,
status: 500,
statusText: 'Internal Server Error',
text: async () => '',
})) as unknown as typeof fetch;
URL.createObjectURL = jest.fn(() => 'blob:core-url');
const revokeObjectUrlSpy = jest.fn();
URL.revokeObjectURL = revokeObjectUrlSpy;
await expect(
fetchSdkClientModulesAsBlobUrls(sdkClientUrls),
).rejects.toMatchObject({
code: 'FRONT_COMPONENT_MODULE_FETCH_FAILED',
});
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:core-url');
});
});
@@ -0,0 +1,57 @@
import { getHeadersFromFetchRequestArguments } from '../getHeadersFromFetchRequestArguments';
describe('getHeadersFromFetchRequestArguments', () => {
it('should convert init headers given as a record', () => {
expect(
getHeadersFromFetchRequestArguments('https://api.twenty.test', {
headers: { 'Content-Type': 'application/json' },
}),
).toEqual({ 'content-type': 'application/json' });
});
it('should convert init headers given as a Headers instance', () => {
expect(
getHeadersFromFetchRequestArguments('https://api.twenty.test', {
headers: new Headers({ authorization: 'Bearer token' }),
}),
).toEqual({ authorization: 'Bearer token' });
});
it('should convert init headers given as an entries array', () => {
expect(
getHeadersFromFetchRequestArguments('https://api.twenty.test', {
headers: [['x-schema-version', '42']],
}),
).toEqual({ 'x-schema-version': '42' });
});
it('should prefer init headers over Request headers when both are present', () => {
const request = {
url: 'https://api.twenty.test',
headers: new Headers({ authorization: 'Bearer request-token' }),
} as unknown as Request;
expect(
getHeadersFromFetchRequestArguments(request, {
headers: { authorization: 'Bearer init-token' },
}),
).toEqual({ authorization: 'Bearer init-token' });
});
it('should fall back to Request headers when init headers are absent', () => {
const request = {
url: 'https://api.twenty.test',
headers: new Headers({ authorization: 'Bearer request-token' }),
} as unknown as Request;
expect(getHeadersFromFetchRequestArguments(request, undefined)).toEqual({
authorization: 'Bearer request-token',
});
});
it('should return an empty record when neither init nor Request provides headers', () => {
expect(
getHeadersFromFetchRequestArguments('https://api.twenty.test', undefined),
).toEqual({});
});
});
@@ -0,0 +1,30 @@
import { getMethodFromFetchRequestArguments } from '../getMethodFromFetchRequestArguments';
describe('getMethodFromFetchRequestArguments', () => {
it('should prefer the init method when both init and Request provide one', () => {
const request = { url: 'https://api.twenty.test', method: 'PUT' };
expect(
getMethodFromFetchRequestArguments(request as unknown as Request, {
method: 'POST',
}),
).toBe('POST');
});
it('should use the Request method when init has no method', () => {
const request = { url: 'https://api.twenty.test', method: 'DELETE' };
expect(
getMethodFromFetchRequestArguments(
request as unknown as Request,
undefined,
),
).toBe('DELETE');
});
it('should default to GET when input is a string and init has no method', () => {
expect(
getMethodFromFetchRequestArguments('https://api.twenty.test', undefined),
).toBe('GET');
});
});
@@ -0,0 +1,90 @@
import { getTextBodyFromFetchRequestArguments } from '../getTextBodyFromFetchRequestArguments';
const createRequestInput = ({
headers,
body,
}: {
headers?: Record<string, string>;
body?: string;
}): Request =>
({
url: 'https://api.twenty.test/graphql',
headers: new Headers(headers),
clone: () => ({ text: async () => body ?? '' }),
}) as unknown as Request;
describe('getTextBodyFromFetchRequestArguments', () => {
it('should return the init body when it is a string', async () => {
await expect(
getTextBodyFromFetchRequestArguments('https://api.twenty.test', {
body: '{"query":"{ me }"}',
}),
).resolves.toBe('{"query":"{ me }"}');
});
it('should stringify URLSearchParams init bodies', async () => {
await expect(
getTextBodyFromFetchRequestArguments('https://api.twenty.test', {
body: new URLSearchParams({ event: 'clicked' }),
}),
).resolves.toBe('event=clicked');
});
it('should return undefined when no body is provided', async () => {
await expect(
getTextBodyFromFetchRequestArguments(
'https://api.twenty.test',
undefined,
),
).resolves.toBeUndefined();
});
it('should read the text body from a cloned Request when init has no body', async () => {
const request = createRequestInput({
headers: { 'content-type': 'application/json' },
body: '{"query":"{ me }"}',
});
await expect(
getTextBodyFromFetchRequestArguments(request, undefined),
).resolves.toBe('{"query":"{ me }"}');
});
it('should return undefined when the Request body is empty', async () => {
const request = createRequestInput({});
await expect(
getTextBodyFromFetchRequestArguments(request, undefined),
).resolves.toBeUndefined();
});
it('should reject with a TypeError when the Request content type is not text', async () => {
const request = createRequestInput({
headers: { 'content-type': 'multipart/form-data; boundary=boundary' },
body: '--boundary\r\nbinary-payload\r\n--boundary--',
});
await expect(
getTextBodyFromFetchRequestArguments(request, undefined),
).rejects.toBeInstanceOf(TypeError);
await expect(
getTextBodyFromFetchRequestArguments(request, undefined),
).rejects.toThrow('multipart/form-data');
});
it('should reject with a TypeError when the init body is FormData', async () => {
await expect(
getTextBodyFromFetchRequestArguments('https://api.twenty.test', {
body: new FormData(),
}),
).rejects.toBeInstanceOf(TypeError);
});
it('should reject with a TypeError when the init body is a Blob', async () => {
await expect(
getTextBodyFromFetchRequestArguments('https://api.twenty.test', {
body: new Blob(['x']),
}),
).rejects.toBeInstanceOf(TypeError);
});
});
@@ -0,0 +1,25 @@
import { getUrlFromFetchRequestInput } from '../getUrlFromFetchRequestInput';
describe('getUrlFromFetchRequestInput', () => {
it('should return the string when input is a string', () => {
expect(getUrlFromFetchRequestInput('https://api.twenty.test/graphql')).toBe(
'https://api.twenty.test/graphql',
);
});
it('should return the href when input is a URL instance', () => {
expect(
getUrlFromFetchRequestInput(new URL('https://api.twenty.test/graphql')),
).toBe('https://api.twenty.test/graphql');
});
it('should return the url property when input is a Request object', () => {
const request = {
url: 'https://api.twenty.test/graphql',
} as unknown as Request;
expect(getUrlFromFetchRequestInput(request)).toBe(
'https://api.twenty.test/graphql',
);
});
});
@@ -0,0 +1,97 @@
import { type HostFetchFunction } from '@/types/HostFetchFunction';
import { installHostFetchProxy } from '../installHostFetchProxy';
class StubResponse {
body: string;
status: number;
statusText: string;
headers: Headers;
constructor(
body: string,
init?: {
status?: number;
statusText?: string;
headers?: Record<string, string>;
},
) {
this.body = body;
this.status = init?.status ?? 200;
this.statusText = init?.statusText ?? '';
this.headers = new Headers(init?.headers);
}
get ok() {
return this.status >= 200 && this.status < 300;
}
async text() {
return this.body;
}
}
const originalFetch = globalThis.fetch;
const originalResponse = (globalThis as { Response?: unknown }).Response;
describe('installHostFetchProxy', () => {
beforeAll(() => {
(globalThis as { Response?: unknown }).Response = StubResponse;
});
afterAll(() => {
(globalThis as { Response?: unknown }).Response = originalResponse;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('should route proxied origins through the host fetch bridge', async () => {
const nativeFetch = jest.fn(async () => new StubResponse('native'));
globalThis.fetch = nativeFetch as unknown as typeof fetch;
const hostFetch: HostFetchFunction = jest.fn(async () => ({
status: 200,
statusText: 'OK',
headers: { 'content-type': 'application/json' },
body: 'proxied',
}));
installHostFetchProxy(hostFetch, ['https://api.twenty.test']);
const response = await fetch('https://api.twenty.test/graphql', {
method: 'POST',
body: '{"query":"{ me }"}',
});
expect(hostFetch).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://api.twenty.test/graphql',
method: 'POST',
body: '{"query":"{ me }"}',
}),
);
expect(nativeFetch).not.toHaveBeenCalled();
expect(await response.text()).toBe('proxied');
});
it('should pass non-proxied origins through to the native fetch', async () => {
const nativeFetch = jest.fn(async () => new StubResponse('native'));
globalThis.fetch = nativeFetch as unknown as typeof fetch;
const hostFetch: HostFetchFunction = jest.fn(async () => ({
status: 200,
statusText: 'OK',
headers: {},
body: 'proxied',
}));
installHostFetchProxy(hostFetch, ['https://api.twenty.test']);
const response = await fetch('https://cdn.public.test/chart.js');
expect(nativeFetch).toHaveBeenCalled();
expect(hostFetch).not.toHaveBeenCalled();
expect(await response.text()).toBe('native');
});
});
@@ -0,0 +1,38 @@
import { isTextContentType } from '../isTextContentType';
describe('isTextContentType', () => {
it('should accept text types', () => {
expect(isTextContentType('text/plain')).toBe(true);
expect(isTextContentType('text/html')).toBe(true);
});
it('should accept form urlencoded and graphql types', () => {
expect(isTextContentType('application/x-www-form-urlencoded')).toBe(true);
expect(isTextContentType('application/graphql')).toBe(true);
});
it('should accept json suffixed types', () => {
expect(isTextContentType('application/json')).toBe(true);
expect(isTextContentType('application/vnd.api+json')).toBe(true);
});
it('should accept xml suffixed types', () => {
expect(isTextContentType('application/xml')).toBe(true);
expect(isTextContentType('image/svg+xml')).toBe(true);
});
it('should ignore parameters such as charset when matching', () => {
expect(isTextContentType('application/json; charset=utf-8')).toBe(true);
expect(isTextContentType('text/plain;charset=UTF-8')).toBe(true);
});
it('should be case insensitive', () => {
expect(isTextContentType('Application/JSON')).toBe(true);
});
it('should reject binary content types', () => {
expect(isTextContentType('multipart/form-data; boundary=x')).toBe(false);
expect(isTextContentType('application/octet-stream')).toBe(false);
expect(isTextContentType('image/png')).toBe(false);
});
});
@@ -0,0 +1,34 @@
import { isUrlFromProxiedOrigin } from '../isUrlFromProxiedOrigin';
describe('isUrlFromProxiedOrigin', () => {
it('should return true when the url origin is in the proxied origins', () => {
expect(
isUrlFromProxiedOrigin('https://api.twenty.test/graphql', [
'https://api.twenty.test',
]),
).toBe(true);
});
it('should return false when the origin differs', () => {
expect(
isUrlFromProxiedOrigin('https://evil.test/graphql', [
'https://api.twenty.test',
]),
).toBe(false);
});
it('should return false when the url is malformed', () => {
expect(
isUrlFromProxiedOrigin('not a url', ['https://api.twenty.test']),
).toBe(false);
});
it('should match on origin regardless of path', () => {
expect(
isUrlFromProxiedOrigin(
'https://api.twenty.test/rest/front-components/id',
['https://api.twenty.test'],
),
).toBe(true);
});
});
@@ -0,0 +1,18 @@
import { type RemoteConnection } from '@remote-dom/core/elements';
import { renderFrontComponent } from '../renderFrontComponent';
describe('renderFrontComponent', () => {
it('should fail closed when the host fetch bridge is unavailable', async () => {
await expect(
renderFrontComponent({
connection: {} as RemoteConnection,
renderContext: {
componentUrl: 'https://api.twenty.test/rest/front-components/id',
},
hostFetch: null,
}),
).rejects.toMatchObject({
code: 'FRONT_COMPONENT_HOST_FETCH_UNAVAILABLE',
});
});
});
@@ -0,0 +1,23 @@
import { revokeSdkClientModuleBlobUrls } from '../revokeSdkClientModuleBlobUrls';
const originalRevokeObjectUrl = URL.revokeObjectURL;
describe('revokeSdkClientModuleBlobUrls', () => {
afterEach(() => {
URL.revokeObjectURL = originalRevokeObjectUrl;
});
it('should revoke both the core and metadata blob urls', () => {
const revokeObjectUrlSpy = jest.fn();
URL.revokeObjectURL = revokeObjectUrlSpy;
revokeSdkClientModuleBlobUrls({
core: 'blob:core-url',
metadata: 'blob:metadata-url',
});
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:core-url');
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:metadata-url');
expect(revokeObjectUrlSpy).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,82 @@
import { rewriteSdkClientImportsToBlobUrls } from '../rewriteSdkClientImportsToBlobUrls';
const sdkModuleBlobUrls = {
core: 'blob:core-url',
metadata: 'blob:metadata-url',
};
describe('rewriteSdkClientImportsToBlobUrls', () => {
it('should rewrite double quoted core and metadata specifiers to blob urls', () => {
const source =
'import { CoreApiClient } from "twenty-client-sdk/core";\nimport { MetadataApiClient } from "twenty-client-sdk/metadata";';
expect(rewriteSdkClientImportsToBlobUrls(source, sdkModuleBlobUrls)).toBe(
'import { CoreApiClient } from "blob:core-url";\nimport { MetadataApiClient } from "blob:metadata-url";',
);
});
it('should rewrite single quoted specifiers', () => {
const source = "import { CoreApiClient } from 'twenty-client-sdk/core';";
expect(rewriteSdkClientImportsToBlobUrls(source, sdkModuleBlobUrls)).toBe(
"import { CoreApiClient } from 'blob:core-url';",
);
});
it('should rewrite every occurrence when a specifier appears multiple times', () => {
const source =
'import "twenty-client-sdk/core";\nconst lazy = () => import("twenty-client-sdk/core");';
expect(rewriteSdkClientImportsToBlobUrls(source, sdkModuleBlobUrls)).toBe(
'import "blob:core-url";\nconst lazy = () => import("blob:core-url");',
);
});
it('should leave sources without sdk specifiers unchanged', () => {
const source = 'export const answer = 42;';
expect(rewriteSdkClientImportsToBlobUrls(source, sdkModuleBlobUrls)).toBe(
source,
);
});
it('should not rewrite specifiers that are a prefix of a longer specifier', () => {
const source = 'import "twenty-client-sdk/core-utils";';
expect(rewriteSdkClientImportsToBlobUrls(source, sdkModuleBlobUrls)).toBe(
source,
);
});
it('should not rewrite the specifier without surrounding quotes', () => {
const source = 'const specifier = `twenty-client-sdk/core`;';
expect(rewriteSdkClientImportsToBlobUrls(source, sdkModuleBlobUrls)).toBe(
source,
);
});
it('should not rewrite the specifier inside a plain string literal', () => {
const source = 'const specifierName = "twenty-client-sdk/core";';
expect(rewriteSdkClientImportsToBlobUrls(source, sdkModuleBlobUrls)).toBe(
source,
);
});
it('should rewrite export from statements', () => {
const source = 'export { CoreApiClient } from "twenty-client-sdk/core";';
expect(rewriteSdkClientImportsToBlobUrls(source, sdkModuleBlobUrls)).toBe(
'export { CoreApiClient } from "blob:core-url";',
);
});
it('should rewrite minified imports without whitespace', () => {
const source = 'import{CoreApiClient}from"twenty-client-sdk/core";';
expect(rewriteSdkClientImportsToBlobUrls(source, sdkModuleBlobUrls)).toBe(
'import{CoreApiClient}from"blob:core-url";',
);
});
});
@@ -1,12 +1,12 @@
import { setWorkerEnv } from '../setWorkerEnv';
import { setWorkerEnvironmentVariables } from '../setWorkerEnvironmentVariables';
describe('setWorkerEnv', () => {
describe('setWorkerEnvironmentVariables', () => {
beforeEach(() => {
delete (globalThis as Record<string, unknown>)['process'];
});
it('should set process.env on globalThis', () => {
setWorkerEnv({
setWorkerEnvironmentVariables({
TWENTY_APP_ACCESS_TOKEN: 'test-key',
TWENTY_API_URL: 'https://api.example.com',
});
@@ -22,6 +22,24 @@ describe('setWorkerEnv', () => {
);
});
it('should let later calls win when the same variable is set twice', () => {
setWorkerEnvironmentVariables({
TWENTY_API_URL: 'https://application-provided.example.com',
});
setWorkerEnvironmentVariables({
TWENTY_API_URL: 'https://system-provided.example.com',
});
const processObject = (globalThis as Record<string, unknown>)[
'process'
] as Record<string, unknown>;
const processEnvironment = processObject['env'] as Record<string, string>;
expect(processEnvironment['TWENTY_API_URL']).toBe(
'https://system-provided.example.com',
);
});
it('should preserve existing process properties and environment values', () => {
(globalThis as Record<string, unknown>)['process'] = {
env: {
@@ -30,7 +48,7 @@ describe('setWorkerEnv', () => {
version: 'test-version',
};
setWorkerEnv({
setWorkerEnvironmentVariables({
TWENTY_APP_ACCESS_TOKEN: 'test-key',
});
@@ -0,0 +1,22 @@
import {
BatchingRemoteConnection,
type RemoteConnection,
type RemoteRootElement,
} from '@remote-dom/core/elements';
import { installStyleBridge } from '@/polyfills/installStyleBridge';
export const attachRemoteRenderRootToWorkerDocument = (
connection: RemoteConnection,
): Element => {
const batchedConnection = new BatchingRemoteConnection(connection);
const remoteRoot = document.createElement('remote-root') as RemoteRootElement;
const renderContainer = document.createElement('remote-fragment');
remoteRoot.connect(batchedConnection);
remoteRoot.append(renderContainer);
document.body.append(remoteRoot);
installStyleBridge(remoteRoot);
return renderContainer;
};
@@ -0,0 +1,8 @@
import { isNonEmptyString } from '@sniptt/guards';
export const buildAuthorizationHeadersFromAccessToken = (
applicationAccessToken?: string,
): Record<string, string> | undefined =>
isNonEmptyString(applicationAccessToken)
? { Authorization: `Bearer ${applicationAccessToken}` }
: undefined;
@@ -0,0 +1,18 @@
import { createOpenCommandConfirmationModalAdapter } from '@/remote/worker/utils/createCommandConfirmationModalBridge';
import { type FrontComponentHostCommunicationApiStore } from '@/types/FrontComponentHostCommunicationApiStore';
import { type FrontComponentHostThreadExports } from '@/types/FrontComponentHostThreadExports';
export const buildFrontComponentHostCommunicationApiFromThreadImports = (
hostThreadImports: FrontComponentHostThreadExports,
): Required<FrontComponentHostCommunicationApiStore> => ({
navigate: hostThreadImports.navigate,
requestAccessTokenRefresh: hostThreadImports.requestAccessTokenRefresh,
openSidePanelPage: hostThreadImports.openSidePanelPage,
openCommandConfirmationModal:
createOpenCommandConfirmationModalAdapter(hostThreadImports),
unmountFrontComponent: hostThreadImports.unmountFrontComponent,
enqueueSnackbar: hostThreadImports.enqueueSnackbar,
closeSidePanel: hostThreadImports.closeSidePanel,
updateProgress: hostThreadImports.updateProgress,
copyToClipboard: hostThreadImports.copyToClipboard,
});
@@ -0,0 +1,31 @@
import { isDefined } from 'twenty-shared/utils';
import { getHeadersFromFetchRequestArguments } from '@/remote/worker/utils/getHeadersFromFetchRequestArguments';
import { getMethodFromFetchRequestArguments } from '@/remote/worker/utils/getMethodFromFetchRequestArguments';
import { getTextBodyFromFetchRequestArguments } from '@/remote/worker/utils/getTextBodyFromFetchRequestArguments';
import { getUrlFromFetchRequestInput } from '@/remote/worker/utils/getUrlFromFetchRequestInput';
import { type HostFetchInput } from '@/types/HostFetchInput';
const URL_SEARCH_PARAMS_CONTENT_TYPE =
'application/x-www-form-urlencoded;charset=UTF-8';
export const buildHostFetchInputFromFetchRequestArguments = async (
input: RequestInfo | URL,
init: RequestInit | undefined,
): Promise<HostFetchInput> => {
const headers = getHeadersFromFetchRequestArguments(input, init);
if (
init?.body instanceof URLSearchParams &&
!isDefined(headers['content-type'])
) {
headers['content-type'] = URL_SEARCH_PARAMS_CONTENT_TYPE;
}
return {
url: getUrlFromFetchRequestInput(input),
method: getMethodFromFetchRequestArguments(input, init),
headers,
body: await getTextBodyFromFetchRequestArguments(input, init),
};
};
@@ -0,0 +1,17 @@
import { type HostFetchResult } from '@/types/HostFetchResult';
const NULL_BODY_STATUSES = new Set([204, 205, 304]);
export const buildResponseFromHostFetchResult = (
hostFetchResult: HostFetchResult,
): Response =>
new Response(
NULL_BODY_STATUSES.has(hostFetchResult.status)
? null
: hostFetchResult.body,
{
status: hostFetchResult.status,
statusText: hostFetchResult.statusText,
headers: hostFetchResult.headers,
},
);
@@ -0,0 +1,4 @@
import { SDK_CLIENT_IMPORT_SPECIFIERS } from '@/remote/worker/constants/SdkClientImportSpecifiers';
export const containsSdkClientImportSpecifier = (source: string): boolean =>
SDK_CLIENT_IMPORT_SPECIFIERS.some((specifier) => source.includes(specifier));
@@ -2,7 +2,8 @@ import {
type CommandConfirmationModalResult,
type OpenCommandConfirmationModalFunction,
} from 'twenty-sdk/front-component';
import { type FrontComponentHostCommunicationApi } from '../../../types/FrontComponentHostCommunicationApi';
import { CustomError } from 'twenty-shared/utils';
import { type FrontComponentHostCommunicationApi } from '@/types/FrontComponentHostCommunicationApi';
type CommandConfirmationModalPromiseCallbacks = {
resolve: (result: CommandConfirmationModalResult) => void;
@@ -24,8 +25,9 @@ export const createOpenCommandConfirmationModalAdapter = (
): OpenCommandConfirmationModalFunction => {
return async (params) => {
if (pendingCommandConfirmationModalPromiseCallbacks !== null) {
throw new Error(
throw new CustomError(
'A confirmation modal is already pending for this front component',
'FRONT_COMPONENT_CONFIRMATION_MODAL_ALREADY_PENDING',
);
}
@@ -1,6 +1,6 @@
// @ts-expect-error - Vite worker inline import
import RemoteWorker from '../remote-worker?worker&inline';
export const createRemoteWorker = (): Worker => {
export const createFrontComponentRemoteWorker = (): Worker => {
return new RemoteWorker();
};
@@ -0,0 +1,2 @@
export const createJavaScriptModuleBlobUrl = (source: string): string =>
URL.createObjectURL(new Blob([source], { type: 'application/javascript' }));
@@ -0,0 +1,28 @@
import { CustomError } from 'twenty-shared/utils';
export const fetchJavaScriptModuleSourceText = async (
url: string,
headers?: Record<string, string>,
): Promise<string> => {
let response: Response;
try {
response = await fetch(url, { headers });
} catch (error) {
throw new CustomError(
`Failed to fetch front component module ${url}: ${
error instanceof Error ? error.message : String(error)
}`,
'FRONT_COMPONENT_MODULE_FETCH_FAILED',
);
}
if (!response.ok) {
throw new CustomError(
`Failed to fetch front component module ${url}: ${response.status} ${response.statusText}`,
'FRONT_COMPONENT_MODULE_FETCH_FAILED',
);
}
return response.text();
};
@@ -0,0 +1,34 @@
import { createJavaScriptModuleBlobUrl } from '@/remote/worker/utils/createJavaScriptModuleBlobUrl';
import { fetchJavaScriptModuleSourceText } from '@/remote/worker/utils/fetchJavaScriptModuleSourceText';
import { type SdkClientUrls } from '@/types/SdkClientUrls';
export const fetchSdkClientModulesAsBlobUrls = async (
sdkClientUrls: SdkClientUrls,
headers?: Record<string, string>,
): Promise<SdkClientUrls> => {
const [coreResult, metadataResult] = await Promise.allSettled([
fetchJavaScriptModuleSourceText(sdkClientUrls.core, headers).then(
createJavaScriptModuleBlobUrl,
),
fetchJavaScriptModuleSourceText(sdkClientUrls.metadata, headers).then(
createJavaScriptModuleBlobUrl,
),
]);
if (
coreResult.status === 'rejected' ||
metadataResult.status === 'rejected'
) {
for (const result of [coreResult, metadataResult]) {
if (result.status === 'fulfilled') {
URL.revokeObjectURL(result.value);
}
}
throw coreResult.status === 'rejected'
? coreResult.reason
: (metadataResult as PromiseRejectedResult).reason;
}
return { core: coreResult.value, metadata: metadataResult.value };
};
@@ -0,0 +1,28 @@
import { isDefined } from 'twenty-shared/utils';
import { isRequestObject } from '@/remote/worker/utils/isRequestObject';
const toHeaderRecord = (headers: HeadersInit): Record<string, string> => {
const record: Record<string, string> = {};
new Headers(headers).forEach((value, key) => {
record[key] = value;
});
return record;
};
export const getHeadersFromFetchRequestArguments = (
input: RequestInfo | URL,
init: RequestInit | undefined,
): Record<string, string> => {
if (isDefined(init?.headers)) {
return toHeaderRecord(init.headers);
}
if (isRequestObject(input)) {
return toHeaderRecord(input.headers);
}
return {};
};
@@ -0,0 +1,6 @@
import { isRequestObject } from '@/remote/worker/utils/isRequestObject';
export const getMethodFromFetchRequestArguments = (
input: RequestInfo | URL,
init: RequestInit | undefined,
): string => init?.method ?? (isRequestObject(input) ? input.method : 'GET');
@@ -0,0 +1,52 @@
import { isNonEmptyString, isString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { isRequestObject } from '@/remote/worker/utils/isRequestObject';
import { isTextContentType } from '@/remote/worker/utils/isTextContentType';
const getRequestInputBody = async (
input: Request,
): Promise<string | undefined> => {
const requestBody = await input.clone().text();
if (!isNonEmptyString(requestBody)) {
return undefined;
}
const contentType = input.headers.get('content-type');
if (isDefined(contentType) && !isTextContentType(contentType)) {
throw new TypeError(
`The front component fetch bridge only supports text request bodies for Twenty API requests, got content type: ${contentType}`,
);
}
return requestBody;
};
export const getTextBodyFromFetchRequestArguments = async (
input: RequestInfo | URL,
init: RequestInit | undefined,
): Promise<string | undefined> => {
const initBody = init?.body;
if (!isDefined(initBody)) {
if (isRequestObject(input)) {
return getRequestInputBody(input);
}
return undefined;
}
if (isString(initBody)) {
return initBody;
}
if (initBody instanceof URLSearchParams) {
return initBody.toString();
}
throw new TypeError(
'The front component fetch bridge only supports string and URLSearchParams request bodies for Twenty API requests',
);
};
@@ -0,0 +1,15 @@
import { isString } from '@sniptt/guards';
export const getUrlFromFetchRequestInput = (
input: RequestInfo | URL,
): string => {
if (isString(input)) {
return input;
}
if (input instanceof URL) {
return input.href;
}
return input.url;
};
@@ -0,0 +1,32 @@
import { buildHostFetchInputFromFetchRequestArguments } from '@/remote/worker/utils/buildHostFetchInputFromFetchRequestArguments';
import { buildResponseFromHostFetchResult } from '@/remote/worker/utils/buildResponseFromHostFetchResult';
import { getUrlFromFetchRequestInput } from '@/remote/worker/utils/getUrlFromFetchRequestInput';
import { isUrlFromProxiedOrigin } from '@/remote/worker/utils/isUrlFromProxiedOrigin';
import { type HostFetchFunction } from '@/types/HostFetchFunction';
export const installHostFetchProxy = (
hostFetch: HostFetchFunction,
proxiedOrigins: string[],
): void => {
const nativeFetch = globalThis.fetch.bind(globalThis);
globalThis.fetch = async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
const url = getUrlFromFetchRequestInput(input);
if (!isUrlFromProxiedOrigin(url, proxiedOrigins)) {
return nativeFetch(input, init);
}
const hostFetchInput = await buildHostFetchInputFromFetchRequestArguments(
input,
init,
);
const hostFetchResult = await hostFetch(hostFetchInput);
return buildResponseFromHostFetchResult(hostFetchResult);
};
};
@@ -0,0 +1,4 @@
import { isObject } from '@sniptt/guards';
export const isRequestObject = (input: RequestInfo | URL): input is Request =>
isObject(input) && !(input instanceof URL);
@@ -0,0 +1,11 @@
export const isTextContentType = (contentType: string): boolean => {
const mimeType = contentType.split(';')[0].trim().toLowerCase();
return (
mimeType.startsWith('text/') ||
mimeType === 'application/x-www-form-urlencoded' ||
mimeType === 'application/graphql' ||
mimeType.endsWith('json') ||
mimeType.endsWith('xml')
);
};
@@ -0,0 +1,10 @@
import { getURLSafely, isDefined } from 'twenty-shared/utils';
export const isUrlFromProxiedOrigin = (
url: string,
proxiedOrigins: string[],
): boolean => {
const origin = getURLSafely(url)?.origin;
return isDefined(origin) && proxiedOrigins.includes(origin);
};
@@ -0,0 +1,63 @@
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';
type LoadFrontComponentModuleInput = {
componentUrl: string;
sdkClientUrls?: SdkClientUrls;
applicationAccessToken?: string;
};
type FrontComponentModule = {
default: (container: Element) => void;
};
export const loadFrontComponentModule = async ({
componentUrl,
sdkClientUrls,
applicationAccessToken,
}: LoadFrontComponentModuleInput): Promise<FrontComponentModule> => {
const authorizationHeaders = buildAuthorizationHeadersFromAccessToken(
applicationAccessToken,
);
const componentSource = await fetchComponentSource({
url: componentUrl,
headers: authorizationHeaders,
});
const sdkModuleBlobUrls =
isDefined(sdkClientUrls) &&
containsSdkClientImportSpecifier(componentSource)
? await fetchSdkClientModulesAsBlobUrls(
sdkClientUrls,
authorizationHeaders,
)
: null;
const componentModuleSource = isDefined(sdkModuleBlobUrls)
? rewriteSdkClientImportsToBlobUrls(componentSource, sdkModuleBlobUrls)
: componentSource;
const componentModuleBlobUrl = createJavaScriptModuleBlobUrl(
componentModuleSource,
);
try {
/* @vite-ignore */
return await import(componentModuleBlobUrl);
} finally {
URL.revokeObjectURL(componentModuleBlobUrl);
if (isDefined(sdkModuleBlobUrls)) {
revokeSdkClientModuleBlobUrls(sdkModuleBlobUrls);
}
}
};
@@ -0,0 +1,42 @@
import { type RemoteConnection } from '@remote-dom/core/elements';
import { CustomError, isDefined } from 'twenty-shared/utils';
import { attachRemoteRenderRootToWorkerDocument } from '@/remote/worker/utils/attachRemoteRenderRootToWorkerDocument';
import { installHostFetchProxy } from '@/remote/worker/utils/installHostFetchProxy';
import { loadFrontComponentModule } from '@/remote/worker/utils/loadFrontComponentModule';
import { setWorkerEnvironmentVariablesFromRenderContext } from '@/remote/worker/utils/setWorkerEnvironmentVariablesFromRenderContext';
import { type HostFetchFunction } from '@/types/HostFetchFunction';
import { type HostToWorkerRenderContext } from '@/types/HostToWorkerRenderContext';
type RenderFrontComponentInput = {
connection: RemoteConnection;
renderContext: HostToWorkerRenderContext;
hostFetch: HostFetchFunction | null;
};
export const renderFrontComponent = async ({
connection,
renderContext,
hostFetch,
}: RenderFrontComponentInput): Promise<void> => {
if (!isDefined(hostFetch)) {
throw new CustomError(
'The front component fetch bridge is unavailable',
'FRONT_COMPONENT_HOST_FETCH_UNAVAILABLE',
);
}
installHostFetchProxy(hostFetch, renderContext.hostFetchOrigins ?? []);
const renderContainer = attachRemoteRenderRootToWorkerDocument(connection);
setWorkerEnvironmentVariablesFromRenderContext(renderContext);
const componentModule = await loadFrontComponentModule({
componentUrl: renderContext.componentUrl,
sdkClientUrls: renderContext.sdkClientUrls,
applicationAccessToken: renderContext.applicationAccessToken,
});
componentModule.default(renderContainer);
};
@@ -0,0 +1,8 @@
import { type SdkClientUrls } from '@/types/SdkClientUrls';
export const revokeSdkClientModuleBlobUrls = (
sdkModuleBlobUrls: SdkClientUrls,
): void => {
URL.revokeObjectURL(sdkModuleBlobUrls.core);
URL.revokeObjectURL(sdkModuleBlobUrls.metadata);
};
@@ -0,0 +1,36 @@
import { type SDK_CLIENT_IMPORT_SPECIFIERS } from '@/remote/worker/constants/SdkClientImportSpecifiers';
import { type SdkClientUrls } from '@/types/SdkClientUrls';
const escapeRegExpToken = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const buildImportContextPattern = (specifier: string): RegExp =>
new RegExp(
`(\\bfrom\\s*|\\bimport\\s*\\(\\s*|\\bimport\\s*)(["'])${escapeRegExpToken(specifier)}\\2`,
'g',
);
export const rewriteSdkClientImportsToBlobUrls = (
source: string,
sdkModuleBlobUrls: SdkClientUrls,
): string => {
const specifierToBlobUrl: Record<
(typeof SDK_CLIENT_IMPORT_SPECIFIERS)[number],
string
> = {
'twenty-client-sdk/core': sdkModuleBlobUrls.core,
'twenty-client-sdk/metadata': sdkModuleBlobUrls.metadata,
};
let rewrittenSource = source;
for (const [specifier, blobUrl] of Object.entries(specifierToBlobUrl)) {
rewrittenSource = rewrittenSource.replace(
buildImportContextPattern(specifier),
(_fullMatch, importContext: string, quote: string) =>
`${importContext}${quote}${blobUrl}${quote}`,
);
}
return rewrittenSource;
};
@@ -1,4 +1,6 @@
export const setWorkerEnv = (variables: Record<string, string>) => {
export const setWorkerEnvironmentVariables = (
variables: Record<string, string>,
) => {
const globalObject = globalThis as Record<string, unknown>;
const processObject =
(globalObject['process'] as Record<string, unknown> | undefined) ?? {};
@@ -0,0 +1,32 @@
import { isDefined } from 'twenty-shared/utils';
import { setWorkerEnvironmentVariables } from '@/remote/worker/utils/setWorkerEnvironmentVariables';
import { type HostToWorkerRenderContext } from '@/types/HostToWorkerRenderContext';
export const setWorkerEnvironmentVariablesFromRenderContext = (
renderContext: HostToWorkerRenderContext,
): void => {
if (isDefined(renderContext.applicationVariables)) {
setWorkerEnvironmentVariables({
applicationVariables: JSON.stringify(renderContext.applicationVariables),
});
}
if (isDefined(renderContext.apiUrl)) {
setWorkerEnvironmentVariables({
TWENTY_API_URL: renderContext.apiUrl,
});
}
if (isDefined(renderContext.functionsBaseUrl)) {
setWorkerEnvironmentVariables({
TWENTY_FUNCTIONS_URL: renderContext.functionsBaseUrl,
});
}
if (isDefined(renderContext.applicationAccessToken)) {
setWorkerEnvironmentVariables({
TWENTY_APP_ACCESS_TOKEN: renderContext.applicationAccessToken,
});
}
};
@@ -0,0 +1,23 @@
import {
type CloseSidePanelFunction,
type CopyToClipboardFunction,
type EnqueueSnackbarFunction,
type NavigateFunction,
type OpenCommandConfirmationModalFunction,
type OpenSidePanelPageFunction,
type RequestAccessTokenRefreshFunction,
type UnmountFrontComponentFunction,
type UpdateProgressFunction,
} from 'twenty-sdk/front-component';
export type FrontComponentHostCommunicationApiStore = {
navigate?: NavigateFunction;
requestAccessTokenRefresh?: RequestAccessTokenRefreshFunction;
openSidePanelPage?: OpenSidePanelPageFunction;
openCommandConfirmationModal?: OpenCommandConfirmationModalFunction;
unmountFrontComponent?: UnmountFrontComponentFunction;
enqueueSnackbar?: EnqueueSnackbarFunction;
closeSidePanel?: CloseSidePanelFunction;
updateProgress?: UpdateProgressFunction;
copyToClipboard?: CopyToClipboardFunction;
};
@@ -0,0 +1,9 @@
import { type ThreadMessagePort } from '@quilted/threads';
import { type FrontComponentHostThreadExports } from '@/types/FrontComponentHostThreadExports';
import { type WorkerExports } from '@/types/WorkerExports';
export type FrontComponentHostThread = ThreadMessagePort<
FrontComponentHostThreadExports,
WorkerExports
>;
@@ -0,0 +1,7 @@
import { type FrontComponentHostCommunicationApi } from '@/types/FrontComponentHostCommunicationApi';
import { type HostFetchFunction } from '@/types/HostFetchFunction';
export type FrontComponentHostThreadExports =
FrontComponentHostCommunicationApi & {
hostFetch: HostFetchFunction;
};
@@ -0,0 +1,9 @@
import { type ThreadMessagePort } from '@quilted/threads';
import { type FrontComponentHostThreadExports } from '@/types/FrontComponentHostThreadExports';
import { type WorkerExports } from '@/types/WorkerExports';
export type FrontComponentThread = ThreadMessagePort<
WorkerExports,
FrontComponentHostThreadExports
>;
@@ -0,0 +1,6 @@
import { type HostFetchInput } from '@/types/HostFetchInput';
import { type HostFetchResult } from '@/types/HostFetchResult';
export type HostFetchFunction = (
input: HostFetchInput,
) => Promise<HostFetchResult>;
@@ -0,0 +1,6 @@
export type HostFetchInput = {
url: string;
method?: string;
headers?: Record<string, string>;
body?: string;
};
@@ -0,0 +1,4 @@
export type HostFetchPolicy = {
allowedOrigins: string[];
fileStorageRedirectableUrls: string[];
};
@@ -0,0 +1,6 @@
export type HostFetchResult = {
status: number;
statusText: string;
headers: Record<string, string>;
body: string;
};
@@ -1,7 +1,4 @@
export type SdkClientUrls = {
core: string;
metadata: string;
};
import { type SdkClientUrls } from '@/types/SdkClientUrls';
export type HostToWorkerRenderContext = {
componentUrl: string;
@@ -9,5 +6,6 @@ export type HostToWorkerRenderContext = {
apiUrl?: string;
functionsBaseUrl?: string;
sdkClientUrls?: SdkClientUrls;
hostFetchOrigins?: string[];
applicationVariables?: Record<string, string>;
};
@@ -0,0 +1,4 @@
export type SdkClientUrls = {
core: string;
metadata: string;
};
@@ -1,4 +1,5 @@
import { type RemoteConnection } from '@remote-dom/core/elements';
import { type CommandConfirmationModalResult } from 'twenty-sdk/front-component';
import { type FrontComponentExecutionContext } from './FrontComponentExecutionContext';
import { type HostToWorkerRenderContext } from './HostToWorkerRenderContext';
@@ -9,5 +10,7 @@ export type WorkerExports = {
) => Promise<void>;
initializeHostCommunicationApi: () => Promise<void>;
updateContext: (context: FrontComponentExecutionContext) => Promise<void>;
onConfirmationModalResult: (result: 'confirm' | 'cancel') => Promise<void>;
onConfirmationModalResult: (
result: CommandConfirmationModalResult,
) => Promise<void>;
};
@@ -31,6 +31,7 @@
"src/remote/mock/**/*",
"src/host/generated/host-component-registry.ts",
"src/remote/generated/remote-components.ts",
"src/remote/generated/remote-elements.ts"
"src/remote/generated/remote-elements.ts",
"src/remote/sandbox/generated/frontComponentSandboxDocument.ts"
]
}

Some files were not shown because too many files have changed in this diff Show More