diff --git a/.changeset/esbuild-module-federation-rebuild.md b/.changeset/esbuild-module-federation-rebuild.md new file mode 100644 index 00000000000..11f5eff3224 --- /dev/null +++ b/.changeset/esbuild-module-federation-rebuild.md @@ -0,0 +1,28 @@ +--- +"@module-federation/esbuild": minor +--- + +Completely redesigned and rebuilt the esbuild plugin from the ground up for full Module Federation support. + +**Breaking changes:** +- Requires `format: 'esm'` and `splitting: true` (auto-set if not configured) +- Requires `@module-federation/runtime` as a peer dependency +- Remote module imports now use default export pattern (see README for migration) + +**New features:** +- Shared modules via `loadShare()` with version negotiation and fallback chunks +- Remote modules via `loadRemote()` with name@url parsing (http + https) +- Container entry (remoteEntry.js) with standard `get()`/`init()` protocol +- Runtime initialization with top-level await for proper async boundaries +- Eager shared module support (static imports instead of dynamic) +- `shareScope` configuration (global and per-module/per-remote overrides) +- `shareStrategy` configuration (`version-first` or `loaded-first`) +- `runtimePlugins` injection into the MF runtime +- `publicPath` configuration for manifest and asset resolution +- `import: false` to disable local fallback for shared modules +- `shareKey` for custom keys in the share scope +- `packageName` for explicit version auto-detection +- Re-export following in export analysis (resolves `export * from`) +- Manifest generation (mf-manifest.json) with full asset metadata +- Subpath import handling for shared packages (e.g., `react/jsx-runtime`) +- 117 tests covering all features diff --git a/packages/esbuild/README.md b/packages/esbuild/README.md index d5598643db7..9a5391d3314 100644 --- a/packages/esbuild/README.md +++ b/packages/esbuild/README.md @@ -1,113 +1,278 @@ # @module-federation/esbuild -This package provides an esbuild plugin for Module Federation, enabling you to easily share code between independently built and deployed applications. +Module Federation plugin for esbuild. Enables sharing code between independently built and deployed applications using the Module Federation protocol. ## Installation -Install the package using npm: - ```bash -npm install @module-federation/esbuild +npm install @module-federation/esbuild @module-federation/runtime +# or +pnpm add @module-federation/esbuild @module-federation/runtime ``` -## Usage +## Requirements + +- **esbuild** `^0.25.0` +- **format**: `'esm'` (ESM output is required for dynamic imports and top-level await) +- **splitting**: `true` (code splitting is required for shared/exposed module chunks) +- **@module-federation/runtime** must be installed and resolvable + +The plugin will automatically set `format: 'esm'` and `splitting: true` if not already configured. + +## Quick Start + +### 1. Create a Federation Config + +```js +// federation.config.js +const { withFederation } = require('@module-federation/esbuild/build'); + +module.exports = withFederation({ + name: 'myApp', + filename: 'remoteEntry.js', + exposes: { + './Button': './src/components/Button', + }, + remotes: { + remoteApp: 'http://localhost:3001/remoteEntry.js', + }, + shared: { + react: { singleton: true, version: '^18.2.0' }, + 'react-dom': { singleton: true, version: '^18.2.0' }, + }, +}); +``` -To use the Module Federation plugin with esbuild, add it to your esbuild configuration: +### 2. Use the Plugin in Your Build ```js const esbuild = require('esbuild'); -const path = require('path'); const { moduleFederationPlugin } = require('@module-federation/esbuild/plugin'); const federationConfig = require('./federation.config.js'); -async function buildApp() { - const tsConfig = 'tsconfig.json'; - const outputPath = path.join('dist', 'host'); - - try { - await esbuild.build({ - entryPoints: [path.join('host', 'main.ts')], - outdir: outputPath, - bundle: true, - platform: 'browser', - format: 'esm', - mainFields: ['es2020', 'browser', 'module', 'main'], - conditions: ['es2020', 'es2015', 'module'], - resolveExtensions: ['.ts', '.tsx', '.mjs', '.js'], - tsconfig: tsConfig, - splitting: true, - plugins: [moduleFederationPlugin(federationConfig)], - }); - } catch (err) { - console.error(err); - process.exit(1); - } -} +esbuild.build({ + entryPoints: ['./src/main.tsx'], + outdir: './dist', + bundle: true, + format: 'esm', + splitting: true, + plugins: [moduleFederationPlugin(federationConfig)], +}); +``` + +## How It Works + +### Architecture + +The plugin uses `@module-federation/runtime` directly for all Module Federation functionality. It works by intercepting module imports via esbuild's plugin hooks and replacing them with virtual modules that use the MF runtime: + +1. **Shared Modules**: Imports of shared dependencies (e.g., `react`) are replaced with virtual proxy modules that call `loadShare()` from the MF runtime for version negotiation between containers. + +2. **Remote Modules**: Imports matching remote names (e.g., `remoteApp/Button`) are replaced with virtual proxy modules that call `loadRemote()` to fetch modules from remote containers at runtime. + +3. **Container Entry**: When `exposes` is configured, a `remoteEntry.js` is generated with standard `get()`/`init()` exports that follow the Module Federation protocol. + +4. **Runtime Initialization**: Entry points are augmented with runtime initialization code that sets up the MF instance before any app code runs, using ESM top-level await. + +5. **Manifest**: An `mf-manifest.json` is generated for runtime discovery. + +### Shared Module Flow + +``` +┌─────────────────────────────────────────────────┐ +│ import React from 'react' │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────┐ │ +│ │ Shared Proxy (virtual) │ │ +│ │ loadShare('react') │ │ +│ │ ├─ Share Scope found? │ │ +│ │ │ ├─ YES: use shared │ │ +│ │ │ └─ NO: use fallback │───► Bundled react │ +│ │ └─ return module │ (separate │ +│ └──────────────────────────┘ chunk) │ +└─────────────────────────────────────────────────┘ +``` + +### Remote Module Flow + +``` +┌─────────────────────────────────────────────────┐ +│ import Button from 'remoteApp/Button' │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────┐ │ +│ │ Remote Proxy (virtual) │ │ +│ │ loadRemote('remoteApp/ │ │ +│ │ Button') │ │ +│ │ ├─ Load remoteEntry.js │ │ +│ │ ├─ Call init(shareScope)│ │ +│ │ ├─ Call get('./Button') │ │ +│ │ └─ return module │ │ +│ └──────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +## Configuration + +### `withFederation(config)` + +Normalizes a federation configuration object. Use this to prepare your config before passing it to `moduleFederationPlugin()`. + +```js +const { withFederation } = require('@module-federation/esbuild/build'); +``` + +#### Config Properties + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `name` | `string` | Yes | Unique name for this federation container | +| `filename` | `string` | No | Remote entry filename (default: `'remoteEntry.js'`) | +| `exposes` | `Record` | No | Modules to expose to other containers | +| `remotes` | `Record` | No | Remote containers to consume | +| `shared` | `Record` | No | Dependencies to share between containers | + +#### SharedConfig + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `singleton` | `boolean` | `false` | Only allow a single version of this package | +| `strictVersion` | `boolean` | `false` | Throw error on version mismatch | +| `requiredVersion` | `string` | `'*'` | Required semver version range | +| `version` | `string` | auto | The version of the shared package | +| `eager` | `boolean` | `false` | Load shared module eagerly | + +### `moduleFederationPlugin(config)` + +Creates the esbuild plugin instance. + +```js +const { moduleFederationPlugin } = require('@module-federation/esbuild/plugin'); +``` -buildApp(); +## Examples -// Example of federation.config.js +### Host Application (Consumer) -const { withFederation, shareAll } = require('@module-federation/esbuild/build'); +```js +// federation.config.js +const { withFederation } = require('@module-federation/esbuild/build'); module.exports = withFederation({ name: 'host', + remotes: { + mfe1: 'http://localhost:3001/remoteEntry.js', + }, + shared: { + react: { singleton: true, version: '^18.2.0' }, + 'react-dom': { singleton: true, version: '^18.2.0' }, + }, +}); +``` + +```tsx +// App.tsx - Using remote modules +import RemoteComponent from 'mfe1/component'; + +export function App() { + return ( +
+

Host App

