diff --git a/.changeset/clean-cobras-rest.md b/.changeset/clean-cobras-rest.md new file mode 100644 index 00000000000..8e5bcc894e1 --- /dev/null +++ b/.changeset/clean-cobras-rest.md @@ -0,0 +1,14 @@ +--- +"@module-federation/runtime-core": patch +"@module-federation/enhanced": patch +"@module-federation/rspack": patch +"@module-federation/sdk": patch +--- + +Add import map remote entry support in runtime-core with a tree-shakeable +`FEDERATION_OPTIMIZE_NO_IMPORTMAP` flag, and expose `disableImportMap` +in Module Federation plugin optimization options (defaulting to `true` +in build plugins). + +Shared modules now honor the `import` field to load shared dependencies +via dynamic import (including bare specifiers resolved by import maps). diff --git a/apps/import-map/README.md b/apps/import-map/README.md new file mode 100644 index 00000000000..cd4f72e9e65 --- /dev/null +++ b/apps/import-map/README.md @@ -0,0 +1,26 @@ +# Import Map Runtime Demo + +This example demonstrates using `@module-federation/runtime-core` with an +import map. The host (app1) loads the remote (app2) by using a bare specifier +mapped in the import map. + +## Running the demo + +Start both apps in separate terminals: + +```bash +pnpm --filter import-map-app2 run build +pnpm --filter import-map-app2 run serve + +pnpm --filter import-map-app1 run build +pnpm --filter import-map-app1 run serve +``` + +- Host: http://127.0.0.1:3101 +- Remote entry (import map target): http://127.0.0.1:3102/remoteEntry.js + +## E2E + +```bash +pnpm --filter import-map-app1 run test:e2e +``` diff --git a/apps/import-map/app1/cypress.config.js b/apps/import-map/app1/cypress.config.js new file mode 100644 index 00000000000..fa2b0d3dbb4 --- /dev/null +++ b/apps/import-map/app1/cypress.config.js @@ -0,0 +1,9 @@ +const { defineConfig } = require('cypress'); + +module.exports = defineConfig({ + e2e: { + specPattern: 'cypress/e2e/**/*.cy.{js,ts,jsx,tsx}', + supportFile: 'cypress/support/e2e.ts', + }, + defaultCommandTimeout: 20000, +}); diff --git a/apps/import-map/app1/cypress/e2e/import-map.cy.ts b/apps/import-map/app1/cypress/e2e/import-map.cy.ts new file mode 100644 index 00000000000..0288177eacc --- /dev/null +++ b/apps/import-map/app1/cypress/e2e/import-map.cy.ts @@ -0,0 +1,10 @@ +describe('import-map runtime host', () => { + beforeEach(() => cy.visit('/')); + + it('loads the remote module via import map', () => { + cy.get('[data-test="status"]').contains('Loaded'); + cy.get('[data-test="remote-message"]').contains( + 'Hello from import map remote', + ); + }); +}); diff --git a/apps/import-map/app1/cypress/fixtures/example.json b/apps/import-map/app1/cypress/fixtures/example.json new file mode 100644 index 00000000000..63f783087b5 --- /dev/null +++ b/apps/import-map/app1/cypress/fixtures/example.json @@ -0,0 +1,3 @@ +{ + "name": "import-map" +} diff --git a/apps/import-map/app1/cypress/support/commands.ts b/apps/import-map/app1/cypress/support/commands.ts new file mode 100644 index 00000000000..7d07d532499 --- /dev/null +++ b/apps/import-map/app1/cypress/support/commands.ts @@ -0,0 +1,14 @@ +/// + +// eslint-disable-next-line @typescript-eslint/no-namespace +declare namespace Cypress { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface Chainable { + login(email: string, password: string): void; + } +} + +Cypress.Commands.add('login', (email, password) => { + void email; + void password; +}); diff --git a/apps/import-map/app1/cypress/support/e2e.ts b/apps/import-map/app1/cypress/support/e2e.ts new file mode 100644 index 00000000000..e58203ee857 --- /dev/null +++ b/apps/import-map/app1/cypress/support/e2e.ts @@ -0,0 +1,6 @@ +// *********************************************************** +// This example support/e2e.ts is processed and +// loaded automatically before your test files. +// *********************************************************** + +import './commands'; diff --git a/apps/import-map/app1/cypress/tsconfig.json b/apps/import-map/app1/cypress/tsconfig.json new file mode 100644 index 00000000000..f0a0fad198f --- /dev/null +++ b/apps/import-map/app1/cypress/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "allowJs": true, + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["cypress", "node"], + "sourceMap": false + }, + "include": [ + "**/*.ts", + "**/*.js", + "../cypress.config.js", + "../**/*.cy.ts", + "../**/*.cy.tsx", + "../**/*.cy.js", + "../**/*.cy.jsx", + "../**/*.d.ts" + ] +} diff --git a/apps/import-map/app1/package.json b/apps/import-map/app1/package.json new file mode 100644 index 00000000000..af7a3b50cf0 --- /dev/null +++ b/apps/import-map/app1/package.json @@ -0,0 +1,15 @@ +{ + "name": "import-map-app1", + "private": true, + "version": "0.0.0", + "scripts": { + "build": "webpack --config ./webpack.config.js --mode=development", + "build:prod": "webpack --config ./webpack.config.js --mode=production", + "serve": "node ../serve-static.js --root ./dist --port 3101", + "e2e": "cypress run --config-file ./cypress.config.js --config baseUrl=http://127.0.0.1:3101 --browser chrome", + "test:e2e": "concurrently -k \"pnpm --filter import-map-app2 run build\" \"pnpm --filter import-map-app1 run build\" \"pnpm --filter import-map-app2 run serve\" \"pnpm --filter import-map-app1 run serve\" \"wait-on http://127.0.0.1:3102/remoteEntry.js http://127.0.0.1:3101 && pnpm --filter import-map-app1 run e2e\"" + }, + "devDependencies": { + "@module-federation/runtime-core": "workspace:*" + } +} diff --git a/apps/import-map/app1/src/index.html b/apps/import-map/app1/src/index.html new file mode 100644 index 00000000000..8b4836542de --- /dev/null +++ b/apps/import-map/app1/src/index.html @@ -0,0 +1,21 @@ + + + + + Import Map Runtime Host + + + +
+

