[FRONT COMPONENTS] Navigate from the remote (#17762)
## PR Description This PR: - Introduces a `FrontComponentHostCommunicationApi`, which allows us to pass functions to be executed from the worker - Exposes a navigate function from the host to the front component remote workers, enabling SDK components to trigger in-app navigation - Gets rid of `useSyncExternalStore` and makes the execution context reactive without it - Refactors and improves the `esbuild` plugin system ## Video https://github.com/user-attachments/assets/7b26a1c2-f85f-4898-a71d-f60c70e61711
This commit is contained in:
+1
-7
@@ -1,7 +1 @@
|
||||
export const FRONT_COMPONENT_EXTERNAL_MODULES: string[] = [
|
||||
'react-dom',
|
||||
'twenty-sdk',
|
||||
'twenty-sdk/*',
|
||||
'twenty-shared',
|
||||
'twenty-shared/*',
|
||||
];
|
||||
export const FRONT_COMPONENT_EXTERNAL_MODULES: string[] = ['react-dom'];
|
||||
|
||||
+6
-1
@@ -18,12 +18,17 @@ export const jsxTransformToRemoteDomWorkerFormatPlugin: esbuild.Plugin = {
|
||||
const sourceWithRemoteComponents =
|
||||
replaceHtmlTagsWithRemoteComponents(frontComponentSourceCode);
|
||||
|
||||
const hasRemoteComponentReplacements =
|
||||
sourceWithRemoteComponents !== frontComponentSourceCode;
|
||||
|
||||
const sourceWithUnwrappedFrontComponent =
|
||||
unwrapDefineFrontComponentToDirectExport(
|
||||
sourceWithRemoteComponents,
|
||||
);
|
||||
|
||||
const transformedContents = `var RemoteComponents = globalThis.RemoteComponents;\n${sourceWithUnwrappedFrontComponent}`;
|
||||
const transformedContents = hasRemoteComponentReplacements
|
||||
? `var RemoteComponents = globalThis.RemoteComponents;\n${sourceWithUnwrappedFrontComponent}`
|
||||
: sourceWithUnwrappedFrontComponent;
|
||||
|
||||
return { contents: transformedContents, loader: 'tsx' };
|
||||
} catch (transformError) {
|
||||
|
||||
+43
-95
@@ -1,128 +1,76 @@
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
import type * as esbuild from 'esbuild';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { extractNamesFromImportSpecifier } from './utils/extract-names-from-import-specifier';
|
||||
|
||||
import { collectNamedImports } from './utils/collect-named-imports';
|
||||
import { createGlobalsPlugin } from './utils/create-globals-plugin';
|
||||
|
||||
const REACT_IMPORT_PATTERN =
|
||||
/import\s+(?:(\w+)\s*,?\s*)?(?:\{([^}]*)\})?\s*from\s*['"]react['"];?/g;
|
||||
/import\s+(?:(?<defaultImport>\w+)\s*,?\s*)?(?:\{(?<namedImports>[^}]*)\})?\s*from\s*['"]react['"];?/g;
|
||||
|
||||
const REACT_MODULE_FILTER_PATTERN = /^react(\/jsx-runtime)?$/;
|
||||
|
||||
const JSX_RUNTIME_EXPORTS = `
|
||||
export var jsx = globalThis.jsx;
|
||||
export var jsxs = globalThis.jsxs;
|
||||
export var Fragment = globalThis.React.Fragment;
|
||||
export var jsx = /* @__PURE__ */ (() => globalThis.jsx)();
|
||||
export var jsxs = /* @__PURE__ */ (() => globalThis.jsxs)();
|
||||
export var Fragment = /* @__PURE__ */ (() => globalThis.React.Fragment)();
|
||||
`.trim();
|
||||
|
||||
const collectReactImports = (sourceContent: string): Set<string> => {
|
||||
const collectedReactImports = new Set<string>();
|
||||
const collectReactImports = (
|
||||
sourceContent: string,
|
||||
): Map<string, Set<string>> => {
|
||||
const namedImports = collectNamedImports({
|
||||
sourceContent,
|
||||
pattern: REACT_IMPORT_PATTERN,
|
||||
});
|
||||
|
||||
let importMatch;
|
||||
|
||||
while (isDefined((importMatch = REACT_IMPORT_PATTERN.exec(sourceContent)))) {
|
||||
const defaultImportName = importMatch[1];
|
||||
const namedImportsString = importMatch[2];
|
||||
const defaultImportName = importMatch.groups?.defaultImport;
|
||||
|
||||
if (defaultImportName) {
|
||||
collectedReactImports.add('default');
|
||||
}
|
||||
if (!namedImports.has('')) {
|
||||
namedImports.set('', new Set());
|
||||
}
|
||||
|
||||
if (namedImportsString) {
|
||||
namedImportsString
|
||||
.split(',')
|
||||
.filter((importSpecifier) => importSpecifier.trim())
|
||||
.forEach((importSpecifier) => {
|
||||
const { originalName } =
|
||||
extractNamesFromImportSpecifier(importSpecifier);
|
||||
|
||||
collectedReactImports.add(originalName);
|
||||
});
|
||||
namedImports.get('')!.add('default');
|
||||
}
|
||||
}
|
||||
|
||||
REACT_IMPORT_PATTERN.lastIndex = 0;
|
||||
|
||||
return collectedReactImports;
|
||||
return namedImports;
|
||||
};
|
||||
|
||||
const generateReactExports = (reactImports: Set<string>): string => {
|
||||
const exportStatements: string[] = [];
|
||||
const generateReactExports = ({
|
||||
namedImports,
|
||||
}: {
|
||||
namedImports: Set<string>;
|
||||
}): string => {
|
||||
const exportLines: string[] = [];
|
||||
|
||||
for (const reactImportName of reactImports) {
|
||||
for (const reactImportName of namedImports) {
|
||||
if (reactImportName === 'default') {
|
||||
exportStatements.push('export default globalThis.React;');
|
||||
exportLines.push(
|
||||
'export default /* @__PURE__ */ (() => globalThis.React)();',
|
||||
);
|
||||
} else {
|
||||
exportStatements.push(
|
||||
`export var ${reactImportName} = globalThis.React.${reactImportName};`,
|
||||
exportLines.push(
|
||||
`export var ${reactImportName} = /* @__PURE__ */ (() => globalThis.React.${reactImportName})();`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return exportStatements.join('\n');
|
||||
return exportLines.join('\n');
|
||||
};
|
||||
|
||||
export const reactGlobalsPlugin: esbuild.Plugin = {
|
||||
name: 'react-globals',
|
||||
setup: async (build) => {
|
||||
const reactImportsByFilePath = new Map<string, Set<string>>();
|
||||
|
||||
build.onStart(() => {
|
||||
reactImportsByFilePath.clear();
|
||||
});
|
||||
|
||||
build.onResolve(
|
||||
{ filter: REACT_MODULE_FILTER_PATTERN },
|
||||
async ({ importer, path }) => {
|
||||
if (importer && !reactImportsByFilePath.has(importer)) {
|
||||
try {
|
||||
const sourceFileContent = await fs.readFile(importer, 'utf-8');
|
||||
reactImportsByFilePath.set(
|
||||
importer,
|
||||
collectReactImports(sourceFileContent),
|
||||
);
|
||||
} catch {
|
||||
reactImportsByFilePath.set(importer, new Set<string>());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path:
|
||||
path === 'react' && importer
|
||||
? `react?importer=${encodeURIComponent(importer)}`
|
||||
: path,
|
||||
namespace: 'react-globals',
|
||||
pluginData: { importer },
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
build.onLoad(
|
||||
{ filter: /.*/, namespace: 'react-globals' },
|
||||
({ path, pluginData }) => {
|
||||
if (path === 'react/jsx-runtime') {
|
||||
return {
|
||||
contents: JSX_RUNTIME_EXPORTS,
|
||||
loader: 'js',
|
||||
};
|
||||
}
|
||||
|
||||
if (path === 'react' || path.startsWith('react?importer=')) {
|
||||
const importerFilePath =
|
||||
pluginData?.importer ||
|
||||
decodeURIComponent(path.split('react?importer=')[1] || '');
|
||||
const collectedReactImports =
|
||||
reactImportsByFilePath.get(importerFilePath) || new Set<string>();
|
||||
|
||||
return {
|
||||
contents: generateReactExports(collectedReactImports),
|
||||
loader: 'js',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
export const reactGlobalsPlugin = createGlobalsPlugin({
|
||||
pluginName: 'react-globals',
|
||||
namespace: 'react-globals',
|
||||
moduleName: 'react',
|
||||
moduleFilter: REACT_MODULE_FILTER_PATTERN,
|
||||
collectImports: collectReactImports,
|
||||
generateExports: generateReactExports,
|
||||
staticContents: {
|
||||
'react/jsx-runtime': JSX_RUNTIME_EXPORTS,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
import type * as esbuild from 'esbuild';
|
||||
|
||||
const SINGLE_LINE_COMMENT_PATTERN = /^\/\/.*$\n/gm;
|
||||
|
||||
export const stripCommentsPlugin: esbuild.Plugin = {
|
||||
name: 'strip-comments',
|
||||
setup: (build) => {
|
||||
build.onEnd(async (result) => {
|
||||
if (result.errors.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const outputFiles = Object.keys(result.metafile?.outputs ?? {}).filter(
|
||||
(file) => file.endsWith('.mjs'),
|
||||
);
|
||||
|
||||
for (const outputFile of outputFiles) {
|
||||
const absolutePath = path.resolve(outputFile);
|
||||
const content = await fs.readFile(absolutePath, 'utf-8');
|
||||
const stripped = content.replace(SINGLE_LINE_COMMENT_PATTERN, '');
|
||||
|
||||
if (stripped !== content) {
|
||||
await fs.writeFile(absolutePath, stripped, 'utf-8');
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { collectNamedImports } from './utils/collect-named-imports';
|
||||
import { createGlobalsPlugin } from './utils/create-globals-plugin';
|
||||
|
||||
const TWENTY_SDK_IMPORT_PATTERN =
|
||||
/import\s+(?:\{(?<namedImports>[^}]*)\})?\s*from\s*['"]twenty-sdk['"];?/g;
|
||||
|
||||
const TWENTY_SDK_MODULE_FILTER_PATTERN = /^twenty-sdk$/;
|
||||
|
||||
export const twentySdkGlobalsPlugin = createGlobalsPlugin({
|
||||
pluginName: 'twenty-sdk-globals',
|
||||
namespace: 'twenty-sdk-globals',
|
||||
moduleName: 'twenty-sdk',
|
||||
moduleFilter: TWENTY_SDK_MODULE_FILTER_PATTERN,
|
||||
collectImports: (sourceContent) =>
|
||||
collectNamedImports({
|
||||
sourceContent,
|
||||
pattern: TWENTY_SDK_IMPORT_PATTERN,
|
||||
}),
|
||||
generateExports: ({ namedImports }) =>
|
||||
[...namedImports]
|
||||
.map(
|
||||
(importName) =>
|
||||
`export var ${importName} = /* @__PURE__ */ (() => globalThis.TwentySdk.${importName})();`,
|
||||
)
|
||||
.join('\n'),
|
||||
});
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { collectNamedImports } from './utils/collect-named-imports';
|
||||
import { createGlobalsPlugin } from './utils/create-globals-plugin';
|
||||
|
||||
const TWENTY_SDK_UI_IMPORT_PATTERN =
|
||||
/import\s+\{(?<namedImports>[^}]*)\}\s*from\s*['"]twenty-sdk\/ui['"];?/g;
|
||||
|
||||
const TWENTY_SDK_UI_MODULE_FILTER_PATTERN = /^twenty-sdk\/ui$/;
|
||||
|
||||
export const twentySdkUiGlobalsPlugin = createGlobalsPlugin({
|
||||
pluginName: 'twenty-sdk-ui-globals',
|
||||
namespace: 'twenty-sdk-ui-globals',
|
||||
moduleName: 'twenty-sdk/ui',
|
||||
moduleFilter: TWENTY_SDK_UI_MODULE_FILTER_PATTERN,
|
||||
collectImports: (sourceContent) =>
|
||||
collectNamedImports({
|
||||
sourceContent,
|
||||
pattern: TWENTY_SDK_UI_IMPORT_PATTERN,
|
||||
}),
|
||||
generateExports: ({ namedImports }) =>
|
||||
[...namedImports]
|
||||
.map(
|
||||
(importName) =>
|
||||
`export var ${importName} = /* @__PURE__ */ (() => globalThis.RemoteComponents.TwentyUi${importName})();`,
|
||||
)
|
||||
.join('\n'),
|
||||
});
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { collectNamedImports } from './utils/collect-named-imports';
|
||||
import { createGlobalsPlugin } from './utils/create-globals-plugin';
|
||||
|
||||
const TWENTY_SHARED_IMPORT_PATTERN =
|
||||
/import\s+\{(?<namedImports>[^}]*)\}\s*from\s*['"]twenty-shared(?:\/(?<subPath>[^'"]*?))?['"];?/g;
|
||||
|
||||
const TWENTY_SHARED_MODULE_FILTER_PATTERN = /^twenty-shared(\/.*)?$/;
|
||||
|
||||
const buildGlobalAccessorExpression = (moduleSubPath: string): string => {
|
||||
if (moduleSubPath === '') {
|
||||
return 'globalThis.TwentyShared';
|
||||
}
|
||||
|
||||
return `globalThis.TwentyShared['${moduleSubPath}']`;
|
||||
};
|
||||
|
||||
export const twentySharedGlobalsPlugin = createGlobalsPlugin({
|
||||
pluginName: 'twenty-shared-globals',
|
||||
namespace: 'twenty-shared-globals',
|
||||
moduleName: 'twenty-shared',
|
||||
moduleFilter: TWENTY_SHARED_MODULE_FILTER_PATTERN,
|
||||
collectImports: (sourceContent) =>
|
||||
collectNamedImports({
|
||||
sourceContent,
|
||||
pattern: TWENTY_SHARED_IMPORT_PATTERN,
|
||||
}),
|
||||
generateExports: ({ namedImports, moduleSubPath }) => {
|
||||
const globalAccessorExpression =
|
||||
buildGlobalAccessorExpression(moduleSubPath);
|
||||
|
||||
return [...namedImports]
|
||||
.map(
|
||||
(importName) =>
|
||||
`export var ${importName} = /* @__PURE__ */ (() => ${globalAccessorExpression}.${importName})();`,
|
||||
)
|
||||
.join('\n');
|
||||
},
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { extractNamesFromImportSpecifier } from './extract-names-from-import-specifier';
|
||||
|
||||
const parseImportSpecifiers = (namedImportsString: string): string[] => {
|
||||
return namedImportsString
|
||||
.split(',')
|
||||
.map((specifier) => specifier.trim())
|
||||
.filter((specifier) => specifier.length > 0)
|
||||
.filter((specifier) => !specifier.startsWith('type '))
|
||||
.map(
|
||||
(specifier) => extractNamesFromImportSpecifier(specifier).originalName,
|
||||
);
|
||||
};
|
||||
|
||||
export const collectNamedImports = ({
|
||||
sourceContent,
|
||||
pattern,
|
||||
}: {
|
||||
sourceContent: string;
|
||||
pattern: RegExp;
|
||||
}): Map<string, Set<string>> => {
|
||||
const collectedImports = new Map<string, Set<string>>();
|
||||
|
||||
let importMatch;
|
||||
|
||||
while (isDefined((importMatch = pattern.exec(sourceContent)))) {
|
||||
const namedImportsString = importMatch.groups?.namedImports;
|
||||
const subPath = importMatch.groups?.subPath ?? '';
|
||||
|
||||
if (!collectedImports.has(subPath)) {
|
||||
collectedImports.set(subPath, new Set());
|
||||
}
|
||||
|
||||
if (namedImportsString) {
|
||||
parseImportSpecifiers(namedImportsString).forEach((name) =>
|
||||
collectedImports.get(subPath)?.add(name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pattern.lastIndex = 0;
|
||||
|
||||
return collectedImports;
|
||||
};
|
||||
+1
-1
@@ -3,7 +3,7 @@ import type * as esbuild from 'esbuild';
|
||||
import { FRONT_COMPONENT_EXTERNAL_MODULES } from '../constants/front-component-external-modules';
|
||||
import { getFrontComponentBuildPlugins } from './get-front-component-build-plugins';
|
||||
|
||||
type FrontComponentBuildOptions = {
|
||||
export type FrontComponentBuildOptions = {
|
||||
entryPoints: esbuild.BuildOptions['entryPoints'];
|
||||
outdir: string;
|
||||
tsconfigPath?: string;
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
import type * as esbuild from 'esbuild';
|
||||
|
||||
type GlobalsPluginData = {
|
||||
importerFilePath: string;
|
||||
originalPath: string;
|
||||
};
|
||||
|
||||
type GlobalsPluginConfig = {
|
||||
pluginName: string;
|
||||
moduleName: string;
|
||||
moduleFilter: RegExp;
|
||||
collectImports: (sourceContent: string) => Map<string, Set<string>>;
|
||||
generateExports: (params: {
|
||||
namedImports: Set<string>;
|
||||
moduleSubPath: string;
|
||||
}) => string;
|
||||
namespace?: string;
|
||||
staticContents?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const createGlobalsPlugin = (
|
||||
config: GlobalsPluginConfig,
|
||||
): esbuild.Plugin => {
|
||||
const namespace = config.namespace ?? config.pluginName;
|
||||
|
||||
return {
|
||||
name: config.pluginName,
|
||||
setup: (build) => {
|
||||
const importsByFilePath = new Map<string, Map<string, Set<string>>>();
|
||||
|
||||
build.onStart(() => {
|
||||
importsByFilePath.clear();
|
||||
});
|
||||
|
||||
build.onResolve(
|
||||
{ filter: config.moduleFilter },
|
||||
async ({ importer, path }) => {
|
||||
if (importer && !importsByFilePath.has(importer)) {
|
||||
try {
|
||||
const sourceFileContent = await fs.readFile(importer, 'utf-8');
|
||||
|
||||
importsByFilePath.set(
|
||||
importer,
|
||||
config.collectImports(sourceFileContent),
|
||||
);
|
||||
} catch {
|
||||
importsByFilePath.set(importer, new Map());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path: importer
|
||||
? `${path}?importer=${encodeURIComponent(importer)}`
|
||||
: path,
|
||||
namespace,
|
||||
pluginData: {
|
||||
importerFilePath: importer,
|
||||
originalPath: path,
|
||||
} satisfies GlobalsPluginData,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
build.onLoad({ filter: /.*/, namespace }, ({ pluginData }) => {
|
||||
const { originalPath, importerFilePath } =
|
||||
pluginData as GlobalsPluginData;
|
||||
|
||||
if (config.staticContents?.[originalPath]) {
|
||||
return {
|
||||
contents: config.staticContents[originalPath],
|
||||
loader: 'js' as const,
|
||||
};
|
||||
}
|
||||
|
||||
const moduleSubPath =
|
||||
originalPath === config.moduleName
|
||||
? ''
|
||||
: originalPath.replace(`${config.moduleName}/`, '');
|
||||
|
||||
const importsBySubPath = importsByFilePath.get(importerFilePath);
|
||||
const namedImportsForSubPath =
|
||||
importsBySubPath?.get(moduleSubPath) ?? new Set<string>();
|
||||
|
||||
return {
|
||||
contents: config.generateExports({
|
||||
namedImports: namedImportsForSubPath,
|
||||
moduleSubPath,
|
||||
}),
|
||||
loader: 'js' as const,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
+8
@@ -2,8 +2,16 @@ import type * as esbuild from 'esbuild';
|
||||
|
||||
import { jsxTransformToRemoteDomWorkerFormatPlugin } from '../jsx-transform-to-remote-dom-worker-format-plugin';
|
||||
import { reactGlobalsPlugin } from '../react-globals-plugin';
|
||||
import { stripCommentsPlugin } from '../strip-comments-plugin';
|
||||
import { twentySdkGlobalsPlugin } from '../twenty-sdk-globals-plugin';
|
||||
import { twentySdkUiGlobalsPlugin } from '../twenty-sdk-ui-globals-plugin';
|
||||
import { twentySharedGlobalsPlugin } from '../twenty-shared-globals-plugin';
|
||||
|
||||
export const getFrontComponentBuildPlugins = (): esbuild.Plugin[] => [
|
||||
reactGlobalsPlugin,
|
||||
twentySdkGlobalsPlugin,
|
||||
twentySdkUiGlobalsPlugin,
|
||||
twentySharedGlobalsPlugin,
|
||||
jsxTransformToRemoteDomWorkerFormatPlugin,
|
||||
stripCommentsPlugin,
|
||||
];
|
||||
|
||||
+25
-3
@@ -1,10 +1,31 @@
|
||||
import path from 'path';
|
||||
import { type ValidationResult } from '@/sdk';
|
||||
import * as esbuild from 'esbuild';
|
||||
import * as fs from 'fs-extra';
|
||||
import { createRequire } from 'module';
|
||||
import * as esbuild from 'esbuild';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { isDefined, isPlainObject } from 'twenty-shared/utils';
|
||||
import { type ValidationResult } from '@/sdk';
|
||||
|
||||
const MANIFEST_MOCK_MODULES = ['twenty-sdk/ui'];
|
||||
|
||||
const manifestMockPlugin: esbuild.Plugin = {
|
||||
name: 'manifest-mock',
|
||||
setup: (build) => {
|
||||
const filter = new RegExp(
|
||||
`^(${MANIFEST_MOCK_MODULES.map((module) => module.replace('/', '\\/')).join('|')})$`,
|
||||
);
|
||||
|
||||
build.onResolve({ filter }, ({ path: modulePath }) => ({
|
||||
path: modulePath,
|
||||
namespace: 'manifest-mock',
|
||||
}));
|
||||
|
||||
build.onLoad({ filter: /.*/, namespace: 'manifest-mock' }, () => ({
|
||||
contents: 'module.exports = new Proxy({}, { get: () => () => {} });',
|
||||
loader: 'js',
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
export const extractManifestFromFile = async <T>({
|
||||
filePath,
|
||||
@@ -53,6 +74,7 @@ const loadModule = async ({
|
||||
...(reactPath && { react: reactPath }),
|
||||
...(reactDomPath && { 'react-dom': reactDomPath }),
|
||||
},
|
||||
plugins: [manifestMockPlugin],
|
||||
logLevel: 'silent',
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user