diff --git a/README.md b/README.md index 4b6821f1a484..4ba19ec67c9d 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ Please refer to the [Modern.js Roadmap](https://github.com/web-infra-dev/modern. ## Examples Modern.js provides a collection of ready-to-use examples that you can find and use in the [modern-js-examples](https://github.com/web-infra-dev/modern-js-examples) repository. +For the in-repo first-class RSC + Module Federation plugin-first fixture contract (direct exposes map, no fixture runtime helpers), see [`tests/integration/rsc-mf/README.md`](./tests/integration/rsc-mf/README.md). ## Contributing diff --git a/RSC_MODULE_FEDERATION_OVERVIEW.md b/RSC_MODULE_FEDERATION_OVERVIEW.md new file mode 100644 index 000000000000..4e2bd5a8d9e5 --- /dev/null +++ b/RSC_MODULE_FEDERATION_OVERVIEW.md @@ -0,0 +1,310 @@ +# Module Federation + RSC Technical Specification (Current Branch) + +This document describes how Module Federation + React Server Components (RSC) +works in this branch based on actual implementation. + +Primary implementation paths: +- `packages/modernjs-v3/src/cli/index.ts` +- `packages/modernjs-v3/src/cli/configPlugin.ts` +- `packages/modernjs-v3/src/cli/ssrPlugin.ts` +- `packages/modernjs-v3/src/cli/mfRuntimePlugins/*` +- `packages/modernjs-v3/src/runtime/rsc-bridge-expose.ts` +- `packages/modernjs-v3/src/runtime/rsc-client-callback-bootstrap.js` +- `packages/runtime/render/src/client/callServer.ts` +- `packages/runtime/render/src/server/rsc/rsc.tsx` +- `packages/server/core/src/adapters/node/plugins/resource.ts` +- `packages/modernjs-v3/src/server/*` +- `tests/integration/rsc-mf/*` + +## 1) Runtime Components and Responsibilities + +The system has four cooperating parts: + +1. Config/build patching +- Implemented by `moduleFederationConfigPlugin` and `moduleFederationSSRPlugin`. +- Converts user MF config into CSR/SSR target-specific configs and runtime plugin stacks. + +2. Host runtime bridge +- Implemented by `rsc-bridge-runtime-plugin`. +- Loads the remote bridge expose, merges manifest metadata into host runtime manifest, + and creates proxy action modules. + +3. Remote runtime bridge +- Implemented by `rsc-bridge-expose`. +- Exposes `{ getManifest, executeAction }` so host can discover and execute remote actions. + +4. Client/server action transport +- Client: `callServer.ts` plus callback bootstrap resolver. +- Server: `rsc.tsx` action endpoint (`x-rsc-action` based dispatch). + +## 2) Build-Time Specification + +### 2.1 Plugin entry wiring + +`moduleFederationPlugin` in `packages/modernjs-v3/src/cli/index.ts`: +- Initializes shared mutable plugin options (`csrConfig`, `ssrConfig`, plugin refs). +- Registers browser MF plugin on web target. +- Registers server runtime plugin `@module-federation/modern-js-v3/server`. +- Chains: + - `moduleFederationConfigPlugin(...)` + - `moduleFederationSSRPlugin(...)` + +### 2.2 Config loading and cloning + +`getMFConfig` in `configPlugin.ts`: +- Source precedence: + 1. `userConfig.config` if provided. + 2. else `module-federation.config.ts` from project root. +- Uses `jiti` with ESM resolve enabled. + +`moduleFederationConfigPlugin`: +- Creates isolated CSR and SSR copies of MF config (`csrConfig`, `ssrConfig`). +- Applies target-specific patching per bundler chain target (`web` vs non-web). + +### 2.3 Required contracts and enforced invariants + +In `patchMFConfig` and `assertRscMfConfig`: +- `mfConfig.name` is required, otherwise throws. +- If `experiments.rsc === true`, then: + - `experiments.asyncStartup` must be `true`. + - server runtime plugin list must include `@module-federation/node/runtimePlugin`. +- Default `remoteType` becomes `'script'` when unset. + +### 2.4 Runtime plugin injection rules + +`patchMFConfig` injects: +- Always: + - `@module-federation/modern-js-v3/shared-strategy` +- SSR + dev only: + - `@module-federation/modern-js-v3/resolve-entry-ipv4` +- RSC enabled and remotes present: + - `rsc-bridge-runtime-plugin` (resolved local/esm/cjs path) +- Server target only: + - `@module-federation/node/runtimePlugin` + - `@module-federation/node/record-dynamic-remote-entry-hash-plugin` (dev only) + - `@module-federation/modern-js-v3/inject-node-fetch` + +### 2.5 RSC expose normalization + +`setRscExposeConfig` in `configPlugin.ts`: +- For each user expose (except internal bridge expose): + - Prepends `RSC_CLIENT_CALLBACK_BOOTSTRAP_MODULE` to expose import list. + - Sets `layer = 'react-server-components'`. +- Ensures internal expose exists: + - `./__rspack_rsc_bridge__ -> rsc-bridge-expose` module, same RSC layer. + +### 2.6 Dev server and source defaults + +`moduleFederationConfigPlugin` config output: +- Sets `REMOTE_IP_STRATEGY` define always. +- Sets `FEDERATION_IPV4` define in SSR dev mode. +- When exposes exist, adds wildcard CORS headers for dev server. +- Sets `source.enableAsyncEntry` to: + - explicit user value if provided + - else `!enableRsc` fallback + +## 3) Server-Side Loading and Static Asset Support + +### 3.1 Strategy-based bundle loading in server-core + +`packages/server/core/src/adapters/node/plugins/resource.ts`: +- Introduces strategy registry: + - `registerBundleLoaderStrategy` + - `getBundleLoaderStrategies` +- `loadBundle(filepath)` algorithm: + 1. `require` / `compatibleRequire` + 2. if immediate non-promise export: return + 3. if promise export: await once + 4. if unresolved/error: iterate registered strategies + 5. if still unresolved: log and return `undefined` + +### 3.2 Async-startup loader strategy + +`packages/modernjs-v3/src/server/asyncStartupLoader.ts`: +- Detects async-startup signature: + - `var __webpack_exports__ = __webpack_require__.x();` + - and `__webpack_require__.mfAsyncStartup` +- Rewrites call to: + - `__webpack_require__.x({}, [])` +- Executes patched bundle in VM context and returns exports. + +### 3.3 Server plugin registration + +`packages/modernjs-v3/src/server/index.ts`: +- Registers `mfAsyncStartupLoaderStrategy` globally into server-core loader registry. +- In production: + - adds static middleware serving only `/bundles/*.js|.json` +- Optional: + - adds wildcard CORS middleware when `MODERN_MF_AUTO_CORS` is set. + +## 4) RSC Bridge Protocol + +### 4.1 Internal bridge expose + +Remote exposes: +- Key: `./__rspack_rsc_bridge__` +- Interface: + - `getManifest(): ManifestLike` + - `executeAction(actionId: string, args: unknown[]): Promise` + +`ManifestLike` shape: +- `serverManifest?: Record` +- `clientManifest?: Record` +- `serverConsumerModuleMap?: Record` + +### 4.2 Host merge behavior + +In `rsc-bridge-runtime-plugin.ts`, `ensureRemoteAliasMerged(alias, args)`: +1. Ensure single in-flight merge per alias via `aliasMergePromises`. +2. Install proxy module ID: + - `__modernjs_mf_rsc_action_proxy__:` +3. Load remote bridge: + - `${alias}/__rspack_rsc_bridge__` +4. Read remote manifest and merge into host `__webpack_require__.rscM`. +5. Track alias as merged (`mergedRemoteAliases`). + +### 4.3 ID namespacing and mapping + +Module ID namespace: +- `remote-module::` + +Action ID namespace: +- `remote::` + +Why these are different namespaces: +- `remote-module:*` is used for module identity inside `clientManifest` and + `serverConsumerModuleMap` (module graph identity). +- `remote:*` is used for server action references in `serverManifest` and + request header `x-rsc-action` (action invocation identity). +- Keeping them distinct avoids conflating module resolution IDs with action + dispatch IDs, even when both originate from the same remote alias. + +Action remap map: +- global key: `__MODERN_RSC_MF_ACTION_ID_MAP__` +- semantics: + - `rawActionId -> remote::rawActionId` when unique + - `rawActionId -> false` when collision across remotes is detected + +Conflict policy: +- `assertNoConflict` throws if two remotes produce different payloads for same manifest key. + +## 5) Client Action Dispatch Specification + +### 5.1 Core call path + +`packages/runtime/render/src/client/callServer.ts`: +- `requestCallServer(id, args)`: + - resolves action ID through global resolver key `__MODERN_RSC_ACTION_RESOLVER__` + - endpoint: + - `'/'` for `main`/`index`/missing entry + - else `/${window.__MODERN_JS_ENTRY_NAME}` + - request: + - `method: POST` + - header `Accept: text/x-component` + - header `x-rsc-action: ` + - body `encodeReply(args)` + +Branch history note: +- This resolver indirection is new on this branch. +- On `main`, `callServer` sent the incoming action ID directly in + `x-rsc-action` with no global remap hook. + +### 5.2 Bootstrap resolver and callback install + +`rsc-client-callback-bootstrap.js`: +- Calls `setResolveActionId(resolveActionId)` once. +- Repeatedly installs `setServerCallback` into discovered client.browser runtimes. +- Hooks webpack chunk loader (`__webpack_require__.e`) to re-install callbacks after late chunk loads. + +Branch history note: +- `rsc-client-callback-bootstrap.js` and the chunk-loader hook are added on this + branch (not present on `main`). +- The hook is a runtime compatibility workaround to catch callbacks loaded after + initial bootstrap. A cleaner long-term option would be a first-class runtime + lifecycle event from federation/runtime instead of patching `__webpack_require__.e`. + +`resolveActionId(id)` behavior: +1. If already prefixed (`remote:`), return as-is. +2. If remap entry is string, return mapped prefixed ID. +3. If remap entry is `false`, return raw ID. +4. Else: + - attempts fallback alias resolution when a single remote alias can be inferred, + - otherwise waits briefly on remap waiters map. + +## 6) Server Action Execution Specification + +`packages/runtime/render/src/server/rsc/rsc.tsx`: +- Reads header `x-rsc-action`. +- Loads action via `loadServerAction(serverReference)`. +- Decodes args: + - `multipart/form-data` -> `decodeReply(formData)` + - else text -> `decodeReply(text)` +- Executes action (`await Promise.resolve(action.apply(null, args))`). +- Renders response stream via `renderToReadableStream`. + +Error behavior: +- Missing action header: `404`. +- Decode failure: `400`. +- Execution failure: `500`. +- In development, `500` includes error message/stack in body. + +## 7) SSR Remote Entry URL Normalization + +In `rsc-bridge-runtime-plugin.ts`: +- Snapshot patching normalizes: + - `ssrPublicPath` + - `remoteEntry.path` + - `ssrRemoteEntry` fallbacks from snapshot metadata +- If remote entry is broken (`undefined`, empty, malformed), plugin attempts to reconstruct from snapshot fields. +- Node-like runtime branch may prefix entry path with `bundles/` when `ssrRemoteEntry` is missing. + +Recommendation: +- Preferred steady-state is to require a complete snapshot contract (non-undefined + `ssrRemoteEntry`/public path fields) from upstream runtime metadata. +- Current reconstruction logic exists for compatibility with incomplete snapshots + seen in practice; it should be reduced/removed once snapshot completeness is guaranteed. + +## 8) Fixture Specification (`tests/integration/rsc-mf`) + +Host config (`tests/integration/rsc-mf/host`): +- `server.rsc = true` +- `source.enableAsyncEntry = false` +- `moduleFederationPlugin({ ssr: true })` +- remote manifest URL uses `RSC_MF_REMOTE_PORT` and points to `/static/mf-manifest.json` + +Remote config (`tests/integration/rsc-mf/remote`): +- `server.rsc = true` +- `server.ssr = true` +- `source.enableAsyncEntry = false` +- `output.assetPrefix = http://127.0.0.1:${remotePort}` +- MF exposes include server/client components + actions. +- MF experiments include: + - `asyncStartup: true` + - `rsc: true` + +Tested contracts: +- Remote server components render in host RSC route. +- Remote client components hydrate and function in host page. +- Remote actions execute via host endpoint and carry expected action headers. +- Expose list includes user exposes plus `./__rspack_rsc_bridge__`. + +## 9) Known Caveats (Current State) + +1. Fallback alias auto-prefixing +- In the bootstrap resolver, unresolved raw IDs may be auto-prefixed to a fallback alias when one remote is detectable. +- This can misroute host-local actions in mixed host+remote pages. + +2. Double `bundles/` risk +- SSR entry normalization can build `.../bundles/bundles/...` URLs for certain snapshot/publicPath combinations. + +3. Collision path with `false` remap values +- On raw action ID collisions across remotes, remap map stores `false`. +- Resolver then sends raw ID, while host manifest action keys are namespaced (`remote::...`), causing unresolved lookup without explicit disambiguation. + +## 10) Practical Integration Rules + +Use these rules to avoid known failure modes: +- Always set `experiments.asyncStartup = true` when using `experiments.rsc = true`. +- Keep unique action IDs across remotes when possible. +- Keep remote snapshot fields complete (`publicPath` and remote entry metadata) in SSR. +- For fixture-like setups, keep `source.enableAsyncEntry = false` for host and remote until async-entry semantics are explicitly validated for your target. diff --git a/package.json b/package.json index f075357b6f13..f8493dafb3b9 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,26 @@ "read-yaml-file>js-yaml": "3.14.2", "@remix-run/router": ">=1.23.2", "h3": ">=1.15.5", + "@rspack/core@2.0.0-beta.2": "npm:@rspack-canary/core@2.0.0-canary-032bd1ff-20260212021235", + "@module-federation/bridge-react": "https://pkg.pr.new/module-federation/core/@module-federation/bridge-react@c160c7ae3246c65fa50f19d966b8acd32448580a", + "@module-federation/bridge-react-webpack-plugin": "https://pkg.pr.new/module-federation/core/@module-federation/bridge-react-webpack-plugin@c160c7ae3246c65fa50f19d966b8acd32448580a", + "@module-federation/bridge-shared": "https://pkg.pr.new/module-federation/core/@module-federation/bridge-shared@c160c7ae3246c65fa50f19d966b8acd32448580a", + "@module-federation/cli": "https://pkg.pr.new/module-federation/core/@module-federation/cli@c160c7ae3246c65fa50f19d966b8acd32448580a", + "@module-federation/data-prefetch": "https://pkg.pr.new/module-federation/core/@module-federation/data-prefetch@c160c7ae3246c65fa50f19d966b8acd32448580a", + "@module-federation/dts-plugin": "https://pkg.pr.new/module-federation/core/@module-federation/dts-plugin@c160c7ae3246c65fa50f19d966b8acd32448580a", + "@module-federation/enhanced": "https://pkg.pr.new/module-federation/core/@module-federation/enhanced@c160c7ae3246c65fa50f19d966b8acd32448580a", + "@module-federation/error-codes": "https://pkg.pr.new/module-federation/core/@module-federation/error-codes@c160c7ae3246c65fa50f19d966b8acd32448580a", + "@module-federation/inject-external-runtime-core-plugin": "https://pkg.pr.new/module-federation/core/@module-federation/inject-external-runtime-core-plugin@c160c7ae3246c65fa50f19d966b8acd32448580a", + "@module-federation/managers": "https://pkg.pr.new/module-federation/core/@module-federation/managers@c160c7ae3246c65fa50f19d966b8acd32448580a", + "@module-federation/manifest": "https://pkg.pr.new/module-federation/core/@module-federation/manifest@c160c7ae3246c65fa50f19d966b8acd32448580a", + "@module-federation/rsbuild-plugin": "https://pkg.pr.new/module-federation/core/@module-federation/rsbuild-plugin@c160c7ae3246c65fa50f19d966b8acd32448580a", + "@module-federation/rspack": "https://pkg.pr.new/module-federation/core/@module-federation/rspack@c160c7ae3246c65fa50f19d966b8acd32448580a", + "@module-federation/runtime": "https://pkg.pr.new/module-federation/core/@module-federation/runtime@c160c7ae3246c65fa50f19d966b8acd32448580a", + "@module-federation/runtime-core": "https://pkg.pr.new/module-federation/core/@module-federation/runtime-core@c160c7ae3246c65fa50f19d966b8acd32448580a", + "@module-federation/runtime-tools": "https://pkg.pr.new/module-federation/core/@module-federation/runtime-tools@c160c7ae3246c65fa50f19d966b8acd32448580a", + "@module-federation/sdk": "https://pkg.pr.new/module-federation/core/@module-federation/sdk@c160c7ae3246c65fa50f19d966b8acd32448580a", + "@module-federation/third-party-dts-extractor": "https://pkg.pr.new/module-federation/core/@module-federation/third-party-dts-extractor@c160c7ae3246c65fa50f19d966b8acd32448580a", + "@module-federation/webpack-bundler-runtime": "https://pkg.pr.new/module-federation/core/@module-federation/webpack-bundler-runtime@c160c7ae3246c65fa50f19d966b8acd32448580a", "tar": ">=7.5.4", "diff": ">=4.0.4", "debug": ">=4.3.1", diff --git a/packages/cli/builder/tests/__snapshots__/default.test.ts.snap b/packages/cli/builder/tests/__snapshots__/default.test.ts.snap index 4994108a3df7..78bac79bf7ef 100644 --- a/packages/cli/builder/tests/__snapshots__/default.test.ts.snap +++ b/packages/cli/builder/tests/__snapshots__/default.test.ts.snap @@ -124,7 +124,7 @@ exports[`builder rspack > should generator rspack config correctly 1`] = ` "sideEffects": true, "use": [ { - "loader": "/node_modules//@rspack/core/dist/cssExtractLoader.js", + "loader": "/node_modules//@rspack-canary/core/dist/cssExtractLoader.js", }, { "loader": "/node_modules//@rsbuild/core/compiled/css-loader/index.js", @@ -541,7 +541,7 @@ exports[`builder rspack > should generator rspack config correctly 1`] = ` "sideEffects": true, "use": [ { - "loader": "/node_modules//@rspack/core/dist/cssExtractLoader.js", + "loader": "/node_modules//@rspack-canary/core/dist/cssExtractLoader.js", }, { "loader": "/node_modules//@rsbuild/core/compiled/css-loader/index.js", @@ -729,7 +729,7 @@ exports[`builder rspack > should generator rspack config correctly 1`] = ` "sideEffects": true, "use": [ { - "loader": "/node_modules//@rspack/core/dist/cssExtractLoader.js", + "loader": "/node_modules//@rspack-canary/core/dist/cssExtractLoader.js", }, { "loader": "/node_modules//@rsbuild/core/compiled/css-loader/index.js", @@ -2158,7 +2158,7 @@ exports[`builder rspack > should generator rspack config correctly when prod 1`] "sideEffects": true, "use": [ { - "loader": "/node_modules//@rspack/core/dist/cssExtractLoader.js", + "loader": "/node_modules//@rspack-canary/core/dist/cssExtractLoader.js", }, { "loader": "/node_modules//@rsbuild/core/compiled/css-loader/index.js", @@ -2574,7 +2574,7 @@ exports[`builder rspack > should generator rspack config correctly when prod 1`] "sideEffects": true, "use": [ { - "loader": "/node_modules//@rspack/core/dist/cssExtractLoader.js", + "loader": "/node_modules//@rspack-canary/core/dist/cssExtractLoader.js", }, { "loader": "/node_modules//@rsbuild/core/compiled/css-loader/index.js", @@ -2762,7 +2762,7 @@ exports[`builder rspack > should generator rspack config correctly when prod 1`] "sideEffects": true, "use": [ { - "loader": "/node_modules//@rspack/core/dist/cssExtractLoader.js", + "loader": "/node_modules//@rspack-canary/core/dist/cssExtractLoader.js", }, { "loader": "/node_modules//@rsbuild/core/compiled/css-loader/index.js", diff --git a/packages/cli/builder/tests/__snapshots__/environment.test.ts.snap b/packages/cli/builder/tests/__snapshots__/environment.test.ts.snap index cc0da04b68eb..1275e86de713 100644 --- a/packages/cli/builder/tests/__snapshots__/environment.test.ts.snap +++ b/packages/cli/builder/tests/__snapshots__/environment.test.ts.snap @@ -125,7 +125,7 @@ exports[`builder environment compat > should generator environment config correc "sideEffects": true, "use": [ { - "loader": "/node_modules//@rspack/core/dist/cssExtractLoader.js", + "loader": "/node_modules//@rspack-canary/core/dist/cssExtractLoader.js", }, { "loader": "/node_modules//@rsbuild/core/compiled/css-loader/index.js", @@ -542,7 +542,7 @@ exports[`builder environment compat > should generator environment config correc "sideEffects": true, "use": [ { - "loader": "/node_modules//@rspack/core/dist/cssExtractLoader.js", + "loader": "/node_modules//@rspack-canary/core/dist/cssExtractLoader.js", }, { "loader": "/node_modules//@rsbuild/core/compiled/css-loader/index.js", @@ -730,7 +730,7 @@ exports[`builder environment compat > should generator environment config correc "sideEffects": true, "use": [ { - "loader": "/node_modules//@rspack/core/dist/cssExtractLoader.js", + "loader": "/node_modules//@rspack-canary/core/dist/cssExtractLoader.js", }, { "loader": "/node_modules//@rsbuild/core/compiled/css-loader/index.js", diff --git a/packages/cli/builder/tests/__snapshots__/postcssLegacy.test.ts.snap b/packages/cli/builder/tests/__snapshots__/postcssLegacy.test.ts.snap index d1db21d4ac91..c1de69c660c7 100644 --- a/packages/cli/builder/tests/__snapshots__/postcssLegacy.test.ts.snap +++ b/packages/cli/builder/tests/__snapshots__/postcssLegacy.test.ts.snap @@ -48,7 +48,7 @@ exports[`lightningcss-loader > should register lightningcss-loader and disable p "sideEffects": true, "use": [ { - "loader": "/node_modules//@rspack/core/dist/cssExtractLoader.js", + "loader": "/node_modules//@rspack-canary/core/dist/cssExtractLoader.js", }, { "loader": "/node_modules//@rsbuild/core/compiled/css-loader/index.js", @@ -148,7 +148,7 @@ exports[`lightningcss-loader > should register lightningcss-loader and postcss-l "sideEffects": true, "use": [ { - "loader": "/node_modules//@rspack/core/dist/cssExtractLoader.js", + "loader": "/node_modules//@rspack-canary/core/dist/cssExtractLoader.js", }, { "loader": "/node_modules//@rsbuild/core/compiled/css-loader/index.js", @@ -252,7 +252,7 @@ exports[`plugin-postcssLegacy > should allow tools.postcss to override the plugi "sideEffects": true, "use": [ { - "loader": "/node_modules//@rspack/core/dist/cssExtractLoader.js", + "loader": "/node_modules//@rspack-canary/core/dist/cssExtractLoader.js", }, { "loader": "/node_modules//@rsbuild/core/compiled/css-loader/index.js", @@ -359,7 +359,7 @@ exports[`plugin-postcssLegacy > should allow tools.postcss to override the plugi "sideEffects": true, "use": [ { - "loader": "/node_modules//@rspack/core/dist/cssExtractLoader.js", + "loader": "/node_modules//@rspack-canary/core/dist/cssExtractLoader.js", }, { "loader": "/node_modules//@rsbuild/core/compiled/css-loader/index.js", @@ -482,7 +482,7 @@ exports[`plugin-postcssLegacy > should allow tools.postcss to override the plugi "sideEffects": true, "use": [ { - "loader": "/node_modules//@rspack/core/dist/cssExtractLoader.js", + "loader": "/node_modules//@rspack-canary/core/dist/cssExtractLoader.js", }, { "loader": "/node_modules//@rsbuild/core/compiled/css-loader/index.js", @@ -615,7 +615,7 @@ exports[`plugin-postcssLegacy > should register postcss plugin by browserslist 1 "sideEffects": true, "use": [ { - "loader": "/node_modules//@rspack/core/dist/cssExtractLoader.js", + "loader": "/node_modules//@rspack-canary/core/dist/cssExtractLoader.js", }, { "loader": "/node_modules//@rsbuild/core/compiled/css-loader/index.js", @@ -772,7 +772,7 @@ exports[`plugin-postcssLegacy > should register postcss plugin by browserslist 2 "sideEffects": true, "use": [ { - "loader": "/node_modules//@rspack/core/dist/cssExtractLoader.js", + "loader": "/node_modules//@rspack-canary/core/dist/cssExtractLoader.js", }, { "loader": "/node_modules//@rsbuild/core/compiled/css-loader/index.js", @@ -945,7 +945,7 @@ exports[`plugin-postcssLegacy > should register postcss plugin by browserslist 3 "sideEffects": true, "use": [ { - "loader": "/node_modules//@rspack/core/dist/cssExtractLoader.js", + "loader": "/node_modules//@rspack-canary/core/dist/cssExtractLoader.js", }, { "loader": "/node_modules//@rsbuild/core/compiled/css-loader/index.js", diff --git a/packages/modernjs-v3/.eslintrc.json b/packages/modernjs-v3/.eslintrc.json new file mode 100644 index 000000000000..5c3013868ca3 --- /dev/null +++ b/packages/modernjs-v3/.eslintrc.json @@ -0,0 +1,41 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": [ + "!**/*", + "**/*.d.ts", + "**/vite.config.*.timestamp*", + "**/vitest.config.*.timestamp*" + ], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/ban-ts-comment": "warn", + "@typescript-eslint/no-var-requires": 0, + "@typescript-eslint/no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "webpack", + "message": "Please use require(normalizeWebpackPath('webpack')) instead.", + "allowTypeImports": true + } + ], + "patterns": [ + { + "group": ["webpack/lib/*"], + "message": "Please use require(normalizeWebpackPath('webpack')) instead.", + "allowTypeImports": true + } + ] + } + ] + } + }, + { + "files": ["*.js", "*.jsx"] + } + ] +} diff --git a/packages/modernjs-v3/CHANGELOG.md b/packages/modernjs-v3/CHANGELOG.md new file mode 100644 index 000000000000..b86780048843 --- /dev/null +++ b/packages/modernjs-v3/CHANGELOG.md @@ -0,0 +1,1081 @@ +# @module-federation/modern-js-v3 + +## 2.0.1 + +### Patch Changes + +- @module-federation/cli@2.0.1 +- @module-federation/enhanced@2.0.1 +- @module-federation/bridge-react@2.0.1 +- @module-federation/runtime@2.0.1 +- @module-federation/node@2.7.32 +- @module-federation/rsbuild-plugin@2.0.1 +- @module-federation/sdk@2.0.1 + +## 2.0.0 + +### Major Changes + +- 2bd445d: feat: upgrade to modern js 3.0 + +### Patch Changes + +- Updated dependencies [2bd445d] + - @module-federation/rsbuild-plugin@2.0.0 + - @module-federation/enhanced@2.0.0 + - @module-federation/node@2.7.31 + - @module-federation/runtime@2.0.0 + - @module-federation/sdk@2.0.0 + - @module-federation/bridge-react@2.0.0 + - @module-federation/cli@2.0.0 + +## 0.24.1 + +### Patch Changes + +- @module-federation/bridge-react@0.24.1 +- @module-federation/runtime@0.24.1 +- @module-federation/node@2.7.30 +- @module-federation/enhanced@0.24.1 +- @module-federation/rsbuild-plugin@0.24.1 +- @module-federation/sdk@0.24.1 +- @module-federation/cli@0.24.1 + +## 0.24.0 + +### Minor Changes + +- f46cdd6: fix(modernjs)!: ship .mjs files for correct ESM output + +### Patch Changes + +- Updated dependencies [c3c068e] + - @module-federation/enhanced@0.24.0 + - @module-federation/node@2.7.29 + - @module-federation/rsbuild-plugin@0.24.0 + - @module-federation/runtime@0.24.0 + - @module-federation/sdk@0.24.0 + - @module-federation/bridge-react@0.24.0 + - @module-federation/cli@0.24.0 + +## 0.23.0 + +### Patch Changes + +- fdce013: fix(modern-js-plugin): use jiti to load config +- Updated dependencies [3aa3cf9] + - @module-federation/rsbuild-plugin@0.23.0 + - @module-federation/cli@0.23.0 + - @module-federation/enhanced@0.23.0 + - @module-federation/bridge-react@0.23.0 + - @module-federation/runtime@0.23.0 + - @module-federation/node@2.7.28 + - @module-federation/sdk@0.23.0 + +## 0.22.1 + +### Patch Changes + +- @module-federation/runtime@0.22.1 +- @module-federation/enhanced@0.22.1 +- @module-federation/sdk@0.22.1 +- @module-federation/bridge-react@0.22.1 +- @module-federation/rsbuild-plugin@0.22.1 +- @module-federation/cli@0.22.1 +- @module-federation/node@2.7.27 + +## 0.22.0 + +### Minor Changes + +- 90c6a40: fix(modernjs)!: ship .mjs files for correct ESM output + +### Patch Changes + +- 6e27880: fix(modern-js-plugin): handle distOutputDir for SSR and web targets differently + - @module-federation/runtime@0.22.0 + - @module-federation/enhanced@0.22.0 + - @module-federation/sdk@0.22.0 + - @module-federation/bridge-react@0.22.0 + - @module-federation/rsbuild-plugin@0.22.0 + - @module-federation/cli@0.22.0 + - @module-federation/node@2.7.26 + +## 0.21.6 + +### Patch Changes + +- Updated dependencies [b307ae0] + - @module-federation/rsbuild-plugin@0.21.6 + - @module-federation/runtime@0.21.6 + - @module-federation/enhanced@0.21.6 + - @module-federation/sdk@0.21.6 + - @module-federation/bridge-react@0.21.6 + - @module-federation/cli@0.21.6 + - @module-federation/node@2.7.25 + +## 0.21.5 + +### Patch Changes + +- Updated dependencies [35c3695] +- Updated dependencies [94d8868] + - @module-federation/cli@0.21.5 + - @module-federation/sdk@0.21.5 + - @module-federation/enhanced@0.21.5 + - @module-federation/bridge-react@0.21.5 + - @module-federation/node@2.7.24 + - @module-federation/rsbuild-plugin@0.21.5 + - @module-federation/runtime@0.21.5 + +## 0.21.4 + +### Patch Changes + +- a50e068: refactor(manifest): collect assets from build hook +- Updated dependencies [444db72] +- Updated dependencies [a50e068] + - @module-federation/bridge-react@0.21.4 + - @module-federation/rsbuild-plugin@0.21.4 + - @module-federation/enhanced@0.21.4 + - @module-federation/sdk@0.21.4 + - @module-federation/cli@0.21.4 + - @module-federation/node@2.7.23 + - @module-federation/runtime@0.21.4 + +## 0.21.3 + +### Patch Changes + +- @module-federation/bridge-react@0.21.3 +- @module-federation/runtime@0.21.3 +- @module-federation/node@2.7.22 +- @module-federation/enhanced@0.21.3 +- @module-federation/rsbuild-plugin@0.21.3 +- @module-federation/sdk@0.21.3 +- @module-federation/cli@0.21.3 + +## 0.21.2 + +### Patch Changes + +- e98133e: feat: Re-export the exports of the v18/v19/plugin from @module-federation/bridge-react in modernjs +- Updated dependencies [e98133e] +- Updated dependencies [dc103ee] + - @module-federation/bridge-react@0.21.2 + - @module-federation/cli@0.21.2 + - @module-federation/enhanced@0.21.2 + - @module-federation/node@2.7.21 + - @module-federation/rsbuild-plugin@0.21.2 + - @module-federation/runtime@0.21.2 + - @module-federation/sdk@0.21.2 + +## 0.21.1 + +### Patch Changes + +- 32600de: fix(modern-js-plugin): add bridge peer deps + - @module-federation/runtime@0.21.1 + - @module-federation/enhanced@0.21.1 + - @module-federation/sdk@0.21.1 + - @module-federation/bridge-react@0.21.1 + - @module-federation/rsbuild-plugin@0.21.1 + - @module-federation/cli@0.21.1 + - @module-federation/node@2.7.20 + +## 0.21.0 + +### Patch Changes + +- aa7daae: feat: delete set disableAlias true in @module-federation/modern-js-v3 +- Updated dependencies [d1e90a4] +- Updated dependencies [d225658] + - @module-federation/sdk@0.21.0 + - @module-federation/bridge-react@0.21.0 + - @module-federation/cli@0.21.0 + - @module-federation/enhanced@0.21.0 + - @module-federation/node@2.7.19 + - @module-federation/rsbuild-plugin@0.21.0 + - @module-federation/runtime@0.21.0 + +## 0.20.0 + +### Patch Changes + +- Updated dependencies [dcc290e] +- Updated dependencies [0008621] +- Updated dependencies [2eea0d0] +- Updated dependencies [b7872a1] +- Updated dependencies [25df940] +- Updated dependencies [22b9ff9] +- Updated dependencies [8a80605] +- Updated dependencies [e89e972] +- Updated dependencies [c66c21e] +- Updated dependencies [37346d4] +- Updated dependencies [8038f61] +- Updated dependencies [639a83b] + - @module-federation/enhanced@0.20.0 + - @module-federation/bridge-react@0.20.0 + - @module-federation/rsbuild-plugin@0.20.0 + - @module-federation/node@2.7.18 + - @module-federation/runtime@0.20.0 + - @module-federation/sdk@0.20.0 + - @module-federation/cli@0.20.0 + +## 0.19.1 + +### Patch Changes + +- Updated dependencies + - @module-federation/sdk@0.19.1 + - @module-federation/bridge-react@0.19.1 + - @module-federation/cli@0.19.1 + - @module-federation/enhanced@0.19.1 + - @module-federation/node@2.7.17 + - @module-federation/rsbuild-plugin@0.19.1 + - @module-federation/runtime@0.19.1 + +## 0.19.0 + +### Patch Changes + +- @module-federation/runtime@0.19.0 +- @module-federation/enhanced@0.19.0 +- @module-federation/sdk@0.19.0 +- @module-federation/bridge-react@0.19.0 +- @module-federation/rsbuild-plugin@0.19.0 +- @module-federation/cli@0.19.0 +- @module-federation/node@2.7.16 + +## 0.18.4 + +### Patch Changes + +- Updated dependencies [8061f8c] + - @module-federation/rsbuild-plugin@0.18.4 + - @module-federation/runtime@0.18.4 + - @module-federation/cli@0.18.4 + - @module-federation/sdk@0.18.4 + - @module-federation/bridge-react@0.18.4 + - @module-federation/enhanced@0.18.4 + - @module-federation/node@2.7.15 + +## 0.18.3 + +### Patch Changes + +- a892d74: feat: support env vars to add cors when use serve command + - @module-federation/runtime@0.18.3 + - @module-federation/enhanced@0.18.3 + - @module-federation/sdk@0.18.3 + - @module-federation/bridge-react@0.18.3 + - @module-federation/rsbuild-plugin@0.18.3 + - @module-federation/cli@0.18.3 + - @module-federation/node@2.7.14 + +## 0.18.2 + +### Patch Changes + +- Updated dependencies [756750e] +- Updated dependencies [756750e] +- Updated dependencies [991f57c] +- Updated dependencies [756750e] +- Updated dependencies [e110593] + - @module-federation/enhanced@0.18.2 + - @module-federation/rsbuild-plugin@0.18.2 + - @module-federation/node@2.7.13 + - @module-federation/bridge-react@0.18.2 + - @module-federation/runtime@0.18.2 + - @module-federation/cli@0.18.2 + - @module-federation/sdk@0.18.2 + +## 0.18.1 + +### Patch Changes + +- fix(modern-js-plugin): set bridge.disableAlias true when installing @module-federation/bridge-react +- 41ee332: chore(modern-js-plugin): re-export all bridge react +- Updated dependencies [8004e95] +- Updated dependencies [0bf3a3a] +- Updated dependencies [0bf3a3a] +- Updated dependencies [0bf3a3a] +- Updated dependencies [0bf3a3a] +- Updated dependencies [765b448] +- Updated dependencies [7dbc25d] + - @module-federation/bridge-react@0.18.1 + - @module-federation/enhanced@0.18.1 + - @module-federation/node@2.7.12 + - @module-federation/sdk@0.18.1 + - @module-federation/rsbuild-plugin@0.18.1 + - @module-federation/runtime@0.18.1 + - @module-federation/cli@0.18.1 + +## 0.18.0 + +### Patch Changes + +- Updated dependencies [609d477] +- Updated dependencies [0ab51b8] +- Updated dependencies [98a29c3] +- Updated dependencies [f6381e6] +- Updated dependencies [38b8d24] + - @module-federation/runtime@0.18.0 + - @module-federation/enhanced@0.18.0 + - @module-federation/sdk@0.18.0 + - @module-federation/rsbuild-plugin@0.18.0 + - @module-federation/bridge-react@0.18.0 + - @module-federation/node@2.7.11 + - @module-federation/cli@0.18.0 + +## 0.17.1 + +### Patch Changes + +- a7cf276: chore: upgrade NX to 21.2.3, Storybook to 9.0.9, and TypeScript to 5.8.3 + + - Upgraded NX from 21.0.3 to 21.2.3 with workspace configuration updates + - Migrated Storybook from 8.3.5 to 9.0.9 with updated configurations and automigrations + - Upgraded TypeScript from 5.7.3 to 5.8.3 with compatibility fixes + - Fixed package exports and type declaration paths across all packages + - Resolved module resolution issues and TypeScript compatibility problems + - Updated build configurations and dependencies to support latest versions + +- d31a326: refactor: sink React packages from root to individual packages + + - Removed React dependencies from root package.json and moved them to packages that actually need them + - Fixed rsbuild-plugin configuration to match workspace patterns + - Updated tests to handle platform-specific files + - This change improves dependency management by ensuring packages only have the dependencies they actually use + +- Updated dependencies [bc3bc10] +- Updated dependencies [7000c1f] +- Updated dependencies [bb953a6] +- Updated dependencies [2428be0] +- Updated dependencies [4ffefbe] +- Updated dependencies [65aa038] +- Updated dependencies [a7cf276] +- Updated dependencies [d31a326] +- Updated dependencies [1825b9d] +- Updated dependencies [8727aa3] + - @module-federation/enhanced@0.17.1 + - @module-federation/rsbuild-plugin@0.17.1 + - @module-federation/runtime@0.17.1 + - @module-federation/cli@0.17.1 + - @module-federation/bridge-react@0.17.1 + - @module-federation/sdk@0.17.1 + - @module-federation/node@2.7.10 + +## 0.17.0 + +### Minor Changes + +- e874c64: refactor(modern-js-plugin): deprecate createRemoteComponent and createRemoteSSRComponent + +### Patch Changes + +- e874c64: refactor(modern-js-plugin): add subpath react to export createLazyCompoent and wrapNoSSR apis +- f9985a8: chore(modern-js-plugin): update source.alias to resolve.alias +- 3f736b6: chore: rename FederationHost to ModuleFederation +- e0ceca6: bump modern.js to fix esbuild vulnerability +- Updated dependencies [e874c64] +- Updated dependencies [3f736b6] +- Updated dependencies [3f736b6] +- Updated dependencies [3f736b6] +- Updated dependencies [e874c64] +- Updated dependencies [3f736b6] +- Updated dependencies [e0ceca6] + - @module-federation/bridge-react@0.17.0 + - @module-federation/runtime@0.17.0 + - @module-federation/node@2.7.9 + - @module-federation/cli@0.17.0 + - @module-federation/enhanced@0.17.0 + - @module-federation/rsbuild-plugin@0.17.0 + - @module-federation/sdk@0.17.0 + +## 0.16.0 + +### Patch Changes + +- 98136ca: fix(modern-js-plugin): use contenthash instead of chunkhash +- de350f3: fix(modern-js-plugin): adjust fetch type +- Updated dependencies [1485fcf] +- Updated dependencies [98136ca] +- Updated dependencies [98136ca] + - @module-federation/sdk@0.16.0 + - @module-federation/node@2.7.8 + - @module-federation/rsbuild-plugin@0.16.0 + - @module-federation/cli@0.16.0 + - @module-federation/enhanced@0.16.0 + - @module-federation/runtime@0.16.0 + +## 0.15.0 + +### Minor Changes + +- f432619: feat(modern-js-plugin): support component-level data fetch + +### Patch Changes + +- c343589: fix(modern-js-plugin): only inject ipv4 str in dev mode +- 2faa3a3: chore(modernjs-js-plugin): keep the version of swc/helpers consistent with rsbuild +- Updated dependencies [ad446af] +- Updated dependencies [f777710] + - @module-federation/enhanced@0.15.0 + - @module-federation/rsbuild-plugin@0.15.0 + - @module-federation/cli@0.15.0 + - @module-federation/node@2.7.7 + - @module-federation/runtime@0.15.0 + - @module-federation/sdk@0.15.0 + +## 0.14.3 + +### Patch Changes + +- fix: empty dist + - @module-federation/enhanced@0.14.3 + - @module-federation/sdk@0.14.3 + - @module-federation/rsbuild-plugin@0.14.3 + - @module-federation/cli@0.14.3 + - @module-federation/node@2.7.6 + +## 0.14.2 + +### Patch Changes + +- e6ac307: fix(modern-js-plugin): downgrade lru-cache + - @module-federation/enhanced@0.14.2 + - @module-federation/sdk@0.14.2 + - @module-federation/rsbuild-plugin@0.14.2 + - @module-federation/cli@0.14.2 + - @module-federation/node@2.7.5 + +## 0.14.1 + +### Patch Changes + +- 0c68c2f: feat(modern-js-plugin): add server plugin to handle remote's SSR assets +- Updated dependencies [0c68c2f] + - @module-federation/cli@0.14.1 + - @module-federation/enhanced@0.14.1 + - @module-federation/node@2.7.4 + - @module-federation/rsbuild-plugin@0.14.1 + - @module-federation/sdk@0.14.1 + +## 0.14.0 + +### Patch Changes + +- Updated dependencies [82b8cac] +- Updated dependencies [82b8cac] +- Updated dependencies [26f8a77] +- Updated dependencies [d237ab9] +- Updated dependencies [0eb6697] + - @module-federation/enhanced@0.14.0 + - @module-federation/sdk@0.14.0 + - @module-federation/rsbuild-plugin@0.14.0 + - @module-federation/node@2.7.3 + - @module-federation/cli@0.14.0 + +## 0.13.1 + +### Patch Changes + +- b99d57c: fix(modern-js-plugin): export kit namespace to prevent import react directly + - @module-federation/enhanced@0.13.1 + - @module-federation/cli@0.13.1 + - @module-federation/node@2.7.2 + - @module-federation/rsbuild-plugin@0.13.1 + - @module-federation/sdk@0.13.1 + +## 0.13.0 + +### Patch Changes + +- 38f324f: Disable live bindings on cjs builds of the runtime packages +- Updated dependencies [e9a0681] +- Updated dependencies [9efb9b9] +- Updated dependencies [122f1b3] +- Updated dependencies [38f324f] + - @module-federation/cli@0.13.0 + - @module-federation/enhanced@0.13.0 + - @module-federation/node@2.7.1 + - @module-federation/rsbuild-plugin@0.13.0 + - @module-federation/sdk@0.13.0 + +## 0.12.0 + +### Patch Changes + +- Updated dependencies [f4fb242] +- Updated dependencies [f4fb242] +- Updated dependencies [f4fb242] +- Updated dependencies [c399b9a] +- Updated dependencies [ef96c4d] +- Updated dependencies [f4fb242] +- Updated dependencies [f4fb242] + - @module-federation/enhanced@0.12.0 + - @module-federation/node@2.7.0 + - @module-federation/sdk@0.12.0 + - @module-federation/rsbuild-plugin@0.12.0 + - @module-federation/cli@0.12.0 + +## 0.11.4 + +### Patch Changes + +- 64a2bc1: fix(modern-js-plugin): correct publicpath in build +- 292f2fd: chore(modern-js-plugin): warn if header origin is not specified +- 21c2fb9: fix(modern-js-plugin): apply ssr.distOutputDir in bundlerChain +- Updated dependencies [64a2bc1] +- Updated dependencies [ed8bda3] +- Updated dependencies [ebe7d89] +- Updated dependencies [c14842f] + - @module-federation/sdk@0.11.4 + - @module-federation/node@2.6.33 + - @module-federation/enhanced@0.11.4 + - @module-federation/cli@0.11.4 + - @module-federation/rsbuild-plugin@0.11.4 + +## 0.11.3 + +### Patch Changes + +- Updated dependencies [e5fae18] + - @module-federation/node@2.6.32 + - @module-federation/cli@0.11.3 + - @module-federation/enhanced@0.11.3 + - @module-federation/rsbuild-plugin@0.11.3 + - @module-federation/sdk@0.11.3 + +## 0.11.2 + +### Patch Changes + +- Updated dependencies [60d1fc1] +- Updated dependencies [047857b] + - @module-federation/rsbuild-plugin@0.11.2 + - @module-federation/sdk@0.11.2 + - @module-federation/cli@0.11.2 + - @module-federation/enhanced@0.11.2 + - @module-federation/node@2.6.31 + +## 0.11.1 + +### Patch Changes + +- Updated dependencies [09d6bc1] + - @module-federation/enhanced@0.11.1 + - @module-federation/node@2.6.30 + - @module-federation/rsbuild-plugin@0.11.1 + - @module-federation/sdk@0.11.1 + +## 0.11.0 + +### Patch Changes + +- Updated dependencies [fce107e] +- Updated dependencies [fce107e] +- Updated dependencies [5c4175e] +- Updated dependencies [f302eeb] + - @module-federation/enhanced@0.11.0 + - @module-federation/sdk@0.11.0 + - @module-federation/node@2.6.29 + - @module-federation/rsbuild-plugin@0.11.0 + +## 0.10.0 + +### Patch Changes + +- 1010f96: chore(modern-js-plugin): use bundlerChain instead of tools.webpack or tools.rspack +- Updated dependencies [0f71cbc] +- Updated dependencies [5b391b5] +- Updated dependencies [1010f96] +- Updated dependencies [22fcccd] +- Updated dependencies [3c8bd83] + - @module-federation/sdk@0.10.0 + - @module-federation/rsbuild-plugin@0.10.0 + - @module-federation/enhanced@0.10.0 + - @module-federation/node@2.6.28 + +## 0.9.1 + +### Patch Changes + +- Updated dependencies [35d925b] +- Updated dependencies [35d925b] +- Updated dependencies [8acd217] + - @module-federation/sdk@0.9.1 + - @module-federation/enhanced@0.9.1 + - @module-federation/node@2.6.27 + - @module-federation/rsbuild-plugin@0.9.1 + +## 0.9.0 + +### Patch Changes + +- @module-federation/enhanced@0.9.0 +- @module-federation/node@2.6.26 +- @module-federation/rsbuild-plugin@0.9.0 +- @module-federation/sdk@0.9.0 + +## 0.8.12 + +### Patch Changes + +- e602d82: fix: enable SSR by utilizing pluginOptions and configuration adjustments for improved accuracy +- Updated dependencies [9062cee] + - @module-federation/enhanced@0.8.12 + - @module-federation/node@2.6.25 + - @module-federation/rsbuild-plugin@0.8.12 + - @module-federation/sdk@0.8.12 + +## 0.8.11 + +### Patch Changes + +- @module-federation/enhanced@0.8.11 +- @module-federation/sdk@0.8.11 +- @module-federation/rsbuild-plugin@0.8.11 +- @module-federation/node@2.6.24 + +## 0.8.10 + +### Patch Changes + +- 21cc62c: chore: use new modern.js plugin for improved functionality + - @module-federation/node@2.6.23 + - @module-federation/enhanced@0.8.10 + - @module-federation/rsbuild-plugin@0.8.10 + - @module-federation/sdk@0.8.10 + +## 0.8.9 + +### Patch Changes + +- Updated dependencies [6e3afc6] + - @module-federation/enhanced@0.8.9 + - @module-federation/node@2.6.22 + - @module-federation/rsbuild-plugin@0.8.9 + - @module-federation/sdk@0.8.9 + +## 0.8.8 + +### Patch Changes + +- Updated dependencies [eda5184] + - @module-federation/enhanced@0.8.8 + - @module-federation/node@2.6.21 + - @module-federation/rsbuild-plugin@0.8.8 + - @module-federation/sdk@0.8.8 + +## 0.8.7 + +### Patch Changes + +- 5f67582: chore(modern-js-plugin): add ssr option +- Updated dependencies [835b09c] +- Updated dependencies [f573ad0] +- Updated dependencies [336f3d8] +- Updated dependencies [4fd33fb] + - @module-federation/sdk@0.8.7 + - @module-federation/enhanced@0.8.7 + - @module-federation/node@2.6.20 + - @module-federation/rsbuild-plugin@0.8.7 + +## 0.8.6 + +### Patch Changes + +- Updated dependencies [a1d46b7] + - @module-federation/rsbuild-plugin@0.8.6 + - @module-federation/enhanced@0.8.6 + - @module-federation/node@2.6.19 + - @module-federation/sdk@0.8.6 + +## 0.8.5 + +### Patch Changes + +- @module-federation/enhanced@0.8.5 +- @module-federation/sdk@0.8.5 +- @module-federation/node@2.6.18 + +## 0.8.4 + +### Patch Changes + +- @module-federation/enhanced@0.8.4 +- @module-federation/node@2.6.17 +- @module-federation/sdk@0.8.4 + +## 0.8.3 + +### Patch Changes + +- Updated dependencies [8e172c8] + - @module-federation/sdk@0.8.3 + - @module-federation/node@2.6.16 + - @module-federation/enhanced@0.8.3 + +## 0.8.2 + +### Patch Changes + +- @module-federation/enhanced@0.8.2 +- @module-federation/node@2.6.15 +- @module-federation/sdk@0.8.2 + +## 0.8.1 + +### Patch Changes + +- @module-federation/enhanced@0.8.1 +- @module-federation/node@2.6.14 +- @module-federation/sdk@0.8.1 + +## 0.8.0 + +### Patch Changes + +- d5c783b: fix: override watchOptions.ignored if the modernjs internal value is regexp +- e10725f: chore: no auto add watchOptions.ignored + - @module-federation/enhanced@0.8.0 + - @module-federation/sdk@0.8.0 + - @module-federation/node@2.6.13 + +## 0.7.7 + +### Patch Changes + +- a960c88: fix(modern-js-plugin): only export esm mfRuntimePlugin + - @module-federation/node@2.6.12 + - @module-federation/enhanced@0.7.7 + - @module-federation/sdk@0.7.7 + +## 0.7.6 + +### Patch Changes + +- Updated dependencies [6d35cf7] + - @module-federation/node@2.6.11 + - @module-federation/enhanced@0.7.6 + - @module-federation/sdk@0.7.6 + +## 0.7.5 + +### Patch Changes + +- a50b000: fix(modern-js-plugin): prevent components render multiple times if props change +- Updated dependencies [5613265] + - @module-federation/enhanced@0.7.5 + - @module-federation/node@2.6.10 + - @module-federation/sdk@0.7.5 + +## 0.7.4 + +### Patch Changes + +- @module-federation/node@2.6.9 +- @module-federation/enhanced@0.7.4 +- @module-federation/sdk@0.7.4 + +## 0.7.3 + +### Patch Changes + +- Updated dependencies [4ab9295] + - @module-federation/sdk@0.7.3 + - @module-federation/enhanced@0.7.3 + - @module-federation/node@2.6.8 + +## 0.7.2 + +### Patch Changes + +- @module-federation/enhanced@0.7.2 +- @module-federation/node@2.6.7 +- @module-federation/sdk@0.7.2 + +## 0.7.1 + +### Patch Changes + +- Updated dependencies [66ba7b1] +- Updated dependencies [6db4c5f] +- Updated dependencies [47fdbc2] + - @module-federation/node@2.6.6 + - @module-federation/sdk@0.7.1 + - @module-federation/enhanced@0.7.1 + +## 0.7.0 + +### Minor Changes + +- Updated dependencies [879ad87] +- Updated dependencies [4eb09e7] +- Updated dependencies [206b56d] + - @module-federation/sdk@0.7.0 + - @module-federation/enhanced@0.7.0 + - @module-federation/node@2.6.5 + +## 0.6.16 + +### Patch Changes + +- Updated dependencies [f779188] +- Updated dependencies [024df60] + - @module-federation/sdk@0.6.16 + - @module-federation/enhanced@0.6.16 + - @module-federation/node@2.6.4 + +## 0.6.15 + +### Patch Changes + +- d1e0f3e: fix(modern-js-plugin): set cors responseHeaders as \* + - @module-federation/node@2.6.3 + - @module-federation/enhanced@0.6.15 + - @module-federation/sdk@0.6.15 + +## 0.6.14 + +### Patch Changes + +- ad605d2: chore: unified logger +- Updated dependencies [87a2862] +- Updated dependencies [ad605d2] + - @module-federation/node@2.6.2 + - @module-federation/enhanced@0.6.14 + - @module-federation/sdk@0.6.14 + +## 0.6.13 + +### Patch Changes + +- Updated dependencies [f1b8848] + - @module-federation/node@2.6.1 + - @module-federation/enhanced@0.6.13 + - @module-federation/sdk@0.6.13 + +## 0.6.12 + +### Patch Changes + +- Updated dependencies [1478f50] +- Updated dependencies [1478f50] + - @module-federation/node@2.6.0 + - @module-federation/enhanced@0.6.12 + - @module-federation/sdk@0.6.12 + +## 0.6.11 + +### Patch Changes + +- Updated dependencies [d5a3072] + - @module-federation/sdk@0.6.11 + - @module-federation/node@2.5.21 + - @module-federation/enhanced@0.6.11 + +## 0.6.10 + +### Patch Changes + +- Updated dependencies [6b02145] +- Updated dependencies [22a3b83] + - @module-federation/enhanced@0.6.10 + - @module-federation/sdk@0.6.10 + - @module-federation/node@2.5.20 + +## 0.6.9 + +### Patch Changes + +- Updated dependencies [70a1708] + - @module-federation/enhanced@0.6.9 + - @module-federation/node@2.5.19 + - @module-federation/sdk@0.6.9 + +## 0.6.8 + +### Patch Changes + +- Updated dependencies [32db0ac] + - @module-federation/sdk@0.6.8 + - @module-federation/enhanced@0.6.8 + - @module-federation/node@2.5.18 + +## 0.6.7 + +### Patch Changes + +- Updated dependencies [1b6bf0e] +- Updated dependencies [9e32644] +- Updated dependencies [9e32644] +- Updated dependencies [9e32644] +- Updated dependencies [9e32644] + - @module-federation/enhanced@0.6.7 + - @module-federation/sdk@0.6.7 + - @module-federation/node@2.5.17 + +## 0.6.6 + +### Patch Changes + +- @module-federation/enhanced@0.6.6 +- @module-federation/node@2.5.16 +- @module-federation/sdk@0.6.6 + +## 0.6.5 + +### Patch Changes + +- @module-federation/enhanced@0.6.5 +- @module-federation/node@2.5.15 +- @module-federation/sdk@0.6.5 + +## 0.6.4 + +### Patch Changes + +- @module-federation/enhanced@0.6.4 +- @module-federation/node@2.5.14 +- @module-federation/sdk@0.6.4 + +## 0.6.3 + +### Patch Changes + +- 81201b8: fix(modernjs): mfConfigPlugin should run after @modern-js/plugin-initialize + - @module-federation/enhanced@0.6.3 + - @module-federation/sdk@0.6.3 + - @module-federation/node@2.5.13 + +## 0.6.2 + +### Patch Changes + +- 541494d: fix(modernjs): correct splitChunks.cacheGroups key which need to be removed +- 2394e38: fix(modernjs): auto set enableAsyncEntry when bundler is rspack + - @module-federation/node@2.5.12 + - @module-federation/enhanced@0.6.2 + - @module-federation/sdk@0.6.2 + +## 0.6.1 + +### Patch Changes + +- Updated dependencies [2855583] +- Updated dependencies [2855583] +- Updated dependencies [2855583] +- Updated dependencies [2855583] +- Updated dependencies [813680f] + - @module-federation/enhanced@0.6.1 + - @module-federation/sdk@0.6.1 + - @module-federation/node@2.5.11 + +## 0.6.0 + +### Patch Changes + +- Updated dependencies [f245bb3] +- Updated dependencies [1d9bb77] + - @module-federation/enhanced@0.6.0 + - @module-federation/sdk@0.6.0 + - @module-federation/node@2.5.10 + +## 0.5.2 + +### Patch Changes + +- Updated dependencies [b90fa7d] + - @module-federation/enhanced@0.5.2 + - @module-federation/sdk@0.5.2 + - @module-federation/node@2.5.9 + +## 0.5.1 + +### Patch Changes + +- @module-federation/enhanced@0.5.1 +- @module-federation/node@2.5.8 +- @module-federation/sdk@0.5.1 + +## 0.5.0 + +### Patch Changes + +- Updated dependencies [8378a77] + - @module-federation/sdk@0.5.0 + - @module-federation/enhanced@0.5.0 + - @module-federation/node@2.5.7 + +## 0.4.0 + +### Patch Changes + +- 88dec4e: fix(modern-js-plugin): require node plugin on demand +- Updated dependencies [a335707] +- Updated dependencies [a6e2bed] +- Updated dependencies [a6e2bed] + - @module-federation/enhanced@0.4.0 + - @module-federation/sdk@0.4.0 + - @module-federation/node@2.5.6 + +## 0.3.5 + +### Patch Changes + +- Updated dependencies [59db2fd] + - @module-federation/enhanced@0.3.5 + - @module-federation/node@2.5.5 + - @module-federation/sdk@0.3.5 + +## 0.3.4 + +### Patch Changes + +- 951d705: chore: upgrade modernjs@2.57.0 + - @module-federation/node@2.5.4 + - @module-federation/enhanced@0.3.4 + - @module-federation/sdk@0.3.4 + +## 0.3.3 + +### Patch Changes + +- Updated dependencies [85c6a12] + - @module-federation/node@2.5.3 + - @module-federation/enhanced@0.3.3 + - @module-federation/sdk@0.3.3 + +## 0.3.2 + +### Patch Changes + +- 85ae159: feat: support rspack ssr +- Updated dependencies [85ae159] + - @module-federation/enhanced@0.3.2 + - @module-federation/node@2.5.2 + - @module-federation/sdk@0.3.2 + +## 0.3.1 + +### Patch Changes + +- @module-federation/enhanced@0.3.1 +- @module-federation/node@2.5.1 +- @module-federation/sdk@0.3.1 + +## 0.2.0 + +### Minor Changes + +- fa37cc4: feat: support modern.js ssr [#2348](https://github.com/module-federation/core/issues/2348) + +### Patch Changes + +- Updated dependencies [fa37cc4] + - @module-federation/enhanced@0.3.0 + - @module-federation/node@2.5.0 + - @module-federation/sdk@0.3.0 diff --git a/packages/modernjs-v3/LICENSE b/packages/modernjs-v3/LICENSE new file mode 100644 index 000000000000..f74c11c43d62 --- /dev/null +++ b/packages/modernjs-v3/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-present zhanghang(2heal1) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/modernjs-v3/README.md b/packages/modernjs-v3/README.md new file mode 100644 index 000000000000..a085c0e0296a --- /dev/null +++ b/packages/modernjs-v3/README.md @@ -0,0 +1,5 @@ +# @module-federation/modern-js-v3 + +This plugin provides Module Federation supporting functions for Modern.js + +See [documentation](https://module-federation.io/guide/framework/modernjs.html) for more details . diff --git a/packages/modernjs-v3/bin/mf.js b/packages/modernjs-v3/bin/mf.js new file mode 100755 index 000000000000..6ac43ef81057 --- /dev/null +++ b/packages/modernjs-v3/bin/mf.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +const { runCli } = require('@module-federation/cli'); + +runCli(); diff --git a/packages/modernjs-v3/package.json b/packages/modernjs-v3/package.json new file mode 100644 index 000000000000..8759c7423f1c --- /dev/null +++ b/packages/modernjs-v3/package.json @@ -0,0 +1,205 @@ +{ + "name": "@module-federation/modern-js-v3", + "version": "2.0.1", + "files": [ + "dist/", + "types.d.ts", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "rslib build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/module-federation/core.git", + "directory": "packages/modernjs-v3" + }, + "exports": { + ".": { + "types": "./dist/types/cli/index.d.ts", + "import": "./dist/esm/cli/index.mjs", + "require": "./dist/cjs/cli/index.js" + }, + "./runtime": { + "types": "./dist/types/runtime/index.d.ts", + "default": "./dist/esm/runtime/index.mjs" + }, + "./react": { + "types": "./dist/types/react/index.d.ts", + "default": "./dist/esm/react/index.mjs" + }, + "./react-v18": { + "types": "./dist/types/react/v18.d.ts", + "default": "./dist/esm/react/v18.mjs" + }, + "./react-v19": { + "types": "./dist/types/react/v19.d.ts", + "default": "./dist/esm/react/v19.mjs" + }, + "./react-plugin": { + "types": "./dist/types/react/plugin.d.ts", + "default": "./dist/esm/react/plugin.mjs" + }, + "./ssr-dev-plugin": { + "types": "./dist/types/ssr-runtime/devPlugin.d.ts", + "default": "./dist/esm/ssr-runtime/devPlugin.mjs" + }, + "./ssr-inject-data-fetch-function-plugin": { + "types": "./dist/types/ssr-runtime/injectDataFetchFunctionPlugin.d.ts", + "default": "./dist/esm/ssr-runtime/injectDataFetchFunctionPlugin.mjs" + }, + "./config-plugin": { + "types": "./dist/types/cli/configPlugin.d.ts", + "import": "./dist/esm/cli/configPlugin.mjs", + "require": "./dist/cjs/cli/configPlugin.js" + }, + "./ssr-plugin": { + "types": "./dist/types/cli/ssrPlugin.d.ts", + "import": "./dist/esm/cli/ssrPlugin.mjs", + "require": "./dist/cjs/cli/ssrPlugin.js" + }, + "./shared-strategy": { + "types": "./dist/types/cli/mfRuntimePlugins/shared-strategy.d.ts", + "import": "./dist/esm/cli/mfRuntimePlugins/shared-strategy.mjs", + "require": "./dist/cjs/cli/mfRuntimePlugins/shared-strategy.js" + }, + "./resolve-entry-ipv4": { + "types": "./dist/types/cli/mfRuntimePlugins/resolve-entry-ipv4.d.ts", + "import": "./dist/esm/cli/mfRuntimePlugins/resolve-entry-ipv4.mjs", + "require": "./dist/cjs/cli/mfRuntimePlugins/resolve-entry-ipv4.js" + }, + "./inject-node-fetch": { + "types": "./dist/types/cli/mfRuntimePlugins/inject-node-fetch.d.ts", + "import": "./dist/esm/cli/mfRuntimePlugins/inject-node-fetch.mjs", + "require": "./dist/cjs/cli/mfRuntimePlugins/inject-node-fetch.js" + }, + "./rsc-bridge-runtime-plugin": { + "types": "./dist/types/cli/mfRuntimePlugins/rsc-bridge-runtime-plugin.d.ts", + "import": "./dist/esm/cli/mfRuntimePlugins/rsc-bridge-runtime-plugin.mjs", + "require": "./dist/cjs/cli/mfRuntimePlugins/rsc-bridge-runtime-plugin.js" + }, + "./rsc-bridge-expose": { + "types": "./dist/types/runtime/rsc-bridge-expose.d.ts", + "import": "./dist/esm/runtime/rsc-bridge-expose.mjs", + "require": "./dist/cjs/runtime/rsc-bridge-expose.js" + }, + "./data-fetch-server-plugin": { + "types": "./dist/types/cli/server/data-fetch-server-plugin.d.ts", + "default": "./dist/cjs/cli/server/data-fetch-server-plugin.js" + }, + "./server": { + "types": "./dist/types/server/index.d.ts", + "default": "./dist/cjs/server/index.js" + } + }, + "typesVersions": { + "*": { + ".": [ + "./dist/types/cli/index.d.ts" + ], + "runtime": [ + "./dist/types/runtime/index.d.ts" + ], + "react": [ + "./dist/types/react/index.d.ts" + ], + "react-v18": [ + "./dist/types/react/v18.d.ts" + ], + "react-v19": [ + "./dist/types/react/v19.d.ts" + ], + "react-plugin": [ + "./dist/types/react/plugin.d.ts" + ], + "config-plugin": [ + "./dist/types/cli/configPlugin.d.ts" + ], + "ssr-plugin": [ + "./dist/types/cli/ssrPlugin.d.ts" + ], + "shared-strategy": [ + "./dist/types/cli/mfRuntimePlugins/shared-strategy.d.ts" + ], + "resolve-entry-ipv4": [ + "./dist/types/cli/mfRuntimePlugins/resolve-entry-ipv4.d.ts" + ], + "inject-node-fetch": [ + "./dist/types/cli/mfRuntimePlugins/inject-node-fetch.d.ts" + ], + "rsc-bridge-runtime-plugin": [ + "./dist/types/cli/mfRuntimePlugins/rsc-bridge-runtime-plugin.d.ts" + ], + "rsc-bridge-expose": [ + "./dist/types/runtime/rsc-bridge-expose.d.ts" + ], + "data-fetch-server-plugin": [ + "./dist/types/cli/server/data-fetch-server-plugin.d.ts" + ], + "ssr-inject-data-fetch-function-plugin": [ + "./dist/types/ssr-runtime/injectDataFetchFunctionPlugin.d.ts" + ], + "server": [ + "./dist/types/server/index.d.ts" + ] + } + }, + "main": "./dist/cjs/cli/index.js", + "types": "./dist/types/cli/index.d.ts", + "author": "hanric ", + "license": "MIT", + "dependencies": { + "@module-federation/rsbuild-plugin": "https://pkg.pr.new/module-federation/core/@module-federation/rsbuild-plugin@73bd1ddb2266002f586a872ddae8020052c5457c", + "@module-federation/bridge-react": "https://pkg.pr.new/module-federation/core/@module-federation/bridge-react@73bd1ddb2266002f586a872ddae8020052c5457c", + "fs-extra": "11.3.0", + "lru-cache": "10.4.3", + "@module-federation/enhanced": "https://pkg.pr.new/module-federation/core/@module-federation/enhanced@73bd1ddb2266002f586a872ddae8020052c5457c", + "@module-federation/runtime": "2.0.0", + "@module-federation/sdk": "https://pkg.pr.new/module-federation/core/@module-federation/sdk@73bd1ddb2266002f586a872ddae8020052c5457c", + "@module-federation/cli": "https://pkg.pr.new/module-federation/core/@module-federation/cli@73bd1ddb2266002f586a872ddae8020052c5457c", + "@swc/helpers": "^0.5.17", + "node-fetch": "~3.3.0", + "jiti": "^2.6.1", + "react-error-boundary": "4.1.2", + "@module-federation/node": "2.7.32", + "@modern-js/server-core": "workspace:*" + }, + "devDependencies": { + "@rsbuild/plugin-react": "1.4.4", + "@module-federation/manifest": "https://pkg.pr.new/module-federation/core/@module-federation/manifest@73bd1ddb2266002f586a872ddae8020052c5457c", + "@rslib/core": "0.19.5", + "@rsbuild/core": "2.0.0-beta.4", + "@modern-js/app-tools": "workspace:*", + "@modern-js/server-runtime": "workspace:*", + "@modern-js/module-tools": "2.70.4", + "@modern-js/runtime": "workspace:*", + "@modern-js/tsconfig": "workspace:*", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17", + "typescript": "^4.9.0 || ^5.0.0", + "vue-tsc": "^1.0.24", + "react-router-dom": "^4 || ^5 || ^6 || ^7", + "react-router": "^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue-tsc": { + "optional": true + }, + "react-router-dom": { + "optional": true + }, + "react-router": { + "optional": true + } + } +} diff --git a/packages/modernjs-v3/project.json b/packages/modernjs-v3/project.json new file mode 100644 index 000000000000..7f1a5bf5e611 --- /dev/null +++ b/packages/modernjs-v3/project.json @@ -0,0 +1,57 @@ +{ + "name": "modern-js-plugin-v3", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/modernjs-v3/src", + "projectType": "library", + "tags": ["type:pkg"], + "implicitDependencies": [], + "targets": { + "build": { + "executor": "nx:run-commands", + "outputs": ["{projectRoot}/packages/modernjs-v3/dist"], + "dependsOn": [ + { + "target": "build", + "dependencies": true + } + ], + "options": { + "parallel": false, + "commands": [ + "cd packages/modernjs-v3; pnpm run build || (sleep 2 && pnpm run build)", + "cp packages/modernjs-v3/LICENSE packages/modernjs-v3/dist" + ] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/modernjs-v3/**/*.ts"] + } + }, + "test": { + "executor": "@nx/vite:test", + "outputs": ["{workspaceRoot}/coverage/packages/modernjs-v3"] + }, + "pre-release": { + "executor": "nx:run-commands", + "options": { + "parallel": false, + "commands": [ + { + "command": "nx run modern-js-plugin-v3:test", + "forwardAllArgs": false + }, + { + "command": "nx run modern-js-plugin-v3:build", + "forwardAllArgs": false + } + ] + } + }, + "semantic-release": { + "executor": "@goestav/nx-semantic-release:semantic-release" + } + } +} diff --git a/packages/modernjs-v3/rslib.config.ts b/packages/modernjs-v3/rslib.config.ts new file mode 100644 index 000000000000..6de6b3be6bd3 --- /dev/null +++ b/packages/modernjs-v3/rslib.config.ts @@ -0,0 +1,58 @@ +import { pluginReact } from '@rsbuild/plugin-react'; +import { defineConfig } from '@rslib/core'; + +const sharedLibOptions = { + bundle: false, + externalHelpers: true, + outBase: 'src', +} as const; + +export default defineConfig({ + source: { + entry: { + index: ['./src/**/*.{ts,tsx,js,jsx}', '!./src/**/*.spec.*'], + }, + }, + plugins: [ + pluginReact({ + swcReactOptions: { + runtime: 'automatic', + }, + }), + ], + lib: [ + { + ...sharedLibOptions, + format: 'cjs', + syntax: 'es2019', + dts: false, + output: { + distPath: { + root: './dist/cjs', + }, + }, + }, + { + ...sharedLibOptions, + format: 'esm', + syntax: 'es5', + dts: false, + output: { + distPath: { + root: './dist/esm', + }, + }, + }, + { + ...sharedLibOptions, + format: 'esm', + syntax: 'es2019', + dts: false, + output: { + distPath: { + root: './dist/esm-node', + }, + }, + }, + ], +}); diff --git a/packages/modernjs-v3/rstest.config.ts b/packages/modernjs-v3/rstest.config.ts new file mode 100644 index 000000000000..71d77ab6efe8 --- /dev/null +++ b/packages/modernjs-v3/rstest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + root: __dirname, + testEnvironment: 'node', + globals: true, + include: [ + 'tests/rsc-client-callback-bootstrap.test.ts', + 'tests/config-plugin-contract.test.ts', + ], +}); diff --git a/packages/modernjs-v3/src/cli/configPlugin.spec.ts b/packages/modernjs-v3/src/cli/configPlugin.spec.ts new file mode 100644 index 000000000000..f9e99e67ab4d --- /dev/null +++ b/packages/modernjs-v3/src/cli/configPlugin.spec.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from 'vitest'; +import { patchMFConfig } from './configPlugin'; +import { getIPV4 } from './utils'; + +const mfConfig = { + name: 'host', + filename: 'remoteEntry.js', + remotes: { + remote: 'http://localhost:3000/remoteEntry.js', + }, + shared: { + react: { singleton: true, eager: true }, + 'react-dom': { singleton: true, eager: true }, + }, +}; +describe('patchMFConfig', async () => { + it('patchMFConfig: server', async () => { + const patchedConfig = JSON.parse(JSON.stringify(mfConfig)); + patchMFConfig(patchedConfig, true); + const ipv4 = getIPV4(); + + expect(patchedConfig).toStrictEqual({ + dev: false, + dts: false, + filename: 'remoteEntry.js', + library: { + name: 'host', + type: 'commonjs-module', + }, + name: 'host', + remotes: { + remote: `http://${ipv4}:3000/remoteEntry.js`, + }, + remoteType: 'script', + runtimePlugins: [ + require.resolve('@module-federation/modern-js-v3/shared-strategy'), + require.resolve('@module-federation/node/runtimePlugin'), + require.resolve('@module-federation/modern-js-v3/inject-node-fetch'), + ], + shared: { + react: { + eager: true, + singleton: true, + }, + 'react-dom': { + eager: true, + singleton: true, + }, + }, + }); + }); + + it('patchMFConfig: client', async () => { + const patchedConfig = JSON.parse(JSON.stringify(mfConfig)); + patchMFConfig(patchedConfig, false); + const ipv4 = getIPV4(); + + expect(patchedConfig).toStrictEqual({ + filename: 'remoteEntry.js', + name: 'host', + remotes: { + remote: `http://${ipv4}:3000/remoteEntry.js`, + }, + remoteType: 'script', + runtimePlugins: [ + require.resolve('@module-federation/modern-js-v3/shared-strategy'), + ], + shared: { + react: { + eager: true, + singleton: true, + }, + 'react-dom': { + eager: true, + singleton: true, + }, + }, + dts: { + consumeTypes: { + runtimePkgs: ['@module-federation/modern-js-v3/runtime'], + }, + }, + }); + }); + + it('patchMFConfig: rsc remotes inject runtime bridge plugin', async () => { + const patchedConfig = { + name: 'rsc-host', + remotes: { + remote: 'http://localhost:3001/remoteEntry.js', + }, + exposes: { + './Widget': './src/widget.ts', + }, + experiments: { + rsc: true, + asyncStartup: true, + }, + }; + + patchMFConfig(patchedConfig as any, true); + + expect( + patchedConfig.runtimePlugins.some(runtimePlugin => + /rsc-bridge-runtime-plugin\.(t|j)s$/.test( + typeof runtimePlugin === 'string' ? runtimePlugin : runtimePlugin[0], + ), + ), + ).toBe(true); + expect(patchedConfig.exposes).toBeDefined(); + const widgetExposeConfig = (patchedConfig.exposes as any)['./Widget']; + expect(widgetExposeConfig.import).toEqual( + expect.arrayContaining([ + expect.stringMatching(/rsc-client-callback-bootstrap\.(mjs|js)$/), + './src/widget.ts', + ]), + ); + expect((patchedConfig.exposes as any)['./Widget']).toMatchObject({ + layer: 'react-server-components', + }); + expect( + (patchedConfig.exposes as any)['./__rspack_rsc_bridge__'], + ).toMatchObject({ + import: expect.stringMatching(/rsc-bridge-expose\.(t|j)s$/), + layer: 'react-server-components', + }); + }); + + it('patchMFConfig: rsc client shared config avoids non-default client.browser providers', async () => { + const patchedConfig = { + name: 'rsc-host', + remotes: { + remote: 'http://localhost:3001/remoteEntry.js', + }, + experiments: { + rsc: true, + asyncStartup: true, + }, + shared: [ + { + 'react-server-dom-rspack/client.browser': { + import: 'react-server-dom-rspack/client.browser', + shareScope: 'default', + }, + }, + { + 'react-server-dom-rspack/client.browser': { + import: 'react-server-dom-rspack/client.browser', + shareScope: 'ssr', + }, + }, + { + 'react-server-dom-rspack/client.browser': { + import: 'react-server-dom-rspack/client.browser', + shareScope: 'rsc', + }, + }, + ], + }; + + patchMFConfig(patchedConfig as any, false); + + const sharedScopes = patchedConfig.shared as Array< + Record + >; + expect( + sharedScopes[0]['react-server-dom-rspack/client.browser'].import, + ).toBe('react-server-dom-rspack/client.browser'); + expect( + sharedScopes[1]['react-server-dom-rspack/client.browser'].import, + ).toBe(false); + expect( + sharedScopes[2]['react-server-dom-rspack/client.browser'].import, + ).toBe(false); + }); + + it('patchMFConfig: rsc requires asyncStartup', async () => { + const patchedConfig = { + name: 'rsc-host', + remotes: { + remote: 'http://localhost:3001/remoteEntry.js', + }, + experiments: { + rsc: true, + }, + }; + + expect(() => patchMFConfig(patchedConfig as any, true)).toThrow( + /experiments\.rsc requires experiments\.asyncStartup = true/, + ); + }); + + it('patchMFConfig: rsc exposes inject bridge expose + bootstrap without runtime bridge plugin', async () => { + const patchedConfig = { + name: 'rsc-remote', + exposes: { + './Widget': './src/widget.ts', + }, + experiments: { + rsc: true, + asyncStartup: true, + }, + }; + + patchMFConfig(patchedConfig as any, true); + + expect( + patchedConfig.runtimePlugins.some(runtimePlugin => + /rsc-bridge-runtime-plugin\.(t|j)s$/.test( + typeof runtimePlugin === 'string' ? runtimePlugin : runtimePlugin[0], + ), + ), + ).toBe(false); + const widgetExposeConfig = (patchedConfig.exposes as any)['./Widget']; + expect(widgetExposeConfig.import).toEqual( + expect.arrayContaining([ + expect.stringMatching(/rsc-client-callback-bootstrap\.(mjs|js)$/), + './src/widget.ts', + ]), + ); + expect( + (patchedConfig.exposes as any)['./__rspack_rsc_bridge__'], + ).toMatchObject({ + import: expect.stringMatching(/rsc-bridge-expose\.(t|j)s$/), + layer: 'react-server-components', + }); + }); + + it('patchMFConfig: non-rsc does not inject internal bridge expose', async () => { + const patchedConfig = { + name: 'host', + remotes: { + remote: 'http://localhost:3001/remoteEntry.js', + }, + exposes: { + './Widget': './src/widget.ts', + }, + experiments: { + asyncStartup: true, + }, + }; + + patchMFConfig(patchedConfig as any, true); + + expect((patchedConfig.exposes as any)['./Widget']).toBe('./src/widget.ts'); + expect( + (patchedConfig.exposes as any)['./__rspack_rsc_bridge__'], + ).toBeUndefined(); + }); +}); diff --git a/packages/modernjs-v3/src/cli/configPlugin.ts b/packages/modernjs-v3/src/cli/configPlugin.ts new file mode 100644 index 000000000000..d96f9b653fa3 --- /dev/null +++ b/packages/modernjs-v3/src/cli/configPlugin.ts @@ -0,0 +1,999 @@ +import fs from 'fs'; +import path from 'path'; +import { + addDataFetchExposes, + autoDeleteSplitChunkCacheGroups, +} from '@module-federation/rsbuild-plugin/utils'; +import { + encodeName, + type moduleFederationPlugin, +} from '@module-federation/sdk'; +import { LOCALHOST, PLUGIN_IDENTIFIER } from '../constant'; +import logger from '../logger'; +import type { PluginOptions } from '../types'; +import { getIPV4, isWebTarget, skipByTarget } from './utils'; +import { isDev } from './utils'; + +import type { + AppTools, + AppUserConfig, + CliPlugin, + Rspack, +} from '@modern-js/app-tools'; +import type { BundlerChainConfig } from '../interfaces/bundler'; +import type { InternalModernPluginOptions } from '../types'; + +const defaultPath = path.resolve(process.cwd(), 'module-federation.config.ts'); + +export type ConfigType = Rspack.Configuration; + +type RuntimePluginEntry = NonNullable< + moduleFederationPlugin.ModuleFederationPluginOptions['runtimePlugins'] +>[number]; + +const RSC_LAYER = 'react-server-components'; +const RSC_BRIDGE_EXPOSE = './__rspack_rsc_bridge__'; +const RSC_CLIENT_BROWSER_SHARED_KEY = 'react-server-dom-rspack/client.browser'; + +const resolveFirstExistingPath = ( + candidatePaths: string[], + fallbackPath: string, +) => + candidatePaths.find(candidatePath => fs.existsSync(candidatePath)) || + fallbackPath; + +const RSC_BRIDGE_RUNTIME_PLUGIN = resolveFirstExistingPath( + [ + path.resolve(__dirname, './mfRuntimePlugins/rsc-bridge-runtime-plugin.ts'), + path.resolve(__dirname, './mfRuntimePlugins/rsc-bridge-runtime-plugin.js'), + path.resolve( + __dirname, + '../esm/cli/mfRuntimePlugins/rsc-bridge-runtime-plugin.mjs', + ), + ], + require.resolve('@module-federation/modern-js-v3/rsc-bridge-runtime-plugin'), +); + +const RSC_BRIDGE_EXPOSE_MODULE = resolveFirstExistingPath( + [ + path.resolve(__dirname, '../runtime/rsc-bridge-expose.ts'), + path.resolve(__dirname, '../runtime/rsc-bridge-expose.js'), + path.resolve(__dirname, '../esm/runtime/rsc-bridge-expose.mjs'), + ], + require.resolve('@module-federation/modern-js-v3/rsc-bridge-expose'), +); + +const RSC_CLIENT_CALLBACK_BOOTSTRAP_MODULE = resolveFirstExistingPath( + [ + path.resolve(__dirname, '../runtime/rsc-client-callback-bootstrap.js'), + path.resolve(__dirname, '../esm/runtime/rsc-client-callback-bootstrap.mjs'), + path.resolve( + path.dirname(RSC_BRIDGE_EXPOSE_MODULE), + 'rsc-client-callback-bootstrap.js', + ), + path.resolve( + path.dirname(RSC_BRIDGE_EXPOSE_MODULE), + 'rsc-client-callback-bootstrap.mjs', + ), + ], + path.resolve(__dirname, '../runtime/rsc-client-callback-bootstrap.js'), +); + +export function setEnv(enableSSR: boolean) { + if (enableSSR) { + process.env.MF_SSR_PRJ = 'true'; + } +} + +export const getMFConfig = async ( + userConfig: PluginOptions, +): Promise => { + const { config, configPath } = userConfig; + if (config) { + return config; + } + const mfConfigPath = configPath ? configPath : defaultPath; + const { createJiti } = require('jiti'); + const jit = createJiti(__filename, { + interopDefault: true, + esmResolve: true, + }); + const configModule = await jit(mfConfigPath); + + const resolvedConfig = ( + configModule && + typeof configModule === 'object' && + 'default' in configModule + ? (configModule as { default: unknown }).default + : configModule + ) as moduleFederationPlugin.ModuleFederationPluginOptions; + + return resolvedConfig; +}; + +const injectRuntimePlugins = ( + runtimePlugin: RuntimePluginEntry, + runtimePlugins: RuntimePluginEntry[], +): void => { + const pluginName = + typeof runtimePlugin === 'string' ? runtimePlugin : runtimePlugin[0]; + + const hasPlugin = runtimePlugins.some(existingPlugin => { + if (typeof existingPlugin === 'string') { + return existingPlugin === pluginName; + } + + return existingPlugin[0] === pluginName; + }); + + if (!hasPlugin) { + runtimePlugins.push(runtimePlugin); + } +}; + +const replaceRemoteUrl = ( + mfConfig: moduleFederationPlugin.ModuleFederationPluginOptions, + remoteIpStrategy?: 'ipv4' | 'inherit', +) => { + if (remoteIpStrategy && remoteIpStrategy === 'inherit') { + return; + } + if (!mfConfig.remotes) { + return; + } + const ipv4 = getIPV4(); + const handleRemoteObject = ( + remoteObject: moduleFederationPlugin.RemotesObject, + ) => { + Object.keys(remoteObject).forEach(remoteKey => { + const remote = remoteObject[remoteKey]; + // no support array items yet + if (Array.isArray(remote)) { + return; + } + if (typeof remote === 'string' && remote.includes(LOCALHOST)) { + remoteObject[remoteKey] = remote.replace(LOCALHOST, ipv4); + } + if (remote && typeof remote === 'object') { + const external = (remote as { external?: unknown }).external; + if (typeof external === 'string' && external.includes(LOCALHOST)) { + (remote as { external?: string }).external = external.replace( + LOCALHOST, + ipv4, + ); + } + } + }); + }; + if (Array.isArray(mfConfig.remotes)) { + mfConfig.remotes.forEach(remoteObject => { + if (typeof remoteObject === 'string') { + return; + } + handleRemoteObject(remoteObject); + }); + } else if (typeof mfConfig.remotes !== 'string') { + handleRemoteObject(mfConfig.remotes); + } +}; + +const patchDTSConfig = ( + mfConfig: moduleFederationPlugin.ModuleFederationPluginOptions, + isServer: boolean, +) => { + if (isServer) { + return; + } + const ModernJSRuntime = '@module-federation/modern-js-v3/runtime'; + if (mfConfig.dts !== false) { + if (typeof mfConfig.dts === 'boolean' || mfConfig.dts === undefined) { + mfConfig.dts = { + consumeTypes: { + runtimePkgs: [ModernJSRuntime], + }, + }; + } else if ( + mfConfig.dts?.consumeTypes || + mfConfig.dts?.consumeTypes === undefined + ) { + if ( + typeof mfConfig.dts.consumeTypes === 'boolean' || + mfConfig.dts?.consumeTypes === undefined + ) { + mfConfig.dts.consumeTypes = { + runtimePkgs: [ModernJSRuntime], + }; + } else { + mfConfig.dts.consumeTypes.runtimePkgs = + mfConfig.dts.consumeTypes.runtimePkgs || []; + if (!mfConfig.dts.consumeTypes.runtimePkgs.includes(ModernJSRuntime)) { + mfConfig.dts.consumeTypes.runtimePkgs.push(ModernJSRuntime); + } + } + } + } +}; + +const hasRemotes = ( + remotes: moduleFederationPlugin.ModuleFederationPluginOptions['remotes'], +) => { + if (!remotes) { + return false; + } + if (Array.isArray(remotes)) { + return remotes.length > 0; + } + if (typeof remotes === 'string') { + return remotes.length > 0; + } + return Object.keys(remotes).length > 0; +}; + +const hasExposes = ( + exposes: moduleFederationPlugin.ModuleFederationPluginOptions['exposes'], +) => { + if (!exposes) { + return false; + } + if (Array.isArray(exposes)) { + return exposes.length > 0; + } + return Object.keys(exposes).length > 0; +}; + +const isRscMfEnabled = ( + mfConfig: moduleFederationPlugin.ModuleFederationPluginOptions, +) => Boolean((mfConfig.experiments as { rsc?: boolean } | undefined)?.rsc); + +const normalizeExposeConfig = ( + exposeConfig: moduleFederationPlugin.ExposesObject[string], +) => { + if (typeof exposeConfig === 'string' || Array.isArray(exposeConfig)) { + return { + import: exposeConfig, + }; + } + + if ( + exposeConfig && + typeof exposeConfig === 'object' && + 'import' in exposeConfig + ) { + return { + ...(exposeConfig as Record), + import: (exposeConfig as { import: string | string[] }).import, + }; + } + + return { + import: exposeConfig as string, + }; +}; + +const setRscExposeConfig = ( + mfConfig: moduleFederationPlugin.ModuleFederationPluginOptions, +) => { + if (!mfConfig.exposes) { + return; + } + + const normalizedExposes: moduleFederationPlugin.ExposesObject = {}; + + const appendExpose = ( + exposeKey: string, + exposeConfig: moduleFederationPlugin.ExposesObject[string], + ) => { + const normalizedConfig = normalizeExposeConfig(exposeConfig); + const importList = Array.isArray(normalizedConfig.import) + ? [...normalizedConfig.import] + : [normalizedConfig.import]; + const normalizedImportList = + exposeKey === RSC_BRIDGE_EXPOSE + ? importList + : [ + RSC_CLIENT_CALLBACK_BOOTSTRAP_MODULE, + ...importList.filter( + importPath => importPath !== RSC_CLIENT_CALLBACK_BOOTSTRAP_MODULE, + ), + ]; + const normalizedImport = + normalizedImportList.length === 1 + ? normalizedImportList[0] + : normalizedImportList; + normalizedExposes[exposeKey] = { + ...normalizedConfig, + import: normalizedImport, + layer: RSC_LAYER, + } as moduleFederationPlugin.ExposesConfig; + }; + + if (Array.isArray(mfConfig.exposes)) { + for (const exposeItem of mfConfig.exposes) { + if (typeof exposeItem === 'string') { + appendExpose(exposeItem, exposeItem); + continue; + } + for (const [exposeKey, exposeConfig] of Object.entries(exposeItem)) { + appendExpose(exposeKey, exposeConfig); + } + } + } else { + for (const [exposeKey, exposeConfig] of Object.entries(mfConfig.exposes)) { + appendExpose(exposeKey, exposeConfig); + } + } + + if ( + !Object.prototype.hasOwnProperty.call(normalizedExposes, RSC_BRIDGE_EXPOSE) + ) { + normalizedExposes[RSC_BRIDGE_EXPOSE] = { + import: RSC_BRIDGE_EXPOSE_MODULE, + layer: RSC_LAYER, + } as moduleFederationPlugin.ExposesConfig; + } + + mfConfig.exposes = normalizedExposes; +}; + +const assertRscMfConfig = ({ + mfConfig, + isServer, + runtimePlugins, +}: { + mfConfig: moduleFederationPlugin.ModuleFederationPluginOptions; + isServer: boolean; + runtimePlugins: RuntimePluginEntry[]; +}) => { + if (!isRscMfEnabled(mfConfig)) { + return; + } + + const asyncStartupEnabled = + (mfConfig.experiments as { asyncStartup?: boolean } | undefined) + ?.asyncStartup === true; + if (!asyncStartupEnabled) { + throw new Error( + `${PLUGIN_IDENTIFIER} experiments.rsc requires experiments.asyncStartup = true`, + ); + } + + if (!isServer) { + return; + } + + const nodeRuntimePluginPath = require.resolve( + '@module-federation/node/runtimePlugin', + ); + const hasNodeRuntimePlugin = runtimePlugins.some(runtimePlugin => { + const runtimePluginPath = + typeof runtimePlugin === 'string' ? runtimePlugin : runtimePlugin[0]; + return runtimePluginPath === nodeRuntimePluginPath; + }); + + if (!hasNodeRuntimePlugin) { + throw new Error( + `${PLUGIN_IDENTIFIER} experiments.rsc requires @module-federation/node/runtimePlugin in runtimePlugins`, + ); + } +}; + +const patchRscClientBrowserSharedConfig = ( + mfConfig: moduleFederationPlugin.ModuleFederationPluginOptions, + isServer: boolean, +) => { + if (isServer || !mfConfig.shared) { + return; + } + + const patchSharedRecord = (sharedRecord: Record) => { + const clientBrowserShared = sharedRecord[RSC_CLIENT_BROWSER_SHARED_KEY] as + | Record + | undefined; + if (!clientBrowserShared || Array.isArray(clientBrowserShared)) { + return; + } + const shareScope = clientBrowserShared.shareScope; + if (typeof shareScope === 'string' && shareScope !== 'default') { + clientBrowserShared.import = false; + } + }; + + if (Array.isArray(mfConfig.shared)) { + for (const sharedConfig of mfConfig.shared) { + if (!sharedConfig || typeof sharedConfig !== 'object') { + continue; + } + patchSharedRecord(sharedConfig as Record); + } + return; + } + + if (typeof mfConfig.shared === 'object') { + patchSharedRecord(mfConfig.shared as Record); + } +}; + +export const patchMFConfig = ( + mfConfig: moduleFederationPlugin.ModuleFederationPluginOptions, + isServer: boolean, + remoteIpStrategy?: 'ipv4' | 'inherit', + enableSSR?: boolean, +) => { + const rscEnabled = isRscMfEnabled(mfConfig); + + replaceRemoteUrl(mfConfig, remoteIpStrategy); + addDataFetchExposes(mfConfig.exposes, isServer); + + if (rscEnabled) { + setRscExposeConfig(mfConfig); + patchRscClientBrowserSharedConfig(mfConfig, isServer); + } + + if (mfConfig.remoteType === undefined) { + mfConfig.remoteType = 'script'; + } + + if (!mfConfig.name) { + throw new Error(`${PLUGIN_IDENTIFIER} mfConfig.name can not be empty!`); + } + + const runtimePlugins = [ + ...(mfConfig.runtimePlugins || []), + ] as RuntimePluginEntry[]; + + patchDTSConfig(mfConfig, isServer); + + injectRuntimePlugins( + require.resolve('@module-federation/modern-js-v3/shared-strategy'), + runtimePlugins, + ); + + if (enableSSR && isDev()) { + injectRuntimePlugins( + require.resolve('@module-federation/modern-js-v3/resolve-entry-ipv4'), + runtimePlugins, + ); + } + + if (rscEnabled && hasRemotes(mfConfig.remotes)) { + injectRuntimePlugins(RSC_BRIDGE_RUNTIME_PLUGIN, runtimePlugins); + } + + if (isServer) { + injectRuntimePlugins( + require.resolve('@module-federation/node/runtimePlugin'), + runtimePlugins, + ); + if (isDev()) { + injectRuntimePlugins( + require.resolve( + '@module-federation/node/record-dynamic-remote-entry-hash-plugin', + ), + runtimePlugins, + ); + } + + injectRuntimePlugins( + require.resolve('@module-federation/modern-js-v3/inject-node-fetch'), + runtimePlugins, + ); + + if (!mfConfig.library) { + mfConfig.library = { + type: 'commonjs-module', + name: mfConfig.name, + }; + } else { + if (!mfConfig.library.type) { + mfConfig.library.type = 'commonjs-module'; + } + if (!mfConfig.library.name) { + mfConfig.library.name = mfConfig.name; + } + } + } + + assertRscMfConfig({ + mfConfig, + isServer, + runtimePlugins, + }); + + mfConfig.runtimePlugins = runtimePlugins; + + if (!isServer) { + if (mfConfig.library?.type === 'commonjs-module') { + mfConfig.library.type = 'global'; + } + return mfConfig; + } + + mfConfig.dts = false; + mfConfig.dev = false; + + return mfConfig; +}; + +function patchIgnoreWarning(chain: BundlerChainConfig) { + const ignoreWarnings = chain.get('ignoreWarnings') || []; + const ignoredMsgs = [ + 'external script', + 'process.env.WS_NO_BUFFER_UTIL', + `Can't resolve 'utf-8-validate`, + ]; + ignoreWarnings.push(warning => { + if (ignoredMsgs.some(msg => warning.message.includes(msg))) { + return true; + } + return false; + }); + chain.ignoreWarnings(ignoreWarnings); +} + +const patchProjectNodeModulesResolution = (chain: BundlerChainConfig) => { + // Keep federation + RSC resolution rooted in the app workspace to avoid + // divergent hoisted dependency paths between client/server manifests. + const projectNodeModulesPath = path.resolve(process.cwd(), 'node_modules'); + if (!fs.existsSync(projectNodeModulesPath)) { + return; + } + + const resolveModules = chain.resolve.modules as { + values?: () => string[]; + clear: () => unknown; + add: (value: string) => unknown; + }; + resolveModules.clear(); + resolveModules.add(projectNodeModulesPath); + resolveModules.add('node_modules'); +}; + +const patchServerOnlyAlias = (chain: BundlerChainConfig) => { + // Align server-only package behavior for federated remote exposes across + // both build targets. + const serverOnlyEmptyPath = path.resolve( + process.cwd(), + 'node_modules/server-only/empty.js', + ); + if (!fs.existsSync(serverOnlyEmptyPath)) { + return; + } + + const aliasChain = chain.resolve.alias as { + has?: (key: string) => boolean; + set: (key: string, value: string) => unknown; + }; + const hasServerOnlyAlias = + typeof aliasChain.has === 'function' + ? aliasChain.has('server-only$') + : false; + if (!hasServerOnlyAlias) { + aliasChain.set('server-only$', serverOnlyEmptyPath); + } +}; + +const resolveProjectDependency = (request: string) => { + try { + return require.resolve(request, { paths: [process.cwd()] }); + } catch { + try { + return require.resolve(request); + } catch { + return undefined; + } + } +}; + +const patchRscServerRuntimeAliases = (chain: BundlerChainConfig) => { + const reactPackagePath = resolveProjectDependency('react/package.json'); + if (!reactPackagePath) { + return; + } + + const reactDir = path.dirname(reactPackagePath); + const reactJsxRuntimeServerPath = path.join( + reactDir, + 'jsx-runtime.react-server.js', + ); + const reactJsxDevRuntimeServerPath = path.join( + reactDir, + 'jsx-dev-runtime.react-server.js', + ); + + if (fs.existsSync(reactJsxRuntimeServerPath)) { + chain.resolve.alias.set('react/jsx-runtime$', reactJsxRuntimeServerPath); + } + if (fs.existsSync(reactJsxDevRuntimeServerPath)) { + chain.resolve.alias.set( + 'react/jsx-dev-runtime$', + reactJsxDevRuntimeServerPath, + ); + } +}; + +const getExposeImports = ( + exposeConfig: moduleFederationPlugin.ExposesObject[string], +): string[] => { + if (typeof exposeConfig === 'string') { + return [exposeConfig]; + } + if (Array.isArray(exposeConfig)) { + return exposeConfig.filter( + (importPath): importPath is string => typeof importPath === 'string', + ); + } + if ( + exposeConfig && + typeof exposeConfig === 'object' && + 'import' in exposeConfig + ) { + const exposeImport = (exposeConfig as { import?: unknown }).import; + if (typeof exposeImport === 'string') { + return [exposeImport]; + } + if (Array.isArray(exposeImport)) { + return exposeImport.filter( + (importPath): importPath is string => typeof importPath === 'string', + ); + } + } + return []; +}; + +const collectExposeImportDirectories = ( + exposes: moduleFederationPlugin.ModuleFederationPluginOptions['exposes'], +) => { + if (!exposes) { + return []; + } + + const exposeEntries = Array.isArray(exposes) + ? exposes.flatMap(exposeItem => + typeof exposeItem === 'string' + ? [[exposeItem, exposeItem] as const] + : (Object.entries(exposeItem || {}) as Array< + readonly [string, moduleFederationPlugin.ExposesObject[string]] + >), + ) + : (Object.entries(exposes) as Array< + readonly [string, moduleFederationPlugin.ExposesObject[string]] + >); + + const directories = new Set(); + for (const [exposeKey, exposeConfig] of exposeEntries) { + if (exposeKey === RSC_BRIDGE_EXPOSE) { + continue; + } + for (const importPath of getExposeImports(exposeConfig)) { + if (!importPath || !importPath.startsWith('.')) { + continue; + } + directories.add(path.dirname(path.resolve(process.cwd(), importPath))); + } + } + + return Array.from(directories); +}; + +const patchRscRemoteComponentLayer = ( + chain: BundlerChainConfig, + mfConfig: moduleFederationPlugin.ModuleFederationPluginOptions, +) => { + // Derive layer coverage from actual expose imports instead of fixture-specific + // source path conventions. + const includeDirectories = collectExposeImportDirectories(mfConfig.exposes); + if (includeDirectories.length === 0) { + return; + } + + const ruleChain = chain.module + .rule('rsc-mf-remote-components-layer') + .test(/\.[cm]?[jt]sx?$/); + + for (const includeDirectory of includeDirectories) { + ruleChain.include.add(includeDirectory); + } + + ruleChain.layer(RSC_LAYER); +}; + +const normalizePublicPath = (publicPath: string) => + publicPath.endsWith('/') ? publicPath.slice(0, -1) : publicPath; + +export function addMyTypes2Ignored( + chain: BundlerChainConfig, + mfConfig: moduleFederationPlugin.ModuleFederationPluginOptions, +) { + const watchOptions = chain.get( + 'watchOptions', + ) as Rspack.Configuration['watchOptions']; + if (!watchOptions || !watchOptions.ignored) { + chain.watchOptions({ + ignored: /[\\/](?:\.git|node_modules|@mf-types)[\\/]/, + }); + return; + } + const ignored = watchOptions.ignored; + const DEFAULT_IGNORED_GLOB = '**/@mf-types/**'; + + if (Array.isArray(ignored)) { + if ( + mfConfig.dts !== false && + typeof mfConfig.dts === 'object' && + typeof mfConfig.dts.consumeTypes === 'object' && + mfConfig.dts.consumeTypes.remoteTypesFolder + ) { + chain.watchOptions({ + ...watchOptions, + ignored: ignored.concat( + `**/${mfConfig.dts.consumeTypes.remoteTypesFolder}/**`, + ), + }); + } else { + chain.watchOptions({ + ...watchOptions, + ignored: ignored.concat(DEFAULT_IGNORED_GLOB), + }); + } + + return; + } + + if (typeof ignored !== 'string') { + chain.watchOptions({ + ...watchOptions, + ignored: /[\\/](?:\.git|node_modules|@mf-types)[\\/]/, + }); + return; + } + + chain.watchOptions({ + ...watchOptions, + ignored: ignored.concat(DEFAULT_IGNORED_GLOB), + }); +} +export function patchBundlerConfig(options: { + chain: BundlerChainConfig; + isServer: boolean; + modernjsConfig: AppUserConfig; + mfConfig: moduleFederationPlugin.ModuleFederationPluginOptions; + enableSSR: boolean; +}) { + const { chain, modernjsConfig, isServer, mfConfig, enableSSR } = options; + const rscMfEnabled = isRscMfEnabled(mfConfig); + + chain.optimization.delete('runtimeChunk'); + + patchIgnoreWarning(chain); + + if (rscMfEnabled && isServer) { + chain.resolve.conditionNames + .clear() + .add('require') + .add('import') + .add('default'); + if (hasExposes(mfConfig.exposes)) { + chain.resolve.conditionNames.add('react-server'); + } + } + + if (rscMfEnabled && hasExposes(mfConfig.exposes)) { + patchProjectNodeModulesResolution(chain); + patchServerOnlyAlias(chain); + + const assetPrefix = modernjsConfig.output?.assetPrefix; + if (typeof assetPrefix === 'string' && assetPrefix.trim()) { + const normalizedAssetPrefix = normalizePublicPath(assetPrefix.trim()); + chain.output.publicPath( + isServer + ? `${normalizedAssetPrefix}/bundles/` + : `${normalizedAssetPrefix}/`, + ); + } + if (!isServer) { + chain.optimization.splitChunks(false); + } else { + patchRscServerRuntimeAliases(chain); + patchRscRemoteComponentLayer(chain, mfConfig); + } + } + + if (!chain.output.get('chunkLoadingGlobal')) { + chain.output.chunkLoadingGlobal(`chunk_${mfConfig.name}`); + } + if (!chain.output.get('uniqueName')) { + chain.output.uniqueName(mfConfig.name!); + } + + const splitChunkConfig = chain.optimization.splitChunks.entries(); + if (!isServer) { + // @ts-ignore type not the same + autoDeleteSplitChunkCacheGroups(mfConfig, splitChunkConfig); + } + + if ( + !isServer && + enableSSR && + splitChunkConfig && + typeof splitChunkConfig === 'object' && + splitChunkConfig.cacheGroups + ) { + splitChunkConfig.chunks = 'async'; + logger.warn( + `splitChunks.chunks = async is not allowed with stream SSR mode, it will auto changed to "async"`, + ); + } + + if (isDev() && chain.output.get('publicPath') === 'auto') { + // TODO: only in dev temp + const port = modernjsConfig.server?.port || 8080; + const publicPath = `http://localhost:${port}/`; + chain.output.publicPath(publicPath); + } + + if (isServer && enableSSR) { + const uniqueName = mfConfig.name || chain.output.get('uniqueName'); + const chunkFileName = chain.output.get('chunkFilename'); + if ( + typeof chunkFileName === 'string' && + uniqueName && + !chunkFileName.includes(uniqueName) + ) { + const suffix = `${encodeName(uniqueName)}-[contenthash].js`; + chain.output.chunkFilename(chunkFileName.replace('.js', suffix)); + } + } + // modernjs project has the same entry for server/client, add polyfill:false to skip compile error in browser target + if (isDev() && enableSSR && !isServer) { + chain.resolve.fallback + .set('crypto', false) + .set('stream', false) + .set('vm', false); + } + + if ( + modernjsConfig.deploy?.microFrontend && + Object.keys(mfConfig.exposes || {}).length + ) { + chain.optimization.usedExports(false); + } +} + +export const moduleFederationConfigPlugin = ( + userConfig: InternalModernPluginOptions, +): CliPlugin => ({ + name: '@modern-js/plugin-module-federation-config', + pre: ['@modern-js/plugin-initialize'], + post: ['@modern-js/plugin-module-federation'], + setup: async api => { + const modernjsConfig = api.getConfig(); + const mfConfig = await getMFConfig(userConfig.originPluginOptions); + const csrConfig = + userConfig.csrConfig || JSON.parse(JSON.stringify(mfConfig)); + const ssrConfig = + userConfig.ssrConfig || JSON.parse(JSON.stringify(mfConfig)); + userConfig.ssrConfig = ssrConfig; + userConfig.csrConfig = csrConfig; + const enableSSR = Boolean( + userConfig.userConfig?.ssr ?? Boolean(modernjsConfig?.server?.ssr), + ); + const enableRsc = Boolean(modernjsConfig?.server?.rsc); + + api.modifyBundlerChain(chain => { + const target = chain.get('target'); + if (skipByTarget(target)) { + return; + } + const isWeb = isWebTarget(target); + addMyTypes2Ignored(chain, !isWeb ? ssrConfig : csrConfig); + + const targetMFConfig = !isWeb ? ssrConfig : csrConfig; + patchMFConfig( + targetMFConfig, + !isWeb, + userConfig.remoteIpStrategy || 'ipv4', + enableSSR, + ); + + patchBundlerConfig({ + chain, + isServer: !isWeb, + modernjsConfig, + mfConfig: targetMFConfig, + enableSSR, + }); + + if (isWeb) { + userConfig.distOutputDir = + chain.output.get('path') || path.resolve(process.cwd(), 'dist'); + } else if (enableSSR && !enableRsc) { + userConfig.userConfig ||= {}; + userConfig.userConfig.ssr ||= {}; + if (userConfig.userConfig.ssr === true) { + userConfig.userConfig.ssr = {}; + } + userConfig.userConfig.ssr.distOutputDir = + chain.output.get('path') || + path.resolve(process.cwd(), 'dist/bundles'); + } + }); + api.config(() => { + const ipv4 = getIPV4(); + + if (userConfig.remoteIpStrategy === undefined) { + if (!enableSSR) { + userConfig.remoteIpStrategy = 'inherit'; + } else { + userConfig.remoteIpStrategy = 'ipv4'; + } + } + + const devServerConfig = modernjsConfig.tools?.devServer; + const corsWarnMsgs = [ + 'View https://module-federation.io/guide/troubleshooting/other.html#cors-warn for more details.', + ]; + if ( + typeof devServerConfig !== 'object' || + !('headers' in devServerConfig) + ) { + corsWarnMsgs.unshift( + 'Detect devServer.headers is empty, mf modern plugin will add default cors header: devServer.headers["Access-Control-Allow-Headers"] = "*". It is recommended to specify an allowlist of trusted origins instead.', + ); + } + + const exposes = userConfig.csrConfig?.exposes; + const hasExposes = + exposes && Array.isArray(exposes) + ? exposes.length + : Object.keys(exposes ?? {}).length; + + if (corsWarnMsgs.length > 1 && hasExposes) { + logger.warn(corsWarnMsgs.join('\n')); + } + + const corsHeaders = hasExposes + ? { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': + 'GET, POST, PUT, DELETE, PATCH, OPTIONS', + 'Access-Control-Allow-Headers': '*', + } + : undefined; + const defineConfig = { + REMOTE_IP_STRATEGY: JSON.stringify(userConfig.remoteIpStrategy), + }; + if (enableSSR && isDev()) { + defineConfig.FEDERATION_IPV4 = JSON.stringify(ipv4); + } + return { + tools: { + devServer: { + headers: corsHeaders, + }, + }, + resolve: { + alias: { + // TODO: deprecated + '@modern-js/runtime/mf': require.resolve( + '@module-federation/modern-js-v3/runtime', + ), + }, + }, + source: { + define: defineConfig, + enableAsyncEntry: + modernjsConfig.source?.enableAsyncEntry ?? !enableRsc, + }, + dev: { + assetPrefix: modernjsConfig?.dev?.assetPrefix + ? modernjsConfig.dev.assetPrefix + : 'auto', + }, + }; + }); + }, +}); + +export default moduleFederationConfigPlugin; + +export { isWebTarget, skipByTarget }; diff --git a/packages/modernjs-v3/src/cli/index.ts b/packages/modernjs-v3/src/cli/index.ts new file mode 100644 index 000000000000..6158ff3be415 --- /dev/null +++ b/packages/modernjs-v3/src/cli/index.ts @@ -0,0 +1,78 @@ +import type { AppTools, CliPlugin } from '@modern-js/app-tools'; +import { + ModuleFederationPlugin as RspackModuleFederationPlugin, + TreeShakingSharedPlugin as RspackTreeShakingSharedPlugin, +} from '@module-federation/enhanced/rspack'; +import type { moduleFederationPlugin as MFPluginOptions } from '@module-federation/sdk'; +import type { InternalModernPluginOptions, PluginOptions } from '../types'; +import { moduleFederationConfigPlugin } from './configPlugin'; +import { moduleFederationSSRPlugin } from './ssrPlugin'; +import { isWebTarget } from './utils'; + +export const moduleFederationPlugin = ( + userConfig: PluginOptions = {}, +): CliPlugin => { + const internalModernPluginOptions: InternalModernPluginOptions = { + csrConfig: undefined, + ssrConfig: undefined, + browserPlugin: undefined, + nodePlugin: undefined, + assetResources: {}, + distOutputDir: '', + originPluginOptions: { ...userConfig }, + remoteIpStrategy: userConfig?.remoteIpStrategy, + userConfig: userConfig || {}, + assetFileNames: {}, + fetchServerQuery: userConfig.fetchServerQuery ?? undefined, + secondarySharedTreeShaking: userConfig.secondarySharedTreeShaking ?? false, + }; + return { + name: '@modern-js/plugin-module-federation', + setup: async api => { + api.modifyBundlerChain(chain => { + const browserPluginOptions = + internalModernPluginOptions.csrConfig as MFPluginOptions.ModuleFederationPluginOptions; + const { secondarySharedTreeShaking } = internalModernPluginOptions; + if (isWebTarget(chain.get('target'))) { + if (secondarySharedTreeShaking) { + chain + .plugin('plugin-module-federation') + .use(RspackTreeShakingSharedPlugin, [ + { + mfConfig: browserPluginOptions, + secondary: true, + } as any, + ]); + } else { + chain + .plugin('plugin-module-federation') + .use(RspackModuleFederationPlugin, [browserPluginOptions]) + .init((Plugin: typeof RspackModuleFederationPlugin, args) => { + internalModernPluginOptions.browserPlugin = new Plugin(args[0]); + return internalModernPluginOptions.browserPlugin; + }); + } + } + }); + + api._internalServerPlugins(({ plugins }) => { + plugins.push({ + name: '@module-federation/modern-js-v3/server', + }); + return { plugins }; + }); + }, + usePlugins: [ + moduleFederationConfigPlugin(internalModernPluginOptions), + moduleFederationSSRPlugin( + internalModernPluginOptions as Required, + ), + ], + }; +}; + +export default moduleFederationPlugin; + +export { createModuleFederationConfig } from '@module-federation/enhanced'; + +export type { ModuleFederationRuntimePlugin } from '@module-federation/enhanced/runtime'; diff --git a/packages/modernjs-v3/src/cli/mfRuntimePlugins/inject-node-fetch.ts b/packages/modernjs-v3/src/cli/mfRuntimePlugins/inject-node-fetch.ts new file mode 100644 index 000000000000..7ea5c64bf326 --- /dev/null +++ b/packages/modernjs-v3/src/cli/mfRuntimePlugins/inject-node-fetch.ts @@ -0,0 +1,14 @@ +import type { ModuleFederationRuntimePlugin } from '@module-federation/enhanced/runtime'; +import nodeFetch from 'node-fetch'; + +const injectNodeFetchPlugin: () => ModuleFederationRuntimePlugin = () => ({ + name: 'inject-node-fetch-plugin', + beforeInit(args) { + if (!globalThis.fetch) { + // @ts-expect-error inject node-fetch + globalThis.fetch = nodeFetch; + } + return args; + }, +}); +export default injectNodeFetchPlugin; diff --git a/packages/modernjs-v3/src/cli/mfRuntimePlugins/resolve-entry-ipv4.ts b/packages/modernjs-v3/src/cli/mfRuntimePlugins/resolve-entry-ipv4.ts new file mode 100644 index 000000000000..5e6c9b3087c6 --- /dev/null +++ b/packages/modernjs-v3/src/cli/mfRuntimePlugins/resolve-entry-ipv4.ts @@ -0,0 +1,70 @@ +import type { ModuleFederationRuntimePlugin } from '@module-federation/enhanced/runtime'; +import { LOCALHOST } from '../../constant'; + +declare const FEDERATION_IPV4: string | undefined; +declare const REMOTE_IP_STRATEGY: 'ipv4' | 'inherit' | undefined; + +const ipv4 = + typeof FEDERATION_IPV4 !== 'undefined' ? FEDERATION_IPV4 : '127.0.0.1'; + +const remoteIpStrategy = + typeof REMOTE_IP_STRATEGY !== 'undefined' ? REMOTE_IP_STRATEGY : 'inherit'; + +function replaceObjectLocalhost(key: string, obj: Record) { + if (remoteIpStrategy !== 'ipv4') { + return; + } + if (!(key in obj)) { + return; + } + const remote = obj[key]; + if (remote && typeof remote === 'string' && remote.includes(LOCALHOST)) { + obj[key] = replaceLocalhost(remote); + } +} +function replaceLocalhost(url: string): string { + return url.replace(LOCALHOST, ipv4); +} + +const resolveEntryIpv4Plugin: () => ModuleFederationRuntimePlugin = () => ({ + name: 'resolve-entry-ipv4', + + beforeRegisterRemote(args) { + const { remote } = args; + replaceObjectLocalhost('entry', remote); + return args; + }, + async afterResolve(args) { + const { remoteInfo } = args; + replaceObjectLocalhost('entry', remoteInfo); + return args; + }, + beforeLoadRemoteSnapshot(args) { + const { moduleInfo } = args; + if ('entry' in moduleInfo) { + replaceObjectLocalhost('entry', moduleInfo); + return args; + } + if ('version' in moduleInfo) { + replaceObjectLocalhost('version', moduleInfo); + } + return args; + }, + loadRemoteSnapshot(args) { + const { remoteSnapshot } = args; + if ('publicPath' in remoteSnapshot) { + replaceObjectLocalhost('publicPath', remoteSnapshot); + } + if ('getPublicPath' in remoteSnapshot) { + replaceObjectLocalhost('getPublicPath', remoteSnapshot); + } + if (remoteSnapshot.remotesInfo) { + Object.keys(remoteSnapshot.remotesInfo).forEach(key => { + const remoteInfo = remoteSnapshot.remotesInfo[key]; + replaceObjectLocalhost('matchedVersion', remoteInfo); + }); + } + return args; + }, +}); +export default resolveEntryIpv4Plugin; diff --git a/packages/modernjs-v3/src/cli/mfRuntimePlugins/rsc-bridge-runtime-plugin.spec.ts b/packages/modernjs-v3/src/cli/mfRuntimePlugins/rsc-bridge-runtime-plugin.spec.ts new file mode 100644 index 000000000000..7abe8c5667aa --- /dev/null +++ b/packages/modernjs-v3/src/cli/mfRuntimePlugins/rsc-bridge-runtime-plugin.spec.ts @@ -0,0 +1,379 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import rscBridgeRuntimePlugin from './rsc-bridge-runtime-plugin'; + +type ManifestLike = { + serverManifest?: Record; + clientManifest?: Record; + serverConsumerModuleMap?: Record; +}; + +type WebpackRequireRuntime = { + m?: Record void>; + c?: Record; + rscM?: ManifestLike; +}; + +const ACTION_REMAP_GLOBAL_KEY = '__MODERN_RSC_MF_ACTION_ID_MAP__'; +const PROXY_MODULE_PREFIX = '__modernjs_mf_rsc_action_proxy__:'; + +const createWebpackRequireRuntime = (): WebpackRequireRuntime => ({ + m: {}, + c: {}, + rscM: { + serverManifest: {}, + clientManifest: {}, + serverConsumerModuleMap: {}, + }, +}); + +const getActionRemapMap = () => + ( + globalThis as typeof globalThis & { + [ACTION_REMAP_GLOBAL_KEY]?: Record; + } + )[ACTION_REMAP_GLOBAL_KEY] || {}; + +describe('rsc-bridge-runtime-plugin', () => { + beforeEach(() => { + vi.useFakeTimers(); + ( + globalThis as typeof globalThis & { + __webpack_require__?: WebpackRequireRuntime; + } + ).__webpack_require__ = createWebpackRequireRuntime(); + ( + globalThis as typeof globalThis & { + window?: { __MODERN_JS_ENTRY_NAME?: string }; + } + ).window = { + __MODERN_JS_ENTRY_NAME: 'server-component-root', + }; + delete ( + globalThis as typeof globalThis & { + [ACTION_REMAP_GLOBAL_KEY]?: Record; + } + )[ACTION_REMAP_GLOBAL_KEY]; + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + delete ( + globalThis as typeof globalThis & { + __webpack_require__?: WebpackRequireRuntime; + } + ).__webpack_require__; + delete ( + globalThis as typeof globalThis & { + window?: { __MODERN_JS_ENTRY_NAME?: string }; + } + ).window; + delete (globalThis as typeof globalThis & { fetch?: unknown }).fetch; + delete ( + globalThis as typeof globalThis & { + [ACTION_REMAP_GLOBAL_KEY]?: Record; + } + )[ACTION_REMAP_GLOBAL_KEY]; + }); + + it('merges remote manifest, registers action remap, and installs proxy dispatcher', async () => { + const executeAction = vi.fn(async (id: string, args: unknown[]) => ({ + id, + args, + })); + + const plugin = rscBridgeRuntimePlugin(); + const loadRemote = vi.fn(async () => ({ + getManifest: () => ({ + clientManifest: { + clientRef: { + id: '123', + name: 'default', + chunks: [], + }, + }, + serverConsumerModuleMap: { + '123': { + '*': { + id: '123', + name: 'default', + chunks: [], + }, + }, + }, + serverManifest: { + rawActionId: { + async: true, + }, + }, + }), + executeAction, + })); + + await plugin.onLoad?.({ + remote: { alias: 'rscRemote' }, + options: { name: 'rscHost' }, + origin: { + loadRemote, + }, + }); + + expect(loadRemote).toHaveBeenCalledTimes(1); + expect(loadRemote).toHaveBeenCalledWith('rscRemote/__rspack_rsc_bridge__'); + + const webpackRequire = ( + globalThis as typeof globalThis & { + __webpack_require__?: WebpackRequireRuntime; + } + ).__webpack_require__!; + const hostManifest = webpackRequire.rscM!; + + expect(hostManifest.clientManifest?.clientRef?.id).toBe( + 'remote-module:rscRemote:123', + ); + expect(hostManifest.serverConsumerModuleMap).toHaveProperty( + 'remote-module:rscRemote:123', + ); + expect( + hostManifest.serverConsumerModuleMap?.['remote-module:rscRemote:123']?.[ + '*' + ]?.id, + ).toBe('remote-module:rscRemote:123'); + + const prefixedActionId = 'remote:rscRemote:rawActionId'; + const proxyModuleId = `${PROXY_MODULE_PREFIX}rscRemote`; + expect(hostManifest.serverManifest?.[prefixedActionId]).toMatchObject({ + id: proxyModuleId, + name: prefixedActionId, + }); + expect(getActionRemapMap().rawActionId).toBe(prefixedActionId); + + const proxyFactory = webpackRequire.m?.[proxyModuleId]; + expect(typeof proxyFactory).toBe('function'); + + const proxyModule = { exports: {} as Record }; + proxyFactory?.(proxyModule); + const result = await proxyModule.exports[prefixedActionId]('payload'); + + expect(executeAction).toHaveBeenCalledWith('rawActionId', ['payload']); + expect(result).toEqual({ + id: 'rawActionId', + args: ['payload'], + }); + }); + + it('awaits async bridge manifest responses before merge', async () => { + const plugin = rscBridgeRuntimePlugin(); + + await plugin.onLoad?.({ + remote: { alias: 'rscRemote' }, + options: { name: 'rscHost' }, + origin: { + loadRemote: vi.fn(async () => ({ + getManifest: async () => ({ + serverManifest: { + asyncRawAction: { + async: true, + }, + }, + }), + executeAction: vi.fn(async () => undefined), + })), + }, + } as any); + + const webpackRequire = ( + globalThis as typeof globalThis & { + __webpack_require__?: WebpackRequireRuntime; + } + ).__webpack_require__!; + expect(webpackRequire.rscM?.serverManifest).toHaveProperty( + 'remote:rscRemote:asyncRawAction', + ); + expect(getActionRemapMap().asyncRawAction).toBe( + 'remote:rscRemote:asyncRawAction', + ); + }); + + it('loads and merges each remote alias once', async () => { + const plugin = rscBridgeRuntimePlugin(); + const loadRemote = vi.fn(async () => ({ + getManifest: () => ({ + serverManifest: { + one: { + async: true, + }, + }, + }), + executeAction: vi.fn(async () => undefined), + })); + + const args = { + remote: { alias: 'rscRemote' }, + options: { name: 'rscHost' }, + origin: { + loadRemote, + }, + }; + + await plugin.onLoad?.(args as any); + await plugin.onLoad?.(args as any); + + expect(loadRemote).toHaveBeenCalledTimes(1); + }); + + it('falls back to federation.instance when origin is unavailable', async () => { + const loadRemote = vi.fn(async () => ({ + getManifest: () => ({ + serverManifest: { + rawActionId: { + async: true, + }, + }, + }), + executeAction: vi.fn(async () => undefined), + })); + + const webpackRequire = ( + globalThis as typeof globalThis & { + __webpack_require__?: WebpackRequireRuntime; + } + ).__webpack_require__!; + ( + webpackRequire as WebpackRequireRuntime & { + federation?: { + instance?: { loadRemote?: (request: string) => Promise }; + }; + } + ).federation = { + instance: { + loadRemote, + }, + }; + + const plugin = rscBridgeRuntimePlugin(); + + await plugin.onLoad?.({ + remote: { alias: 'rscRemote' }, + options: { name: 'rscHost' }, + } as any); + + expect(loadRemote).toHaveBeenCalledWith('rscRemote/__rspack_rsc_bridge__'); + expect(getActionRemapMap().rawActionId).toBe( + 'remote:rscRemote:rawActionId', + ); + }); + + it('throws deterministic conflict errors when manifests disagree', async () => { + const webpackRequire = ( + globalThis as typeof globalThis & { + __webpack_require__?: WebpackRequireRuntime; + } + ).__webpack_require__!; + webpackRequire.rscM = { + serverManifest: {}, + clientManifest: { + sharedKey: { + id: 'remote-module:existingRemote:123', + name: 'default', + chunks: [], + }, + }, + serverConsumerModuleMap: {}, + }; + + const plugin = rscBridgeRuntimePlugin(); + + await expect( + plugin.onLoad?.({ + remote: { alias: 'rscRemote' }, + options: { name: 'rscHost' }, + origin: { + loadRemote: vi.fn(async () => ({ + getManifest: () => ({ + clientManifest: { + sharedKey: { + id: '123', + name: 'default', + chunks: [], + }, + }, + }), + executeAction: vi.fn(async () => undefined), + })), + }, + } as any), + ).rejects.toThrow(/clientManifest conflict/); + }); + + it('marks raw action remaps as false when aliases collide', async () => { + const plugin = rscBridgeRuntimePlugin(); + + const loadRemote = vi.fn(async (request: string) => { + if (request.startsWith('rscRemoteA/')) { + return { + getManifest: () => ({ + serverManifest: { + sameRawId: { + async: true, + }, + }, + }), + executeAction: vi.fn(async () => undefined), + }; + } + + return { + getManifest: () => ({ + serverManifest: { + sameRawId: { + async: true, + }, + }, + }), + executeAction: vi.fn(async () => undefined), + }; + }); + + await plugin.onLoad?.({ + remote: { alias: 'rscRemoteA' }, + options: { name: 'rscHost' }, + origin: { loadRemote }, + } as any); + + await plugin.onLoad?.({ + remote: { alias: 'rscRemoteB' }, + options: { name: 'rscHost' }, + origin: { loadRemote }, + } as any); + + expect(getActionRemapMap().sameRawId).toBe(false); + }); + + it('normalizes missing ssrPublicPath on resolved remote snapshots', async () => { + const plugin = rscBridgeRuntimePlugin(); + const loadRemote = vi.fn(async () => ({ + getManifest: () => ({}), + executeAction: vi.fn(async () => undefined), + })); + const args: any = { + remote: { alias: 'rscRemote' }, + remoteInfo: { + publicPath: 'http://127.0.0.1:3008/bundles/', + remoteEntry: { + name: 'static/remoteEntry.js', + }, + }, + origin: { + loadRemote, + }, + }; + + await plugin.afterResolve?.(args); + + expect(args.remoteInfo.ssrPublicPath).toBe( + 'http://127.0.0.1:3008/bundles/', + ); + expect(args.remoteInfo.remoteEntry.path).toBe(''); + }); +}); diff --git a/packages/modernjs-v3/src/cli/mfRuntimePlugins/rsc-bridge-runtime-plugin.ts b/packages/modernjs-v3/src/cli/mfRuntimePlugins/rsc-bridge-runtime-plugin.ts new file mode 100644 index 000000000000..bbdd16c44627 --- /dev/null +++ b/packages/modernjs-v3/src/cli/mfRuntimePlugins/rsc-bridge-runtime-plugin.ts @@ -0,0 +1,700 @@ +import type { ModuleFederationRuntimePlugin } from '@module-federation/enhanced/runtime'; + +const RSC_BRIDGE_EXPOSE = '__rspack_rsc_bridge__'; +const ACTION_PREFIX = 'remote:'; +const MODULE_PREFIX = 'remote-module:'; +const ACTION_REMAP_GLOBAL_KEY = '__MODERN_RSC_MF_ACTION_ID_MAP__'; +const ACTION_REMAP_WAITERS_KEY = '__MODERN_RSC_MF_ACTION_ID_MAP_WAITERS__'; +const PROXY_MODULE_PREFIX = '__modernjs_mf_rsc_action_proxy__:'; + +type ManifestLike = { + serverManifest?: Record; + clientManifest?: Record; + serverConsumerModuleMap?: Record; +}; + +type BridgeModule = { + getManifest?: () => ManifestLike; + executeAction?: (actionId: string, args: unknown[]) => Promise; +}; + +type ActionMapRecord = Record; +type ActionRemapWaiter = (prefixedActionId: string) => void; +type ActionRemapWaiterMap = Map; +type ActionRemapMap = Record; +type WebpackRequireRuntime = { + m?: Record void>; + c?: Record; + rscM?: ManifestLike; + federation?: { + instance?: { + loadRemote?: (request: string) => Promise; + }; + }; +}; + +declare const __webpack_require__: WebpackRequireRuntime; + +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const stableStringify = (value: unknown) => { + try { + return JSON.stringify(value, (_key, current) => { + if (Array.isArray(current)) { + return current; + } + if (!isObject(current)) { + return current; + } + return Object.fromEntries( + Object.keys(current) + .sort() + .map(key => [key, current[key]]), + ); + }); + } catch { + return String(value); + } +}; + +const assertNoConflict = ( + target: Record, + key: string, + nextValue: unknown, + alias: string, + section: string, +) => { + if (!Object.prototype.hasOwnProperty.call(target, key)) { + return; + } + if (stableStringify(target[key]) !== stableStringify(nextValue)) { + throw new Error( + `[modern-js-v3:rsc-bridge] ${section} conflict for "${key}" while merging remote "${alias}"`, + ); + } +}; + +const getNamespacedModuleId = (alias: string, rawId: string | number) => + `${MODULE_PREFIX}${alias}:${String(rawId)}`; + +const getActionRemapMap = () => { + const globalState = globalThis as typeof globalThis & { + [ACTION_REMAP_GLOBAL_KEY]?: ActionRemapMap; + [ACTION_REMAP_WAITERS_KEY]?: ActionRemapWaiterMap; + }; + if (!isObject(globalState[ACTION_REMAP_GLOBAL_KEY])) { + globalState[ACTION_REMAP_GLOBAL_KEY] = {}; + } + return globalState[ACTION_REMAP_GLOBAL_KEY] as ActionRemapMap; +}; + +const getActionRemapWaiters = () => { + const globalState = globalThis as typeof globalThis & { + [ACTION_REMAP_WAITERS_KEY]?: ActionRemapWaiterMap; + }; + const existingWaiters = globalState[ACTION_REMAP_WAITERS_KEY]; + if (!(existingWaiters instanceof Map)) { + globalState[ACTION_REMAP_WAITERS_KEY] = new Map(); + return globalState[ACTION_REMAP_WAITERS_KEY] as ActionRemapWaiterMap; + } + return existingWaiters; +}; + +const registerActionRemap = (rawActionId: string, prefixedActionId: string) => { + const remapMap = getActionRemapMap(); + const remapWaiters = getActionRemapWaiters(); + const existingValue = remapMap[rawActionId]; + if (typeof existingValue === 'undefined') { + remapMap[rawActionId] = prefixedActionId; + const waiters = remapWaiters.get(rawActionId); + if (waiters?.length) { + waiters.forEach(waiter => waiter(prefixedActionId)); + remapWaiters.delete(rawActionId); + } + return; + } + if (existingValue === prefixedActionId) { + return; + } + // Ambiguous mapping across remotes; skip unsafe remap. + remapMap[rawActionId] = false; + const waiters = remapWaiters.get(rawActionId); + if (waiters?.length) { + waiters.forEach(waiter => waiter(rawActionId)); + remapWaiters.delete(rawActionId); + } +}; + +const getWebpackRequireIfAvailable = (): WebpackRequireRuntime | undefined => { + if (typeof __webpack_require__ !== 'undefined') { + return __webpack_require__; + } + + const runtime = ( + globalThis as typeof globalThis & { + __webpack_require__?: WebpackRequireRuntime; + } + ).__webpack_require__; + return isObject(runtime) ? (runtime as WebpackRequireRuntime) : undefined; +}; + +const getWebpackRequire = (): WebpackRequireRuntime => { + const runtime = getWebpackRequireIfAvailable(); + if (!runtime) { + throw new Error( + '[modern-js-v3:rsc-bridge] __webpack_require__ is unavailable while installing the RSC bridge runtime plugin', + ); + } + return runtime; +}; + +const ensureHostManifest = () => { + const webpackRequire = getWebpackRequire(); + if (!isObject(webpackRequire.rscM)) { + webpackRequire.rscM = {}; + } + const manifest = webpackRequire.rscM as ManifestLike; + manifest.serverManifest = isObject(manifest.serverManifest) + ? manifest.serverManifest + : {}; + manifest.clientManifest = isObject(manifest.clientManifest) + ? manifest.clientManifest + : {}; + manifest.serverConsumerModuleMap = isObject(manifest.serverConsumerModuleMap) + ? manifest.serverConsumerModuleMap + : {}; + return manifest; +}; + +const remapConsumerNode = ( + alias: string, + value: unknown, + namespacedClientIds: Record, +) => { + if (!isObject(value)) { + return value; + } + return Object.fromEntries( + Object.entries(value).map(([exportName, exportValue]) => { + const nextExportValue = isObject(exportValue) + ? { ...exportValue } + : exportValue; + if (isObject(nextExportValue) && nextExportValue.id != null) { + const rawId = String(nextExportValue.id); + nextExportValue.id = Object.prototype.hasOwnProperty.call( + namespacedClientIds, + rawId, + ) + ? namespacedClientIds[rawId] + : getNamespacedModuleId(alias, rawId); + } + return [exportName, nextExportValue]; + }), + ); +}; + +const getProxyModuleId = (alias: string) => `${PROXY_MODULE_PREFIX}${alias}`; + +const installProxyModule = ({ + proxyModuleId, + actionMap, + ensureBridge, +}: { + proxyModuleId: string; + actionMap: ActionMapRecord; + ensureBridge: (alias: string) => Promise; +}) => { + const webpackRequire = getWebpackRequire(); + if (!isObject(webpackRequire.m)) { + webpackRequire.m = {}; + } + if (webpackRequire.m![proxyModuleId]) { + return; + } + + webpackRequire.m![proxyModuleId] = (module: { exports: any }) => { + module.exports = new Proxy( + {}, + { + get(_target, property) { + if (property === 'then') { + return undefined; + } + if (typeof property !== 'string') { + return undefined; + } + if (!Object.prototype.hasOwnProperty.call(actionMap, property)) { + return undefined; + } + const mapping = actionMap[property]; + return async (...args: unknown[]) => { + const bridge = await ensureBridge(mapping.alias); + if (typeof bridge.executeAction !== 'function') { + throw new Error( + `[modern-js-v3:rsc-bridge] Missing executeAction bridge method for remote "${mapping.alias}"`, + ); + } + return bridge.executeAction( + mapping.rawActionId, + Array.isArray(args) ? args : [], + ); + }; + }, + }, + ); + }; +}; + +const isNodeLikeRuntime = () => + typeof process !== 'undefined' && + Boolean(process.versions?.node) && + typeof document === 'undefined'; + +const rscBridgeRuntimePlugin = (): ModuleFederationRuntimePlugin => { + const bridgePromises: Partial>> = {}; + const aliasMergePromises: Partial>> = {}; + const actionMap: ActionMapRecord = {}; + const mergedRemoteAliases = new Set(); + + const resolveRemoteAlias = (args: any): string | undefined => { + const candidateAliases = [ + args?.remote?.alias, + args?.pkgNameOrAlias, + args?.remote?.name, + args?.remoteInfo?.alias, + args?.remoteInfo?.name, + args?.name, + ]; + for (const candidate of candidateAliases) { + if (typeof candidate === 'string' && candidate.trim()) { + return candidate; + } + } + return undefined; + }; + + const ensureBridge = async (alias: string, args?: any) => { + const existingBridgePromise = bridgePromises[alias]; + if (existingBridgePromise) { + return existingBridgePromise; + } + const runtimeInstance = + args?.origin && typeof args.origin.loadRemote === 'function' + ? args.origin + : getWebpackRequireIfAvailable()?.federation?.instance; + if (!runtimeInstance || typeof runtimeInstance.loadRemote !== 'function') { + throw new Error( + '[modern-js-v3:rsc-bridge] Module Federation runtime instance is unavailable while loading the RSC bridge', + ); + } + let resolveBridge!: (bridge: BridgeModule) => void; + let rejectBridge!: (error: unknown) => void; + const bridgePromise = new Promise((resolve, reject) => { + resolveBridge = resolve; + rejectBridge = reject; + }); + bridgePromises[alias] = bridgePromise; + + void Promise.resolve() + .then(() => runtimeInstance.loadRemote(`${alias}/${RSC_BRIDGE_EXPOSE}`)) + .then((bridge: BridgeModule) => { + if ( + !bridge || + typeof bridge.getManifest !== 'function' || + typeof bridge.executeAction !== 'function' + ) { + throw new Error( + `[modern-js-v3:rsc-bridge] Remote "${alias}" is missing the internal RSC bridge expose`, + ); + } + resolveBridge(bridge); + }) + .catch((error: unknown) => { + // Allow retry when bridge loading fails transiently. + delete bridgePromises[alias]; + rejectBridge(error); + }); + + return bridgePromise; + }; + + const mergeRemoteManifest = ( + alias: string, + remoteManifest: ManifestLike, + proxyModuleId: string, + ) => { + if (!isObject(remoteManifest)) { + return; + } + + const hostManifest = ensureHostManifest(); + const namespacedClientIds: Record = {}; + + if (isObject(remoteManifest.clientManifest)) { + for (const [key, value] of Object.entries( + remoteManifest.clientManifest, + )) { + const nextValue = isObject(value) ? { ...value } : value; + if (isObject(nextValue) && nextValue.id != null) { + const namespacedClientId = getNamespacedModuleId(alias, nextValue.id); + namespacedClientIds[String(nextValue.id)] = namespacedClientId; + nextValue.id = namespacedClientId; + } + assertNoConflict( + hostManifest.clientManifest as Record, + key, + nextValue, + alias, + 'clientManifest', + ); + (hostManifest.clientManifest as Record)[key] = nextValue; + } + } + + if (isObject(remoteManifest.serverConsumerModuleMap)) { + for (const [rawModuleId, value] of Object.entries( + remoteManifest.serverConsumerModuleMap, + )) { + const scopedModuleId = Object.prototype.hasOwnProperty.call( + namespacedClientIds, + String(rawModuleId), + ) + ? namespacedClientIds[String(rawModuleId)] + : getNamespacedModuleId(alias, rawModuleId); + const nextValue = remapConsumerNode(alias, value, namespacedClientIds); + assertNoConflict( + hostManifest.serverConsumerModuleMap as Record, + scopedModuleId, + nextValue, + alias, + 'serverConsumerModuleMap', + ); + (hostManifest.serverConsumerModuleMap as Record)[ + scopedModuleId + ] = nextValue; + } + } + + if (!isObject(remoteManifest.serverManifest)) { + return; + } + + for (const [rawActionId, actionEntry] of Object.entries( + remoteManifest.serverManifest, + )) { + const prefixedActionId = `${ACTION_PREFIX}${alias}:${rawActionId}`; + const hostActionEntry = { + id: proxyModuleId, + name: prefixedActionId, + chunks: [], + async: + isObject(actionEntry) && 'async' in actionEntry + ? (actionEntry as Record).async + : true, + }; + assertNoConflict( + hostManifest.serverManifest as Record, + prefixedActionId, + hostActionEntry, + alias, + 'serverManifest', + ); + (hostManifest.serverManifest as Record)[prefixedActionId] = + hostActionEntry; + actionMap[prefixedActionId] = { alias, rawActionId }; + registerActionRemap(rawActionId, prefixedActionId); + } + }; + + const ensureRemoteAliasMerged = async (alias: string, args: any) => { + const existingMergePromise = aliasMergePromises[alias]; + if (existingMergePromise) { + await existingMergePromise; + return; + } + if (mergedRemoteAliases.has(alias)) { + return; + } + + const mergePromise = (async () => { + const proxyModuleId = getProxyModuleId(alias); + installProxyModule({ + proxyModuleId, + actionMap, + ensureBridge: async resolvedAlias => ensureBridge(resolvedAlias, args), + }); + + const bridge = await ensureBridge(alias, args); + const remoteManifest = + typeof bridge.getManifest === 'function' + ? await Promise.resolve(bridge.getManifest()) + : {}; + mergeRemoteManifest(alias, remoteManifest || {}, proxyModuleId); + mergedRemoteAliases.add(alias); + })(); + + aliasMergePromises[alias] = mergePromise; + try { + await mergePromise; + } finally { + delete aliasMergePromises[alias]; + } + }; + + const ensureTrailingSlash = (value: string) => + value.endsWith('/') ? value : `${value}/`; + + const isAbsoluteHttpUrl = (value: string) => + value.startsWith('http://') || value.startsWith('https://'); + + const isBrokenRemoteEntryUrl = (value: unknown) => + typeof value !== 'string' || + !value.trim() || + value.includes('undefined') || + value.startsWith('https:undefined') || + value.startsWith('http:undefined'); + + const resolveSnapshotPublicPath = (snapshot: Record) => { + const metaData = isObject(snapshot.metaData) + ? (snapshot.metaData as Record) + : undefined; + const candidates = [ + snapshot.ssrPublicPath, + snapshot.publicPath, + metaData?.ssrPublicPath, + metaData?.publicPath, + ]; + const publicPath = candidates.find( + candidate => typeof candidate === 'string' && candidate.trim(), + ) as string | undefined; + if (!publicPath) { + return undefined; + } + return ensureTrailingSlash(publicPath.trim()); + }; + + const resolveSnapshotRemoteEntryRequest = (snapshot: Record) => { + const remoteEntryCandidate = isObject(snapshot.ssrRemoteEntry) + ? (snapshot.ssrRemoteEntry as Record) + : (snapshot.ssrRemoteEntry ?? snapshot.remoteEntry); + if (!remoteEntryCandidate) { + return undefined; + } + + let entryPath = + typeof remoteEntryCandidate === 'string' + ? remoteEntryCandidate + : (() => { + const pathPart = + typeof remoteEntryCandidate.path === 'string' + ? remoteEntryCandidate.path + : ''; + const namePart = + typeof remoteEntryCandidate.name === 'string' + ? remoteEntryCandidate.name + : ''; + return `${pathPart}${namePart}`; + })(); + if (!entryPath) { + return undefined; + } + + if (isAbsoluteHttpUrl(entryPath)) { + if ( + isNodeLikeRuntime() && + !snapshot.ssrRemoteEntry && + !entryPath.includes('/bundles/') + ) { + try { + const url = new URL(entryPath); + const normalizedPathname = url.pathname.startsWith('/') + ? url.pathname.slice(1) + : url.pathname; + url.pathname = `/bundles/${normalizedPathname}`; + return url.href; + } catch { + return entryPath; + } + } + return entryPath; + } + + if (isNodeLikeRuntime() && !snapshot.ssrRemoteEntry) { + const normalizedEntryPath = entryPath.startsWith('/') + ? entryPath.slice(1) + : entryPath; + if (!normalizedEntryPath.startsWith('bundles/')) { + entryPath = `bundles/${normalizedEntryPath}`; + } + } + + const publicPath = resolveSnapshotPublicPath(snapshot); + if (!publicPath) { + return undefined; + } + + try { + return new URL(entryPath, publicPath).href; + } catch { + const normalizedEntry = entryPath.startsWith('/') + ? entryPath.slice(1) + : entryPath; + return `${publicPath}${normalizedEntry}`; + } + }; + + const patchRemoteInfoEntry = ( + remoteInfo: Record | undefined, + snapshot: Record | undefined, + ) => { + if (!isObject(remoteInfo) || !isObject(snapshot)) { + return; + } + const resolvedEntry = resolveSnapshotRemoteEntryRequest(snapshot); + if (!resolvedEntry) { + return; + } + + if ( + isBrokenRemoteEntryUrl(remoteInfo.entry) || + (typeof remoteInfo.entry === 'string' && + remoteInfo.entry.includes('undefined')) + ) { + remoteInfo.entry = resolvedEntry; + } + }; + + const normalizeRemoteEntryPath = (remoteEntry: Record) => { + if ( + remoteEntry.path === undefined || + remoteEntry.path === null || + remoteEntry.path === 'undefined' + ) { + remoteEntry.path = ''; + } + }; + + const patchSnapshotSsrPublicPath = (snapshot: any) => { + if (!isObject(snapshot)) { + return; + } + + const metaData = isObject(snapshot.metaData) + ? (snapshot.metaData as Record) + : undefined; + + const rawPublicPathCandidates = [ + snapshot.ssrPublicPath, + snapshot.publicPath, + metaData?.ssrPublicPath, + metaData?.publicPath, + ]; + const publicPath = rawPublicPathCandidates.find( + candidate => typeof candidate === 'string' && candidate.trim(), + ) as string | undefined; + if (publicPath) { + const normalizedPublicPath = ensureTrailingSlash(publicPath.trim()); + snapshot.ssrPublicPath = normalizedPublicPath; + if (metaData) { + metaData.ssrPublicPath = normalizedPublicPath; + } + } + + const remoteEntry = isObject(snapshot.remoteEntry) + ? (snapshot.remoteEntry as Record) + : isObject(metaData?.remoteEntry) + ? (metaData?.remoteEntry as Record) + : undefined; + if (remoteEntry) { + normalizeRemoteEntryPath(remoteEntry); + if (!isObject(snapshot.remoteEntry)) { + snapshot.remoteEntry = remoteEntry; + } + if (metaData && !isObject(metaData.remoteEntry)) { + metaData.remoteEntry = remoteEntry; + } + } + + if ( + !isObject(snapshot.ssrRemoteEntry) && + (isObject(metaData?.ssrRemoteEntry) || remoteEntry) + ) { + const source = isObject(metaData?.ssrRemoteEntry) + ? (metaData?.ssrRemoteEntry as Record) + : (remoteEntry as Record); + const normalizedSsrRemoteEntry = { ...source }; + normalizeRemoteEntryPath(normalizedSsrRemoteEntry); + snapshot.ssrRemoteEntry = normalizedSsrRemoteEntry; + if (metaData) { + metaData.ssrRemoteEntry = { ...normalizedSsrRemoteEntry }; + } + } else if (isObject(snapshot.ssrRemoteEntry)) { + normalizeRemoteEntryPath(snapshot.ssrRemoteEntry as Record); + if (metaData && !isObject(metaData.ssrRemoteEntry)) { + metaData.ssrRemoteEntry = snapshot.ssrRemoteEntry; + } + } + }; + + const patchRuntimeArgsSnapshots = (args: any) => { + patchSnapshotSsrPublicPath(args?.remoteSnapshot); + patchSnapshotSsrPublicPath(args?.remoteInfo); + patchSnapshotSsrPublicPath(args?.remote?.remoteSnapshot); + patchSnapshotSsrPublicPath(args?.remote?.remoteInfo); + patchSnapshotSsrPublicPath(args?.resolvedRemote?.remoteSnapshot); + patchSnapshotSsrPublicPath(args?.resolvedRemote?.remoteInfo); + patchRemoteInfoEntry(args?.remoteInfo, args?.remoteSnapshot); + patchRemoteInfoEntry( + args?.remote?.remoteInfo, + args?.remote?.remoteSnapshot, + ); + patchRemoteInfoEntry( + args?.resolvedRemote?.remoteInfo, + args?.resolvedRemote?.remoteSnapshot, + ); + }; + + return { + name: 'modernjs-rsc-bridge-runtime-plugin', + async afterResolve(args: any) { + patchRuntimeArgsSnapshots(args); + const alias = resolveRemoteAlias(args); + if (!alias) { + return args; + } + await ensureRemoteAliasMerged(alias, args); + return args; + }, + async onLoad(args: any) { + patchRuntimeArgsSnapshots(args); + const alias = resolveRemoteAlias(args); + if (!alias) { + return args; + } + + const hasExposeContext = + typeof args?.expose === 'string' || + typeof args?.id === 'string' || + typeof args?.id === 'number'; + if (!hasExposeContext) { + await ensureRemoteAliasMerged(alias, args); + return args; + } + + const expose = + typeof args?.expose === 'string' ? args.expose : String(args?.id || ''); + if ( + mergedRemoteAliases.has(alias) || + expose.includes(RSC_BRIDGE_EXPOSE) + ) { + return args; + } + await ensureRemoteAliasMerged(alias, args); + + return args; + }, + }; +}; + +export default rscBridgeRuntimePlugin; diff --git a/packages/modernjs-v3/src/cli/mfRuntimePlugins/shared-strategy.ts b/packages/modernjs-v3/src/cli/mfRuntimePlugins/shared-strategy.ts new file mode 100644 index 000000000000..295e01d3a856 --- /dev/null +++ b/packages/modernjs-v3/src/cli/mfRuntimePlugins/shared-strategy.ts @@ -0,0 +1,22 @@ +import type { ModuleFederationRuntimePlugin } from '@module-federation/enhanced/runtime'; + +const sharedStrategy: () => ModuleFederationRuntimePlugin = () => ({ + name: 'shared-strategy-plugin', + beforeInit(args) { + const { userOptions } = args; + const shared = userOptions.shared; + if (shared) { + Object.keys(shared).forEach(sharedKey => { + const sharedConfigs = shared[sharedKey]; + const arraySharedConfigs = Array.isArray(sharedConfigs) + ? sharedConfigs + : [sharedConfigs]; + arraySharedConfigs.forEach(s => { + s.strategy = 'loaded-first'; + }); + }); + } + return args; + }, +}); +export default sharedStrategy; diff --git a/packages/modernjs-v3/src/cli/server/data-fetch-server-plugin.ts b/packages/modernjs-v3/src/cli/server/data-fetch-server-plugin.ts new file mode 100644 index 000000000000..08a4ae728f04 --- /dev/null +++ b/packages/modernjs-v3/src/cli/server/data-fetch-server-plugin.ts @@ -0,0 +1,19 @@ +import dataFetchMiddleWare from '@module-federation/bridge-react/data-fetch-server-middleware'; + +import type { ServerPlugin } from '@modern-js/server-runtime'; + +const dataFetchServePlugin = (): ServerPlugin => ({ + name: 'mf-data-fetch-server-plugin', + setup: api => { + api.onPrepare(() => { + const { middlewares } = api.getServerContext(); + middlewares.push({ + name: 'module-federation-serve-manifest', + // @ts-ignore type error + handler: dataFetchMiddleWare, + }); + }); + }, +}); + +export default dataFetchServePlugin; diff --git a/packages/modernjs-v3/src/cli/ssrPlugin.ts b/packages/modernjs-v3/src/cli/ssrPlugin.ts new file mode 100644 index 000000000000..c0d67cb460d1 --- /dev/null +++ b/packages/modernjs-v3/src/cli/ssrPlugin.ts @@ -0,0 +1,382 @@ +import path from 'path'; +import { + ModuleFederationPlugin as RspackModuleFederationPlugin, + TreeShakingSharedPlugin as RspackTreeShakingSharedPlugin, +} from '@module-federation/enhanced/rspack'; +import UniverseEntryChunkTrackerPlugin from '@module-federation/node/universe-entry-chunk-tracker-plugin'; +import { + type StatsAssetResource, + updateStatsAndManifest, +} from '@module-federation/rsbuild-plugin/utils'; +import { + ManifestFileName, + StatsFileName, + type moduleFederationPlugin, + simpleJoinRemoteEntry, +} from '@module-federation/sdk'; +import fs from 'fs-extra'; +import logger from '../logger'; +import { isDev } from './utils'; +import { isWebTarget, skipByTarget } from './utils'; + +import type { AppTools, CliPlugin } from '@modern-js/app-tools'; +import type { ModifyRspackConfigFn, RsbuildPlugin } from '@rsbuild/core'; +import type { + AssetFileNames, + InternalModernPluginOptions, + PluginOptions, +} from '../types'; + +export function setEnv() { + process.env.MF_SSR_PRJ = 'true'; +} + +export const CHAIN_MF_PLUGIN_ID = 'plugin-module-federation-server'; +const isBuildCommand = () => + process.argv.includes('build') || process.argv.includes('deploy'); + +const hasExposes = ( + exposes: moduleFederationPlugin.ModuleFederationPluginOptions['exposes'], +) => { + if (!exposes) { + return false; + } + if (Array.isArray(exposes)) { + return exposes.length > 0; + } + return Object.keys(exposes).length > 0; +}; + +function getManifestAssetFileNames( + manifestOption?: moduleFederationPlugin.ModuleFederationPluginOptions['manifest'], +): AssetFileNames { + if (!manifestOption) { + return { + statsFileName: StatsFileName, + manifestFileName: ManifestFileName, + }; + } + + const JSON_EXT = '.json'; + const filePath = + typeof manifestOption === 'boolean' ? '' : manifestOption.filePath || ''; + const baseFileName = + typeof manifestOption === 'boolean' ? '' : manifestOption.fileName || ''; + const ensureExt = (name: string) => + name.endsWith(JSON_EXT) ? name : `${name}${JSON_EXT}`; + const withSuffix = (name: string, suffix: string) => + name.replace(JSON_EXT, `${suffix}${JSON_EXT}`); + const manifestFileName = baseFileName + ? ensureExt(baseFileName) + : ManifestFileName; + const statsFileName = baseFileName + ? withSuffix(manifestFileName, '-stats') + : StatsFileName; + + return { + statsFileName: simpleJoinRemoteEntry(filePath, statsFileName), + manifestFileName: simpleJoinRemoteEntry(filePath, manifestFileName), + }; +} + +type ModifyBundlerConfiguration = Parameters[0]; +type ModifyBundlerUtils = Parameters[1]; + +const mfSSRRsbuildPlugin = ( + pluginOptions: Required, +): RsbuildPlugin => { + return { + name: '@modern-js/plugin-mf-post-config', + pre: ['@modern-js/builder-plugin-ssr'], + setup(api) { + if (pluginOptions.csrConfig.getPublicPath) { + return; + } + let csrOutputPath = ''; + let ssrOutputPath = ''; + let ssrEnv = ''; + let csrEnv = ''; + + api.modifyEnvironmentConfig((config, { name }) => { + const target = config.output.target; + if (skipByTarget(target)) { + return config; + } + if (isWebTarget(target)) { + csrOutputPath = config.output.distPath.root; + csrEnv = name; + } else { + ssrOutputPath = config.output.distPath.root; + ssrEnv = name; + } + return config; + }); + + const modifySSRPublicPath = ( + config: ModifyBundlerConfiguration, + utils: ModifyBundlerUtils, + ) => { + if (ssrEnv !== utils.environment.name) { + return config; + } + const userSSRConfig = pluginOptions.userConfig.ssr + ? typeof pluginOptions.userConfig.ssr === 'object' + ? pluginOptions.userConfig.ssr + : {} + : {}; + if (!userSSRConfig.distOutputDir) { + return; + } + config.output!.publicPath = `${config.output!.publicPath}${path.relative(csrOutputPath, ssrOutputPath)}/`; + return config; + }; + api.modifyRspackConfig((config, utils) => { + modifySSRPublicPath(config, utils); + return config; + }); + }, + }; +}; + +export const moduleFederationSSRPlugin = ( + pluginOptions: Required, +): CliPlugin => ({ + name: '@modern-js/plugin-module-federation-ssr', + pre: [ + '@modern-js/plugin-module-federation-config', + '@modern-js/plugin-module-federation', + ], + setup: async api => { + const modernjsConfig = api.getConfig(); + const enableSSR = + pluginOptions.userConfig?.ssr ?? Boolean(modernjsConfig?.server?.ssr); + const enableRsc = Boolean(modernjsConfig?.server?.rsc); + const enableMfRsc = Boolean( + (pluginOptions.ssrConfig.experiments as { rsc?: boolean } | undefined) + ?.rsc, + ); + const hasRscExposes = hasExposes(pluginOptions.ssrConfig.exposes); + const { secondarySharedTreeShaking } = pluginOptions; + if (!enableSSR) { + return; + } + + setEnv(); + + api._internalRuntimePlugins(({ entrypoint, plugins }) => { + if (secondarySharedTreeShaking) { + return { entrypoint, plugins }; + } + const { fetchServerQuery } = pluginOptions; + plugins.push({ + name: 'injectDataFetchFunction', + path: '@module-federation/modern-js-v3/ssr-inject-data-fetch-function-plugin', + config: { + fetchServerQuery, + }, + }); + if (!isDev()) { + return { entrypoint, plugins }; + } + plugins.push({ + name: 'mfSSRDev', + path: '@module-federation/modern-js-v3/ssr-dev-plugin', + config: {}, + }); + return { entrypoint, plugins }; + }); + + if (pluginOptions.ssrConfig.remotes) { + api._internalServerPlugins(({ plugins }) => { + plugins.push({ + name: '@module-federation/modern-js-v3/data-fetch-server-plugin', + options: {}, + }); + + return { plugins }; + }); + } + + api.modifyBundlerChain(chain => { + const target = chain.get('target'); + if (skipByTarget(target)) { + return; + } + const isWeb = isWebTarget(target); + + if (!isWeb) { + if (!chain.plugins.has(CHAIN_MF_PLUGIN_ID)) { + if (secondarySharedTreeShaking) { + chain + .plugin(CHAIN_MF_PLUGIN_ID) + .use(RspackTreeShakingSharedPlugin, [ + { + mfConfig: pluginOptions.ssrConfig, + secondary: true, + } as any, + ]); + } else { + chain + .plugin(CHAIN_MF_PLUGIN_ID) + .use(RspackModuleFederationPlugin, [pluginOptions.ssrConfig]) + .init((Plugin: typeof RspackModuleFederationPlugin, args) => { + pluginOptions.nodePlugin = new Plugin(args[0]); + return pluginOptions.nodePlugin; + }); + } + } + } + + if (!isWeb && !secondarySharedTreeShaking) { + chain.target('async-node'); + + if (enableRsc && (!enableMfRsc || hasRscExposes)) { + chain.resolve.conditionNames.add('react-server'); + } + + if (isDev()) { + chain + .plugin('UniverseEntryChunkTrackerPlugin') + .use(UniverseEntryChunkTrackerPlugin); + } + } + + if (isDev() && isWeb) { + chain.externals({ + '@module-federation/node/utils': 'NOT_USED_IN_BROWSER', + }); + } + }); + // @ts-ignore + api.config(() => { + return { + builderPlugins: [mfSSRRsbuildPlugin(pluginOptions)], + dev: { + setupMiddlewares: [ + middlewares => + middlewares.unshift((req, res, next) => { + if (!enableSSR) { + next(); + return; + } + try { + const requestPath = req.url?.split('?')[0] || ''; + const isJsonRequest = path.extname(requestPath) === '.json'; + if (isJsonRequest && !requestPath.includes('hot-update')) { + if (!requestPath.startsWith('/')) { + next(); + return; + } + + const distRoot = path.resolve(process.cwd(), 'dist'); + const filepath = path.resolve(distRoot, `.${requestPath}`); + const allowedPrefix = `${distRoot}${path.sep}`; + if ( + filepath !== distRoot && + !filepath.startsWith(allowedPrefix) + ) { + next(); + return; + } + + fs.statSync(filepath); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader( + 'Access-Control-Allow-Methods', + 'GET, POST, PUT, DELETE, PATCH, OPTIONS', + ); + res.setHeader('Access-Control-Allow-Headers', '*'); + fs.createReadStream(filepath).pipe(res); + } else { + next(); + } + } catch (err) { + logger.debug(err); + next(); + } + }), + ], + }, + }; + }); + + const readAssetResourceFromDisk = ( + outputDir: string, + fileNames: AssetFileNames, + tag: 'browser' | 'node', + ): StatsAssetResource | undefined => { + const statsFilePath = path.resolve(outputDir, fileNames.statsFileName); + const manifestFilePath = path.resolve( + outputDir, + fileNames.manifestFileName, + ); + if (!fs.existsSync(statsFilePath) || !fs.existsSync(manifestFilePath)) { + return undefined; + } + + try { + return { + stats: { + data: fs.readJSONSync(statsFilePath), + filename: fileNames.statsFileName, + }, + manifest: { + data: fs.readJSONSync(manifestFilePath), + filename: fileNames.manifestFileName, + }, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error( + `[module-federation-ssr] Failed to read ${tag} manifest assets from disk: ${message}`, + ); + return undefined; + } + }; + + const writeMergedManifest = () => { + if (!isBuildCommand()) { + return; + } + const distOutputDir = + pluginOptions.distOutputDir || path.resolve(process.cwd(), 'dist'); + const userSSRConfig = pluginOptions.userConfig.ssr; + const ssrDistOutputDir = + typeof userSSRConfig === 'object' && userSSRConfig.distOutputDir + ? userSSRConfig.distOutputDir + : path.resolve(distOutputDir, 'bundles'); + const browserFileNames = + pluginOptions.assetFileNames.browser || + getManifestAssetFileNames(pluginOptions.csrConfig?.manifest); + const nodeFileNames = + pluginOptions.assetFileNames.node || + getManifestAssetFileNames(pluginOptions.ssrConfig?.manifest); + const browserAssets = readAssetResourceFromDisk( + distOutputDir, + browserFileNames, + 'browser', + ); + const nodeAssets = readAssetResourceFromDisk( + ssrDistOutputDir, + nodeFileNames, + 'node', + ); + + if (!browserAssets || !nodeAssets) { + return; + } + + try { + updateStatsAndManifest(nodeAssets, browserAssets, distOutputDir); + } catch (err) { + logger.error(err); + } + }; + + api.onAfterBuild(() => { + writeMergedManifest(); + }); + }, +}); + +export default moduleFederationSSRPlugin; diff --git a/packages/modernjs-v3/src/cli/utils.ts b/packages/modernjs-v3/src/cli/utils.ts new file mode 100644 index 000000000000..63b26989ef1b --- /dev/null +++ b/packages/modernjs-v3/src/cli/utils.ts @@ -0,0 +1,57 @@ +import os from 'os'; +import type { Rspack } from '@modern-js/app-tools'; + +export type ConfigType = Rspack.Configuration; + +const localIpv4 = '127.0.0.1'; + +const getIpv4Interfaces = (): os.NetworkInterfaceInfo[] => { + try { + const interfaces = os.networkInterfaces(); + const ipv4Interfaces: os.NetworkInterfaceInfo[] = []; + + Object.values(interfaces).forEach(detail => { + detail?.forEach(detail => { + // 'IPv4' is in Node <= 17, from 18 it's a number 4 or 6 + const familyV4Value = typeof detail.family === 'string' ? 'IPv4' : 4; + + if (detail.family === familyV4Value && detail.address !== localIpv4) { + ipv4Interfaces.push(detail); + } + }); + }); + return ipv4Interfaces; + } catch (_err) { + return []; + } +}; + +export const getIPV4 = (): string => { + const ipv4Interfaces = getIpv4Interfaces(); + const ipv4Interface = ipv4Interfaces[0] || { address: localIpv4 }; + return ipv4Interface.address; +}; + +export const isWebTarget = (target: string[] | string) => { + const WEB_TARGET = 'web'; + if (Array.isArray(target)) { + return target.includes(WEB_TARGET); + } else if (typeof target === 'string') { + return target === WEB_TARGET; + } + return false; +}; + +export const skipByTarget = (target: string[] | string) => { + const IGNORE_TARGET = 'webworker'; + if (Array.isArray(target)) { + return target.includes(IGNORE_TARGET); + } else if (typeof target === 'string') { + return target === IGNORE_TARGET; + } + return false; +}; + +export function isDev() { + return process.env.NODE_ENV === 'development'; +} diff --git a/packages/modernjs-v3/src/constant.ts b/packages/modernjs-v3/src/constant.ts new file mode 100644 index 000000000000..8b547453196b --- /dev/null +++ b/packages/modernjs-v3/src/constant.ts @@ -0,0 +1,2 @@ +export const LOCALHOST = 'localhost'; +export const PLUGIN_IDENTIFIER = '[ Modern.js Module Federation ]'; diff --git a/packages/modernjs-v3/src/interfaces/bundler.ts b/packages/modernjs-v3/src/interfaces/bundler.ts new file mode 100644 index 000000000000..5b02ec48e0ee --- /dev/null +++ b/packages/modernjs-v3/src/interfaces/bundler.ts @@ -0,0 +1,29 @@ +import type { AppUserConfig } from '@modern-js/app-tools'; + +type AppToolsUserConfig = AppUserConfig['tools']; + +type ExcludeUndefined = T extends undefined ? never : T; + +type ExtractObjectType = T extends (...args: any[]) => any ? never : T; + +type OmitArrayConfiguration = T extends Array + ? T extends (infer U)[] + ? U + : T + : ExtractObjectType; + +type RspackConfigs = ExcludeUndefined extends { + rspack?: infer U; +} + ? U + : never; + +type ObjectRspack = ExtractObjectType>; + +type BundlerChain = ExcludeUndefined['bundlerChain']; + +type BundlerChainFunc = Extract any>; + +export type BundlerChainConfig = Parameters[0]; + +export type BundlerConfig = ObjectRspack; diff --git a/packages/modernjs-v3/src/logger.ts b/packages/modernjs-v3/src/logger.ts new file mode 100644 index 000000000000..c1be3252163d --- /dev/null +++ b/packages/modernjs-v3/src/logger.ts @@ -0,0 +1,6 @@ +import { createLogger } from '@module-federation/sdk'; +import { PLUGIN_IDENTIFIER } from './constant'; + +const logger = createLogger(PLUGIN_IDENTIFIER); + +export default logger; diff --git a/packages/modernjs-v3/src/react/index.ts b/packages/modernjs-v3/src/react/index.ts new file mode 100644 index 000000000000..7aedf02e47e6 --- /dev/null +++ b/packages/modernjs-v3/src/react/index.ts @@ -0,0 +1 @@ +export * from '@module-federation/bridge-react'; diff --git a/packages/modernjs-v3/src/react/plugin.ts b/packages/modernjs-v3/src/react/plugin.ts new file mode 100644 index 000000000000..5033022a0dc7 --- /dev/null +++ b/packages/modernjs-v3/src/react/plugin.ts @@ -0,0 +1,2 @@ +export * from '@module-federation/bridge-react/plugin'; +export { default } from '@module-federation/bridge-react/plugin'; diff --git a/packages/modernjs-v3/src/react/v18.ts b/packages/modernjs-v3/src/react/v18.ts new file mode 100644 index 000000000000..7985c425f7c6 --- /dev/null +++ b/packages/modernjs-v3/src/react/v18.ts @@ -0,0 +1 @@ +export * from '@module-federation/bridge-react/v18'; diff --git a/packages/modernjs-v3/src/react/v19.ts b/packages/modernjs-v3/src/react/v19.ts new file mode 100644 index 000000000000..dde3fcbda1e8 --- /dev/null +++ b/packages/modernjs-v3/src/react/v19.ts @@ -0,0 +1 @@ +export * from '@module-federation/bridge-react/v19'; diff --git a/packages/modernjs-v3/src/runtime/index.ts b/packages/modernjs-v3/src/runtime/index.ts new file mode 100644 index 000000000000..532254ed0fc5 --- /dev/null +++ b/packages/modernjs-v3/src/runtime/index.ts @@ -0,0 +1 @@ +export * from '@module-federation/enhanced/runtime'; diff --git a/packages/modernjs-v3/src/runtime/rsc-bridge-expose.spec.ts b/packages/modernjs-v3/src/runtime/rsc-bridge-expose.spec.ts new file mode 100644 index 000000000000..230564f969ae --- /dev/null +++ b/packages/modernjs-v3/src/runtime/rsc-bridge-expose.spec.ts @@ -0,0 +1,168 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +type ManifestLike = { + serverManifest?: Record; + clientManifest?: Record; + serverConsumerModuleMap?: Record; +}; + +type ExposeModuleMap = Record Promise | unknown>; + +type WebpackRequireRuntime = { + initializeExposesData?: { + moduleMap?: ExposeModuleMap; + }; + rscM?: ManifestLike; +}; + +const setWebpackRequireRuntime = (runtime: WebpackRequireRuntime) => { + ( + globalThis as typeof globalThis & { + __webpack_require__?: WebpackRequireRuntime; + } + ).__webpack_require__ = runtime; +}; + +const loadBridgeExposeModule = async () => { + vi.resetModules(); + return import('./rsc-bridge-expose'); +}; + +afterEach(() => { + delete ( + globalThis as typeof globalThis & { + __webpack_require__?: WebpackRequireRuntime; + } + ).__webpack_require__; +}); + +describe('rsc-bridge-expose', () => { + it('returns remote manifest through getManifest', async () => { + const manifest: ManifestLike = { + serverManifest: { + action: { + id: 'remote:raw', + }, + }, + }; + setWebpackRequireRuntime({ + rscM: manifest, + initializeExposesData: { + moduleMap: {}, + }, + }); + + const { getManifest } = await loadBridgeExposeModule(); + + expect(getManifest()).toBe(manifest); + }); + + it('scans exposes and executes action references by raw id', async () => { + const action = vi.fn(async (value: string) => `ok:${value}`) as { + (...args: unknown[]): Promise; + $$id?: string; + }; + action.$$id = 'raw-action-id'; + + const getFactory = vi.fn(async () => () => ({ + nested: { + action, + }, + })); + + setWebpackRequireRuntime({ + initializeExposesData: { + moduleMap: { + './actions': getFactory, + './__rspack_rsc_bridge__': vi.fn(async () => () => ({ + ignored: true, + })), + }, + }, + rscM: {}, + }); + + const bridgeExpose = await loadBridgeExposeModule(); + + await expect( + bridgeExpose.executeAction('raw-action-id', ['value-a']), + ).resolves.toBe('ok:value-a'); + await expect( + bridgeExpose.executeAction('raw-action-id', ['value-b']), + ).resolves.toBe('ok:value-b'); + + expect(getFactory).toHaveBeenCalledTimes(1); + expect(action).toHaveBeenNthCalledWith(1, 'value-a'); + expect(action).toHaveBeenNthCalledWith(2, 'value-b'); + }); + + it('normalizes non-array args to empty array before dispatch', async () => { + const action = vi.fn(async (...args: unknown[]) => args.length) as { + (...args: unknown[]): Promise; + $$id?: string; + }; + action.$$id = 'raw-action-id'; + + setWebpackRequireRuntime({ + initializeExposesData: { + moduleMap: { + './actions': async () => () => ({ action }), + }, + }, + rscM: {}, + }); + + const bridgeExpose = await loadBridgeExposeModule(); + + await expect( + bridgeExpose.executeAction('raw-action-id', 'not-array' as any), + ).resolves.toBe(0); + expect(action).toHaveBeenCalledWith(); + }); + + it('throws explicit errors when action id is unresolved', async () => { + setWebpackRequireRuntime({ + initializeExposesData: { + moduleMap: { + './actions': async () => () => ({ value: 'noop' }), + }, + }, + rscM: {}, + }); + + const bridgeExpose = await loadBridgeExposeModule(); + + await expect( + bridgeExpose.executeAction('missing-action-id', []), + ).rejects.toThrow(/Missing remote action for id "missing-action-id"/); + }); + + it('discovers action references from module cache exports', async () => { + const cachedAction = vi.fn(async () => 'cached') as { + (...args: unknown[]): Promise; + $$id?: string; + }; + cachedAction.$$id = 'raw-action-from-cache'; + + setWebpackRequireRuntime({ + initializeExposesData: { + moduleMap: {}, + }, + c: { + cachedModule: { + exports: { + nested: { + cachedAction, + }, + }, + }, + }, + rscM: {}, + }); + + const bridgeExpose = await loadBridgeExposeModule(); + await expect( + bridgeExpose.executeAction('raw-action-from-cache', []), + ).resolves.toBe('cached'); + }); +}); diff --git a/packages/modernjs-v3/src/runtime/rsc-bridge-expose.ts b/packages/modernjs-v3/src/runtime/rsc-bridge-expose.ts new file mode 100644 index 000000000000..ee57a8f76c5f --- /dev/null +++ b/packages/modernjs-v3/src/runtime/rsc-bridge-expose.ts @@ -0,0 +1,172 @@ +type ManifestLike = { + serverManifest?: Record; + clientManifest?: Record; + serverConsumerModuleMap?: Record; +}; + +type ExposeModuleMap = Record< + string, + () => Promise<(() => unknown) | unknown> | (() => unknown) | unknown +>; + +type WebpackRequireRuntime = { + initializeExposesData?: { + moduleMap?: ExposeModuleMap; + }; + m?: Record; + c?: Record; + rscM?: ManifestLike; +}; + +declare const __webpack_require__: WebpackRequireRuntime; + +const BRIDGE_EXPOSE_NAME = './__rspack_rsc_bridge__'; +const actionReferenceCache: Record unknown> = + Object.create(null); +let scanPromise: Promise | null = null; +let scanHadErrors = false; + +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null; +const isFunction = (value: unknown): value is (...args: unknown[]) => unknown => + typeof value === 'function'; +const isWebpackRequireRuntime = ( + value: unknown, +): value is WebpackRequireRuntime => isObject(value) || isFunction(value); +const runtimeRequireFromWrapper: WebpackRequireRuntime | undefined = (() => { + try { + // Access the module-wrapper require function when available. + // biome-ignore lint/security/noGlobalEval: The bundler module wrapper exposes `require` via arguments only. + const wrapperRequire = eval('arguments[2]'); + return isWebpackRequireRuntime(wrapperRequire) + ? (wrapperRequire as WebpackRequireRuntime) + : undefined; + } catch { + return undefined; + } +})(); + +const getWebpackRequire = (): WebpackRequireRuntime => { + if (runtimeRequireFromWrapper) { + return runtimeRequireFromWrapper; + } + + if ( + typeof __webpack_require__ !== 'undefined' && + isWebpackRequireRuntime(__webpack_require__) + ) { + return __webpack_require__; + } + + const runtime = ( + globalThis as typeof globalThis & { + __webpack_require__?: WebpackRequireRuntime; + } + ).__webpack_require__; + if (!isWebpackRequireRuntime(runtime)) { + throw new Error( + '[modern-js-v3:rsc-bridge] __webpack_require__ is unavailable while evaluating the internal bridge expose', + ); + } + + return runtime as WebpackRequireRuntime; +}; + +const cacheActionReferencesFromExports = ( + exposedValue: unknown, + visited: WeakSet, +) => { + if ( + typeof exposedValue === 'function' && + typeof (exposedValue as { $$id?: unknown }).$$id === 'string' + ) { + actionReferenceCache[(exposedValue as { $$id: string }).$$id] = + exposedValue as (...args: unknown[]) => unknown; + } + + if (!isObject(exposedValue)) { + return; + } + if (visited.has(exposedValue)) { + return; + } + visited.add(exposedValue); + + for (const value of Object.values(exposedValue)) { + cacheActionReferencesFromExports(value, visited); + } +}; + +const scanExposedModulesForActions = async () => { + if (scanPromise) { + await scanPromise; + if (scanHadErrors || Object.keys(actionReferenceCache).length === 0) { + scanPromise = null; + scanHadErrors = false; + } + return; + } + + scanHadErrors = false; + scanPromise = (async () => { + let hadExposeScanError = false; + const webpackRequire = getWebpackRequire(); + const moduleMap = webpackRequire.initializeExposesData?.moduleMap; + if (isObject(moduleMap)) { + for (const [exposeName, getFactory] of Object.entries(moduleMap)) { + if ( + exposeName === BRIDGE_EXPOSE_NAME || + typeof getFactory !== 'function' + ) { + continue; + } + + try { + const maybeFactory = await getFactory(); + const exportsValue = + typeof maybeFactory === 'function' ? maybeFactory() : maybeFactory; + cacheActionReferencesFromExports(exportsValue, new WeakSet()); + if (isObject(exportsValue) && isObject(exportsValue.default)) { + cacheActionReferencesFromExports( + exportsValue.default, + new WeakSet(), + ); + } + } catch { + hadExposeScanError = true; + // Ignore expose modules that fail to execute during scan. + } + } + } + + if (isObject(webpackRequire.c)) { + for (const moduleRecord of Object.values(webpackRequire.c)) { + const moduleExports = isObject(moduleRecord) + ? (moduleRecord as { exports?: unknown }).exports + : undefined; + cacheActionReferencesFromExports(moduleExports, new WeakSet()); + } + } + + scanHadErrors = hadExposeScanError; + })(); + + await scanPromise; + if (scanHadErrors || Object.keys(actionReferenceCache).length === 0) { + scanPromise = null; + scanHadErrors = false; + } +}; + +export const getManifest = () => getWebpackRequire().rscM; + +export const executeAction = async (actionId: string, args: unknown[]) => { + await scanExposedModulesForActions(); + const action = actionReferenceCache[actionId]; + if (typeof action !== 'function') { + throw new Error( + `[modern-js-v3:rsc-bridge] Missing remote action for id "${actionId}". Ensure it is reachable from a federated expose.`, + ); + } + return action(...(Array.isArray(args) ? args : [])); +}; diff --git a/packages/modernjs-v3/src/runtime/rsc-client-callback-bootstrap.js b/packages/modernjs-v3/src/runtime/rsc-client-callback-bootstrap.js new file mode 100644 index 000000000000..99435c60a9c6 --- /dev/null +++ b/packages/modernjs-v3/src/runtime/rsc-client-callback-bootstrap.js @@ -0,0 +1,355 @@ +import { setResolveActionId } from '@modern-js/runtime/rsc/client'; +import { + createFromFetch, + createTemporaryReferenceSet, + encodeReply, + setServerCallback, +} from 'react-server-dom-rspack/client.browser'; + +const ACTION_PREFIX = 'remote:'; +const ACTION_REMAP_GLOBAL_KEY = '__MODERN_RSC_MF_ACTION_ID_MAP__'; +const ACTION_REMAP_WAITERS_KEY = '__MODERN_RSC_MF_ACTION_ID_MAP_WAITERS__'; +const ACTION_REMAP_WAIT_TIMEOUT_MS = 3000; +const CALLBACK_INSTALL_RETRY_DELAY_MS = 50; +const MAX_CALLBACK_INSTALL_ATTEMPTS = 120; +const CALLBACK_CHUNK_LOADER_HOOK_FLAG = '__MODERN_RSC_MF_CALLBACK_HOOKED__'; +let hasResolvedFallbackAlias = false; +let fallbackRemoteAlias; +let callbackInstallAttempts = 0; +const installedClientBrowserRuntimes = new WeakSet(); + +function isObject(value) { + return typeof value === 'object' && value !== null; +} + +function isFunction(value) { + return typeof value === 'function'; +} + +const runtimeRequireFromWrapper = (() => { + try { + // Access the module-wrapper require function when available. + // biome-ignore lint/security/noGlobalEval: The bundler module wrapper exposes `require` via arguments only. + const wrapperRequire = eval('arguments[2]'); + if (isFunction(wrapperRequire) || isObject(wrapperRequire)) { + return wrapperRequire; + } + } catch {} + return undefined; +})(); + +function isClientBrowserRuntime(value) { + return ( + isObject(value) && + isFunction(value.setServerCallback) && + isFunction(value.createTemporaryReferenceSet) && + isFunction(value.encodeReply) && + isFunction(value.createFromFetch) + ); +} + +function getWebpackRequire() { + if (runtimeRequireFromWrapper) { + return runtimeRequireFromWrapper; + } + if (typeof __webpack_require__ !== 'undefined') { + return __webpack_require__; + } + const runtime = globalThis.__webpack_require__; + if (isFunction(runtime) || isObject(runtime)) { + return runtime; + } + return undefined; +} + +function getActionRemapMap() { + const map = globalThis[ACTION_REMAP_GLOBAL_KEY]; + if (!isObject(map)) { + globalThis[ACTION_REMAP_GLOBAL_KEY] = Object.create(null); + return globalThis[ACTION_REMAP_GLOBAL_KEY]; + } + return map; +} + +function resolveFallbackRemoteAlias() { + if (hasResolvedFallbackAlias) { + return fallbackRemoteAlias; + } + + const webpackRequire = getWebpackRequire(); + const runtimeInstance = + webpackRequire && + isObject(webpackRequire.federation) && + isObject(webpackRequire.federation.instance) + ? webpackRequire.federation.instance + : undefined; + if (!runtimeInstance) { + return undefined; + } + + const aliasSet = new Set(); + const remotes = Array.isArray(runtimeInstance.options?.remotes) + ? runtimeInstance.options.remotes + : []; + for (const remote of remotes) { + if (isObject(remote)) { + const alias = + typeof remote.alias === 'string' && remote.alias + ? remote.alias + : typeof remote.name === 'string' && remote.name + ? remote.name + : undefined; + if (alias) { + aliasSet.add(alias); + } + } + } + + const idToRemoteMap = runtimeInstance.remoteHandler?.idToRemoteMap; + if (isObject(idToRemoteMap)) { + for (const remoteInfo of Object.values(idToRemoteMap)) { + if (!isObject(remoteInfo)) { + continue; + } + const name = + typeof remoteInfo.name === 'string' && remoteInfo.name + ? remoteInfo.name + : undefined; + if (name) { + aliasSet.add(name); + } + } + } + + if (aliasSet.size === 1) { + fallbackRemoteAlias = Array.from(aliasSet)[0]; + hasResolvedFallbackAlias = true; + return fallbackRemoteAlias; + } + + if (aliasSet.size === 0 && !globalThis.window) { + return undefined; + } + + if (globalThis.window) { + const containerAliases = Object.keys(globalThis.window).filter(alias => { + const candidate = globalThis.window[alias]; + return ( + isObject(candidate) && + isFunction(candidate.get) && + isFunction(candidate.init) + ); + }); + if (aliasSet.size === 0 && containerAliases.length === 1) { + fallbackRemoteAlias = containerAliases[0]; + hasResolvedFallbackAlias = true; + return fallbackRemoteAlias; + } + if (aliasSet.size === 0 && containerAliases.length === 0) { + return undefined; + } + } + + fallbackRemoteAlias = undefined; + hasResolvedFallbackAlias = true; + return undefined; +} + +function getActionRemapWaiters() { + const waiters = globalThis[ACTION_REMAP_WAITERS_KEY]; + if (!(waiters instanceof Map)) { + const nextWaiters = new Map(); + globalThis[ACTION_REMAP_WAITERS_KEY] = nextWaiters; + return nextWaiters; + } + return waiters; +} + +function resolveActionEndpoint() { + if (!globalThis.window) { + return '/'; + } + + const entryName = window.__MODERN_JS_ENTRY_NAME; + if (!entryName || entryName === 'main' || entryName === 'index') { + return '/'; + } + return `/${entryName}`; +} + +function waitForActionRemap(rawId) { + const waiters = getActionRemapWaiters(); + return new Promise(resolve => { + let settled = false; + const resolveOnce = value => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeoutHandle); + resolve(value); + }; + + const list = waiters.get(rawId) || []; + list.push(resolveOnce); + waiters.set(rawId, list); + + const timeoutHandle = setTimeout(() => { + const current = waiters.get(rawId) || []; + const next = current.filter(waiter => waiter !== resolveOnce); + if (next.length === 0) { + waiters.delete(rawId); + } else { + waiters.set(rawId, next); + } + resolveOnce(rawId); + }, ACTION_REMAP_WAIT_TIMEOUT_MS); + }); +} + +async function resolveActionId(id) { + if (typeof id === 'string' && id.startsWith(ACTION_PREFIX)) { + return id; + } + + const remapMap = getActionRemapMap(); + const remappedId = remapMap[id]; + if (typeof remappedId === 'string') { + return remappedId; + } + if (remappedId === false) { + return id; + } + + const fallbackAlias = resolveFallbackRemoteAlias(); + if (typeof fallbackAlias === 'string' && fallbackAlias) { + const prefixedId = `${ACTION_PREFIX}${fallbackAlias}:${id}`; + remapMap[id] = prefixedId; + return prefixedId; + } + + return waitForActionRemap(id); +} + +function createServerCallback(runtime) { + return async (id, args) => { + const actionId = await resolveActionId(id); + const temporaryReferences = runtime.createTemporaryReferenceSet(); + const body = await runtime.encodeReply(Array.isArray(args) ? args : [], { + temporaryReferences, + }); + const endpoint = resolveActionEndpoint(); + const response = await fetch(endpoint, { + method: 'POST', + headers: { + Accept: 'text/x-component', + 'x-rsc-action': actionId, + }, + body, + }); + + if (!response.ok) { + throw new Error( + `[modern-js-v3:rsc-bridge] callback request failed (${response.status}) for "${actionId}"`, + ); + } + + return runtime.createFromFetch(Promise.resolve(response), { + temporaryReferences, + }); + }; +} + +function collectClientBrowserRuntimes() { + const runtimes = []; + const maybePushRuntime = candidate => { + if (isClientBrowserRuntime(candidate)) { + runtimes.push(candidate); + } else if ( + isObject(candidate) && + isClientBrowserRuntime(candidate.default) + ) { + runtimes.push(candidate.default); + } + }; + + maybePushRuntime({ + createFromFetch, + createTemporaryReferenceSet, + encodeReply, + setServerCallback, + }); + + const webpackRequire = getWebpackRequire(); + const moduleCache = webpackRequire?.c; + if (isObject(moduleCache)) { + for (const moduleRecord of Object.values(moduleCache)) { + if (isObject(moduleRecord) && 'exports' in moduleRecord) { + maybePushRuntime(moduleRecord.exports); + } + } + } + + return runtimes; +} + +function installServerCallbacks() { + let installedCount = 0; + for (const runtime of collectClientBrowserRuntimes()) { + if (installedClientBrowserRuntimes.has(runtime)) { + continue; + } + installedClientBrowserRuntimes.add(runtime); + runtime.setServerCallback(createServerCallback(runtime)); + installedCount += 1; + } + return installedCount; +} + +function hookChunkLoaderInstall() { + const webpackRequire = getWebpackRequire(); + if (!webpackRequire || !isFunction(webpackRequire.e)) { + return; + } + + const chunkLoader = webpackRequire.e; + if (chunkLoader[CALLBACK_CHUNK_LOADER_HOOK_FLAG]) { + return; + } + + const wrappedChunkLoader = function (...args) { + const chunkLoadResult = chunkLoader.apply(this, args); + Promise.resolve(chunkLoadResult) + .catch(() => undefined) + .then(() => { + installServerCallbacks(); + }); + return chunkLoadResult; + }; + wrappedChunkLoader[CALLBACK_CHUNK_LOADER_HOOK_FLAG] = true; + webpackRequire.e = wrappedChunkLoader; +} + +function runInstallAttempt() { + hookChunkLoaderInstall(); + installServerCallbacks(); + callbackInstallAttempts += 1; + + if ( + callbackInstallAttempts >= MAX_CALLBACK_INSTALL_ATTEMPTS || + typeof setTimeout !== 'function' + ) { + return; + } + + setTimeout(runInstallAttempt, CALLBACK_INSTALL_RETRY_DELAY_MS); +} + +setResolveActionId(resolveActionId); + +runInstallAttempt(); +if (typeof queueMicrotask === 'function') { + queueMicrotask(() => { + installServerCallbacks(); + }); +} diff --git a/packages/modernjs-v3/src/server/asyncStartupLoader.ts b/packages/modernjs-v3/src/server/asyncStartupLoader.ts new file mode 100644 index 000000000000..d0d126f3b4d9 --- /dev/null +++ b/packages/modernjs-v3/src/server/asyncStartupLoader.ts @@ -0,0 +1,71 @@ +import path from 'path'; +import vm from 'vm'; +import type { BundleLoaderStrategy } from '@modern-js/server-core/node'; +import fs from 'fs-extra'; + +const ASYNC_NODE_STARTUP_CALL = + 'var __webpack_exports__ = __webpack_require__.x();'; + +const isPromiseLike = (value: unknown): value is Promise => + Boolean(value) && + (typeof value === 'object' || typeof value === 'function') && + 'then' in (value as Promise) && + typeof (value as Promise).then === 'function'; + +/** + * MF async-node startup loader strategy. + * Handles bundles built with Module Federation experiments.asyncStartup + * that use __webpack_require__.mfAsyncStartup for deferred initialization. + * Patches the async startup call to run synchronously when the default + * require path returns a resolved promise with undefined. + */ +export const mfAsyncStartupLoaderStrategy: BundleLoaderStrategy = async ( + filepath, + context, +): Promise => { + try { + const bundleCode = await fs.readFile(filepath, 'utf-8'); + + if ( + !bundleCode.includes(ASYNC_NODE_STARTUP_CALL) || + !bundleCode.includes('__webpack_require__.mfAsyncStartup') + ) { + return undefined; + } + + const patchedCode = bundleCode.replace( + ASYNC_NODE_STARTUP_CALL, + 'var __webpack_exports__ = __webpack_require__.x({}, []);', + ); + + const localModule: { exports: unknown } = { exports: {} }; + const wrapped = `(function(exports, require, module, __filename, __dirname){${patchedCode}\n})`; + const runBundle = vm.runInThisContext(wrapped, { filename: filepath }) as ( + exports: unknown, + require: NodeJS.Require, + module: { exports: unknown }, + __filename: string, + __dirname: string, + ) => void; + + runBundle( + localModule.exports, + require, + localModule, + filepath, + path.dirname(filepath), + ); + + if (isPromiseLike(localModule.exports)) { + return await localModule.exports; + } + + return localModule.exports; + } catch (error) { + context?.monitors?.error( + '[MF] Load async startup bundle strategy failed, error = %s', + error instanceof Error ? error.stack || error.message : error, + ); + return undefined; + } +}; diff --git a/packages/modernjs-v3/src/server/fileCache.spec.ts b/packages/modernjs-v3/src/server/fileCache.spec.ts new file mode 100644 index 000000000000..bd5b4bd0a0bf --- /dev/null +++ b/packages/modernjs-v3/src/server/fileCache.spec.ts @@ -0,0 +1,29 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { FileCache } from './fileCache'; + +beforeAll(() => { + vi.mock('fs-extra', () => ({ + default: { + pathExists: () => { + return true; + }, + lstat: () => { + return { + mtimeMs: Date.now(), + size: 4, + }; + }, + readFile: () => { + return 'test'; + }, + }, + })); +}); + +describe('modern serve static file cache', async () => { + it('should cache file', async () => { + const cache = new FileCache(); + const result = await cache.getFile('test.txt'); + expect(result?.content).toBe('test'); + }); +}); diff --git a/packages/modernjs-v3/src/server/fileCache.ts b/packages/modernjs-v3/src/server/fileCache.ts new file mode 100644 index 000000000000..8fbbf2fa6432 --- /dev/null +++ b/packages/modernjs-v3/src/server/fileCache.ts @@ -0,0 +1,60 @@ +import fs from 'fs-extra'; +import { LRUCache } from 'lru-cache'; + +export interface FileResult { + content: string; + lastModified: number; +} + +export class FileCache { + private cache = new LRUCache({ + maxSize: 200 * 1024 * 1024, // 200MB + }); + + /** + * Check if file exists and return file info + * @param filepath Path to the file + * @returns FileResult or null if file doesn't exist + */ + async getFile(filepath: string): Promise { + // Check if file exists + if (!(await fs.pathExists(filepath))) { + return null; + } + + try { + const stat = await fs.lstat(filepath); + const currentModified = stat.mtimeMs; + + // Check if file is in cache and if the cached version is still valid + const cachedEntry = this.cache.get(filepath); + if (cachedEntry && currentModified <= cachedEntry.lastModified) { + return { + content: cachedEntry.content, + lastModified: cachedEntry.lastModified, + }; + } + + // Read file and update cache + const content = await fs.readFile(filepath, 'utf-8'); + const newEntry: FileResult = { + content, + lastModified: currentModified, + }; + + this.cache.set(filepath, newEntry, { + size: stat.size || content.length, + }); + + return { + content, + lastModified: currentModified, + }; + } catch (err) { + return null; + } + } +} + +// Export singleton instance +export const fileCache = new FileCache(); diff --git a/packages/modernjs-v3/src/server/index.ts b/packages/modernjs-v3/src/server/index.ts new file mode 100644 index 000000000000..cf5520401149 --- /dev/null +++ b/packages/modernjs-v3/src/server/index.ts @@ -0,0 +1,53 @@ +import { registerBundleLoaderStrategy } from '@modern-js/server-core/node'; +import type { ServerPlugin } from '@modern-js/server-runtime'; +import { mfAsyncStartupLoaderStrategy } from './asyncStartupLoader'; +import { + createCorsMiddleware, + createStaticMiddleware, +} from './staticMiddleware'; + +const staticServePlugin = (): ServerPlugin => ({ + name: '@modern-js/module-federation/server', + setup: api => { + registerBundleLoaderStrategy(mfAsyncStartupLoaderStrategy); + + api.onPrepare(() => { + // In development, we don't need to serve the manifest file, bundler dev server will handle it + if (process.env.NODE_ENV === 'development') { + return; + } + + const { middlewares } = api.getServerContext(); + const config = api.getServerConfig(); + + const assetPrefix = config.output?.assetPrefix || ''; + // When SSR is enabled, we need to serve the files in `bundle/` directory externally + // Modern.js will only serve the files in `static/` directory + if (config.server?.ssr) { + const context = api.getServerContext(); + const pwd = context.distDirectory!; + const serverStaticMiddleware = createStaticMiddleware({ + assetPrefix, + pwd, + }); + middlewares.push({ + name: 'module-federation-serve-manifest', + handler: serverStaticMiddleware, + }); + } + + // When the MODERN_MF_AUTO_CORS environment variable is set, the server will add CORS headers to the response + // This environment variable should only be set when running `serve` command in local test. + if (process.env.MODERN_MF_AUTO_CORS) { + const corsMiddleware = createCorsMiddleware(); + middlewares.push({ + name: 'module-federation-cors', + handler: corsMiddleware, + }); + } + }); + }, +}); + +export default staticServePlugin; +export { staticServePlugin }; diff --git a/packages/modernjs-v3/src/server/staticMiddleware.spec.ts b/packages/modernjs-v3/src/server/staticMiddleware.spec.ts new file mode 100644 index 000000000000..6896be814a46 --- /dev/null +++ b/packages/modernjs-v3/src/server/staticMiddleware.spec.ts @@ -0,0 +1,271 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createStaticMiddleware } from './staticMiddleware'; + +// Mock dependencies +vi.mock('fs-extra', () => ({ + default: { + pathExists: vi.fn(), + }, +})); + +vi.mock('./fileCache', () => ({ + fileCache: { + getFile: vi.fn(), + }, +})); + +import fs from 'fs-extra'; +import { fileCache } from './fileCache'; + +describe('staticMiddleware', () => { + let middleware: any; + let mockContext: any; + let nextSpy: any; + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks(); + + // Create middleware instance + middleware = createStaticMiddleware({ + assetPrefix: '', + pwd: '/test/path', + }); + + // Setup mock context + nextSpy = vi.fn(); + mockContext = { + req: { + path: '', + }, + header: vi.fn(), + body: vi.fn(), + }; + }); + + describe('file extension filtering', () => { + it('should call next() for non-js files', async () => { + mockContext.req.path = '/bundles/test.css'; + + await middleware(mockContext, nextSpy); + + expect(nextSpy).toHaveBeenCalledOnce(); + expect(mockContext.header).not.toHaveBeenCalled(); + expect(mockContext.body).not.toHaveBeenCalled(); + }); + + it('should call next() for files without extension', async () => { + mockContext.req.path = '/bundles/test'; + + await middleware(mockContext, nextSpy); + + expect(nextSpy).toHaveBeenCalledOnce(); + expect(mockContext.header).not.toHaveBeenCalled(); + expect(mockContext.body).not.toHaveBeenCalled(); + }); + + it('should process .js files', async () => { + mockContext.req.path = '/bundles/test.js'; + (fs.pathExists as any).mockResolvedValue(false); + + await middleware(mockContext, nextSpy); + + // Should not return early due to extension check + expect(fs.pathExists).toHaveBeenCalled(); + }); + }); + + describe('asset prefix filtering', () => { + it('should call next() for paths not starting with /bundles', async () => { + mockContext.req.path = '/assets/test.js'; + + await middleware(mockContext, nextSpy); + + expect(nextSpy).toHaveBeenCalledOnce(); + expect(fs.pathExists).not.toHaveBeenCalled(); + expect(mockContext.header).not.toHaveBeenCalled(); + expect(mockContext.body).not.toHaveBeenCalled(); + }); + + it('should call next() for paths that only share /bundles prefix', async () => { + mockContext.req.path = '/bundlesevil/test.js'; + + await middleware(mockContext, nextSpy); + + expect(nextSpy).toHaveBeenCalledOnce(); + expect(fs.pathExists).not.toHaveBeenCalled(); + expect(mockContext.header).not.toHaveBeenCalled(); + expect(mockContext.body).not.toHaveBeenCalled(); + }); + + it('should call next() for root path', async () => { + mockContext.req.path = '/test.js'; + + await middleware(mockContext, nextSpy); + + expect(nextSpy).toHaveBeenCalledOnce(); + expect(fs.pathExists).not.toHaveBeenCalled(); + }); + + it('should process paths starting with /bundles', async () => { + mockContext.req.path = '/bundles/test.js'; + (fs.pathExists as any).mockResolvedValue(false); + + await middleware(mockContext, nextSpy); + + // Should proceed to file existence check + expect(fs.pathExists).toHaveBeenCalledWith('/test/path/bundles/test.js'); + }); + }); + + describe('file existence check', () => { + it('should call next() when file does not exist', async () => { + mockContext.req.path = '/bundles/nonexistent.js'; + (fs.pathExists as any).mockResolvedValue(false); + + await middleware(mockContext, nextSpy); + + expect(fs.pathExists).toHaveBeenCalledWith( + '/test/path/bundles/nonexistent.js', + ); + expect(nextSpy).toHaveBeenCalledOnce(); + expect(fileCache.getFile).not.toHaveBeenCalled(); + expect(mockContext.header).not.toHaveBeenCalled(); + expect(mockContext.body).not.toHaveBeenCalled(); + }); + + it('should proceed to file cache when file exists', async () => { + mockContext.req.path = '/bundles/existing.js'; + (fs.pathExists as any).mockResolvedValue(true); + (fileCache.getFile as any).mockResolvedValue(null); + + await middleware(mockContext, nextSpy); + + expect(fs.pathExists).toHaveBeenCalledWith( + '/test/path/bundles/existing.js', + ); + expect(fileCache.getFile).toHaveBeenCalledWith( + '/test/path/bundles/existing.js', + ); + }); + }); + + describe('successful file serving', () => { + it('should serve file content with correct headers', async () => { + const mockFileContent = 'console.log("test");'; + const mockFileResult = { + content: mockFileContent, + lastModified: Date.now(), + }; + + mockContext.req.path = '/bundles/app.js'; + (fs.pathExists as any).mockResolvedValue(true); + (fileCache.getFile as any).mockResolvedValue(mockFileResult); + mockContext.body.mockReturnValue('response'); + + const result = await middleware(mockContext, nextSpy); + + expect(fs.pathExists).toHaveBeenCalledWith('/test/path/bundles/app.js'); + expect(fileCache.getFile).toHaveBeenCalledWith( + '/test/path/bundles/app.js', + ); + expect(nextSpy).not.toHaveBeenCalled(); + + // Check headers + expect(mockContext.header).toHaveBeenCalledWith( + 'Content-Type', + 'application/javascript', + ); + expect(mockContext.header).toHaveBeenCalledWith( + 'Content-Length', + String(Buffer.byteLength(mockFileResult.content)), + ); + + // Check response + expect(mockContext.body).toHaveBeenCalledWith( + mockFileResult.content, + 200, + ); + expect(result).toBe('response'); + }); + + it('should set content-length in bytes for unicode content', async () => { + const mockFileResult = { + content: 'ä½ ', + lastModified: Date.now(), + }; + + mockContext.req.path = '/bundles/unicode.js'; + (fs.pathExists as any).mockResolvedValue(true); + (fileCache.getFile as any).mockResolvedValue(mockFileResult); + mockContext.body.mockReturnValue('unicode-response'); + + await middleware(mockContext, nextSpy); + + expect(mockContext.header).toHaveBeenCalledWith( + 'Content-Length', + String(Buffer.byteLength(mockFileResult.content)), + ); + expect(nextSpy).not.toHaveBeenCalled(); + }); + + it('should handle empty file content', async () => { + const mockFileResult = { + content: '', + lastModified: Date.now(), + }; + + mockContext.req.path = '/bundles/empty.js'; + (fs.pathExists as any).mockResolvedValue(true); + (fileCache.getFile as any).mockResolvedValue(mockFileResult); + mockContext.body.mockReturnValue('empty-response'); + + const result = await middleware(mockContext, nextSpy); + + expect(mockContext.header).toHaveBeenCalledWith('Content-Length', '0'); + expect(mockContext.body).toHaveBeenCalledWith( + mockFileResult.content, + 200, + ); + expect(result).toBe('empty-response'); + expect(nextSpy).not.toHaveBeenCalled(); + }); + }); + + describe('asset prefix handling', () => { + it('should handle custom asset prefix correctly', async () => { + const customMiddleware = createStaticMiddleware({ + assetPrefix: '/custom-prefix', + pwd: '/test/path', + }); + + mockContext.req.path = '/bundles/test.js'; + await customMiddleware(mockContext, nextSpy); + + expect(nextSpy).toHaveBeenCalledOnce(); + expect(mockContext.header).not.toHaveBeenCalled(); + expect(mockContext.body).not.toHaveBeenCalled(); + }); + + it('should handle asset prefix removal correctly', async () => { + const customMiddleware = createStaticMiddleware({ + assetPrefix: '/prefix', + pwd: '/test/path', + }); + + const mockFileResult = { + content: 'test content', + lastModified: Date.now(), + }; + + mockContext.req.path = '/prefix/bundles/test.js'; + (fs.pathExists as any).mockResolvedValue(true); + (fileCache.getFile as any).mockResolvedValue(mockFileResult); + + await customMiddleware(mockContext, nextSpy); + + // Should remove prefix from path + expect(fs.pathExists).toHaveBeenCalledWith('/test/path/bundles/test.js'); + }); + }); +}); diff --git a/packages/modernjs-v3/src/server/staticMiddleware.ts b/packages/modernjs-v3/src/server/staticMiddleware.ts new file mode 100644 index 000000000000..101747d7f52b --- /dev/null +++ b/packages/modernjs-v3/src/server/staticMiddleware.ts @@ -0,0 +1,94 @@ +import path from 'node:path'; +import type { MiddlewareHandler } from '@modern-js/server-runtime'; +import fs from 'fs-extra'; +import { fileCache } from './fileCache'; + +const bundlesAssetPrefix = '/bundles'; +// Remove domain name from assetPrefix if it exists +// and remove trailing slash if it exists, if the url is a single slash, return it as empty string +const removeHost = (url: string): string => { + try { + // Extract pathname + const hasProtocol = url.includes('://'); + const hasDomain = hasProtocol || url.startsWith('//'); + const pathname = hasDomain + ? new URL(hasProtocol ? url : `http:${url}`).pathname + : url; + + return pathname; + } catch (e) { + return url; + } +}; + +const createStaticMiddleware = (options: { + assetPrefix: string; + pwd: string; +}): MiddlewareHandler => { + const { assetPrefix, pwd } = options; + const bundlesRootDir = path.resolve(pwd, `.${bundlesAssetPrefix}`); + + return async (c, next) => { + const pathname = c.req.path; + const extension = path.extname(pathname); + + // Only serve assets required by server-side federation runtime. + if (extension !== '.js' && extension !== '.json') { + return next(); + } + + const prefixWithoutHost = removeHost(assetPrefix); + const prefixWithBundle = path.posix.join( + prefixWithoutHost || '/', + bundlesAssetPrefix, + ); + const isBundleRequest = + pathname === prefixWithBundle || + pathname.startsWith(`${prefixWithBundle}/`); + // Skip if the request is not for asset prefix + `/bundles` + if (!isBundleRequest) { + return next(); + } + + const pathnameWithoutPrefix = pathname.slice(prefixWithBundle.length); + const relativeBundlePath = pathnameWithoutPrefix.replace(/^\/+/, ''); + const filepath = path.resolve(bundlesRootDir, relativeBundlePath); + const allowedPrefix = `${bundlesRootDir}${path.sep}`; + if (filepath !== bundlesRootDir && !filepath.startsWith(allowedPrefix)) { + return next(); + } + if (!(await fs.pathExists(filepath))) { + return next(); + } + + const fileResult = await fileCache.getFile(filepath); + if (!fileResult) { + return next(); + } + + c.header( + 'Content-Type', + extension === '.json' ? 'application/json' : 'application/javascript', + ); + c.header('Content-Length', String(Buffer.byteLength(fileResult.content))); + return c.body(fileResult.content, 200); + }; +}; + +const createCorsMiddleware = (): MiddlewareHandler => { + return async (c, next) => { + const pathname = c.req.path; + // If the request is only for a static file + if (path.extname(pathname)) { + c.header('Access-Control-Allow-Origin', '*'); + c.header( + 'Access-Control-Allow-Methods', + 'GET, POST, PUT, DELETE, PATCH, OPTIONS', + ); + c.header('Access-Control-Allow-Headers', '*'); + } + return next(); + }; +}; + +export { createStaticMiddleware, createCorsMiddleware }; diff --git a/packages/modernjs-v3/src/ssr-runtime/SSRLiveReload.tsx b/packages/modernjs-v3/src/ssr-runtime/SSRLiveReload.tsx new file mode 100644 index 000000000000..76f65b356554 --- /dev/null +++ b/packages/modernjs-v3/src/ssr-runtime/SSRLiveReload.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +export function SSRLiveReload() { + if (process.env.NODE_ENV !== 'development') { + return null; + } + return ( +