Import Map Runtime Host

+

Idle

+

Waiting for remote...

+
+ + diff --git a/apps/import-map/app1/src/index.ts b/apps/import-map/app1/src/index.ts new file mode 100644 index 00000000000..bdc74cdfbdd --- /dev/null +++ b/apps/import-map/app1/src/index.ts @@ -0,0 +1,48 @@ +import { ModuleFederation } from '@module-federation/runtime-core'; + +const statusEl = document.querySelector('[data-test="status"]'); +const messageEl = document.querySelector('[data-test="remote-message"]'); + +const setStatus = (message: string) => { + if (statusEl) { + statusEl.textContent = message; + } +}; + +const setMessage = (message: string) => { + if (messageEl) { + messageEl.textContent = message; + } +}; + +const federation = new ModuleFederation({ + name: 'import-map-host', + remotes: [ + { + name: 'import_map_remote', + entry: 'import_map_remote', + type: 'module', + entryFormat: 'importmap', + }, + ], + shared: {}, +}); + +const loadRemoteMessage = async () => { + try { + setStatus('Loading remote...'); + const remoteModule = await federation.loadRemote<{ + default?: () => string; + }>('import_map_remote/hello'); + + const message = + remoteModule?.default?.() ?? 'Remote module did not return a message.'; + setMessage(message); + setStatus('Loaded'); + } catch (error) { + setStatus('Failed'); + setMessage(`Error loading remote: ${String(error)}`); + } +}; + +void loadRemoteMessage(); diff --git a/apps/import-map/app1/tsconfig.app.json b/apps/import-map/app1/tsconfig.app.json new file mode 100644 index 00000000000..d5657e0790f --- /dev/null +++ b/apps/import-map/app1/tsconfig.app.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": ["node"] + }, + "exclude": [ + "jest.config.ts", + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.js", + "**/*.test.js", + "dist/**" + ], + "include": ["**/*.ts"] +} diff --git a/apps/import-map/app1/tsconfig.json b/apps/import-map/app1/tsconfig.json new file mode 100644 index 00000000000..4ee1ce40929 --- /dev/null +++ b/apps/import-map/app1/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "./", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + } + ] +} diff --git a/apps/import-map/app1/webpack.config.js b/apps/import-map/app1/webpack.config.js new file mode 100644 index 00000000000..77c68434643 --- /dev/null +++ b/apps/import-map/app1/webpack.config.js @@ -0,0 +1,50 @@ +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); + +module.exports = { + entry: path.resolve(__dirname, 'src/index.ts'), + devtool: false, + experiments: { + outputModule: true, + }, + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'main.js', + publicPath: 'http://127.0.0.1:3101/', + module: true, + scriptType: 'module', + clean: true, + }, + resolve: { + extensions: ['.ts', '.js'], + }, + module: { + rules: [ + { + test: /\.ts$/, + exclude: /node_modules/, + use: { + loader: 'swc-loader', + options: { + jsc: { + parser: { + syntax: 'typescript', + }, + target: 'es2021', + }, + }, + }, + }, + ], + }, + plugins: [ + new HtmlWebpackPlugin({ + template: path.resolve(__dirname, 'src/index.html'), + scriptLoading: 'module', + }), + ], + optimization: { + runtimeChunk: false, + splitChunks: false, + }, +}; diff --git a/apps/import-map/app2/package.json b/apps/import-map/app2/package.json new file mode 100644 index 00000000000..374938a8c57 --- /dev/null +++ b/apps/import-map/app2/package.json @@ -0,0 +1,13 @@ +{ + "name": "import-map-app2", + "private": true, + "version": "0.0.0", + "scripts": { + "build": "webpack --config ./webpack.config.js --mode=development", + "build:prod": "webpack --config ./webpack.config.js --mode=production", + "serve": "node ../serve-static.js --root ./dist --port 3102" + }, + "devDependencies": { + "@module-federation/enhanced": "workspace:*" + } +} diff --git a/apps/import-map/app2/src/hello.ts b/apps/import-map/app2/src/hello.ts new file mode 100644 index 00000000000..0bd2d9a4fc9 --- /dev/null +++ b/apps/import-map/app2/src/hello.ts @@ -0,0 +1,3 @@ +const hello = () => 'Hello from import map remote'; + +export default hello; diff --git a/apps/import-map/app2/src/index.html b/apps/import-map/app2/src/index.html new file mode 100644 index 00000000000..9b0aee88294 --- /dev/null +++ b/apps/import-map/app2/src/index.html @@ -0,0 +1,10 @@ + + + + + Import Map Remote + + +
Import Map Remote
+ + diff --git a/apps/import-map/app2/src/index.ts b/apps/import-map/app2/src/index.ts new file mode 100644 index 00000000000..4944d9a5c95 --- /dev/null +++ b/apps/import-map/app2/src/index.ts @@ -0,0 +1,5 @@ +const root = document.getElementById('root'); + +if (root) { + root.textContent = 'Import Map Remote is running.'; +} diff --git a/apps/import-map/app2/tsconfig.app.json b/apps/import-map/app2/tsconfig.app.json new file mode 100644 index 00000000000..d5657e0790f --- /dev/null +++ b/apps/import-map/app2/tsconfig.app.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": ["node"] + }, + "exclude": [ + "jest.config.ts", + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.js", + "**/*.test.js", + "dist/**" + ], + "include": ["**/*.ts"] +} diff --git a/apps/import-map/app2/tsconfig.json b/apps/import-map/app2/tsconfig.json new file mode 100644 index 00000000000..4ee1ce40929 --- /dev/null +++ b/apps/import-map/app2/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "./", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + } + ] +} diff --git a/apps/import-map/app2/webpack.config.js b/apps/import-map/app2/webpack.config.js new file mode 100644 index 00000000000..3fc4742bfbf --- /dev/null +++ b/apps/import-map/app2/webpack.config.js @@ -0,0 +1,61 @@ +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const { + ModuleFederationPlugin, +} = require('@module-federation/enhanced/webpack'); + +module.exports = { + entry: path.resolve(__dirname, 'src/index.ts'), + devtool: false, + experiments: { + outputModule: true, + }, + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'main.js', + publicPath: 'http://127.0.0.1:3102/', + module: true, + scriptType: 'module', + clean: true, + }, + resolve: { + extensions: ['.ts', '.js'], + }, + module: { + rules: [ + { + test: /\.ts$/, + exclude: /node_modules/, + use: { + loader: 'swc-loader', + options: { + jsc: { + parser: { + syntax: 'typescript', + }, + target: 'es2021', + }, + }, + }, + }, + ], + }, + plugins: [ + new ModuleFederationPlugin({ + name: 'import_map_remote', + filename: 'remoteEntry.js', + library: { type: 'module' }, + exposes: { + './hello': './src/hello.ts', + }, + }), + new HtmlWebpackPlugin({ + template: path.resolve(__dirname, 'src/index.html'), + scriptLoading: 'module', + }), + ], + optimization: { + runtimeChunk: false, + splitChunks: false, + }, +}; diff --git a/apps/import-map/serve-static.js b/apps/import-map/serve-static.js new file mode 100644 index 00000000000..8cd29e3619c --- /dev/null +++ b/apps/import-map/serve-static.js @@ -0,0 +1,64 @@ +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { URL } = require('url'); + +const args = process.argv.slice(2); +const getArg = (name, fallback) => { + const idx = args.indexOf(name); + if (idx === -1 || idx + 1 >= args.length) { + return fallback; + } + return args[idx + 1]; +}; + +const root = getArg('--root', process.cwd()); +const port = Number(getArg('--port', '3000')); + +const mimeTypes = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', +}; + +const server = http.createServer((req, res) => { + const requestUrl = new URL(req.url || '/', `http://${req.headers.host}`); + const rawPath = + requestUrl.pathname === '/' ? '/index.html' : requestUrl.pathname; + const safePath = path.normalize(rawPath).replace(/^(\.\.(\/|\\|$))+/, ''); + const filePath = path.join(root, safePath); + + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS'); + res.setHeader( + 'Access-Control-Allow-Headers', + 'X-Requested-With, content-type, Authorization', + ); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end('Not found'); + return; + } + const ext = path.extname(filePath); + res.writeHead(200, { + 'Content-Type': mimeTypes[ext] || 'application/octet-stream', + }); + res.end(data); + }); +}); + +server.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`Serving ${root} at http://127.0.0.1:${port}`); +}); diff --git a/arch-doc/architecture-overview.md b/arch-doc/architecture-overview.md index 90381b575a2..bed187df04a 100644 --- a/arch-doc/architecture-overview.md +++ b/arch-doc/architecture-overview.md @@ -554,6 +554,8 @@ graph TB style OptCheck fill:#f96,stroke:#333,stroke-width:2px ``` +Import map entry preservation is controlled separately via `FEDERATION_OPTIMIZE_NO_IMPORTMAP`, allowing builds that do not use import maps to tree-shake that logic from runtime-core. + ### Share Scope Management ```mermaid diff --git a/arch-doc/runtime-architecture.md b/arch-doc/runtime-architecture.md index a9552c4a3b6..d9b7dfce3d4 100644 --- a/arch-doc/runtime-architecture.md +++ b/arch-doc/runtime-architecture.md @@ -75,6 +75,19 @@ const USE_SNAPSHOT = When `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN` is `true`, snapshot functionality is disabled for smaller bundle sizes. +Import map entry preservation is controlled by the `FEDERATION_OPTIMIZE_NO_IMPORTMAP` build-time flag: + +```typescript +// Declared in remote/index.ts with DefinePlugin +declare const FEDERATION_OPTIMIZE_NO_IMPORTMAP: boolean; +const USE_IMPORTMAP = + typeof FEDERATION_OPTIMIZE_NO_IMPORTMAP === 'boolean' + ? !FEDERATION_OPTIMIZE_NO_IMPORTMAP + : true; // Default to true (enable import map support) +``` + +When `FEDERATION_OPTIMIZE_NO_IMPORTMAP` is `true`, import map-specific handling is tree-shaken from runtime-core. + ```mermaid classDiagram class ModuleFederation { @@ -1037,7 +1050,7 @@ class ViteBundlerRuntime implements BundlerRuntimeIntegration { ### Build-Time Responsibilities The build-time layer handles: -- **DefinePlugin Integration**: Defines `FEDERATION_BUILD_IDENTIFIER` and `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN` flags +- **DefinePlugin Integration**: Defines `FEDERATION_BUILD_IDENTIFIER`, `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN`, and `FEDERATION_OPTIMIZE_NO_IMPORTMAP` flags - **Bundle Generation**: Creates remote entry files and module manifests - **Static Analysis**: Determines shared dependencies and remote configurations - **Code Splitting**: Separates remote modules from host bundles @@ -1057,10 +1070,12 @@ The runtime layer handles: // Build-time defines these globals, runtime consumes them declare const FEDERATION_BUILD_IDENTIFIER: string; declare const FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: boolean; +declare const FEDERATION_OPTIMIZE_NO_IMPORTMAP: boolean; // Runtime uses build-time generated information const buildId = getBuilderId(); // Reads FEDERATION_BUILD_IDENTIFIER const useSnapshot = !FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN; // Feature flag +const useImportMap = !FEDERATION_OPTIMIZE_NO_IMPORTMAP; // Feature flag // Build-time generates manifest, runtime consumes it const manifest = await fetch('./federation-manifest.json'); diff --git a/arch-doc/sdk-reference.md b/arch-doc/sdk-reference.md index ecba49c6060..854356709fa 100644 --- a/arch-doc/sdk-reference.md +++ b/arch-doc/sdk-reference.md @@ -109,6 +109,10 @@ interface ModuleFederationPluginOptions { * Enable optimization to skip snapshot plugin */ disableSnapshot?: boolean; + /** + * Enable optimization to skip import map support in runtime-core + */ + disableImportMap?: boolean; /** * Target environment for the build */ diff --git a/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts b/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts index f308f576e25..2afa27a2550 100644 --- a/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts +++ b/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts @@ -105,6 +105,10 @@ class ModuleFederationPlugin implements WebpackPluginInstance { definePluginOptions['FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN'] = disableSnapshot; + const disableImportMap = + experiments?.optimization?.disableImportMap ?? true; + definePluginOptions['FEDERATION_OPTIMIZE_NO_IMPORTMAP'] = disableImportMap; + // Determine ENV_TARGET: only if manually specified in experiments.optimization.target if ( experiments?.optimization && diff --git a/packages/enhanced/src/schemas/container/ModuleFederationPlugin.check.ts b/packages/enhanced/src/schemas/container/ModuleFederationPlugin.check.ts index 47c62bf5dac..679d9d2cce0 100644 --- a/packages/enhanced/src/schemas/container/ModuleFederationPlugin.check.ts +++ b/packages/enhanced/src/schemas/container/ModuleFederationPlugin.check.ts @@ -468,6 +468,7 @@ const t = { type: 'object', properties: { disableSnapshot: { type: 'boolean' }, + disableImportMap: { type: 'boolean' }, target: { enum: ['web', 'node'] }, }, additionalProperties: !1, @@ -4629,6 +4630,8 @@ function D( if ( 'disableSnapshot' !== e && + 'disableImportMap' !== + e && 'target' !== e ) return ( @@ -4664,6 +4667,28 @@ function D( ); var I = e === c; } else I = !0; + if (I) + if ( + void 0 !== + r.disableImportMap + ) { + const e = c; + if ( + 'boolean' != + typeof r.disableImportMap + ) + return ( + (D.errors = [ + { + params: { + type: 'boolean', + }, + }, + ]), + !1 + ); + I = e === c; + } else I = !0; if (I) if ( void 0 !== diff --git a/packages/enhanced/src/schemas/container/ModuleFederationPlugin.json b/packages/enhanced/src/schemas/container/ModuleFederationPlugin.json index 4ca4c940283..7a7d0c26b62 100644 --- a/packages/enhanced/src/schemas/container/ModuleFederationPlugin.json +++ b/packages/enhanced/src/schemas/container/ModuleFederationPlugin.json @@ -883,6 +883,10 @@ "description": "Enable optimization to skip snapshot plugin", "type": "boolean" }, + "disableImportMap": { + "description": "Enable optimization to skip import map support in runtime-core", + "type": "boolean" + }, "target": { "description": "Target environment for the build", "enum": ["web", "node"] diff --git a/packages/enhanced/src/schemas/container/ModuleFederationPlugin.ts b/packages/enhanced/src/schemas/container/ModuleFederationPlugin.ts index 4cfba3f43aa..5884764e6dc 100644 --- a/packages/enhanced/src/schemas/container/ModuleFederationPlugin.ts +++ b/packages/enhanced/src/schemas/container/ModuleFederationPlugin.ts @@ -977,6 +977,11 @@ export default { description: 'Enable optimization to skip snapshot plugin', type: 'boolean', }, + disableImportMap: { + description: + 'Enable optimization to skip import map support in runtime-core', + type: 'boolean', + }, target: { description: 'Target environment for the build', enum: ['web', 'node'], diff --git a/packages/rspack/src/ModuleFederationPlugin.ts b/packages/rspack/src/ModuleFederationPlugin.ts index 37f12dac7fc..7fc545398b4 100644 --- a/packages/rspack/src/ModuleFederationPlugin.ts +++ b/packages/rspack/src/ModuleFederationPlugin.ts @@ -59,6 +59,10 @@ export class ModuleFederationPlugin implements RspackPluginInstance { definePluginOptions['FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN'] = disableSnapshot; + const disableImportMap = + experiments?.optimization?.disableImportMap ?? true; + definePluginOptions['FEDERATION_OPTIMIZE_NO_IMPORTMAP'] = disableImportMap; + // Determine ENV_TARGET: only if manually specified in experiments.optimization.target if ( experiments?.optimization && diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 02886810e83..fca384fbdc5 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -8,6 +8,47 @@ See [https://module-federation.io/guide/runtime/index.html](https://module-federation.io/guide/runtime/index.html) for details. +## Import Maps + +When using Import Maps with `type: "module"` or `type: "system"` remotes, preserve bare specifiers by setting `entryFormat: "importmap"`: + +```ts +import { ModuleFederation } from '@module-federation/runtime-core'; + +const mf = new ModuleFederation({ + name: 'host', + remotes: [ + { + name: 'webpack_remote', + entry: 'webpack_remote', + type: 'module', + entryFormat: 'importmap', + }, + ], +}); +``` + +This keeps the entry untouched so the browser/SystemJS can resolve it via the import map. + +To tree-shake import map support, define `FEDERATION_OPTIMIZE_NO_IMPORTMAP` as `true` (or use `experiments.optimization.disableImportMap` when using the ModuleFederationPlugin). Note that the build plugins default `disableImportMap` to `true`, so set it to `false` if you want import map support enabled. + +### Shared modules with import maps + +You can also load shared modules from import-map specifiers by using the same `import` field naming convention: + +```ts +const mf = new ModuleFederation({ + name: 'host', + shared: { + react: { + version: '18.3.1', + import: 'react', + shareConfig: { singleton: true }, + }, + }, +}); +``` + ## License `@module-federation/runtime` is [MIT licensed](https://github.com/module-federation/core/blob/main/packages/runtime/LICENSE). diff --git a/packages/runtime-core/__tests__/register-remotes.spec.ts b/packages/runtime-core/__tests__/register-remotes.spec.ts index 174d914c491..1a8953b2e9e 100644 --- a/packages/runtime-core/__tests__/register-remotes.spec.ts +++ b/packages/runtime-core/__tests__/register-remotes.spec.ts @@ -102,4 +102,38 @@ describe('ModuleFederation', () => { // Value is different from the registered remote expect(newApp1Res).toBe('hello app1 entry2'); }); + + it('preserves import map entries when entryFormat is importmap', () => { + const entry = 'webpack_remote'; + const FM = new ModuleFederation({ + name: '@federation/instance', + remotes: [ + { + name: '@register-remotes/importmap', + entry, + entryFormat: 'importmap', + type: 'module', + }, + ], + }); + + expect(FM.options.remotes[0].entry).toBe(entry); + }); + + it('normalizes relative entries to absolute urls by default', () => { + const entry = '/static/remoteEntry.js'; + const FM = new ModuleFederation({ + name: '@federation/instance', + remotes: [ + { + name: '@register-remotes/relative', + entry, + }, + ], + }); + + expect(FM.options.remotes[0].entry).toBe( + new URL(entry, window.location.origin).href, + ); + }); }); diff --git a/packages/runtime-core/__tests__/shared-import.spec.ts b/packages/runtime-core/__tests__/shared-import.spec.ts new file mode 100644 index 00000000000..2add2a14dba --- /dev/null +++ b/packages/runtime-core/__tests__/shared-import.spec.ts @@ -0,0 +1,36 @@ +import { assert, describe, expect, it } from 'vitest'; +import { ModuleFederation } from '../src/index'; + +describe('shared import map support', () => { + it('loads shared module from import specifier', async () => { + const moduleSource = + "export default { message: 'Hello from import map shared module' };"; + const specifier = `data:text/javascript,${encodeURIComponent( + moduleSource, + )}`; + + const federation = new ModuleFederation({ + name: '@import-map/shared-host', + remotes: [], + shared: { + 'import-map-shared': { + version: '1.0.0', + import: specifier, + shareConfig: { + singleton: true, + requiredVersion: '^1.0.0', + eager: false, + }, + }, + }, + }); + + const factory = await federation.loadShare<{ + default: { message: string }; + }>('import-map-shared'); + + assert(factory); + const mod = factory(); + expect(mod.default.message).toBe('Hello from import map shared module'); + }); +}); diff --git a/packages/runtime-core/src/remote/index.ts b/packages/runtime-core/src/remote/index.ts index 128016dc874..f4e594f2d23 100644 --- a/packages/runtime-core/src/remote/index.ts +++ b/packages/runtime-core/src/remote/index.ts @@ -46,6 +46,14 @@ import { formatPreloadArgs, preloadAssets } from '../utils/preload'; import { getGlobalShareScope } from '../utils/share'; import { getGlobalRemoteInfo } from '../plugins/snapshot/SnapshotHandler'; +declare const FEDERATION_OPTIMIZE_NO_IMPORTMAP: boolean; +const USE_IMPORTMAP = + typeof FEDERATION_OPTIMIZE_NO_IMPORTMAP === 'boolean' + ? !FEDERATION_OPTIMIZE_NO_IMPORTMAP + : true; + +const IMPORTMAP_REMOTE_TYPES = new Set(['module', 'system']); + export interface LoadRemoteMatch { id: string; pkgNameOrAlias: string; @@ -429,7 +437,23 @@ export class RemoteHandler { } // Set the remote entry to a complete path if ('entry' in remote) { - if (isBrowserEnv() && !remote.entry.startsWith('http')) { + const preserveImportMapEntry = + USE_IMPORTMAP && remote.entryFormat === 'importmap'; + if ( + preserveImportMapEntry && + !IMPORTMAP_REMOTE_TYPES.has(remote.type || DEFAULT_REMOTE_TYPE) + ) { + warn( + `Remote "${remote.name}" uses entryFormat="importmap" but remote type "${ + remote.type || DEFAULT_REMOTE_TYPE + }" does not support import maps. Use type "module" or "system" to enable import map resolution.`, + ); + } + if ( + isBrowserEnv() && + !preserveImportMapEntry && + !remote.entry.startsWith('http') + ) { remote.entry = new URL(remote.entry, window.location.origin).href; } } diff --git a/packages/runtime-core/src/type/config.ts b/packages/runtime-core/src/type/config.ts index 98c6c357bbf..e6198a40470 100644 --- a/packages/runtime-core/src/type/config.ts +++ b/packages/runtime-core/src/type/config.ts @@ -12,11 +12,14 @@ export type PartialOptional = Omit & { [P in K]-?: T[P]; }; +export type RemoteEntryFormat = 'url' | 'importmap'; + export interface RemoteInfoCommon { alias?: string; shareScope?: string | string[]; type?: RemoteEntryType; entryGlobalName?: string; + entryFormat?: RemoteEntryFormat; } export type RemoteInfoOptionalVersion = { @@ -40,6 +43,7 @@ export interface RemoteInfo { type: RemoteEntryType; entryGlobalName: string; shareScope: string | string[]; + entryFormat?: RemoteEntryFormat; } export type HostInfo = Pick< @@ -74,6 +78,7 @@ type SharedBaseArgs = { strategy?: 'version-first' | 'loaded-first'; loaded?: boolean; treeShaking?: TreeShakingArgs; + import?: string | false; }; export type SharedGetter = (() => () => Module) | (() => Promise<() => Module>); @@ -96,6 +101,7 @@ export type Shared = { lib?: () => Module; loaded?: boolean; loading?: null | Promise; + import?: string | false; // compatibility with previous shared eager?: boolean; /** diff --git a/packages/runtime-core/src/utils/load.ts b/packages/runtime-core/src/utils/load.ts index d3573139aa7..3609156d711 100644 --- a/packages/runtime-core/src/utils/load.ts +++ b/packages/runtime-core/src/utils/load.ts @@ -313,5 +313,6 @@ export function getRemoteInfo(remote: Remote): RemoteInfo { type: remote.type || DEFAULT_REMOTE_TYPE, entryGlobalName: remote.entryGlobalName || remote.name, shareScope: remote.shareScope || DEFAULT_SCOPE, + entryFormat: remote.entryFormat, }; } diff --git a/packages/runtime-core/src/utils/share.ts b/packages/runtime-core/src/utils/share.ts index 007a7b2ef8e..99d36e52f58 100644 --- a/packages/runtime-core/src/utils/share.ts +++ b/packages/runtime-core/src/utils/share.ts @@ -1,5 +1,5 @@ import { DEFAULT_SCOPE } from '../constant'; -import { TreeShakingStatus } from '@module-federation/sdk'; +import { Module, TreeShakingStatus } from '@module-federation/sdk'; import { Global, Federation } from '../global'; import { GlobalShareScopeMap, @@ -19,6 +19,30 @@ import { satisfy } from './semver'; import { SyncWaterfallHook } from './hooks'; import { addUniqueItem, arrayOptions } from './tool'; +declare const FEDERATION_OPTIMIZE_NO_IMPORTMAP: boolean; +const USE_IMPORTMAP = + typeof FEDERATION_OPTIMIZE_NO_IMPORTMAP === 'boolean' + ? !FEDERATION_OPTIMIZE_NO_IMPORTMAP + : true; + +const createImportGetter = (specifier: string): SharedGetter => { + const dynamicImport = (target: string): Promise => { + if (typeof FEDERATION_ALLOW_NEW_FUNCTION !== 'undefined') { + return new Function('specifier', 'return import(specifier)')( + target, + ) as Promise; + } + return import( + /* webpackIgnore: true */ + /* @vite-ignore */ + target + ) as Promise; + }; + + return () => + dynamicImport(specifier).then((module) => () => module as Module); +}; + function formatShare( shareArgs: ShareArgs, from: string, @@ -31,6 +55,12 @@ function formatShare( get = shareArgs.get; } else if ('lib' in shareArgs) { get = () => Promise.resolve(shareArgs.lib); + } else if ( + USE_IMPORTMAP && + 'import' in shareArgs && + typeof shareArgs.import === 'string' + ) { + get = createImportGetter(shareArgs.import); } else { get = () => Promise.resolve(() => { diff --git a/packages/sdk/src/types/plugins/ModuleFederationPlugin.ts b/packages/sdk/src/types/plugins/ModuleFederationPlugin.ts index 80da0aaa856..b12a6a8b327 100644 --- a/packages/sdk/src/types/plugins/ModuleFederationPlugin.ts +++ b/packages/sdk/src/types/plugins/ModuleFederationPlugin.ts @@ -263,6 +263,10 @@ export interface ModuleFederationPluginOptions { * Enable optimization to skip snapshot plugin */ disableSnapshot?: boolean; + /** + * Enable optimization to skip import map support in runtime-core + */ + disableImportMap?: boolean; /** * Target environment for the build */