Files
twenty/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts
T
Raphaël Bosi 3bacf7a24b Widen front component crossing attributes to aria-*, data-* and draggable (#22614)
Only a closed allow-list of props crossed the front-component
worker→host boundary (`id, className, style, title, tabIndex, role,
aria-label, aria-hidden, data-testid`), so arbitrary `aria-*`/`data-*`
attributes and `draggable` never reached the host DOM. That breaks
headless UI libraries (Radix, cmdk, react-aria) that drive styling/state
through those attributes.

This widens the crossing set to all `aria-*`, all `data-*`, and
`draggable`:
- `draggable` becomes an enumerated remote property (it's a DOM IDL
property React may set as a property, bypassing `setAttribute`, so it
can't ride the prefix path).
- Arbitrary `aria-*`/`data-*` are forwarded in the worker by patching
`setAttribute`/`removeAttribute` through remote-dom's attribute channel,
only for names not already synced as observed attributes.

Security: only inert `aria-*`/`data-*`/`draggable` cross, and they still
route through the host `filterProps` guards (non-function `on*` dropped,
`javascript:` URLs denied) — nothing bypasses them. The enumerated
`aria-label`/`aria-hidden`/`data-testid` keep their existing path.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22614?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 12:42:03 +02:00

195 lines
6.2 KiB
TypeScript

import '@remote-dom/core/polyfill';
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 { isDefined } from 'twenty-shared/utils';
import { installStyleBridge } from '@/polyfills/installStyleBridge';
import { installStylePropertyOnRemoteElements } from '@/remote/utils/installStylePropertyOnRemoteElements';
import { patchRemoteElementAttributes } from '@/remote/utils/patchRemoteElementAttributes';
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';
installStylePropertyOnRemoteElements();
patchRemoteElementAttributes();
installErrorEventBridge();
exposeGlobals({
__HTML_TAG_TO_CUSTOM_ELEMENT_TAG__: HTML_TAG_TO_CUSTOM_ELEMENT_TAG,
});
const fetchComponentSource = async (
url: string,
headers?: Record<string, string>,
): Promise<string> => {
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(
`Failed to fetch ${url}: ${response.status} ${response.statusText}`,
);
}
return response.text();
};
const SDK_IMPORT_SPECIFIERS = [
'twenty-client-sdk/core',
'twenty-client-sdk/metadata',
] as const;
// Rewrites bare SDK import specifiers to the blob URLs provided by the host.
const rewriteSdkImports = (
source: string,
sdkClientUrls: { core: string; metadata: string },
): string => {
const specifierToBlobUrl: Record<string, string> = {
'twenty-client-sdk/core': sdkClientUrls.core,
'twenty-client-sdk/metadata': sdkClientUrls.metadata,
};
let rewritten = source;
for (const [specifier, blobUrl] of Object.entries(specifierToBlobUrl)) {
rewritten = rewritten
.split(`"${specifier}"`)
.join(`"${blobUrl}"`)
.split(`'${specifier}'`)
.join(`'${blobUrl}'`);
}
return rewritten;
};
const render: WorkerExports['render'] = async (
connection: RemoteConnection,
renderContext: HostToWorkerRenderContext,
) => {
const batchedConnection = new BatchingRemoteConnection(connection);
const root = document.createElement('remote-root') as RemoteRootElement;
const renderContainer = document.createElement('remote-fragment');
root.connect(batchedConnection);
root.append(renderContainer);
document.body.append(root);
installStyleBridge(root);
if (isDefined(renderContext.applicationVariables)) {
setWorkerEnv({
applicationVariables: JSON.stringify(renderContext.applicationVariables),
});
}
// 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(
renderContext.componentUrl,
authHeaders,
);
const hasSdkImports =
isDefined(renderContext.sdkClientUrls) &&
SDK_IMPORT_SPECIFIERS.some((specifier) =>
componentSource.includes(specifier),
);
const finalSource = hasSdkImports
? rewriteSdkImports(componentSource, renderContext.sdkClientUrls!)
: componentSource;
const componentBlob = new Blob([finalSource], {
type: 'application/javascript',
});
const importUrl = URL.createObjectURL(componentBlob);
try {
/* @vite-ignore */
const componentModule = await import(importUrl);
componentModule.default(renderContainer);
} finally {
URL.revokeObjectURL(importUrl);
}
};
const initializeHostCommunicationApi: WorkerExports['initializeHostCommunicationApi'] =
async () => {
const hostApi =
ThreadWebWorker.self.import<FrontComponentHostCommunicationApi>();
frontComponentHostCommunicationApi.navigate = hostApi.navigate;
frontComponentHostCommunicationApi.requestAccessTokenRefresh =
hostApi.requestAccessTokenRefresh;
frontComponentHostCommunicationApi.openSidePanelPage =
hostApi.openSidePanelPage;
frontComponentHostCommunicationApi.openCommandConfirmationModal =
createOpenCommandConfirmationModalAdapter(hostApi);
frontComponentHostCommunicationApi.unmountFrontComponent =
hostApi.unmountFrontComponent;
frontComponentHostCommunicationApi.enqueueSnackbar =
hostApi.enqueueSnackbar;
frontComponentHostCommunicationApi.closeSidePanel = hostApi.closeSidePanel;
frontComponentHostCommunicationApi.updateProgress = hostApi.updateProgress;
frontComponentHostCommunicationApi.copyToClipboard =
hostApi.copyToClipboard;
};
const onConfirmationModalResult: WorkerExports['onConfirmationModalResult'] =
async (result) => {
await handleCommandConfirmationModalResult(result);
};
const updateContext: WorkerExports['updateContext'] = async (
context: FrontComponentExecutionContext,
) => {
setFrontComponentExecutionContext(context);
};
ThreadWebWorker.self.export({
render,
initializeHostCommunicationApi,
onConfirmationModalResult,
updateContext,
});