From 60f5964c64f36667ccea70126898969756b17593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:10:30 +0200 Subject: [PATCH] Run front components in a sandboxed opaque-origin iframe (#22588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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)
exports = host API + hostFetch Frame-->>Host: READY Host->>Frame: INIT + transfer port2 Frame->>Worker: spawn inlined Worker + re-transfer port2 Worker->>Worker: ThreadMessagePort(port)
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
(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,
cookies, IndexedDB, BroadcastChannel ``` --- .../extend/apps/layout/front-components.mdx | 4 +- .../.gitignore | 2 + .../project.json | 37 +++- .../build-sandbox-document.ts | 88 ++++++++ ...FrontComponentSandboxIsolation.stories.tsx | 25 +++ .../runFrontComponentSandboxIsolationProbe.ts | 165 ++++++++++++++ .../frontComponentHostCommunicationApi.ts | 24 +-- .../components/FrontComponentErrorBox.tsx | 31 +++ .../components/FrontComponentRenderer.tsx | 67 ++---- .../FrontComponentHostCommunicationApiNoop.ts | 16 ++ .../MaxHostFetchResponseBodyBytes.ts | 1 + ...tFetchPolicyFromFrontComponentUrls.test.ts | 81 +++++++ .../createHostFetchEnforcingPolicy.test.ts | 155 ++++++++++++++ .../getUniqueHttpOriginsFromUrls.test.ts | 44 ++++ .../resolveHostFetchRedirectMode.test.ts | 43 ++++ ...serializeResponseToHostFetchResult.test.ts | 42 ++++ ...ldHostFetchPolicyFromFrontComponentUrls.ts | 33 +++ .../utils/createFrontComponentHostThread.ts | 26 +++ .../utils/createHostFetchEnforcingPolicy.ts | 47 ++++ .../utils/getUniqueHttpOriginsFromUrls.ts | 14 ++ .../utils/resolveHostFetchRedirectMode.ts | 11 + .../serializeResponseToHostFetchResult.ts | 39 ++++ .../src/index.ts | 9 +- ...ComponentConfirmationModalResultEffect.tsx | 50 +++++ ...ntInitializeHostCommunicationApiEffect.tsx | 6 +- .../FrontComponentUpdateContextEffect.tsx | 6 +- ...ponentUpdateHostCommunicationApiEffect.tsx | 5 +- .../components/FrontComponentWorkerEffect.tsx | 115 +++------- .../FrontComponentSandboxMessageType.ts | 5 + .../frontComponentSandboxDocument.d.ts | 1 + .../src/remote/sandbox/sandbox-bootstrap.ts | 62 ++++++ .../types/FrontComponentSandboxMessage.ts | 12 ++ .../createFrontComponentSandboxIframe.test.ts | 38 ++++ ...rontComponentSandboxMessageHandler.test.ts | 112 ++++++++++ ...oxErrorMessageFromWorkerErrorEvent.test.ts | 21 ++ ...eateWorkerSpawnErrorSandboxMessage.test.ts | 27 +++ .../parseFrontComponentSandboxMessage.test.ts | 73 +++++++ .../createFrontComponentSandboxIframe.ts | 16 ++ ...eateFrontComponentSandboxMessageHandler.ts | 64 ++++++ ...SandboxErrorMessageFromWorkerErrorEvent.ts | 12 ++ .../createWorkerSpawnErrorSandboxMessage.ts | 17 ++ .../parseFrontComponentSandboxMessage.ts | 40 ++++ .../constants/SdkClientImportSpecifiers.ts | 4 + .../src/remote/worker/remote-worker.ts | 201 +++++------------- ...uthorizationHeadersFromAccessToken.test.ts | 17 ++ ...tCommunicationApiFromThreadImports.test.ts | 91 ++++++++ ...etchInputFromFetchRequestArguments.test.ts | 62 ++++++ .../buildResponseFromHostFetchResult.test.ts | 81 +++++++ ...eateCommandConfirmationModalBridge.test.ts | 90 ++++++++ .../createJavaScriptModuleBlobUrl.test.ts | 28 +++ .../fetchJavaScriptModuleSourceText.test.ts | 80 +++++++ .../fetchSdkClientModulesAsBlobUrls.test.ts | 99 +++++++++ ...etHeadersFromFetchRequestArguments.test.ts | 57 +++++ ...getMethodFromFetchRequestArguments.test.ts | 30 +++ ...tTextBodyFromFetchRequestArguments.test.ts | 90 ++++++++ .../getUrlFromFetchRequestInput.test.ts | 25 +++ .../__tests__/installHostFetchProxy.test.ts | 97 +++++++++ .../utils/__tests__/isTextContentType.test.ts | 38 ++++ .../__tests__/isUrlFromProxiedOrigin.test.ts | 34 +++ .../__tests__/renderFrontComponent.test.ts | 18 ++ .../revokeSdkClientModuleBlobUrls.test.ts | 23 ++ .../rewriteSdkClientImportsToBlobUrls.test.ts | 82 +++++++ ... => setWorkerEnvironmentVariables.test.ts} | 26 ++- .../attachRemoteRenderRootToWorkerDocument.ts | 22 ++ ...uildAuthorizationHeadersFromAccessToken.ts | 8 + ...ntHostCommunicationApiFromThreadImports.ts | 18 ++ ...HostFetchInputFromFetchRequestArguments.ts | 31 +++ .../utils/buildResponseFromHostFetchResult.ts | 17 ++ .../utils/containsSdkClientImportSpecifier.ts | 4 + .../createCommandConfirmationModalBridge.ts | 6 +- ...ts => createFrontComponentRemoteWorker.ts} | 2 +- .../utils/createJavaScriptModuleBlobUrl.ts | 2 + .../utils/fetchJavaScriptModuleSourceText.ts | 28 +++ .../utils/fetchSdkClientModulesAsBlobUrls.ts | 34 +++ .../getHeadersFromFetchRequestArguments.ts | 28 +++ .../getMethodFromFetchRequestArguments.ts | 6 + .../getTextBodyFromFetchRequestArguments.ts | 52 +++++ .../utils/getUrlFromFetchRequestInput.ts | 15 ++ .../worker/utils/installHostFetchProxy.ts | 32 +++ .../remote/worker/utils/isRequestObject.ts | 4 + .../remote/worker/utils/isTextContentType.ts | 11 + .../worker/utils/isUrlFromProxiedOrigin.ts | 10 + .../worker/utils/loadFrontComponentModule.ts | 63 ++++++ .../worker/utils/renderFrontComponent.ts | 42 ++++ .../utils/revokeSdkClientModuleBlobUrls.ts | 8 + .../rewriteSdkClientImportsToBlobUrls.ts | 36 ++++ ...nv.ts => setWorkerEnvironmentVariables.ts} | 4 +- ...erEnvironmentVariablesFromRenderContext.ts | 32 +++ ...FrontComponentHostCommunicationApiStore.ts | 23 ++ .../src/types/FrontComponentHostThread.ts | 9 + .../types/FrontComponentHostThreadExports.ts | 7 + .../src/types/FrontComponentThread.ts | 9 + .../src/types/HostFetchFunction.ts | 6 + .../src/types/HostFetchInput.ts | 6 + .../src/types/HostFetchPolicy.ts | 4 + .../src/types/HostFetchResult.ts | 6 + .../src/types/HostToWorkerRenderContext.ts | 6 +- .../src/types/SdkClientUrls.ts | 4 + .../src/types/WorkerExports.ts | 5 +- .../tsconfig.json | 3 +- .../vitest.storybook.config.ts | 3 +- .../CommandMenuConfirmationModalManager.tsx | 4 +- ...nfirmationModalResultBrowserEventDetail.ts | 8 - ...ssConfirmationModalEngineCommandEffect.tsx | 4 +- .../components/FrontComponentRenderer.tsx | 49 ++--- .../FrontComponentRendererWithSdkClient.tsx | 64 ------ .../components/SdkClientBlobUrlsEffect.tsx | 49 ----- .../states/sdkClientFamilyState.ts | 20 -- .../utils/__tests__/getSdkClientUrls.test.ts | 11 + .../utils/fetchSdkClientBlobUrls.ts | 55 ----- .../scripts/build-seed-front-components.ts | 10 +- .../seed-project/list-companies/index.mjs | 66 ++++++ .../seed-project/list-companies/index.tsx | 127 +++++++++++ ...refill-front-component-definitions.util.ts | 32 ++- ...onfirmationModalResultBrowserEventName.ts} | 0 packages/twenty-shared/src/constants/index.ts | 1 + .../CommandMenuConfirmationModalResult.ts | 1 + ...nfirmationModalResultBrowserEventDetail.ts | 7 + packages/twenty-shared/src/types/index.ts | 2 + 119 files changed, 3498 insertions(+), 577 deletions(-) create mode 100644 packages/twenty-front-component-renderer/scripts/front-component-sandbox/build-sandbox-document.ts create mode 100644 packages/twenty-front-component-renderer/src/__stories__/FrontComponentSandboxIsolation.stories.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/utils/runFrontComponentSandboxIsolationProbe.ts create mode 100644 packages/twenty-front-component-renderer/src/host/components/FrontComponentErrorBox.tsx create mode 100644 packages/twenty-front-component-renderer/src/host/constants/FrontComponentHostCommunicationApiNoop.ts create mode 100644 packages/twenty-front-component-renderer/src/host/constants/MaxHostFetchResponseBodyBytes.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/__tests__/buildHostFetchPolicyFromFrontComponentUrls.test.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/__tests__/createHostFetchEnforcingPolicy.test.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/__tests__/getUniqueHttpOriginsFromUrls.test.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/__tests__/resolveHostFetchRedirectMode.test.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/__tests__/serializeResponseToHostFetchResult.test.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/buildHostFetchPolicyFromFrontComponentUrls.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/createFrontComponentHostThread.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/createHostFetchEnforcingPolicy.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/getUniqueHttpOriginsFromUrls.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/resolveHostFetchRedirectMode.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/serializeResponseToHostFetchResult.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/components/FrontComponentConfirmationModalResultEffect.tsx create mode 100644 packages/twenty-front-component-renderer/src/remote/sandbox/constants/FrontComponentSandboxMessageType.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/sandbox/generated/frontComponentSandboxDocument.d.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/sandbox/sandbox-bootstrap.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/sandbox/types/FrontComponentSandboxMessage.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createFrontComponentSandboxIframe.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createFrontComponentSandboxMessageHandler.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createSandboxErrorMessageFromWorkerErrorEvent.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createWorkerSpawnErrorSandboxMessage.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/parseFrontComponentSandboxMessage.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/sandbox/utils/createFrontComponentSandboxIframe.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/sandbox/utils/createFrontComponentSandboxMessageHandler.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/sandbox/utils/createSandboxErrorMessageFromWorkerErrorEvent.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/sandbox/utils/createWorkerSpawnErrorSandboxMessage.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/sandbox/utils/parseFrontComponentSandboxMessage.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/constants/SdkClientImportSpecifiers.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildAuthorizationHeadersFromAccessToken.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildFrontComponentHostCommunicationApiFromThreadImports.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildHostFetchInputFromFetchRequestArguments.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildResponseFromHostFetchResult.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/createCommandConfirmationModalBridge.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/createJavaScriptModuleBlobUrl.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchJavaScriptModuleSourceText.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchSdkClientModulesAsBlobUrls.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/getHeadersFromFetchRequestArguments.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/getMethodFromFetchRequestArguments.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/getTextBodyFromFetchRequestArguments.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/getUrlFromFetchRequestInput.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/installHostFetchProxy.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/isTextContentType.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/isUrlFromProxiedOrigin.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/renderFrontComponent.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/revokeSdkClientModuleBlobUrls.test.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/rewriteSdkClientImportsToBlobUrls.test.ts rename packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/{setWorkerEnv.test.ts => setWorkerEnvironmentVariables.test.ts} (61%) create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/attachRemoteRenderRootToWorkerDocument.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/buildAuthorizationHeadersFromAccessToken.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/buildFrontComponentHostCommunicationApiFromThreadImports.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/buildHostFetchInputFromFetchRequestArguments.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/buildResponseFromHostFetchResult.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/containsSdkClientImportSpecifier.ts rename packages/twenty-front-component-renderer/src/remote/worker/utils/{createRemoteWorker.ts => createFrontComponentRemoteWorker.ts} (68%) create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/createJavaScriptModuleBlobUrl.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/fetchJavaScriptModuleSourceText.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/fetchSdkClientModulesAsBlobUrls.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/getHeadersFromFetchRequestArguments.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/getMethodFromFetchRequestArguments.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/getTextBodyFromFetchRequestArguments.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/getUrlFromFetchRequestInput.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/installHostFetchProxy.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/isRequestObject.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/isTextContentType.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/isUrlFromProxiedOrigin.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/loadFrontComponentModule.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/renderFrontComponent.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/revokeSdkClientModuleBlobUrls.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/rewriteSdkClientImportsToBlobUrls.ts rename packages/twenty-front-component-renderer/src/remote/worker/utils/{setWorkerEnv.ts => setWorkerEnvironmentVariables.ts} (81%) create mode 100644 packages/twenty-front-component-renderer/src/remote/worker/utils/setWorkerEnvironmentVariablesFromRenderContext.ts create mode 100644 packages/twenty-front-component-renderer/src/types/FrontComponentHostCommunicationApiStore.ts create mode 100644 packages/twenty-front-component-renderer/src/types/FrontComponentHostThread.ts create mode 100644 packages/twenty-front-component-renderer/src/types/FrontComponentHostThreadExports.ts create mode 100644 packages/twenty-front-component-renderer/src/types/FrontComponentThread.ts create mode 100644 packages/twenty-front-component-renderer/src/types/HostFetchFunction.ts create mode 100644 packages/twenty-front-component-renderer/src/types/HostFetchInput.ts create mode 100644 packages/twenty-front-component-renderer/src/types/HostFetchPolicy.ts create mode 100644 packages/twenty-front-component-renderer/src/types/HostFetchResult.ts create mode 100644 packages/twenty-front-component-renderer/src/types/SdkClientUrls.ts delete mode 100644 packages/twenty-front/src/modules/command-menu-item/confirmation-modal/types/CommandMenuConfirmationModalResultBrowserEventDetail.ts delete mode 100644 packages/twenty-front/src/modules/front-components/components/FrontComponentRendererWithSdkClient.tsx delete mode 100644 packages/twenty-front/src/modules/front-components/components/SdkClientBlobUrlsEffect.tsx delete mode 100644 packages/twenty-front/src/modules/front-components/states/sdkClientFamilyState.ts create mode 100644 packages/twenty-front/src/modules/front-components/utils/__tests__/getSdkClientUrls.test.ts delete mode 100644 packages/twenty-front/src/modules/front-components/utils/fetchSdkClientBlobUrls.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/front-component/constants/seed-project/list-companies/index.mjs create mode 100644 packages/twenty-server/src/engine/metadata-modules/front-component/constants/seed-project/list-companies/index.tsx rename packages/{twenty-front/src/modules/command-menu-item/confirmation-modal/constants/CommandMenuItemConfirmationModalResultBrowserEventName.ts => twenty-shared/src/constants/CommandMenuConfirmationModalResultBrowserEventName.ts} (100%) create mode 100644 packages/twenty-shared/src/types/CommandMenuConfirmationModalResult.ts create mode 100644 packages/twenty-shared/src/types/CommandMenuConfirmationModalResultBrowserEventDetail.ts diff --git a/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx b/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx index 2a1ab9a3ec..80dbdddab7 100644 --- a/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx +++ b/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx @@ -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`: diff --git a/packages/twenty-front-component-renderer/.gitignore b/packages/twenty-front-component-renderer/.gitignore index cb263b1f71..080883351e 100644 --- a/packages/twenty-front-component-renderer/.gitignore +++ b/packages/twenty-front-component-renderer/.gitignore @@ -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 diff --git a/packages/twenty-front-component-renderer/project.json b/packages/twenty-front-component-renderer/project.json index 9f481837c7..d433218a50 100644 --- a/packages/twenty-front-component-renderer/project.json +++ b/packages/twenty-front-component-renderer/project.json @@ -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" diff --git a/packages/twenty-front-component-renderer/scripts/front-component-sandbox/build-sandbox-document.ts b/packages/twenty-front-component-renderer/scripts/front-component-sandbox/build-sandbox-document.ts new file mode 100644 index 0000000000..583211fd62 --- /dev/null +++ b/packages/twenty-front-component-renderer/scripts/front-component-sandbox/build-sandbox-document.ts @@ -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 => { + 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 = ``; + const sandboxDocument = `${sandboxBootstrapScriptTag}`; + + 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); +}); diff --git a/packages/twenty-front-component-renderer/src/__stories__/FrontComponentSandboxIsolation.stories.tsx b/packages/twenty-front-component-renderer/src/__stories__/FrontComponentSandboxIsolation.stories.tsx new file mode 100644 index 0000000000..7fce2e560d --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/FrontComponentSandboxIsolation.stories.tsx @@ -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: () =>
, +}; + +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); + }, +}; diff --git a/packages/twenty-front-component-renderer/src/__stories__/utils/runFrontComponentSandboxIsolationProbe.ts b/packages/twenty-front-component-renderer/src/__stories__/utils/runFrontComponentSandboxIsolationProbe.ts new file mode 100644 index 0000000000..a5248f0b91 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/utils/runFrontComponentSandboxIsolationProbe.ts @@ -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) => { + 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 ``; +}; + +export const runFrontComponentSandboxIsolationProbe = + (): Promise => { + const sandboxIframe = createFrontComponentSandboxIframe( + buildSandboxIsolationProbeDocument(), + ); + + return new Promise( + (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); + }, + ); + }; diff --git a/packages/twenty-front-component-renderer/src/constants/frontComponentHostCommunicationApi.ts b/packages/twenty-front-component-renderer/src/constants/frontComponentHostCommunicationApi.ts index a4c5b15c4a..ccddaf4db4 100644 --- a/packages/twenty-front-component-renderer/src/constants/frontComponentHostCommunicationApi.ts +++ b/packages/twenty-front-component-renderer/src/constants/frontComponentHostCommunicationApi.ts @@ -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)[ FRONT_COMPONENT_HOST_COMMUNICATION_API_KEY diff --git a/packages/twenty-front-component-renderer/src/host/components/FrontComponentErrorBox.tsx b/packages/twenty-front-component-renderer/src/host/components/FrontComponentErrorBox.tsx new file mode 100644 index 0000000000..9ab4ad4d4b --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/components/FrontComponentErrorBox.tsx @@ -0,0 +1,31 @@ +import { useTheme } from 'twenty-ui/theme-constants'; + +type FrontComponentErrorBoxProps = { + error: Error; +}; + +export const FrontComponentErrorBox = ({ + error, +}: FrontComponentErrorBoxProps) => { + const theme = useTheme(); + + return ( +
+ FrontComponent error: {error.message} +
+ ); +}; diff --git a/packages/twenty-front-component-renderer/src/host/components/FrontComponentRenderer.tsx b/packages/twenty-front-component-renderer/src/host/components/FrontComponentRenderer.tsx index 29a4168dbf..3585fa1bcb 100644 --- a/packages/twenty-front-component-renderer/src/host/components/FrontComponentRenderer.tsx +++ b/packages/twenty-front-component-renderer/src/host/components/FrontComponentRenderer.tsx @@ -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(null); - const [thread, setThread] = useState | null>(null); + const [thread, setThread] = useState(null); const [error, setError] = useState(null); const [isExecutionContextInitialized, setIsExecutionContextInitialized] = useState(false); - const MemoizedFrontComponentWorkerEffect = useMemo(() => { - return ( + return ( + <> - ); - }, [ - componentUrl, - setError, - setReceiver, - setThread, - applicationAccessToken, - apiUrl, - functionsBaseUrl, - sdkClientUrls, - applicationVariables, - executionContext.frontComponentId, - ]); - - return ( - <> - {MemoizedFrontComponentWorkerEffect} {isDefined(error) && ( - <> + -
- FrontComponent error: {error.message} -
- + +
)} {isDefined(thread) && ( @@ -128,6 +92,11 @@ export const FrontComponentRenderer = ({ setIsExecutionContextInitialized(true) } /> + )} diff --git a/packages/twenty-front-component-renderer/src/host/constants/FrontComponentHostCommunicationApiNoop.ts b/packages/twenty-front-component-renderer/src/host/constants/FrontComponentHostCommunicationApiNoop.ts new file mode 100644 index 0000000000..d8395e425a --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/constants/FrontComponentHostCommunicationApiNoop.ts @@ -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, + }; diff --git a/packages/twenty-front-component-renderer/src/host/constants/MaxHostFetchResponseBodyBytes.ts b/packages/twenty-front-component-renderer/src/host/constants/MaxHostFetchResponseBodyBytes.ts new file mode 100644 index 0000000000..90cadcdb2d --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/constants/MaxHostFetchResponseBodyBytes.ts @@ -0,0 +1 @@ +export const MAX_HOST_FETCH_RESPONSE_BODY_BYTES = 50 * 1024 * 1024; diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/buildHostFetchPolicyFromFrontComponentUrls.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/buildHostFetchPolicyFromFrontComponentUrls.test.ts new file mode 100644 index 0000000000..56a94b223a --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/buildHostFetchPolicyFromFrontComponentUrls.test.ts @@ -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,', + 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', + ]); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHostFetchEnforcingPolicy.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHostFetchEnforcingPolicy.test.ts new file mode 100644 index 0000000000..1c298d2163 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHostFetchEnforcingPolicy.test.ts @@ -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', + ); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/getUniqueHttpOriginsFromUrls.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/getUniqueHttpOriginsFromUrls.test.ts new file mode 100644 index 0000000000..f22f3413f4 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/getUniqueHttpOriginsFromUrls.test.ts @@ -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,', + 'file:///etc/passwd', + 'blob:https://api.twenty.test/id', + 'https://api.twenty.test', + ]), + ).toEqual(['https://api.twenty.test']); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/resolveHostFetchRedirectMode.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/resolveHostFetchRedirectMode.test.ts new file mode 100644 index 0000000000..c39925ab69 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/resolveHostFetchRedirectMode.test.ts @@ -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'); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/serializeResponseToHostFetchResult.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/serializeResponseToHostFetchResult.test.ts new file mode 100644 index 0000000000..64306bc892 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/serializeResponseToHostFetchResult.test.ts @@ -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', + }); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/utils/buildHostFetchPolicyFromFrontComponentUrls.ts b/packages/twenty-front-component-renderer/src/host/utils/buildHostFetchPolicyFromFrontComponentUrls.ts new file mode 100644 index 0000000000..064591a577 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/buildHostFetchPolicyFromFrontComponentUrls.ts @@ -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 }; +}; diff --git a/packages/twenty-front-component-renderer/src/host/utils/createFrontComponentHostThread.ts b/packages/twenty-front-component-renderer/src/host/utils/createFrontComponentHostThread.ts new file mode 100644 index 0000000000..3a1d82041c --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/createFrontComponentHostThread.ts @@ -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; +}; diff --git a/packages/twenty-front-component-renderer/src/host/utils/createHostFetchEnforcingPolicy.ts b/packages/twenty-front-component-renderer/src/host/utils/createHostFetchEnforcingPolicy.ts new file mode 100644 index 0000000000..3fdcb0c000 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/createHostFetchEnforcingPolicy.ts @@ -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 => { + 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); + }; +}; diff --git a/packages/twenty-front-component-renderer/src/host/utils/getUniqueHttpOriginsFromUrls.ts b/packages/twenty-front-component-renderer/src/host/utils/getUniqueHttpOriginsFromUrls.ts new file mode 100644 index 0000000000..bce0d9f4c2 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/getUniqueHttpOriginsFromUrls.ts @@ -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), + ), +]; diff --git a/packages/twenty-front-component-renderer/src/host/utils/resolveHostFetchRedirectMode.ts b/packages/twenty-front-component-renderer/src/host/utils/resolveHostFetchRedirectMode.ts new file mode 100644 index 0000000000..256004d562 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/resolveHostFetchRedirectMode.ts @@ -0,0 +1,11 @@ +export const resolveHostFetchRedirectMode = ( + requestMethod: string, + requestUrl: string, + fileStorageRedirectableUrls: Set, +): RequestRedirect => { + const isReadOnlyMethod = requestMethod === 'GET' || requestMethod === 'HEAD'; + + return isReadOnlyMethod && fileStorageRedirectableUrls.has(requestUrl) + ? 'follow' + : 'error'; +}; diff --git a/packages/twenty-front-component-renderer/src/host/utils/serializeResponseToHostFetchResult.ts b/packages/twenty-front-component-renderer/src/host/utils/serializeResponseToHostFetchResult.ts new file mode 100644 index 0000000000..8095b39317 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/serializeResponseToHostFetchResult.ts @@ -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 => { + 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 = {}; + + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + return { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + body, + }; +}; diff --git a/packages/twenty-front-component-renderer/src/index.ts b/packages/twenty-front-component-renderer/src/index.ts index 44a691548a..4d751f8058 100644 --- a/packages/twenty-front-component-renderer/src/index.ts +++ b/packages/twenty-front-component-renderer/src/index.ts @@ -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'; diff --git a/packages/twenty-front-component-renderer/src/remote/components/FrontComponentConfirmationModalResultEffect.tsx b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentConfirmationModalResultEffect.tsx new file mode 100644 index 0000000000..9ae307e070 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentConfirmationModalResultEffect.tsx @@ -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, + ) => { + 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; +}; diff --git a/packages/twenty-front-component-renderer/src/remote/components/FrontComponentInitializeHostCommunicationApiEffect.tsx b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentInitializeHostCommunicationApiEffect.tsx index 15cdd8f9dd..dc2a9ec17f 100644 --- a/packages/twenty-front-component-renderer/src/remote/components/FrontComponentInitializeHostCommunicationApiEffect.tsx +++ b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentInitializeHostCommunicationApiEffect.tsx @@ -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; + thread: FrontComponentThread; }; export const FrontComponentInitializeHostCommunicationApiEffect = ({ diff --git a/packages/twenty-front-component-renderer/src/remote/components/FrontComponentUpdateContextEffect.tsx b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentUpdateContextEffect.tsx index de9addc798..577145837b 100644 --- a/packages/twenty-front-component-renderer/src/remote/components/FrontComponentUpdateContextEffect.tsx +++ b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentUpdateContextEffect.tsx @@ -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; + thread: FrontComponentThread; executionContext: FrontComponentExecutionContext; onExecutionContextInitialized: () => void; }; diff --git a/packages/twenty-front-component-renderer/src/remote/components/FrontComponentUpdateHostCommunicationApiEffect.tsx b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentUpdateHostCommunicationApiEffect.tsx index bdc8dd4540..f4afca9fc4 100644 --- a/packages/twenty-front-component-renderer/src/remote/components/FrontComponentUpdateHostCommunicationApiEffect.tsx +++ b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentUpdateHostCommunicationApiEffect.tsx @@ -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; + thread: FrontComponentThread; frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi; }; diff --git a/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx index 8b5baab1d0..18c71c28ae 100644 --- a/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx +++ b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx @@ -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; - frontComponentId: string; setReceiver: React.Dispatch>; - setThread: React.Dispatch< - React.SetStateAction | null> - >; + setThread: React.Dispatch>; setError: React.Dispatch>; }; @@ -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, - ) => { - 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, diff --git a/packages/twenty-front-component-renderer/src/remote/sandbox/constants/FrontComponentSandboxMessageType.ts b/packages/twenty-front-component-renderer/src/remote/sandbox/constants/FrontComponentSandboxMessageType.ts new file mode 100644 index 0000000000..fd9467d87b --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/sandbox/constants/FrontComponentSandboxMessageType.ts @@ -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; diff --git a/packages/twenty-front-component-renderer/src/remote/sandbox/generated/frontComponentSandboxDocument.d.ts b/packages/twenty-front-component-renderer/src/remote/sandbox/generated/frontComponentSandboxDocument.d.ts new file mode 100644 index 0000000000..1ad50e18ae --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/sandbox/generated/frontComponentSandboxDocument.d.ts @@ -0,0 +1 @@ +export declare const FRONT_COMPONENT_SANDBOX_DOCUMENT: string; diff --git a/packages/twenty-front-component-renderer/src/remote/sandbox/sandbox-bootstrap.ts b/packages/twenty-front-component-renderer/src/remote/sandbox/sandbox-bootstrap.ts new file mode 100644 index 0000000000..606e2f4543 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/sandbox/sandbox-bootstrap.ts @@ -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, +}); diff --git a/packages/twenty-front-component-renderer/src/remote/sandbox/types/FrontComponentSandboxMessage.ts b/packages/twenty-front-component-renderer/src/remote/sandbox/types/FrontComponentSandboxMessage.ts new file mode 100644 index 0000000000..2891b87389 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/sandbox/types/FrontComponentSandboxMessage.ts @@ -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; + }; diff --git a/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createFrontComponentSandboxIframe.test.ts b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createFrontComponentSandboxIframe.test.ts new file mode 100644 index 0000000000..230895d700 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createFrontComponentSandboxIframe.test.ts @@ -0,0 +1,38 @@ +import { createFrontComponentSandboxIframe } from '../createFrontComponentSandboxIframe'; + +const SANDBOX_DOCUMENT = + ''; + +const toSandboxTokenSet = (iframe: HTMLIFrameElement): Set => + 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(); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createFrontComponentSandboxMessageHandler.test.ts b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createFrontComponentSandboxMessageHandler.test.ts new file mode 100644 index 0000000000..878ed2f595 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createFrontComponentSandboxMessageHandler.test.ts @@ -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(); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createSandboxErrorMessageFromWorkerErrorEvent.test.ts b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createSandboxErrorMessageFromWorkerErrorEvent.test.ts new file mode 100644 index 0000000000..6c55b17873 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createSandboxErrorMessageFromWorkerErrorEvent.test.ts @@ -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, + }); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createWorkerSpawnErrorSandboxMessage.test.ts b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createWorkerSpawnErrorSandboxMessage.test.ts new file mode 100644 index 0000000000..9fef050b55 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createWorkerSpawnErrorSandboxMessage.test.ts @@ -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', + }); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/parseFrontComponentSandboxMessage.test.ts b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/parseFrontComponentSandboxMessage.test.ts new file mode 100644 index 0000000000..eead862dd5 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/parseFrontComponentSandboxMessage.test.ts @@ -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(); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createFrontComponentSandboxIframe.ts b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createFrontComponentSandboxIframe.ts new file mode 100644 index 0000000000..1da25d44b2 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createFrontComponentSandboxIframe.ts @@ -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; +}; diff --git a/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createFrontComponentSandboxMessageHandler.ts b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createFrontComponentSandboxMessageHandler.ts new file mode 100644 index 0000000000..df605989b9 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createFrontComponentSandboxMessageHandler.ts @@ -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', + ), + ); + } + }; +}; diff --git a/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createSandboxErrorMessageFromWorkerErrorEvent.ts b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createSandboxErrorMessageFromWorkerErrorEvent.ts new file mode 100644 index 0000000000..e3acb5a1b0 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createSandboxErrorMessageFromWorkerErrorEvent.ts @@ -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, +}); diff --git a/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createWorkerSpawnErrorSandboxMessage.ts b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createWorkerSpawnErrorSandboxMessage.ts new file mode 100644 index 0000000000..2cbe5789e5 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createWorkerSpawnErrorSandboxMessage.ts @@ -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, +}); diff --git a/packages/twenty-front-component-renderer/src/remote/sandbox/utils/parseFrontComponentSandboxMessage.ts b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/parseFrontComponentSandboxMessage.ts new file mode 100644 index 0000000000..a75831a98b --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/parseFrontComponentSandboxMessage.ts @@ -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; +}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/constants/SdkClientImportSpecifiers.ts b/packages/twenty-front-component-renderer/src/remote/worker/constants/SdkClientImportSpecifiers.ts new file mode 100644 index 0000000000..4bed010224 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/constants/SdkClientImportSpecifiers.ts @@ -0,0 +1,4 @@ +export const SDK_CLIENT_IMPORT_SPECIFIERS = [ + 'twenty-client-sdk/core', + 'twenty-client-sdk/metadata', +] as const; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts b/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts index 1d2cc526ca..cb557badd2 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts +++ b/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts @@ -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 = { - '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.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(); }); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildAuthorizationHeadersFromAccessToken.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildAuthorizationHeadersFromAccessToken.test.ts new file mode 100644 index 0000000000..6aeff6ae37 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildAuthorizationHeadersFromAccessToken.test.ts @@ -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(); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildFrontComponentHostCommunicationApiFromThreadImports.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildFrontComponentHostCommunicationApiFromThreadImports.test.ts new file mode 100644 index 0000000000..20e2cfabca --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildFrontComponentHostCommunicationApiFromThreadImports.test.ts @@ -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'); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildHostFetchInputFromFetchRequestArguments.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildHostFetchInputFromFetchRequestArguments.test.ts new file mode 100644 index 0000000000..ab51f50a5e --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildHostFetchInputFromFetchRequestArguments.test.ts @@ -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, + }); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildResponseFromHostFetchResult.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildResponseFromHostFetchResult.test.ts new file mode 100644 index 0000000000..78df36044c --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildResponseFromHostFetchResult.test.ts @@ -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; + }, + ) { + 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(); + } + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/createCommandConfirmationModalBridge.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/createCommandConfirmationModalBridge.test.ts new file mode 100644 index 0000000000..ab213edfb1 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/createCommandConfirmationModalBridge.test.ts @@ -0,0 +1,90 @@ +import { + createOpenCommandConfirmationModalAdapter, + handleCommandConfirmationModalResult, +} from '../createCommandConfirmationModalBridge'; + +type OpenModalAdapter = ReturnType< + typeof createOpenCommandConfirmationModalAdapter +>; + +const modalParams = {} as Parameters[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(); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/createJavaScriptModuleBlobUrl.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/createJavaScriptModuleBlobUrl.test.ts new file mode 100644 index 0000000000..59f62ae617 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/createJavaScriptModuleBlobUrl.test.ts @@ -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'); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchJavaScriptModuleSourceText.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchJavaScriptModuleSourceText.test.ts new file mode 100644 index 0000000000..1cb32c9253 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchJavaScriptModuleSourceText.test.ts @@ -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', + ); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchSdkClientModulesAsBlobUrls.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchSdkClientModulesAsBlobUrls.test.ts new file mode 100644 index 0000000000..84b501202c --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchSdkClientModulesAsBlobUrls.test.ts @@ -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'); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/getHeadersFromFetchRequestArguments.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/getHeadersFromFetchRequestArguments.test.ts new file mode 100644 index 0000000000..c780a38b0e --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/getHeadersFromFetchRequestArguments.test.ts @@ -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({}); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/getMethodFromFetchRequestArguments.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/getMethodFromFetchRequestArguments.test.ts new file mode 100644 index 0000000000..f2d5ae26a9 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/getMethodFromFetchRequestArguments.test.ts @@ -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'); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/getTextBodyFromFetchRequestArguments.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/getTextBodyFromFetchRequestArguments.test.ts new file mode 100644 index 0000000000..79fa82fe14 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/getTextBodyFromFetchRequestArguments.test.ts @@ -0,0 +1,90 @@ +import { getTextBodyFromFetchRequestArguments } from '../getTextBodyFromFetchRequestArguments'; + +const createRequestInput = ({ + headers, + body, +}: { + headers?: Record; + 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); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/getUrlFromFetchRequestInput.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/getUrlFromFetchRequestInput.test.ts new file mode 100644 index 0000000000..fb864db4f2 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/getUrlFromFetchRequestInput.test.ts @@ -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', + ); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/installHostFetchProxy.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/installHostFetchProxy.test.ts new file mode 100644 index 0000000000..14d609a2a5 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/installHostFetchProxy.test.ts @@ -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; + }, + ) { + 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'); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/isTextContentType.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/isTextContentType.test.ts new file mode 100644 index 0000000000..742a8409c1 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/isTextContentType.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/isUrlFromProxiedOrigin.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/isUrlFromProxiedOrigin.test.ts new file mode 100644 index 0000000000..70f5163aa4 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/isUrlFromProxiedOrigin.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/renderFrontComponent.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/renderFrontComponent.test.ts new file mode 100644 index 0000000000..04f16e2315 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/renderFrontComponent.test.ts @@ -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', + }); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/revokeSdkClientModuleBlobUrls.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/revokeSdkClientModuleBlobUrls.test.ts new file mode 100644 index 0000000000..2a985f5057 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/revokeSdkClientModuleBlobUrls.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/rewriteSdkClientImportsToBlobUrls.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/rewriteSdkClientImportsToBlobUrls.test.ts new file mode 100644 index 0000000000..c320704f7e --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/rewriteSdkClientImportsToBlobUrls.test.ts @@ -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";', + ); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/setWorkerEnv.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/setWorkerEnvironmentVariables.test.ts similarity index 61% rename from packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/setWorkerEnv.test.ts rename to packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/setWorkerEnvironmentVariables.test.ts index e0d4828ba2..7112000dca 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/setWorkerEnv.test.ts +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/setWorkerEnvironmentVariables.test.ts @@ -1,12 +1,12 @@ -import { setWorkerEnv } from '../setWorkerEnv'; +import { setWorkerEnvironmentVariables } from '../setWorkerEnvironmentVariables'; -describe('setWorkerEnv', () => { +describe('setWorkerEnvironmentVariables', () => { beforeEach(() => { delete (globalThis as Record)['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)[ + 'process' + ] as Record; + const processEnvironment = processObject['env'] as Record; + + expect(processEnvironment['TWENTY_API_URL']).toBe( + 'https://system-provided.example.com', + ); + }); + it('should preserve existing process properties and environment values', () => { (globalThis as Record)['process'] = { env: { @@ -30,7 +48,7 @@ describe('setWorkerEnv', () => { version: 'test-version', }; - setWorkerEnv({ + setWorkerEnvironmentVariables({ TWENTY_APP_ACCESS_TOKEN: 'test-key', }); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/attachRemoteRenderRootToWorkerDocument.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/attachRemoteRenderRootToWorkerDocument.ts new file mode 100644 index 0000000000..693c528780 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/attachRemoteRenderRootToWorkerDocument.ts @@ -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; +}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/buildAuthorizationHeadersFromAccessToken.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/buildAuthorizationHeadersFromAccessToken.ts new file mode 100644 index 0000000000..e29fd7af61 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/buildAuthorizationHeadersFromAccessToken.ts @@ -0,0 +1,8 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +export const buildAuthorizationHeadersFromAccessToken = ( + applicationAccessToken?: string, +): Record | undefined => + isNonEmptyString(applicationAccessToken) + ? { Authorization: `Bearer ${applicationAccessToken}` } + : undefined; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/buildFrontComponentHostCommunicationApiFromThreadImports.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/buildFrontComponentHostCommunicationApiFromThreadImports.ts new file mode 100644 index 0000000000..1a9a0e6aeb --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/buildFrontComponentHostCommunicationApiFromThreadImports.ts @@ -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 => ({ + 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, +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/buildHostFetchInputFromFetchRequestArguments.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/buildHostFetchInputFromFetchRequestArguments.ts new file mode 100644 index 0000000000..c4c3a09f15 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/buildHostFetchInputFromFetchRequestArguments.ts @@ -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 => { + 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), + }; +}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/buildResponseFromHostFetchResult.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/buildResponseFromHostFetchResult.ts new file mode 100644 index 0000000000..ae23ba0855 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/buildResponseFromHostFetchResult.ts @@ -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, + }, + ); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/containsSdkClientImportSpecifier.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/containsSdkClientImportSpecifier.ts new file mode 100644 index 0000000000..6751b7da5f --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/containsSdkClientImportSpecifier.ts @@ -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)); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/createCommandConfirmationModalBridge.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/createCommandConfirmationModalBridge.ts index dc647ccca7..55afd3f3cd 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/utils/createCommandConfirmationModalBridge.ts +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/createCommandConfirmationModalBridge.ts @@ -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', ); } diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/createRemoteWorker.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/createFrontComponentRemoteWorker.ts similarity index 68% rename from packages/twenty-front-component-renderer/src/remote/worker/utils/createRemoteWorker.ts rename to packages/twenty-front-component-renderer/src/remote/worker/utils/createFrontComponentRemoteWorker.ts index 54adb3ceec..54bea76b42 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/utils/createRemoteWorker.ts +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/createFrontComponentRemoteWorker.ts @@ -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(); }; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/createJavaScriptModuleBlobUrl.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/createJavaScriptModuleBlobUrl.ts new file mode 100644 index 0000000000..d75ba03462 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/createJavaScriptModuleBlobUrl.ts @@ -0,0 +1,2 @@ +export const createJavaScriptModuleBlobUrl = (source: string): string => + URL.createObjectURL(new Blob([source], { type: 'application/javascript' })); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchJavaScriptModuleSourceText.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchJavaScriptModuleSourceText.ts new file mode 100644 index 0000000000..401246478c --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchJavaScriptModuleSourceText.ts @@ -0,0 +1,28 @@ +import { CustomError } from 'twenty-shared/utils'; + +export const fetchJavaScriptModuleSourceText = async ( + url: string, + headers?: Record, +): Promise => { + 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(); +}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchSdkClientModulesAsBlobUrls.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchSdkClientModulesAsBlobUrls.ts new file mode 100644 index 0000000000..f9c5f72754 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchSdkClientModulesAsBlobUrls.ts @@ -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, +): Promise => { + 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 }; +}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/getHeadersFromFetchRequestArguments.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/getHeadersFromFetchRequestArguments.ts new file mode 100644 index 0000000000..f56cbbb5eb --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/getHeadersFromFetchRequestArguments.ts @@ -0,0 +1,28 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { isRequestObject } from '@/remote/worker/utils/isRequestObject'; + +const toHeaderRecord = (headers: HeadersInit): Record => { + const record: Record = {}; + + new Headers(headers).forEach((value, key) => { + record[key] = value; + }); + + return record; +}; + +export const getHeadersFromFetchRequestArguments = ( + input: RequestInfo | URL, + init: RequestInit | undefined, +): Record => { + if (isDefined(init?.headers)) { + return toHeaderRecord(init.headers); + } + + if (isRequestObject(input)) { + return toHeaderRecord(input.headers); + } + + return {}; +}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/getMethodFromFetchRequestArguments.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/getMethodFromFetchRequestArguments.ts new file mode 100644 index 0000000000..1d9d55a913 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/getMethodFromFetchRequestArguments.ts @@ -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'); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/getTextBodyFromFetchRequestArguments.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/getTextBodyFromFetchRequestArguments.ts new file mode 100644 index 0000000000..2eacda671e --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/getTextBodyFromFetchRequestArguments.ts @@ -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 => { + 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 => { + 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', + ); +}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/getUrlFromFetchRequestInput.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/getUrlFromFetchRequestInput.ts new file mode 100644 index 0000000000..bc132d305a --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/getUrlFromFetchRequestInput.ts @@ -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; +}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/installHostFetchProxy.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/installHostFetchProxy.ts new file mode 100644 index 0000000000..0530688d95 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/installHostFetchProxy.ts @@ -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 => { + 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); + }; +}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/isRequestObject.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/isRequestObject.ts new file mode 100644 index 0000000000..fb0d0f2634 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/isRequestObject.ts @@ -0,0 +1,4 @@ +import { isObject } from '@sniptt/guards'; + +export const isRequestObject = (input: RequestInfo | URL): input is Request => + isObject(input) && !(input instanceof URL); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/isTextContentType.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/isTextContentType.ts new file mode 100644 index 0000000000..5af22ac22b --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/isTextContentType.ts @@ -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') + ); +}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/isUrlFromProxiedOrigin.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/isUrlFromProxiedOrigin.ts new file mode 100644 index 0000000000..a5abad3e71 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/isUrlFromProxiedOrigin.ts @@ -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); +}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/loadFrontComponentModule.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/loadFrontComponentModule.ts new file mode 100644 index 0000000000..cf086f55d4 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/loadFrontComponentModule.ts @@ -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 => { + 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); + } + } +}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/renderFrontComponent.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/renderFrontComponent.ts new file mode 100644 index 0000000000..abe186f364 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/renderFrontComponent.ts @@ -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 => { + 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); +}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/revokeSdkClientModuleBlobUrls.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/revokeSdkClientModuleBlobUrls.ts new file mode 100644 index 0000000000..29318e2059 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/revokeSdkClientModuleBlobUrls.ts @@ -0,0 +1,8 @@ +import { type SdkClientUrls } from '@/types/SdkClientUrls'; + +export const revokeSdkClientModuleBlobUrls = ( + sdkModuleBlobUrls: SdkClientUrls, +): void => { + URL.revokeObjectURL(sdkModuleBlobUrls.core); + URL.revokeObjectURL(sdkModuleBlobUrls.metadata); +}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/rewriteSdkClientImportsToBlobUrls.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/rewriteSdkClientImportsToBlobUrls.ts new file mode 100644 index 0000000000..4e12db3f0b --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/rewriteSdkClientImportsToBlobUrls.ts @@ -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; +}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/setWorkerEnv.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/setWorkerEnvironmentVariables.ts similarity index 81% rename from packages/twenty-front-component-renderer/src/remote/worker/utils/setWorkerEnv.ts rename to packages/twenty-front-component-renderer/src/remote/worker/utils/setWorkerEnvironmentVariables.ts index ab6c8e07a9..5808c34077 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/utils/setWorkerEnv.ts +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/setWorkerEnvironmentVariables.ts @@ -1,4 +1,6 @@ -export const setWorkerEnv = (variables: Record) => { +export const setWorkerEnvironmentVariables = ( + variables: Record, +) => { const globalObject = globalThis as Record; const processObject = (globalObject['process'] as Record | undefined) ?? {}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/setWorkerEnvironmentVariablesFromRenderContext.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/setWorkerEnvironmentVariablesFromRenderContext.ts new file mode 100644 index 0000000000..c068bdbcb0 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/setWorkerEnvironmentVariablesFromRenderContext.ts @@ -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, + }); + } +}; diff --git a/packages/twenty-front-component-renderer/src/types/FrontComponentHostCommunicationApiStore.ts b/packages/twenty-front-component-renderer/src/types/FrontComponentHostCommunicationApiStore.ts new file mode 100644 index 0000000000..fef60fd7a8 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/types/FrontComponentHostCommunicationApiStore.ts @@ -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; +}; diff --git a/packages/twenty-front-component-renderer/src/types/FrontComponentHostThread.ts b/packages/twenty-front-component-renderer/src/types/FrontComponentHostThread.ts new file mode 100644 index 0000000000..5e90ba6845 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/types/FrontComponentHostThread.ts @@ -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 +>; diff --git a/packages/twenty-front-component-renderer/src/types/FrontComponentHostThreadExports.ts b/packages/twenty-front-component-renderer/src/types/FrontComponentHostThreadExports.ts new file mode 100644 index 0000000000..db0669db42 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/types/FrontComponentHostThreadExports.ts @@ -0,0 +1,7 @@ +import { type FrontComponentHostCommunicationApi } from '@/types/FrontComponentHostCommunicationApi'; +import { type HostFetchFunction } from '@/types/HostFetchFunction'; + +export type FrontComponentHostThreadExports = + FrontComponentHostCommunicationApi & { + hostFetch: HostFetchFunction; + }; diff --git a/packages/twenty-front-component-renderer/src/types/FrontComponentThread.ts b/packages/twenty-front-component-renderer/src/types/FrontComponentThread.ts new file mode 100644 index 0000000000..a150bb6925 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/types/FrontComponentThread.ts @@ -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 +>; diff --git a/packages/twenty-front-component-renderer/src/types/HostFetchFunction.ts b/packages/twenty-front-component-renderer/src/types/HostFetchFunction.ts new file mode 100644 index 0000000000..65bef33d99 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/types/HostFetchFunction.ts @@ -0,0 +1,6 @@ +import { type HostFetchInput } from '@/types/HostFetchInput'; +import { type HostFetchResult } from '@/types/HostFetchResult'; + +export type HostFetchFunction = ( + input: HostFetchInput, +) => Promise; diff --git a/packages/twenty-front-component-renderer/src/types/HostFetchInput.ts b/packages/twenty-front-component-renderer/src/types/HostFetchInput.ts new file mode 100644 index 0000000000..fb412d321b --- /dev/null +++ b/packages/twenty-front-component-renderer/src/types/HostFetchInput.ts @@ -0,0 +1,6 @@ +export type HostFetchInput = { + url: string; + method?: string; + headers?: Record; + body?: string; +}; diff --git a/packages/twenty-front-component-renderer/src/types/HostFetchPolicy.ts b/packages/twenty-front-component-renderer/src/types/HostFetchPolicy.ts new file mode 100644 index 0000000000..66d28004b3 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/types/HostFetchPolicy.ts @@ -0,0 +1,4 @@ +export type HostFetchPolicy = { + allowedOrigins: string[]; + fileStorageRedirectableUrls: string[]; +}; diff --git a/packages/twenty-front-component-renderer/src/types/HostFetchResult.ts b/packages/twenty-front-component-renderer/src/types/HostFetchResult.ts new file mode 100644 index 0000000000..761e5e8b92 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/types/HostFetchResult.ts @@ -0,0 +1,6 @@ +export type HostFetchResult = { + status: number; + statusText: string; + headers: Record; + body: string; +}; diff --git a/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts b/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts index 8236610a9b..5d76dc09aa 100644 --- a/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts +++ b/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts @@ -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; }; diff --git a/packages/twenty-front-component-renderer/src/types/SdkClientUrls.ts b/packages/twenty-front-component-renderer/src/types/SdkClientUrls.ts new file mode 100644 index 0000000000..b2f60cc45c --- /dev/null +++ b/packages/twenty-front-component-renderer/src/types/SdkClientUrls.ts @@ -0,0 +1,4 @@ +export type SdkClientUrls = { + core: string; + metadata: string; +}; diff --git a/packages/twenty-front-component-renderer/src/types/WorkerExports.ts b/packages/twenty-front-component-renderer/src/types/WorkerExports.ts index 4a355349b2..59a06529b6 100644 --- a/packages/twenty-front-component-renderer/src/types/WorkerExports.ts +++ b/packages/twenty-front-component-renderer/src/types/WorkerExports.ts @@ -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; initializeHostCommunicationApi: () => Promise; updateContext: (context: FrontComponentExecutionContext) => Promise; - onConfirmationModalResult: (result: 'confirm' | 'cancel') => Promise; + onConfirmationModalResult: ( + result: CommandConfirmationModalResult, + ) => Promise; }; diff --git a/packages/twenty-front-component-renderer/tsconfig.json b/packages/twenty-front-component-renderer/tsconfig.json index 298d986ea8..1125cd5a92 100644 --- a/packages/twenty-front-component-renderer/tsconfig.json +++ b/packages/twenty-front-component-renderer/tsconfig.json @@ -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" ] } diff --git a/packages/twenty-front-component-renderer/vitest.storybook.config.ts b/packages/twenty-front-component-renderer/vitest.storybook.config.ts index 000da2a565..18e70d9209 100644 --- a/packages/twenty-front-component-renderer/vitest.storybook.config.ts +++ b/packages/twenty-front-component-renderer/vitest.storybook.config.ts @@ -2,7 +2,7 @@ import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; import { playwright } from '@vitest/browser-playwright'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { defineConfig } from 'vitest/config'; +import { coverageConfigDefaults, defineConfig } from 'vitest/config'; const MINUTES_IN_MS = 60 * 1000; @@ -17,6 +17,7 @@ export default defineConfig({ provider: 'istanbul', reporter: ['json', 'text'], reportsDirectory: './coverage/storybook', + exclude: [...coverageConfigDefaults.exclude, 'src/__stories__/**'], }, projects: [ { diff --git a/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/components/CommandMenuConfirmationModalManager.tsx b/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/components/CommandMenuConfirmationModalManager.tsx index a926a22205..921783f59a 100644 --- a/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/components/CommandMenuConfirmationModalManager.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/components/CommandMenuConfirmationModalManager.tsx @@ -1,10 +1,10 @@ import { COMMAND_MENU_CONFIRMATION_MODAL_INSTANCE_ID } from '@/command-menu-item/confirmation-modal/constants/CommandMenuItemConfirmationModalId'; -import { COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME } from '@/command-menu-item/confirmation-modal/constants/CommandMenuItemConfirmationModalResultBrowserEventName'; +import { COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME } from 'twenty-shared/constants'; import { commandMenuItemConfirmationModalConfigState } from '@/command-menu-item/confirmation-modal/states/commandMenuItemConfirmationModalState'; import { type CommandMenuConfirmationModalResult, type CommandMenuConfirmationModalResultBrowserEventDetail, -} from '@/command-menu-item/confirmation-modal/types/CommandMenuConfirmationModalResultBrowserEventDetail'; +} from 'twenty-shared/types'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; diff --git a/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/types/CommandMenuConfirmationModalResultBrowserEventDetail.ts b/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/types/CommandMenuConfirmationModalResultBrowserEventDetail.ts deleted file mode 100644 index 41bfe70b47..0000000000 --- a/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/types/CommandMenuConfirmationModalResultBrowserEventDetail.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { type ConfirmationModalCaller } from 'twenty-shared/types'; - -export type CommandMenuConfirmationModalResult = 'confirm' | 'cancel'; - -export type CommandMenuConfirmationModalResultBrowserEventDetail = { - caller: ConfirmationModalCaller; - confirmationResult: CommandMenuConfirmationModalResult; -}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessConfirmationModalEngineCommandEffect.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessConfirmationModalEngineCommandEffect.tsx index 61e22b885e..12894f3b35 100644 --- a/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessConfirmationModalEngineCommandEffect.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessConfirmationModalEngineCommandEffect.tsx @@ -1,9 +1,9 @@ import { useIsHeadlessEngineCommandEffectInitialized } from '@/command-menu-item/engine-command/hooks/useIsHeadlessEngineCommandEffectInitialized'; import { type ReactNode, useEffect } from 'react'; -import { COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME } from '@/command-menu-item/confirmation-modal/constants/CommandMenuItemConfirmationModalResultBrowserEventName'; +import { COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME } from 'twenty-shared/constants'; import { useCommandMenuConfirmationModal } from '@/command-menu-item/confirmation-modal/hooks/useCommandMenuConfirmationModal'; -import { type CommandMenuConfirmationModalResultBrowserEventDetail } from '@/command-menu-item/confirmation-modal/types/CommandMenuConfirmationModalResultBrowserEventDetail'; +import { type CommandMenuConfirmationModalResultBrowserEventDetail } from 'twenty-shared/types'; import { useUnmountCommand } from '@/command-menu-item/engine-command/hooks/useUnmountEngineCommand'; import { CommandComponentInstanceContext } from '@/command-menu-item/engine-command/states/contexts/CommandComponentInstanceContext'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; diff --git a/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx b/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx index bdd68aeee2..477ec87052 100644 --- a/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx +++ b/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx @@ -1,5 +1,5 @@ import { FrontComponentRendererProvider } from '@/front-components/components/FrontComponentRendererProvider'; -import { FrontComponentRendererWithSdkClient } from '@/front-components/components/FrontComponentRendererWithSdkClient'; +import { getSdkClientUrls } from '@/front-components/utils/getSdkClientUrls'; import { useGetLogicFunctionHttpUrl } from '@/settings/logic-functions/hooks/useGetLogicFunctionHttpUrl'; import { useFrontComponentExecutionContext } from '@/front-components/hooks/useFrontComponentExecutionContext'; import { useOnFrontComponentUpdated } from '@/front-components/hooks/useOnFrontComponentUpdated'; @@ -8,7 +8,7 @@ import { getFrontComponentUrl } from '@/front-components/utils/getFrontComponent import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; import { t } from '@lingui/core/macro'; -import { useCallback, useContext, useEffect } from 'react'; +import { useCallback, useContext, useEffect, useMemo } from 'react'; import { FrontComponentRenderer as SharedFrontComponentRenderer } from 'twenty-front-component-renderer'; import { isDefined } from 'twenty-shared/utils'; import { ThemeContext } from 'twenty-ui/theme-constants'; @@ -70,22 +70,26 @@ export const FrontComponentRenderer = ({ } }, [error, handleError]); - useEffect(() => { - if (data) { - const tokenPair = data.frontComponent?.applicationTokenPair; + const applicationTokenPair = + data?.frontComponent?.applicationTokenPair ?? null; - if (isDefined(tokenPair)) { - setFrontComponentApplicationTokenPair(tokenPair); - } + useEffect(() => { + if (isDefined(applicationTokenPair)) { + setFrontComponentApplicationTokenPair(applicationTokenPair); } - }, [data, setFrontComponentApplicationTokenPair]); + }, [applicationTokenPair, setFrontComponentApplicationTokenPair]); useOnFrontComponentUpdated({ frontComponentId, }); - const applicationTokenPair = - data?.frontComponent?.applicationTokenPair ?? null; + const applicationId = data?.frontComponent?.applicationId; + + const sdkClientUrls = useMemo( + () => + isDefined(applicationId) ? getSdkClientUrls(applicationId) : undefined, + [applicationId], + ); if ( loading || @@ -100,33 +104,11 @@ export const FrontComponentRenderer = ({ checksum: data.frontComponent.builtComponentChecksum, }); - const usesSdkClient = data.frontComponent.usesSdkClient; - const accessToken = applicationTokenPair.applicationAccessToken.token; const applicationVariables = data.frontComponent.applicationVariables ?? undefined; - if (usesSdkClient) { - return ( - - - - ); - } - return ( ; - onError: (error?: Error) => void; -}; - -export const FrontComponentRendererWithSdkClient = ({ - colorScheme, - componentUrl, - applicationAccessToken, - applicationId, - functionsBaseUrl, - executionContext, - frontComponentHostCommunicationApi, - applicationVariables, - onError, -}: FrontComponentRendererWithSdkClientProps) => { - const sdkClientState = useAtomValue( - sdkClientFamilyState.atomFamily(applicationId), - ); - - return ( - <> - - {sdkClientState.status === 'loaded' && ( - - )} - - ); -}; diff --git a/packages/twenty-front/src/modules/front-components/components/SdkClientBlobUrlsEffect.tsx b/packages/twenty-front/src/modules/front-components/components/SdkClientBlobUrlsEffect.tsx deleted file mode 100644 index 161ed2e400..0000000000 --- a/packages/twenty-front/src/modules/front-components/components/SdkClientBlobUrlsEffect.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useStore } from 'jotai'; -import { useEffect } from 'react'; - -import { sdkClientFamilyState } from '@/front-components/states/sdkClientFamilyState'; -import { fetchSdkClientBlobUrls } from '@/front-components/utils/fetchSdkClientBlobUrls'; - -export const SdkClientBlobUrlsEffect = ({ - applicationId, - accessToken, - onError, -}: { - applicationId: string; - accessToken: string; - onError?: (error: Error) => void; -}) => { - const store = useStore(); - - useEffect(() => { - const atom = sdkClientFamilyState.atomFamily(applicationId); - const { status } = store.get(atom); - - if (status === 'loading' || status === 'loaded') { - return; - } - - store.set(atom, { status: 'loading' }); - - const fetchBlobUrls = async () => { - try { - const blobUrls = await fetchSdkClientBlobUrls( - applicationId, - accessToken, - ); - - store.set(atom, { status: 'loaded', blobUrls }); - } catch (error: unknown) { - const normalizedError = - error instanceof Error ? error : new Error(String(error)); - - store.set(atom, { status: 'error', error: normalizedError }); - onError?.(normalizedError); - } - }; - - fetchBlobUrls(); - }, [applicationId, accessToken, store, onError]); - - return null; -}; diff --git a/packages/twenty-front/src/modules/front-components/states/sdkClientFamilyState.ts b/packages/twenty-front/src/modules/front-components/states/sdkClientFamilyState.ts deleted file mode 100644 index 43347bf6c9..0000000000 --- a/packages/twenty-front/src/modules/front-components/states/sdkClientFamilyState.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { createAtomFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomFamilyState'; - -export type SdkClientBlobUrls = { - core: string; - metadata: string; -}; - -export type SdkClientState = - | { status: 'idle' } - | { status: 'loading' } - | { status: 'loaded'; blobUrls: SdkClientBlobUrls } - | { status: 'error'; error: Error }; - -export const sdkClientFamilyState = createAtomFamilyState< - SdkClientState, - string ->({ - key: 'sdkClientFamilyState', - defaultValue: { status: 'idle' }, -}); diff --git a/packages/twenty-front/src/modules/front-components/utils/__tests__/getSdkClientUrls.test.ts b/packages/twenty-front/src/modules/front-components/utils/__tests__/getSdkClientUrls.test.ts new file mode 100644 index 0000000000..c210887ddb --- /dev/null +++ b/packages/twenty-front/src/modules/front-components/utils/__tests__/getSdkClientUrls.test.ts @@ -0,0 +1,11 @@ +import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url'; +import { getSdkClientUrls } from '@/front-components/utils/getSdkClientUrls'; + +describe('getSdkClientUrls', () => { + it('builds application-scoped core and metadata sdk client urls', () => { + expect(getSdkClientUrls('application-id')).toEqual({ + core: `${REST_API_BASE_URL}/sdk-client/application-id/core`, + metadata: `${REST_API_BASE_URL}/sdk-client/application-id/metadata`, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/front-components/utils/fetchSdkClientBlobUrls.ts b/packages/twenty-front/src/modules/front-components/utils/fetchSdkClientBlobUrls.ts deleted file mode 100644 index 48d2c89b37..0000000000 --- a/packages/twenty-front/src/modules/front-components/utils/fetchSdkClientBlobUrls.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { type SdkClientBlobUrls } from '@/front-components/states/sdkClientFamilyState'; -import { getSdkClientUrls } from '@/front-components/utils/getSdkClientUrls'; - -const fetchAndCreateBlobUrl = async ( - url: string, - token: string, -): Promise => { - const response = await fetch(url, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch SDK module from ${url}: ${response.status}`, - ); - } - - const source = await response.text(); - const blob = new Blob([source], { type: 'application/javascript' }); - - return URL.createObjectURL(blob); -}; - -export const fetchSdkClientBlobUrls = async ( - applicationId: string, - accessToken: string, -): Promise => { - const urls = getSdkClientUrls(applicationId); - - const [coreResult, metadataResult] = await Promise.allSettled([ - fetchAndCreateBlobUrl(urls.core, accessToken), - fetchAndCreateBlobUrl(urls.metadata, accessToken), - ]); - - if ( - coreResult.status === 'fulfilled' && - metadataResult.status === 'fulfilled' - ) { - return { core: coreResult.value, metadata: metadataResult.value }; - } - - if (coreResult.status === 'fulfilled') { - URL.revokeObjectURL(coreResult.value); - } - - if (metadataResult.status === 'fulfilled') { - URL.revokeObjectURL(metadataResult.value); - } - - throw coreResult.status === 'rejected' - ? coreResult.reason - : metadataResult.status === 'rejected' - ? metadataResult.reason - : new Error('Unexpected SDK client fetch failure'); -}; diff --git a/packages/twenty-server/scripts/build-seed-front-components.ts b/packages/twenty-server/scripts/build-seed-front-components.ts index db58b4b2ea..5893ba7ac9 100644 --- a/packages/twenty-server/scripts/build-seed-front-components.ts +++ b/packages/twenty-server/scripts/build-seed-front-components.ts @@ -4,6 +4,7 @@ // Usage: npx tsx scripts/build-seed-front-components.ts import * as esbuild from 'esbuild'; +import { existsSync, readdirSync } from 'fs'; import { join, resolve } from 'path'; import { getFrontComponentBuildPlugins } from 'twenty-sdk/front-component-renderer/build'; @@ -20,7 +21,14 @@ const alias: Record = { 'react-dom': join(ROOT_NODE_MODULES, 'react-dom'), }; -const COMPONENTS = ['hello-world', 'show-notification']; +const COMPONENTS = readdirSync(SEED_PROJECT_DIR, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + existsSync(join(SEED_PROJECT_DIR, entry.name, 'index.tsx')), + ) + .map((entry) => entry.name) + .sort(); const build = async () => { for (const component of COMPONENTS) { diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/constants/seed-project/list-companies/index.mjs b/packages/twenty-server/src/engine/metadata-modules/front-component/constants/seed-project/list-companies/index.mjs new file mode 100644 index 0000000000..b0cee205ef --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/constants/seed-project/list-companies/index.mjs @@ -0,0 +1,66 @@ +var wm=Object.create;var Sn=Object.defineProperty;var Wm=Object.getOwnPropertyDescriptor;var $m=Object.getOwnPropertyNames;var Fm=Object.getPrototypeOf,km=Object.prototype.hasOwnProperty;var Ji=(l,t,u)=>()=>{if(u)throw u[0];try{return l&&(t=l(l=0)),t}catch(a){throw u=[a],a}};var $l=(l,t)=>()=>{try{return t||l((t={exports:{}}).exports,t),t.exports}catch(u){throw t=0,u}},Im=(l,t)=>{for(var u in t)Sn(l,u,{get:t[u],enumerable:!0})},sn=(l,t,u,a)=>{if(t&&typeof t=="object"||typeof t=="function")for(let n of $m(t))!km.call(l,n)&&n!==u&&Sn(l,n,{get:()=>t[n],enumerable:!(a=Wm(t,n))||a.enumerable});return l},Ot=(l,t,u)=>(sn(l,t,"default"),u&&sn(u,t,"default")),gn=(l,t,u)=>(u=l!=null?wm(Fm(l)):{},sn(t||!l||!l.__esModule?Sn(u,"default",{value:l,enumerable:!0}):u,l)),wi=l=>sn(Sn({},"__esModule",{value:!0}),l);var a0=$l(w=>{"use strict";function ef(l,t){var u=l.length;l.push(t);l:for(;0>>1,n=l[a];if(0>>1;abn(c,u))ibn(m,c)?(l[a]=m,l[i]=u,a=i):(l[a]=c,l[f]=u,a=f);else if(ibn(m,u))l[a]=m,l[i]=u,a=i;else break l}}return t}function bn(l,t){var u=l.sortIndex-t.sortIndex;return u!==0?u:l.id-t.id}w.unstable_now=void 0;typeof performance=="object"&&typeof performance.now=="function"?(Wi=performance,w.unstable_now=function(){return Wi.now()}):(uf=Date,$i=uf.now(),w.unstable_now=function(){return uf.now()-$i});var Wi,uf,$i,at=[],Mt=[],Pm=1,Rl=null,ol=3,ff=!1,da=!1,ha=!1,cf=!1,Ii=typeof setTimeout=="function"?setTimeout:null,Pi=typeof clearTimeout=="function"?clearTimeout:null,Fi=typeof setImmediate<"u"?setImmediate:null;function zn(l){for(var t=Fl(Mt);t!==null;){if(t.callback===null)En(Mt);else if(t.startTime<=l)En(Mt),t.sortIndex=t.expirationTime,ef(at,t);else break;t=Fl(Mt)}}function yf(l){if(ha=!1,zn(l),!da)if(Fl(at)!==null)da=!0,Tu||(Tu=!0,Eu());else{var t=Fl(Mt);t!==null&&vf(yf,t.startTime-l)}}var Tu=!1,oa=-1,l0=5,t0=-1;function u0(){return cf?!0:!(w.unstable_now()-t0l&&u0());){var a=Rl.callback;if(typeof a=="function"){Rl.callback=null,ol=Rl.priorityLevel;var n=a(Rl.expirationTime<=l);if(l=w.unstable_now(),typeof n=="function"){Rl.callback=n,zn(l),t=!0;break t}Rl===Fl(at)&&En(at),zn(l)}else En(at);Rl=Fl(at)}if(Rl!==null)t=!0;else{var e=Fl(Mt);e!==null&&vf(yf,e.startTime-l),t=!1}}break l}finally{Rl=null,ol=u,ff=!1}t=void 0}}finally{t?Eu():Tu=!1}}}var Eu;typeof Fi=="function"?Eu=function(){Fi(af)}:typeof MessageChannel<"u"?(nf=new MessageChannel,ki=nf.port2,nf.port1.onmessage=af,Eu=function(){ki.postMessage(null)}):Eu=function(){Ii(af,0)};var nf,ki;function vf(l,t){oa=Ii(function(){l(w.unstable_now())},t)}w.unstable_IdlePriority=5;w.unstable_ImmediatePriority=1;w.unstable_LowPriority=4;w.unstable_NormalPriority=3;w.unstable_Profiling=null;w.unstable_UserBlockingPriority=2;w.unstable_cancelCallback=function(l){l.callback=null};w.unstable_forceFrameRate=function(l){0>l||125a?(l.sortIndex=u,ef(Mt,l),Fl(at)===null&&l===Fl(Mt)&&(ha?(Pi(oa),oa=-1):ha=!0,vf(yf,u-a))):(l.sortIndex=n,ef(at,l),da||ff||(da=!0,Tu||(Tu=!0,Eu()))),l};w.unstable_shouldYield=u0;w.unstable_wrapCallback=function(l){var t=ol;return function(){var u=ol;ol=t;try{return l.apply(this,arguments)}finally{ol=u}}}});var e0=$l((t2,n0)=>{"use strict";n0.exports=a0()});var S0=$l(O=>{"use strict";var hf=Symbol.for("react.transitional.element"),ld=Symbol.for("react.portal"),td=Symbol.for("react.fragment"),ud=Symbol.for("react.strict_mode"),ad=Symbol.for("react.profiler"),nd=Symbol.for("react.consumer"),ed=Symbol.for("react.context"),fd=Symbol.for("react.forward_ref"),cd=Symbol.for("react.suspense"),id=Symbol.for("react.memo"),v0=Symbol.for("react.lazy"),yd=Symbol.for("react.activity"),f0=Symbol.iterator;function vd(l){return l===null||typeof l!="object"?null:(l=f0&&l[f0]||l["@@iterator"],typeof l=="function"?l:null)}var m0={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},d0=Object.assign,h0={};function _u(l,t,u){this.props=l,this.context=t,this.refs=h0,this.updater=u||m0}_u.prototype.isReactComponent={};_u.prototype.setState=function(l,t){if(typeof l!="object"&&typeof l!="function"&&l!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,l,t,"setState")};_u.prototype.forceUpdate=function(l){this.updater.enqueueForceUpdate(this,l,"forceUpdate")};function o0(){}o0.prototype=_u.prototype;function of(l,t,u){this.props=l,this.context=t,this.refs=h0,this.updater=u||m0}var sf=of.prototype=new o0;sf.constructor=of;d0(sf,_u.prototype);sf.isPureReactComponent=!0;var c0=Array.isArray;function df(){}var V={H:null,A:null,T:null,S:null},s0=Object.prototype.hasOwnProperty;function Sf(l,t,u){var a=u.ref;return{$$typeof:hf,type:l,key:t,ref:a!==void 0?a:null,props:u}}function md(l,t){return Sf(l.type,t,l.props)}function gf(l){return typeof l=="object"&&l!==null&&l.$$typeof===hf}function dd(l){var t={"=":"=0",":":"=2"};return"$"+l.replace(/[=:]/g,function(u){return t[u]})}var i0=/\/+/g;function mf(l,t){return typeof l=="object"&&l!==null&&l.key!=null?dd(""+l.key):t.toString(36)}function hd(l){switch(l.status){case"fulfilled":return l.value;case"rejected":throw l.reason;default:switch(typeof l.status=="string"?l.then(df,df):(l.status="pending",l.then(function(t){l.status==="pending"&&(l.status="fulfilled",l.value=t)},function(t){l.status==="pending"&&(l.status="rejected",l.reason=t)})),l.status){case"fulfilled":return l.value;case"rejected":throw l.reason}}throw l}function Au(l,t,u,a,n){var e=typeof l;(e==="undefined"||e==="boolean")&&(l=null);var f=!1;if(l===null)f=!0;else switch(e){case"bigint":case"string":case"number":f=!0;break;case"object":switch(l.$$typeof){case hf:case ld:f=!0;break;case v0:return f=l._init,Au(f(l._payload),t,u,a,n)}}if(f)return n=n(l),f=a===""?"."+mf(l,0):a,c0(n)?(u="",f!=null&&(u=f.replace(i0,"$&/")+"/"),Au(n,t,u,"",function(m){return m})):n!=null&&(gf(n)&&(n=md(n,u+(n.key==null||l&&l.key===n.key?"":(""+n.key).replace(i0,"$&/")+"/")+f)),t.push(n)),1;f=0;var c=a===""?".":a+":";if(c0(l))for(var i=0;i{"use strict";g0.exports=S0()});function _n(l){if(l){for(var t=0,u=0;u2&&l.charCodeAt(0)===111&&l.charCodeAt(1)===110&&l.charCodeAt(2)>=65&&l.charCodeAt(2)<=90}function Mn(l){if(!l)return{cleanProps:l,events:null};var t=null,u=null;for(var a in l)if(gd(a)&&typeof l[a]=="function"){if(!t){t={},u={};for(var n in l){if(n===a)break;u[n]=l[n]}}t[a]=l[a]}else t&&(u[a]=l[a]);return{cleanProps:u||l,events:t}}function Dn(l,t){return function(u){if(u)for(var a in l){var n=Sd[a.toLowerCase()]||a.toLowerCase();u[n]=l[a]}typeof t=="function"?t(u):t!=null&&typeof t=="object"&&(t.current=u)}}var An,b0,Sd,zf=Ji(()=>{An=globalThis.__HTML_TAG_TO_CUSTOM_ELEMENT_TAG__||{},b0={};Sd={ondoubleclick:"ondblclick"}});var il={};Im(il,{createElement:()=>z0,default:()=>bd});function z0(l){var t=arguments;if(typeof l=="string"){if(l==="style"){var u=t.length>1?t[1]:null;if(u){var a=u.dangerouslySetInnerHTML?u.dangerouslySetInnerHTML.__html||"":On(u.children);_n(a)}return null}var n=An[l];if(n){var e=t.length>1?t[1]:null,f=Mn(e);if(f.events){var c=f.cleanProps||{};c.ref=Dn(f.events,c.ref);for(var i=[n,c],m=2;m{Ot(il,gn(bf()));Tf=gn(bf());zf();Ef=Tf.default.createElement;bd=Object.assign({},Tf.default,{createElement:z0})});var T0=$l(Sl=>{"use strict";var zd=(Un(),wi(il));function E0(l){var t="https://react.dev/errors/"+l;if(1{"use strict";function A0(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(A0)}catch(l){console.error(l)}}A0(),_0.exports=T0()});var rm=$l(Pe=>{"use strict";var nl=e0(),F1=(Un(),wi(il)),Ad=O0();function b(l){var t="https://react.dev/errors/"+l;if(1pu||(l.current=fc[pu],fc[pu]=null,pu--)}function x(l,t){pu++,fc[pu]=l.current,l.current=t}var lt=tt(null),Za=tt(null),Qt=tt(null),ie=tt(null);function ye(l,t){switch(x(Qt,t),x(Za,l),x(lt,null),t.nodeType){case 9:case 11:l=(l=t.documentElement)&&(l=l.namespaceURI)?Y1(l):0;break;default:if(l=t.tagName,t=t.namespaceURI)t=Y1(t),l=Am(t,l);else switch(l){case"svg":l=1;break;case"math":l=2;break;default:l=0}}cl(lt),x(lt,l)}function Wu(){cl(lt),cl(Za),cl(Qt)}function cc(l){l.memoizedState!==null&&x(ie,l);var t=lt.current,u=Am(t,l.type);t!==u&&(x(Za,l),x(lt,u))}function ve(l){Za.current===l&&(cl(lt),cl(Za)),ie.current===l&&(cl(ie),Ia._currentValue=eu)}var Af,U0;function tu(l){if(Af===void 0)try{throw Error()}catch(u){var t=u.stack.trim().match(/\n( *(at )?)/);Af=t&&t[1]||"",U0=-1)":-1n||i[a]!==m[n]){var s=` +`+i[a].replace(" at new "," at ");return l.displayName&&s.includes("")&&(s=s.replace("",l.displayName)),s}while(1<=a&&0<=n);break}}}finally{_f=!1,Error.prepareStackTrace=u}return(u=l?l.displayName||l.name:"")?tu(u):""}function Ud(l,t){switch(l.tag){case 26:case 27:case 5:return tu(l.type);case 16:return tu("Lazy");case 13:return l.child!==t&&t!==null?tu("Suspense Fallback"):tu("Suspense");case 19:return tu("SuspenseList");case 0:case 15:return Of(l.type,!1);case 11:return Of(l.type.render,!1);case 1:return Of(l.type,!0);case 31:return tu("Activity");default:return""}}function H0(l){try{var t="",u=null;do t+=Ud(l,u),u=l,l=l.return;while(l);return t}catch(a){return` +Error generating stack: `+a.message+` +`+a.stack}}var ic=Object.prototype.hasOwnProperty,Ic=nl.unstable_scheduleCallback,Mf=nl.unstable_cancelCallback,Hd=nl.unstable_shouldYield,Nd=nl.unstable_requestPaint,Nl=nl.unstable_now,pd=nl.unstable_getCurrentPriorityLevel,ay=nl.unstable_ImmediatePriority,ny=nl.unstable_UserBlockingPriority,me=nl.unstable_NormalPriority,Cd=nl.unstable_LowPriority,ey=nl.unstable_IdlePriority,qd=nl.log,Yd=nl.unstable_setDisableYieldValue,un=null,pl=null;function Yt(l){if(typeof qd=="function"&&Yd(l),pl&&typeof pl.setStrictMode=="function")try{pl.setStrictMode(un,l)}catch{}}var Cl=Math.clz32?Math.clz32:rd,Bd=Math.log,Rd=Math.LN2;function rd(l){return l>>>=0,l===0?32:31-(Bd(l)/Rd|0)|0}var pn=256,Cn=262144,qn=4194304;function uu(l){var t=l&42;if(t!==0)return t;switch(l&-l){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return l&261888;case 262144:case 524288:case 1048576:case 2097152:return l&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return l&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return l}}function Ge(l,t,u){var a=l.pendingLanes;if(a===0)return 0;var n=0,e=l.suspendedLanes,f=l.pingedLanes;l=l.warmLanes;var c=a&134217727;return c!==0?(a=c&~e,a!==0?n=uu(a):(f&=c,f!==0?n=uu(f):u||(u=c&~l,u!==0&&(n=uu(u))))):(c=a&~e,c!==0?n=uu(c):f!==0?n=uu(f):u||(u=a&~l,u!==0&&(n=uu(u)))),n===0?0:t!==0&&t!==n&&(t&e)===0&&(e=n&-n,u=t&-t,e>=u||e===32&&(u&4194048)!==0)?t:n}function an(l,t){return(l.pendingLanes&~(l.suspendedLanes&~l.pingedLanes)&t)===0}function Gd(l,t){switch(l){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function fy(){var l=qn;return qn<<=1,(qn&62914560)===0&&(qn=4194304),l}function Df(l){for(var t=[],u=0;31>u;u++)t.push(l);return t}function nn(l,t){l.pendingLanes|=t,t!==268435456&&(l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0)}function Qd(l,t,u,a,n,e){var f=l.pendingLanes;l.pendingLanes=u,l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0,l.expiredLanes&=u,l.entangledLanes&=u,l.errorRecoveryDisabledLanes&=u,l.shellSuspendCounter=0;var c=l.entanglements,i=l.expirationTimes,m=l.hiddenUpdates;for(u=f&~u;0"u")return null;try{return l.activeElement||l.body}catch{return l.body}}var Ld=/[\n"\\]/g;function jl(l){return l.replace(Ld,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function mc(l,t,u,a,n,e,f,c){l.name="",f!=null&&typeof f!="function"&&typeof f!="symbol"&&typeof f!="boolean"?l.type=f:l.removeAttribute("type"),t!=null?f==="number"?(t===0&&l.value===""||l.value!=t)&&(l.value=""+Gl(t)):l.value!==""+Gl(t)&&(l.value=""+Gl(t)):f!=="submit"&&f!=="reset"||l.removeAttribute("value"),t!=null?dc(l,f,Gl(t)):u!=null?dc(l,f,Gl(u)):a!=null&&l.removeAttribute("value"),n==null&&e!=null&&(l.defaultChecked=!!e),n!=null&&(l.checked=n&&typeof n!="function"&&typeof n!="symbol"),c!=null&&typeof c!="function"&&typeof c!="symbol"&&typeof c!="boolean"?l.name=""+Gl(c):l.removeAttribute("name")}function sy(l,t,u,a,n,e,f,c){if(e!=null&&typeof e!="function"&&typeof e!="symbol"&&typeof e!="boolean"&&(l.type=e),t!=null||u!=null){if(!(e!=="submit"&&e!=="reset"||t!=null)){vc(l);return}u=u!=null?""+Gl(u):"",t=t!=null?""+Gl(t):u,c||t===l.value||(l.value=t),l.defaultValue=t}a=a??n,a=typeof a!="function"&&typeof a!="symbol"&&!!a,l.checked=c?l.checked:!!a,l.defaultChecked=!!a,f!=null&&typeof f!="function"&&typeof f!="symbol"&&typeof f!="boolean"&&(l.name=f),vc(l)}function dc(l,t,u){t==="number"&&de(l.ownerDocument)===l||l.defaultValue===""+u||(l.defaultValue=""+u)}function xu(l,t,u,a){if(l=l.options,t){t={};for(var n=0;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),oc=!1;if(gt)try{Ou={},Object.defineProperty(Ou,"passive",{get:function(){oc=!0}}),window.addEventListener("test",Ou,Ou),window.removeEventListener("test",Ou,Ou)}catch{oc=!1}var Ou,Bt=null,ni=null,$n=null;function Ey(){if($n)return $n;var l,t=ni,u=t.length,a,n="value"in Bt?Bt.value:Bt.textContent,e=n.length;for(l=0;l=Ha),X0=" ",j0=!1;function Ay(l,t){switch(l){case"keyup":return bh.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function _y(l){return l=l.detail,typeof l=="object"&&"data"in l?l.data:null}var Yu=!1;function Eh(l,t){switch(l){case"compositionend":return _y(t);case"keypress":return t.which!==32?null:(j0=!0,X0);case"textInput":return l=t.data,l===X0&&j0?null:l;default:return null}}function Th(l,t){if(Yu)return l==="compositionend"||!fi&&Ay(l,t)?(l=Ey(),$n=ni=Bt=null,Yu=!1,l):null;switch(l){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:u,offset:t-l};l=a}l:{for(;u;){if(u.nextSibling){u=u.nextSibling;break l}u=u.parentNode}u=void 0}u=L0(u)}}function Uy(l,t){return l&&t?l===t?!0:l&&l.nodeType===3?!1:t&&t.nodeType===3?Uy(l,t.parentNode):"contains"in l?l.contains(t):l.compareDocumentPosition?!!(l.compareDocumentPosition(t)&16):!1:!1}function Hy(l){l=l!=null&&l.ownerDocument!=null&&l.ownerDocument.defaultView!=null?l.ownerDocument.defaultView:window;for(var t=de(l.document);t instanceof l.HTMLIFrameElement;){try{var u=typeof t.contentWindow.location.href=="string"}catch{u=!1}if(u)l=t.contentWindow;else break;t=de(l.document)}return t}function ci(l){var t=l&&l.nodeName&&l.nodeName.toLowerCase();return t&&(t==="input"&&(l.type==="text"||l.type==="search"||l.type==="tel"||l.type==="url"||l.type==="password")||t==="textarea"||l.contentEditable==="true")}var Nh=gt&&"documentMode"in document&&11>=document.documentMode,Bu=null,sc=null,pa=null,Sc=!1;function J0(l,t,u){var a=u.window===u?u.document:u.nodeType===9?u:u.ownerDocument;Sc||Bu==null||Bu!==de(a)||(a=Bu,"selectionStart"in a&&ci(a)?a={start:a.selectionStart,end:a.selectionEnd}:(a=(a.ownerDocument&&a.ownerDocument.defaultView||window).getSelection(),a={anchorNode:a.anchorNode,anchorOffset:a.anchorOffset,focusNode:a.focusNode,focusOffset:a.focusOffset}),pa&&La(pa,a)||(pa=a,a=pe(sc,"onSelect"),0>=f,n-=f,kl=1<<32-Cl(t)+n|u<D?(N=E,E=null):N=E.sibling;var Y=h(v,E,d[D],g);if(Y===null){E===null&&(E=N);break}l&&E&&Y.alternate===null&&t(v,E),y=e(Y,y,D),q===null?T=Y:q.sibling=Y,q=Y,E=N}if(D===d.length)return u(v,E),C&&yt(v,D),T;if(E===null){for(;DD?(N=E,E=null):N=E.sibling;var _t=h(v,E,Y.value,g);if(_t===null){E===null&&(E=N);break}l&&E&&_t.alternate===null&&t(v,E),y=e(_t,y,D),q===null?T=_t:q.sibling=_t,q=_t,E=N}if(Y.done)return u(v,E),C&&yt(v,D),T;if(E===null){for(;!Y.done;D++,Y=d.next())Y=S(v,Y.value,g),Y!==null&&(y=e(Y,y,D),q===null?T=Y:q.sibling=Y,q=Y);return C&&yt(v,D),T}for(E=a(E);!Y.done;D++,Y=d.next())Y=o(E,v,D,Y.value,g),Y!==null&&(l&&Y.alternate!==null&&E.delete(Y.key===null?D:Y.key),y=e(Y,y,D),q===null?T=Y:q.sibling=Y,q=Y);return l&&E.forEach(function(Jm){return t(v,Jm)}),C&&yt(v,D),T}function Q(v,y,d,g){if(typeof d=="object"&&d!==null&&d.type===Nu&&d.key===null&&(d=d.props.children),typeof d=="object"&&d!==null){switch(d.$$typeof){case Nn:l:{for(var T=d.key;y!==null;){if(y.key===T){if(T=d.type,T===Nu){if(y.tag===7){u(v,y.sibling),g=n(y,d.props.children),g.return=v,v=g;break l}}else if(y.elementType===T||typeof T=="object"&&T!==null&&T.$$typeof===Ut&&au(T)===y.type){u(v,y.sibling),g=n(y,d.props),ba(g,d),g.return=v,v=g;break l}u(v,y);break}else t(v,y);y=y.sibling}d.type===Nu?(g=fu(d.props.children,v.mode,g,d.key),g.return=v,v=g):(g=kn(d.type,d.key,d.props,null,v.mode,g),ba(g,d),g.return=v,v=g)}return f(v);case _a:l:{for(T=d.key;y!==null;){if(y.key===T)if(y.tag===4&&y.stateNode.containerInfo===d.containerInfo&&y.stateNode.implementation===d.implementation){u(v,y.sibling),g=n(y,d.children||[]),g.return=v,v=g;break l}else{u(v,y);break}else t(v,y);y=y.sibling}g=Bf(d,v.mode,g),g.return=v,v=g}return f(v);case Ut:return d=au(d),Q(v,y,d,g)}if(Oa(d))return z(v,y,d,g);if(Sa(d)){if(T=Sa(d),typeof T!="function")throw Error(b(150));return d=T.call(d),A(v,y,d,g)}if(typeof d.then=="function")return Q(v,y,Qn(d),g);if(d.$$typeof===mt)return Q(v,y,Gn(v,d),g);Xn(v,d)}return typeof d=="string"&&d!==""||typeof d=="number"||typeof d=="bigint"?(d=""+d,y!==null&&y.tag===6?(u(v,y.sibling),g=n(y,d),g.return=v,v=g):(u(v,y),g=Yf(d,v.mode,g),g.return=v,v=g),f(v)):u(v,y)}return function(v,y,d,g){try{wa=0;var T=Q(v,y,d,g);return Ku=null,T}catch(E){if(E===ia||E===Ve)throw E;var q=Ul(29,E,null,v.mode);return q.lanes=g,q.return=v,q}}}var du=Vy(!0),Ly=Vy(!1),Ht=!1;function Si(l){l.updateQueue={baseState:l.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function _c(l,t){l=l.updateQueue,t.updateQueue===l&&(t.updateQueue={baseState:l.baseState,firstBaseUpdate:l.firstBaseUpdate,lastBaseUpdate:l.lastBaseUpdate,shared:l.shared,callbacks:null})}function jt(l){return{lane:l,tag:0,payload:null,callback:null,next:null}}function Zt(l,t,u){var a=l.updateQueue;if(a===null)return null;if(a=a.shared,(B&2)!==0){var n=a.pending;return n===null?t.next=t:(t.next=n.next,n.next=t),a.pending=t,t=oe(l),Ry(l,null,u),t}return xe(l,a,t,u),oe(l)}function qa(l,t,u){if(t=t.updateQueue,t!==null&&(t=t.shared,(u&4194048)!==0)){var a=t.lanes;a&=l.pendingLanes,u|=a,t.lanes=u,iy(l,u)}}function rf(l,t){var u=l.updateQueue,a=l.alternate;if(a!==null&&(a=a.updateQueue,u===a)){var n=null,e=null;if(u=u.firstBaseUpdate,u!==null){do{var f={lane:u.lane,tag:u.tag,payload:u.payload,callback:null,next:null};e===null?n=e=f:e=e.next=f,u=u.next}while(u!==null);e===null?n=e=t:e=e.next=t}else n=e=t;u={baseState:a.baseState,firstBaseUpdate:n,lastBaseUpdate:e,shared:a.shared,callbacks:a.callbacks},l.updateQueue=u;return}l=u.lastBaseUpdate,l===null?u.firstBaseUpdate=t:l.next=t,u.lastBaseUpdate=t}var Oc=!1;function Ya(){if(Oc){var l=Lu;if(l!==null)throw l}}function Ba(l,t,u,a){Oc=!1;var n=l.updateQueue;Ht=!1;var e=n.firstBaseUpdate,f=n.lastBaseUpdate,c=n.shared.pending;if(c!==null){n.shared.pending=null;var i=c,m=i.next;i.next=null,f===null?e=m:f.next=m,f=i;var s=l.alternate;s!==null&&(s=s.updateQueue,c=s.lastBaseUpdate,c!==f&&(c===null?s.firstBaseUpdate=m:c.next=m,s.lastBaseUpdate=i))}if(e!==null){var S=n.baseState;f=0,s=m=i=null,c=e;do{var h=c.lane&-536870913,o=h!==c.lane;if(o?(p&h)===h:(a&h)===h){h!==0&&h===ku&&(Oc=!0),s!==null&&(s=s.next={lane:0,tag:c.tag,payload:c.payload,callback:null,next:null});l:{var z=l,A=c;h=t;var Q=u;switch(A.tag){case 1:if(z=A.payload,typeof z=="function"){S=z.call(Q,S,h);break l}S=z;break l;case 3:z.flags=z.flags&-65537|128;case 0:if(z=A.payload,h=typeof z=="function"?z.call(Q,S,h):z,h==null)break l;S=J({},S,h);break l;case 2:Ht=!0}}h=c.callback,h!==null&&(l.flags|=64,o&&(l.flags|=8192),o=n.callbacks,o===null?n.callbacks=[h]:o.push(h))}else o={lane:h,tag:c.tag,payload:c.payload,callback:c.callback,next:null},s===null?(m=s=o,i=S):s=s.next=o,f|=h;if(c=c.next,c===null){if(c=n.shared.pending,c===null)break;o=c,c=o.next,o.next=null,n.lastBaseUpdate=o,n.shared.pending=null}}while(!0);s===null&&(i=S),n.baseState=i,n.firstBaseUpdate=m,n.lastBaseUpdate=s,e===null&&(n.shared.lanes=0),Ft|=f,l.lanes=f,l.memoizedState=S}}function Ky(l,t){if(typeof l!="function")throw Error(b(191,l));l.call(t)}function Jy(l,t){var u=l.callbacks;if(u!==null)for(l.callbacks=null,l=0;le?e:8;var f=_.T,c={};_.T=c,Ni(l,!1,t,u);try{var i=n(),m=_.S;if(m!==null&&m(c,i),i!==null&&typeof i=="object"&&typeof i.then=="function"){var s=Qh(i,a);Ra(l,t,s,ql(l))}else Ra(l,t,a,ql(l))}catch(S){Ra(l,t,{then:function(){},status:"rejected",reason:S},ql())}finally{R.p=e,f!==null&&c.types!==null&&(f.types=c.types),_.T=f}}function Lh(){}function Nc(l,t,u,a){if(l.tag!==5)throw Error(b(476));var n=gv(l).queue;Sv(l,n,t,eu,u===null?Lh:function(){return bv(l),u(a)})}function gv(l){var t=l.memoizedState;if(t!==null)return t;t={memoizedState:eu,baseState:eu,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:zt,lastRenderedState:eu},next:null};var u={};return t.next={memoizedState:u,baseState:u,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:zt,lastRenderedState:u},next:null},l.memoizedState=t,l=l.alternate,l!==null&&(l.memoizedState=t),t}function bv(l){var t=gv(l);t.next===null&&(t=l.alternate.memoizedState),Ra(l,t.next.queue,{},ql())}function Hi(){return dl(Ia)}function zv(){return I().memoizedState}function Ev(){return I().memoizedState}function Kh(l){for(var t=l.return;t!==null;){switch(t.tag){case 24:case 3:var u=ql();l=jt(u);var a=Zt(t,l,u);a!==null&&(Al(a,t,u),qa(a,t,u)),t={cache:hi()},l.payload=t;return}t=t.return}}function Jh(l,t,u){var a=ql();u={lane:a,revertLane:0,gesture:null,action:u,hasEagerState:!1,eagerState:null,next:null},we(l)?Av(t,u):(u=yi(l,t,u,a),u!==null&&(Al(u,l,a),_v(u,t,a)))}function Tv(l,t,u){var a=ql();Ra(l,t,u,a)}function Ra(l,t,u,a){var n={lane:a,revertLane:0,gesture:null,action:u,hasEagerState:!1,eagerState:null,next:null};if(we(l))Av(t,n);else{var e=l.alternate;if(l.lanes===0&&(e===null||e.lanes===0)&&(e=t.lastRenderedReducer,e!==null))try{var f=t.lastRenderedState,c=e(f,u);if(n.hasEagerState=!0,n.eagerState=c,Yl(c,f))return xe(l,t,n,0),Z===null&&Ze(),!1}catch{}if(u=yi(l,t,n,a),u!==null)return Al(u,l,a),_v(u,t,a),!0}return!1}function Ni(l,t,u,a){if(a={lane:2,revertLane:Qi(),gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},we(l)){if(t)throw Error(b(479))}else t=yi(l,u,a,2),t!==null&&Al(t,l,2)}function we(l){var t=l.alternate;return l===M||t!==null&&t===M}function Av(l,t){Ju=Ee=!0;var u=l.pending;u===null?t.next=t:(t.next=u.next,u.next=t),l.pending=t}function _v(l,t,u){if((u&4194048)!==0){var a=t.lanes;a&=l.pendingLanes,u|=a,t.lanes=u,iy(l,u)}}var $a={readContext:dl,use:Ke,useCallback:$,useContext:$,useEffect:$,useImperativeHandle:$,useLayoutEffect:$,useInsertionEffect:$,useMemo:$,useReducer:$,useRef:$,useState:$,useDebugValue:$,useDeferredValue:$,useTransition:$,useSyncExternalStore:$,useId:$,useHostTransitionStatus:$,useFormState:$,useActionState:$,useOptimistic:$,useMemoCache:$,useCacheRefresh:$};$a.useEffectEvent=$;var Ov={readContext:dl,use:Ke,useCallback:function(l,t){return gl().memoizedState=[l,t===void 0?null:t],l},useContext:dl,useEffect:c1,useImperativeHandle:function(l,t,u){u=u!=null?u.concat([l]):null,le(4194308,4,mv.bind(null,t,l),u)},useLayoutEffect:function(l,t){return le(4194308,4,l,t)},useInsertionEffect:function(l,t){le(4,2,l,t)},useMemo:function(l,t){var u=gl();t=t===void 0?null:t;var a=l();if(hu){Yt(!0);try{l()}finally{Yt(!1)}}return u.memoizedState=[a,t],a},useReducer:function(l,t,u){var a=gl();if(u!==void 0){var n=u(t);if(hu){Yt(!0);try{u(t)}finally{Yt(!1)}}}else n=t;return a.memoizedState=a.baseState=n,l={pending:null,lanes:0,dispatch:null,lastRenderedReducer:l,lastRenderedState:n},a.queue=l,l=l.dispatch=Jh.bind(null,M,l),[a.memoizedState,l]},useRef:function(l){var t=gl();return l={current:l},t.memoizedState=l},useState:function(l){l=Uc(l);var t=l.queue,u=Tv.bind(null,M,t);return t.dispatch=u,[l.memoizedState,u]},useDebugValue:Di,useDeferredValue:function(l,t){var u=gl();return Ui(u,l,t)},useTransition:function(){var l=Uc(!1);return l=Sv.bind(null,M,l.queue,!0,!1),gl().memoizedState=l,[!1,l]},useSyncExternalStore:function(l,t,u){var a=M,n=gl();if(C){if(u===void 0)throw Error(b(407));u=u()}else{if(u=t(),Z===null)throw Error(b(349));(p&127)!==0||ky(a,t,u)}n.memoizedState=u;var e={value:u,getSnapshot:t};return n.queue=e,c1(Py.bind(null,a,e,l),[l]),a.flags|=2048,Pu(9,{destroy:void 0},Iy.bind(null,a,e,u,t),null),u},useId:function(){var l=gl(),t=Z.identifierPrefix;if(C){var u=Il,a=kl;u=(a&~(1<<32-Cl(a)-1)).toString(32)+u,t="_"+t+"R_"+u,u=Te++,0<\/script>",e=e.removeChild(e.firstChild);break;case"select":e=typeof a.is=="string"?f.createElement("select",{is:a.is}):f.createElement("select"),a.multiple?e.multiple=!0:a.size&&(e.size=a.size);break;default:e=typeof a.is=="string"?f.createElement(n,{is:a.is}):f.createElement(n)}}e[vl]=t,e[_l]=a;l:for(f=t.child;f!==null;){if(f.tag===5||f.tag===6)e.appendChild(f.stateNode);else if(f.tag!==4&&f.tag!==27&&f.child!==null){f.child.return=f,f=f.child;continue}if(f===t)break l;for(;f.sibling===null;){if(f.return===null||f.return===t)break l;f=f.return}f.sibling.return=f.return,f=f.sibling}t.stateNode=e;l:switch(hl(e,n,a),n){case"button":case"input":case"select":case"textarea":a=!!a.autoFocus;break l;case"img":a=!0;break l;default:a=!1}a&&et(t)}}return L(t),Lf(t,t.type,l===null?null:l.memoizedProps,t.pendingProps,u),null;case 6:if(l&&t.stateNode!=null)l.memoizedProps!==a&&et(t);else{if(typeof a!="string"&&t.stateNode===null)throw Error(b(166));if(l=Qt.current,Mu(t)){if(l=t.stateNode,u=t.memoizedProps,a=null,n=ml,n!==null)switch(n.tag){case 27:case 5:a=n.memoizedProps}l[vl]=t,l=!!(l.nodeValue===u||a!==null&&a.suppressHydrationWarning===!0||Tm(l.nodeValue,u)),l||Wt(t,!0)}else l=Ce(l).createTextNode(a),l[vl]=t,t.stateNode=l}return L(t),null;case 31:if(u=t.memoizedState,l===null||l.memoizedState!==null){if(a=Mu(t),u!==null){if(l===null){if(!a)throw Error(b(318));if(l=t.memoizedState,l=l!==null?l.dehydrated:null,!l)throw Error(b(557));l[vl]=t}else vu(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;L(t),l=!1}else u=Rf(),l!==null&&l.memoizedState!==null&&(l.memoizedState.hydrationErrors=u),l=!0;if(!l)return t.flags&256?(Dl(t),t):(Dl(t),null);if((t.flags&128)!==0)throw Error(b(558))}return L(t),null;case 13:if(a=t.memoizedState,l===null||l.memoizedState!==null&&l.memoizedState.dehydrated!==null){if(n=Mu(t),a!==null&&a.dehydrated!==null){if(l===null){if(!n)throw Error(b(318));if(n=t.memoizedState,n=n!==null?n.dehydrated:null,!n)throw Error(b(317));n[vl]=t}else vu(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;L(t),n=!1}else n=Rf(),l!==null&&l.memoizedState!==null&&(l.memoizedState.hydrationErrors=n),n=!0;if(!n)return t.flags&256?(Dl(t),t):(Dl(t),null)}return Dl(t),(t.flags&128)!==0?(t.lanes=u,t):(u=a!==null,l=l!==null&&l.memoizedState!==null,u&&(a=t.child,n=null,a.alternate!==null&&a.alternate.memoizedState!==null&&a.alternate.memoizedState.cachePool!==null&&(n=a.alternate.memoizedState.cachePool.pool),e=null,a.memoizedState!==null&&a.memoizedState.cachePool!==null&&(e=a.memoizedState.cachePool.pool),e!==n&&(a.flags|=2048)),u!==l&&u&&(t.child.flags|=8192),jn(t,t.updateQueue),L(t),null);case 4:return Wu(),l===null&&Xi(t.stateNode.containerInfo),L(t),null;case 10:return st(t.type),L(t),null;case 19:if(cl(k),a=t.memoizedState,a===null)return L(t),null;if(n=(t.flags&128)!==0,e=a.rendering,e===null)if(n)za(a,!1);else{if(F!==0||l!==null&&(l.flags&128)!==0)for(l=t.child;l!==null;){if(e=ze(l),e!==null){for(t.flags|=128,za(a,!1),l=e.updateQueue,t.updateQueue=l,jn(t,l),t.subtreeFlags=0,l=u,u=t.child;u!==null;)ry(u,l),u=u.sibling;return x(k,k.current&1|2),C&&yt(t,a.treeForkCount),t.child}l=l.sibling}a.tail!==null&&Nl()>Me&&(t.flags|=128,n=!0,za(a,!1),t.lanes=4194304)}else{if(!n)if(l=ze(e),l!==null){if(t.flags|=128,n=!0,l=l.updateQueue,t.updateQueue=l,jn(t,l),za(a,!0),a.tail===null&&a.tailMode==="hidden"&&!e.alternate&&!C)return L(t),null}else 2*Nl()-a.renderingStartTime>Me&&u!==536870912&&(t.flags|=128,n=!0,za(a,!1),t.lanes=4194304);a.isBackwards?(e.sibling=t.child,t.child=e):(l=a.last,l!==null?l.sibling=e:t.child=e,a.last=e)}return a.tail!==null?(l=a.tail,a.rendering=l,a.tail=l.sibling,a.renderingStartTime=Nl(),l.sibling=null,u=k.current,x(k,n?u&1|2:u&1),C&&yt(t,a.treeForkCount),l):(L(t),null);case 22:case 23:return Dl(t),gi(),a=t.memoizedState!==null,l!==null?l.memoizedState!==null!==a&&(t.flags|=8192):a&&(t.flags|=8192),a?(u&536870912)!==0&&(t.flags&128)===0&&(L(t),t.subtreeFlags&6&&(t.flags|=8192)):L(t),u=t.updateQueue,u!==null&&jn(t,u.retryQueue),u=null,l!==null&&l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(u=l.memoizedState.cachePool.pool),a=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(a=t.memoizedState.cachePool.pool),a!==u&&(t.flags|=2048),l!==null&&cl(cu),null;case 24:return u=null,l!==null&&(u=l.memoizedState.cache),t.memoizedState.cache!==u&&(t.flags|=2048),st(tl),L(t),null;case 25:return null;case 30:return null}throw Error(b(156,t.tag))}function kh(l,t){switch(di(t),t.tag){case 1:return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 3:return st(tl),Wu(),l=t.flags,(l&65536)!==0&&(l&128)===0?(t.flags=l&-65537|128,t):null;case 26:case 27:case 5:return ve(t),null;case 31:if(t.memoizedState!==null){if(Dl(t),t.alternate===null)throw Error(b(340));vu()}return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 13:if(Dl(t),l=t.memoizedState,l!==null&&l.dehydrated!==null){if(t.alternate===null)throw Error(b(340));vu()}return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 19:return cl(k),null;case 4:return Wu(),null;case 10:return st(t.type),null;case 22:case 23:return Dl(t),gi(),l!==null&&cl(cu),l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 24:return st(tl),null;case 25:return null;default:return null}}function rv(l,t){switch(di(t),t.tag){case 3:st(tl),Wu();break;case 26:case 27:case 5:ve(t);break;case 4:Wu();break;case 31:t.memoizedState!==null&&Dl(t);break;case 13:Dl(t);break;case 19:cl(k);break;case 10:st(t.type);break;case 22:case 23:Dl(t),gi(),l!==null&&cl(cu);break;case 24:st(tl)}}function vn(l,t){try{var u=t.updateQueue,a=u!==null?u.lastEffect:null;if(a!==null){var n=a.next;u=n;do{if((u.tag&l)===l){a=void 0;var e=u.create,f=u.inst;a=e(),f.destroy=a}u=u.next}while(u!==n)}}catch(c){G(t,t.return,c)}}function $t(l,t,u){try{var a=t.updateQueue,n=a!==null?a.lastEffect:null;if(n!==null){var e=n.next;a=e;do{if((a.tag&l)===l){var f=a.inst,c=f.destroy;if(c!==void 0){f.destroy=void 0,n=t;var i=u,m=c;try{m()}catch(s){G(n,i,s)}}}a=a.next}while(a!==e)}}catch(s){G(t,t.return,s)}}function Gv(l){var t=l.updateQueue;if(t!==null){var u=l.stateNode;try{Jy(t,u)}catch(a){G(l,l.return,a)}}}function Qv(l,t,u){u.props=ou(l.type,l.memoizedProps),u.state=l.memoizedState;try{u.componentWillUnmount()}catch(a){G(l,t,a)}}function ra(l,t){try{var u=l.ref;if(u!==null){switch(l.tag){case 26:case 27:case 5:var a=l.stateNode;break;case 30:a=l.stateNode;break;default:a=l.stateNode}typeof u=="function"?l.refCleanup=u(a):u.current=a}}catch(n){G(l,t,n)}}function Pl(l,t){var u=l.ref,a=l.refCleanup;if(u!==null)if(typeof a=="function")try{a()}catch(n){G(l,t,n)}finally{l.refCleanup=null,l=l.alternate,l!=null&&(l.refCleanup=null)}else if(typeof u=="function")try{u(null)}catch(n){G(l,t,n)}else u.current=null}function Xv(l){var t=l.type,u=l.memoizedProps,a=l.stateNode;try{l:switch(t){case"button":case"input":case"select":case"textarea":u.autoFocus&&a.focus();break l;case"img":u.src?a.src=u.src:u.srcSet&&(a.srcset=u.srcSet)}}catch(n){G(l,l.return,n)}}function Kf(l,t,u){try{var a=l.stateNode;bo(a,l.type,u,t),a[_l]=t}catch(n){G(l,l.return,n)}}function jv(l){return l.tag===5||l.tag===3||l.tag===26||l.tag===27&&It(l.type)||l.tag===4}function Jf(l){l:for(;;){for(;l.sibling===null;){if(l.return===null||jv(l.return))return null;l=l.return}for(l.sibling.return=l.return,l=l.sibling;l.tag!==5&&l.tag!==6&&l.tag!==18;){if(l.tag===27&&It(l.type)||l.flags&2||l.child===null||l.tag===4)continue l;l.child.return=l,l=l.child}if(!(l.flags&2))return l.stateNode}}function Bc(l,t,u){var a=l.tag;if(a===5||a===6)l=l.stateNode,t?(u.nodeType===9?u.body:u.nodeName==="HTML"?u.ownerDocument.body:u).insertBefore(l,t):(t=u.nodeType===9?u.body:u.nodeName==="HTML"?u.ownerDocument.body:u,t.appendChild(l),u=u._reactRootContainer,u!=null||t.onclick!==null||(t.onclick=dt));else if(a!==4&&(a===27&&It(l.type)&&(u=l.stateNode,t=null),l=l.child,l!==null))for(Bc(l,t,u),l=l.sibling;l!==null;)Bc(l,t,u),l=l.sibling}function Oe(l,t,u){var a=l.tag;if(a===5||a===6)l=l.stateNode,t?u.insertBefore(l,t):u.appendChild(l);else if(a!==4&&(a===27&&It(l.type)&&(u=l.stateNode),l=l.child,l!==null))for(Oe(l,t,u),l=l.sibling;l!==null;)Oe(l,t,u),l=l.sibling}function Zv(l){var t=l.stateNode,u=l.memoizedProps;try{for(var a=l.type,n=t.attributes;n.length;)t.removeAttributeNode(n[0]);hl(t,a,u),t[vl]=l,t[_l]=u}catch(e){G(l,l.return,e)}}var vt=!1,ll=!1,wf=!1,E1=typeof WeakSet=="function"?WeakSet:Set,el=null;function Ih(l,t){if(l=l.containerInfo,Zc=Re,l=Hy(l),ci(l)){if("selectionStart"in l)var u={start:l.selectionStart,end:l.selectionEnd};else l:{u=(u=l.ownerDocument)&&u.defaultView||window;var a=u.getSelection&&u.getSelection();if(a&&a.rangeCount!==0){u=a.anchorNode;var n=a.anchorOffset,e=a.focusNode;a=a.focusOffset;try{u.nodeType,e.nodeType}catch{u=null;break l}var f=0,c=-1,i=-1,m=0,s=0,S=l,h=null;t:for(;;){for(var o;S!==u||n!==0&&S.nodeType!==3||(c=f+n),S!==e||a!==0&&S.nodeType!==3||(i=f+a),S.nodeType===3&&(f+=S.nodeValue.length),(o=S.firstChild)!==null;)h=S,S=o;for(;;){if(S===l)break t;if(h===u&&++m===n&&(c=f),h===e&&++s===a&&(i=f),(o=S.nextSibling)!==null)break;S=h,h=S.parentNode}S=o}u=c===-1||i===-1?null:{start:c,end:i}}else u=null}u=u||{start:0,end:0}}else u=null;for(xc={focusedElem:l,selectionRange:u},Re=!1,el=t;el!==null;)if(t=el,l=t.child,(t.subtreeFlags&1028)!==0&&l!==null)l.return=t,el=l;else for(;el!==null;){switch(t=el,e=t.alternate,l=t.flags,t.tag){case 0:if((l&4)!==0&&(l=t.updateQueue,l=l!==null?l.events:null,l!==null))for(u=0;u title"))),hl(e,a,u),e[vl]=l,fl(e),a=e;break l;case"link":var f=x1("link","href",n).get(a+(u.href||""));if(f){for(var c=0;cQ&&(f=Q,Q=A,A=f);var v=K0(c,A),y=K0(c,Q);if(v&&y&&(o.rangeCount!==1||o.anchorNode!==v.node||o.anchorOffset!==v.offset||o.focusNode!==y.node||o.focusOffset!==y.offset)){var d=S.createRange();d.setStart(v.node,v.offset),o.removeAllRanges(),A>Q?(o.addRange(d),o.extend(y.node,y.offset)):(d.setEnd(y.node,y.offset),o.addRange(d))}}}}for(S=[],o=c;o=o.parentNode;)o.nodeType===1&&S.push({element:o,left:o.scrollLeft,top:o.scrollTop});for(typeof c.focus=="function"&&c.focus(),c=0;cu?32:u,_.T=null,u=Gc,Gc=null;var e=Vt,f=St;if(al=0,ta=Vt=null,St=0,(B&6)!==0)throw Error(b(331));var c=B;if(B|=4,Iv(e.current),$v(e,e.current,f,u),B=c,mn(0,!1),pl&&typeof pl.onPostCommitFiberRoot=="function")try{pl.onPostCommitFiberRoot(un,e)}catch{}return!0}finally{R.p=n,_.T=a,hm(l,t)}}function O1(l,t,u){t=Zl(u,t),t=Cc(l.stateNode,t,2),l=Zt(l,t,2),l!==null&&(nn(l,2),ut(l))}function G(l,t,u){if(l.tag===3)O1(l,l,u);else for(;t!==null;){if(t.tag===3){O1(t,l,u);break}else if(t.tag===1){var a=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof a.componentDidCatch=="function"&&(xt===null||!xt.has(a))){l=Zl(u,l),u=Nv(2),a=Zt(t,u,2),a!==null&&(pv(u,a,t,l),nn(a,2),ut(a));break}}t=t.return}}function $f(l,t,u){var a=l.pingCache;if(a===null){a=l.pingCache=new to;var n=new Set;a.set(t,n)}else n=a.get(t),n===void 0&&(n=new Set,a.set(t,n));n.has(u)||(Ri=!0,n.add(u),l=fo.bind(null,l,t,u),t.then(l,l))}function fo(l,t,u){var a=l.pingCache;a!==null&&a.delete(t),l.pingedLanes|=l.suspendedLanes&u,l.warmLanes&=~u,Z===l&&(p&u)===u&&(F===4||F===3&&(p&62914560)===p&&300>Nl()-We?(B&2)===0&&ua(l,0):ri|=u,la===p&&(la=0)),ut(l)}function sm(l,t){t===0&&(t=fy()),l=bu(l,t),l!==null&&(nn(l,t),ut(l))}function co(l){var t=l.memoizedState,u=0;t!==null&&(u=t.retryLane),sm(l,u)}function io(l,t){var u=0;switch(l.tag){case 31:case 13:var a=l.stateNode,n=l.memoizedState;n!==null&&(u=n.retryLane);break;case 19:a=l.stateNode;break;case 22:a=l.stateNode._retryCache;break;default:throw Error(b(314))}a!==null&&a.delete(t),sm(l,u)}function yo(l,t){return Ic(l,t)}var He=null,Hu=null,Xc=!1,Ne=!1,Ff=!1,Gt=0;function ut(l){l!==Hu&&l.next===null&&(Hu===null?He=Hu=l:Hu=Hu.next=l),Ne=!0,Xc||(Xc=!0,mo())}function mn(l,t){if(!Ff&&Ne){Ff=!0;do for(var u=!1,a=He;a!==null;){if(!t)if(l!==0){var n=a.pendingLanes;if(n===0)var e=0;else{var f=a.suspendedLanes,c=a.pingedLanes;e=(1<<31-Cl(42|l)+1)-1,e&=n&~(f&~c),e=e&201326741?e&201326741|1:e?e|2:0}e!==0&&(u=!0,M1(a,e))}else e=p,e=Ge(a,a===Z?e:0,a.cancelPendingCommit!==null||a.timeoutHandle!==-1),(e&3)===0||an(a,e)||(u=!0,M1(a,e));a=a.next}while(u);Ff=!1}}function vo(){Sm()}function Sm(){Ne=Xc=!1;var l=0;Gt!==0&&Eo()&&(l=Gt);for(var t=Nl(),u=null,a=He;a!==null;){var n=a.next,e=gm(a,t);e===0?(a.next=null,u===null?He=n:u.next=n,n===null&&(Hu=u)):(u=a,(l!==0||(e&3)!==0)&&(Ne=!0)),a=n}al!==0&&al!==5||mn(l,!1),Gt!==0&&(Gt=0)}function gm(l,t){for(var u=l.suspendedLanes,a=l.pingedLanes,n=l.expirationTimes,e=l.pendingLanes&-62914561;0c)break;var s=i.transferSize,S=i.initiatorType;s&&q1(S)&&(i=i.responseEnd,f+=s*(i"u"?null:document;function Dm(l,t,u){var a=va;if(a&&typeof t=="string"&&t){var n=jl(t);n='link[rel="'+l+'"][href="'+n+'"]',typeof u=="string"&&(n+='[crossorigin="'+u+'"]'),X1.has(n)||(X1.add(n),l={rel:l,crossOrigin:u,href:t},a.querySelector(n)===null&&(t=a.createElement("link"),hl(t,"link",l),fl(t),a.head.appendChild(t)))}}function No(l){At.D(l),Dm("dns-prefetch",l,null)}function po(l,t){At.C(l,t),Dm("preconnect",l,t)}function Co(l,t,u){At.L(l,t,u);var a=va;if(a&&l&&t){var n='link[rel="preload"][as="'+jl(t)+'"]';t==="image"&&u&&u.imageSrcSet?(n+='[imagesrcset="'+jl(u.imageSrcSet)+'"]',typeof u.imageSizes=="string"&&(n+='[imagesizes="'+jl(u.imageSizes)+'"]')):n+='[href="'+jl(l)+'"]';var e=n;switch(t){case"style":e=aa(l);break;case"script":e=ma(l)}Kl.has(e)||(l=J({rel:"preload",href:t==="image"&&u&&u.imageSrcSet?void 0:l,as:t},u),Kl.set(e,l),a.querySelector(n)!==null||t==="style"&&a.querySelector(dn(e))||t==="script"&&a.querySelector(hn(e))||(t=a.createElement("link"),hl(t,"link",l),fl(t),a.head.appendChild(t)))}}function qo(l,t){At.m(l,t);var u=va;if(u&&l){var a=t&&typeof t.as=="string"?t.as:"script",n='link[rel="modulepreload"][as="'+jl(a)+'"][href="'+jl(l)+'"]',e=n;switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":e=ma(l)}if(!Kl.has(e)&&(l=J({rel:"modulepreload",href:l},t),Kl.set(e,l),u.querySelector(n)===null)){switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(u.querySelector(hn(e)))return}a=u.createElement("link"),hl(a,"link",l),fl(a),u.head.appendChild(a)}}}function Yo(l,t,u){At.S(l,t,u);var a=va;if(a&&l){var n=Zu(a).hoistableStyles,e=aa(l);t=t||"default";var f=n.get(e);if(!f){var c={loading:0,preload:null};if(f=a.querySelector(dn(e)))c.loading=5;else{l=J({rel:"stylesheet",href:l,"data-precedence":t},u),(u=Kl.get(e))&&ji(l,u);var i=f=a.createElement("link");fl(i),hl(i,"link",l),i._p=new Promise(function(m,s){i.onload=m,i.onerror=s}),i.addEventListener("load",function(){c.loading|=1}),i.addEventListener("error",function(){c.loading|=2}),c.loading|=4,ne(f,t,a)}f={type:"stylesheet",instance:f,count:1,state:c},n.set(e,f)}}}function Bo(l,t){At.X(l,t);var u=va;if(u&&l){var a=Zu(u).hoistableScripts,n=ma(l),e=a.get(n);e||(e=u.querySelector(hn(n)),e||(l=J({src:l,async:!0},t),(t=Kl.get(n))&&Zi(l,t),e=u.createElement("script"),fl(e),hl(e,"link",l),u.head.appendChild(e)),e={type:"script",instance:e,count:1,state:null},a.set(n,e))}}function Ro(l,t){At.M(l,t);var u=va;if(u&&l){var a=Zu(u).hoistableScripts,n=ma(l),e=a.get(n);e||(e=u.querySelector(hn(n)),e||(l=J({src:l,async:!0,type:"module"},t),(t=Kl.get(n))&&Zi(l,t),e=u.createElement("script"),fl(e),hl(e,"link",l),u.head.appendChild(e)),e={type:"script",instance:e,count:1,state:null},a.set(n,e))}}function j1(l,t,u,a){var n=(n=Qt.current)?qe(n):null;if(!n)throw Error(b(446));switch(l){case"meta":case"title":return null;case"style":return typeof u.precedence=="string"&&typeof u.href=="string"?(t=aa(u.href),u=Zu(n).hoistableStyles,a=u.get(t),a||(a={type:"style",instance:null,count:0,state:null},u.set(t,a)),a):{type:"void",instance:null,count:0,state:null};case"link":if(u.rel==="stylesheet"&&typeof u.href=="string"&&typeof u.precedence=="string"){l=aa(u.href);var e=Zu(n).hoistableStyles,f=e.get(l);if(f||(n=n.ownerDocument||n,f={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},e.set(l,f),(e=n.querySelector(dn(l)))&&!e._p&&(f.instance=e,f.state.loading=5),Kl.has(l)||(u={rel:"preload",as:"style",href:u.href,crossOrigin:u.crossOrigin,integrity:u.integrity,media:u.media,hrefLang:u.hrefLang,referrerPolicy:u.referrerPolicy},Kl.set(l,u),e||ro(n,l,u,f.state))),t&&a===null)throw Error(b(528,""));return f}if(t&&a!==null)throw Error(b(529,""));return null;case"script":return t=u.async,u=u.src,typeof u=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=ma(u),u=Zu(n).hoistableScripts,a=u.get(t),a||(a={type:"script",instance:null,count:0,state:null},u.set(t,a)),a):{type:"void",instance:null,count:0,state:null};default:throw Error(b(444,l))}}function aa(l){return'href="'+jl(l)+'"'}function dn(l){return'link[rel="stylesheet"]['+l+"]"}function Um(l){return J({},l,{"data-precedence":l.precedence,precedence:null})}function ro(l,t,u,a){l.querySelector('link[rel="preload"][as="style"]['+t+"]")?a.loading=1:(t=l.createElement("link"),a.preload=t,t.addEventListener("load",function(){return a.loading|=1}),t.addEventListener("error",function(){return a.loading|=2}),hl(t,"link",u),fl(t),l.head.appendChild(t))}function ma(l){return'[src="'+jl(l)+'"]'}function hn(l){return"script[async]"+l}function Z1(l,t,u){if(t.count++,t.instance===null)switch(t.type){case"style":var a=l.querySelector('style[data-href~="'+jl(u.href)+'"]');if(a)return t.instance=a,fl(a),a;var n=J({},u,{"data-href":u.href,"data-precedence":u.precedence,href:null,precedence:null});return a=(l.ownerDocument||l).createElement("style"),fl(a),hl(a,"style",n),ne(a,u.precedence,l),t.instance=a;case"stylesheet":n=aa(u.href);var e=l.querySelector(dn(n));if(e)return t.state.loading|=4,t.instance=e,fl(e),e;a=Um(u),(n=Kl.get(n))&&ji(a,n),e=(l.ownerDocument||l).createElement("link"),fl(e);var f=e;return f._p=new Promise(function(c,i){f.onload=c,f.onerror=i}),hl(e,"link",a),t.state.loading|=4,ne(e,u.precedence,l),t.instance=e;case"script":return e=ma(u.src),(n=l.querySelector(hn(e)))?(t.instance=n,fl(n),n):(a=u,(n=Kl.get(e))&&(a=J({},u),Zi(a,n)),l=l.ownerDocument||l,n=l.createElement("script"),fl(n),hl(n,"link",a),l.head.appendChild(n),t.instance=n);case"void":return null;default:throw Error(b(443,t.type))}else t.type==="stylesheet"&&(t.state.loading&4)===0&&(a=t.instance,t.state.loading|=4,ne(a,u.precedence,l));return t.instance}function ne(l,t,u){for(var a=u.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),n=a.length?a[a.length-1]:null,e=n,f=0;f title"):null)}function Go(l,t,u){if(u===1||t.itemProp!=null)return!1;switch(l){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;return t.rel==="stylesheet"?(l=t.disabled,typeof t.precedence=="string"&&l==null):!0;case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function Hm(l){return!(l.type==="stylesheet"&&(l.state.loading&3)===0)}function Qo(l,t,u,a){if(u.type==="stylesheet"&&(typeof a.media!="string"||matchMedia(a.media).matches!==!1)&&(u.state.loading&4)===0){if(u.instance===null){var n=aa(a.href),e=t.querySelector(dn(n));if(e){t=e._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(l.count++,l=Ye.bind(l),t.then(l,l)),u.state.loading|=4,u.instance=e,fl(e);return}e=t.ownerDocument||t,a=Um(a),(n=Kl.get(n))&&ji(a,n),e=e.createElement("link"),fl(e);var f=e;f._p=new Promise(function(c,i){f.onload=c,f.onerror=i}),hl(e,"link",a),u.instance=e}l.stylesheets===null&&(l.stylesheets=new Map),l.stylesheets.set(u,t),(t=u.state.preload)&&(u.state.loading&3)===0&&(l.count++,u=Ye.bind(l),t.addEventListener("load",u),t.addEventListener("error",u))}}var lc=0;function Xo(l,t){return l.stylesheets&&l.count===0&&fe(l,l.stylesheets),0lc?50:800)+t);return l.unsuspend=u,function(){l.unsuspend=null,clearTimeout(a),clearTimeout(n)}}:null}function Ye(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)fe(this,this.stylesheets);else if(this.unsuspend){var l=this.unsuspend;this.unsuspend=null,l()}}}var Be=null;function fe(l,t){l.stylesheets=null,l.unsuspend!==null&&(l.count++,Be=new Map,t.forEach(jo,l),Be=null,Ye.call(l))}function jo(l,t){if(!(t.state.loading&4)){var u=Be.get(l);if(u)var a=u.get(null);else{u=new Map,Be.set(l,u);for(var n=l.querySelectorAll("link[data-precedence],style[data-precedence]"),e=0;e{"use strict";function Gm(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(Gm)}catch(l){console.error(l)}}Gm(),Qm.exports=rm()});var Zm=$l(lf=>{"use strict";var Wo=Symbol.for("react.transitional.element"),$o=Symbol.for("react.fragment");function jm(l,t,u){var a=null;if(u!==void 0&&(a=""+u),t.key!==void 0&&(a=""+t.key),"key"in t){u={};for(var n in t)n!=="key"&&(u[n]=t[n])}else u=t;return t=u.ref,{$$typeof:Wo,type:l,key:a,ref:t!==void 0?t:null,props:u}}lf.Fragment=$o;lf.jsx=jm;lf.jsxs=jm});var Vm=$l((d2,xm)=>{"use strict";xm.exports=Zm()});var Km=gn(Xm());var on=gn(Vm());zf();function Lm(l){return function(u,a,n){if(typeof u=="string"){if(u==="style"){var e=a&&a.dangerouslySetInnerHTML?a.dangerouslySetInnerHTML.__html||"":On(a&&a.children);return _n(e),null}var f=An[u];if(f){var c=Mn(a);if(c.events){var i=c.cleanProps;return i.ref=Dn(c.events,i.ref),l(f,i,n)}return l(f,a,n)}}return l(u,a,n)}}var Pt=Lm(on.jsx),tf=Lm(on.jsxs);Un();import{CoreApiClient as Fo}from"twenty-client-sdk/core";var ko=()=>{let[l,t]=(0,il.useState)([]),[u,a]=(0,il.useState)(!0),[n,e]=(0,il.useState)(null);return(0,il.useEffect)(()=>{let f=!1;return(async()=>{try{a(!0),e(null);let m=await new Fo().query({companies:{edges:{node:{id:!0,name:!0}}}});f||t(m.companies.edges.map(s=>s.node))}catch(i){f||e(i instanceof Error?i.message:"Failed to load companies")}finally{f||a(!1)}})(),()=>{f=!0}},[]),tf("div",{style:{padding:24,display:"flex",flexDirection:"column",gap:16,fontFamily:"system-ui, sans-serif",background:"#f0f9ff",borderRadius:12,border:"2px solid #38bdf8",maxWidth:400},children:[Pt("h2",{style:{color:"#0369a1",fontWeight:700,fontSize:18,margin:0},children:"List Companies"}),Pt("p",{style:{fontSize:12,fontWeight:600,color:"#64748b",textTransform:"uppercase",letterSpacing:1},children:"Queried via CoreApiClient"}),u&&Pt("p",{style:{color:"#0c4a6e"},children:"Loading companies\u2026"}),n&&tf("p",{style:{color:"#b91c1c",wordBreak:"break-all"},children:["Error: ",n]}),!u&&!n&&tf("ul",{style:{margin:0,paddingLeft:20,color:"#0c4a6e",fontWeight:600},children:[l.length===0&&Pt("li",{children:"No companies found"}),l.map(f=>Pt("li",{children:f.name},f.id))]})]})};function Io(l){(0,Km.createRoot)(l).render(Pt(ko,{}))}export{Io as default}; +/*! Bundled license information: + +scheduler/cjs/scheduler.production.js: + (** + * @license React + * scheduler.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + *) + +react/cjs/react.production.js: + (** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + *) + +react-dom/cjs/react-dom.production.js: + (** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + *) + +react-dom/cjs/react-dom-client.production.js: + (** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + *) + +react/cjs/react-jsx-runtime.production.js: + (** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + *) +*/ diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/constants/seed-project/list-companies/index.tsx b/packages/twenty-server/src/engine/metadata-modules/front-component/constants/seed-project/list-companies/index.tsx new file mode 100644 index 0000000000..f166a08257 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/constants/seed-project/list-companies/index.tsx @@ -0,0 +1,127 @@ +import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core'; +import { defineFrontComponent } from 'twenty-sdk/define'; +import { useEffect, useState } from 'react'; + +type CompanySummary = Pick; + +const ListCompanies = () => { + const [companies, setCompanies] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + const loadCompanies = async () => { + try { + setLoading(true); + setError(null); + + const client = new CoreApiClient(); + const result = await client.query({ + companies: { + edges: { + node: { + id: true, + name: true, + }, + }, + }, + }); + + if (!cancelled) { + setCompanies(result.companies.edges.map((edge) => edge.node)); + } + } catch (caughtError) { + if (!cancelled) { + setError( + caughtError instanceof Error + ? caughtError.message + : 'Failed to load companies', + ); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + loadCompanies(); + + return () => { + cancelled = true; + }; + }, []); + + return ( +
+

+ List Companies +

+ +

+ Queried via CoreApiClient +

+ + {loading &&

Loading companies…

} + + {error && ( +

+ Error: {error} +

+ )} + + {!loading && !error && ( +
    + {companies.length === 0 &&
  • No companies found
  • } + {companies.map((company) => ( +
  • {company.name}
  • + ))} +
+ )} +
+ ); +}; + +export default defineFrontComponent({ + universalIdentifier: 'seed-front-component-list-companies', + name: 'List Companies', + description: + 'A sample visual front component that queries companies through the SDK client', + component: ListCompanies, +}); diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-front-component-definitions.util.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-front-component-definitions.util.ts index 4224dce09d..33e1cc6029 100644 --- a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-front-component-definitions.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-front-component-definitions.util.ts @@ -36,12 +36,16 @@ export const getSeedFrontComponentIds = (workspaceId: string) => ({ `${workspaceId}:seed-front-component:show-notification`, SEED_FRONT_COMPONENT_ID_NAMESPACE, ), + listCompaniesId: uuidv5( + `${workspaceId}:seed-front-component:list-companies`, + SEED_FRONT_COMPONENT_ID_NAMESPACE, + ), }); export const getSeedFrontComponentDefinitions = ( workspaceId: string, ): SeedFrontComponentDefinition[] => { - const { helloWorldId, showNotificationId } = + const { helloWorldId, showNotificationId, listCompaniesId } = getSeedFrontComponentIds(workspaceId); return [ @@ -73,13 +77,27 @@ export const getSeedFrontComponentDefinitions = ( usesSdkClient: false, seedProjectSubdir: 'show-notification', }, + { + id: listCompaniesId, + universalIdentifier: uuidv5( + `${workspaceId}:seed-front-component-uid:list-companies`, + SEED_FRONT_COMPONENT_ID_NAMESPACE, + ), + name: 'List Companies', + description: + 'A sample visual front component that queries companies through the SDK client', + componentName: 'ListCompanies', + isHeadless: false, + usesSdkClient: true, + seedProjectSubdir: 'list-companies', + }, ]; }; export const getSeedFrontComponentCommandMenuItemDefinitions = ( workspaceId: string, ): SeedFrontComponentCommandMenuItemDefinition[] => { - const { helloWorldId, showNotificationId } = + const { helloWorldId, showNotificationId, listCompaniesId } = getSeedFrontComponentIds(workspaceId); return [ @@ -118,5 +136,15 @@ export const getSeedFrontComponentCommandMenuItemDefinitions = ( PAGE_LAYOUT_SEEDS.DOCUMENTATION_STANDALONE_PAGE, ), }, + { + universalIdentifier: uuidv5( + `${workspaceId}:seed-front-component-command:list-companies`, + SEED_FRONT_COMPONENT_ID_NAMESPACE, + ), + frontComponentId: listCompaniesId, + label: 'List Companies', + icon: 'IconBuildingSkyscraper', + position: 203, + }, ]; }; diff --git a/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/constants/CommandMenuItemConfirmationModalResultBrowserEventName.ts b/packages/twenty-shared/src/constants/CommandMenuConfirmationModalResultBrowserEventName.ts similarity index 100% rename from packages/twenty-front/src/modules/command-menu-item/confirmation-modal/constants/CommandMenuItemConfirmationModalResultBrowserEventName.ts rename to packages/twenty-shared/src/constants/CommandMenuConfirmationModalResultBrowserEventName.ts diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts index e1c1359b0a..f026c6f730 100644 --- a/packages/twenty-shared/src/constants/index.ts +++ b/packages/twenty-shared/src/constants/index.ts @@ -14,6 +14,7 @@ export { AUTO_SELECT_FAST_MODEL_ID } from './AutoSelectFastModelId'; export { AUTO_SELECT_SMART_MODEL_ID } from './AutoSelectSmartModelId'; export { BACKEND_BATCH_REQUEST_MAX_COUNT } from './BackendBatchRequestMaxCount'; export { CalendarStartDay } from './CalendarStartDay'; +export { COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME } from './CommandMenuConfirmationModalResultBrowserEventName'; export { COMPOSITE_FIELD_TYPE_SUB_FIELDS_NAMES } from './CompositeFieldTypeSubFieldsNames'; export { CurrencyCode } from './CurrencyCode'; export { CURRENCY_CODE_LABELS } from './CurrencyCodeLabels'; diff --git a/packages/twenty-shared/src/types/CommandMenuConfirmationModalResult.ts b/packages/twenty-shared/src/types/CommandMenuConfirmationModalResult.ts new file mode 100644 index 0000000000..45a0f8ad2d --- /dev/null +++ b/packages/twenty-shared/src/types/CommandMenuConfirmationModalResult.ts @@ -0,0 +1 @@ +export type CommandMenuConfirmationModalResult = 'confirm' | 'cancel'; diff --git a/packages/twenty-shared/src/types/CommandMenuConfirmationModalResultBrowserEventDetail.ts b/packages/twenty-shared/src/types/CommandMenuConfirmationModalResultBrowserEventDetail.ts new file mode 100644 index 0000000000..82d9ffc14c --- /dev/null +++ b/packages/twenty-shared/src/types/CommandMenuConfirmationModalResultBrowserEventDetail.ts @@ -0,0 +1,7 @@ +import { type CommandMenuConfirmationModalResult } from './CommandMenuConfirmationModalResult'; +import { type ConfirmationModalCaller } from './ConfirmationModalCaller'; + +export type CommandMenuConfirmationModalResultBrowserEventDetail = { + caller: ConfirmationModalCaller; + confirmationResult: CommandMenuConfirmationModalResult; +}; diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index 7fa8a4a438..f007e0ecea 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -19,6 +19,8 @@ export { CalendarChannelContactAutoCreationPolicy } from './CalendarChannelConta export { CalendarChannelSyncStage } from './CalendarChannelSyncStage'; export { CalendarChannelSyncStatus } from './CalendarChannelSyncStatus'; export { CalendarChannelVisibility } from './CalendarChannelVisibility'; +export type { CommandMenuConfirmationModalResult } from './CommandMenuConfirmationModalResult'; +export type { CommandMenuConfirmationModalResultBrowserEventDetail } from './CommandMenuConfirmationModalResultBrowserEventDetail'; export type { CommandMenuContextApi } from './CommandMenuContextApi'; export { CommandMenuItemViewType } from './CommandMenuItemViewType'; export type { ActorMetadata } from './composite-types/actor.composite-type';