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:
@@ -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"
|
||||
|
||||
+88
@@ -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);
|
||||
});
|
||||
+25
@@ -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);
|
||||
},
|
||||
};
|
||||
+165
@@ -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
-23
@@ -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
|
||||
|
||||
+31
@@ -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>
|
||||
);
|
||||
};
|
||||
+18
-49
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
+16
@@ -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,
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const MAX_HOST_FETCH_RESPONSE_BODY_BYTES = 50 * 1024 * 1024;
|
||||
+81
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
+155
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+44
@@ -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']);
|
||||
});
|
||||
});
|
||||
+43
@@ -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');
|
||||
});
|
||||
});
|
||||
+42
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+33
@@ -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 };
|
||||
};
|
||||
+26
@@ -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;
|
||||
};
|
||||
+47
@@ -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);
|
||||
};
|
||||
};
|
||||
+14
@@ -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),
|
||||
),
|
||||
];
|
||||
+11
@@ -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';
|
||||
};
|
||||
+39
@@ -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';
|
||||
|
||||
+50
@@ -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;
|
||||
};
|
||||
+2
-4
@@ -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 = ({
|
||||
|
||||
+2
-4
@@ -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;
|
||||
};
|
||||
|
||||
+2
-3
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
+32
-83
@@ -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,
|
||||
|
||||
+5
@@ -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;
|
||||
+1
@@ -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,
|
||||
});
|
||||
+12
@@ -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;
|
||||
};
|
||||
+38
@@ -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();
|
||||
});
|
||||
});
|
||||
+112
@@ -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();
|
||||
});
|
||||
});
|
||||
+21
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+27
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+73
@@ -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();
|
||||
});
|
||||
});
|
||||
+16
@@ -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;
|
||||
};
|
||||
+64
@@ -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',
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
||||
+12
@@ -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,
|
||||
});
|
||||
+17
@@ -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,
|
||||
});
|
||||
+40
@@ -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;
|
||||
};
|
||||
+4
@@ -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();
|
||||
});
|
||||
|
||||
+17
@@ -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();
|
||||
});
|
||||
});
|
||||
+91
@@ -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');
|
||||
});
|
||||
});
|
||||
+62
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+81
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
+90
@@ -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();
|
||||
});
|
||||
});
|
||||
+28
@@ -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');
|
||||
});
|
||||
});
|
||||
+80
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+99
@@ -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');
|
||||
});
|
||||
});
|
||||
+57
@@ -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({});
|
||||
});
|
||||
});
|
||||
+30
@@ -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');
|
||||
});
|
||||
});
|
||||
+90
@@ -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);
|
||||
});
|
||||
});
|
||||
+25
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+97
@@ -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');
|
||||
});
|
||||
});
|
||||
+38
@@ -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);
|
||||
});
|
||||
});
|
||||
+34
@@ -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);
|
||||
});
|
||||
});
|
||||
+18
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+23
@@ -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);
|
||||
});
|
||||
});
|
||||
+82
@@ -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";',
|
||||
);
|
||||
});
|
||||
});
|
||||
+22
-4
@@ -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',
|
||||
});
|
||||
|
||||
+22
@@ -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;
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
export const buildAuthorizationHeadersFromAccessToken = (
|
||||
applicationAccessToken?: string,
|
||||
): Record<string, string> | undefined =>
|
||||
isNonEmptyString(applicationAccessToken)
|
||||
? { Authorization: `Bearer ${applicationAccessToken}` }
|
||||
: undefined;
|
||||
+18
@@ -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,
|
||||
});
|
||||
+31
@@ -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),
|
||||
};
|
||||
};
|
||||
+17
@@ -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,
|
||||
},
|
||||
);
|
||||
+4
@@ -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));
|
||||
+4
-2
@@ -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
-1
@@ -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();
|
||||
};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const createJavaScriptModuleBlobUrl = (source: string): string =>
|
||||
URL.createObjectURL(new Blob([source], { type: 'application/javascript' }));
|
||||
+28
@@ -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();
|
||||
};
|
||||
+34
@@ -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 };
|
||||
};
|
||||
+28
@@ -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 {};
|
||||
};
|
||||
+6
@@ -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');
|
||||
+52
@@ -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',
|
||||
);
|
||||
};
|
||||
+15
@@ -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;
|
||||
};
|
||||
+32
@@ -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')
|
||||
);
|
||||
};
|
||||
+10
@@ -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);
|
||||
};
|
||||
+63
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
+42
@@ -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);
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type SdkClientUrls } from '@/types/SdkClientUrls';
|
||||
|
||||
export const revokeSdkClientModuleBlobUrls = (
|
||||
sdkModuleBlobUrls: SdkClientUrls,
|
||||
): void => {
|
||||
URL.revokeObjectURL(sdkModuleBlobUrls.core);
|
||||
URL.revokeObjectURL(sdkModuleBlobUrls.metadata);
|
||||
};
|
||||
+36
@@ -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;
|
||||
};
|
||||
+3
-1
@@ -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) ?? {};
|
||||
+32
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
+23
@@ -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
Reference in New Issue
Block a user