+ +
+ ); +} +``` + +### Remote Application (Provider) + +```js +// federation.config.js +const { withFederation } = require('@module-federation/esbuild/build'); + +module.exports = withFederation({ + name: 'mfe1', filename: 'remoteEntry.js', exposes: { - './Component': './src/Component', + './component': './src/MyComponent', }, shared: { - react: { - singleton: true, - version: '^18.2.0', - }, - 'react-dom': { - singleton: true, - version: '^18.2.0', - }, - rxjs: { - singleton: true, - version: '^7.8.1', - }, - ...shareAll({ - singleton: true, - strictVersion: true, - requiredVersion: 'auto', - includeSecondaries: false, - }), + react: { singleton: true, version: '^18.2.0' }, + 'react-dom': { singleton: true, version: '^18.2.0' }, }, }); ``` -The `moduleFederationPlugin` accepts a configuration object with the following properties: +### Both Host and Remote -- `name` (string): The name of the host application. -- `filename` (string, optional): The name of the remote entry file. Defaults to `'remoteEntry.js'`. -- `remotes` (object, optional): An object specifying the remote applications and their entry points. -- `exposes` (object, optional): An object specifying the modules to be exposed by the host application. -- `shared` (array, optional): An array of package names to be shared between the host and remote applications. +An application can be both a host and a remote simultaneously: -## Plugin Features - -The `moduleFederationPlugin` includes the following features: +```js +const { withFederation } = require('@module-federation/esbuild/build'); -- **Virtual Share Module**: Creates a virtual module for sharing dependencies between the host and remote applications. -- **Virtual Remote Module**: Creates a virtual module for importing exposed modules from remote applications. -- **CommonJS to ESM Transformation**: Transforms CommonJS modules to ESM format for compatibility with Module Federation. -- **Shared Dependencies Linking**: Links shared dependencies between the host and remote applications. -- **Manifest Generation**: Generates a manifest file containing information about the exposed modules and their exports. +module.exports = withFederation({ + name: 'shell', + filename: 'remoteEntry.js', + exposes: { + './Header': './src/Header', + }, + remotes: { + sidebar: 'http://localhost:3002/remoteEntry.js', + }, + shared: { + react: { singleton: true, version: '^18.2.0' }, + 'react-dom': { singleton: true, version: '^18.2.0' }, + }, +}); +``` ## API -### `moduleFederationPlugin(config)` +### Exports from `@module-federation/esbuild/plugin` + +- `moduleFederationPlugin(config)` - Creates the esbuild plugin + +### Exports from `@module-federation/esbuild/build` + +- `withFederation(config)` - Normalizes federation configuration +- `share(shareObjects)` - Processes shared dependency configurations +- `shareAll(config)` - Shares all dependencies from package.json +- `findPackageJson(folder)` - Finds nearest package.json +- `lookupVersion(key, workspaceRoot)` - Looks up dependency version +- `setInferVersion(infer)` - Enable/disable version inference + +### Exports from `@module-federation/esbuild` + +Re-exports everything from both `plugin` and `build` entry points. + +## Notes + +### Remote Module Imports Work Like Webpack + +All standard import forms work with remote modules, just like webpack: + +```tsx +// Named imports - works! +import { App, Button } from 'remote/component'; + +// Default import +import Component from 'remote/component'; + +// Mixed default + named +import Component, { helper } from 'remote/utils'; + +// Aliased imports +import { App as RemoteApp } from 'remote/component'; + +// Namespace imports +import * as RemoteLib from 'remote/lib'; +``` -Creates an esbuild plugin for Module Federation. +The plugin automatically transforms named imports from remote modules at build time, +converting them into a pattern that esbuild can process while preserving the natural +import syntax you'd use with webpack. -- `config` (object): The Module Federation configuration. - - `name` (string): The name of the host application. - - `filename` (string, optional): The name of the remote entry file. Defaults to `'remoteEntry.js'`. - - `remotes` (object, optional): An object specifying the remote applications and their entry points. - - `exposes` (object, optional): An object specifying the modules to be exposed by the host application. - - `shared` (array, optional): An array of package names to be shared between the host and remote applications. +### Shared Module Subpaths -Returns an esbuild plugin instance. +When you share a package like `react`, subpath imports like `react/jsx-runtime` are also handled through the share scope. The plugin automatically detects subpath imports and routes them appropriately. diff --git a/packages/esbuild/jest.config.ts b/packages/esbuild/jest.config.ts new file mode 100644 index 00000000000..1007752fa61 --- /dev/null +++ b/packages/esbuild/jest.config.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'fs'; + +const { exclude: _, ...swcJestConfig } = JSON.parse( + readFileSync(`${__dirname}/.swcrc`, 'utf-8'), +); + +swcJestConfig.swcrc ??= false; + +export default { + clearMocks: true, + cache: false, + testEnvironment: 'node', + coveragePathIgnorePatterns: ['__tests__', '/node_modules/', '/dist/'], + globals: { + __VERSION__: '"0.0.0-test"', + FEDERATION_DEBUG: '""', + }, + preset: 'ts-jest', + transformIgnorePatterns: ['/node_modules/', '/dist/'], + transform: { + '^.+\\.(t|j)sx?$': ['@swc/jest', swcJestConfig], + }, + rootDir: __dirname, + testMatch: [ + '/src/**/*.spec.[jt]s?(x)', + '/src/**/*.test.[jt]s?(x)', + ], + testPathIgnorePatterns: ['/node_modules/'], +}; diff --git a/packages/esbuild/package.json b/packages/esbuild/package.json index 0de8fe0f5c7..66b69835ce0 100644 --- a/packages/esbuild/package.json +++ b/packages/esbuild/package.json @@ -50,16 +50,21 @@ } }, "dependencies": { - "@chialab/esbuild-plugin-commonjs": "^0.18.0", - "@hyrious/esbuild-plugin-commonjs": "^0.2.4", - "@module-federation/sdk": "workspace:*", + "@module-federation/runtime": "workspace:*", "cjs-module-lexer": "^1.3.1", "enhanced-resolve": "^5.16.1", "es-module-lexer": "^1.5.3", "esbuild": "^0.25.0", - "json5": "^2.2.3", "npmlog": "^7.0.1" }, + "peerDependencies": { + "@module-federation/runtime": "workspace:*" + }, + "peerDependenciesMeta": { + "@module-federation/runtime": { + "optional": false + } + }, "devDependencies": { "@rslib/core": "^0.12.4" } diff --git a/packages/esbuild/project.json b/packages/esbuild/project.json index 57537b3492e..87a39baf6d2 100644 --- a/packages/esbuild/project.json +++ b/packages/esbuild/project.json @@ -29,6 +29,14 @@ ] } }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/packages/esbuild"], + "options": { + "jestConfig": "packages/esbuild/jest.config.ts", + "passWithNoTests": true + } + }, "build-debug": { "executor": "nx:run-commands", "options": { diff --git a/packages/esbuild/rslib.config.ts b/packages/esbuild/rslib.config.ts index 904bbbfde82..c3467bfdb2b 100644 --- a/packages/esbuild/rslib.config.ts +++ b/packages/esbuild/rslib.config.ts @@ -60,12 +60,6 @@ export default defineConfig({ // Optional dependency that may not be available 'pnpapi', ], - copy: [ - { - from: './src/resolve', - to: './resolve', - }, - ], }, tools: { rspack: (config: any) => { diff --git a/packages/esbuild/src/adapters/lib/collect-exports.ts b/packages/esbuild/src/adapters/lib/collect-exports.ts index e3af28890bf..40ab0e1d533 100644 --- a/packages/esbuild/src/adapters/lib/collect-exports.ts +++ b/packages/esbuild/src/adapters/lib/collect-exports.ts @@ -12,67 +12,115 @@ import enhancedResolve from 'enhanced-resolve'; import fs from 'fs'; import path from 'path'; -export const resolve = promisify( +const resolve = promisify( enhancedResolve.create({ mainFields: ['browser', 'module', 'main'], }), ); -export const resolvePackageJson = async ( - packageName: string, - callback: (err: Error | null, result?: string) => void, -): Promise => { - try { - const filepath = await resolve(__dirname, packageName); - if (typeof filepath !== 'string') { - return callback(new Error('Failed to resolve package path')); - } - - // Resolve the path to the package.json file - const packageJsonPath = path.join(filepath, 'package.json'); - if (fs.existsSync(packageJsonPath)) { - callback(null, packageJsonPath); - } else { - callback(new Error(`package.json not found for package: ${packageName}`)); - } - } catch (err) { - callback(err as Error); - } -}; +/** + * Analyze a module's exports by reading its source code and parsing with + * es-module-lexer (ESM) and cjs-module-lexer (CJS). + * + * Handles re-exports (`export * from './other'`) by recursively following + * the re-export chain up to a depth limit to avoid infinite loops. + * + * @param modulePath - The module specifier or path to analyze + * @returns Array of export names (always includes 'default') + */ export async function getExports(modulePath: string): Promise { await initEsLexer; await initCjsLexer; try { const exports: string[] = []; - const paths: string[] = []; + const visited = new Set(); + const paths: Array<{ filePath: string; depth: number }> = []; + const resolvedPath = await resolve(process.cwd(), modulePath); if (typeof resolvedPath === 'string') { - paths.push(resolvedPath); + paths.push({ filePath: resolvedPath, depth: 0 }); } + + const MAX_DEPTH = 5; + while (paths.length > 0) { - const currentPath = paths.pop(); - if (currentPath) { - const content = await fs.promises.readFile(currentPath, 'utf8'); + const item = paths.pop(); + if (!item) continue; + const { filePath, depth } = item; + + // Skip already-visited files (handles circular re-exports) + if (visited.has(filePath)) continue; + visited.add(filePath); + + let content: string; + try { + content = await fs.promises.readFile(filePath, 'utf8'); + } catch { + continue; + } + + try { + // Try CJS first + const { exports: cjsExports, reexports: cjsReexports } = + parseCjsModule(content); + exports.push(...cjsExports); - try { - const { exports: cjsExports } = parseCjsModule(content); - exports.push(...cjsExports); - } catch { - const [, esExports] = parseEsModule(content); - exports.push(...esExports.map((exp: ExportSpecifier) => exp.n)); + // Follow CJS re-exports + if (depth < MAX_DEPTH && cjsReexports.length > 0) { + for (const reexport of cjsReexports) { + try { + const resolved = await resolve(path.dirname(filePath), reexport); + if (typeof resolved === 'string' && !visited.has(resolved)) { + paths.push({ filePath: resolved, depth: depth + 1 }); + } + } catch { + // Can't resolve re-export target, skip + } + } } + } catch { + // Not CJS, try ESM + const [esImports, esExports] = parseEsModule(content); + exports.push(...esExports.map((exp: ExportSpecifier) => exp.n)); - // TODO: Handle re-exports + // Follow ESM re-exports (`export * from '...'` and `export { x } from '...'`) + // es-module-lexer returns import entries; re-exports appear as imports + // with assertion `a === -1` for `export *` style. + if (depth < MAX_DEPTH) { + for (const imp of esImports) { + // imp.n is the module specifier, imp.a is the assert index + // For `export * from 'x'`, the import will have imp.n set + // and the corresponding export will reference it. + // Since es-module-lexer treats `export * from` as an import, + // we check if it's a re-export by looking at the statement. + if (imp.n && imp.t === 2) { + // type 2 = export star + try { + const resolved = await resolve(path.dirname(filePath), imp.n); + if (typeof resolved === 'string' && !visited.has(resolved)) { + paths.push({ filePath: resolved, depth: depth + 1 }); + } + } catch { + // Can't resolve re-export target, skip + } + } + } + } } } if (!exports.includes('default')) { exports.push('default'); } - return exports; + // Deduplicate + return [...new Set(exports)]; } catch (e) { - console.log(e); + console.warn( + '[module-federation] Failed to analyze exports for', + modulePath, + e, + ); return ['default']; } } diff --git a/packages/esbuild/src/adapters/lib/commonjs.ts b/packages/esbuild/src/adapters/lib/commonjs.ts deleted file mode 100644 index 17a1e099f36..00000000000 --- a/packages/esbuild/src/adapters/lib/commonjs.ts +++ /dev/null @@ -1,349 +0,0 @@ -import type { Message, Plugin } from 'esbuild'; -import { promises } from 'fs'; -import { Lexer } from './lexer'; -import { cachedReduce, makeLegalIdentifier, orderedUniq } from './utils'; -import { resolve } from './collect-exports'; - -export interface CommonJSOptions { - /** - * The regexp passed to onLoad() to match commonjs files. - * - * @default /\.c?js$/ - */ - filter?: RegExp; - - /** - * _Experimental_: Transform commonjs to es modules. You have to install - * `cjs-module-lexer` to let it work. - * - * When `true`, the plugin tries to wrap the commonjs module into: - * - * ```js - * var exports = {}, module = { exports }; - * { - * // ... original content ... - * } - * exports = module.exports; - * // the exported names are extracted by cjs-module-lexer - * export default exports; - * var { something, "a-b" as a_b } = exports; - * export { something, a_b as "a-b" }; - * ``` - * - * @default false - */ - transform?: - | boolean - | ((path: string) => boolean | TransformConfig | null | void); - - /** - * _Experimental_: This options acts as a fallback of the `transform` option above. - */ - transformConfig?: Pick; - - /** - * Controls which style of import should be used. By default, it transforms: - * - * ```js - * // input - * const foo = require("foo") - * // output - * import foo from "foo" - * ``` - * - * The above case is often correct when 'foo' is also a commonjs module. - * But if 'foo' has es module exports, it is better to use: - * - * ```js - * // output - * import * as foo from "foo" - * ``` - * - * In which case you can set `requireReturnsDefault` to `false` to get the above output. - * Or use the callback style to control the behavior for each module. - * - * @default true - */ - requireReturnsDefault?: boolean | ((path: string) => boolean); - - /** - * Don't replace require("ignored-modules"). Note that this will cause - * esbuild generates the __require() wrapper which throw error at runtime. - */ - ignore?: string[] | ((path: string) => boolean); -} - -export interface TransformConfig { - /** - * If `"babel"`, it will check if there be `exports.__esModule`, - * then export `exports.default`. i.e. The wrapper code becomes: - * - * ```js - * export default exports.__esModule ? exports.default : exports; - * ``` - * - * @default "node" - */ - behavior?: 'babel' | 'node'; - - /** - * Also include these named exports if they aren't recognized automatically. - * - * @example ["something"] - */ - exports?: string[]; - - /** - * If `false`, slightly change the result to make it side-effect free. - * But it doesn't actually remove many code. So you maybe not need this. - * - * ```js - * var mod; - * var exports = /\*#__PURE__*\/ ((exports, module) => { - * // ... original content ... - * return module.exports; - * })((mod = { exports: {} }).exports, mod); - * export default exports; - * var a_b = /\*#__PURE__*\/ (() => exports['a-b'])(); - * var something = /\*#__PURE__*\/ (() => exports.something)(); - * export { a_b as "a-b", something }; - * ``` - */ - sideEffects?: boolean; -} - -export function commonjs({ - filter = /\.c?js$/, - transform = true, - transformConfig, - requireReturnsDefault = true, - ignore, -}: CommonJSOptions = {}): Plugin { - const init_cjs_module_lexer = transform - ? import('cjs-module-lexer') - : undefined; - - const use_default_export = - typeof requireReturnsDefault === 'function' - ? requireReturnsDefault - : (_path: string) => requireReturnsDefault; - - const is_ignored = - typeof ignore === 'function' - ? ignore - : Array.isArray(ignore) - ? (path: string) => ignore.includes(path) - : () => false; - - return { - name: 'commonjs', - setup({ onLoad, esbuild, initialOptions }) { - let esbuild_shim: typeof import('esbuild') | undefined; - const require_esbuild = () => - esbuild || (esbuild_shim ||= require('esbuild')); - const read = promises.readFile; - const lexer = new Lexer(); - - //@ts-ignore - onLoad({ filter: filter }, async (args) => { - let parseCJS: typeof import('cjs-module-lexer').parse | undefined; - if (init_cjs_module_lexer) { - const { init, parse } = await init_cjs_module_lexer; - await init(); - parseCJS = parse; - } - let contents: string; - try { - //@ts-ignore - contents = await read(args.path, 'utf8'); - } catch { - return null; - } - const willTransform = - transform === true || - (typeof transform === 'function' && transform(args.path)); - let cjsExports: ReturnType> | undefined; - try { - if (parseCJS && willTransform) { - // move sourcemap to the end of the transformed file - const sourcemapIndex = contents.lastIndexOf( - '//# sourceMappingURL=', - ); - let sourcemap: string | undefined; - if (sourcemapIndex !== -1) { - sourcemap = contents.slice(sourcemapIndex); - const sourcemapEnd = sourcemap.indexOf('\n'); - if ( - sourcemapEnd !== -1 && - sourcemap.slice(sourcemapEnd + 1).trimStart().length > 0 - ) { - // if there's code after sourcemap, it is invalid, don't do this. - sourcemap = undefined; - } else { - contents = contents.slice(0, sourcemapIndex); - } - } - // transform commonjs to es modules, easy mode - cjsExports = parseCJS(contents); - let { behavior, exports, sideEffects } = - typeof willTransform === 'object' - ? willTransform - : ({} as TransformConfig); - behavior ??= transformConfig?.behavior ?? 'node'; - exports = orderedUniq(cjsExports.exports.concat(exports ?? [])); - sideEffects ??= transformConfig?.sideEffects ?? true; - let exportDefault = - behavior === 'node' - ? 'export default exports;' - : 'export default exports.__esModule ? exports.default : exports;'; - let exportsMap = exports.map((e) => [e, makeLegalIdentifier(e)]); - if (exportsMap.some(([e]) => e === 'default')) { - if (behavior === 'node') { - exportsMap = exportsMap.filter(([e]) => e !== 'default'); - } else { - exportDefault = ''; - } - } - const reexports = cjsExports.reexports - .map((e) => `export * from ${JSON.stringify(e)};`) - .join(''); - let transformed: string[]; - if (sideEffects === false) { - transformed = [ - // make sure we don't manipulate the first line so that sourcemap is fine - reexports + - 'var mod, exports = /* @__PURE__ */ ((exports, module) => {' + - contents, - 'return module.exports})((mod = { exports: {} }).exports, mod); ' + - exportDefault, - ]; - if (exportsMap.length > 0) { - for (const [e, name] of exportsMap) { - transformed.push( - `var ${name} = /* @__PURE__ */ (() => exports[${JSON.stringify( - e, - )}])();`, - ); - } - transformed.push( - `export { ${exportsMap - .map(([e, name]) => - e === name ? e : `${name} as ${JSON.stringify(e)}`, - ) - .join(', ')} };`, - ); - } - } else { - transformed = [ - reexports + - 'var exports = {}, module = { exports }; {' + - contents, - '}; exports = module.exports; ' + exportDefault, - ]; - if (exportsMap.length > 0) { - transformed.push( - `var { ${exportsMap - .map(([e, name]) => - e === name ? e : `${JSON.stringify(e)}: ${name}`, - ) - .join(', ')} } = exports;`, - `export { ${exportsMap - .map(([e, name]) => - e === name ? e : `${name} as ${JSON.stringify(e)}`, - ) - .join(', ')} };`, - ); - } - } - contents = - transformed.join('\n') + (sourcemap ? '\n' + sourcemap : ''); - } - } catch (e) { - return null; - } - - function makeName(path: string) { - let name = `__import_${makeLegalIdentifier(path)}`; - - if (contents.includes(name)) { - let suffix = 2; - while (contents.includes(`${name}${suffix}`)) suffix++; - name = `${name}${suffix}`; - } - - return name; - } - - let warnings: Message[]; - try { - ({ warnings } = await require_esbuild().transform(contents, { - format: 'esm', - logLevel: 'silent', - })); - } catch (err) { - ({ warnings } = err as any); - } - - const lines = contents.split('\n'); - const getOffset = cachedReduce(lines, (a, b) => a + 1 + b.length, 0); - - if ( - warnings && - (warnings = warnings.filter((e) => - e.text.includes('"require" to "esm"'), - )).length - ) { - const edits: [start: number, end: number, replace: string][] = []; - let imports: string[] = []; - - for (const { location } of warnings) { - if (location === null) continue; - - const { line, lineText, column, length } = location; - - const leftBrace = column + length + 1; - const path = lexer.readString(lineText, leftBrace); - if (path === null || is_ignored(path)) continue; - const rightBrace = - lineText.indexOf(')', leftBrace + 2 + path.length) + 1; - - const name = makeName(path); - let import_statement: string; - if (use_default_export(path)) { - import_statement = `import ${name} from ${JSON.stringify(path)};`; - } else { - import_statement = `import * as ${name} from ${JSON.stringify( - path, - )};`; - } - - const offset = getOffset(line - 1); - edits.push([offset + column, offset + rightBrace, name]); - imports.push(import_statement); - } - - if (imports.length === 0) return null; - - imports = orderedUniq(imports); - - let offset = 0; - for (const [start, end, name] of edits) { - contents = - contents.slice(0, start + offset) + - name + - contents.slice(end + offset); - offset += name.length - (end - start); - } - - // if we have transformed this module (i.e. having `cjsExports`), don't make the file commonjs - contents = [...imports, cjsExports ? 'exports;' : '', contents].join( - '', - ); - return { contents }; - } - }); - }, - }; -} - -export default commonjs; diff --git a/packages/esbuild/src/adapters/lib/containerPlugin.ts b/packages/esbuild/src/adapters/lib/containerPlugin.ts deleted file mode 100644 index 1307fbedfd3..00000000000 --- a/packages/esbuild/src/adapters/lib/containerPlugin.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { OnResolveArgs, OnLoadArgs, PluginBuild } from 'esbuild'; -import { createContainerCode } from '../../lib/core/createContainerTemplate.js'; -import { NormalizedFederationConfig } from '../../lib/config/federation-config.js'; - -const buildContainerHost = (config: NormalizedFederationConfig) => { - const { name, remotes = {}, shared = {}, exposes = {} } = config; - - const remoteConfigs = Object.entries(remotes).map( - ([remoteAlias, remote]) => ({ - type: 'esm', - name: remoteAlias, - entry: (remote as any).entry, - alias: remoteAlias, - }), - ); - - const sharedConfig = - Object.entries(shared).reduce((acc, [pkg, config]) => { - const version = - (config as any).requiredVersion?.replace(/^[^0-9]/, '') || ''; - acc += `${JSON.stringify(pkg)}: { - "package": "${pkg}", - "version": "${version}", - "scope": "default", - "get": async () => import('federationShare/${pkg}'), - "shareConfig": { - "singleton": ${(config as any).singleton}, - "requiredVersion": "${(config as any).requiredVersion}", - "eager": ${(config as any).eager}, - "strictVersion": ${(config as any).strictVersion} - } - },\n`; - return acc; - }, '{') + '}'; - - let exposesConfig = Object.entries(exposes) - .map( - ([exposeName, exposePath]) => - `${JSON.stringify( - exposeName, - )}: async () => await import('${exposePath}')`, - ) - .join(',\n'); - exposesConfig = `{${exposesConfig}}`; - - const injectedContent = ` - export const moduleMap = '__MODULE_MAP__'; - - function appendImportMap(importMap) { - const script = document.createElement('script'); - script.type = 'importmap-shim'; - script.innerHTML = JSON.stringify(importMap); - document.head.appendChild(script); - } - - export const createVirtualRemoteModule = (name, ref, exports) => { - const genExports = exports.map(e => - e === 'default' ? 'export default mfLsZJ92.default' : \`export const \${e} = mfLsZJ92[\${JSON.stringify(e)}];\` - ).join(''); - - const loadRef = \`const mfLsZJ92 = await container.loadRemote(\${JSON.stringify(ref)});\`; - - return \` - const container = __FEDERATION__.__INSTANCES__.find(container => container.name === name) || __FEDERATION__.__INSTANCES__[0]; - \${loadRef} - \${genExports} - \`; - }; - - function encodeInlineESM(code) { - const encodedCode = encodeURIComponent(code); - return \`data:text/javascript;charset=utf-8,\${encodedCode}\`; - } - - const runtimePlugin = () => ({ - name: 'import-maps-plugin', - async init(args) { - - const remotePrefetch = args.options.remotes.map(async (remote) => { - if (remote.type === 'esm') { - await import(remote.entry); - } - }); - - - await Promise.all(remotePrefetch); - - const map = Object.keys(moduleMap).reduce((acc, expose) => { - const importMap = importShim.getImportMap().imports; - const key = args.origin.name + expose.replace('.', ''); - if (!importMap[key]) { - const encodedModule = encodeInlineESM( - createVirtualRemoteModule(args.origin.name, key, moduleMap[expose].exports) - ); - acc[key] = encodedModule; - } - return acc; - }, {}); - await importShim.addImportMap({ imports: map }); - - return args; - } - }); - - const createdContainer = await createContainer({ - name: ${JSON.stringify(name)}, - exposes: ${exposesConfig}, - remotes: ${JSON.stringify(remoteConfigs)}, - shared: ${sharedConfig}, - plugins: [runtimePlugin()], - }); - - export const get = createdContainer.get; - export const init = createdContainer.init; - `; - //replace with createContainer from bundler runtime - import it in the string as a dep etc - - return [createContainerCode, injectedContent].join('\n'); -}; - -export const createContainerPlugin = (config: NormalizedFederationConfig) => ({ - name: 'createContainer', - setup(build: PluginBuild) { - const { filename } = config; - - const filter = new RegExp([filename].map((name) => `${name}$`).join('|')); - const hasShared = Object.keys(config.shared || {}).length; - - const shared = Object.keys(config.shared || {}) - .map((name: string) => `${name}$`) - .join('|'); - const sharedExternals = new RegExp(shared); - - build.onResolve({ filter }, async (args: OnResolveArgs) => ({ - path: args.path, - namespace: 'container', - pluginData: { kind: args.kind, resolveDir: args.resolveDir }, - })); - - build.onResolve( - { filter: /^federationShare/ }, - async (args: OnResolveArgs) => ({ - path: args.path.replace('federationShare/', ''), - namespace: 'esm-shares', - pluginData: { kind: args.kind, resolveDir: args.resolveDir }, - }), - ); - if (hasShared) { - build.onResolve({ filter: sharedExternals }, (args: OnResolveArgs) => { - if (args.namespace === 'esm-shares') return null; - return { - path: args.path, - namespace: 'virtual-share-module', - pluginData: { kind: args.kind, resolveDir: args.resolveDir }, - }; - }); - - build.onResolve( - { filter: /.*/, namespace: 'esm-shares' }, - async (args: OnResolveArgs) => { - if (sharedExternals.test(args.path)) { - return { - path: args.path, - namespace: 'virtual-share-module', - pluginData: { kind: args.kind, resolveDir: args.resolveDir }, - }; - } - - return undefined; - }, - ); - } - - build.onLoad( - { filter, namespace: 'container' }, - async (args: OnLoadArgs) => ({ - contents: buildContainerHost(config), - loader: 'js', - resolveDir: args.pluginData.resolveDir, - }), - ); - }, -}); diff --git a/packages/esbuild/src/adapters/lib/containerReference.ts b/packages/esbuild/src/adapters/lib/containerReference.ts deleted file mode 100644 index 653f91575d4..00000000000 --- a/packages/esbuild/src/adapters/lib/containerReference.ts +++ /dev/null @@ -1,162 +0,0 @@ -import fs from 'fs'; -import { NormalizedFederationConfig } from '../../lib/config/federation-config'; - -// Builds the federation host code -export const buildFederationHost = (config: NormalizedFederationConfig) => { - const { name, remotes, shared } = config; - - const remoteConfigs = remotes - ? JSON.stringify( - Object.entries(remotes).map(([remoteAlias, remote]) => ({ - name: remoteAlias, - entry: remote, - alias: remoteAlias, - type: 'esm', - })), - ) - : '[]'; - - const sharedConfig = - Object.entries(shared ?? {}).reduce((acc, [pkg, config]) => { - const version = config.requiredVersion?.replace(/^[^0-9]/, '') || ''; - acc += `${JSON.stringify(pkg)}: { - "package": "${pkg}", - "version": "${version}", - "scope": "default", - "get": async () => await import('federationShare/${pkg}'), - "shareConfig": { - "singleton": ${config.singleton}, - "requiredVersion": "${config.requiredVersion}", - "eager": ${config.eager}, - "strictVersion": ${config.strictVersion} - } - },\n`; - return acc; - }, '{') + '}'; - return ` - import { init as initFederationHost } from "@module-federation/runtime"; - - const createVirtualRemoteModule = (name, ref, exports) => { - const genExports = exports.map(e => - e === 'default' - ? 'export default mfLsZJ92.default;' - : \`export const \${e} = mfLsZJ92[\${JSON.stringify(e)}];\` - ).join(''); - - const loadRef = \`const mfLsZJ92 = await container.loadRemote(\${JSON.stringify(ref)});\`; - - return \` - const container = __FEDERATION__.__INSTANCES__.find(container => container.name === name) || __FEDERATION__.__INSTANCES__[0]; - \${loadRef} - \${genExports} - \`; - }; - - function encodeInlineESM(code) { - return 'data:text/javascript;charset=utf-8,' + encodeURIComponent(code); - } - - const runtimePlugin = () => ({ - name: 'import-maps-plugin', - async init(args) { - const remotePrefetch = args.options.remotes.map(async (remote) => { - console.log('remote', remote); - if (remote.type === 'esm') { - await import(remote.entry); - } - }); - - await Promise.all(remotePrefetch); - if (typeof moduleMap !== 'undefined') { - const map = Object.keys(moduleMap).reduce((acc, expose) => { - const importMap = importShim.getImportMap().imports; - const key = args.origin.name + expose.replace('.', ''); - if (!importMap[key]) { - const encodedModule = encodeInlineESM( - createVirtualRemoteModule(args.origin.name, key, moduleMap[expose].exports) - ); - acc[key] = encodedModule; - } - return acc; - }, {}); - - await importShim.addImportMap({ imports: map }); - } - - return args; - } - }); - - const mfHoZJ92 = initFederationHost({ - name: ${JSON.stringify(name)}, - remotes: ${remoteConfigs}, - shared: ${sharedConfig}, - plugins: [runtimePlugin()], - }); - - await Promise.all(mfHoZJ92.initializeSharing('default', 'version-first')); - - - `; -}; - -export const initializeHostPlugin = (config: NormalizedFederationConfig) => ({ - name: 'host-initialization', - setup(build: any) { - build.onResolve({ filter: /federation-host/ }, (args: any) => ({ - path: args.path, - namespace: 'federation-host', - pluginData: { kind: args.kind, resolveDir: args.resolveDir }, - })); - - build.onLoad( - { filter: /.*/, namespace: 'federation-host' }, - async (args: any) => ({ - contents: buildFederationHost(config), - resolveDir: args.pluginData.resolveDir, - }), - ); - - // Add custom loaders - const loaders = build.initialOptions.loader || {}; - - // Apply custom loaders - for (const [ext, loader] of Object.entries(loaders)) { - build.onLoad( - { filter: new RegExp(`\\${ext}$`), namespace: 'file' }, - async (args: any) => { - const contents = await fs.promises.readFile(args.path, 'utf8'); - return { - contents: buildFederationHost(config) + contents, - loader, - }; - }, - ); - } - - // Fallback loader for files not matched by custom loaders - const fallbackFilter = new RegExp( - Object.keys(loaders) - .map((ext) => `\\${ext}$`) - .join('|'), - ); - - build.onLoad( - { filter: /.*\.(ts|js|mjs)$/, namespace: 'file' }, - //@ts-ignore - async (args: any) => { - if (!fallbackFilter.test(args.path)) { - if ( - !build.initialOptions.entryPoints.some((e: string) => - args.path.includes(e), - ) - ) { - return; - } - const contents = await fs.promises.readFile(args.path, 'utf8'); - return { contents: 'import "federation-host"; \n ' + contents }; - } - }, - ); - }, -}); diff --git a/packages/esbuild/src/adapters/lib/lexer.ts b/packages/esbuild/src/adapters/lib/lexer.ts deleted file mode 100644 index 377e1b7a841..00000000000 --- a/packages/esbuild/src/adapters/lib/lexer.ts +++ /dev/null @@ -1,146 +0,0 @@ -// simplified from acorn (MIT license) - -function isNewLine(code: number): boolean { - return code === 10 || code === 13 || code === 0x2028 || code === 0x2029; -} - -function codePointToString(ch: number): string { - if (ch <= 0xffff) return String.fromCharCode(ch); - ch -= 0x10000; - return String.fromCharCode((ch >> 10) + 0xd800, (ch & 0x03ff) + 0xdc00); -} - -export class Lexer { - input = ''; - pos = 0; - - readString(input: string, pos: number): string | null { - if (pos >= input.length) return null; - this.input = input; - this.pos = pos; - - const quote = this.input.charCodeAt(pos); - if (!(quote === 34 || quote === 39)) return null; - - let out = ''; - let chunkStart = ++this.pos; - //eslint-disable-next-line no-constant-condition - while (true) { - if (this.pos >= this.input.length) return null; - const ch = this.input.charCodeAt(this.pos); - if (ch === quote) break; - if (ch === 92) { - out += this.input.slice(chunkStart, this.pos); - const escaped = this.readEscapedChar(); - if (escaped === null) return null; - out += escaped; - chunkStart = this.pos; - } else { - if (isNewLine(ch)) return null; - ++this.pos; - } - } - out += this.input.slice(chunkStart, this.pos++); - - return out; - } - - readEscapedChar(): string | null { - const ch = this.input.charCodeAt(++this.pos); - let code: number | null; - ++this.pos; - switch (ch) { - case 110: - return '\n'; - case 114: - return '\r'; - case 120: - code = this.readHexChar(2); - if (code === null) return null; - return String.fromCharCode(code); - case 117: - code = this.readCodePoint(); - if (code === null) return null; - return codePointToString(code); - case 116: - return '\t'; - case 98: - return '\b'; - case 118: - return '\u000b'; - case 102: - return '\f'; - //@ts-ignore - case 13: - if (this.input.charCodeAt(this.pos) === 10) { - ++this.pos; - } - // fall through - case 10: - return ''; - case 56: - case 57: - return null; - default: - if (ch >= 48 && ch <= 55) { - const match = this.input - .slice(this.pos - 1, this.pos + 2) - .match(/^[0-7]+/); - if (match === null) return null; - let octalStr = match[0]; - let octal = parseInt(octalStr, 8); - if (octal > 255) { - octalStr = octalStr.slice(0, -1); - octal = parseInt(octalStr, 8); - } - this.pos += octalStr.length - 1; - const nextCh = this.input.charCodeAt(this.pos); - if (octalStr !== '0' || nextCh === 56 || nextCh === 57) return null; - return String.fromCharCode(octal); - } - if (isNewLine(ch)) return ''; - return String.fromCharCode(ch); - } - } - - readInt(radix: number, len: number): number | null { - const start = this.pos; - let total = 0; - for (let i = 0; i < len; ++i, ++this.pos) { - const code = this.input.charCodeAt(this.pos); - let val: number; - if (code >= 97) { - val = code - 97 + 10; - } else if (code >= 65) { - val = code - 65 + 10; - } else if (code >= 48 && code <= 57) { - val = code - 48; - } else { - val = Infinity; - } - if (val >= radix) break; - total = total * radix + val; - } - if (this.pos === start || (len != null && this.pos - start !== len)) - return null; - return total; - } - - readHexChar(len: number): number | null { - return this.readInt(16, len); - } - - readCodePoint(): number | null { - const ch = this.input.charCodeAt(this.pos); - let code: number | null; - if (ch === 123) { - ++this.pos; - code = this.readHexChar(this.input.indexOf('}', this.pos) - this.pos); - ++this.pos; - if (code && code > 0x10ffff) return null; - } else { - code = this.readHexChar(4); - } - return code; - } -} diff --git a/packages/esbuild/src/adapters/lib/linkRemotesPlugin.ts b/packages/esbuild/src/adapters/lib/linkRemotesPlugin.ts deleted file mode 100644 index a1ceab9790d..00000000000 --- a/packages/esbuild/src/adapters/lib/linkRemotesPlugin.ts +++ /dev/null @@ -1,43 +0,0 @@ -import path from 'path'; -import { NormalizedFederationConfig } from '../../lib/config/federation-config'; - -// relys on import map since i dont know the named exports of a remote to return. -export const createVirtualRemoteModule = (name: string, ref: string) => ` -export * from ${JSON.stringify('federationRemote/' + ref)} -`; - -export const linkRemotesPlugin = (config: NormalizedFederationConfig) => ({ - name: 'linkRemotes', - setup(build: any) { - const remotes = config.remotes || {}; - const filter = new RegExp( - Object.keys(remotes) - .reduce((acc, key) => { - if (!key) return acc; - acc.push(`^${key}`); - return acc; - }, [] as string[]) - .join('|'), - ); - - build.onResolve({ filter: filter }, async (args: any) => { - return { path: args.path, namespace: 'remote-module' }; - }); - - build.onResolve({ filter: /^federationRemote/ }, async (args: any) => { - return { - path: args.path.replace('federationRemote/', ''), - external: true, - namespace: 'externals', - }; - }); - - build.onLoad({ filter, namespace: 'remote-module' }, async (args: any) => { - return { - contents: createVirtualRemoteModule(config.name, args.path), - loader: 'js', - resolveDir: path.dirname(args.path), - }; - }); - }, -}); diff --git a/packages/esbuild/src/adapters/lib/manifest.spec.ts b/packages/esbuild/src/adapters/lib/manifest.spec.ts new file mode 100644 index 00000000000..807fec78424 --- /dev/null +++ b/packages/esbuild/src/adapters/lib/manifest.spec.ts @@ -0,0 +1,56 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import type { BuildResult } from 'esbuild'; +import type { NormalizedFederationConfig } from '../../lib/config/federation-config'; +import { writeRemoteManifest } from './manifest'; + +describe('writeRemoteManifest', () => { + it('should resolve pluginVersion from package root package.json', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mf-manifest-test-')); + const distDir = path.join(dir, 'dist'); + fs.mkdirSync(distDir, { recursive: true }); + + const config: NormalizedFederationConfig = { + name: 'mfe1', + filename: 'remoteEntry.js', + exposes: { './component': './src/Component.js' }, + remotes: {}, + shared: {}, + }; + + const chunkPath = path.join(distDir, 'remoteEntry.js'); + const result = { + errors: [], + warnings: [], + metafile: { + inputs: {}, + outputs: { + [chunkPath]: { + bytes: 1, + imports: [], + exports: ['get', 'init'], + entryPoint: 'mf-container:remoteEntry.js', + inputs: {}, + }, + }, + }, + } as BuildResult; + + await writeRemoteManifest(config, result); + + const manifestPath = path.join(distDir, 'mf-manifest.json'); + expect(fs.existsSync(manifestPath)).toBe(true); + + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + const expectedVersion = JSON.parse( + fs.readFileSync( + path.resolve(__dirname, '../../../package.json'), + 'utf-8', + ), + ).version; + expect(manifest.metaData.pluginVersion).toBe(expectedVersion); + + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/packages/esbuild/src/adapters/lib/manifest.ts b/packages/esbuild/src/adapters/lib/manifest.ts index 6aaaa3280a0..b07b55cc0b9 100644 --- a/packages/esbuild/src/adapters/lib/manifest.ts +++ b/packages/esbuild/src/adapters/lib/manifest.ts @@ -1,21 +1,11 @@ import fs from 'fs'; import path from 'path'; -import { resolve } from './collect-exports.js'; -import { - BuildOptions, - PluginBuild, - Plugin, - OnResolveArgs, - OnLoadArgs, - BuildResult, - BuildContext, -} from 'esbuild'; -//@ts-expect-error -import { version as pluginVersion } from '@module-federation/esbuild/package.json'; +import type { BuildResult } from 'esbuild'; +import type { NormalizedFederationConfig } from '../../lib/config/federation-config'; interface OutputFile { entryPoint?: string; - imports?: { path: string }[]; + imports?: { path: string; kind?: string }[]; exports?: string[]; kind?: string; chunk: string; @@ -81,129 +71,205 @@ interface Manifest { exposes: ExposeConfig[]; } -export const writeRemoteManifest = async (config: any, result: BuildResult) => { +/** + * Collect assets (JS and CSS chunks) for a given output entry. + */ +function getChunks( + meta: OutputFile | undefined, + outputMap: Record, +): Assets { + const assets: Assets = { + js: { async: [], sync: [] }, + css: { async: [], sync: [] }, + }; + + if (!meta?.imports) return assets; + + for (const imp of meta.imports) { + const importMeta = outputMap[imp.path]; + if (importMeta && imp.kind !== 'dynamic-import') { + const childAssets = getChunks(importMeta, outputMap); + assets.js.async.push(...childAssets.js.async); + assets.js.sync.push(...childAssets.js.sync); + assets.css.async.push(...childAssets.css.async); + assets.css.sync.push(...childAssets.css.sync); + } + } + + if (meta.chunk) { + const assetType = meta.chunk.endsWith('.css') ? 'css' : 'js'; + const syncOrAsync = meta.kind === 'dynamic-import' ? 'async' : 'sync'; + assets[assetType][syncOrAsync].push(meta.chunk); + } + + return assets; +} + +/** + * Read the package version. Uses a safe approach that works in both + * CJS and ESM contexts. + */ +function getPluginVersion(): string { + let currentDir = __dirname; + for (let i = 0; i < 8; i++) { + const pkgPath = path.join(currentDir, 'package.json'); + try { + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + if (pkg?.name === '@module-federation/esbuild' && pkg?.version) { + return pkg.version; + } + } + } catch { + // ignore and continue walking up directories + } + const nextDir = path.dirname(currentDir); + if (nextDir === currentDir) break; + currentDir = nextDir; + } + return '0.0.0'; +} + +/** + * Write the mf-manifest.json file for runtime module discovery. + * + * The manifest contains metadata about: + * - Remote entry point location + * - Shared dependencies with versions + * - Remote configurations + * - Exposed modules and their assets + */ +export async function writeRemoteManifest( + config: NormalizedFederationConfig, + result: BuildResult, +): Promise { if (result.errors && result.errors.length > 0) { - console.warn('Build errors detected, skipping writeRemoteManifest.'); + console.warn( + '[module-federation] Build errors detected, skipping manifest generation.', + ); return; } - let packageJson: { name: string }; - try { - const packageJsonPath = - (await resolve(process.cwd(), '/package.json')) || ''; - packageJson = require(packageJsonPath); - } catch (e) { - packageJson = { name: config.name }; - } - const envType = - process.env['NODE_ENV'] === 'development' - ? 'local' - : process.env['NODE_ENV'] ?? ''; + if (!result.metafile?.outputs) return; + + const pluginVersion = getPluginVersion(); const publicPath = config.publicPath || 'auto'; - let containerName: string = ''; - - const outputMap: Record = Object.entries( - result.metafile?.outputs || {}, - ).reduce( - (acc, [chunkKey, chunkValue]) => { - const { entryPoint } = chunkValue; - const key = entryPoint || chunkKey; - if (key.startsWith('container:') && key.endsWith(config.filename)) { - containerName = key; - } - acc[key] = { ...chunkValue, chunk: chunkKey }; - return acc; - }, - {} as Record, - ); - - if (!outputMap[containerName]) return; - - const outputMapWithoutExt: Record = Object.entries( - result.metafile?.outputs || {}, - ).reduce( - (acc, [chunkKey, chunkValue]) => { - const { entryPoint } = chunkValue; - const key = entryPoint || chunkKey; - const trimKey = key.substring(0, key.lastIndexOf('.')) || key; - acc[trimKey] = { ...chunkValue, chunk: chunkKey }; - return acc; - }, - {} as Record, - ); - - const getChunks = ( - meta: OutputFile | undefined, - outputMap: Record, - ): Assets => { - const assets: Assets = { - js: { async: [], sync: [] }, - css: { async: [], sync: [] }, - }; - if (meta?.imports) { - meta.imports.forEach((imp) => { - const importMeta = outputMap[imp.path]; - if (importMeta && importMeta.kind !== 'dynamic-import') { - const childAssets = getChunks(importMeta, outputMap); - assets.js.async.push(...childAssets.js.async); - assets.js.sync.push(...childAssets.js.sync); - assets.css.async.push(...childAssets.css.async); - assets.css.sync.push(...childAssets.css.sync); - } - }); + // Build output map indexed by entry point or chunk key + let containerName = ''; + const outputMap: Record = {}; - const assetType = meta.chunk.endsWith('.js') ? 'js' : 'css'; - const syncOrAsync = meta.kind === 'dynamic-import' ? 'async' : 'sync'; - assets[assetType][syncOrAsync].push(meta.chunk); + for (const [chunkKey, chunkValue] of Object.entries( + result.metafile.outputs, + )) { + const key = chunkValue.entryPoint || chunkKey; + if ( + key.startsWith('mf-container:') || + (key.endsWith(config.filename || 'remoteEntry.js') && + key.includes('container')) + ) { + containerName = key; } - return assets; - }; + // Also match direct filename + if ( + !containerName && + path.basename(chunkKey) === + path.basename(config.filename || 'remoteEntry.js') + ) { + containerName = key; + } + outputMap[key] = { ...chunkValue, chunk: chunkKey }; + } - const shared: SharedConfig[] = config.shared + // If no container entry found, try to find by filename + if (!containerName) { + for (const [chunkKey, chunkValue] of Object.entries( + result.metafile.outputs, + )) { + if ( + chunkKey.endsWith(path.basename(config.filename || 'remoteEntry.js')) + ) { + containerName = chunkValue.entryPoint || chunkKey; + break; + } + } + } + + // If still no container, skip manifest for host-only builds + if (!containerName || !outputMap[containerName]) { + return; + } + + // Build output map without extensions (for flexible matching) + const outputMapNoExt: Record = {}; + for (const [chunkKey, chunkValue] of Object.entries( + result.metafile.outputs, + )) { + const key = chunkValue.entryPoint || chunkKey; + const trimKey = key.substring(0, key.lastIndexOf('.')) || key; + outputMapNoExt[trimKey] = { ...chunkValue, chunk: chunkKey }; + } + + // Build shared module metadata + const sharedEntries: SharedConfig[] = config.shared ? await Promise.all( - Object.entries(config.shared).map( - async ([pkg, config]: [string, any]) => { - const meta = outputMap['esm-shares:' + pkg]; - const chunks = getChunks(meta, outputMap); - let { version } = config; - - if (!version) { - try { - const packageJsonPath = await resolve( - process.cwd(), - `${pkg}/package.json`, - ); - if (packageJsonPath) { - version = JSON.parse( - fs.readFileSync(packageJsonPath, 'utf-8'), - ).version; - } - } catch (e) { - console.warn( - `Can't resolve ${pkg} version automatically, consider setting "version" manually`, - ); + Object.entries(config.shared).map(async ([pkg, sharedCfg]) => { + const meta = outputMap['mf-shared:' + pkg]; + const chunks = getChunks(meta, outputMap); + let version = sharedCfg.version || ''; + + if (!version) { + try { + // Try to read version from node_modules + const pkgJsonPath = path.join( + process.cwd(), + 'node_modules', + pkg, + 'package.json', + ); + if (fs.existsSync(pkgJsonPath)) { + version = JSON.parse( + fs.readFileSync(pkgJsonPath, 'utf-8'), + ).version; } + } catch { + // Version unknown } + } - return { - id: `${config.name}:${pkg}`, - name: pkg, - version: version || config.version, - singleton: config.singleton || false, - requiredVersion: config.requiredVersion || '*', - assets: chunks, - }; - }, - ), + return { + id: `${config.name}:${pkg}`, + name: pkg, + version: version || sharedCfg.requiredVersion || '0.0.0', + singleton: sharedCfg.singleton || false, + requiredVersion: sharedCfg.requiredVersion || '*', + assets: chunks, + }; + }), ) : []; - const remotes: RemoteConfig[] = config.remotes - ? Object.entries(config.remotes).map(([alias, remote]: [string, any]) => { - const [federationContainerName, entry] = remote.includes('@') - ? remote.split('@') - : [alias, remote]; + // Build remote metadata + // Remotes can be strings ("http://...") or objects ({ entry: "http://...", shareScope: "..." }) + const remoteEntries: RemoteConfig[] = config.remotes + ? Object.entries(config.remotes).map(([alias, remote]) => { + let federationContainerName = alias; + let entry: string; + + if (typeof remote === 'string') { + entry = remote; + } else if (remote && typeof remote === 'object' && 'entry' in remote) { + entry = (remote as { entry: string }).entry; + } else { + entry = ''; + } + + // Parse name@url format + const match = entry.match(/^(.+?)@(https?:\/\/.+)$/); + if (match) { + federationContainerName = match[1]; + entry = match[2]; + } return { federationContainerName, @@ -214,31 +280,27 @@ export const writeRemoteManifest = async (config: any, result: BuildResult) => { }) : []; - const exposes: ExposeConfig[] = config.exposes + // Build expose metadata + const exposeEntries: ExposeConfig[] = config.exposes ? await Promise.all( - Object.entries(config.exposes).map( - async ([expose, value]: [string, any]) => { - const exposedFound = outputMapWithoutExt[value.replace('./', '')]; - const chunks = getChunks(exposedFound, outputMap); - - return { - id: `${config.name}:${expose.replace(/^\.\//, '')}`, - name: expose.replace(/^\.\//, ''), - assets: chunks, - path: expose, - }; - }, - ), + Object.entries(config.exposes).map(async ([expose, value]) => { + const found = + outputMapNoExt[value.replace('./', '')] || + outputMapNoExt[expose.replace('./', '')]; + const chunks = getChunks(found, outputMap); + + return { + id: `${config.name}:${expose.replace(/^\.\//, '')}`, + name: expose.replace(/^\.\//, ''), + assets: chunks, + path: expose, + }; + }), ) : []; - const types: TypesConfig = { - path: '', - name: '', - zip: '@mf-types.zip', - api: '@mf-types.d.ts', - }; - + // Build the manifest + const containerOutput = outputMap[containerName]; const manifest: Manifest = { id: config.name, name: config.name, @@ -246,32 +308,42 @@ export const writeRemoteManifest = async (config: any, result: BuildResult) => { name: config.name, type: 'app', buildInfo: { - buildVersion: envType, - buildName: (packageJson.name ?? 'default').replace( - /[^a-zA-Z0-9]/g, - '_', - ), + buildVersion: + process.env['NODE_ENV'] === 'development' + ? 'local' + : (process.env['NODE_ENV'] ?? ''), + buildName: config.name.replace(/[^a-zA-Z0-9]/g, '_'), }, remoteEntry: { - name: config.filename, - path: outputMap[containerName] - ? path.dirname(outputMap[containerName].chunk) - : '', + name: config.filename || 'remoteEntry.js', + path: containerOutput ? path.dirname(containerOutput.chunk) : '', type: 'esm', }, - types, + types: { + path: '', + name: '', + zip: '@mf-types.zip', + api: '@mf-types.d.ts', + }, globalName: config.name, pluginVersion, publicPath, }, - shared, - remotes, - exposes, + shared: sharedEntries, + remotes: remoteEntries, + exposes: exposeEntries, }; - const manifestPath = path.join( - path.dirname(outputMap[containerName].chunk), - 'mf-manifest.json', - ); - fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8'); -}; + // Write manifest to disk + const manifestDir = containerOutput + ? path.dirname(containerOutput.chunk) + : 'dist'; + const manifestPath = path.join(manifestDir, 'mf-manifest.json'); + + try { + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8'); + } catch (e) { + console.warn('[module-federation] Failed to write manifest:', e); + } +} diff --git a/packages/esbuild/src/adapters/lib/plugin.spec.ts b/packages/esbuild/src/adapters/lib/plugin.spec.ts new file mode 100644 index 00000000000..186ed71370a --- /dev/null +++ b/packages/esbuild/src/adapters/lib/plugin.spec.ts @@ -0,0 +1,1704 @@ +/** + * Comprehensive tests for the Module Federation esbuild plugin. + * + * Modeled after the webpack enhanced plugin test suite, covering: + * - Code generation for all virtual modules + * - Plugin setup and hook registration + * - Full esbuild integration builds + * - Config normalization (withFederation) + * - Container entry get/init protocol + * - Shared module negotiation patterns + * - Remote module loading patterns + * - Manifest generation + * - Edge cases, error handling, special characters + */ +import * as esbuild from 'esbuild'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { + moduleFederationPlugin, + generateRuntimeInitCode, + generateContainerEntryCode, + generateSharedProxyCode, + generateRemoteProxyCode, + transformRemoteImports, +} from './plugin'; +import type { + NormalizedFederationConfig, + NormalizedSharedConfig, +} from '../../lib/config/federation-config'; + +// ============================================================================= +// Helpers +// ============================================================================= + +function tmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'mf-esbuild-test-')); +} + +function rm(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + /* noop */ + } +} + +function writeFile(dir: string, name: string, content: string): string { + const fp = path.join(dir, name); + fs.mkdirSync(path.dirname(fp), { recursive: true }); + fs.writeFileSync(fp, content); + return fp; +} + +function host( + o: Partial = {}, +): NormalizedFederationConfig { + return { + name: 'host', + remotes: { mfe1: 'http://localhost:3001/remoteEntry.js' }, + shared: { + react: { + singleton: true, + strictVersion: false, + requiredVersion: '^18.2.0', + version: '18.2.0', + }, + }, + ...o, + }; +} + +function remote( + o: Partial = {}, +): NormalizedFederationConfig { + return { + name: 'mfe1', + filename: 'remoteEntry.js', + exposes: { './component': './src/Component' }, + shared: { + react: { + singleton: true, + strictVersion: false, + requiredVersion: '^18.2.0', + version: '18.2.0', + }, + }, + ...o, + }; +} + +/** Build helper that runs esbuild with the MF plugin. */ +async function build( + dir: string, + config: NormalizedFederationConfig, + files: Record, + opts: Partial = {}, +): Promise { + const srcDir = path.join(dir, 'src'); + const entries: string[] = []; + for (const [name, content] of Object.entries(files)) { + const fp = writeFile(dir, name, content); + if (name.startsWith('src/main')) entries.push(fp); + } + if (entries.length === 0) { + // use first file as entry + entries.push(path.join(dir, Object.keys(files)[0])); + } + + const { external: extraExternal, plugins: extraPlugins, ...restOpts } = opts; + return esbuild.build({ + entryPoints: entries, + outdir: path.join(dir, 'dist'), + bundle: true, + format: 'esm', + splitting: true, + write: true, + metafile: true, + ...restOpts, + external: ['@module-federation/runtime', ...(extraExternal || [])], + plugins: [moduleFederationPlugin(config), ...(extraPlugins || [])], + }); +} + +// ============================================================================= +// 1. Code Generation - Runtime Init +// ============================================================================= + +describe('generateRuntimeInitCode', () => { + it('should generate a module that imports from the runtime', () => { + const code = generateRuntimeInitCode(host()); + expect(code).toContain('import { init as __mfInit }'); + expect(code).toContain('@module-federation/runtime'); + }); + + it('should call init with container name', () => { + const code = generateRuntimeInitCode(host({ name: 'myHost' })); + expect(code).toContain('"myHost"'); + }); + + it('should escape unsafe characters in generated string literals', () => { + const code = generateRuntimeInitCode( + host({ name: 'myHost', shareScope: 'scope\u2028name' }), + ); + expect(code).toContain('"myHost\\u003C/script\\u003E"'); + expect(code).toContain('"scope\\u2028name"'); + }); + + it('should include all remote entries', () => { + const code = generateRuntimeInitCode( + host({ + remotes: { + r1: 'http://a.com/re.js', + r2: 'http://b.com/re.js', + r3: 'r3@https://c.com/re.js', + }, + }), + ); + expect(code).toContain('"r1"'); + expect(code).toContain('http://a.com/re.js'); + expect(code).toContain('"r2"'); + expect(code).toContain('http://b.com/re.js'); + expect(code).toContain('"r3"'); + expect(code).toContain('https://c.com/re.js'); + }); + + it('should parse name@http format', () => { + const code = generateRuntimeInitCode( + host({ remotes: { mfe1: 'mfe1@http://localhost:3001/re.js' } }), + ); + expect(code).toContain('"name":"mfe1"'); + expect(code).toContain('"entry":"http://localhost:3001/re.js"'); + }); + + it('should parse name@https format', () => { + const code = generateRuntimeInitCode( + host({ remotes: { x: 'myApp@https://cdn.com/re.js' } }), + ); + expect(code).toContain('"name":"myApp"'); + expect(code).toContain('"entry":"https://cdn.com/re.js"'); + }); + + it('should handle plain URL (no name@)', () => { + const code = generateRuntimeInitCode( + host({ remotes: { mfe1: 'http://localhost:3001/re.js' } }), + ); + expect(code).toContain('"name":"mfe1"'); + expect(code).toContain('"entry":"http://localhost:3001/re.js"'); + }); + + it('should set type to esm for all remotes', () => { + const code = generateRuntimeInitCode( + host({ remotes: { a: 'http://a.com/re.js' } }), + ); + expect(code).toContain('"type":"esm"'); + }); + + it('should include per-remote shareScope', () => { + const code = generateRuntimeInitCode( + host({ + remotes: { + mfe1: { + entry: 'http://localhost:3001/re.js', + shareScope: 'isolated', + }, + }, + }), + ); + expect(code).toContain('"shareScope":"isolated"'); + }); + + it('should include shared config with version/scope/get', () => { + const code = generateRuntimeInitCode(host()); + expect(code).toContain('"react"'); + expect(code).toContain('version: "18.2.0"'); + expect(code).toContain('scope: "default"'); + expect(code).toContain('get:'); + }); + + it('should include shareConfig booleans', () => { + const code = generateRuntimeInitCode(host()); + expect(code).toContain('singleton: true'); + expect(code).toContain('strictVersion: false'); + expect(code).toContain('eager: false'); + }); + + it('should use dynamic import for non-eager shared', () => { + const code = generateRuntimeInitCode(host()); + expect(code).toContain('import("__mf_fallback__/react")'); + expect(code).not.toContain('import * as __mfEager'); + }); + + it('should use static import for eager shared', () => { + const code = generateRuntimeInitCode( + host({ + shared: { + react: { + singleton: true, + strictVersion: false, + requiredVersion: '^18.0.0', + version: '18.2.0', + eager: true, + }, + }, + }), + ); + expect(code).toContain('import * as __mfEager_react'); + expect(code).toContain('Promise.resolve'); + expect(code).not.toContain('import("__mf_fallback__/react")'); + }); + + it('should handle import:false (no fallback)', () => { + const code = generateRuntimeInitCode( + host({ + shared: { + react: { + singleton: true, + strictVersion: false, + requiredVersion: '^18.0.0', + import: false, + }, + }, + }), + ); + expect(code).toContain('undefined'); + expect(code).not.toContain('__mf_fallback__/react'); + }); + + it('should use custom shareKey', () => { + const code = generateRuntimeInitCode( + host({ + shared: { + react: { + singleton: true, + strictVersion: false, + requiredVersion: '^18.0.0', + version: '18.2.0', + shareKey: 'my-react', + }, + }, + }), + ); + // The key in the shared object should be the shareKey + expect(code).toContain('"my-react"'); + }); + + it('should use per-shared shareScope', () => { + const code = generateRuntimeInitCode( + host({ + shared: { + react: { + singleton: true, + strictVersion: false, + requiredVersion: '^18.0.0', + version: '18.2.0', + shareScope: 'react-only', + }, + }, + }), + ); + expect(code).toContain('scope: "react-only"'); + }); + + it('should use global shareScope', () => { + const code = generateRuntimeInitCode(host({ shareScope: 'myScope' })); + expect(code).toContain('initializeSharing("myScope"'); + expect(code).toContain('scope: "myScope"'); + }); + + it('should default shareScope to "default"', () => { + const code = generateRuntimeInitCode(host()); + expect(code).toContain('initializeSharing("default"'); + }); + + it('should use shareStrategy from config', () => { + const code = generateRuntimeInitCode( + host({ shareStrategy: 'loaded-first' }), + ); + expect(code).toContain('"loaded-first"'); + }); + + it('should default shareStrategy to version-first', () => { + const code = generateRuntimeInitCode(host()); + expect(code).toContain('"version-first"'); + }); + + it('should call initializeSharing with await', () => { + const code = generateRuntimeInitCode(host()); + expect(code).toContain('await Promise.all(__mfSharePromises)'); + }); + + it('should wrap initializeSharing in try/catch', () => { + const code = generateRuntimeInitCode(host()); + expect(code).toContain('try {'); + expect(code).toContain('} catch(__mfErr)'); + }); + + it('should inject runtimePlugins', () => { + const code = generateRuntimeInitCode( + host({ runtimePlugins: ['./plug1.js', '@mf/logger'] }), + ); + expect(code).toContain('import __mfRuntimePlugin0 from "./plug1.js"'); + expect(code).toContain('import __mfRuntimePlugin1 from "@mf/logger"'); + expect(code).toContain('plugins: __mfPlugins'); + }); + + it('should not inject plugins section when no runtimePlugins', () => { + const code = generateRuntimeInitCode(host()); + expect(code).not.toContain('plugins:'); + expect(code).not.toContain('__mfRuntimePlugin'); + }); + + it('should handle empty remotes', () => { + const code = generateRuntimeInitCode(host({ remotes: {} })); + expect(code).toContain('remotes: []'); + }); + + it('should handle empty shared', () => { + const code = generateRuntimeInitCode(host({ shared: {} })); + expect(code).toContain('shared: {'); + expect(code).toContain('}'); + }); + + it('should handle multiple shared deps', () => { + const code = generateRuntimeInitCode( + host({ + shared: { + react: { + singleton: true, + strictVersion: false, + requiredVersion: '^18.0.0', + version: '18.2.0', + }, + 'react-dom': { + singleton: true, + strictVersion: false, + requiredVersion: '^18.0.0', + version: '18.2.0', + }, + lodash: { + singleton: false, + strictVersion: true, + requiredVersion: '^4.17.0', + version: '4.17.21', + }, + }, + }), + ); + expect(code).toContain('"react"'); + expect(code).toContain('"react-dom"'); + expect(code).toContain('"lodash"'); + }); +}); + +// ============================================================================= +// 2. Code Generation - Container Entry +// ============================================================================= + +describe('generateContainerEntryCode', () => { + it('should export get function', () => { + const code = generateContainerEntryCode(remote()); + expect(code).toContain('export function get(module, getScope)'); + }); + + it('should export init function', () => { + const code = generateContainerEntryCode(remote()); + expect(code).toContain( + 'export function init(shareScope, initScope, remoteEntryInitOptions)', + ); + }); + + it('should have module map with exposes', () => { + const code = generateContainerEntryCode(remote()); + expect(code).toContain('"./component"'); + expect(code).toContain('import("./src/Component")'); + }); + + it('should escape unsafe characters in expose import paths', () => { + const code = generateContainerEntryCode( + remote({ + exposes: { + './component': './src//entry', + }, + }), + ); + expect(code).toContain('import("./src/\\u003CComponent\\u003E/entry")'); + }); + + it('should return factory from get()', () => { + const code = generateContainerEntryCode(remote()); + expect(code).toContain('return function() { return m; }'); + }); + + it('should throw for unknown module in get()', () => { + const code = generateContainerEntryCode(remote()); + expect(code).toContain('does not exist in container'); + expect(code).toContain('"mfe1"'); + }); + + it('should handle multiple exposes', () => { + const code = generateContainerEntryCode( + remote({ + exposes: { + './Button': './src/Button', + './Input': './src/Input', + './utils': './src/utils', + '.': './src/index', + }, + }), + ); + expect(code).toContain('"./Button"'); + expect(code).toContain('"./Input"'); + expect(code).toContain('"./utils"'); + expect(code).toContain('"."'); + expect(code).toContain('import("./src/Button")'); + expect(code).toContain('import("./src/index")'); + }); + + it('should call initShareScopeMap in init()', () => { + const code = generateContainerEntryCode(remote()); + expect(code).toContain('initShareScopeMap('); + expect(code).toContain('hostShareScopeMap'); + }); + + it('should call initOptions in init()', () => { + const code = generateContainerEntryCode(remote()); + expect(code).toContain('__mfInstance.initOptions('); + }); + + it('should forward initScope', () => { + const code = generateContainerEntryCode(remote()); + expect(code).toContain('initScope: initScope'); + }); + + it('should call initializeSharing in init()', () => { + const code = generateContainerEntryCode(remote()); + expect(code).toContain('initializeSharing('); + }); + + it('should use shareStrategy', () => { + const code = generateContainerEntryCode( + remote({ shareStrategy: 'loaded-first' }), + ); + expect(code).toContain('"loaded-first"'); + }); + + it('should use custom shareScope', () => { + const code = generateContainerEntryCode(remote({ shareScope: 'custom' })); + expect(code).toContain('initializeSharing("custom"'); + expect(code).toContain('initShareScopeMap("custom"'); + }); + + it('should include shared deps', () => { + const code = generateContainerEntryCode(remote()); + expect(code).toContain('"react"'); + expect(code).toContain('__mf_fallback__/react'); + }); + + it('should handle eager shared', () => { + const code = generateContainerEntryCode( + remote({ + shared: { + react: { + singleton: true, + strictVersion: false, + requiredVersion: '^18.0.0', + version: '18.2.0', + eager: true, + }, + }, + }), + ); + expect(code).toContain('import * as __mfEager_react'); + }); + + it('should inject runtimePlugins', () => { + const code = generateContainerEntryCode( + remote({ runtimePlugins: ['./my-plugin.js'] }), + ); + expect(code).toContain('import __mfRuntimePlugin0 from "./my-plugin.js"'); + expect(code).toContain('plugins: __mfPlugins'); + }); + + it('should handle empty exposes', () => { + const code = generateContainerEntryCode(remote({ exposes: {} })); + expect(code).toContain('__mfModuleMap'); + }); + + it('should handle import:false in container shared', () => { + const code = generateContainerEntryCode( + remote({ + shared: { + react: { + singleton: true, + strictVersion: false, + requiredVersion: '^18.0.0', + import: false, + }, + }, + }), + ); + expect(code).toContain('undefined'); + expect(code).not.toContain('__mf_fallback__/react'); + }); +}); + +// ============================================================================= +// 3. Code Generation - Shared Proxy +// ============================================================================= + +describe('generateSharedProxyCode', () => { + const cfg = ( + o: Partial = {}, + ): NormalizedSharedConfig => ({ + singleton: true, + strictVersion: false, + requiredVersion: '^18.2.0', + ...o, + }); + + it('should call loadShare with package name', async () => { + const code = await generateSharedProxyCode('react', 'react', cfg()); + expect(code).toContain('loadShare("react")'); + }); + + it('should import from the MF runtime', async () => { + const code = await generateSharedProxyCode('react', 'react', cfg()); + expect(code).toContain('import { loadShare }'); + expect(code).toContain('@module-federation/runtime'); + }); + + it('should have fallback dynamic import', async () => { + const code = await generateSharedProxyCode('react', 'react', cfg()); + expect(code).toContain('import("__mf_fallback__/react")'); + }); + + it('should export default', async () => { + const code = await generateSharedProxyCode('react', 'react', cfg()); + expect(code).toContain('export default'); + }); + + it('should check for "default" in module', async () => { + const code = await generateSharedProxyCode('react', 'react', cfg()); + expect(code).toContain('"default" in __mfMod'); + }); + + it('should handle subpath imports', async () => { + const code = await generateSharedProxyCode( + 'react/jsx-runtime', + 'react', + cfg(), + ); + expect(code).toContain('loadShare("react/jsx-runtime")'); + expect(code).toContain('__mf_fallback__/react/jsx-runtime'); + }); + + it('should have catch for subpath loadShare', async () => { + const code = await generateSharedProxyCode( + 'react/jsx-runtime', + 'react', + cfg(), + ); + expect(code).toContain('catch(__mfErr)'); + }); + + it('should handle import:false', async () => { + const code = await generateSharedProxyCode( + 'react', + 'react', + cfg({ import: false }), + ); + expect(code).toContain('throw new Error'); + expect(code).toContain('import:false prevents local fallback'); + expect(code).not.toContain('__mf_fallback__'); + }); + + it('should use custom shareKey in loadShare but real package for fallback', async () => { + const code = await generateSharedProxyCode( + 'react', + 'react', + cfg({ shareKey: 'my-react' }), + ); + // loadShare uses the shareKey for share scope negotiation + expect(code).toContain('loadShare("my-react")'); + // Fallback uses the real package name for disk resolution + expect(code).toContain('__mf_fallback__/react'); + expect(code).not.toContain('__mf_fallback__/my-react'); + }); + + it('should handle scoped package', async () => { + const code = await generateSharedProxyCode( + '@emotion/react', + '@emotion/react', + cfg({ requiredVersion: '^11.0.0' }), + ); + expect(code).toContain('loadShare("@emotion/react")'); + expect(code).toContain('__mf_fallback__/@emotion/react'); + }); + + it('should handle scoped package subpath', async () => { + const code = await generateSharedProxyCode( + '@emotion/react/jsx-runtime', + '@emotion/react', + cfg({ requiredVersion: '^11.0.0' }), + ); + expect(code).toContain('loadShare("@emotion/react/jsx-runtime")'); + expect(code).toContain('__mf_fallback__/@emotion/react/jsx-runtime'); + }); + + it('should handle packages with dots', async () => { + const code = await generateSharedProxyCode('core.js', 'core.js', cfg()); + expect(code).toContain('loadShare("core.js")'); + }); + + it('should log warning on top-level loadShare failure', async () => { + const code = await generateSharedProxyCode('react', 'react', cfg()); + expect(code).toContain('console.warn'); + }); +}); + +// ============================================================================= +// 4. Code Generation - Remote Proxy +// ============================================================================= + +describe('generateRemoteProxyCode', () => { + it('should call loadRemote', () => { + const code = generateRemoteProxyCode('mfe1/component'); + expect(code).toContain('loadRemote("mfe1/component")'); + }); + + it('should import from runtime', () => { + const code = generateRemoteProxyCode('mfe1/component'); + expect(code).toContain('import { loadRemote }'); + expect(code).toContain('@module-federation/runtime'); + }); + + it('should use top-level await', () => { + const code = generateRemoteProxyCode('mfe1/component'); + expect(code).toContain('await loadRemote'); + }); + + it('should throw on null result', () => { + const code = generateRemoteProxyCode('mfe1/component'); + expect(code).toContain('throw new Error'); + expect(code).toContain('Failed to load remote module'); + }); + + it('should export default', () => { + const code = generateRemoteProxyCode('mfe1/component'); + expect(code).toContain('export default'); + }); + + it('should prefer module.default', () => { + const code = generateRemoteProxyCode('mfe1/component'); + expect(code).toContain('"default" in __mfRemote'); + expect(code).toContain('__mfRemote["default"]'); + }); + + it('should export __mfModule for full access', () => { + const code = generateRemoteProxyCode('mfe1/component'); + expect(code).toContain('export var __mfModule = __mfRemote'); + }); + + it('should handle deep path', () => { + const code = generateRemoteProxyCode('mfe1/components/deep/Button'); + expect(code).toContain('loadRemote("mfe1/components/deep/Button")'); + }); + + it('should handle dashes in remote name', () => { + const code = generateRemoteProxyCode('my-app/utils'); + expect(code).toContain('loadRemote("my-app/utils")'); + }); +}); + +// ============================================================================= +// 5. Plugin Object +// ============================================================================= + +describe('moduleFederationPlugin', () => { + it('should return plugin with correct name', () => { + expect(moduleFederationPlugin(host()).name).toBe('module-federation'); + }); + + it('should have a setup function', () => { + expect(typeof moduleFederationPlugin(host()).setup).toBe('function'); + }); + + it('should accept minimal config', () => { + expect(moduleFederationPlugin({ name: 'x' })).toBeDefined(); + }); + + it('should accept host config', () => { + expect(moduleFederationPlugin(host())).toBeDefined(); + }); + + it('should accept remote config', () => { + expect(moduleFederationPlugin(remote())).toBeDefined(); + }); + + it('should accept combined config', () => { + expect( + moduleFederationPlugin({ + name: 'shell', + filename: 'remoteEntry.js', + remotes: { mfe1: 'http://a.com/re.js' }, + exposes: { './H': './src/H' }, + shared: { + react: { + singleton: true, + strictVersion: false, + requiredVersion: '^18.0.0', + }, + }, + shareScope: 'myScope', + runtimePlugins: ['./p.js'], + publicPath: 'https://cdn.com/', + shareStrategy: 'loaded-first', + }), + ).toBeDefined(); + }); +}); + +// ============================================================================= +// 6. esbuild Integration Builds +// ============================================================================= + +describe('esbuild integration', () => { + let dir: string; + beforeEach(() => { + dir = tmpDir(); + }); + afterEach(() => rm(dir)); + + it('should build a host with shared deps', async () => { + const result = await build( + dir, + { + name: 'host', + remotes: {}, + shared: { + 'some-lib': { + singleton: true, + strictVersion: false, + requiredVersion: '^1.0.0', + version: '1.0.0', + }, + }, + }, + { 'src/main.js': 'console.log("hello");\n' }, + { external: ['some-lib'] }, + ); + expect(result.errors).toHaveLength(0); + expect(fs.readdirSync(path.join(dir, 'dist')).length).toBeGreaterThan(0); + }); + + it('should build a container with exposes', async () => { + const compFile = writeFile( + dir, + 'src/Component.js', + 'export default function C() {}\n', + ); + const result = await build( + dir, + { + name: 'mfe1', + filename: 'remoteEntry.js', + exposes: { './component': compFile }, + shared: {}, + }, + { 'src/main.js': 'console.log("app");\n' }, + ); + expect(result.errors).toHaveLength(0); + }); + + it('should preserve nested filename path with object entryPoints', async () => { + const cFile = writeFile(dir, 'src/C.js', 'export default 1;\n'); + const main = writeFile(dir, 'src/main.js', 'console.log(1);\n'); + const result = await esbuild.build({ + entryPoints: { main }, + outdir: path.join(dir, 'dist'), + bundle: true, + format: 'esm', + splitting: true, + write: false, + external: ['@module-federation/runtime'], + plugins: [ + moduleFederationPlugin({ + name: 'mfe1', + filename: 'mf/remoteEntry.js', + exposes: { './C': cFile }, + shared: {}, + }), + ], + }); + expect(result.errors).toHaveLength(0); + const outputs = + result.outputFiles?.map((f) => + path.relative(path.join(dir, 'dist'), f.path).replace(/\\/g, '/'), + ) || []; + expect(outputs).toContain('mf/remoteEntry.js'); + expect(outputs).not.toContain('remoteEntry.js'); + }); + + it('should auto-set format and splitting', async () => { + const result = await esbuild.build({ + entryPoints: [writeFile(dir, 'src/main.js', 'console.log(1);\n')], + outdir: path.join(dir, 'dist'), + bundle: true, + write: true, + plugins: [moduleFederationPlugin({ name: 'test' })], + }); + expect(result.errors).toHaveLength(0); + }); + + it('should enable metafile', async () => { + const result = await build( + dir, + { name: 'test' }, + { + 'src/main.js': 'console.log(1);\n', + }, + ); + expect(result.metafile).toBeDefined(); + }); + + it('should inject runtime init into entry', async () => { + const result = await esbuild.build({ + entryPoints: [writeFile(dir, 'src/main.js', 'export const x = 1;\n')], + outdir: path.join(dir, 'dist'), + bundle: true, + format: 'esm', + splitting: true, + write: false, + external: ['@module-federation/runtime'], + plugins: [ + moduleFederationPlugin({ + name: 'host', + shared: {}, + remotes: { m: 'http://a.com/re.js' }, + }), + ], + }); + expect(result.errors).toHaveLength(0); + const main = result.outputFiles?.find((f) => f.path.includes('main')); + expect(main).toBeDefined(); + expect(main!.text).toContain('@module-federation/runtime'); + }); + + it('should NOT inject runtime init when no remotes/shared', async () => { + const result = await esbuild.build({ + entryPoints: [writeFile(dir, 'src/main.js', 'export const x = 1;\n')], + outdir: path.join(dir, 'dist'), + bundle: true, + format: 'esm', + splitting: true, + write: false, + plugins: [moduleFederationPlugin({ name: 'test' })], + }); + expect(result.errors).toHaveLength(0); + const main = result.outputFiles?.find((f) => f.path.includes('main')); + expect(main).toBeDefined(); + expect(main!.text).not.toContain('__mf_runtime_init__'); + }); + + it('should handle remote imports as virtual modules', async () => { + const result = await esbuild.build({ + entryPoints: [ + writeFile( + dir, + 'src/main.js', + `import R from 'mfe1/component';\nexport default R;\n`, + ), + ], + outdir: path.join(dir, 'dist'), + bundle: true, + format: 'esm', + splitting: true, + write: false, + external: ['@module-federation/runtime'], + plugins: [ + moduleFederationPlugin({ + name: 'host', + shared: {}, + remotes: { mfe1: 'http://a.com/re.js' }, + }), + ], + }); + expect(result.errors).toHaveLength(0); + const all = result.outputFiles?.map((f) => f.text).join('\n') || ''; + expect(all).toContain('loadRemote'); + }); + + it('should produce valid ESM output', async () => { + const result = await esbuild.build({ + entryPoints: [writeFile(dir, 'src/main.js', 'export const x = 1;\n')], + outdir: path.join(dir, 'dist'), + bundle: true, + format: 'esm', + splitting: true, + write: false, + external: ['@module-federation/runtime', 'some-lib'], + plugins: [ + moduleFederationPlugin({ + name: 'host', + shared: { + 'some-lib': { + singleton: true, + strictVersion: false, + requiredVersion: '*', + version: '1.0.0', + }, + }, + }), + ], + }); + expect(result.errors).toHaveLength(0); + // Output files should be ESM (contain export/import keywords) + for (const f of result.outputFiles || []) { + if (f.path.endsWith('.js')) { + // Basic ESM check: should not have module.exports + expect(f.text).not.toContain('module.exports'); + } + } + }); + + it('should build container entry that has get and init', async () => { + const cFile = writeFile(dir, 'src/C.js', 'export default 1;\n'); + const result = await esbuild.build({ + entryPoints: [writeFile(dir, 'src/main.js', 'console.log(1);\n')], + outdir: path.join(dir, 'dist'), + bundle: true, + format: 'esm', + splitting: true, + write: false, + external: ['@module-federation/runtime'], + plugins: [ + moduleFederationPlugin({ + name: 'mfe1', + filename: 'remoteEntry.js', + exposes: { './C': cFile }, + shared: {}, + }), + ], + }); + expect(result.errors).toHaveLength(0); + const all = result.outputFiles?.map((f) => f.text).join('\n') || ''; + expect(all).toContain('function get('); + expect(all).toContain('function init('); + }); + + it('should build with multiple shared deps', async () => { + const result = await build( + dir, + { + name: 'host', + remotes: {}, + shared: { + a: { + singleton: true, + strictVersion: false, + requiredVersion: '*', + version: '1.0.0', + }, + b: { + singleton: false, + strictVersion: true, + requiredVersion: '^2.0.0', + version: '2.1.0', + }, + }, + }, + { 'src/main.js': 'console.log(1);\n' }, + { external: ['a', 'b'] }, + ); + expect(result.errors).toHaveLength(0); + }); + + it('should build with eager shared dep', async () => { + const result = await build( + dir, + { + name: 'host', + remotes: {}, + shared: { + mylib: { + singleton: true, + strictVersion: false, + requiredVersion: '*', + version: '1.0.0', + eager: true, + }, + }, + }, + { 'src/main.js': 'console.log(1);\n' }, + { external: ['mylib'] }, + ); + expect(result.errors).toHaveLength(0); + }); +}); + +// ============================================================================= +// 7. withFederation Config Normalization +// ============================================================================= + +describe('withFederation', () => { + let withFederation: (c: any) => any; + beforeAll(async () => { + withFederation = (await import('../../lib/config/with-federation')) + .withFederation; + }); + + it('should normalize basic config', () => { + const r = withFederation({ + name: 'test', + filename: 'remoteEntry.js', + shared: { react: { singleton: true } }, + }); + expect(r.name).toBe('test'); + expect(r.filename).toBe('remoteEntry.js'); + }); + + it('should append .js extension', () => { + expect( + withFederation({ name: 'x', filename: 'remoteEntry' }).filename, + ).toBe('remoteEntry.js'); + }); + + it('should not double .js', () => { + expect( + withFederation({ name: 'x', filename: 'remoteEntry.js' }).filename, + ).toBe('remoteEntry.js'); + }); + + it('should preserve .mjs', () => { + expect( + withFederation({ name: 'x', filename: 'remoteEntry.mjs' }).filename, + ).toBe('remoteEntry.mjs'); + }); + + it('should default filename to remoteEntry.js', () => { + expect(withFederation({ name: 'x' }).filename).toBe('remoteEntry.js'); + }); + + it('should default name to empty', () => { + expect(withFederation({}).name).toBe(''); + }); + + it('should default exposes/remotes', () => { + const r = withFederation({ name: 'x' }); + expect(r.exposes).toEqual({}); + expect(r.remotes).toEqual({}); + }); + + it('should normalize shared config', () => { + const r = withFederation({ + name: 'x', + shared: { react: { singleton: true, version: '18.2.0' } }, + }); + expect(r.shared.react.singleton).toBe(true); + expect(r.shared.react.version).toBe('18.2.0'); + }); + + it('should default shared booleans', () => { + const r = withFederation({ + name: 'x', + shared: { react: {} }, + }); + expect(r.shared.react.singleton).toBe(false); + expect(r.shared.react.strictVersion).toBe(false); + expect(r.shared.react.requiredVersion).toBe('auto'); + }); + + // Pass-through fields + it('should pass through shareScope', () => { + expect(withFederation({ name: 'x', shareScope: 's' }).shareScope).toBe('s'); + }); + + it('should pass through shareStrategy', () => { + expect( + withFederation({ name: 'x', shareStrategy: 'loaded-first' }) + .shareStrategy, + ).toBe('loaded-first'); + }); + + it('should pass through runtimePlugins', () => { + expect( + withFederation({ name: 'x', runtimePlugins: ['a', 'b'] }).runtimePlugins, + ).toEqual(['a', 'b']); + }); + + it('should pass through publicPath', () => { + expect( + withFederation({ name: 'x', publicPath: 'https://cdn.com/' }).publicPath, + ).toBe('https://cdn.com/'); + }); + + // Remote config objects + it('should normalize remote string', () => { + const r = withFederation({ + name: 'x', + remotes: { mfe1: 'http://a.com/re.js' }, + }); + expect(r.remotes.mfe1).toBe('http://a.com/re.js'); + }); + + it('should normalize remote config object', () => { + const r = withFederation({ + name: 'x', + remotes: { + mfe1: { + external: 'http://a.com/re.js', + shareScope: 'isolated', + }, + }, + }); + expect(r.remotes.mfe1.entry).toBe('http://a.com/re.js'); + expect(r.remotes.mfe1.shareScope).toBe('isolated'); + }); + + it('should normalize remote config with array external', () => { + const r = withFederation({ + name: 'x', + remotes: { + mfe1: { + external: ['http://a.com/re.js', 'http://b.com/re.js'], + shareScope: 'test', + }, + }, + }); + expect(r.remotes.mfe1.entry).toBe('http://a.com/re.js'); + }); + + // Shared advanced fields + it('should pass through import:false', () => { + const r = withFederation({ + name: 'x', + shared: { react: { import: false } }, + }); + expect(r.shared.react.import).toBe(false); + }); + + it('should pass through shareKey', () => { + const r = withFederation({ + name: 'x', + shared: { react: { shareKey: 'k' } }, + }); + expect(r.shared.react.shareKey).toBe('k'); + }); + + it('should pass through per-shared shareScope', () => { + const r = withFederation({ + name: 'x', + shared: { react: { shareScope: 'rs' } }, + }); + expect(r.shared.react.shareScope).toBe('rs'); + }); + + it('should pass through packageName', () => { + const r = withFederation({ + name: 'x', + shared: { react: { packageName: 'react-pkg' } }, + }); + expect(r.shared.react.packageName).toBe('react-pkg'); + }); + + it('should pass through eager', () => { + const r = withFederation({ + name: 'x', + shared: { react: { eager: true } }, + }); + expect(r.shared.react.eager).toBe(true); + }); +}); + +// ============================================================================= +// 8. Edge Cases & Error Handling +// ============================================================================= + +describe('edge cases', () => { + describe('container with no shared', () => { + it('should generate container entry without shared section crashing', () => { + const code = generateContainerEntryCode({ + name: 'bare', + filename: 'remoteEntry.js', + exposes: { './A': './A' }, + }); + expect(code).toContain('export function get('); + expect(code).toContain('export function init('); + }); + }); + + describe('host with no remotes', () => { + it('should generate init code with empty remotes', () => { + const code = generateRuntimeInitCode({ + name: 'hostOnly', + shared: { + react: { + singleton: true, + strictVersion: false, + requiredVersion: '^18.0.0', + version: '18.2.0', + }, + }, + }); + expect(code).toContain('remotes: []'); + }); + }); + + describe('config with only name', () => { + it('should generate minimal init code', () => { + const code = generateRuntimeInitCode({ name: 'minimal' }); + expect(code).toContain('"minimal"'); + expect(code).toContain('remotes: []'); + expect(code).toContain('shared: {'); + }); + }); + + describe('shared with import:false and custom shareKey', () => { + it('should use shareKey and skip fallback', async () => { + const code = await generateSharedProxyCode('react', 'react', { + singleton: true, + strictVersion: false, + requiredVersion: '^18.0.0', + import: false, + shareKey: 'store', + }); + expect(code).toContain('loadShare("store")'); + expect(code).not.toContain('__mf_fallback__'); + expect(code).toContain('import:false prevents local fallback'); + }); + }); + + describe('multiple share scopes', () => { + it('should put different shared deps in different scopes', () => { + const code = generateRuntimeInitCode({ + name: 'host', + shareScope: 'default', + shared: { + react: { + singleton: true, + strictVersion: false, + requiredVersion: '^18.0.0', + version: '18.2.0', + shareScope: 'react-scope', + }, + lodash: { + singleton: false, + strictVersion: false, + requiredVersion: '^4.0.0', + version: '4.17.21', + // uses global scope + }, + }, + }); + expect(code).toContain('scope: "react-scope"'); + expect(code).toContain('scope: "default"'); + }); + }); + + describe('version auto-detection', () => { + it('should use requiredVersion to derive version when version is empty', () => { + const code = generateRuntimeInitCode({ + name: 'host', + shared: { + react: { + singleton: true, + strictVersion: false, + requiredVersion: '^18.2.0', + // no version field + }, + }, + }); + // Should derive version from requiredVersion by stripping prefix + expect(code).toContain('version:'); + // Should contain some version string (derived from requiredVersion or auto-detected) + }); + }); + + describe('mixed eager and non-eager shared', () => { + it('should handle both eager and non-eager in the same config', () => { + const code = generateRuntimeInitCode({ + name: 'host', + shared: { + react: { + singleton: true, + strictVersion: false, + requiredVersion: '^18.0.0', + version: '18.2.0', + eager: true, + }, + 'react-dom': { + singleton: true, + strictVersion: false, + requiredVersion: '^18.0.0', + version: '18.2.0', + eager: false, + }, + lodash: { + singleton: false, + strictVersion: false, + requiredVersion: '^4.0.0', + version: '4.17.21', + }, + }, + }); + // react should be eager (static import) + expect(code).toContain('import * as __mfEager_react from'); + // react-dom and lodash should be non-eager (dynamic import) + expect(code).toContain('import("__mf_fallback__/react-dom")'); + expect(code).toContain('import("__mf_fallback__/lodash")'); + // Only react should have the eager var, not react-dom + expect(code).not.toContain('__mfEager_react_dom'); + expect(code).not.toContain('__mfEager_lodash'); + }); + }); + + describe('special characters in config', () => { + it('should handle exposed module with dot path', () => { + const code = generateContainerEntryCode({ + name: 'test', + filename: 'remoteEntry.js', + exposes: { '.': './src/index' }, + }); + expect(code).toContain('"."'); + }); + + it('should handle scoped package in shared', () => { + const code = generateRuntimeInitCode({ + name: 'host', + shared: { + '@scope/pkg': { + singleton: true, + strictVersion: false, + requiredVersion: '^1.0.0', + version: '1.0.0', + }, + }, + }); + expect(code).toContain('"@scope/pkg"'); + expect(code).toContain('__mf_fallback__/@scope/pkg'); + }); + + it('should handle remote with numbers in name', () => { + const code = generateRemoteProxyCode('app2/widget'); + expect(code).toContain('loadRemote("app2/widget")'); + }); + + it('should handle underscore in remote name', () => { + const code = generateRemoteProxyCode('my_app/utils'); + expect(code).toContain('loadRemote("my_app/utils")'); + }); + }); + + describe('runtimePlugins code generation', () => { + it('should handle single runtime plugin', () => { + const code = generateRuntimeInitCode( + host({ runtimePlugins: ['./single-plugin.js'] }), + ); + expect(code).toContain('import __mfRuntimePlugin0'); + expect(code).toContain('__mfRuntimePlugin0'); + }); + + it('should handle multiple runtime plugins', () => { + const code = generateRuntimeInitCode( + host({ + runtimePlugins: ['./a.js', './b.js', './c.js'], + }), + ); + expect(code).toContain('__mfRuntimePlugin0'); + expect(code).toContain('__mfRuntimePlugin1'); + expect(code).toContain('__mfRuntimePlugin2'); + }); + + it('should call plugins as functions or pass as objects', () => { + const code = generateRuntimeInitCode( + host({ runtimePlugins: ['./p.js'] }), + ); + expect(code).toContain( + 'typeof __mfRuntimePlugin0 === "function" ? __mfRuntimePlugin0() : __mfRuntimePlugin0', + ); + }); + }); + + describe('P1 regression: shareKey vs package name in fallback', () => { + it('should use package name (not shareKey) for fallback import path', async () => { + // When shareKey differs from the package name, the fallback import + // must resolve the actual package from node_modules, not the shareKey. + // e.g., shared react with shareKey "my-react" should fallback to + // __mf_fallback__/react, NOT __mf_fallback__/my-react + const code = await generateSharedProxyCode('react', 'react', { + singleton: true, + strictVersion: false, + requiredVersion: '^18.0.0', + shareKey: 'my-react', + }); + // loadShare should use the shareKey for scope negotiation + expect(code).toContain('loadShare("my-react")'); + // But the fallback import should use the actual package name + expect(code).toContain('__mf_fallback__/react'); + // Must NOT have __mf_fallback__/my-react + expect(code).not.toContain('__mf_fallback__/my-react'); + }); + + it('should use package name for fallback in runtime init too', () => { + // In the runtime init shared config, the get() factory must also + // use the real package name for the fallback import + const code = generateRuntimeInitCode({ + name: 'host', + shared: { + react: { + singleton: true, + strictVersion: false, + requiredVersion: '^18.0.0', + version: '18.2.0', + shareKey: 'aliased-react', + }, + }, + }); + // The shared entry key should be the shareKey + expect(code).toContain('"aliased-react"'); + // The fallback import should use the actual package name + expect(code).toContain('__mf_fallback__/react'); + expect(code).not.toContain('__mf_fallback__/aliased-react'); + }); + }); +}); + +// ============================================================================= +// 9. transformRemoteImports +// ============================================================================= + +describe('transformRemoteImports', () => { + const remotes = ['mfe1', 'mfe2', 'my-remote']; + + it('should transform named imports from remotes', async () => { + const code = `import { App } from 'mfe1/component';`; + const result = await transformRemoteImports(code, remotes); + expect(result).toContain( + 'import { __mfModule as __mfR0 } from "mfe1/component"', + ); + expect(result).toContain('const { App } = __mfR0'); + expect(result).not.toContain('import { App }'); + }); + + it('should transform multiple named imports', async () => { + const code = `import { App, Button, utils } from 'mfe1/component';`; + const result = await transformRemoteImports(code, remotes); + expect(result).toContain('const { App, Button, utils } = __mfR0'); + }); + + it('should transform aliased imports (as)', async () => { + const code = `import { App as RemoteApp, utils as remoteUtils } from 'mfe1/component';`; + const result = await transformRemoteImports(code, remotes); + expect(result).toContain( + 'const { App: RemoteApp, utils: remoteUtils } = __mfR0', + ); + }); + + it('should preserve default import alongside named imports', async () => { + const code = `import Default, { App } from 'mfe1/component';`; + const result = await transformRemoteImports(code, remotes); + expect(result).toContain( + 'import Default, { __mfModule as __mfR0 } from "mfe1/component"', + ); + expect(result).toContain('const { App } = __mfR0'); + }); + + it('should transform namespace imports', async () => { + const code = `import * as Mod from 'mfe1/component';`; + const result = await transformRemoteImports(code, remotes); + expect(result).toContain( + 'import { __mfModule as Mod } from "mfe1/component"', + ); + }); + + it('should NOT transform default-only imports', async () => { + const code = `import RemoteApp from 'mfe1/component';`; + const result = await transformRemoteImports(code, remotes); + // Should be unchanged + expect(result).toBe(code); + }); + + it('should NOT transform side-effect-only imports', async () => { + const code = `import 'mfe1/component';`; + const result = await transformRemoteImports(code, remotes); + expect(result).toBe(code); + }); + + it('should NOT transform imports from non-remote modules', async () => { + const code = `import { useState } from 'react';`; + const result = await transformRemoteImports(code, remotes); + expect(result).toBe(code); + }); + + it('should NOT transform TypeScript type-only imports', async () => { + const code = `import type { AppProps } from 'mfe1/component';`; + const result = await transformRemoteImports(code, remotes); + expect(result).toBe(code); + }); + + it('should handle multiple imports from different remotes', async () => { + const code = [ + `import { App } from 'mfe1/component';`, + `import { Widget } from 'mfe2/widget';`, + `import React from 'react';`, + ].join('\n'); + const result = await transformRemoteImports(code, remotes); + expect(result).toContain('const { App } = __mfR0'); + expect(result).toContain('const { Widget } = __mfR1'); + // React import should be unchanged + expect(result).toContain(`import React from 'react'`); + }); + + it('should handle remotes with dashes in the name', async () => { + const code = `import { helper } from 'my-remote/utils';`; + const result = await transformRemoteImports(code, remotes); + expect(result).toContain( + 'import { __mfModule as __mfR0 } from "my-remote/utils"', + ); + expect(result).toContain('const { helper } = __mfR0'); + }); + + it('should leave code without remote imports unchanged', async () => { + const code = `const x = 1;\nconsole.log(x);`; + const result = await transformRemoteImports(code, remotes); + expect(result).toBe(code); + }); + + it('should handle deep subpath remote imports', async () => { + const code = `import { Button } from 'mfe1/components/ui/Button';`; + const result = await transformRemoteImports(code, remotes); + expect(result).toContain('from "mfe1/components/ui/Button"'); + expect(result).toContain('const { Button } = __mfR0'); + }); + + it('should transform default re-exports without invalid identifiers', async () => { + const code = `export { default as RemoteApp, helper } from 'mfe1/component';`; + const result = await transformRemoteImports(code, remotes); + expect(result).toContain( + 'import { __mfModule as __mfR0 } from "mfe1/component"', + ); + expect(result).toContain('export {'); + expect(result).toContain('as RemoteApp'); + expect(result).not.toContain('var default ='); + }); +}); + +// ============================================================================= +// 10. Integration: named imports from remotes (webpack-like) +// ============================================================================= + +describe('integration: named imports from remotes', () => { + let dir: string; + beforeEach(() => { + dir = tmpDir(); + }); + afterEach(() => rm(dir)); + + it('should build successfully with named import from remote', async () => { + const result = await esbuild.build({ + entryPoints: [ + writeFile( + dir, + 'src/main.js', + `import { App } from 'mfe1/component';\nexport default App;\n`, + ), + ], + outdir: path.join(dir, 'dist'), + bundle: true, + format: 'esm', + splitting: true, + write: false, + external: ['@module-federation/runtime'], + plugins: [ + moduleFederationPlugin({ + name: 'host', + shared: {}, + remotes: { mfe1: 'http://localhost:3001/remoteEntry.js' }, + }), + ], + }); + expect(result.errors).toHaveLength(0); + const all = result.outputFiles?.map((f) => f.text).join('\n') || ''; + expect(all).toContain('loadRemote'); + }); + + it('should build with mixed default + named imports from remote', async () => { + const result = await esbuild.build({ + entryPoints: [ + writeFile( + dir, + 'src/main.js', + `import Default, { helper } from 'mfe1/utils';\nexport { Default, helper };\n`, + ), + ], + outdir: path.join(dir, 'dist'), + bundle: true, + format: 'esm', + splitting: true, + write: false, + external: ['@module-federation/runtime'], + plugins: [ + moduleFederationPlugin({ + name: 'host', + shared: {}, + remotes: { mfe1: 'http://localhost:3001/remoteEntry.js' }, + }), + ], + }); + expect(result.errors).toHaveLength(0); + }); + + it('should build with namespace import from remote', async () => { + const result = await esbuild.build({ + entryPoints: [ + writeFile( + dir, + 'src/main.js', + `import * as Remote from 'mfe1/utils';\nexport default Remote;\n`, + ), + ], + outdir: path.join(dir, 'dist'), + bundle: true, + format: 'esm', + splitting: true, + write: false, + external: ['@module-federation/runtime'], + plugins: [ + moduleFederationPlugin({ + name: 'host', + shared: {}, + remotes: { mfe1: 'http://localhost:3001/remoteEntry.js' }, + }), + ], + }); + expect(result.errors).toHaveLength(0); + }); + + it('should build with default re-export from remote', async () => { + const result = await esbuild.build({ + entryPoints: [ + writeFile( + dir, + 'src/main.js', + `export { default as RemoteApp } from 'mfe1/component';\n`, + ), + ], + outdir: path.join(dir, 'dist'), + bundle: true, + format: 'esm', + splitting: true, + write: false, + external: ['@module-federation/runtime'], + plugins: [ + moduleFederationPlugin({ + name: 'host', + shared: {}, + remotes: { mfe1: 'http://localhost:3001/remoteEntry.js' }, + }), + ], + }); + expect(result.errors).toHaveLength(0); + }); +}); diff --git a/packages/esbuild/src/adapters/lib/plugin.ts b/packages/esbuild/src/adapters/lib/plugin.ts index b7b7b8fc382..0e821629ce8 100644 --- a/packages/esbuild/src/adapters/lib/plugin.ts +++ b/packages/esbuild/src/adapters/lib/plugin.ts @@ -1,248 +1,1082 @@ +/** + * @module-federation/esbuild - Module Federation Plugin for esbuild + * + * Full-featured Module Federation support for esbuild, with near-parity + * to the enhanced webpack plugin. Features: + * + * 1. SHARED MODULES - loadShare() proxy with version negotiation, eager support, + * import:false, custom shareKey, per-module shareScope, packageName, subpath handling + * 2. REMOTE MODULES - loadRemote() proxy, name@url parsing, per-remote shareScope + * 3. CONTAINER ENTRY - get()/init() protocol, dynamic import of exposed modules + * 4. RUNTIME INIT - top-level await, runtimePlugins injection, shareStrategy + * 5. MANIFEST - mf-manifest.json with full asset/chunk metadata + * 6. AUTO VERSION - reads package.json to detect shared dep versions + * + * Requirements: format:'esm', splitting:true, @module-federation/runtime + */ import fs from 'fs'; -import { resolve, getExports } from './collect-exports.js'; import path from 'path'; -import { writeRemoteManifest } from './manifest.js'; -import { createContainerPlugin } from './containerPlugin'; -import { initializeHostPlugin } from './containerReference'; -import { linkRemotesPlugin } from './linkRemotesPlugin'; -import { commonjs } from './commonjs'; -import { - BuildOptions, - PluginBuild, +import { init as initEsLexer, parse as parseEsModule } from 'es-module-lexer'; +import type { Plugin, + PluginBuild, OnResolveArgs, OnLoadArgs, + Loader, + BuildResult, } from 'esbuild'; -import { getExternals } from '../../lib/core/get-externals'; -import { NormalizedFederationConfig } from '../../lib/config/federation-config.js'; - -// Creates a virtual module for sharing dependencies -export const createVirtualShareModule = ( - name: string, - ref: string, - exports: string[], -): string => ` - const container = __FEDERATION__.__INSTANCES__.find(container => container.name === ${JSON.stringify( - name, - )}) || __FEDERATION__.__INSTANCES__[0] - - const mfLsZJ92 = await container.loadShare(${JSON.stringify(ref)}) - - ${exports - .map((e) => - e === 'default' - ? `export default mfLsZJ92.default` - : `export const ${e} = mfLsZJ92[${JSON.stringify(e)}];`, - ) - .join('\n')} +import { getExports } from './collect-exports'; +import type { + NormalizedFederationConfig, + NormalizedSharedConfig, + NormalizedRemoteConfig, +} from '../../lib/config/federation-config'; +import { writeRemoteManifest } from './manifest'; + +// ============================================================================= +// Constants +// ============================================================================= + +const PLUGIN_NAME = 'module-federation'; +const NS_CONTAINER = 'mf-container'; +const NS_REMOTE = 'mf-remote'; +const NS_SHARED = 'mf-shared'; +const NS_RUNTIME_INIT = 'mf-runtime-init'; +const RUNTIME_INIT_ID = '__mf_runtime_init__'; +const FALLBACK_PREFIX = '__mf_fallback__/'; +const MF_RUNTIME = '@module-federation/runtime'; + +// ============================================================================= +// Utilities +// ============================================================================= + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function createPrefixFilter(names: string[]): RegExp | null { + if (names.length === 0) return null; + return new RegExp(`^(${names.map(escapeRegex).join('|')})(\/.*)?$`); +} + +function getLoader(filePath: string): Loader { + const ext = path.extname(filePath).toLowerCase(); + const map: Record = { + '.ts': 'ts', + '.tsx': 'tsx', + '.js': 'js', + '.jsx': 'jsx', + '.mjs': 'js', + '.mts': 'ts', + '.cjs': 'js', + '.cts': 'ts', + '.css': 'css', + '.json': 'json', + }; + return map[ext] || 'js'; +} + +function isValidIdentifier(name: string): boolean { + return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name); +} + +function getPackageName(importPath: string): string { + const parts = importPath.split('/'); + if (importPath.startsWith('@') && parts.length >= 2) { + return `${parts[0]}/${parts[1]}`; + } + return parts[0]; +} + +function parseRemoteEntry(entry: string): string { + const match = entry.match(/^(.+?)@(https?:\/\/.+)$/); + return match ? match[2] : entry; +} + +function parseRemoteName(entry: string, fallbackAlias: string): string { + const match = entry.match(/^(.+?)@(https?:\/\/.+)$/); + return match ? match[1] : fallbackAlias; +} + +/** esbuild's entryPoints can be string[], {in,out}[], or Record */ +type EntryPoints = + | string[] + | Array<{ in: string; out: string }> + | Record + | undefined; + +function canonicalFilePath(filePath: string): string { + const resolved = path.resolve(filePath); + try { + return fs.realpathSync.native(resolved); + } catch { + return resolved; + } +} + +function getEntryPaths(entryPoints: EntryPoints): string[] { + if (!entryPoints) return []; + const result: string[] = []; + if (Array.isArray(entryPoints)) { + for (const ep of entryPoints) { + if (typeof ep === 'string') result.push(canonicalFilePath(ep)); + else if (ep && typeof ep === 'object' && 'in' in ep) + result.push(canonicalFilePath(ep.in)); + } + } else if (typeof entryPoints === 'object') { + for (const v of Object.values(entryPoints)) { + if (typeof v === 'string') result.push(canonicalFilePath(v)); + } + } + return result; +} + +/** Safe variable name from package name */ +function safeVarName(pkg: string): string { + return `__mfEager_${pkg.replace(/[^a-zA-Z0-9]/g, '_')}`; +} + +const UNSAFE_JS_CODEPOINT_RE = /[<>\u2028\u2029]/g; +const UNSAFE_JS_CODEPOINT_ESCAPE_MAP: Record = { + '<': '\\u003C', + '>': '\\u003E', + '\u2028': '\\u2028', + '\u2029': '\\u2029', +}; + +/** + * Sanitize a string for safe embedding in generated JavaScript code. + * JSON.stringify handles quoting/escaping for string literals, and we + * additionally escape unsafe code points to prevent accidental script/context + * breakouts in generated code blobs. + */ +function safeStr(value: string): string { + return JSON.stringify(value).replace( + UNSAFE_JS_CODEPOINT_RE, + (ch) => UNSAFE_JS_CODEPOINT_ESCAPE_MAP[ch] || ch, + ); +} + +/** + * Try to auto-detect a package version by reading its package.json from node_modules. + */ +function detectPackageVersion(pkg: string): string | undefined { + const lookupPkg = pkg + .split('/') + .slice(0, pkg.startsWith('@') ? 2 : 1) + .join('/'); + const candidates = [ + path.join(process.cwd(), 'node_modules', lookupPkg, 'package.json'), + path.join(process.cwd(), '..', 'node_modules', lookupPkg, 'package.json'), + ]; + for (const candidate of candidates) { + try { + if (fs.existsSync(candidate)) { + return JSON.parse(fs.readFileSync(candidate, 'utf-8')).version; + } + } catch { + // continue + } + } + return undefined; +} + +/** Get the remote entry string from a remote config (string or object) */ +function getRemoteEntryStr(remote: string | NormalizedRemoteConfig): string { + if (typeof remote === 'string') return remote; + return remote.entry; +} + +/** Get the shareScope override for a remote, if any */ +function getRemoteShareScope( + remote: string | NormalizedRemoteConfig, +): string | undefined { + if (typeof remote === 'string') return undefined; + return remote.shareScope; +} + +// ============================================================================= +// Code Generation - Shared config builder (reused by init + container) +// ============================================================================= + +function buildSharedCodeEntries( + shared: Record, + globalScope: string, + eagerImports: string[], +): string { + return Object.entries(shared) + .map(([pkg, cfg]) => { + // Skip import:false modules (no local fallback) + const hasImport = cfg.import !== false; + const shareKey = cfg.shareKey || pkg; + const scope = cfg.shareScope || globalScope; + + // Auto-detect version if not provided + let version = + cfg.version || cfg.requiredVersion?.replace(/^[^0-9]*/, '') || ''; + if (!version) { + const detected = detectPackageVersion(cfg.packageName || pkg); + if (detected) version = detected; + } + if (!version) version = '0.0.0'; + + let getFactory: string; + if (!hasImport) { + // No local fallback: get returns undefined, runtime must find it in scope + getFactory = `function() { return Promise.resolve(function() { return undefined; }); }`; + } else if (cfg.eager) { + const varName = safeVarName(pkg); + eagerImports.push( + `import * as ${varName} from ${safeStr(FALLBACK_PREFIX + pkg)};`, + ); + getFactory = `function() { return Promise.resolve(function() { return ${varName}; }); }`; + } else { + getFactory = `function() { return import(${safeStr(FALLBACK_PREFIX + pkg)}).then(function(m) { return function() { return m; }; }); }`; + } + + return ` ${safeStr(shareKey)}: { + version: ${safeStr(version)}, + scope: ${safeStr(scope)}, + get: ${getFactory}, + shareConfig: { + singleton: ${!!cfg.singleton}, + requiredVersion: ${safeStr(cfg.requiredVersion || '*')}, + eager: ${!!cfg.eager}, + strictVersion: ${!!cfg.strictVersion} + } + }`; + }) + .join(',\n'); +} + +// ============================================================================= +// Code Generation - Runtime Initialization +// ============================================================================= + +function generateRuntimeInitCode(config: NormalizedFederationConfig): string { + const { name, remotes = {}, shared = {} } = config; + const strategy = config.shareStrategy || 'version-first'; + const globalScope = config.shareScope || 'default'; + + // Build remote configs + const remoteConfigs = Object.entries(remotes).map(([alias, remote]) => { + const entryStr = getRemoteEntryStr(remote); + const remoteShareScope = getRemoteShareScope(remote); + return { + name: parseRemoteName(entryStr, alias), + alias, + entry: parseRemoteEntry(entryStr), + type: 'esm' as const, + shareScope: remoteShareScope || globalScope, + }; + }); + + // Build shared entries + const eagerImports: string[] = []; + const sharedEntries = buildSharedCodeEntries( + shared, + globalScope, + eagerImports, + ); + + const eagerSection = + eagerImports.length > 0 ? eagerImports.join('\n') + '\n' : ''; + + // Build runtime plugins injection + const runtimePlugins = config.runtimePlugins || []; + let runtimePluginsSection = ''; + if (runtimePlugins.length > 0) { + const pluginImports = runtimePlugins + .map((p, i) => `import __mfRuntimePlugin${i} from ${safeStr(p)};`) + .join('\n'); + const pluginArray = runtimePlugins + .map( + (_, i) => + `typeof __mfRuntimePlugin${i} === "function" ? __mfRuntimePlugin${i}() : __mfRuntimePlugin${i}`, + ) + .join(', '); + runtimePluginsSection = `${pluginImports} +var __mfPlugins = [${pluginArray}]; `; + } + + const pluginsArg = + runtimePlugins.length > 0 ? ',\n plugins: __mfPlugins' : ''; + + return `import { init as __mfInit } from ${safeStr(MF_RUNTIME)}; +${eagerSection}${runtimePluginsSection} +var __mfInstance = __mfInit({ + name: ${safeStr(name)}, + remotes: ${JSON.stringify(remoteConfigs)}, + shared: { +${sharedEntries} + }${pluginsArg} +}); -export const createVirtualRemoteModule = ( - name: string, - ref: string, -): string => ` -export * from ${JSON.stringify('federationRemote/' + ref)} +try { + var __mfSharePromises = __mfInstance.initializeSharing(${safeStr(globalScope)}, { + strategy: ${safeStr(strategy)}, + from: "build" + }); + if (__mfSharePromises && __mfSharePromises.length) { + await Promise.all(__mfSharePromises); + } +} catch(__mfErr) { + console.warn("[Module Federation] Sharing initialization warning:", __mfErr); +} `; +} -// Plugin to transform CommonJS modules to ESM -const cjsToEsmPlugin: Plugin = { - name: 'cjs-to-esm', - setup(build: PluginBuild) { - build.onLoad( - { filter: /.*/, namespace: 'esm-shares' }, - async (args: OnLoadArgs) => { - let esbuild_shim: typeof import('esbuild') | undefined; - const require_esbuild = () => - build.esbuild || (esbuild_shim ||= require('esbuild')); - - const packageBuilder = await require_esbuild().build({ - ...build.initialOptions, - external: build.initialOptions.external?.filter((e) => { - if (e.includes('*')) { - const prefix = e.split('*')[0]; - return !args.path.startsWith(prefix); - } - return e !== args.path; - }), - entryPoints: [args.path], - plugins: [commonjs({ filter: /.*/ })], - write: false, - }); - return { - contents: packageBuilder.outputFiles[0].text, - loader: 'js', - resolveDir: args.pluginData.resolveDir, - }; - }, - ); - }, +// ============================================================================= +// Code Generation - Container Entry (remoteEntry.js) +// ============================================================================= + +function generateContainerEntryCode( + config: NormalizedFederationConfig, +): string { + const { name, shared = {}, exposes = {} } = config; + const strategy = config.shareStrategy || 'version-first'; + const globalScope = config.shareScope || 'default'; + + const eagerImports: string[] = []; + const sharedEntries = buildSharedCodeEntries( + shared, + globalScope, + eagerImports, + ); + + const moduleMapEntries = Object.entries(exposes) + .map( + ([exposeName, exposePath]) => + ` ${safeStr(exposeName)}: function() { return import(${safeStr(exposePath)}); }`, + ) + .join(',\n'); + + const eagerSection = + eagerImports.length > 0 ? eagerImports.join('\n') + '\n' : ''; + + // Runtime plugins for container + const runtimePlugins = config.runtimePlugins || []; + let runtimePluginsSection = ''; + if (runtimePlugins.length > 0) { + const pluginImports = runtimePlugins + .map((p, i) => `import __mfRuntimePlugin${i} from ${safeStr(p)};`) + .join('\n'); + const pluginArray = runtimePlugins + .map( + (_, i) => + `typeof __mfRuntimePlugin${i} === "function" ? __mfRuntimePlugin${i}() : __mfRuntimePlugin${i}`, + ) + .join(', '); + runtimePluginsSection = `${pluginImports} +var __mfPlugins = [${pluginArray}]; +`; + } + + const pluginsArg = + runtimePlugins.length > 0 ? ',\n plugins: __mfPlugins' : ''; + + return `import { init as __mfInit } from ${safeStr(MF_RUNTIME)}; +${eagerSection}${runtimePluginsSection} +var __mfInstance = __mfInit({ + name: ${safeStr(name)}, + remotes: [], + shared: { +${sharedEntries} + }${pluginsArg} +}); + +var __mfModuleMap = { +${moduleMapEntries} }; -// Plugin to link shared dependencies -const linkSharedPlugin = (config: NormalizedFederationConfig): Plugin => ({ - name: 'linkShared', - setup(build: PluginBuild) { - const filter = new RegExp( - Object.keys(config.shared || {}) - .map((name: string) => `${name}$`) - .join('|'), + +export function get(module, getScope) { + if (!__mfModuleMap[module]) { + throw new Error( + 'Module "' + module + '" does not exist in container "' + ${safeStr(name)} + '"' ); + } + return __mfModuleMap[module]().then(function(m) { return function() { return m; }; }); +} - build.onResolve({ filter }, (args: OnResolveArgs) => { - if (args.namespace === 'esm-shares') return null; - return { - path: args.path, - namespace: 'virtual-share-module', - pluginData: { kind: args.kind, resolveDir: args.resolveDir }, - }; +export function init(shareScope, initScope, remoteEntryInitOptions) { + var opts = remoteEntryInitOptions || {}; + + __mfInstance.initOptions({ + name: ${safeStr(name)}, + remotes: [], + ...opts + }); + + if (shareScope) { + __mfInstance.initShareScopeMap(${safeStr(globalScope)}, shareScope, { + hostShareScopeMap: (opts && opts.shareScopeMap) || {} }); + } - build.onResolve( - { filter: /.*/, namespace: 'esm-shares' }, - (args: OnResolveArgs) => { - if (filter.test(args.path)) { - return { - path: args.path, - namespace: 'virtual-share-module', - pluginData: { kind: args.kind, resolveDir: args.resolveDir }, - }; + return __mfInstance.initializeSharing(${safeStr(globalScope)}, { + strategy: ${safeStr(strategy)}, + from: "build", + initScope: initScope + }); +} +`; +} + +// ============================================================================= +// Code Generation - Shared Module Proxy +// ============================================================================= + +async function generateSharedProxyCode( + importPath: string, + pkgName: string, + cfg: NormalizedSharedConfig, +): Promise { + const isSubpath = importPath !== pkgName; + const shareKey = cfg.shareKey || pkgName; + + let exportNames: string[]; + try { + exportNames = await getExports(importPath); + } catch { + exportNames = ['default']; + } + + const hasDefault = exportNames.includes('default'); + const namedExports = exportNames.filter( + (e) => e !== 'default' && isValidIdentifier(e), + ); + + let code: string; + + if (isSubpath) { + code = `import { loadShare } from ${safeStr(MF_RUNTIME)}; + +var __mfFactory = null; +try { + __mfFactory = await loadShare(${safeStr(importPath)}); +} catch(__mfErr) { + // Subpath not registered in share scope, will use fallback +} + +var __mfMod; +if (__mfFactory && typeof __mfFactory === "function") { + __mfMod = __mfFactory(); +} else { + __mfMod = await import(${safeStr(FALLBACK_PREFIX + importPath)}); +} +`; + } else if (cfg.import === false) { + // No local fallback: module MUST come from the share scope + code = `import { loadShare } from ${safeStr(MF_RUNTIME)}; + +var __mfFactory = await loadShare(${safeStr(shareKey)}); +if (!__mfFactory || typeof __mfFactory !== "function") { + throw new Error("[Module Federation] Shared module ${safeStr(shareKey)} not available in share scope and import:false prevents local fallback."); +} +var __mfMod = __mfFactory(); +`; + } else { + // loadShare uses the shareKey (for scope negotiation), + // but the fallback import uses the actual package name (for disk resolution) + code = `import { loadShare } from ${safeStr(MF_RUNTIME)}; + +var __mfFactory; +try { + __mfFactory = await loadShare(${safeStr(shareKey)}); +} catch(__mfErr) { + console.warn("[Module Federation] loadShare(" + ${safeStr(shareKey)} + ") failed:", __mfErr); +} + +var __mfMod; +if (__mfFactory && typeof __mfFactory === "function") { + __mfMod = __mfFactory(); +} else { + __mfMod = await import(${safeStr(FALLBACK_PREFIX + pkgName)}); +} +`; + } + + if (hasDefault) { + code += `\nexport default (__mfMod && "default" in __mfMod) ? __mfMod["default"] : __mfMod;\n`; + } + + if (namedExports.length > 0) { + for (const exp of namedExports) { + code += `export var ${exp} = __mfMod[${safeStr(exp)}];\n`; + } + } + + return code; +} + +// ============================================================================= +// Code Generation - Remote Module Proxy +// ============================================================================= + +function generateRemoteProxyCode(importPath: string): string { + return `import { loadRemote } from ${safeStr(MF_RUNTIME)}; + +var __mfRemote = await loadRemote(${safeStr(importPath)}); +if (!__mfRemote) { + throw new Error("[Module Federation] Failed to load remote module: " + ${safeStr(importPath)}); +} + +export default (__mfRemote && typeof __mfRemote === "object" && "default" in __mfRemote) + ? __mfRemote["default"] + : __mfRemote; + +export var __mfModule = __mfRemote; +`; +} + +// ============================================================================= +// Source File Transform - Rewrite named imports from remotes +// ============================================================================= + +/** + * Transform named imports from remote modules so they work like webpack. + * + * ESM requires static export declarations, but remote module exports are + * unknown at build time. This transform rewrites the importing file so that + * named imports are converted to destructuring from the proxy's __mfModule: + * + * import { App, utils as u } from 'mfe1/component'; + * // becomes: + * import { __mfModule as __mfR0 } from 'mfe1/component'; + * const { App, utils: u } = __mfR0; + * + * import Default, { App } from 'mfe1/component'; + * // becomes: + * import Default, { __mfModule as __mfR0 } from 'mfe1/component'; + * const { App } = __mfR0; + * + * import * as Mod from 'mfe1/component'; + * // becomes: + * import { __mfModule as Mod } from 'mfe1/component'; + * + * Default-only imports are left unchanged (already handled by the proxy). + */ +async function transformRemoteImports( + code: string, + remoteNames: string[], +): Promise { + // Quick check: does the code have any import/export from a remote? + // Use a targeted check to avoid false positives from variable names or comments + if ( + !remoteNames.some( + (name) => + code.includes(`'${name}/`) || + code.includes(`"${name}/`) || + code.includes(`'${name}'`) || + code.includes(`"${name}"`), + ) + ) { + return code; + } + + await initEsLexer; + let imports; + try { + [imports] = parseEsModule(code); + } catch { + return code; // Parse error - return unchanged + } + + if (imports.length === 0) return code; + + // Collect replacements (will apply in reverse order to preserve positions) + const replacements: Array<{ + start: number; + end: number; + text: string; + }> = []; + let counter = 0; + + for (const imp of imports) { + // Skip dynamic imports + if (imp.d >= 0) continue; + + // Check if this import is from a remote + const moduleName = imp.n; + if (!moduleName) continue; + const isRemote = remoteNames.some( + (name) => moduleName === name || moduleName.startsWith(name + '/'), + ); + if (!isRemote) continue; + + // Extract the full import statement text + const stmt = code.slice(imp.ss, imp.se); + + // Skip type-only imports (TypeScript) + if (/^import\s+type[\s{]/.test(stmt)) continue; + + // --- Case 0: Re-exports --- + // export { App } from 'remote' + // export { App as MyApp } from 'remote' + const reexportMatch = stmt.match(/^export\s+\{([^}]*)\}\s*from\s/); + if (reexportMatch) { + const namedRaw = reexportMatch[1].trim(); + if (!namedRaw) continue; + + const specifiers = namedRaw + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .filter((s) => !s.startsWith('type ')); + if (specifiers.length === 0) continue; + + // Convert re-export to: import + re-export from local binding + const varName = `__mfR${counter++}`; + const modStr = safeStr(moduleName); + + // Build local bindings and re-export declarations. + // `default` cannot be used as a local variable name, so alias it. + const localDecls: string[] = []; + const exportParts: string[] = []; + const localByImported = new Map(); + const usedLocals = new Set(); + let localCounter = 0; + for (const spec of specifiers) { + const asMatch = spec.match(/^([\w$]+)\s+as\s+([\w$]+)$/); + const imported = asMatch ? asMatch[1] : spec; + const exported = asMatch ? asMatch[2] : spec; + + let local = localByImported.get(imported); + if (!local) { + if ( + imported !== 'default' && + isValidIdentifier(imported) && + !usedLocals.has(imported) + ) { + local = imported; + } else { + local = `__mfReExport${counter}_${localCounter++}`; + } + localByImported.set(imported, local); + usedLocals.add(local); + localDecls.push(`var ${local} = ${varName}[${safeStr(imported)}];`); } - if (filter.test(args.importer)) { - return { - path: args.path, - namespace: 'esm-shares', - pluginData: { kind: args.kind, resolveDir: args.resolveDir }, - }; + if (asMatch) { + exportParts.push(`${local} as ${exported}`); + } else { + exportParts.push( + local === exported ? local : `${local} as ${exported}`, + ); } - return undefined; - }, - ); + } - build.onResolve( - { filter: /^federationShare/ }, - async (args: OnResolveArgs) => ({ - path: args.path.replace('federationShare/', ''), - namespace: 'esm-shares', - pluginData: { kind: args.kind, resolveDir: args.resolveDir }, - }), - ); + const replacement = + `import { __mfModule as ${varName} } from ${modStr};\n` + + localDecls.join('\n') + + `\nexport { ${exportParts.join(', ')} };`; - build.onLoad( - { filter, namespace: 'virtual-share-module' }, - async (args: OnLoadArgs) => { - const exp = await getExports(args.path); - return { - contents: createVirtualShareModule(config.name, args.path, exp), - loader: 'js', - resolveDir: path.dirname(args.path), - }; - }, + replacements.push({ start: imp.ss, end: imp.se, text: replacement }); + continue; + } + + // --- Case 1: Named imports with optional default --- + // import { App } from 'remote' + // import Default, { App } from 'remote' + const namedMatch = stmt.match( + /^import\s+(?:([\w$]+)\s*,\s*)?\{([^}]*)\}\s*from\s/, ); - }, -}); + if (namedMatch) { + const defaultName = namedMatch[1]; // may be undefined + const namedRaw = namedMatch[2].trim(); -// Main module federation plugin -export const moduleFederationPlugin = (config: NormalizedFederationConfig) => ({ - name: 'module-federation', - setup(build: PluginBuild) { - build.initialOptions.metafile = true; - const externals = getExternals(config); - if (build.initialOptions.external) { - build.initialOptions.external = [ - ...new Set([...build.initialOptions.external, ...externals]), - ]; - } else { - build.initialOptions.external = externals; - } - const pluginStack: Plugin[] = []; - const remotes = Object.keys(config.remotes || {}).length; - const shared = Object.keys(config.shared || {}).length; - const exposes = Object.keys(config.exposes || {}).length; - const entryPoints = build.initialOptions.entryPoints; - const filename = config.filename || 'remoteEntry.js'; + if (!namedRaw) continue; // empty braces, skip + + // Parse specifiers, filtering out TypeScript inline type imports + const specifiers = namedRaw + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .filter((s) => !s.startsWith('type ')); + + if (specifiers.length === 0) continue; // all type-only - if (remotes) { - pluginStack.push(linkRemotesPlugin(config)); + // Convert "X as Y" (ESM import) to "X: Y" (destructuring) + const destructured = specifiers + .map((spec) => { + const m = spec.match(/^([\w$]+)\s+as\s+([\w$]+)$/); + return m ? `${m[1]}: ${m[2]}` : spec; + }) + .join(', '); + + const varName = `__mfR${counter++}`; + const modStr = safeStr(moduleName); + let replacement: string; + + if (defaultName) { + replacement = + `import ${defaultName}, { __mfModule as ${varName} } from ${modStr};\n` + + `const { ${destructured} } = ${varName};`; + } else { + replacement = + `import { __mfModule as ${varName} } from ${modStr};\n` + + `const { ${destructured} } = ${varName};`; + } + + replacements.push({ start: imp.ss, end: imp.se, text: replacement }); + continue; } - if (shared) { - pluginStack.push(linkSharedPlugin(config)); + // --- Case 2: Namespace import --- + // import * as Mod from 'remote' + const nsMatch = stmt.match(/^import\s+\*\s+as\s+([\w$]+)\s+from\s/); + if (nsMatch) { + const nsName = nsMatch[1]; + const modStr = safeStr(moduleName); + replacements.push({ + start: imp.ss, + end: imp.se, + text: `import { __mfModule as ${nsName} } from ${modStr};`, + }); + continue; } - if (!entryPoints) { - build.initialOptions.entryPoints = []; + // Default-only and side-effect-only imports are left unchanged. + } + + if (replacements.length === 0) return code; + + // Apply replacements in reverse order to preserve positions + let result = code; + for (const rep of replacements.sort((a, b) => b.start - a.start)) { + result = result.slice(0, rep.start) + rep.text + result.slice(rep.end); + } + + return result; +} + +// ============================================================================= +// Main Plugin +// ============================================================================= + +export const moduleFederationPlugin = ( + config: NormalizedFederationConfig, +): Plugin => ({ + name: PLUGIN_NAME, + setup(build: PluginBuild) { + const shared = config.shared || {}; + const remotes = config.remotes || {}; + const exposes = config.exposes || {}; + const filename = config.filename || 'remoteEntry.js'; + + const sharedNames = Object.keys(shared); + const remoteNames = Object.keys(remotes); + + const hasShared = sharedNames.length > 0; + const hasRemotes = remoteNames.length > 0; + const hasExposes = Object.keys(exposes).length > 0; + const needsRuntimeInit = hasRemotes || hasShared; + + // Ensure required build options + if (build.initialOptions.format !== 'esm') { + console.warn( + `[${PLUGIN_NAME}] Setting format to "esm" (required for Module Federation)`, + ); + build.initialOptions.format = 'esm'; + } + if (!build.initialOptions.splitting) { + console.warn( + `[${PLUGIN_NAME}] Enabling code splitting (required for Module Federation)`, + ); + build.initialOptions.splitting = true; } + if (!build.initialOptions.outdir) { + console.warn( + `[${PLUGIN_NAME}] "outdir" is required when splitting is enabled`, + ); + } + build.initialOptions.metafile = true; + + // Track original entry points + const originalEntryPaths = new Set( + getEntryPaths(build.initialOptions.entryPoints), + ); - if (exposes) { + // Add container entry + if (hasExposes) { + const entryPoints = build.initialOptions.entryPoints; if (Array.isArray(entryPoints)) { (entryPoints as string[]).push(filename); } else if (entryPoints && typeof entryPoints === 'object') { - (entryPoints as Record)[filename] = filename; + const ext = path.extname(filename); + const entryKey = ext ? filename.slice(0, -ext.length) : filename; + (entryPoints as Record)[entryKey] = filename; } else { build.initialOptions.entryPoints = [filename]; } } - [ - initializeHostPlugin(config), - createContainerPlugin(config), - cjsToEsmPlugin, - ...pluginStack, - ].forEach((plugin) => plugin.setup(build)); + // Build regex filters + const sharedFilter = hasShared ? createPrefixFilter(sharedNames) : null; + const remoteFilter = hasRemotes ? createPrefixFilter(remoteNames) : null; + const containerBasename = path.basename(filename); + const containerFilter = new RegExp( + `(^|/)${escapeRegex(containerBasename)}$`, + ); - build.onEnd(async (result: any) => { - if (!result.metafile) return; - if (exposes) { - const exposedConfig = config.exposes || {}; - const remoteFile = config.filename; - const exposedEntries: Record = {}; - const outputMapWithoutExt = Object.entries( - result.metafile.outputs, - ).reduce((acc, [chunkKey, chunkValue]) => { - //@ts-ignore - const { entryPoint } = chunkValue; - const key = entryPoint || chunkKey; - const trimKey = key.substring(0, key.lastIndexOf('.')) || key; - //@ts-ignore - acc[trimKey] = { ...chunkValue, chunk: chunkKey }; - return acc; - }, {}); - - for (const [expose, value] of Object.entries(exposedConfig)) { - const exposedFound = - //@ts-ignore - outputMapWithoutExt[value.replace('./', '')] || - //@ts-ignore - outputMapWithoutExt[expose.replace('./', '')]; - - if (exposedFound) { - exposedEntries[expose] = { - entryPoint: exposedFound.entryPoint, - exports: exposedFound.exports, + // ================================================================== + // RESOLVE HOOKS + // ================================================================== + + // 1. Container entry + if (hasExposes) { + build.onResolve({ filter: containerFilter }, (args: OnResolveArgs) => { + const basename = path.basename(args.path); + if (basename !== containerBasename && !args.path.endsWith(filename)) + return undefined; + return { + path: args.path, + namespace: NS_CONTAINER, + pluginData: { resolveDir: args.resolveDir || process.cwd() }, + }; + }); + } + + // 2. Runtime init + if (needsRuntimeInit) { + build.onResolve( + { filter: new RegExp(`^${escapeRegex(RUNTIME_INIT_ID)}$`) }, + (args) => ({ + path: RUNTIME_INIT_ID, + namespace: NS_RUNTIME_INIT, + pluginData: { resolveDir: args.resolveDir || process.cwd() }, + }), + ); + } + + // 3. Share fallback + if (hasShared) { + build.onResolve( + { filter: new RegExp(`^${escapeRegex(FALLBACK_PREFIX)}`) }, + async (args) => { + const pkgName = args.path.slice(FALLBACK_PREFIX.length); + const resolveDir = + args.pluginData?.resolveDir || args.resolveDir || process.cwd(); + + // Check if this shared dep has import:false (no fallback allowed) + const topPkg = getPackageName(pkgName); + if (shared[topPkg]?.import === false) { + // Return an empty module - no fallback + return { + path: pkgName, + namespace: 'mf-empty', }; } - } - for (const [outputPath, value] of Object.entries( - result.metafile.outputs, - )) { - if (!(value as any).entryPoint) continue; + try { + const result = await build.resolve(pkgName, { + kind: args.kind, + resolveDir, + pluginData: { __mfFallback: true }, + }); + return result; + } catch (e) { + console.error( + `[${PLUGIN_NAME}] Cannot resolve fallback for "${pkgName}":`, + e, + ); + return { path: pkgName, external: true }; + } + }, + ); - if (!(value as any).entryPoint.startsWith('container:')) continue; + // Empty module for import:false shared deps + build.onLoad({ filter: /.*/, namespace: 'mf-empty' }, () => ({ + contents: 'export default undefined;', + loader: 'js' as Loader, + })); + } - if (!(value as any).entryPoint.endsWith(remoteFile)) continue; + // 4. Remote modules (before shared for priority) + if (hasRemotes && remoteFilter) { + build.onResolve({ filter: remoteFilter }, (args: OnResolveArgs) => { + const remoteName = remoteNames.find( + (name) => args.path === name || args.path.startsWith(name + '/'), + ); + if (!remoteName) return undefined; + return { + path: args.path, + namespace: NS_REMOTE, + pluginData: { + resolveDir: args.resolveDir || process.cwd(), + remoteName, + }, + }; + }); + } - const container = fs.readFileSync(outputPath, 'utf-8'); + // 5. Shared modules + if (hasShared && sharedFilter) { + build.onResolve({ filter: sharedFilter }, (args: OnResolveArgs) => { + if (args.pluginData?.__mfFallback) return undefined; + if (args.namespace === NS_CONTAINER) return undefined; + if (args.namespace === NS_RUNTIME_INIT) return undefined; + if (args.namespace === NS_SHARED) return undefined; + if (args.path.startsWith('@module-federation/')) return undefined; - const withExports = container - .replace('"__MODULE_MAP__"', `${JSON.stringify(exposedEntries)}`) - .replace("'__MODULE_MAP__'", `${JSON.stringify(exposedEntries)}`); + const pkgName = getPackageName(args.path); + if (!shared[pkgName]) return undefined; - fs.writeFileSync(outputPath, withExports, 'utf-8'); - } + return { + path: args.path, + namespace: NS_SHARED, + pluginData: { + resolveDir: args.resolveDir || process.cwd(), + pkgName, + }, + }; + }); + } + + // ================================================================== + // LOAD HOOKS + // ================================================================== + + // 1. Container entry + if (hasExposes) { + build.onLoad( + { filter: /.*/, namespace: NS_CONTAINER }, + (_args: OnLoadArgs) => ({ + contents: generateContainerEntryCode(config), + loader: 'js' as Loader, + resolveDir: _args.pluginData?.resolveDir || process.cwd(), + }), + ); + } + + // 2. Runtime init + if (needsRuntimeInit) { + build.onLoad( + { filter: /.*/, namespace: NS_RUNTIME_INIT }, + (_args: OnLoadArgs) => ({ + contents: generateRuntimeInitCode(config), + loader: 'js' as Loader, + resolveDir: _args.pluginData?.resolveDir || process.cwd(), + }), + ); + } + + // 3. Shared modules + if (hasShared) { + build.onLoad( + { filter: /.*/, namespace: NS_SHARED }, + async (args: OnLoadArgs) => { + const pkgName = args.pluginData?.pkgName || getPackageName(args.path); + const sharedConfig = shared[pkgName]; + if (!sharedConfig) return undefined; + + const contents = await generateSharedProxyCode( + args.path, + pkgName, + sharedConfig, + ); + + return { + contents, + loader: 'js' as Loader, + resolveDir: args.pluginData?.resolveDir || process.cwd(), + }; + }, + ); + } + + // 4. Remote modules + if (hasRemotes) { + build.onLoad( + { filter: /.*/, namespace: NS_REMOTE }, + (args: OnLoadArgs) => ({ + contents: generateRemoteProxyCode(args.path), + loader: 'js' as Loader, + resolveDir: args.pluginData?.resolveDir || process.cwd(), + }), + ); + } + + // 5. Source file transform: runtime init injection + remote import rewriting + // - Entry points: prepend `import '__mf_runtime_init__'` + // - Any file importing from remotes: rewrite named imports to + // destructured default imports so `import { App } from 'remote/mod'` + // works exactly like webpack MF. + if (needsRuntimeInit || hasRemotes) { + build.onLoad( + { filter: /\.(tsx?|jsx?|mjs|mts|cjs|cts)$/, namespace: 'file' }, + async (args: OnLoadArgs) => { + const isEntry = originalEntryPaths.has(canonicalFilePath(args.path)); + const wantsInit = isEntry && needsRuntimeInit; + + // Quick read to check if transform is needed + let contents: string; + try { + contents = await fs.promises.readFile(args.path, 'utf8'); + } catch { + return undefined; + } + + // Check if this file imports from any remote (targeted check to avoid false positives) + const wantsRemoteTransform = + hasRemotes && + remoteNames.some( + (name) => + contents.includes(`'${name}/`) || + contents.includes(`"${name}/`) || + contents.includes(`'${name}'`) || + contents.includes(`"${name}"`), + ); + + if (!wantsInit && !wantsRemoteTransform) return undefined; + + // Apply remote import transform (rewrite named imports) + if (wantsRemoteTransform) { + contents = await transformRemoteImports(contents, remoteNames); + } + + // Inject runtime init at top of entry points + if (wantsInit) { + contents = `import ${safeStr(RUNTIME_INIT_ID)};\n${contents}`; + } + + return { + contents, + loader: getLoader(args.path), + resolveDir: path.dirname(args.path), + }; + }, + ); + } + + // ================================================================== + // BUILD END + // ================================================================== + + build.onEnd(async (result: BuildResult) => { + if (!result.metafile) return; + + try { + await writeRemoteManifest(config, result); + } catch (e) { + console.error(`[${PLUGIN_NAME}] Manifest generation error:`, e); } - await writeRemoteManifest(config, result); - console.log(`build ended with ${result.errors.length} errors`); + + const errorCount = result.errors?.length || 0; + console.log( + `[${PLUGIN_NAME}] Build completed${errorCount > 0 ? ` with ${errorCount} errors` : ' successfully'}`, + ); }); }, }); + +export default moduleFederationPlugin; + +export { + generateRuntimeInitCode, + generateContainerEntryCode, + generateSharedProxyCode, + generateRemoteProxyCode, + transformRemoteImports, +}; diff --git a/packages/esbuild/src/adapters/lib/react-replacements.ts b/packages/esbuild/src/adapters/lib/react-replacements.ts deleted file mode 100644 index 82ed97d7430..00000000000 --- a/packages/esbuild/src/adapters/lib/react-replacements.ts +++ /dev/null @@ -1,41 +0,0 @@ -'use strict'; - -interface Replacement { - file: string; -} - -interface ReactReplacements { - dev: Record; - prod: Record; -} - -export const reactReplacements: ReactReplacements = { - dev: { - 'node_modules/react/index.js': { - file: 'node_modules/react/cjs/react.development.js', - }, - 'node_modules/react/jsx-dev-runtime.js': { - file: 'node_modules/react/cjs/react-jsx-dev-runtime.development.js', - }, - 'node_modules/react/jsx-runtime.js': { - file: 'node_modules/react/cjs/react-jsx-runtime.development.js', - }, - 'node_modules/react-dom/index.js': { - file: 'node_modules/react-dom/cjs/react-dom.development.js', - }, - }, - prod: { - 'node_modules/react/index.js': { - file: 'node_modules/react/cjs/react.production.min.js', - }, - 'node_modules/react/jsx-dev-runtime.js': { - file: 'node_modules/react/cjs/react-jsx-dev-runtime.production.min.js', - }, - 'node_modules/react/jsx-runtime.js': { - file: 'node_modules/react/cjs/react-jsx-runtime.production.min.js', - }, - 'node_modules/react-dom/index.js': { - file: 'node_modules/react-dom/cjs/react-dom.production.min.js', - }, - }, -}; diff --git a/packages/esbuild/src/adapters/lib/transform.ts b/packages/esbuild/src/adapters/lib/transform.ts deleted file mode 100644 index f9a483a3366..00000000000 --- a/packages/esbuild/src/adapters/lib/transform.ts +++ /dev/null @@ -1,122 +0,0 @@ -import * as esbuild from 'esbuild'; -import * as path from 'path'; - -interface TransformInput { - code: string; - importMap?: string; - filename: string; - target?: string; -} - -const targets: Record = { - esnext: 'esnext', - es2015: 'es2015', - es2016: 'es2016', - es2017: 'es2017', - es2018: 'es2018', - es2019: 'es2019', - es2020: 'es2020', - es2021: 'es2021', - es2022: 'es2022', -}; - -export async function transform(input: TransformInput): Promise { - let target: esbuild.BuildOptions['target'] = 'esnext'; - if (input.target && targets[input.target]) { - target = targets[input.target]; - } else if (input.target) { - throw new Error('<400> invalid target'); - } - - let loader: esbuild.Loader = 'js'; - const extname = path.extname(input.filename); - switch (extname) { - case '.jsx': - loader = 'jsx'; - break; - case '.ts': - loader = 'ts'; - break; - case '.tsx': - loader = 'tsx'; - break; - } - - const imports: Record = {}; - const trailingSlashImports: Record = {}; - let jsxImportSource = ''; - - if (input.importMap) { - const im = JSON.parse(input.importMap); - if (im.imports) { - for (const [key, value] of Object.entries(im.imports)) { - if (typeof value === 'string' && value !== '') { - if (key.endsWith('/')) { - trailingSlashImports[key] = value; - } else { - if (key === '@jsxImportSource') { - jsxImportSource = value; - } - imports[key] = value; - } - } - } - } - } - - const onResolver = (args: esbuild.OnResolveArgs): esbuild.OnResolveResult => { - let resolvedPath = args.path; - if (imports[resolvedPath]) { - resolvedPath = imports[resolvedPath]; - } else { - for (const [key, value] of Object.entries(trailingSlashImports)) { - if (resolvedPath.startsWith(key)) { - resolvedPath = value + resolvedPath.slice(key.length); - break; - } - } - } - return { path: resolvedPath, external: true }; - }; - - const stdin: esbuild.StdinOptions = { - contents: input.code, - resolveDir: '/', - sourcefile: input.filename, - loader: loader, - }; - - const jsx = jsxImportSource ? 'automatic' : 'transform'; - - const opts: esbuild.BuildOptions = { - outdir: '/esbuild', - stdin: stdin, - platform: 'browser', - format: 'esm', - target: target, - jsx: jsx, - jsxImportSource: jsxImportSource, - bundle: true, - treeShaking: false, - minifyWhitespace: false, - minifySyntax: false, - write: false, - plugins: [ - { - name: 'resolver', - setup(build) { - build.onResolve({ filter: /.*/ }, onResolver); - }, - }, - ], - }; - - const ret = await esbuild.build(opts); - if (ret.errors.length > 0) { - throw new Error('<400> failed to validate code: ' + ret.errors[0].text); - } - if (!ret.outputFiles || ret.outputFiles.length === 0) { - throw new Error('<400> failed to validate code: no output files'); - } - return ret.outputFiles[0].text; -} diff --git a/packages/esbuild/src/adapters/lib/utils.ts b/packages/esbuild/src/adapters/lib/utils.ts deleted file mode 100644 index d90891a0732..00000000000 --- a/packages/esbuild/src/adapters/lib/utils.ts +++ /dev/null @@ -1,42 +0,0 @@ -export function orderedUniq(array: T[]): T[] { - // prettier-ignore - const ret: T[] = [], visited = new Set(); - for (const val of array) - if (!visited.has(val)) visited.add(val), ret.push(val); - return ret; -} - -export function cachedReduce( - array: T[], - reducer: (s: S, a: T) => S, - s: S, -): (len: number) => S { - // prettier-ignore - const cache = [s]; - let cacheLen = 1, - last = s; - return (len: number): S => { - while (cacheLen <= len) - cacheLen = cache.push((last = reducer(last, array[cacheLen - 1]))); - return cache[len]; - }; -} - -// from @rollup/pluginutils -const reservedWords = - 'break case class catch const continue debugger default delete do else export extends finally for function if import in instanceof let new return super switch this throw try typeof var void while with yield enum await implements package protected static interface private public'; -const builtin = - 'arguments Infinity NaN undefined null true false eval uneval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Symbol Error EvalError InternalError RangeError ReferenceError SyntaxError TypeError URIError Number Math Date String RegExp Array Int8Array Uint8Array Uint8ClampedArray Int16Array Uint16Array Int32Array Uint32Array Float32Array Float64Array Map Set WeakMap WeakSet SIMD ArrayBuffer DataView JSON Promise Generator GeneratorFunction Reflect Proxy Intl'; -const forbiddenIdentifiers = new Set(`${reservedWords} ${builtin}`.split(' ')); -forbiddenIdentifiers.add(''); -export const makeLegalIdentifier = function makeLegalIdentifier( - str: string, -): string { - let identifier = str - .replace(/-(\w)/g, (_, letter) => letter.toUpperCase()) - .replace(/[^$_a-zA-Z0-9]/g, '_'); - if (/\d/.test(identifier[0]) || forbiddenIdentifiers.has(identifier)) { - identifier = `_${identifier}`; - } - return identifier || '_'; -}; diff --git a/packages/esbuild/src/build.ts b/packages/esbuild/src/build.ts index b42e617e772..394b6f3aa7d 100644 --- a/packages/esbuild/src/build.ts +++ b/packages/esbuild/src/build.ts @@ -1,5 +1,29 @@ -export * from './lib/core/get-externals'; -export * from './lib/core/load-federation-config'; -export * from './lib/config/with-native-federation'; -export * from './lib/config/share-utils'; -export * from './lib/utils/logger'; +/** + * @module-federation/esbuild/build + * + * Build-time configuration utilities for Module Federation. + * Use withFederation() to normalize your federation config before + * passing it to moduleFederationPlugin(). + */ + +export { withFederation } from './lib/config/with-federation'; +export { + share, + shareAll, + findPackageJson, + findRootTsConfigJson, + lookupVersion, + setInferVersion, +} from './lib/config/share-utils'; +export { getExternals } from './lib/core/get-externals'; +export { loadFederationConfig } from './lib/core/load-federation-config'; +export { setLogLevel, logger } from './lib/utils/logger'; + +// Types +export type { + FederationConfig, + SharedConfig, + NormalizedSharedConfig, + NormalizedFederationConfig, + NormalizedRemoteConfig, +} from './lib/config/federation-config'; diff --git a/packages/esbuild/src/index.ts b/packages/esbuild/src/index.ts index ff8b4c56321..2528286c9cd 100644 --- a/packages/esbuild/src/index.ts +++ b/packages/esbuild/src/index.ts @@ -1 +1,40 @@ -export default {}; +/** + * @module-federation/esbuild + * + * Main entry point for the Module Federation esbuild plugin. + * Re-exports the plugin and configuration utilities. + */ + +// Plugin +export { moduleFederationPlugin } from './adapters/lib/plugin'; + +// Configuration utilities +export { withFederation } from './lib/config/with-federation'; +export { + share, + shareAll, + findPackageJson, + lookupVersion, + setInferVersion, +} from './lib/config/share-utils'; + +// Config context +export { + useWorkspace, + usePackageJson, + getConfigContext, +} from './lib/config/configuration-context'; + +// Types +export type { + FederationConfig, + SharedConfig, + NormalizedSharedConfig, + NormalizedFederationConfig, + NormalizedRemoteConfig, +} from './lib/config/federation-config'; + +// Core utilities +export { getExternals } from './lib/core/get-externals'; +export { loadFederationConfig } from './lib/core/load-federation-config'; +export { setLogLevel, logger } from './lib/utils/logger'; diff --git a/packages/esbuild/src/lib/config/federation-config.ts b/packages/esbuild/src/lib/config/federation-config.ts index 3f2555c03d8..79cc6ca689d 100644 --- a/packages/esbuild/src/lib/config/federation-config.ts +++ b/packages/esbuild/src/lib/config/federation-config.ts @@ -1,5 +1,4 @@ import { SkipList } from '../core/default-skip-list'; -import { MappedPath } from '../utils/mapped-paths'; export interface SharedConfig { singleton?: boolean; @@ -17,19 +16,89 @@ export interface FederationConfig { skip?: SkipList; } +/** + * Normalized shared module configuration. + * All boolean fields are required (defaulted during normalization). + */ export interface NormalizedSharedConfig { + /** Allow only a single version of this module in share scope */ singleton: boolean; + /** Throw error on version mismatch (default: false) */ strictVersion: boolean; + /** Semver version requirement for this module */ requiredVersion: string; + /** Actual version of the provided module */ version?: string; + /** Load eagerly (inline) rather than as a lazy chunk */ eager?: boolean; + /** Include subpath exports of the package */ includeSecondaries?: boolean; + /** + * Disable the fallback module (no local bundled version). + * When set to false, the shared module must be provided by another container. + */ + import?: false | string; + /** + * Custom key in the share scope (defaults to the package name). + * Used when the package name differs from the share scope key. + */ + shareKey?: string; + /** + * Custom share scope name for this module (defaults to the global shareScope). + * Enables placing specific modules in isolated share scopes. + */ + shareScope?: string; + /** + * Explicit package name for version auto-detection. + * Used when the import request differs from the package.json name. + */ + packageName?: string; } +/** + * Advanced remote configuration with share scope override. + */ +export interface NormalizedRemoteConfig { + /** The remote entry URL or name@url string */ + entry: string; + /** Custom share scope for this remote (defaults to global shareScope) */ + shareScope?: string; +} + +/** + * Fully normalized federation configuration. + * All optional fields have been defaulted and validated. + */ export interface NormalizedFederationConfig { + /** Unique name for this federation container */ name: string; + /** Remote entry filename (e.g., 'remoteEntry.js') */ filename?: string; + /** Modules to expose to other containers */ exposes?: Record; + /** Shared dependency configurations */ shared?: Record; - remotes?: Record; + /** + * Remote containers to consume. + * Values can be a URL string or a NormalizedRemoteConfig object. + */ + remotes?: Record; + /** Share scope negotiation strategy */ + shareStrategy?: 'version-first' | 'loaded-first'; + /** + * Default share scope name for all shared modules (defaults to 'default'). + * Can be overridden per-shared-module via NormalizedSharedConfig.shareScope. + */ + shareScope?: string; + /** + * Runtime plugin file paths or package names to inject into the MF runtime. + * Each plugin is loaded at runtime and added to the MF instance. + */ + runtimePlugins?: string[]; + /** + * Custom public path for container assets. + * Used in the manifest and for resolving relative chunk paths. + * Defaults to 'auto'. + */ + publicPath?: string; } diff --git a/packages/esbuild/src/lib/config/share-utils.ts b/packages/esbuild/src/lib/config/share-utils.ts index 91400b9b197..4519bc330fc 100644 --- a/packages/esbuild/src/lib/config/share-utils.ts +++ b/packages/esbuild/src/lib/config/share-utils.ts @@ -192,7 +192,7 @@ export function readConfiguredSecondaries( } const entry = getDefaultEntry(exports, key); if (typeof entry !== 'string') { - console.log(`No entry point found for ${secondaryName}`); + logger.warn(`No entry point found for ${secondaryName}`); continue; } if (['.css', '.scss', '.less'].some((ext) => entry.endsWith(ext))) { diff --git a/packages/esbuild/src/lib/config/with-federation.ts b/packages/esbuild/src/lib/config/with-federation.ts new file mode 100644 index 00000000000..c5b921dd10b --- /dev/null +++ b/packages/esbuild/src/lib/config/with-federation.ts @@ -0,0 +1,137 @@ +import { + prepareSkipList, + isInSkipList, + PreparedSkipList, +} from '../core/default-skip-list'; +import { shareAll } from './share-utils'; + +interface SharedConfig { + requiredVersion?: string; + singleton?: boolean; + strictVersion?: boolean; + version?: string; + eager?: boolean; + includeSecondaries?: boolean; + /** Set to false to disable local fallback (module must come from share scope) */ + import?: false | string; + /** Custom key in share scope (defaults to package name) */ + shareKey?: string; + /** Override share scope for this specific module */ + shareScope?: string; + /** Explicit package name for version detection */ + packageName?: string; +} + +interface RemoteConfig { + /** Remote entry URL */ + external: string | string[]; + /** Override share scope for this remote */ + shareScope?: string; +} + +interface FederationConfig { + name?: string; + filename?: string; + exposes?: Record; + remotes?: Record; + shared?: Record; + skip?: string[]; + /** Default share scope name (defaults to 'default') */ + shareScope?: string; + /** Share negotiation strategy */ + shareStrategy?: 'version-first' | 'loaded-first'; + /** Runtime plugin file paths */ + runtimePlugins?: string[]; + /** Custom public path */ + publicPath?: string; +} + +export function withFederation(config: FederationConfig) { + const skip: PreparedSkipList = prepareSkipList(config.skip ?? []); + + // Ensure filename has .js extension for proper container entry matching + let filename = config.filename ?? 'remoteEntry.js'; + if (!filename.endsWith('.js') && !filename.endsWith('.mjs')) { + filename = filename + '.js'; + } + + // Normalize remotes: can be string URL or RemoteConfig object + const remotes: Record< + string, + string | { entry: string; shareScope?: string } + > = {}; + if (config.remotes) { + for (const [key, value] of Object.entries(config.remotes)) { + if (typeof value === 'string') { + remotes[key] = value; + } else if (value && typeof value === 'object') { + const entry = Array.isArray(value.external) + ? value.external[0] + : value.external; + remotes[key] = { + entry, + shareScope: value.shareScope, + }; + } + } + } + + return { + name: config.name ?? '', + filename, + exposes: config.exposes ?? {}, + remotes, + shared: normalizeShared(config, skip), + shareScope: config.shareScope, + shareStrategy: config.shareStrategy, + runtimePlugins: config.runtimePlugins, + publicPath: config.publicPath, + }; +} + +function normalizeShared( + config: FederationConfig, + skip: PreparedSkipList, +): Record { + let result: Record = {}; + const shared = config.shared; + if (!shared) { + result = shareAll({ + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + }) as Record; + } else { + result = Object.keys(shared).reduce( + (acc, cur) => { + return { + ...acc, + [cur]: { + requiredVersion: shared[cur].requiredVersion ?? 'auto', + singleton: shared[cur].singleton ?? false, + strictVersion: shared[cur].strictVersion ?? false, + version: shared[cur].version, + eager: shared[cur].eager, + includeSecondaries: shared[cur].includeSecondaries, + import: shared[cur].import, + shareKey: shared[cur].shareKey, + shareScope: shared[cur].shareScope, + packageName: shared[cur].packageName, + }, + }; + }, + {} as Record, + ); + } + result = Object.keys(result) + .filter((key) => !isInSkipList(key, skip)) + .reduce( + (acc, cur) => ({ + ...acc, + [cur]: result[cur], + }), + {} as Record, + ); + + return result; +} diff --git a/packages/esbuild/src/lib/config/with-native-federation.ts b/packages/esbuild/src/lib/config/with-native-federation.ts deleted file mode 100644 index ac9c6126985..00000000000 --- a/packages/esbuild/src/lib/config/with-native-federation.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { - prepareSkipList, - isInSkipList, - PreparedSkipList, -} from '../core/default-skip-list'; -import { shareAll } from './share-utils'; -import { getMappedPaths, MappedPath } from '../utils/mapped-paths'; -import { findRootTsConfigJson } from './share-utils'; -import { logger } from '../utils/logger'; - -interface FederationConfig { - name?: string; - filename?: string; - exposes?: Record; - remotes?: Record; - shared?: Record; - skip?: string[]; -} - -interface SharedConfig { - requiredVersion?: string; - singleton?: boolean; - strictVersion?: boolean; - version?: string; - includeSecondaries?: boolean; -} - -export function withFederation(config: FederationConfig) { - const skip: PreparedSkipList = prepareSkipList(config.skip ?? []); - return { - name: config.name ?? '', - filename: config.filename ?? 'remoteEntry', - exposes: config.exposes ?? {}, - remotes: config.remotes ?? {}, - shared: normalizeShared(config, skip), - }; -} - -function normalizeShared( - config: FederationConfig, - skip: PreparedSkipList, -): Record { - let result: Record = {}; - const shared = config.shared; - if (!shared) { - result = shareAll({ - singleton: true, - strictVersion: true, - requiredVersion: 'auto', - }) as Record; - } else { - result = Object.keys(shared).reduce((acc, cur) => { - return { - ...acc, - [cur]: { - requiredVersion: shared[cur].requiredVersion ?? 'auto', - singleton: shared[cur].singleton ?? false, - strictVersion: shared[cur].strictVersion ?? false, - version: shared[cur].version, - includeSecondaries: shared[cur].includeSecondaries, - }, - }; - }, {}); - } - result = Object.keys(result) - .filter((key) => !isInSkipList(key, skip)) - .reduce( - (acc, cur) => ({ - ...acc, - [cur]: result[cur], - }), - {}, - ); - - return result; -} - -function normalizeSharedMappings( - config: FederationConfig, - skip: PreparedSkipList, -): MappedPath[] { - const rootTsConfigPath = findRootTsConfigJson(); - const paths = getMappedPaths({ - rootTsConfigPath, - }); - const result = paths.filter( - (p) => !isInSkipList(p.key, skip) && !p.key.includes('*'), - ); - if (paths.find((p) => p.key.includes('*'))) { - logger.warn('Sharing mapped paths with wildcards (*) not supported'); - } - return result; -} diff --git a/packages/esbuild/src/lib/core/build-adapter.ts b/packages/esbuild/src/lib/core/build-adapter.ts deleted file mode 100644 index c172f180f1a..00000000000 --- a/packages/esbuild/src/lib/core/build-adapter.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { logger } from '../utils/logger'; - -type BuildAdapter = () => Promise; - -let _buildAdapter: BuildAdapter = async () => { - logger.error('Please set a BuildAdapter!'); - return []; -}; - -export function setBuildAdapter(buildAdapter: BuildAdapter): void { - _buildAdapter = buildAdapter; -} - -export function getBuildAdapter(): BuildAdapter { - return _buildAdapter; -} diff --git a/packages/esbuild/src/lib/core/createContainerTemplate.ts b/packages/esbuild/src/lib/core/createContainerTemplate.ts deleted file mode 100644 index 11933b7bc4d..00000000000 --- a/packages/esbuild/src/lib/core/createContainerTemplate.ts +++ /dev/null @@ -1,181 +0,0 @@ -export const createContainerCode = ` -import bundler_runtime_base from '@module-federation/webpack-bundler-runtime'; -// import instantiatePatch from "./federation.js"; - -const createContainer = (federationOptions) => { - // await instantiatePatch(federationOptions, true); - const {exposes, name, remotes = [], shared, plugins} = federationOptions; - - const __webpack_modules__ = { - "./node_modules/.federation/entry.1f2288102e035e2ed66b2efaf60ad043.js": (module, __webpack_exports__, __webpack_require__) => { - __webpack_require__.r(__webpack_exports__); - const bundler_runtime = __webpack_require__.n(bundler_runtime_base); - const prevFederation = __webpack_require__.federation; - __webpack_require__.federation = {}; - for (const key in bundler_runtime()) { - __webpack_require__.federation[key] = bundler_runtime()[key]; - } - for (const key in prevFederation) { - __webpack_require__.federation[key] = prevFederation[key]; - } - if (!__webpack_require__.federation.instance) { - const pluginsToAdd = plugins || []; - __webpack_require__.federation.initOptions.plugins = __webpack_require__.federation.initOptions.plugins ? - __webpack_require__.federation.initOptions.plugins.concat(pluginsToAdd) : pluginsToAdd; - __webpack_require__.federation.instance = __webpack_require__.federation.runtime.init(__webpack_require__.federation.initOptions); - if (__webpack_require__.federation.attachShareScopeMap) { - __webpack_require__.federation.attachShareScopeMap(__webpack_require__); - } - if (__webpack_require__.federation.installInitialConsumes) { - __webpack_require__.federation.installInitialConsumes(); - } - } - }, - - "webpack/container/entry/createContainer": (module, exports, __webpack_require__) => { - const moduleMap = {}; - for (const key in exposes) { - if (Object.prototype.hasOwnProperty.call(exposes, key)) { - moduleMap[key] = () => Promise.resolve(exposes[key]()).then(m => () => m); - } - } - - const get = (module, getScope) => { - __webpack_require__.R = getScope; - getScope = ( - __webpack_require__.o(moduleMap, module) - ? moduleMap[module]() - : Promise.resolve().then(() => { - throw new Error("Module '" + module + "' does not exist in container."); - }) - ); - __webpack_require__.R = undefined; - return getScope; - }; - const init = (shareScope, initScope, remoteEntryInitOptions) => { - return __webpack_require__.federation.bundlerRuntime.initContainerEntry({ - webpackRequire: __webpack_require__, - shareScope: shareScope, - initScope: initScope, - remoteEntryInitOptions: remoteEntryInitOptions, - shareScopeKey: "default" - }); - }; - __webpack_require__("./node_modules/.federation/entry.1f2288102e035e2ed66b2efaf60ad043.js"); - - // This exports getters to disallow modifications - __webpack_require__.d(exports, { - get: () => get, - init: () => init, - moduleMap: () => moduleMap, - }); - } - }; - - const __webpack_module_cache__ = {}; - - const __webpack_require__ = (moduleId) => { - let cachedModule = __webpack_module_cache__[moduleId]; - if (cachedModule !== undefined) { - return cachedModule.exports; - } - let module = __webpack_module_cache__[moduleId] = { - id: moduleId, - loaded: false, - exports: {} - }; - - const execOptions = { - id: moduleId, - module: module, - factory: __webpack_modules__[moduleId], - require: __webpack_require__ - }; - __webpack_require__.i.forEach(handler => { - handler(execOptions); - }); - module = execOptions.module; - execOptions.factory.call(module.exports, module, module.exports, execOptions.require); - - module.loaded = true; - - return module.exports; - }; - - __webpack_require__.m = __webpack_modules__; - __webpack_require__.c = __webpack_module_cache__; - __webpack_require__.i = []; - - if (!__webpack_require__.federation) { - __webpack_require__.federation = { - initOptions: { - "name": name, - "remotes": remotes.map(remote => ({ - "type": remote.type, - "alias": remote.alias, - "name": remote.name, - "entry": remote.entry, - "shareScope": remote.shareScope || "default" - })) - }, - chunkMatcher: () => true, - rootOutputDir: "", - initialConsumes: undefined, - bundlerRuntimeOptions: {} - }; - } - - __webpack_require__.n = (module) => { - const getter = module && module.__esModule ? () => module['default'] : () => module; - __webpack_require__.d(getter, {a: getter}); - return getter; - }; - - __webpack_require__.d = (exports, definition) => { - for (const key in definition) { - if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { - Object.defineProperty(exports, key, {enumerable: true, get: definition[key]}); - } - } - }; - - __webpack_require__.f = {}; - - __webpack_require__.g = (() => { - if (typeof globalThis === 'object') return globalThis; - try { - return this || new Function('return this')(); - } catch (e) { - if (typeof window === 'object') return window; - } - })(); - - __webpack_require__.o = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop); - - __webpack_require__.r = (exports) => { - if (typeof Symbol !== 'undefined' && Symbol.toStringTag) { - Object.defineProperty(exports, Symbol.toStringTag, {value: 'Module'}); - } - Object.defineProperty(exports, '__esModule', {value: true}); - }; - - __webpack_require__.federation.initOptions.shared = shared; - __webpack_require__.S = {}; - const initPromises = {}; - const initTokens = {}; - __webpack_require__.I = (name, initScope) => { - return __webpack_require__.federation.bundlerRuntime.I({ - shareScopeName: name, - webpackRequire: __webpack_require__, - initPromises: initPromises, - initTokens: initTokens, - initScope: initScope, - }); - }; - - const __webpack_exports__ = __webpack_require__("webpack/container/entry/createContainer"); - const __webpack_exports__get = __webpack_exports__.get; - const __webpack_exports__init = __webpack_exports__.init; - const __webpack_exports__moduleMap = __webpack_exports__.moduleMap; - return __webpack_exports__; -}`; diff --git a/packages/esbuild/src/lib/core/default-skip-list.ts b/packages/esbuild/src/lib/core/default-skip-list.ts index 67a21c94ef1..909d2bfc671 100644 --- a/packages/esbuild/src/lib/core/default-skip-list.ts +++ b/packages/esbuild/src/lib/core/default-skip-list.ts @@ -8,12 +8,6 @@ export type PreparedSkipList = { }; export const DEFAULT_SKIP_LIST: SkipListEntry[] = [ - '@module-federation/native-federation-runtime', - '@module-federation/native-federation', - '@module-federation/native-federation-core', - '@module-federation/native-federation-esbuild', - '@angular-architects/native-federation', - '@angular-architects/native-federation-runtime', 'es-module-shims', 'zone.js', 'tslib/', diff --git a/packages/esbuild/src/lib/core/federation-options.ts b/packages/esbuild/src/lib/core/federation-options.ts deleted file mode 100644 index 7c5cb51bd47..00000000000 --- a/packages/esbuild/src/lib/core/federation-options.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface FederationOptions { - workspaceRoot: string; - outputPath: string; - federationConfig: string; - tsConfig?: string; - verbose?: boolean; - dev?: boolean; - watch?: boolean; - packageJson?: string; -} diff --git a/packages/esbuild/src/lib/core/write-federation-info.ts b/packages/esbuild/src/lib/core/write-federation-info.ts deleted file mode 100644 index 41f7bc9c14f..00000000000 --- a/packages/esbuild/src/lib/core/write-federation-info.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as _path from 'path'; -import * as fs from 'fs'; - -interface FederationInfo { - // Define the structure of federationInfo here - [key: string]: any; -} - -interface FedOptions { - workspaceRoot: string; - outputPath: string; -} - -export function writeFederationInfo( - federationInfo: FederationInfo, - fedOptions: FedOptions, -): void { - const metaDataPath = _path.join( - fedOptions.workspaceRoot, - fedOptions.outputPath, - 'remoteEntry.json', - ); - fs.writeFileSync(metaDataPath, JSON.stringify(federationInfo, null, 2)); -} diff --git a/packages/esbuild/src/lib/utils/logger.ts b/packages/esbuild/src/lib/utils/logger.ts index 77af8614b2f..a3e8bee33cc 100644 --- a/packages/esbuild/src/lib/utils/logger.ts +++ b/packages/esbuild/src/lib/utils/logger.ts @@ -1,5 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -//@ts-ignore +// @ts-expect-error npmlog has no type declarations import npmlog from 'npmlog'; const levels = npmlog.levels; diff --git a/packages/esbuild/src/lib/utils/mapped-paths.ts b/packages/esbuild/src/lib/utils/mapped-paths.ts deleted file mode 100644 index 84f1ec4489f..00000000000 --- a/packages/esbuild/src/lib/utils/mapped-paths.ts +++ /dev/null @@ -1,50 +0,0 @@ -import * as path from 'path'; -import * as fs from 'fs'; -import * as JSON5 from 'json5'; - -export interface MappedPath { - key: string; - path: string; -} - -export interface GetMappedPathsParams { - rootTsConfigPath: string; - sharedMappings?: string[]; - rootPath?: string; -} - -export function getMappedPaths({ - rootTsConfigPath, - sharedMappings = [], - rootPath, -}: GetMappedPathsParams): MappedPath[] { - const result: MappedPath[] = []; - if (!path.isAbsolute(rootTsConfigPath)) { - throw new Error( - 'SharedMappings.register: tsConfigPath needs to be an absolute path!', - ); - } - if (!rootPath) { - rootPath = path.normalize(path.dirname(rootTsConfigPath)); - } - const shareAll = sharedMappings.length === 0; - const tsConfig = JSON5.parse( - fs.readFileSync(rootTsConfigPath, { encoding: 'utf-8' }), - ); - const mappings = tsConfig?.compilerOptions?.paths; - if (!mappings) { - return result; - } - for (const key in mappings) { - if (Object.prototype.hasOwnProperty.call(mappings, key)) { - const libPath = path.normalize(path.join(rootPath, mappings[key][0])); - if (sharedMappings.includes(key) || shareAll) { - result.push({ - key, - path: libPath, - }); - } - } - } - return result; -} diff --git a/packages/esbuild/src/lib/utils/package-info.ts b/packages/esbuild/src/lib/utils/package-info.ts index 0f634547236..23115c71299 100644 --- a/packages/esbuild/src/lib/utils/package-info.ts +++ b/packages/esbuild/src/lib/utils/package-info.ts @@ -8,13 +8,6 @@ interface PackageJsonInfo { directory: string; } -interface PackageInfo { - entryPoint: string; - packageName: string; - version: string; - esm: boolean; -} - const packageCache: Record = {}; export function findPackageJsonFiles( @@ -48,22 +41,6 @@ export function expandFolders(child: string, parent: string): string[] { return result; } -export function getPackageInfo( - packageName: string, - workspaceRoot: string, -): PackageInfo | null { - workspaceRoot = normalize(workspaceRoot, true); - const packageJsonInfos = getPackageJsonFiles(workspaceRoot, workspaceRoot); - for (const info of packageJsonInfos) { - const cand = _getPackageInfo(packageName, info.directory); - if (cand) { - return cand; - } - } - logger.warn('No meta data found for shared lib ' + packageName); - return null; -} - function getVersionMapCacheKey(project: string, workspace: string): string { return `${project}**${workspace}`; } @@ -124,169 +101,6 @@ export function findDepPackageJson( return mainPkgJsonPath; } -export function _getPackageInfo( - packageName: string, - directory: string, -): PackageInfo | null { - const mainPkgName = getPkgFolder(packageName); - const mainPkgJsonPath = findDepPackageJson(packageName, directory); - if (!mainPkgJsonPath) { - return null; - } - const mainPkgPath = path.dirname(mainPkgJsonPath); - const mainPkgJson = readJson(mainPkgJsonPath); - const version = mainPkgJson['version']; - const esm = mainPkgJson['type'] === 'module'; - if (!version) { - logger.warn('No version found for ' + packageName); - return null; - } - let relSecondaryPath = path.relative(mainPkgName, packageName); - if (!relSecondaryPath) { - relSecondaryPath = '.'; - } else { - relSecondaryPath = './' + relSecondaryPath.replace(/\\/g, '/'); - } - let cand = mainPkgJson?.exports?.[relSecondaryPath]; - if (typeof cand === 'string') { - return { - entryPoint: path.join(mainPkgPath, cand), - packageName, - version, - esm, - }; - } - cand = mainPkgJson?.exports?.[relSecondaryPath]?.import; - if (typeof cand === 'object') { - if (cand.module) { - cand = cand.module; - } else if (cand.import) { - cand = cand.import; - } else if (cand.default) { - cand = cand.default; - } else { - cand = null; - } - } - if (cand) { - if (typeof cand === 'object') { - if (cand.module) { - cand = cand.module; - } else if (cand.import) { - cand = cand.import; - } else if (cand.default) { - cand = cand.default; - } else { - cand = null; - } - } - return { - entryPoint: path.join(mainPkgPath, cand), - packageName, - version, - esm, - }; - } - cand = mainPkgJson?.exports?.[relSecondaryPath]?.module; - if (typeof cand === 'object') { - if (cand.module) { - cand = cand.module; - } else if (cand.import) { - cand = cand.import; - } else if (cand.default) { - cand = cand.default; - } else { - cand = null; - } - } - if (cand) { - return { - entryPoint: path.join(mainPkgPath, cand), - packageName, - version, - esm, - }; - } - cand = mainPkgJson?.exports?.[relSecondaryPath]?.default; - if (cand) { - if (typeof cand === 'object') { - if (cand.module) { - cand = cand.module; - } else if (cand.import) { - cand = cand.import; - } else if (cand.default) { - cand = cand.default; - } else { - cand = null; - } - } - return { - entryPoint: path.join(mainPkgPath, cand), - packageName, - version, - esm, - }; - } - cand = mainPkgJson['module']; - if (cand && relSecondaryPath === '.') { - return { - entryPoint: path.join(mainPkgPath, cand), - packageName, - version, - esm: true, - }; - } - const secondaryPgkPath = path.join(mainPkgPath, relSecondaryPath); - const secondaryPgkJsonPath = path.join(secondaryPgkPath, 'package.json'); - let secondaryPgkJson: any = null; - if (fs.existsSync(secondaryPgkJsonPath)) { - secondaryPgkJson = readJson(secondaryPgkJsonPath); - } - if (secondaryPgkJson && secondaryPgkJson.module) { - return { - entryPoint: path.join(secondaryPgkPath, secondaryPgkJson.module), - packageName, - version, - esm: true, - }; - } - cand = path.join(secondaryPgkPath, 'index.mjs'); - if (fs.existsSync(cand)) { - return { - entryPoint: cand, - packageName, - version, - esm: true, - }; - } - if (secondaryPgkJson && secondaryPgkJson.main) { - return { - entryPoint: path.join(secondaryPgkPath, secondaryPgkJson.main), - packageName, - version, - esm, - }; - } - cand = path.join(secondaryPgkPath, 'index.js'); - if (fs.existsSync(cand)) { - return { - entryPoint: cand, - packageName, - version, - esm, - }; - } - logger.warn('No entry point found for ' + packageName); - logger.warn( - "If you don't need this package, skip it in your federation.config.js or consider moving it into depDependencies in your package.json", - ); - return null; -} - -function readJson(mainPkgJsonPath: string): any { - return JSON.parse(fs.readFileSync(mainPkgJsonPath, 'utf-8')); -} - function getPkgFolder(packageName: string): string { const parts = packageName.split('/'); let folder = parts[0]; diff --git a/packages/esbuild/src/resolve/esm-resolver.mjs b/packages/esbuild/src/resolve/esm-resolver.mjs deleted file mode 100644 index 4a44e535cc6..00000000000 --- a/packages/esbuild/src/resolve/esm-resolver.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import { createRequire } from 'module'; -import nodePath from 'path'; -export default (path, options = {}) => { - const p = options.path || undefined; - const mode = options.mode || 'esm'; - if (mode === 'cjs') { - const require = createRequire(import.meta.url); - if (!p) return require.resolve(path); - return require.resolve(path, { paths: [p] }); - } else { - try { - return import.meta.resolve(path.join(p, path)).replace(/^file:\/\//, ''); - } catch (e) { - const require = createRequire(import.meta.url); - if (!p) return require.resolve(path); - return require.resolve(path, { paths: [p] }); - } - } -}; diff --git a/packages/esbuild/src/resolve/package.json b/packages/esbuild/src/resolve/package.json deleted file mode 100644 index 3dbc1ca591c..00000000000 --- a/packages/esbuild/src/resolve/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "module" -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 191c25d70e3..e993b7ec18d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3337,15 +3337,9 @@ importers: packages/esbuild: dependencies: - '@chialab/esbuild-plugin-commonjs': - specifier: ^0.18.0 - version: 0.18.0 - '@hyrious/esbuild-plugin-commonjs': - specifier: ^0.2.4 - version: 0.2.6(cjs-module-lexer@1.4.3)(esbuild@0.25.0) - '@module-federation/sdk': + '@module-federation/runtime': specifier: workspace:* - version: link:../sdk + version: link:../runtime cjs-module-lexer: specifier: ^1.3.1 version: 1.4.3 @@ -3358,9 +3352,6 @@ importers: esbuild: specifier: ^0.25.0 version: 0.25.0 - json5: - specifier: ^2.2.3 - version: 2.2.3 npmlog: specifier: ^7.0.1 version: 7.0.1