diff --git a/apps/next-app-router/next-app-router-4000/app/remote-button.tsx b/apps/next-app-router/next-app-router-4000/app/remote-button.tsx
new file mode 100644
index 00000000000..7745e9149c1
--- /dev/null
+++ b/apps/next-app-router/next-app-router-4000/app/remote-button.tsx
@@ -0,0 +1,23 @@
+'use client';
+
+import React, { Suspense } from 'react';
+
+const Button = React.lazy(() => import('remote_4001/Button'));
+
+export default function RemoteButton({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/next-app-router/next-app-router-4000/app/styling/styled-jsx/layout.tsx b/apps/next-app-router/next-app-router-4000/app/styling/styled-jsx/layout.tsx
index 0072c517b27..7c1165a0e82 100644
--- a/apps/next-app-router/next-app-router-4000/app/styling/styled-jsx/layout.tsx
+++ b/apps/next-app-router/next-app-router-4000/app/styling/styled-jsx/layout.tsx
@@ -1,5 +1,7 @@
import StyledJsxRegistry from './registry';
+export const dynamic = 'force-dynamic';
+
export default function Layout({ children }: { children: React.ReactNode }) {
return
;
}
diff --git a/apps/next-app-router/next-app-router-4000/app/styling/styled-jsx/page.tsx b/apps/next-app-router/next-app-router-4000/app/styling/styled-jsx/page.tsx
index 60f514a285c..d0d7ecdd167 100644
--- a/apps/next-app-router/next-app-router-4000/app/styling/styled-jsx/page.tsx
+++ b/apps/next-app-router/next-app-router-4000/app/styling/styled-jsx/page.tsx
@@ -1,5 +1,7 @@
'use client';
+export const dynamic = 'force-dynamic';
+
const SkeletonCard = () => (
<>
diff --git a/apps/next-app-router/next-app-router-4000/cypress/e2e/app.cy.ts b/apps/next-app-router/next-app-router-4000/cypress/e2e/app.cy.ts
index e281a0070b0..440aa42bed1 100644
--- a/apps/next-app-router/next-app-router-4000/cypress/e2e/app.cy.ts
+++ b/apps/next-app-router/next-app-router-4000/cypress/e2e/app.cy.ts
@@ -1,39 +1,28 @@
-import { getH1 } from '../support/app.po';
-
-describe('next-app-router-4000', () => {
+describe('next-app-router-4000 federation host', () => {
beforeEach(() => cy.visit('/'));
- describe('Home page', () => {
- it('should display examples heading', () => {
- getH1().contains('Examples');
- });
-
- it('should have remote button from module federation', () => {
- cy.get('button').contains('Button from remote').should('exist');
- });
-
- it('should have navigation links', () => {
- cy.get('a[href="/layouts"]').should('exist');
- cy.get('a[href="/error-handling"]').should('exist');
- cy.get('a[href="/loading"]').should('exist');
- });
+ it('renders app shell and remote button on the home route', () => {
+ cy.contains('h1', 'Examples').should('be.visible');
+ cy.contains('button', 'Button from remote').should('be.visible');
+ cy.get('a[href="/layouts"]').should('exist');
+ cy.get('a[href="/parallel-routes"]').should('exist');
});
- describe('Navigation', () => {
- it('should navigate to layouts page', () => {
- cy.get('a[href="/layouts"]').first().click();
- cy.url().should('include', '/layouts');
- });
-
- it('should navigate to parallel routes', () => {
- cy.get('a[href="/parallel-routes"]').first().click();
- cy.url().should('include', '/parallel-routes');
- });
+ it('keeps federated remote components working after app-router navigation', () => {
+ cy.contains('a', 'Client Context').click();
+ cy.url().should('include', '/context');
+ cy.contains('button', 'testing').should('be.visible');
+ cy.contains('button', '0 Clicks').click();
+ cy.contains('button', '1 Clicks').should('be.visible');
});
- describe('Module Federation', () => {
- it('should load remote button component', () => {
- cy.get('button').contains('Button from remote').should('be.visible');
- });
+ it('can fetch the 4001 remote container from the host test run', () => {
+ cy.request('http://localhost:4001/_next/static/chunks/remoteEntry.js').then(
+ ({ status, body }) => {
+ expect(status).to.eq(200);
+ expect(body).to.include('./Button');
+ expect(body).to.include('./GlobalNav');
+ },
+ );
});
});
diff --git a/apps/next-app-router/next-app-router-4000/next-env.d.ts b/apps/next-app-router/next-app-router-4000/next-env.d.ts
index 40c3d68096c..1511519d389 100644
--- a/apps/next-app-router/next-app-router-4000/next-env.d.ts
+++ b/apps/next-app-router/next-app-router-4000/next-env.d.ts
@@ -1,5 +1,6 @@
///
///
+import './.next/types/routes.d.ts';
// NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/next-app-router/next-app-router-4000/next.config.js b/apps/next-app-router/next-app-router-4000/next.config.js
index ce678e2fa1c..28e2b0ace30 100644
--- a/apps/next-app-router/next-app-router-4000/next.config.js
+++ b/apps/next-app-router/next-app-router-4000/next.config.js
@@ -1,70 +1,47 @@
-const { NextFederationPlugin } = require('@module-federation/nextjs-mf');
+process.env.FEDERATION_WEBPACK_PATH =
+ process.env.FEDERATION_WEBPACK_PATH ||
+ require.resolve('next/dist/compiled/webpack/webpack-lib');
+
+const { withNextFederation } = require('@module-federation/nextjs-mf');
/** @type {import('next').NextConfig} */
-const nextConfig = {
+const baseConfig = {
typescript: {
ignoreBuildErrors: true,
},
- eslint: {
- ignoreDuringBuilds: true,
- },
- port: 4000,
- webpack(config, options) {
- const { isServer } = options;
+ webpack(config) {
config.watchOptions = {
ignored: ['**/node_modules/**', '**/@mf-types/**'],
};
- // used for testing build output snapshots
- const remotes = {
- remote_4001: `remote_4001@http://localhost:4001/_next/static/${
- isServer ? 'ssr' : 'chunks'
- }/remoteEntry.js`,
- checkout: `checkout@http://localhost:4000/_next/static/${
- isServer ? 'ssr' : 'chunks'
- }/remoteEntry.js`,
- home_app: `home_app@http://localhost:4000/_next/static/${
- isServer ? 'ssr' : 'chunks'
- }/remoteEntry.js`,
- shop: `shop@http://localhost:4000/_next/static/${
- isServer ? 'ssr' : 'chunks'
- }/remoteEntry.js`,
- };
- config.plugins.push(
- new NextFederationPlugin({
- name: 'home_app',
- filename: 'static/chunks/remoteEntry.js',
- remotes: {
- remote_4001: remotes.remote_4001,
- shop: remotes.shop,
- checkout: remotes.checkout,
- },
- shared: {
- // 'react': {
- // singleton: true,
- // requiredVersion: false
- // },
- // 'react-dom': {
- // singleton: true,
- // requiredVersion: false
- // }
- },
- extraOptions: {
- // debug: false,
- // exposePages: true,
- // enableImageLoaderFix: true,
- // enableUrlLoaderFix: true,
- },
- }),
- );
- config.plugins.push({
- name: 'xxx',
- apply(compiler) {
- compiler.options.devtool = false;
- },
- });
return config;
},
};
-module.exports = nextConfig;
+module.exports = withNextFederation(baseConfig, {
+ name: 'home_app',
+ mode: 'app',
+ filename: 'static/chunks/remoteEntry.js',
+ remotes: ({ isServer }) => ({
+ remote_4001: `remote_4001@http://localhost:4001/_next/static/${
+ isServer ? 'ssr' : 'chunks'
+ }/remoteEntry.js`,
+ shop: `shop@http://localhost:4000/_next/static/${
+ isServer ? 'ssr' : 'chunks'
+ }/remoteEntry.js`,
+ checkout: `checkout@http://localhost:4000/_next/static/${
+ isServer ? 'ssr' : 'chunks'
+ }/remoteEntry.js`,
+ }),
+ app: {
+ enableClientComponents: true,
+ enableRsc: true,
+ },
+ dts: false,
+ runtime: {
+ onRemoteFailure: 'null-fallback',
+ },
+ diagnostics: {
+ level: 'warn',
+ },
+});
diff --git a/apps/next-app-router/next-app-router-4000/package.json b/apps/next-app-router/next-app-router-4000/package.json
index 214dfb1d2df..28650b674eb 100644
--- a/apps/next-app-router/next-app-router-4000/package.json
+++ b/apps/next-app-router/next-app-router-4000/package.json
@@ -2,8 +2,8 @@
"name": "app-router-4000",
"private": true,
"scripts": {
- "build": "next build",
- "dev": "NEXT_PRIVATE_LOCAL_WEBPACK=true next dev -p 4000",
+ "build": "next build --webpack",
+ "dev": "next dev --webpack -p 4000",
"lint": "next lint",
"lint-staged": "lint-staged",
"prettier": "prettier --write --ignore-unknown .",
diff --git a/apps/next-app-router/next-app-router-4000/project.json b/apps/next-app-router/next-app-router-4000/project.json
index b25d7b49323..0bb7fd40844 100644
--- a/apps/next-app-router/next-app-router-4000/project.json
+++ b/apps/next-app-router/next-app-router-4000/project.json
@@ -9,7 +9,7 @@
"executor": "nx:run-commands",
"defaultConfiguration": "production",
"options": {
- "command": "npx next build",
+ "command": "npx next build --webpack",
"cwd": "apps/next-app-router/next-app-router-4000"
},
"configurations": {
@@ -37,38 +37,35 @@
]
},
"lint": {
- "executor": "nx:run-commands",
+ "executor": "@nx/eslint:lint",
+ "outputs": ["{options.outputFile}"],
"options": {
- "command": "pnpm exec eslint apps/next-app-router/next-app-router-4000/**/*.{ts,tsx,js,jsx}",
- "forwardAllArgs": false
+ "lintFilePatterns": ["apps/next-app-router-4000/**/*.{ts,tsx,js,jsx}"]
}
},
"e2e": {
- "executor": "nx:run-commands",
+ "executor": "@nx/cypress:cypress",
"options": {
- "command": "pnpm exec cypress run --project apps/next-app-router/next-app-router-4000 --e2e --config baseUrl=http://localhost:4000 --key 27e40c91-5ac3-4433-8a87-651d10f51cf6",
- "forwardAllArgs": false
+ "cypressConfig": "apps/next-app-router/next-app-router-4000/cypress.config.ts",
+ "testingType": "e2e",
+ "baseUrl": "http://localhost:4000",
+ "key": "27e40c91-5ac3-4433-8a87-651d10f51cf6"
},
"configurations": {
"production": {
- "command": "pnpm exec cypress run --project apps/next-app-router/next-app-router-4000 --e2e --config baseUrl=http://localhost:4000 --key 27e40c91-5ac3-4433-8a87-651d10f51cf6"
+ "devServerTarget": "next-app-router-4000:serve:production"
}
}
},
"test:e2e": {
"executor": "nx:run-commands",
"options": {
- "parallel": true,
- "commands": [
- {
- "command": "lsof -i :4000 || nx run next-app-router-4000:serve",
- "forwardAllArgs": false
- },
- {
- "command": "sleep 4 && nx run next-app-router-4000:e2e",
- "forwardAllArgs": true
- }
- ]
+ "command": "node tools/scripts/run-next-app-router-e2e.mjs --mode=dev"
+ },
+ "configurations": {
+ "production": {
+ "command": "node tools/scripts/run-next-app-router-e2e.mjs --mode=prod"
+ }
}
}
}
diff --git a/apps/next-app-router/next-app-router-4000/tsconfig.json b/apps/next-app-router/next-app-router-4000/tsconfig.json
index 399d25eda35..7567ee7e416 100644
--- a/apps/next-app-router/next-app-router-4000/tsconfig.json
+++ b/apps/next-app-router/next-app-router-4000/tsconfig.json
@@ -12,7 +12,7 @@
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"incremental": true,
"baseUrl": ".",
"paths": {
@@ -24,6 +24,12 @@
}
]
},
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
"exclude": ["node_modules"]
}
diff --git a/apps/next-app-router/next-app-router-4000/ui/buggy-button.tsx b/apps/next-app-router/next-app-router-4000/ui/buggy-button.tsx
index 3a0906605d6..3166ca69eb7 100644
--- a/apps/next-app-router/next-app-router-4000/ui/buggy-button.tsx
+++ b/apps/next-app-router/next-app-router-4000/ui/buggy-button.tsx
@@ -1,7 +1,8 @@
'use client';
-import Button from 'remote_4001/Button';
-import React from 'react';
+import React, { Suspense } from 'react';
+
+const Button = React.lazy(() => import('remote_4001/Button'));
export default function BuggyButton() {
const [clicked, setClicked] = React.useState(false);
@@ -11,13 +12,21 @@ export default function BuggyButton() {
}
return (
-
+ }
>
- Trigger Error
-
+
+
);
}
diff --git a/apps/next-app-router/next-app-router-4000/ui/rendered-time-ago.tsx b/apps/next-app-router/next-app-router-4000/ui/rendered-time-ago.tsx
index d7c78d1c6b8..a0019063160 100644
--- a/apps/next-app-router/next-app-router-4000/ui/rendered-time-ago.tsx
+++ b/apps/next-app-router/next-app-router-4000/ui/rendered-time-ago.tsx
@@ -41,11 +41,7 @@ export function RenderedTimeAgo({ timestamp }: { timestamp: number }) {
>
{msAgo ? (
<>
-
+
{msAgo >= 1000 ? ms(msAgo) : '0s'}
{' '}
ago
diff --git a/apps/next-app-router/next-app-router-4001/app/demo/layout.tsx b/apps/next-app-router/next-app-router-4001/app/demo/layout.tsx
new file mode 100644
index 00000000000..d51b3a51b18
--- /dev/null
+++ b/apps/next-app-router/next-app-router-4001/app/demo/layout.tsx
@@ -0,0 +1,9 @@
+export const dynamic = 'force-dynamic';
+
+export default function DemoLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return children;
+}
diff --git a/apps/next-app-router/next-app-router-4001/app/demo/page.tsx b/apps/next-app-router/next-app-router-4001/app/demo/page.tsx
index 25a589d1919..a5b239268b9 100644
--- a/apps/next-app-router/next-app-router-4001/app/demo/page.tsx
+++ b/apps/next-app-router/next-app-router-4001/app/demo/page.tsx
@@ -1,90 +1,205 @@
//@ts-check
'use client';
-import dynamic from 'next/dynamic';
+import React, { Suspense } from 'react';
// Dynamically import remote components
-const Button = dynamic(() => import('remote_4001/Button'), { ssr: true });
-const Header = dynamic(() => import('remote_4001/Header'), { ssr: true });
-const ProductCard = dynamic(() => import('remote_4001/ProductCard'), {
- ssr: true,
-});
-const TabGroup = dynamic(() => import('remote_4001/TabGroup'), { ssr: true });
-const TabNavItem = dynamic(() => import('remote_4001/TabNavItem'), {
- ssr: true,
-});
-const CountUp = dynamic(() => import('remote_4001/CountUp'), { ssr: true });
-const RenderingInfo = dynamic(() => import('remote_4001/RenderingInfo'), {
- ssr: true,
-});
+const Button = React.lazy(() => import('remote_4001/Button'));
+const Header = React.lazy(() => import('remote_4001/Header'));
+const ProductCard = React.lazy(() =>
+ import('remote_4001/ProductCard').then((mod) => ({
+ default: mod.ProductCard,
+ })),
+);
+const TabGroup = React.lazy(() =>
+ import('remote_4001/TabGroup').then((mod) => ({
+ default: mod.TabGroup,
+ })),
+);
+const TabNavItem = React.lazy(() =>
+ import('remote_4001/TabNavItem').then((mod) => ({
+ default: mod.TabNavItem,
+ })),
+);
+const CountUp = React.lazy(() => import('remote_4001/CountUp'));
+const RenderingInfo = React.lazy(() =>
+ import('remote_4001/RenderingInfo').then((mod) => ({
+ default: mod.RenderingInfo,
+ })),
+);
+
+export const dynamic = 'force-dynamic';
export default function DemoPage() {
+ const [isMounted, setIsMounted] = React.useState(false);
+ const demoProduct = {
+ id: 'demo-product',
+ stock: 2,
+ rating: 4.7,
+ name: 'Demo Product',
+ description: 'A demo product for federated rendering.',
+ price: {
+ amount: 12900,
+ currency: {
+ code: 'USD',
+ base: 10,
+ exponent: 2,
+ },
+ scale: 2,
+ },
+ isBestSeller: true,
+ leadTime: 2,
+ image: 'grid.svg',
+ imageBlur:
+ 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=',
+ };
+
+ React.useEffect(() => {
+ setIsMounted(true);
+ }, []);
+
return (
-
-
Remote Components Demo
+
+ {isMounted ? (
+
+
+
+ ) : (
+
+ )}
+
+
Basic UI Components
-
-
+ {isMounted ? (
+
+
+
+
+
+ ) : (
+ <>
+
+
+
+ >
+ )}
Navigation Components
-
-
- Tab 1
-
-
- Tab 2
-
-
- Tab 3
-
-
+
+ {isMounted ? (
+
+
+
+ Demo Home
+
+ Details
+
+ Pricing
+
+
+ ) : (
+ <>
+
+
+ Overview
+
+
+ Details
+
+
+ Pricing
+
+
+
+
+ Demo Home
+
+
+ Details
+
+
+ Pricing
+
+
+ >
+ )}
+
Product Components
-
-
+ {isMounted ? (
+
+
+
+
+ ) : (
+ <>
+
+ Demo Product
+
+
+ Demo Pro
+
+ >
+ )}
- Interactive Components
-
-
-
Count Up Animation
-
-
-
-
Rendering Information
-
-
+
Rendering + Metrics
+
+ {isMounted ? (
+
+
+ Active users:
+
+
+
+
+
+ ) : (
+ <>
+
+ Active users: 128
+
+
+
+ Dynamically rendered at request time
+
+
+ >
+ )}
diff --git a/apps/next-app-router/next-app-router-4001/app/styling/styled-jsx/layout.tsx b/apps/next-app-router/next-app-router-4001/app/styling/styled-jsx/layout.tsx
index 0072c517b27..7c1165a0e82 100644
--- a/apps/next-app-router/next-app-router-4001/app/styling/styled-jsx/layout.tsx
+++ b/apps/next-app-router/next-app-router-4001/app/styling/styled-jsx/layout.tsx
@@ -1,5 +1,7 @@
import StyledJsxRegistry from './registry';
+export const dynamic = 'force-dynamic';
+
export default function Layout({ children }: { children: React.ReactNode }) {
return
{children};
}
diff --git a/apps/next-app-router/next-app-router-4001/app/styling/styled-jsx/page.tsx b/apps/next-app-router/next-app-router-4001/app/styling/styled-jsx/page.tsx
index 60f514a285c..d0d7ecdd167 100644
--- a/apps/next-app-router/next-app-router-4001/app/styling/styled-jsx/page.tsx
+++ b/apps/next-app-router/next-app-router-4001/app/styling/styled-jsx/page.tsx
@@ -1,5 +1,7 @@
'use client';
+export const dynamic = 'force-dynamic';
+
const SkeletonCard = () => (
<>
diff --git a/apps/next-app-router/next-app-router-4001/cypress/e2e/app.cy.ts b/apps/next-app-router/next-app-router-4001/cypress/e2e/app.cy.ts
index d74378f60a3..ddfade81a5b 100644
--- a/apps/next-app-router/next-app-router-4001/cypress/e2e/app.cy.ts
+++ b/apps/next-app-router/next-app-router-4001/cypress/e2e/app.cy.ts
@@ -1,27 +1,30 @@
-describe('next-app-router-4001', () => {
- beforeEach(() => cy.visit('/'));
-
- describe('Remote component (Button)', () => {
- it('should export Button component for module federation', () => {
- // This app serves as a remote that exports the Button component
- // Testing that the app loads successfully
- cy.visit('/');
- });
+describe('next-app-router-4001 federation remote', () => {
+ it('renders the app-router remote home page', () => {
+ cy.visit('/');
+ cy.contains('h1', 'Examples').should('be.visible');
});
- describe('Button component functionality', () => {
- it('should render default button', () => {
- // If there's a test page that renders the button
- cy.visit('/');
- // Check if the page loads without errors
- cy.get('body').should('exist');
- });
+ it('publishes remoteEntry with expected exposed modules', () => {
+ cy.request('/_next/static/chunks/remoteEntry.js').then(
+ ({ status, body }) => {
+ expect(status).to.eq(200);
+ expect(body).to.include('./Button');
+ expect(body).to.include('./Header');
+ expect(body).to.include('./Footer');
+ expect(body).to.include('./GlobalNav');
+ expect(body).to.include('./ProductCard');
+ expect(body).to.include('./TabGroup');
+ expect(body).to.include('./TabNavItem');
+ expect(body).to.include('./CountUp');
+ expect(body).to.include('./RenderingInfo');
+ },
+ );
});
- describe('Module Federation Remote', () => {
- it('should be accessible as a remote module', () => {
- // Verify the app is running and accessible
- cy.request('/').its('status').should('eq', 200);
- });
+ it('can self-consume exposed modules on the demo route', () => {
+ cy.visit('/demo');
+ cy.contains('h1', 'Remote Components Demo').should('be.visible');
+ cy.contains('button', 'Primary Button').should('be.visible');
+ cy.contains('h2', 'Navigation Components').should('be.visible');
});
});
diff --git a/apps/next-app-router/next-app-router-4001/next-env.d.ts b/apps/next-app-router/next-app-router-4001/next-env.d.ts
index 40c3d68096c..20e7bcfb039 100644
--- a/apps/next-app-router/next-app-router-4001/next-env.d.ts
+++ b/apps/next-app-router/next-app-router-4001/next-env.d.ts
@@ -1,5 +1,6 @@
///
///
+import './.next/dev/types/routes.d.ts';
// NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/next-app-router/next-app-router-4001/next.config.js b/apps/next-app-router/next-app-router-4001/next.config.js
index 8702442854c..c71b84b62b0 100644
--- a/apps/next-app-router/next-app-router-4001/next.config.js
+++ b/apps/next-app-router/next-app-router-4001/next.config.js
@@ -1,73 +1,52 @@
-const { NextFederationPlugin } = require('@module-federation/nextjs-mf');
+process.env.FEDERATION_WEBPACK_PATH =
+ process.env.FEDERATION_WEBPACK_PATH ||
+ require.resolve('next/dist/compiled/webpack/webpack-lib');
+
+const { withNextFederation } = require('@module-federation/nextjs-mf');
/** @type {import('next').NextConfig} */
-const nextConfig = {
+const baseConfig = {
typescript: {
ignoreBuildErrors: true,
},
- eslint: {
- ignoreDuringBuilds: true,
- },
- webpack(config, options) {
- const { isServer } = options;
+ webpack(config) {
config.watchOptions = {
ignored: ['**/node_modules/**', '**/@mf-types/**'],
};
- config.plugins.push(
- new NextFederationPlugin({
- name: 'remote_4001',
- filename: 'static/chunks/remoteEntry.js',
- exposes: {
- // Core UI Components
- './Button': './ui/button',
- // './Header': isServer ? './ui/header?rsc' : './ui/header?shared',
- './Footer': './ui/footer',
- // './GlobalNav(rsc)': isServer ? './ui/global-nav?rsc' : './ui/global-nav',
- // './GlobalNav(ssr)': isServer ? './ui/global-nav?ssr' : './ui/global-nav',
- './GlobalNav': './ui/global-nav',
- //
- // // Product Related Components
- // './ProductCard': './ui/product-card',
- // './ProductPrice': './ui/product-price',
- // './ProductRating': './ui/product-rating',
- // './ProductDeal': './ui/product-deal',
- //
- // // Navigation Components
- // './TabGroup': './ui/tab-group',
- // './TabNavItem': './ui/tab-nav-item',
- //
- // // Utility Components
- // './Boundary': './ui/boundary',
- // './CountUp': './ui/count-up',
- // './RenderedTimeAgo': './ui/rendered-time-ago',
- // './RenderingInfo': './ui/rendering-info'
- },
- shared: {
- // 'react': {
- // singleton: true,
- // requiredVersion: false
- // },
- // 'react-dom': {
- // singleton: true,
- // requiredVersion: false
- // }
- },
- extraOptions: {
- debug: false,
- exposePages: true,
- enableImageLoaderFix: true,
- enableUrlLoaderFix: true,
- },
- }),
- );
- config.plugins.push({
- name: 'xxx',
- apply(compiler) {
- compiler.options.devtool = false;
- },
- });
+
return config;
},
};
-module.exports = nextConfig;
+module.exports = withNextFederation(baseConfig, {
+ name: 'remote_4001',
+ mode: 'app',
+ filename: 'static/chunks/remoteEntry.js',
+ remotes: ({ isServer }) => ({
+ remote_4001: `remote_4001@http://localhost:4001/_next/static/${
+ isServer ? 'ssr' : 'chunks'
+ }/remoteEntry.js`,
+ }),
+ exposes: {
+ './Button': './ui/button',
+ './Header': './ui/header',
+ './Footer': './ui/footer',
+ './GlobalNav': './ui/global-nav',
+ './ProductCard': './ui/product-card',
+ './TabGroup': './ui/tab-group',
+ './TabNavItem': './ui/tab-nav-item',
+ './CountUp': './ui/count-up',
+ './RenderingInfo': './ui/rendering-info',
+ },
+ app: {
+ enableClientComponents: true,
+ enableRsc: true,
+ },
+ dts: false,
+ runtime: {
+ onRemoteFailure: 'null-fallback',
+ },
+ diagnostics: {
+ level: 'warn',
+ },
+});
diff --git a/apps/next-app-router/next-app-router-4001/package.json b/apps/next-app-router/next-app-router-4001/package.json
index fdf1757f167..f3cd0becc62 100644
--- a/apps/next-app-router/next-app-router-4001/package.json
+++ b/apps/next-app-router/next-app-router-4001/package.json
@@ -2,8 +2,8 @@
"private": true,
"name": "app-router-4001",
"scripts": {
- "build": "next build",
- "dev": "NEXT_PRIVATE_LOCAL_WEBPACK=true next dev -p 4001",
+ "build": "next build --webpack",
+ "dev": "next dev --webpack -p 4001",
"lint": "next lint",
"lint-staged": "lint-staged",
"prettier": "prettier --write --ignore-unknown .",
diff --git a/apps/next-app-router/next-app-router-4001/project.json b/apps/next-app-router/next-app-router-4001/project.json
index a37b642468a..bbcc32013a7 100644
--- a/apps/next-app-router/next-app-router-4001/project.json
+++ b/apps/next-app-router/next-app-router-4001/project.json
@@ -9,7 +9,7 @@
"executor": "nx:run-commands",
"defaultConfiguration": "production",
"options": {
- "command": "npx next build",
+ "command": "npx next build --webpack",
"cwd": "apps/next-app-router/next-app-router-4001"
},
"configurations": {
@@ -37,38 +37,35 @@
]
},
"lint": {
- "executor": "nx:run-commands",
+ "executor": "@nx/eslint:lint",
+ "outputs": ["{options.outputFile}"],
"options": {
- "command": "pnpm exec eslint apps/next-app-router/next-app-router-4001/**/*.{ts,tsx,js,jsx}",
- "forwardAllArgs": false
+ "lintFilePatterns": ["apps/next-app-router-4001/**/*.{ts,tsx,js,jsx}"]
}
},
"e2e": {
- "executor": "nx:run-commands",
+ "executor": "@nx/cypress:cypress",
"options": {
- "command": "pnpm exec cypress run --project apps/next-app-router/next-app-router-4001 --e2e --config baseUrl=http://localhost:4001 --key 27e40c91-5ac3-4433-8a87-651d10f51cf6",
- "forwardAllArgs": false
+ "cypressConfig": "apps/next-app-router/next-app-router-4001/cypress.config.ts",
+ "testingType": "e2e",
+ "baseUrl": "http://localhost:4001",
+ "key": "27e40c91-5ac3-4433-8a87-651d10f51cf6"
},
"configurations": {
"production": {
- "command": "pnpm exec cypress run --project apps/next-app-router/next-app-router-4001 --e2e --config baseUrl=http://localhost:4001 --key 27e40c91-5ac3-4433-8a87-651d10f51cf6"
+ "devServerTarget": "next-app-router-4001:serve:production"
}
}
},
"test:e2e": {
"executor": "nx:run-commands",
"options": {
- "parallel": true,
- "commands": [
- {
- "command": "lsof -i :4001 || nx run next-app-router-4001:serve",
- "forwardAllArgs": false
- },
- {
- "command": "sleep 4 && nx run next-app-router-4001:e2e",
- "forwardAllArgs": true
- }
- ]
+ "command": "node tools/scripts/run-next-app-router-e2e.mjs --mode=dev"
+ },
+ "configurations": {
+ "production": {
+ "command": "node tools/scripts/run-next-app-router-e2e.mjs --mode=prod"
+ }
}
}
}
diff --git a/apps/next-app-router/next-app-router-4001/tsconfig.json b/apps/next-app-router/next-app-router-4001/tsconfig.json
index 399d25eda35..7567ee7e416 100644
--- a/apps/next-app-router/next-app-router-4001/tsconfig.json
+++ b/apps/next-app-router/next-app-router-4001/tsconfig.json
@@ -12,7 +12,7 @@
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"incremental": true,
"baseUrl": ".",
"paths": {
@@ -24,6 +24,12 @@
}
]
},
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
"exclude": ["node_modules"]
}
diff --git a/apps/next-app-router/next-app-router-4001/ui/button.tsx b/apps/next-app-router/next-app-router-4001/ui/button.tsx
index 8aafa7e7a92..22d25cbafc0 100644
--- a/apps/next-app-router/next-app-router-4001/ui/button.tsx
+++ b/apps/next-app-router/next-app-router-4001/ui/button.tsx
@@ -1,3 +1,5 @@
+'use client';
+
import clsx from 'clsx';
export default function Button({
diff --git a/apps/next-app-router/next-app-router-4001/ui/rendered-time-ago.tsx b/apps/next-app-router/next-app-router-4001/ui/rendered-time-ago.tsx
index d7c78d1c6b8..a0019063160 100644
--- a/apps/next-app-router/next-app-router-4001/ui/rendered-time-ago.tsx
+++ b/apps/next-app-router/next-app-router-4001/ui/rendered-time-ago.tsx
@@ -41,11 +41,7 @@ export function RenderedTimeAgo({ timestamp }: { timestamp: number }) {
>
{msAgo ? (
<>
-
+
{msAgo >= 1000 ? ms(msAgo) : '0s'}
{' '}
ago
diff --git a/packages/dts-plugin/src/plugins/GenerateTypesPlugin.ts b/packages/dts-plugin/src/plugins/GenerateTypesPlugin.ts
index d103436ff60..c29cd580a29 100644
--- a/packages/dts-plugin/src/plugins/GenerateTypesPlugin.ts
+++ b/packages/dts-plugin/src/plugins/GenerateTypesPlugin.ts
@@ -169,9 +169,13 @@ export class GenerateTypesPlugin implements WebpackPluginInstance {
!compilation.getAsset(zipName) &&
fs.existsSync(zipTypesPath)
) {
+ const webpackSources =
+ compiler.webpack?.sources ||
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ require('webpack').sources;
compilation.emitAsset(
zipName,
- new compiler.webpack.sources.RawSource(
+ new webpackSources.RawSource(
fs.readFileSync(zipTypesPath) as unknown as string,
),
);
@@ -182,9 +186,13 @@ export class GenerateTypesPlugin implements WebpackPluginInstance {
!compilation.getAsset(apiFileName) &&
fs.existsSync(apiTypesPath)
) {
+ const webpackSources =
+ compiler.webpack?.sources ||
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ require('webpack').sources;
compilation.emitAsset(
apiFileName,
- new compiler.webpack.sources.RawSource(
+ new webpackSources.RawSource(
fs.readFileSync(apiTypesPath) as unknown as string,
),
);
diff --git a/packages/dts-plugin/tsup.config.ts b/packages/dts-plugin/tsup.config.ts
index 0ddad27a7ba..226c65fe215 100644
--- a/packages/dts-plugin/tsup.config.ts
+++ b/packages/dts-plugin/tsup.config.ts
@@ -13,7 +13,12 @@ function generateConfigurations(
dts: true,
legacyOutput: true,
outDir: 'dist',
- external: [join(__dirname, 'package.json')],
+ external: [
+ join(__dirname, 'package.json'),
+ '@swc/core',
+ '@swc/wasm',
+ '@swc/helpers',
+ ],
...config,
};
});
diff --git a/packages/enhanced/src/lib/container/AsyncBoundaryPlugin.ts b/packages/enhanced/src/lib/container/AsyncBoundaryPlugin.ts
index cd0b61399f0..a5910c3c7d3 100644
--- a/packages/enhanced/src/lib/container/AsyncBoundaryPlugin.ts
+++ b/packages/enhanced/src/lib/container/AsyncBoundaryPlugin.ts
@@ -4,6 +4,10 @@
*/
import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-path';
import { moduleFederationPlugin } from '@module-federation/sdk';
+import {
+ getJavascriptModulesPlugin,
+ getWebpackSources,
+} from '../webpackCompat';
import type {
Compiler,
Compilation,
@@ -83,118 +87,122 @@ class AsyncEntryStartupPlugin {
}
private _handleRenderStartup(compiler: Compiler, compilation: Compilation) {
- compiler.webpack.javascript.JavascriptModulesPlugin.getCompilationHooks(
- compilation,
- ).renderStartup.tap(
- 'AsyncEntryStartupPlugin',
- (
- source: sources.Source,
- _renderContext: Module,
- upperContext: StartupRenderContext,
- ) => {
- const isSingleRuntime = compiler.options?.optimization?.runtimeChunk;
- if (upperContext?.chunk.id && isSingleRuntime) {
- if (upperContext?.chunk.hasRuntime()) {
- this._runtimeChunks.set(upperContext.chunk.id, upperContext.chunk);
- return source;
+ getJavascriptModulesPlugin(compiler)
+ .getCompilationHooks(compilation)
+ .renderStartup.tap(
+ 'AsyncEntryStartupPlugin',
+ (
+ source: sources.Source,
+ _renderContext: Module,
+ upperContext: StartupRenderContext,
+ ) => {
+ const isSingleRuntime = compiler.options?.optimization?.runtimeChunk;
+ if (upperContext?.chunk.id && isSingleRuntime) {
+ if (upperContext?.chunk.hasRuntime()) {
+ this._runtimeChunks.set(
+ upperContext.chunk.id,
+ upperContext.chunk,
+ );
+ return source;
+ }
}
- }
-
- if (
- this._options.excludeChunk &&
- this._options.excludeChunk(upperContext.chunk)
- ) {
- return source;
- }
-
- const runtime = this._getChunkRuntime(upperContext);
- let remotes = '';
- let shared = '';
-
- for (const runtimeItem of runtime) {
- if (!runtimeItem) {
- continue;
+ if (
+ this._options.excludeChunk &&
+ this._options.excludeChunk(upperContext.chunk)
+ ) {
+ return source;
}
- const requirements =
- compilation.chunkGraph.getTreeRuntimeRequirements(runtimeItem);
+ const runtime = this._getChunkRuntime(upperContext);
- const entryOptions = upperContext.chunk.getEntryOptions();
- const chunkInitialsSet = new Set(
- compilation.chunkGraph.getChunkEntryDependentChunksIterable(
- upperContext.chunk,
- ),
- );
+ let remotes = '';
+ let shared = '';
- chunkInitialsSet.add(upperContext.chunk);
- const dependOn = entryOptions?.dependOn || [];
- this.getChunkByName(compilation, dependOn, chunkInitialsSet);
+ for (const runtimeItem of runtime) {
+ if (!runtimeItem) {
+ continue;
+ }
- const initialChunks = [];
+ const requirements =
+ compilation.chunkGraph.getTreeRuntimeRequirements(runtimeItem);
+
+ const entryOptions = upperContext.chunk.getEntryOptions();
+ const chunkInitialsSet = new Set(
+ compilation.chunkGraph.getChunkEntryDependentChunksIterable(
+ upperContext.chunk,
+ ),
+ );
+
+ chunkInitialsSet.add(upperContext.chunk);
+ const dependOn = entryOptions?.dependOn || [];
+ this.getChunkByName(compilation, dependOn, chunkInitialsSet);
+
+ const initialChunks = [];
+
+ let hasRemoteModules = false;
+ let consumeShares = false;
+
+ for (const chunk of chunkInitialsSet) {
+ initialChunks.push(chunk.id);
+ if (!hasRemoteModules) {
+ hasRemoteModules = Boolean(
+ compilation.chunkGraph.getChunkModulesIterableBySourceType(
+ chunk,
+ 'remote',
+ ),
+ );
+ }
+ if (!consumeShares) {
+ consumeShares = Boolean(
+ compilation.chunkGraph.getChunkModulesIterableBySourceType(
+ chunk,
+ 'consume-shared',
+ ),
+ );
+ }
+ if (hasRemoteModules && consumeShares) {
+ break;
+ }
+ }
- let hasRemoteModules = false;
- let consumeShares = false;
+ remotes = this._getRemotes(
+ compiler.webpack.RuntimeGlobals,
+ requirements,
+ hasRemoteModules,
+ initialChunks,
+ remotes,
+ );
+
+ shared = this._getShared(
+ compiler.webpack.RuntimeGlobals,
+ requirements,
+ consumeShares,
+ initialChunks,
+ shared,
+ );
+ }
- for (const chunk of chunkInitialsSet) {
- initialChunks.push(chunk.id);
- if (!hasRemoteModules) {
- hasRemoteModules = Boolean(
- compilation.chunkGraph.getChunkModulesIterableBySourceType(
- chunk,
- 'remote',
- ),
- );
- }
- if (!consumeShares) {
- consumeShares = Boolean(
- compilation.chunkGraph.getChunkModulesIterableBySourceType(
- chunk,
- 'consume-shared',
- ),
- );
- }
- if (hasRemoteModules && consumeShares) {
- break;
- }
+ if (!remotes && !shared) {
+ return source;
}
- remotes = this._getRemotes(
- compiler.webpack.RuntimeGlobals,
- requirements,
- hasRemoteModules,
- initialChunks,
- remotes,
+ const initialEntryModules = this._getInitialEntryModules(
+ compilation,
+ upperContext,
);
-
- shared = this._getShared(
- compiler.webpack.RuntimeGlobals,
- requirements,
- consumeShares,
- initialChunks,
+ const templateString = this._getTemplateString(
+ compiler,
+ initialEntryModules,
shared,
+ remotes,
+ source,
);
- }
- if (!remotes && !shared) {
- return source;
- }
-
- const initialEntryModules = this._getInitialEntryModules(
- compilation,
- upperContext,
- );
- const templateString = this._getTemplateString(
- compiler,
- initialEntryModules,
- shared,
- remotes,
- source,
- );
-
- return new compiler.webpack.sources.ConcatSource(templateString);
- },
- );
+ const webpackSources = getWebpackSources(compiler);
+ return new webpackSources.ConcatSource(templateString);
+ },
+ );
}
private _getChunkRuntime(upperContext: StartupRenderContext) {
diff --git a/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts b/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts
index 7749a7f32f6..92399950bb4 100644
--- a/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts
+++ b/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts
@@ -4,7 +4,7 @@
*/
'use strict';
-import { DtsPlugin } from '@module-federation/dts-plugin';
+import type { DtsPlugin as DtsPluginType } from '@module-federation/dts-plugin';
import { ContainerManager, utils } from '@module-federation/managers';
import { StatsPlugin } from '@module-federation/manifest';
import {
@@ -108,6 +108,22 @@ class ModuleFederationPlugin implements WebpackPluginInstance {
compiler,
'EnhancedModuleFederationPlugin',
);
+ if (!compiler.webpack || !compiler.webpack.sources) {
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const webpack = require(
+ process.env['FEDERATION_WEBPACK_PATH'] || 'webpack',
+ );
+ if (!compiler.webpack) {
+ compiler.webpack = webpack;
+ } else if (!compiler.webpack.sources && webpack?.sources) {
+ // Webpack typings mark `sources` readonly, but runtime fallback needs it populated.
+ (compiler.webpack as any).sources = webpack.sources;
+ }
+ } catch {
+ // ignore fallback failures
+ }
+ }
const { _options: options } = this;
const { name, experiments, dts, remotes, shared, shareScope } = options;
if (!name) {
@@ -149,6 +165,10 @@ class ModuleFederationPlugin implements WebpackPluginInstance {
}
if (dts !== false) {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const { DtsPlugin } = require('@module-federation/dts-plugin') as {
+ DtsPlugin: typeof DtsPluginType;
+ };
const dtsPlugin = new DtsPlugin(options);
dtsPlugin.apply(compiler);
dtsPlugin.addRuntimePlugins();
diff --git a/packages/enhanced/src/lib/container/runtime/ChildCompilationRuntimePlugin.ts b/packages/enhanced/src/lib/container/runtime/ChildCompilationRuntimePlugin.ts
index acca3b52739..b52a1604d06 100644
--- a/packages/enhanced/src/lib/container/runtime/ChildCompilationRuntimePlugin.ts
+++ b/packages/enhanced/src/lib/container/runtime/ChildCompilationRuntimePlugin.ts
@@ -9,6 +9,7 @@
import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-path';
import type { Compiler, Compilation, Chunk, Module, ChunkGraph } from 'webpack';
import { getFederationGlobalScope } from './utils';
+import { getJavascriptModulesPlugin } from '../../webpackCompat';
import fs from 'fs';
import path from 'path';
import { ConcatSource } from 'webpack-sources';
@@ -45,9 +46,7 @@ class RuntimeModuleChunkPlugin {
);
const hooks =
- compiler.webpack.javascript.JavascriptModulesPlugin.getCompilationHooks(
- compilation,
- );
+ getJavascriptModulesPlugin(compiler).getCompilationHooks(compilation);
hooks.renderChunk.tap(
'ModuleChunkFormatPlugin',
diff --git a/packages/enhanced/src/lib/container/runtime/EmbedFederationRuntimePlugin.ts b/packages/enhanced/src/lib/container/runtime/EmbedFederationRuntimePlugin.ts
index c1fe93ee994..3c70e1b0710 100644
--- a/packages/enhanced/src/lib/container/runtime/EmbedFederationRuntimePlugin.ts
+++ b/packages/enhanced/src/lib/container/runtime/EmbedFederationRuntimePlugin.ts
@@ -5,6 +5,10 @@ import type { Compiler, Chunk, Compilation } from 'webpack';
import { getFederationGlobalScope } from './utils';
import ContainerEntryDependency from '../ContainerEntryDependency';
import FederationRuntimeDependency from './FederationRuntimeDependency';
+import {
+ getJavascriptModulesPlugin,
+ getWebpackSources,
+} from '../../webpackCompat';
const { RuntimeGlobals } = require(
normalizeWebpackPath('webpack'),
@@ -69,9 +73,7 @@ class EmbedFederationRuntimePlugin {
(compilation: Compilation) => {
// --- Part 1: Modify renderStartup to append a startup call when none is added automatically ---
const { renderStartup } =
- compiler.webpack.javascript.JavascriptModulesPlugin.getCompilationHooks(
- compilation,
- );
+ getJavascriptModulesPlugin(compiler).getCompilationHooks(compilation);
renderStartup.tap(
PLUGIN_NAME,
@@ -97,7 +99,8 @@ class EmbedFederationRuntimePlugin {
}
// Otherwise, append a startup call.
- return new compiler.webpack.sources.ConcatSource(
+ const webpackSources = getWebpackSources(compiler);
+ return new webpackSources.ConcatSource(
startupSource,
'\n// Custom hook: appended startup call because none was added automatically\n',
`${RuntimeGlobals.startup}();\n`,
diff --git a/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts b/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts
index f3ba3cbbefc..e7da11c98ed 100644
--- a/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts
+++ b/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts
@@ -150,6 +150,10 @@ class FederationRuntimePlugin {
'}',
]);
+ const installInitialConsumesCall = options.experiments?.asyncStartup
+ ? `${federationGlobal}.installInitialConsumes({ asyncLoad: true })`
+ : `${federationGlobal}.installInitialConsumes()`;
+
return Template.asString([
`import federation from '${normalizedBundlerRuntimePath}';`,
runtimePluginTemplates,
@@ -175,7 +179,7 @@ class FederationRuntimePlugin {
]),
'}',
`if(${federationGlobal}.installInitialConsumes){`,
- Template.indent([`${federationGlobal}.installInitialConsumes()`]),
+ Template.indent([installInitialConsumesCall]),
'}',
]),
PrefetchPlugin.addRuntime(compiler, {
diff --git a/packages/enhanced/src/lib/sharing/tree-shaking/IndependentSharedPlugin.ts b/packages/enhanced/src/lib/sharing/tree-shaking/IndependentSharedPlugin.ts
index a6435c05adc..6f903dddee1 100644
--- a/packages/enhanced/src/lib/sharing/tree-shaking/IndependentSharedPlugin.ts
+++ b/packages/enhanced/src/lib/sharing/tree-shaking/IndependentSharedPlugin.ts
@@ -24,6 +24,7 @@ import type { SharedConfig } from '../../../declarations/plugins/sharing/SharePl
import ConsumeSharedPlugin from '../ConsumeSharedPlugin';
import { NormalizedSharedOptions } from '../SharePlugin';
import IndependentSharedRuntimeModule from './IndependentSharedRuntimeModule';
+import { getWebpackSources } from '../../webpackCompat';
const IGNORED_ENTRY = 'ignored-entry';
@@ -205,7 +206,7 @@ export default class IndependentSharedPlugin {
compilation.updateAsset(
StatsFileName,
- new compiler.webpack.sources.RawSource(
+ new (getWebpackSources(compiler).RawSource)(
JSON.stringify(statsContent),
),
);
@@ -246,35 +247,58 @@ export default class IndependentSharedPlugin {
if (!shareConfig.treeShaking) {
return;
}
- const shareRequests = shareRequestsMap[shareName].requests;
- await Promise.all(
- shareRequests.map(async ([request, version]) => {
- const sharedConfig = sharedOptions.find(
- ([name]) => name === shareName,
- )?.[1];
- const [shareFileName, globalName, sharedVersion] =
- await this.createIndependentCompiler(parentCompiler, {
- shareRequestsMap,
- currentShare: {
- shareName,
- version,
- request,
- independentShareFileName: sharedConfig?.treeShaking?.filename,
- },
- });
- if (typeof shareFileName === 'string') {
- this.buildAssets[shareName] ||= [];
- this.buildAssets[shareName].push([
- path.join(
- resolveOutputDir(outputDir, shareName),
- shareFileName,
- ),
- sharedVersion,
- globalName,
- ]);
- }
- }),
+
+ const shareRequests = shareRequestsMap[shareName]?.requests || [];
+ if (!shareRequests.length) {
+ return;
+ }
+
+ // De-dupe identical (request, version) pairs. Duplicate requests can
+ // happen when a package is both directly imported and also imported by
+ // another shared package.
+ const seen = new Set();
+ const uniqueShareRequests: [string, string][] = [];
+ for (const [request, version] of shareRequests) {
+ const key = `${version}@@${request}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ uniqueShareRequests.push([request, version]);
+ }
+
+ // Ensure we don't keep stale outputs for this share across builds.
+ // Each request/version compilation emits into `${version}/...` under this
+ // directory, so we clean once per shareName, and keep per-compiler
+ // `output.clean` disabled to avoid inter-compiler races.
+ const fullShareOutputDir = path.resolve(
+ parentCompiler.outputPath,
+ resolveOutputDir(outputDir, shareName),
);
+ try {
+ fs.rmSync(fullShareOutputDir, { recursive: true, force: true });
+ } catch {
+ // ignore
+ }
+
+ for (const [request, version] of uniqueShareRequests) {
+ const [shareFileName, globalName, sharedVersion] =
+ await this.createIndependentCompiler(parentCompiler, {
+ shareRequestsMap,
+ currentShare: {
+ shareName,
+ version,
+ request,
+ independentShareFileName: shareConfig?.treeShaking?.filename,
+ },
+ });
+ if (typeof shareFileName === 'string') {
+ this.buildAssets[shareName] ||= [];
+ this.buildAssets[shareName].push([
+ path.join(resolveOutputDir(outputDir, shareName), shareFileName),
+ sharedVersion,
+ globalName,
+ ]);
+ }
+ }
}),
);
@@ -379,7 +403,11 @@ export default class IndependentSharedPlugin {
// 输出配置
output: {
path: fullOutputDir,
- clean: true,
+ // For the initial "collector" compilation we want a clean directory.
+ // For per-share compilations, avoid cleaning the whole output directory
+ // on every compiler run to prevent deleting outputs produced by other
+ // (possibly concurrent) share builds.
+ clean: !extraOptions,
publicPath: parentConfig.output?.publicPath || 'auto',
},
diff --git a/packages/enhanced/src/lib/sharing/tree-shaking/SharedUsedExportsOptimizerPlugin.ts b/packages/enhanced/src/lib/sharing/tree-shaking/SharedUsedExportsOptimizerPlugin.ts
index 53136e75852..3d667680158 100644
--- a/packages/enhanced/src/lib/sharing/tree-shaking/SharedUsedExportsOptimizerPlugin.ts
+++ b/packages/enhanced/src/lib/sharing/tree-shaking/SharedUsedExportsOptimizerPlugin.ts
@@ -16,6 +16,7 @@ import { NormalizedSharedOptions } from '../SharePlugin';
import ConsumeSharedModule from '../ConsumeSharedModule';
import ProvideSharedModule from '../ProvideSharedModule';
import SharedEntryModule from './SharedContainerPlugin/SharedEntryModule';
+import { getWebpackSources } from '../../webpackCompat';
export type CustomReferencedExports = { [sharedName: string]: string[] };
@@ -315,7 +316,7 @@ export default class SharedUsedExportsOptimizerPlugin
compilation.updateAsset(
statsFileName,
- new compiler.webpack.sources.RawSource(
+ new (getWebpackSources(compiler).RawSource)(
JSON.stringify(statsContent, null, 2),
),
);
diff --git a/packages/enhanced/src/lib/startup/MfStartupChunkDependenciesPlugin.ts b/packages/enhanced/src/lib/startup/MfStartupChunkDependenciesPlugin.ts
index 46b8b563d81..21e7278112e 100644
--- a/packages/enhanced/src/lib/startup/MfStartupChunkDependenciesPlugin.ts
+++ b/packages/enhanced/src/lib/startup/MfStartupChunkDependenciesPlugin.ts
@@ -6,6 +6,10 @@ import {
generateEntryStartup,
generateESMEntryStartup,
} from './StartupHelpers';
+import {
+ getJavascriptModulesPlugin,
+ getWebpackSources,
+} from '../webpackCompat';
import type { Compiler, Chunk } from 'webpack';
import ContainerEntryModule from '../container/ContainerEntryModule';
@@ -84,9 +88,7 @@ class StartupChunkDependenciesPlugin {
// Replace the generated startup with a custom version if entry modules exist.
const { renderStartup } =
- compiler.webpack.javascript.JavascriptModulesPlugin.getCompilationHooks(
- compilation,
- );
+ getJavascriptModulesPlugin(compiler).getCompilationHooks(compilation);
renderStartup.tap(
'MfStartupChunkDependenciesPlugin',
@@ -122,7 +124,8 @@ class StartupChunkDependenciesPlugin {
? generateESMEntryStartup
: generateEntryStartup;
- return new compiler.webpack.sources.ConcatSource(
+ const webpackSources = getWebpackSources(compiler);
+ return new webpackSources.ConcatSource(
entryGeneration(
compilation,
chunkGraph,
diff --git a/packages/enhanced/src/lib/startup/StartupHelpers.ts b/packages/enhanced/src/lib/startup/StartupHelpers.ts
index abe51000232..0ae3f0285a8 100644
--- a/packages/enhanced/src/lib/startup/StartupHelpers.ts
+++ b/packages/enhanced/src/lib/startup/StartupHelpers.ts
@@ -9,6 +9,10 @@ import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-p
import type { EntryModuleWithChunkGroup } from 'webpack/lib/ChunkGraph';
import type RuntimeTemplate from 'webpack/lib/RuntimeTemplate';
import type Entrypoint from 'webpack/lib/Entrypoint';
+import {
+ getJavascriptModulesPlugin,
+ getWebpackSources,
+} from '../webpackCompat';
const { RuntimeGlobals, Template } = require(
normalizeWebpackPath('webpack'),
@@ -160,10 +164,10 @@ export const generateESMEntryStartup = (
chunk: Chunk,
passive: boolean,
): string => {
- const { chunkHasJs, getChunkFilenameTemplate } =
- compilation.compiler.webpack?.javascript?.JavascriptModulesPlugin ||
- compilation.compiler.webpack.JavascriptModulesPlugin;
- const { ConcatSource } = compilation.compiler.webpack.sources;
+ const { chunkHasJs, getChunkFilenameTemplate } = getJavascriptModulesPlugin(
+ compilation.compiler,
+ );
+ const { ConcatSource } = getWebpackSources(compilation.compiler);
const hotUpdateChunk = chunk instanceof HotUpdateChunk ? chunk : null;
if (hotUpdateChunk) {
throw new Error('HMR is not implemented for module chunk format yet');
diff --git a/packages/enhanced/src/lib/webpackCompat.ts b/packages/enhanced/src/lib/webpackCompat.ts
new file mode 100644
index 00000000000..d93af7fb9b6
--- /dev/null
+++ b/packages/enhanced/src/lib/webpackCompat.ts
@@ -0,0 +1,44 @@
+import type { Compiler } from 'webpack';
+import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-path';
+
+const JavascriptModulesPlugin = require(
+ normalizeWebpackPath('webpack/lib/javascript/JavascriptModulesPlugin'),
+) as typeof import('webpack/lib/javascript/JavascriptModulesPlugin');
+
+type CompilerWithJavascriptModulesPlugin = Compiler['webpack'] & {
+ javascript?: {
+ JavascriptModulesPlugin?: typeof import('webpack/lib/javascript/JavascriptModulesPlugin');
+ };
+};
+
+type WebpackSources = NonNullable['sources'];
+
+export function getWebpackSources(compiler: Compiler): WebpackSources {
+ if (compiler.webpack?.sources) {
+ return compiler.webpack.sources as WebpackSources;
+ }
+
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const webpack = require(
+ process.env['FEDERATION_WEBPACK_PATH'] || 'webpack',
+ ) as typeof import('webpack');
+ if (webpack?.sources) {
+ return webpack.sources as WebpackSources;
+ }
+ } catch {
+ // ignore fallback failures
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ return require('webpack').sources as WebpackSources;
+}
+
+export function getJavascriptModulesPlugin(
+ compiler: Compiler,
+): typeof import('webpack/lib/javascript/JavascriptModulesPlugin') {
+ const maybePlugin = (compiler.webpack as CompilerWithJavascriptModulesPlugin)
+ ?.javascript?.JavascriptModulesPlugin;
+
+ return maybePlugin || JavascriptModulesPlugin;
+}
diff --git a/packages/enhanced/test/ConfigTestCases.rstest.ts b/packages/enhanced/test/ConfigTestCases.rstest.ts
index a2d3a93bf6e..0d6bc828084 100644
--- a/packages/enhanced/test/ConfigTestCases.rstest.ts
+++ b/packages/enhanced/test/ConfigTestCases.rstest.ts
@@ -353,7 +353,6 @@ export const describeCases = (config: any) => {
`${path.sep}tree-shaking-share${path.sep}`,
)
) {
- nativeRequire('./scripts/ensure-reshake-fixtures');
ensureTreeShakingFixturesIfNeeded();
}
options = prepareOptions(
diff --git a/packages/manifest/src/StatsPlugin.ts b/packages/manifest/src/StatsPlugin.ts
index fe104630332..2ab1fb6db54 100644
--- a/packages/manifest/src/StatsPlugin.ts
+++ b/packages/manifest/src/StatsPlugin.ts
@@ -78,9 +78,13 @@ export class StatsPlugin implements WebpackPluginInstance {
})) || updatedStats;
}
+ const webpackSources =
+ compiler.webpack?.sources ||
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ require('webpack').sources;
compilation.updateAsset(
this._statsManager.fileName,
- new compiler.webpack.sources.RawSource(
+ new webpackSources.RawSource(
JSON.stringify(updatedStats, null, 2),
),
);
@@ -91,7 +95,7 @@ export class StatsPlugin implements WebpackPluginInstance {
compiler,
bundler: this._bundler,
});
- const source = new compiler.webpack.sources.RawSource(
+ const source = new webpackSources.RawSource(
JSON.stringify(updatedManifest, null, 2),
);
compilation.updateAsset(this._manifestManager.fileName, source);
@@ -126,17 +130,17 @@ export class StatsPlugin implements WebpackPluginInstance {
bundler: this._bundler,
});
+ const webpackSources =
+ compiler.webpack?.sources ||
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ require('webpack').sources;
compilation.emitAsset(
this._statsManager.fileName,
- new compiler.webpack.sources.RawSource(
- JSON.stringify(stats, null, 2),
- ),
+ new webpackSources.RawSource(JSON.stringify(stats, null, 2)),
);
compilation.emitAsset(
this._manifestManager.fileName,
- new compiler.webpack.sources.RawSource(
- JSON.stringify(manifest, null, 2),
- ),
+ new webpackSources.RawSource(JSON.stringify(manifest, null, 2)),
);
}
},
diff --git a/packages/manifest/src/utils.ts b/packages/manifest/src/utils.ts
index ddd5d05abce..3651b714c96 100644
--- a/packages/manifest/src/utils.ts
+++ b/packages/manifest/src/utils.ts
@@ -14,10 +14,6 @@ import {
normalizeOptions,
MetaDataTypes,
} from '@module-federation/sdk';
-import {
- isTSProject,
- retrieveTypesAssetsInfo,
-} from '@module-federation/dts-plugin/core';
import { HOT_UPDATE_SUFFIX, PLUGIN_IDENTIFIER } from './constants';
import logger from './logger';
@@ -239,6 +235,10 @@ export function getTypesMetaInfo(
api: '',
};
try {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const dtsUtils =
+ require('@module-federation/dts-plugin/core') as typeof import('@module-federation/dts-plugin/core');
+ const { isTSProject, retrieveTypesAssetsInfo } = dtsUtils;
const normalizedDtsOptions =
normalizeOptions(
isTSProject(pluginOptions.dts, context),
diff --git a/packages/modernjs-v3/src/ssr-runtime/devPlugin.tsx b/packages/modernjs-v3/src/ssr-runtime/devPlugin.tsx
index d19e5181632..8c30ee28e49 100644
--- a/packages/modernjs-v3/src/ssr-runtime/devPlugin.tsx
+++ b/packages/modernjs-v3/src/ssr-runtime/devPlugin.tsx
@@ -2,6 +2,12 @@ import type { RuntimePlugin } from '@modern-js/runtime';
import { SSRLiveReload } from './SSRLiveReload';
import { flushDataFetch } from '@module-federation/bridge-react/lazy-utils';
+let remoteHotReloadController:
+ | {
+ check: (force?: boolean) => Promise;
+ }
+ | undefined;
+
export const mfSSRDevPlugin = (): RuntimePlugin => ({
name: '@module-federation/modern-js-v3',
@@ -12,10 +18,18 @@ export const mfSSRDevPlugin = (): RuntimePlugin => ({
}
globalThis.shouldUpdate = false;
const nodeUtils = await import('@module-federation/node/utils');
- const shouldUpdate = await nodeUtils.revalidate();
- console.log('shouldUpdate: ', shouldUpdate);
+ if (!remoteHotReloadController) {
+ remoteHotReloadController = nodeUtils.ensureRemoteHotReload({
+ enabled: process.env['MF_REMOTE_HOT_RELOAD'] !== 'false',
+ intervalMs: Number(
+ process.env['MF_REMOTE_REVALIDATE_INTERVAL_MS'] || 10_000,
+ ),
+ immediate: true,
+ });
+ }
+
+ const shouldUpdate = await remoteHotReloadController.check(false);
if (shouldUpdate) {
- console.log('should RELOAD', shouldUpdate);
await nodeUtils.flushChunks();
flushDataFetch();
globalThis.shouldUpdate = true;
diff --git a/packages/modernjs/src/ssr-runtime/devPlugin.tsx b/packages/modernjs/src/ssr-runtime/devPlugin.tsx
index a2c8ec8b086..0b1b9eaed03 100644
--- a/packages/modernjs/src/ssr-runtime/devPlugin.tsx
+++ b/packages/modernjs/src/ssr-runtime/devPlugin.tsx
@@ -2,6 +2,12 @@ import type { RuntimePluginFuture } from '@modern-js/runtime';
import { SSRLiveReload } from './SSRLiveReload';
import { flushDataFetch } from '@module-federation/bridge-react/lazy-utils';
+let remoteHotReloadController:
+ | {
+ check: (force?: boolean) => Promise;
+ }
+ | undefined;
+
export const mfSSRDevPlugin = (): RuntimePluginFuture => ({
name: '@module-federation/modern-js',
@@ -12,10 +18,18 @@ export const mfSSRDevPlugin = (): RuntimePluginFuture => ({
}
globalThis.shouldUpdate = false;
const nodeUtils = await import('@module-federation/node/utils');
- const shouldUpdate = await nodeUtils.revalidate();
- console.log('shouldUpdate: ', shouldUpdate);
+ if (!remoteHotReloadController) {
+ remoteHotReloadController = nodeUtils.ensureRemoteHotReload({
+ enabled: process.env['MF_REMOTE_HOT_RELOAD'] !== 'false',
+ intervalMs: Number(
+ process.env['MF_REMOTE_REVALIDATE_INTERVAL_MS'] || 10_000,
+ ),
+ immediate: true,
+ });
+ }
+
+ const shouldUpdate = await remoteHotReloadController.check(false);
if (shouldUpdate) {
- console.log('should RELOAD', shouldUpdate);
await nodeUtils.flushChunks();
flushDataFetch();
globalThis.shouldUpdate = true;
diff --git a/packages/nextjs-mf/MIGRATION-v9.md b/packages/nextjs-mf/MIGRATION-v9.md
new file mode 100644
index 00000000000..5b1e20156a2
--- /dev/null
+++ b/packages/nextjs-mf/MIGRATION-v9.md
@@ -0,0 +1,103 @@
+# Migration guide: nextjs-mf v8 -> v9
+
+## Breaking changes
+
+- `NextFederationPlugin` is removed from public API.
+- `extraOptions` is removed.
+- `@module-federation/nextjs-mf/utils` export is removed.
+- Webpack mode is required in Next 16 (`--webpack`).
+
+## API migration
+
+### Before (v8)
+
+```js
+const NextFederationPlugin = require('@module-federation/nextjs-mf');
+
+module.exports = {
+ webpack(config, options) {
+ config.plugins.push(
+ new NextFederationPlugin({
+ name: 'home',
+ filename: 'static/chunks/remoteEntry.js',
+ remotes: {
+ shop: `shop@http://localhost:3001/_next/static/${
+ options.isServer ? 'ssr' : 'chunks'
+ }/remoteEntry.js`,
+ },
+ extraOptions: {
+ exposePages: true,
+ debug: false,
+ },
+ }),
+ );
+
+ return config;
+ },
+};
+```
+
+### After (v9)
+
+```js
+const { withNextFederation } = require('@module-federation/nextjs-mf');
+
+module.exports = withNextFederation(
+ {
+ webpack(config) {
+ return config;
+ },
+ },
+ {
+ name: 'home',
+ mode: 'pages',
+ filename: 'static/chunks/remoteEntry.js',
+ remotes: ({ isServer }) => ({
+ shop: `shop@http://localhost:3001/_next/static/${
+ isServer ? 'ssr' : 'chunks'
+ }/remoteEntry.js`,
+ }),
+ pages: {
+ exposePages: true,
+ pageMapFormat: 'routes-v2',
+ },
+ diagnostics: {
+ level: 'warn',
+ },
+ },
+);
+```
+
+## Legacy option mapping
+
+- `extraOptions.exposePages` -> `pages.exposePages`
+- `extraOptions.skipSharingNextInternals` -> `sharing.includeNextInternals = false`
+- `extraOptions.debug` -> `diagnostics.level = 'debug'`
+- `extraOptions.enableImageLoaderFix` -> removed (`NMF005`)
+- `extraOptions.enableUrlLoaderFix` -> removed (`NMF005`)
+- `extraOptions.automaticPageStitching` -> removed (`NMF005`)
+
+## Utilities migration
+
+- Replace:
+
+```js
+import { revalidate, flushChunks } from '@module-federation/nextjs-mf/utils';
+```
+
+- With:
+
+```js
+import { revalidate, flushChunks } from '@module-federation/node/utils';
+```
+
+## Required scripts for Next 16+
+
+```json
+{
+ "scripts": {
+ "dev": "NEXT_PRIVATE_LOCAL_WEBPACK=true next dev --webpack",
+ "build": "NEXT_PRIVATE_LOCAL_WEBPACK=true next build --webpack"
+ }
+}
+```
diff --git a/packages/nextjs-mf/README.md b/packages/nextjs-mf/README.md
index ff3ff16b26a..021cedb38cc 100644
--- a/packages/nextjs-mf/README.md
+++ b/packages/nextjs-mf/README.md
@@ -1,5 +1,84 @@
-# Next.js Support is in maintenance mode
+# nextjs-mf v9 (Next.js 16+)
-Read about it [here](https://github.com/module-federation/core/issues/3153)
+`@module-federation/nextjs-mf` v9 is a clean rewrite for Next.js 16+.
-Plugin Documentation: [here](https://module-federation.io/practice/frameworks/next/index.html)
+## Support matrix
+
+- Next.js `>=16.0.0`
+- Webpack mode only (`next dev --webpack`, `next build --webpack`)
+- Pages Router: stable
+- App Router: beta (`Client Components` + `RSC`)
+- Node runtime federation only
+
+## Not supported
+
+- Turbopack / Rspack builds
+- Edge runtime federation
+- App Router route handlers federation (`app/**/route.*`)
+- Middleware federation
+- Server action federation (`'use server'` modules)
+
+## Installation
+
+```bash
+pnpm add @module-federation/nextjs-mf webpack
+```
+
+## Usage
+
+```js
+const { withNextFederation } = require('@module-federation/nextjs-mf');
+
+/** @type {import('next').NextConfig} */
+const baseConfig = {
+ webpack(config) {
+ return config;
+ },
+};
+
+module.exports = withNextFederation(baseConfig, {
+ name: 'host',
+ mode: 'hybrid',
+ filename: 'static/chunks/remoteEntry.js',
+ remotes: ({ isServer }) => ({
+ remote: `remote@http://localhost:3001/_next/static/${
+ isServer ? 'ssr' : 'chunks'
+ }/remoteEntry.js`,
+ }),
+ exposes: {
+ './Header': './components/Header',
+ },
+ pages: {
+ exposePages: true,
+ pageMapFormat: 'routes-v2',
+ },
+ app: {
+ enableClientComponents: true,
+ enableRsc: true,
+ },
+ sharing: {
+ includeNextInternals: true,
+ strategy: 'loaded-first',
+ },
+});
+```
+
+## Required scripts
+
+```json
+{
+ "scripts": {
+ "dev": "NEXT_PRIVATE_LOCAL_WEBPACK=true next dev --webpack",
+ "build": "NEXT_PRIVATE_LOCAL_WEBPACK=true next build --webpack"
+ }
+}
+```
+
+## Migration from v8
+
+- `NextFederationPlugin` constructor usage is replaced by `withNextFederation` wrapper.
+- `extraOptions` is removed.
+- `@module-federation/nextjs-mf/utils` is removed.
+- Migrate utility calls to `@module-federation/node/utils`.
+
+See `MIGRATION-v9.md` for mapping details.
diff --git a/packages/nextjs-mf/client/UrlNode.ts b/packages/nextjs-mf/client/UrlNode.ts
deleted file mode 100644
index dd7fb89a290..00000000000
--- a/packages/nextjs-mf/client/UrlNode.ts
+++ /dev/null
@@ -1,199 +0,0 @@
-// TODO: fix the no-non-null assertion errors
-/* eslint-disable @typescript-eslint/no-non-null-assertion */
-/**
- * This class provides a logic of sorting dynamic routes in NextJS.
- *
- * It was copied from
- * @see https://github.com/vercel/next.js/blob/canary/packages/next/shared/lib/router/utils/sorted-routes.ts
- */
-export class UrlNode {
- placeholder = true;
- children: Map = new Map();
- slugName: string | null = null;
- restSlugName: string | null = null;
- optionalRestSlugName: string | null = null;
-
- insert(urlPath: string): void {
- this._insert(urlPath.split('/').filter(Boolean), [], false);
- }
-
- smoosh(): string[] {
- return this._smoosh();
- }
-
- private _smoosh(prefix = '/'): string[] {
- const childrenPaths = [...this.children.keys()].sort();
- if (this.slugName !== null) {
- childrenPaths.splice(childrenPaths.indexOf('[]'), 1);
- }
- if (this.restSlugName !== null) {
- childrenPaths.splice(childrenPaths.indexOf('[...]'), 1);
- }
- if (this.optionalRestSlugName !== null) {
- childrenPaths.splice(childrenPaths.indexOf('[[...]]'), 1);
- }
-
- const routes = childrenPaths
- .map((c) => this.children.get(c)!._smoosh(`${prefix}${c}/`))
- .reduce((prev, curr) => [...prev, ...curr], []);
-
- if (this.slugName !== null) {
- routes.push(
- ...this.children.get('[]')!._smoosh(`${prefix}[${this.slugName}]/`),
- );
- }
-
- if (!this.placeholder) {
- const r = prefix === '/' ? '/' : prefix.slice(0, -1);
- if (this.optionalRestSlugName != null) {
- throw new Error(
- `You cannot define a route with the same specificity as a optional catch-all route ("${r}" and "${r}[[...${this.optionalRestSlugName}]]").`,
- );
- }
-
- routes.unshift(r);
- }
-
- if (this.restSlugName !== null) {
- routes.push(
- ...this.children
- .get('[...]')!
- ._smoosh(`${prefix}[...${this.restSlugName}]/`),
- );
- }
-
- if (this.optionalRestSlugName !== null) {
- routes.push(
- ...this.children
- .get('[[...]]')!
- ._smoosh(`${prefix}[[...${this.optionalRestSlugName}]]/`),
- );
- }
-
- return routes;
- }
-
- private _insert(
- urlPaths: string[],
- slugNames: string[],
- isCatchAll: boolean,
- ): void {
- if (urlPaths.length === 0) {
- this.placeholder = false;
- return;
- }
-
- if (isCatchAll) {
- throw new Error(`Catch-all must be the last part of the URL.`);
- }
-
- // The next segment in the urlPaths list
- let nextSegment = urlPaths[0];
-
- // Check if the segment matches `[something]`
- if (nextSegment.startsWith('[') && nextSegment.endsWith(']')) {
- // Strip `[` and `]`, leaving only `something`
- let segmentName = nextSegment.slice(1, -1);
-
- let isOptional = false;
- if (segmentName.startsWith('[') && segmentName.endsWith(']')) {
- // Strip optional `[` and `]`, leaving only `something`
- segmentName = segmentName.slice(1, -1);
- isOptional = true;
- }
-
- if (segmentName.startsWith('...')) {
- // Strip `...`, leaving only `something`
- segmentName = segmentName.substring(3);
- isCatchAll = true;
- }
-
- if (segmentName.startsWith('[') || segmentName.endsWith(']')) {
- throw new Error(
- `Segment names may not start or end with extra brackets ('${segmentName}').`,
- );
- }
-
- if (segmentName.startsWith('.')) {
- throw new Error(
- `Segment names may not start with erroneous periods ('${segmentName}').`,
- );
- }
-
- const handleSlug = function handleSlug(
- previousSlug: string | null,
- nextSlug: string,
- ) {
- if (previousSlug !== null && previousSlug !== nextSlug) {
- throw new Error(
- `You cannot use different slug names for the same dynamic path ('${previousSlug}' !== '${nextSlug}').`,
- );
- }
-
- slugNames.forEach((slug) => {
- if (slug === nextSlug) {
- throw new Error(
- `You cannot have the same slug name "${nextSlug}" repeat within a single dynamic path`,
- );
- }
-
- if (slug.replace(/\W/g, '') === nextSegment.replace(/\W/g, '')) {
- throw new Error(
- `You cannot have the slug names "${slug}" and "${nextSlug}" differ only by non-word symbols within a single dynamic path`,
- );
- }
- });
-
- slugNames.push(nextSlug);
- };
-
- if (isCatchAll) {
- if (isOptional) {
- if (this.restSlugName != null) {
- throw new Error(
- `You cannot use both an required and optional catch-all route at the same level ("[...${this.restSlugName}]" and "${urlPaths[0]}" ).`,
- );
- }
-
- handleSlug(this.optionalRestSlugName, segmentName);
- // slugName is kept as it can only be one particular slugName
- this.optionalRestSlugName = segmentName;
- // nextSegment is overwritten to [[...]] so that it can later be sorted specifically
- nextSegment = '[[...]]';
- } else {
- if (this.optionalRestSlugName != null) {
- throw new Error(
- `You cannot use both an optional and required catch-all route at the same level ("[[...${this.optionalRestSlugName}]]" and "${urlPaths[0]}").`,
- );
- }
-
- handleSlug(this.restSlugName, segmentName);
- // slugName is kept as it can only be one particular slugName
- this.restSlugName = segmentName;
- // nextSegment is overwritten to [...] so that it can later be sorted specifically
- nextSegment = '[...]';
- }
- } else {
- if (isOptional) {
- throw new Error(
- `Optional route parameters are not yet supported ("${urlPaths[0]}").`,
- );
- }
- handleSlug(this.slugName, segmentName);
- // slugName is kept as it can only be one particular slugName
- this.slugName = segmentName;
- // nextSegment is overwritten to [] so that it can later be sorted specifically
- nextSegment = '[]';
- }
- }
-
- // If this UrlNode doesn't have the nextSegment yet we create a new child UrlNode
- if (!this.children.has(nextSegment)) {
- this.children.set(nextSegment, new UrlNode());
- }
-
- this.children
- .get(nextSegment)!
- ._insert(urlPaths.slice(1), slugNames, isCatchAll);
- }
-}
diff --git a/packages/nextjs-mf/client/helpers.ts b/packages/nextjs-mf/client/helpers.ts
deleted file mode 100644
index 85fc4295c56..00000000000
--- a/packages/nextjs-mf/client/helpers.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { UrlNode } from './UrlNode';
-
-const TEST_DYNAMIC_ROUTE = /\/\[[^/]+?\](?=\/|$)/;
-const reHasRegExp = /[|\\{}()[\]^$+*?.-]/;
-const reReplaceRegExp = /[|\\{}()[\]^$+*?.-]/g;
-
-export function isDynamicRoute(route: string) {
- return TEST_DYNAMIC_ROUTE.test(route);
-}
-
-/**
- * Parses a given parameter from a route to a data structure that can be used
- * to generate the parametrized route. Examples:
- * - `[...slug]` -> `{ name: 'slug', repeat: true, optional: true }`
- * - `[foo]` -> `{ name: 'foo', repeat: false, optional: true }`
- * - `bar` -> `{ name: 'bar', repeat: false, optional: false }`
- */
-function parseParameter(param: string) {
- const optional = param.startsWith('[') && param.endsWith(']');
- if (optional) {
- param = param.slice(1, -1);
- }
- const repeat = param.startsWith('...');
- if (repeat) {
- param = param.slice(3);
- }
- return { key: param, repeat, optional };
-}
-
-function getParametrizedRoute(route: string) {
- // const segments = removeTrailingSlash(route).slice(1).split('/')
- const segments = route.slice(1).split('/');
- const groups = {} as Record;
- let groupIndex = 1;
- return {
- parameterizedRoute: segments
- .map((segment) => {
- if (segment.startsWith('[') && segment.endsWith(']')) {
- const { key, optional, repeat } = parseParameter(
- segment.slice(1, -1),
- );
- groups[key] = { pos: groupIndex++, repeat, optional };
- return repeat ? (optional ? '(?:/(.+?))?' : '/(.+?)') : '/([^/]+?)';
- } else {
- return `/${escapeStringRegexp(segment)}`;
- }
- })
- .join(''),
- groups,
- };
-}
-
-export function getRouteRegex(normalizedRoute: string) {
- const { parameterizedRoute, groups } = getParametrizedRoute(normalizedRoute);
- return {
- re: new RegExp(`^${parameterizedRoute}(?:/)?$`),
- groups,
- };
-}
-
-function escapeStringRegexp(str: string) {
- // see also: https://github.com/lodash/lodash/blob/2da024c3b4f9947a48517639de7560457cd4ec6c/escapeRegExp.js#L23
- if (reHasRegExp.test(str)) {
- return str.replace(reReplaceRegExp, '\\$&');
- }
- return str;
-}
-
-/**
- * Convert browser pathname to NextJs route.
- * This method is required for proper work of Dynamic routes in NextJS.
- */
-export function pathnameToRoute(
- cleanPathname: string,
- routes: string[],
-): string | undefined {
- if (routes.includes(cleanPathname)) {
- return cleanPathname;
- }
-
- for (const route of routes) {
- if (isDynamicRoute(route) && getRouteRegex(route).re.test(cleanPathname)) {
- return route;
- }
- }
-
- return undefined;
-}
-
-/**
- * Sort provided pages in correct nextjs order.
- * This sorting is required if you are using dynamic routes in your apps.
- * If order is incorrect then Nextjs may use dynamicRoute instead of exact page.
- */
-export function sortNextPages(pages: string[]): string[] {
- const root = new UrlNode();
- pages.forEach((pageRoute) => root.insert(pageRoute));
- // Smoosh will then sort those sublevels up to the point where you get the correct route definition priority
- return root.smoosh();
-}
diff --git a/packages/nextjs-mf/package.json b/packages/nextjs-mf/package.json
index d7772ba9c16..193578d0d61 100644
--- a/packages/nextjs-mf/package.json
+++ b/packages/nextjs-mf/package.json
@@ -1,11 +1,11 @@
{
"name": "@module-federation/nextjs-mf",
- "version": "8.8.56",
+ "version": "9.0.0",
"license": "MIT",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
"type": "commonjs",
- "description": "Module Federation helper for NextJS",
+ "description": "Module Federation for Next.js 16+ (webpack mode)",
"repository": {
"type": "git",
"url": "git+https://github.com/module-federation/core.git",
@@ -17,23 +17,21 @@
],
"files": [
"dist/",
- "README.md"
+ "README.md",
+ "MIGRATION-v9.md"
],
- "scripts": {
- "postinstall": "echo \"Deprecation Notice: We intend to deprecate 'nextjs-mf'. Please see https://github.com/module-federation/core/issues/3153 for more details.\""
- },
"exports": {
".": "./dist/src/index.js",
- "./utils": "./dist/utils/index.js",
- "./*": "./*"
+ "./node": "./dist/node.js",
+ "./package.json": "./package.json"
},
"typesVersions": {
"*": {
".": [
"./dist/src/index.d.ts"
],
- "utils": [
- "./dist/utils/index.d.ts"
+ "node": [
+ "./dist/node.d.ts"
]
}
},
@@ -41,12 +39,11 @@
"access": "public"
},
"dependencies": {
- "fast-glob": "^3.2.11",
- "@module-federation/runtime": "workspace:*",
- "@module-federation/sdk": "workspace:*",
"@module-federation/enhanced": "workspace:*",
"@module-federation/node": "workspace:*",
- "@module-federation/webpack-bundler-runtime": "workspace:*"
+ "@module-federation/runtime": "workspace:*",
+ "@module-federation/sdk": "workspace:*",
+ "fast-glob": "^3.2.11"
},
"devDependencies": {
"@types/btoa": "^1.2.5",
@@ -54,11 +51,11 @@
"@types/react-dom": "^18.3.1"
},
"peerDependencies": {
- "webpack": "^5.40.0",
- "next": "^12 || ^13 || ^14 || ^15",
- "react": "^17 || ^18 || ^19",
- "react-dom": "^17 || ^18 || ^19",
- "styled-jsx": "*"
+ "next": ">=16.0.0",
+ "react": "^18 || ^19",
+ "react-dom": "^18 || ^19",
+ "styled-jsx": "*",
+ "webpack": "^5.40.0"
},
"peerDependenciesMeta": {
"webpack": {
diff --git a/packages/nextjs-mf/project.json b/packages/nextjs-mf/project.json
index 1d6a85b97b8..faaa57a27c5 100644
--- a/packages/nextjs-mf/project.json
+++ b/packages/nextjs-mf/project.json
@@ -29,23 +29,6 @@
{
"command": "nx run nextjs-mf:build-tsc",
"forwardAllArgs": false
- },
- {
- "command": "nx run nextjs-mf:rename-dist-files",
- "forwardAllArgs": false
- }
- ]
- }
- },
- "rename-dist-files": {
- "executor": "nx:run-commands",
- "options": {
- "commands": [
- {
- "command": "mv packages/nextjs-mf/dist/src/federation-noop.js packages/nextjs-mf/dist/src/federation-noop.cjs"
- },
- {
- "command": "mv packages/nextjs-mf/dist/src/plugins/container/runtimePlugin.js packages/nextjs-mf/dist/src/plugins/container/runtimePlugin.cjs"
}
]
}
@@ -82,7 +65,7 @@
"forwardAllArgs": false
},
{
- "command": "rm ./packages/nextjs-mf/dist/package.json",
+ "command": "rm -f ./packages/nextjs-mf/dist/package.json",
"forwardAllArgs": false
}
]
diff --git a/packages/nextjs-mf/src/core/compilers/client.ts b/packages/nextjs-mf/src/core/compilers/client.ts
new file mode 100644
index 00000000000..26ff0bf0ccb
--- /dev/null
+++ b/packages/nextjs-mf/src/core/compilers/client.ts
@@ -0,0 +1,48 @@
+import type { Configuration } from 'webpack';
+import type { moduleFederationPlugin } from '@module-federation/sdk';
+
+function getChunkCorrelationPluginCtor(): typeof import('@module-federation/node').ChunkCorrelationPlugin {
+ const mfNode =
+ require('@module-federation/node') as typeof import('@module-federation/node');
+ return mfNode.ChunkCorrelationPlugin;
+}
+
+function getInvertedContainerPluginCtor(): typeof import('../container/InvertedContainerPlugin').default {
+ return require('../container/InvertedContainerPlugin')
+ .default as typeof import('../container/InvertedContainerPlugin').default;
+}
+
+export function configureClientCompiler(
+ config: Configuration,
+ options: moduleFederationPlugin.ModuleFederationPluginOptions,
+): void {
+ const output = config.output || (config.output = {});
+
+ output.uniqueName = options.name;
+ if (output.publicPath === '/_next/') {
+ output.publicPath = 'auto';
+ }
+ output.environment = {
+ ...output.environment,
+ asyncFunction: true,
+ };
+
+ options.library = {
+ type: 'window',
+ name: options.name,
+ };
+
+ const plugins = config.plugins || [];
+ const ChunkCorrelationPlugin = getChunkCorrelationPluginCtor();
+ const InvertedContainerPlugin = getInvertedContainerPluginCtor();
+ plugins.push(
+ new ChunkCorrelationPlugin({
+ filename: [
+ 'static/chunks/federated-stats.json',
+ 'server/federated-stats.json',
+ ],
+ }),
+ new InvertedContainerPlugin(),
+ );
+ config.plugins = plugins;
+}
diff --git a/packages/nextjs-mf/src/core/compilers/server.ts b/packages/nextjs-mf/src/core/compilers/server.ts
new file mode 100644
index 00000000000..3977b9c9d89
--- /dev/null
+++ b/packages/nextjs-mf/src/core/compilers/server.ts
@@ -0,0 +1,245 @@
+import { promises as fs } from 'fs';
+import path from 'path';
+import type {
+ Configuration,
+ ExternalItemFunctionData,
+ WebpackPluginInstance,
+} from 'webpack';
+import type { moduleFederationPlugin } from '@module-federation/sdk';
+
+function getUniverseEntryChunkTrackerPluginCtor(): typeof import('@module-federation/node/universe-entry-chunk-tracker-plugin').default {
+ const pluginModule =
+ require('@module-federation/node/universe-entry-chunk-tracker-plugin') as typeof import('@module-federation/node/universe-entry-chunk-tracker-plugin');
+ return pluginModule.default;
+}
+
+function getInvertedContainerPluginCtor(): typeof import('../container/InvertedContainerPlugin').default {
+ return require('../container/InvertedContainerPlugin')
+ .default as typeof import('../container/InvertedContainerPlugin').default;
+}
+
+type ExternalsFunction = (
+ data: ExternalItemFunctionData,
+ callback: (
+ error?: Error | null,
+ result?: string | boolean | string[] | Record,
+ ) => void,
+) => Promise | unknown;
+
+function isProtectedExternalRequest(request: string): boolean {
+ return (
+ request.startsWith('next') ||
+ request.startsWith('react/') ||
+ request.startsWith('react-dom/') ||
+ request === 'react' ||
+ request === 'react-dom' ||
+ request === 'styled-jsx/style'
+ );
+}
+
+async function copyDir(source: string, destination: string): Promise {
+ await fs.mkdir(destination, { recursive: true });
+ const entries = await fs.readdir(source, { withFileTypes: true });
+
+ for (const entry of entries) {
+ const sourcePath = path.join(source, entry.name);
+ const destinationPath = path.join(destination, entry.name);
+
+ if (entry.isDirectory()) {
+ await copyDir(sourcePath, destinationPath);
+ continue;
+ }
+
+ await fs.copyFile(sourcePath, destinationPath);
+ }
+}
+
+class ServerRemoteEntryCopyPlugin implements WebpackPluginInstance {
+ apply(compiler: import('webpack').Compiler): void {
+ compiler.hooks.afterEmit.tapPromise(
+ 'ServerRemoteEntryCopyPlugin',
+ async () => {
+ const outputPath = compiler.outputPath;
+ const serverSplitToken = `${path.sep}server`;
+
+ if (!outputPath.includes(serverSplitToken)) {
+ return;
+ }
+
+ const serverIndex = outputPath.lastIndexOf(serverSplitToken);
+ if (serverIndex < 0) {
+ return;
+ }
+
+ const outputRoot = outputPath.slice(0, serverIndex);
+ const destination = path.join(outputRoot, 'static', 'ssr');
+
+ try {
+ await copyDir(outputPath, destination);
+ } catch {
+ // ignore copy failures for unsupported output layouts
+ }
+ },
+ );
+ }
+}
+
+export function configureServerCompiler(
+ config: Configuration,
+ options: moduleFederationPlugin.ModuleFederationPluginOptions,
+): void {
+ const output = config.output || (config.output = {});
+
+ output.uniqueName = options.name;
+ output.environment = {
+ ...output.environment,
+ asyncFunction: true,
+ };
+
+ config.node = {
+ ...config.node,
+ global: false,
+ };
+
+ config.target = 'async-node';
+
+ if (typeof output.chunkFilename === 'string') {
+ const chunkFilename = output.chunkFilename;
+ if (!chunkFilename.includes('[contenthash]')) {
+ output.chunkFilename = chunkFilename.replace('.js', '-[contenthash].js');
+ }
+ }
+
+ options.library = {
+ type: 'commonjs-module',
+ name: options.name,
+ };
+
+ if (typeof options.filename === 'string') {
+ options.filename = path.basename(options.filename);
+ }
+
+ const plugins = config.plugins || [];
+ const UniverseEntryChunkTrackerPlugin =
+ getUniverseEntryChunkTrackerPluginCtor();
+ const InvertedContainerPlugin = getInvertedContainerPluginCtor();
+ plugins.push(
+ new UniverseEntryChunkTrackerPlugin(),
+ new ServerRemoteEntryCopyPlugin(),
+ new InvertedContainerPlugin(),
+ );
+ config.plugins = plugins;
+ handleServerExternals(config, options);
+}
+
+export function handleServerExternals(
+ config: Configuration,
+ options: moduleFederationPlugin.ModuleFederationPluginOptions,
+): void {
+ if (!Array.isArray(config.externals)) {
+ return;
+ }
+
+ const functionExternalIndex = config.externals.findIndex(
+ (external) => typeof external === 'function',
+ );
+
+ if (functionExternalIndex < 0) {
+ return;
+ }
+
+ const originalExternals = config.externals[
+ functionExternalIndex
+ ] as ExternalsFunction;
+
+ (config.externals as any[])[functionExternalIndex] = async (
+ ctx: ExternalItemFunctionData,
+ callback: (error?: Error, result?: string) => void,
+ ) => {
+ const externalResult = await new Promise(
+ (resolve, reject) => {
+ let callbackCalled = false;
+ const wrappedCallback = (
+ error?: Error | null,
+ result?: string | boolean | string[] | Record,
+ ) => {
+ callbackCalled = true;
+ if (error) {
+ reject(error);
+ return;
+ }
+
+ if (typeof result === 'string') {
+ resolve(result);
+ return;
+ }
+
+ resolve(undefined);
+ };
+
+ const maybePromise = originalExternals(ctx, wrappedCallback);
+ if (
+ maybePromise &&
+ typeof (maybePromise as Promise).then === 'function'
+ ) {
+ (maybePromise as Promise)
+ .then((result) => {
+ if (callbackCalled) {
+ return;
+ }
+ resolve(typeof result === 'string' ? result : undefined);
+ })
+ .catch((error) => reject(error as Error));
+ return;
+ }
+
+ if (!callbackCalled) {
+ resolve(typeof maybePromise === 'string' ? maybePromise : undefined);
+ }
+ },
+ );
+
+ if (!externalResult) {
+ return;
+ }
+
+ const resolvedRequest = externalResult.split(' ')[1] || '';
+ const request = ctx.request || '';
+
+ if (request.includes('@module-federation/')) {
+ return;
+ }
+
+ const shared = options.shared || {};
+ const sharedKey = Object.keys(shared).find((key) =>
+ key.endsWith('/')
+ ? resolvedRequest.startsWith(key)
+ : resolvedRequest === key,
+ );
+
+ if (sharedKey) {
+ const sharedConfig = (
+ shared as Record<
+ string,
+ moduleFederationPlugin.SharedConfig | undefined
+ >
+ )[sharedKey];
+
+ if (
+ sharedConfig &&
+ typeof sharedConfig === 'object' &&
+ sharedConfig.import === false
+ ) {
+ return externalResult;
+ }
+
+ return;
+ }
+
+ if (isProtectedExternalRequest(resolvedRequest)) {
+ return externalResult;
+ }
+
+ return;
+ };
+}
diff --git a/packages/nextjs-mf/src/plugins/container/InvertedContainerPlugin.ts b/packages/nextjs-mf/src/core/container/InvertedContainerPlugin.ts
similarity index 55%
rename from packages/nextjs-mf/src/plugins/container/InvertedContainerPlugin.ts
rename to packages/nextjs-mf/src/core/container/InvertedContainerPlugin.ts
index 0331c0ec38f..4ff623fa245 100644
--- a/packages/nextjs-mf/src/plugins/container/InvertedContainerPlugin.ts
+++ b/packages/nextjs-mf/src/core/container/InvertedContainerPlugin.ts
@@ -1,29 +1,33 @@
-import type { Compilation, Compiler, Chunk } from 'webpack';
-import InvertedContainerRuntimeModule from './InvertedContainerRuntimeModule';
-import {
- FederationModulesPlugin,
- dependencies,
-} from '@module-federation/enhanced';
+import type { Compilation, Compiler } from 'webpack';
class InvertedContainerPlugin {
- public apply(compiler: Compiler): void {
+ apply(compiler: Compiler): void {
+ const enhanced =
+ require('@module-federation/enhanced') as typeof import('@module-federation/enhanced');
+ const FederationModulesPlugin = enhanced.FederationModulesPlugin;
+ const dependencies = enhanced.dependencies;
+ const InvertedContainerRuntimeModule =
+ require('./InvertedContainerRuntimeModule')
+ .default as typeof import('./InvertedContainerRuntimeModule').default;
+
compiler.hooks.thisCompilation.tap(
- 'EmbeddedContainerPlugin',
+ 'InvertedContainerPlugin',
(compilation: Compilation) => {
const hooks = FederationModulesPlugin.getCompilationHooks(compilation);
const containers = new Set();
+
hooks.addContainerEntryDependency.tap(
- 'EmbeddedContainerPlugin',
+ 'InvertedContainerPlugin',
(dependency) => {
if (dependency instanceof dependencies.ContainerEntryDependency) {
containers.add(dependency);
}
},
);
- // Adding the runtime module
+
compilation.hooks.additionalTreeRuntimeRequirements.tap(
- 'EmbeddedContainerPlugin',
- (chunk, set) => {
+ 'InvertedContainerPlugin',
+ (chunk) => {
compilation.addRuntimeModule(
chunk,
new InvertedContainerRuntimeModule({
diff --git a/packages/nextjs-mf/src/plugins/container/InvertedContainerRuntimeModule.ts b/packages/nextjs-mf/src/core/container/InvertedContainerRuntimeModule.ts
similarity index 61%
rename from packages/nextjs-mf/src/plugins/container/InvertedContainerRuntimeModule.ts
rename to packages/nextjs-mf/src/core/container/InvertedContainerRuntimeModule.ts
index 1a223fe2c0d..5c994b829de 100644
--- a/packages/nextjs-mf/src/plugins/container/InvertedContainerRuntimeModule.ts
+++ b/packages/nextjs-mf/src/core/container/InvertedContainerRuntimeModule.ts
@@ -1,12 +1,11 @@
import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-path';
-import type ContainerEntryModule from '@module-federation/enhanced/src/lib/container/ContainerEntryModule';
+
const { RuntimeModule, Template, RuntimeGlobals } = require(
normalizeWebpackPath('webpack'),
) as typeof import('webpack');
interface InvertedContainerRuntimeModuleOptions {
- name?: string;
- containers: Set; // Adjust the type as necessary
+ containers: Set;
}
class InvertedContainerRuntimeModule extends RuntimeModule {
@@ -22,67 +21,62 @@ class InvertedContainerRuntimeModule extends RuntimeModule {
if (!compilation || !chunk || !chunkGraph) {
return '';
}
+
if (chunk.runtime === 'webpack-api-runtime') {
return '';
}
- const runtimeChunk = compilation.options.optimization?.runtimeChunk;
- if (typeof runtimeChunk === 'object' && runtimeChunk !== null) {
- const logger = compilation.getLogger('InvertedContainerRuntimeModule');
- logger.info(
- 'Runtime chunk is configured. Consider adding runtime: false to your ModuleFederationPlugin configuration to prevent runtime conflicts.',
- );
- }
-
- let containerEntryModule;
+ let containerEntryModule: any;
for (const containerDep of this.options.containers) {
const mod = compilation.moduleGraph.getModule(containerDep);
- if (!mod) continue;
+ if (!mod) {
+ continue;
+ }
if (chunkGraph.isModuleInChunk(mod, chunk)) {
- containerEntryModule = mod as ContainerEntryModule;
+ containerEntryModule = mod;
}
}
- if (!containerEntryModule) return '';
+ if (!containerEntryModule) {
+ return '';
+ }
if (
compilation.chunkGraph.isEntryModuleInChunk(containerEntryModule, chunk)
) {
- // Don't apply to the remote entry itself
return '';
}
+
const initRuntimeModuleGetter = compilation.runtimeTemplate.moduleRaw({
module: containerEntryModule,
chunkGraph,
weak: false,
runtimeRequirements: new Set(),
});
-
- //@ts-ignore
const nameJSON = JSON.stringify(containerEntryModule._name);
return Template.asString([
`var prevStartup = ${RuntimeGlobals.startup};`,
- `var hasRun = false;`,
- `var cachedRemote;`,
+ 'var hasRun = false;',
+ 'var cachedRemote;',
`${RuntimeGlobals.startup} = ${compilation.runtimeTemplate.basicFunction(
'',
Template.asString([
- `if (!hasRun) {`,
+ 'if (!hasRun) {',
Template.indent(
Template.asString([
- `hasRun = true;`,
- `if (typeof prevStartup === 'function') {`,
- Template.indent(Template.asString([`prevStartup();`])),
- `}`,
+ 'hasRun = true;',
+ "if (typeof prevStartup === 'function') {",
+ Template.indent('prevStartup();'),
+ '}',
`cachedRemote = ${initRuntimeModuleGetter};`,
`var gs = ${RuntimeGlobals.global} || globalThis;`,
`gs[${nameJSON}] = cachedRemote;`,
]),
),
- `} else if (typeof prevStartup === 'function') {`,
- Template.indent(`prevStartup();`),
- `}`,
+ "} else if (typeof prevStartup === 'function') {",
+ Template.indent('prevStartup();'),
+ '}',
]),
)};`,
]);
diff --git a/packages/nextjs-mf/src/core/errors.ts b/packages/nextjs-mf/src/core/errors.ts
new file mode 100644
index 00000000000..a656640f02b
--- /dev/null
+++ b/packages/nextjs-mf/src/core/errors.ts
@@ -0,0 +1,36 @@
+export type NextFederationErrorCode =
+ | 'NMF001'
+ | 'NMF002'
+ | 'NMF003'
+ | 'NMF004'
+ | 'NMF005';
+
+const DEFAULT_MESSAGES: Record = {
+ NMF001:
+ 'Webpack mode is required. Run Next with --webpack (for example: next build --webpack or next dev --webpack).',
+ NMF002:
+ 'NEXT_PRIVATE_LOCAL_WEBPACK must be enabled and webpack must be installed in the host app.',
+ NMF003:
+ 'Edge runtime federation is unsupported in nextjs-mf v9. Only Node runtime federation is supported.',
+ NMF004:
+ 'Unsupported App Router federation target detected. Route handlers, middleware, and server actions are not supported.',
+ NMF005:
+ 'Legacy nextjs-mf options were detected. Migrate from legacy extraOptions/utils to the v9 API.',
+};
+
+export class NextFederationError extends Error {
+ public readonly code: NextFederationErrorCode;
+
+ constructor(code: NextFederationErrorCode, message?: string) {
+ super(`[${code}] ${message || DEFAULT_MESSAGES[code]}`);
+ this.name = 'NextFederationError';
+ this.code = code;
+ }
+}
+
+export function createNextFederationError(
+ code: NextFederationErrorCode,
+ message?: string,
+): NextFederationError {
+ return new NextFederationError(code, message);
+}
diff --git a/packages/nextjs-mf/src/core/features/app.test.ts b/packages/nextjs-mf/src/core/features/app.test.ts
new file mode 100644
index 00000000000..c4e537a4e00
--- /dev/null
+++ b/packages/nextjs-mf/src/core/features/app.test.ts
@@ -0,0 +1,103 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import {
+ assertModeRouterCompatibility,
+ assertUnsupportedAppRouterTargets,
+ detectRouterPresence,
+} from './app';
+
+function createTempAppDir(): string {
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'nextjs-mf-v9-test-'));
+}
+
+describe('core/features/app', () => {
+ it('detects pages and app routers by directory', () => {
+ const cwd = createTempAppDir();
+ fs.mkdirSync(path.join(cwd, 'pages'), { recursive: true });
+ fs.mkdirSync(path.join(cwd, 'app'), { recursive: true });
+
+ expect(detectRouterPresence(cwd)).toEqual({ hasPages: true, hasApp: true });
+ });
+
+ it('throws when mode pages is used with app router', () => {
+ expect(() => assertModeRouterCompatibility('pages', true)).toThrow(
+ '[NMF004]',
+ );
+ });
+
+ it('throws on route handler exposes', () => {
+ const cwd = createTempAppDir();
+ fs.mkdirSync(path.join(cwd, 'app', 'api', 'health'), { recursive: true });
+ fs.writeFileSync(
+ path.join(cwd, 'app', 'api', 'health', 'route.ts'),
+ 'export const GET = () => new Response("ok")',
+ );
+
+ expect(() =>
+ assertUnsupportedAppRouterTargets(cwd, {
+ './health': './app/api/health/route.ts',
+ }),
+ ).toThrow('[NMF004]');
+ });
+
+ it('throws on extensionless route handler exposes', () => {
+ const cwd = createTempAppDir();
+ fs.mkdirSync(path.join(cwd, 'app', 'api', 'health'), { recursive: true });
+ fs.writeFileSync(
+ path.join(cwd, 'app', 'api', 'health', 'route.ts'),
+ 'export const GET = () => new Response("ok")',
+ );
+
+ expect(() =>
+ assertUnsupportedAppRouterTargets(cwd, {
+ './health': './app/api/health/route',
+ }),
+ ).toThrow('[NMF004]');
+ });
+
+ it('throws on aliased route handler exposes', () => {
+ const cwd = createTempAppDir();
+ fs.mkdirSync(path.join(cwd, 'app', 'api', 'health'), { recursive: true });
+ fs.writeFileSync(
+ path.join(cwd, 'app', 'api', 'health', 'route.ts'),
+ 'export const GET = () => new Response("ok")',
+ );
+
+ expect(() =>
+ assertUnsupportedAppRouterTargets(cwd, {
+ './health': '@/app/api/health/route',
+ }),
+ ).toThrow('[NMF004]');
+ });
+
+ it('does not treat similarly named files as route handlers', () => {
+ const cwd = createTempAppDir();
+ fs.mkdirSync(path.join(cwd, 'app', 'api'), { recursive: true });
+ fs.writeFileSync(
+ path.join(cwd, 'app', 'api', 'routes.ts'),
+ 'export const routes = [];',
+ );
+
+ expect(() =>
+ assertUnsupportedAppRouterTargets(cwd, {
+ './routes': './app/api/routes.ts',
+ }),
+ ).not.toThrow();
+ });
+
+ it('throws on use server exposes', () => {
+ const cwd = createTempAppDir();
+ fs.mkdirSync(path.join(cwd, 'app', 'actions'), { recursive: true });
+ fs.writeFileSync(
+ path.join(cwd, 'app', 'actions', 'save.ts'),
+ `'use server';\nexport async function save() {}`,
+ );
+
+ expect(() =>
+ assertUnsupportedAppRouterTargets(cwd, {
+ './save': './app/actions/save.ts',
+ }),
+ ).toThrow('[NMF004]');
+ });
+});
diff --git a/packages/nextjs-mf/src/core/features/app.ts b/packages/nextjs-mf/src/core/features/app.ts
new file mode 100644
index 00000000000..40b39f0d403
--- /dev/null
+++ b/packages/nextjs-mf/src/core/features/app.ts
@@ -0,0 +1,209 @@
+import fs from 'fs';
+import path from 'path';
+import type { moduleFederationPlugin } from '@module-federation/sdk';
+import { createNextFederationError } from '../errors';
+import type { NextFederationMode, RouterPresence } from '../../types';
+
+function isObject(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null;
+}
+
+function extractExposeRequests(
+ exposes: moduleFederationPlugin.ModuleFederationPluginOptions['exposes'],
+): string[] {
+ if (!exposes) {
+ return [];
+ }
+
+ const requests: string[] = [];
+
+ const pushRequest = (value: unknown): void => {
+ if (typeof value === 'string') {
+ requests.push(value);
+ return;
+ }
+
+ if (Array.isArray(value)) {
+ value.forEach((item) => pushRequest(item));
+ return;
+ }
+
+ if (isObject(value) && 'import' in value) {
+ pushRequest((value as { import?: unknown }).import);
+ }
+ };
+
+ if (Array.isArray(exposes)) {
+ exposes.forEach((entry) => {
+ pushRequest(entry);
+ });
+ return requests;
+ }
+
+ if (isObject(exposes)) {
+ Object.values(exposes).forEach((entry) => {
+ pushRequest(entry);
+ });
+ }
+
+ return requests;
+}
+
+function readFileHead(filePath: string): string {
+ try {
+ return fs.readFileSync(filePath, 'utf-8').slice(0, 512);
+ } catch {
+ return '';
+ }
+}
+
+function hasUseServerDirective(filePath: string): boolean {
+ const head = readFileHead(filePath);
+ return /^\s*['"]use server['"];?/m.test(head);
+}
+
+function isRouteHandler(filePath: string): boolean {
+ const normalized = filePath.replace(/\\/g, '/');
+ const segments = normalized.split('/').filter(Boolean);
+
+ if (segments.length < 2) {
+ return false;
+ }
+
+ const lastSegment = segments[segments.length - 1];
+ if (!/^route\.[jt]sx?$/.test(lastSegment)) {
+ return false;
+ }
+
+ return segments.slice(0, -1).includes('app');
+}
+
+function isMiddleware(filePath: string): boolean {
+ return /(^|[/\\])middleware\.[jt]sx?$/.test(filePath);
+}
+
+function toPathCandidates(basePath: string): string[] {
+ return [
+ basePath,
+ `${basePath}.ts`,
+ `${basePath}.tsx`,
+ `${basePath}.js`,
+ `${basePath}.jsx`,
+ path.join(basePath, 'index.ts'),
+ path.join(basePath, 'index.tsx'),
+ path.join(basePath, 'index.js'),
+ path.join(basePath, 'index.jsx'),
+ ];
+}
+
+function getRequestBasePaths(cwd: string, request: string): string[] {
+ const normalizedRequest = request.replace(/\\/g, '/');
+
+ if (path.isAbsolute(request)) {
+ return [request];
+ }
+
+ if (request.startsWith('.')) {
+ return [path.resolve(cwd, request)];
+ }
+
+ if (normalizedRequest.startsWith('@/')) {
+ const aliasPath = normalizedRequest.slice(2);
+ return [path.resolve(cwd, aliasPath), path.resolve(cwd, 'src', aliasPath)];
+ }
+
+ if (normalizedRequest.startsWith('~/')) {
+ return [path.resolve(cwd, normalizedRequest.slice(2))];
+ }
+
+ if (
+ normalizedRequest.startsWith('app/') ||
+ normalizedRequest.startsWith('src/app/') ||
+ normalizedRequest.startsWith('pages/') ||
+ normalizedRequest.startsWith('src/pages/') ||
+ normalizedRequest.startsWith('middleware.')
+ ) {
+ return [path.resolve(cwd, normalizedRequest)];
+ }
+
+ return [];
+}
+
+function resolveLocalPath(cwd: string, request: string): string | null {
+ const basePaths = getRequestBasePaths(cwd, request);
+ const candidates = basePaths.flatMap((basePath) =>
+ toPathCandidates(basePath),
+ );
+
+ for (const candidate of candidates) {
+ try {
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
+ return candidate;
+ }
+ } catch {
+ continue;
+ }
+ }
+
+ return null;
+}
+
+export function detectRouterPresence(cwd: string): RouterPresence {
+ const pagesDir = path.join(cwd, 'pages');
+ const srcPagesDir = path.join(cwd, 'src/pages');
+ const appDir = path.join(cwd, 'app');
+ const srcAppDir = path.join(cwd, 'src/app');
+
+ return {
+ hasPages: fs.existsSync(pagesDir) || fs.existsSync(srcPagesDir),
+ hasApp: fs.existsSync(appDir) || fs.existsSync(srcAppDir),
+ };
+}
+
+export function assertModeRouterCompatibility(
+ mode: NextFederationMode,
+ hasAppRouter: boolean,
+): void {
+ if (mode === 'pages' && hasAppRouter) {
+ throw createNextFederationError(
+ 'NMF004',
+ 'mode="pages" cannot be used when an App Router directory exists. Use mode="hybrid" or mode="app".',
+ );
+ }
+}
+
+export function assertUnsupportedAppRouterTargets(
+ cwd: string,
+ exposes: moduleFederationPlugin.ModuleFederationPluginOptions['exposes'],
+): void {
+ const requests = extractExposeRequests(exposes);
+
+ for (const request of requests) {
+ const localPath = resolveLocalPath(cwd, request);
+
+ if (!localPath) {
+ continue;
+ }
+
+ if (isRouteHandler(localPath)) {
+ throw createNextFederationError(
+ 'NMF004',
+ `Route handlers are unsupported in v9 app-router beta: ${request}`,
+ );
+ }
+
+ if (isMiddleware(localPath)) {
+ throw createNextFederationError(
+ 'NMF004',
+ `Middleware federation is unsupported in v9 app-router beta: ${request}`,
+ );
+ }
+
+ if (hasUseServerDirective(localPath)) {
+ throw createNextFederationError(
+ 'NMF004',
+ `Server actions are unsupported in v9 app-router beta: ${request}`,
+ );
+ }
+ }
+}
diff --git a/packages/nextjs-mf/src/core/features/pages-map-loader.test.ts b/packages/nextjs-mf/src/core/features/pages-map-loader.test.ts
new file mode 100644
index 00000000000..8ace4ed3142
--- /dev/null
+++ b/packages/nextjs-mf/src/core/features/pages-map-loader.test.ts
@@ -0,0 +1,74 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import * as vm from 'vm';
+import type { LoaderContext } from 'webpack';
+import pagesMapLoader from './pages-map-loader';
+
+function createTempAppDir(): string {
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'nextjs-mf-pages-map-test-'));
+}
+
+function compilePagesMap(
+ rootContext: string,
+ useV2: boolean,
+): Record {
+ let generatedSource = '';
+ pagesMapLoader.call({
+ getOptions: () => (useV2 ? { v2: true } : {}),
+ rootContext,
+ callback: (_error: Error | null | undefined, source?: string) => {
+ generatedSource = source || '';
+ },
+ } as unknown as LoaderContext>);
+
+ const sandbox = {
+ module: { exports: {} as { default?: Record } },
+ exports: {},
+ };
+ vm.runInNewContext(generatedSource, sandbox);
+ return sandbox.module.exports.default || {};
+}
+
+describe('core/features/pages-map-loader', () => {
+ it('sorts dynamic routes ahead of optional catch-all routes', () => {
+ const cwd = createTempAppDir();
+ fs.mkdirSync(path.join(cwd, 'pages', 'blog'), { recursive: true });
+ fs.writeFileSync(
+ path.join(cwd, 'pages', 'blog', 'index.tsx'),
+ 'export default function Page() { return null; }',
+ );
+ fs.writeFileSync(
+ path.join(cwd, 'pages', 'blog', '[slug].tsx'),
+ 'export default function Page() { return null; }',
+ );
+ fs.writeFileSync(
+ path.join(cwd, 'pages', 'blog', '[[...slug]].tsx'),
+ 'export default function Page() { return null; }',
+ );
+
+ const map = compilePagesMap(cwd, true);
+ expect(Object.keys(map)).toEqual([
+ '/blog',
+ '/blog/[slug]',
+ '/blog/[[...slug]]',
+ ]);
+ });
+
+ it('prioritizes static segments over dynamic segments at the same depth', () => {
+ const cwd = createTempAppDir();
+ fs.mkdirSync(path.join(cwd, 'pages', 'foo', '[id]'), { recursive: true });
+ fs.mkdirSync(path.join(cwd, 'pages', 'foo', 'bar'), { recursive: true });
+ fs.writeFileSync(
+ path.join(cwd, 'pages', 'foo', '[id]', 'bar.tsx'),
+ 'export default function Page() { return null; }',
+ );
+ fs.writeFileSync(
+ path.join(cwd, 'pages', 'foo', 'bar', '[id].tsx'),
+ 'export default function Page() { return null; }',
+ );
+
+ const map = compilePagesMap(cwd, true);
+ expect(Object.keys(map)).toEqual(['/foo/bar/[id]', '/foo/[id]/bar']);
+ });
+});
diff --git a/packages/nextjs-mf/src/core/features/pages-map-loader.ts b/packages/nextjs-mf/src/core/features/pages-map-loader.ts
new file mode 100644
index 00000000000..f02f13b8f8b
--- /dev/null
+++ b/packages/nextjs-mf/src/core/features/pages-map-loader.ts
@@ -0,0 +1,175 @@
+import type { LoaderContext } from 'webpack';
+import fg from 'fast-glob';
+import fs from 'fs';
+
+const PAGE_EXTENSION_PATTERN = /\.(ts|tsx|js|jsx)$/i;
+
+function getPagesRoot(appRoot: string): [string, string] {
+ const srcPages = `${appRoot}/src/pages`;
+ if (fs.existsSync(srcPages)) {
+ return [srcPages, 'src/pages'];
+ }
+ return [`${appRoot}/pages`, 'pages'];
+}
+
+function discoverPages(rootDir: string): string[] {
+ const [absolutePagesRoot, relativePagesRoot] = getPagesRoot(rootDir);
+
+ if (!fs.existsSync(absolutePagesRoot)) {
+ return [];
+ }
+
+ const pageFiles = fg.sync('**/*.{ts,tsx,js,jsx}', {
+ cwd: absolutePagesRoot,
+ onlyFiles: true,
+ ignore: ['api/**'],
+ });
+
+ return pageFiles
+ .filter((file) => {
+ return ![/^_app\./, /^_document\./, /^_error\./, /^404\./, /^500\./].some(
+ (pattern) => pattern.test(file),
+ );
+ })
+ .map((file) => `${relativePagesRoot}/${file}`);
+}
+
+function sanitizePagePath(page: string): string {
+ return page
+ .replace(/^src\/pages\//i, 'pages/')
+ .replace(PAGE_EXTENSION_PATTERN, '');
+}
+
+function normalizeRoute(route: string, format: 'legacy' | 'routes-v2'): string {
+ const cleaned =
+ route.replace(/^\/pages\//, '/').replace(/\/index$/, '') || '/';
+
+ if (format === 'routes-v2') {
+ return cleaned;
+ }
+
+ return cleaned
+ .replace(/\[\.\.\.[^\]]+\]/g, '*')
+ .replace(/\[([^\]]+)\]/g, ':$1');
+}
+
+type RouteSegmentKind = 'static' | 'dynamic' | 'catchAll' | 'optionalCatchAll';
+
+function getSegmentKind(segment: string): RouteSegmentKind {
+ if (/^\[\[\.\.\.[^\]]+\]\]$/.test(segment)) {
+ return 'optionalCatchAll';
+ }
+ if (/^\[\.\.\.[^\]]+\]$/.test(segment)) {
+ return 'catchAll';
+ }
+ if (/^\[[^\]]+\]$/.test(segment)) {
+ return 'dynamic';
+ }
+ return 'static';
+}
+
+function getSegmentSpecificity(kind: RouteSegmentKind): number {
+ switch (kind) {
+ case 'static':
+ return 0;
+ case 'dynamic':
+ return 1;
+ case 'catchAll':
+ return 2;
+ case 'optionalCatchAll':
+ return 3;
+ default:
+ return 4;
+ }
+}
+
+function compareRouteSpecificity(left: string, right: string): number {
+ const leftSegments = left.split('/').filter(Boolean);
+ const rightSegments = right.split('/').filter(Boolean);
+ const maxLength = Math.max(leftSegments.length, rightSegments.length);
+
+ for (let index = 0; index < maxLength; index += 1) {
+ const leftSegment = leftSegments[index];
+ const rightSegment = rightSegments[index];
+
+ if (leftSegment === undefined) {
+ return -1;
+ }
+ if (rightSegment === undefined) {
+ return 1;
+ }
+
+ const leftKind = getSegmentKind(leftSegment);
+ const rightKind = getSegmentKind(rightSegment);
+ const leftSpecificity = getSegmentSpecificity(leftKind);
+ const rightSpecificity = getSegmentSpecificity(rightKind);
+
+ if (leftSpecificity !== rightSpecificity) {
+ return leftSpecificity - rightSpecificity;
+ }
+
+ if (leftSegment !== rightSegment) {
+ return leftSegment.localeCompare(rightSegment);
+ }
+ }
+
+ return left.localeCompare(right);
+}
+
+function sortPagesForMatchPriority(routes: string[]): string[] {
+ return [...routes].sort(compareRouteSpecificity);
+}
+
+function createPagesMap(
+ pages: string[],
+ format: 'legacy' | 'routes-v2',
+): Record {
+ const routes = pages.map((page) => `/${sanitizePagePath(page)}`);
+ const sortedRoutes = sortPagesForMatchPriority(routes);
+
+ return sortedRoutes.reduce(
+ (acc, route) => {
+ const mappedRoute = normalizeRoute(route, format);
+ acc[mappedRoute] = `.${route}`;
+ return acc;
+ },
+ {} as Record,
+ );
+}
+
+export function exposeNextPages(
+ cwd: string,
+ pageMapFormat: 'legacy' | 'routes-v2',
+): Record {
+ const pages = discoverPages(cwd);
+ const exposeMap = pages.reduce(
+ (acc, page) => {
+ acc[`./${sanitizePagePath(page)}`] = `./${page}`;
+ return acc;
+ },
+ {} as Record,
+ );
+
+ const loaderPath = __filename;
+ const includeLegacyMap = pageMapFormat === 'legacy';
+
+ return {
+ './pages-map': `${loaderPath}${includeLegacyMap ? '' : '?v2'}!${loaderPath}`,
+ './pages-map-v2': `${loaderPath}?v2!${loaderPath}`,
+ ...exposeMap,
+ };
+}
+
+export default function pagesMapLoader(
+ this: LoaderContext>,
+): void {
+ const options = this.getOptions();
+ const pageMapFormat = Object.prototype.hasOwnProperty.call(options, 'v2')
+ ? 'routes-v2'
+ : 'legacy';
+
+ const pages = discoverPages(this.rootContext);
+ const map = createPagesMap(pages, pageMapFormat);
+
+ this.callback(null, `module.exports = { default: ${JSON.stringify(map)} };`);
+}
diff --git a/packages/nextjs-mf/src/core/features/pages.ts b/packages/nextjs-mf/src/core/features/pages.ts
new file mode 100644
index 00000000000..540d87c4a82
--- /dev/null
+++ b/packages/nextjs-mf/src/core/features/pages.ts
@@ -0,0 +1,8 @@
+import { exposeNextPages } from './pages-map-loader';
+
+export function buildPagesExposes(
+ cwd: string,
+ pageMapFormat: 'legacy' | 'routes-v2',
+): Record {
+ return exposeNextPages(cwd, pageMapFormat);
+}
diff --git a/packages/nextjs-mf/src/core/loaders/asset-loader-fixes.test.ts b/packages/nextjs-mf/src/core/loaders/asset-loader-fixes.test.ts
new file mode 100644
index 00000000000..4bc53dde197
--- /dev/null
+++ b/packages/nextjs-mf/src/core/loaders/asset-loader-fixes.test.ts
@@ -0,0 +1,115 @@
+import type { LoaderContext } from 'webpack';
+import { fixNextImageLoader } from './fixNextImageLoader';
+import fixUrlLoader from './fixUrlLoader';
+
+function createLoaderContext({
+ compilerName = 'server',
+ moduleExport = {
+ src: '/_next/static/media/webpack.png',
+ width: 200,
+ },
+}: {
+ compilerName?: string;
+ moduleExport?: unknown;
+} = {}): {
+ context: LoaderContext>;
+ cacheable: jest.Mock;
+ importModule: jest.Mock;
+} {
+ const cacheable = jest.fn();
+ const importModule = jest.fn().mockResolvedValue({ default: moduleExport });
+
+ const context = {
+ cacheable,
+ importModule,
+ resourcePath: '/tmp/webpack.png',
+ _compiler: {
+ options: { name: compilerName },
+ webpack: {
+ RuntimeGlobals: {
+ publicPath: '__webpack_require__.p',
+ },
+ },
+ },
+ } as unknown as LoaderContext>;
+
+ return { context, cacheable, importModule };
+}
+
+describe('core/loaders asset prefix fixes', () => {
+ it('injects federation-aware runtime prefix for url-loader exports', () => {
+ const content = 'export default "/_next/static/media/webpack.svg";';
+ const transformed = fixUrlLoader(content);
+
+ expect(transformed).toContain('resolveFederatedAssetPrefix');
+ expect(transformed).toContain("if (!hasRemoteEntry) return '';");
+ expect(transformed).toContain(' + "/_next/static/media/webpack.svg";');
+ });
+
+ it('keeps non-matching url-loader output unchanged', () => {
+ const content = 'module.exports = "/_next/static/media/webpack.svg";';
+ expect(fixUrlLoader(content)).toBe(content);
+ });
+
+ it('generates server asset-prefix guards for next-image-loader modules', async () => {
+ const { context, cacheable, importModule } = createLoaderContext({
+ compilerName: 'server',
+ moduleExport: {
+ src: '/_next/static/media/webpack.png',
+ width: 120,
+ },
+ });
+
+ const transformed = await fixNextImageLoader.call(
+ context,
+ 'next-image-loader?name=webpack.png',
+ );
+
+ expect(cacheable).toHaveBeenCalledWith(true);
+ expect(importModule).toHaveBeenCalledTimes(1);
+ expect(transformed).toContain('resolveServerAssetPrefix');
+ expect(transformed).toContain("if (hasFederationInstance) return '';");
+ expect(transformed).toContain(
+ '__nextmf_asset_prefix__ + "/_next/static/media/webpack.png"',
+ );
+ expect(transformed).toContain('"width": 120');
+ });
+
+ it('generates client asset-prefix guards for next-image-loader modules', async () => {
+ const { context } = createLoaderContext({
+ compilerName: 'client',
+ moduleExport: {
+ src: '/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fwebpack.png&w=384&q=75',
+ },
+ });
+
+ const transformed = await fixNextImageLoader.call(
+ context,
+ 'next-image-loader?name=webpack.png',
+ );
+
+ expect(transformed).toContain('resolveClientAssetPrefix');
+ expect(transformed).toContain("if (hasFederationInstance) return '';");
+ expect(transformed).toContain(
+ 'const currentScript = document.currentScript && document.currentScript.src;',
+ );
+ expect(transformed).toContain(
+ '__nextmf_asset_prefix__ + "/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fwebpack.png&w=384&q=75"',
+ );
+ });
+
+ it('passes through non-object next-image-loader exports', async () => {
+ const { context } = createLoaderContext({
+ moduleExport: '/_next/static/media/webpack.png',
+ });
+
+ const transformed = await fixNextImageLoader.call(
+ context,
+ 'next-image-loader?name=webpack.png',
+ );
+
+ expect(transformed).toBe(
+ 'export default "/_next/static/media/webpack.png";',
+ );
+ });
+});
diff --git a/packages/nextjs-mf/src/core/loaders/fixNextImageLoader.ts b/packages/nextjs-mf/src/core/loaders/fixNextImageLoader.ts
new file mode 100644
index 00000000000..78001398620
--- /dev/null
+++ b/packages/nextjs-mf/src/core/loaders/fixNextImageLoader.ts
@@ -0,0 +1,210 @@
+import type { LoaderContext } from 'webpack';
+import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-path';
+
+const { Template } = require(
+ normalizeWebpackPath('webpack'),
+) as typeof import('webpack');
+
+type ImageModuleShape = Record;
+
+function buildServerAssetPrefixExpression(publicPathRef: string): string {
+ return Template.asString([
+ '(function resolveServerAssetPrefix(){',
+ Template.indent([
+ `const publicPath = ${publicPathRef};`,
+ "let assetPrefix = '';",
+ 'let hasFederationInstance = false;',
+ 'try {',
+ Template.indent([
+ "const globalThisVal = new Function('return globalThis')();",
+ 'const federationRoot = globalThisVal.__FEDERATION__;',
+ 'if (federationRoot && Array.isArray(federationRoot.__INSTANCES__)) {',
+ Template.indent([
+ 'const currentInstance = __webpack_require__.federation && __webpack_require__.federation.instance;',
+ "const name = currentInstance && typeof currentInstance.name === 'string' ? currentInstance.name : '';",
+ 'if (name) {',
+ Template.indent([
+ 'hasFederationInstance = true;',
+ 'for (const instance of federationRoot.__INSTANCES__) {',
+ Template.indent([
+ 'if (!instance) continue;',
+ 'const moduleCache = instance.moduleCache;',
+ 'if (moduleCache && moduleCache.get) {',
+ Template.indent([
+ 'const container = moduleCache.get(name);',
+ 'const remoteInfo = container && container.remoteInfo;',
+ "const remoteEntry = remoteInfo && typeof remoteInfo.entry === 'string' ? remoteInfo.entry : '';",
+ "if (remoteEntry.includes('/_next/')) {",
+ Template.indent([
+ "assetPrefix = remoteEntry.slice(0, remoteEntry.lastIndexOf('/_next/'));",
+ 'break;',
+ ]),
+ '}',
+ ]),
+ '}',
+ ]),
+ '}',
+ ]),
+ '}',
+ ]),
+ '}',
+ ]),
+ '} catch (_error) {}',
+ 'if (assetPrefix) return assetPrefix;',
+ "if (hasFederationInstance) return '';",
+ "if (typeof publicPath === 'string' && publicPath.includes('://') && publicPath.includes('/_next/')) {",
+ Template.indent([
+ "return publicPath.slice(0, publicPath.lastIndexOf('/_next/'));",
+ ]),
+ '}',
+ "return '';",
+ ]),
+ '})()',
+ ]);
+}
+
+function buildClientAssetPrefixExpression(publicPathRef: string): string {
+ return Template.asString([
+ '(function resolveClientAssetPrefix(){',
+ Template.indent([
+ 'try {',
+ Template.indent([
+ `const publicPath = ${publicPathRef};`,
+ "let assetPrefix = '';",
+ 'let hasFederationInstance = false;',
+ 'try {',
+ Template.indent([
+ "const globalThisVal = new Function('return globalThis')();",
+ 'const federationRoot = globalThisVal.__FEDERATION__;',
+ 'if (federationRoot && Array.isArray(federationRoot.__INSTANCES__)) {',
+ Template.indent([
+ 'const currentInstance = __webpack_require__.federation && __webpack_require__.federation.instance;',
+ "const name = currentInstance && typeof currentInstance.name === 'string' ? currentInstance.name : '';",
+ 'if (name) {',
+ Template.indent([
+ 'hasFederationInstance = true;',
+ 'for (const instance of federationRoot.__INSTANCES__) {',
+ Template.indent([
+ 'if (!instance) continue;',
+ 'const moduleCache = instance.moduleCache;',
+ 'if (moduleCache && moduleCache.get) {',
+ Template.indent([
+ 'const container = moduleCache.get(name);',
+ 'const remoteInfo = container && container.remoteInfo;',
+ "const remoteEntry = remoteInfo && typeof remoteInfo.entry === 'string' ? remoteInfo.entry : '';",
+ "if (remoteEntry.includes('/_next/')) {",
+ Template.indent([
+ "assetPrefix = remoteEntry.slice(0, remoteEntry.lastIndexOf('/_next/'));",
+ 'break;',
+ ]),
+ '}',
+ ]),
+ '}',
+ ]),
+ '}',
+ ]),
+ '}',
+ ]),
+ '}',
+ ]),
+ '} catch (_error) {}',
+ 'if (assetPrefix) return assetPrefix;',
+ "if (hasFederationInstance) return '';",
+ "if (typeof publicPath === 'string' && publicPath.includes('://') && publicPath.includes('/_next/')) {",
+ Template.indent([
+ "assetPrefix = publicPath.slice(0, publicPath.lastIndexOf('/_next/'));",
+ ]),
+ '}',
+ 'if (!assetPrefix) {',
+ Template.indent([
+ 'const currentScript = document.currentScript && document.currentScript.src;',
+ "if (typeof currentScript === 'string' && currentScript.includes('/_next/')) {",
+ Template.indent([
+ "assetPrefix = currentScript.slice(0, currentScript.lastIndexOf('/_next/'));",
+ ]),
+ '}',
+ ]),
+ '}',
+ 'return assetPrefix;',
+ ]),
+ '} catch (_error) {}',
+ "return '';",
+ ]),
+ '})()',
+ ]);
+}
+
+function shouldPrefixValue(value: unknown): value is string {
+ if (typeof value !== 'string') {
+ return false;
+ }
+
+ if (value.startsWith('http://') || value.startsWith('https://')) {
+ return false;
+ }
+
+ return (
+ value.startsWith('/_next/') ||
+ value.startsWith('/_next/image?') ||
+ value.includes('%2F_next%2F')
+ );
+}
+
+function toLiteralValue(value: unknown): string {
+ return JSON.stringify(value);
+}
+
+export async function fixNextImageLoader(
+ this: LoaderContext>,
+ remaining: string,
+): Promise {
+ this.cacheable(true);
+
+ const isServer = this._compiler?.options?.name !== 'client';
+ const publicPathRef =
+ this._compiler?.webpack?.RuntimeGlobals?.publicPath ?? '';
+
+ const result = await this.importModule(
+ `${this.resourcePath}.webpack[javascript/auto]!=!${remaining}`,
+ );
+
+ const content = (result.default || result) as ImageModuleShape;
+ if (!content || typeof content !== 'object') {
+ return `export default ${toLiteralValue(content)};`;
+ }
+
+ const assetPrefixExpression = isServer
+ ? buildServerAssetPrefixExpression(publicPathRef)
+ : buildClientAssetPrefixExpression(publicPathRef);
+
+ const mappedEntries = Object.entries(content).map(([key, value]) => {
+ if (shouldPrefixValue(value)) {
+ return `${JSON.stringify(key)}: __nextmf_asset_prefix__ + ${JSON.stringify(value)}`;
+ }
+
+ return `${JSON.stringify(key)}: ${toLiteralValue(value)}`;
+ });
+
+ return Template.asString([
+ "let __nextmf_asset_prefix__ = '';",
+ 'try {',
+ Template.indent(`__nextmf_asset_prefix__ = ${assetPrefixExpression};`),
+ Template.indent([
+ "if (typeof __nextmf_asset_prefix__ === 'string') {",
+ Template.indent(
+ "__nextmf_asset_prefix__ = __nextmf_asset_prefix__.replace(/\\/$/, '');",
+ ),
+ '} else {',
+ Template.indent("__nextmf_asset_prefix__ = '';"),
+ '}',
+ ]),
+ '} catch (_error) {',
+ Template.indent("__nextmf_asset_prefix__ = '';"),
+ '}',
+ 'export default {',
+ Template.indent(mappedEntries.join(',\n')),
+ '};',
+ ]);
+}
+
+export const pitch = fixNextImageLoader;
diff --git a/packages/nextjs-mf/src/core/loaders/fixUrlLoader.ts b/packages/nextjs-mf/src/core/loaders/fixUrlLoader.ts
new file mode 100644
index 00000000000..8c954b99383
--- /dev/null
+++ b/packages/nextjs-mf/src/core/loaders/fixUrlLoader.ts
@@ -0,0 +1,42 @@
+/**
+ * Rewrites absolute `/_next/*` urls emitted by url-loader so federated remotes
+ * resolve assets from the remote origin instead of the host origin.
+ */
+export default function fixUrlLoader(content: string): string {
+ const assetPrefixExpression = [
+ '(function resolveFederatedAssetPrefix(){',
+ 'try {',
+ "const publicPath = typeof __webpack_require__ !== 'undefined' ? __webpack_require__.p : '';",
+ "const hostname = typeof publicPath === 'string' ? publicPath.replace(/(.+\\:\\/\\/[^\\/]+){0,1}\\/.*/i, '$1') : '';",
+ "const globalThisVal = new Function('return globalThis')();",
+ 'const federationRoot = globalThisVal.__FEDERATION__;',
+ 'if (!federationRoot || !Array.isArray(federationRoot.__INSTANCES__)) return hostname;',
+ 'const currentInstance = __webpack_require__ && __webpack_require__.federation && __webpack_require__.federation.instance;',
+ "const name = currentInstance && typeof currentInstance.name === 'string' ? currentInstance.name : '';",
+ 'if (!name) return hostname;',
+ 'let hasRemoteEntry = false;',
+ 'for (const instance of federationRoot.__INSTANCES__) {',
+ 'if (!instance) continue;',
+ 'const moduleCache = instance.moduleCache;',
+ 'if (moduleCache && moduleCache.get) {',
+ 'const container = moduleCache.get(name);',
+ 'const remoteInfo = container && container.remoteInfo;',
+ "const remoteEntry = remoteInfo && typeof remoteInfo.entry === 'string' ? remoteInfo.entry : '';",
+ "if (remoteEntry.includes('/_next/')) {",
+ 'hasRemoteEntry = true;',
+ "return remoteEntry.slice(0, remoteEntry.lastIndexOf('/_next/'));",
+ '}',
+ '}',
+ '}',
+ "if (!hasRemoteEntry) return '';",
+ 'return hostname;',
+ '} catch (_error) {}',
+ "return '';",
+ '})()',
+ ].join(' ');
+
+ return content.replace(
+ 'export default "/',
+ `export default ${assetPrefixExpression} + "/`,
+ );
+}
diff --git a/packages/nextjs-mf/src/core/loaders/patchLoaders.ts b/packages/nextjs-mf/src/core/loaders/patchLoaders.ts
new file mode 100644
index 00000000000..aa06e9120e9
--- /dev/null
+++ b/packages/nextjs-mf/src/core/loaders/patchLoaders.ts
@@ -0,0 +1,175 @@
+import type { Configuration, RuleSetRule, RuleSetUseItem } from 'webpack';
+import path from 'path';
+import fs from 'fs';
+
+type MutableRule = RuleSetRule & {
+ oneOf?: RuleSetRule[];
+ rules?: RuleSetRule[];
+ use?: RuleSetUseItem | RuleSetUseItem[];
+ loader?: string;
+ options?: unknown;
+};
+
+function getUseLoaderIds(rule: MutableRule): string[] {
+ const ids: string[] = [];
+
+ const pushUseItem = (item: RuleSetUseItem): void => {
+ if (typeof item === 'string') {
+ ids.push(item);
+ return;
+ }
+
+ if (item && typeof item === 'object' && 'loader' in item) {
+ const loader = item.loader;
+ if (typeof loader === 'string') {
+ ids.push(loader);
+ }
+ }
+ };
+
+ if (typeof rule.loader === 'string') {
+ ids.push(rule.loader);
+ }
+
+ if (Array.isArray(rule.use)) {
+ rule.use.forEach((item) => {
+ if (!item) {
+ return;
+ }
+ pushUseItem(item as RuleSetUseItem);
+ });
+ } else if (rule.use && typeof rule.use !== 'function') {
+ pushUseItem(rule.use as RuleSetUseItem);
+ }
+
+ return ids;
+}
+
+function toUseItems(rule: MutableRule): RuleSetUseItem[] {
+ if (Array.isArray(rule.use)) {
+ const collected: RuleSetUseItem[] = [];
+ rule.use.forEach((item) => {
+ if (!item || typeof item === 'function') {
+ return;
+ }
+
+ collected.push(item as RuleSetUseItem);
+ });
+ return collected;
+ }
+
+ if (rule.use && typeof rule.use !== 'function') {
+ return [rule.use as RuleSetUseItem];
+ }
+
+ if (typeof rule.loader === 'string') {
+ return [{ loader: rule.loader, options: rule.options }];
+ }
+
+ return [];
+}
+
+function setUseItems(rule: MutableRule, items: RuleSetUseItem[]): void {
+ rule.use = items;
+
+ if ('loader' in rule) {
+ delete rule.loader;
+ }
+
+ if ('options' in rule) {
+ delete rule.options;
+ }
+}
+
+function ensurePrependedLoader(
+ rule: MutableRule,
+ targetLoaderPath: string,
+): void {
+ const items = toUseItems(rule);
+ const alreadyInjected = items.some((item) => {
+ if (typeof item === 'string') {
+ return item === targetLoaderPath;
+ }
+
+ return Boolean(
+ item && typeof item === 'object' && item.loader === targetLoaderPath,
+ );
+ });
+
+ if (alreadyInjected) {
+ return;
+ }
+
+ setUseItems(rule, [{ loader: targetLoaderPath }, ...items]);
+}
+
+function visitRule(
+ rule: RuleSetRule,
+ callback: (rule: MutableRule) => void,
+): void {
+ if (!rule || typeof rule !== 'object') {
+ return;
+ }
+
+ const mutableRule = rule as MutableRule;
+ callback(mutableRule);
+
+ if (Array.isArray(mutableRule.oneOf)) {
+ mutableRule.oneOf.forEach((nestedRule) => {
+ if (!nestedRule || typeof nestedRule !== 'object') {
+ return;
+ }
+ visitRule(nestedRule as RuleSetRule, callback);
+ });
+ }
+
+ if (Array.isArray(mutableRule.rules)) {
+ mutableRule.rules.forEach((nestedRule) => {
+ if (!nestedRule || typeof nestedRule !== 'object') {
+ return;
+ }
+ visitRule(nestedRule as RuleSetRule, callback);
+ });
+ }
+}
+
+function resolveLoaderPath(localName: string): string {
+ const absolutePath = path.resolve(__dirname, `${localName}.js`);
+
+ if (fs.existsSync(absolutePath)) {
+ return absolutePath;
+ }
+
+ return require.resolve(`./${localName}`);
+}
+
+export function applyFederatedAssetLoaderFixes(config: Configuration): void {
+ if (!config.module || !Array.isArray(config.module.rules)) {
+ return;
+ }
+
+ const fixNextImageLoaderPath = resolveLoaderPath('fixNextImageLoader');
+ const fixUrlLoaderPath = resolveLoaderPath('fixUrlLoader');
+
+ config.module.rules.forEach((rule) => {
+ if (!rule || typeof rule !== 'object') {
+ return;
+ }
+
+ visitRule(rule as RuleSetRule, (mutableRule) => {
+ const loaderIds = getUseLoaderIds(mutableRule);
+ const hasNextImageLoader = loaderIds.some((id) =>
+ id.includes('next-image-loader'),
+ );
+ const hasUrlLoader = loaderIds.some((id) => id.includes('url-loader'));
+
+ if (hasNextImageLoader) {
+ ensurePrependedLoader(mutableRule, fixNextImageLoaderPath);
+ }
+
+ if (hasUrlLoader) {
+ ensurePrependedLoader(mutableRule, fixUrlLoaderPath);
+ }
+ });
+ });
+}
diff --git a/packages/nextjs-mf/src/core/options.test.ts b/packages/nextjs-mf/src/core/options.test.ts
new file mode 100644
index 00000000000..9585b33e96b
--- /dev/null
+++ b/packages/nextjs-mf/src/core/options.test.ts
@@ -0,0 +1,87 @@
+import {
+ assertLocalWebpackEnabled,
+ assertWebpackBuildInvocation,
+ isNextBuildOrDevCommand,
+ normalizeNextFederationOptions,
+ resolveFederationRemotes,
+} from './options';
+import { NextFederationError } from './errors';
+
+describe('core/options', () => {
+ const originalArgv = process.argv;
+ const originalEnv = { ...process.env };
+
+ afterEach(() => {
+ process.argv = originalArgv;
+ process.env = { ...originalEnv };
+ });
+
+ it('throws NMF001 when webpack flag is missing in next build command', () => {
+ process.argv = ['node', '/tmp/next/dist/bin/next', 'build'];
+
+ expect(() => assertWebpackBuildInvocation()).toThrow(NextFederationError);
+ expect(() => assertWebpackBuildInvocation()).toThrow('[NMF001]');
+ });
+
+ it('passes webpack invocation when build command includes --webpack', () => {
+ process.argv = ['node', '/tmp/next/dist/bin/next', 'build', '--webpack'];
+
+ expect(() => assertWebpackBuildInvocation()).not.toThrow();
+ });
+
+ it('does not treat next start as a webpack build/dev invocation', () => {
+ process.argv = ['node', '/tmp/next/dist/bin/next', 'start'];
+
+ expect(isNextBuildOrDevCommand()).toBe(false);
+ expect(() => assertWebpackBuildInvocation()).not.toThrow();
+ });
+
+ it('passes when NEXT_PRIVATE_LOCAL_WEBPACK is set', () => {
+ process.env['NEXT_PRIVATE_LOCAL_WEBPACK'] = 'true';
+
+ expect(() => assertLocalWebpackEnabled()).not.toThrow();
+ expect(process.env['NEXT_PRIVATE_LOCAL_WEBPACK']).toBe('true');
+ });
+
+ it('throws NMF002 when NEXT_PRIVATE_LOCAL_WEBPACK is missing', () => {
+ delete process.env['NEXT_PRIVATE_LOCAL_WEBPACK'];
+
+ expect(() => assertLocalWebpackEnabled()).toThrow(NextFederationError);
+ expect(() => assertLocalWebpackEnabled()).toThrow('[NMF002]');
+ expect(process.env['NEXT_PRIVATE_LOCAL_WEBPACK']).toBeUndefined();
+ });
+
+ it('normalizes defaults and resolves remotes resolver', () => {
+ const normalized = normalizeNextFederationOptions({
+ name: 'home',
+ remotes: ({ isServer }) => ({
+ shop: `shop@http://localhost:3001/_next/static/${
+ isServer ? 'ssr' : 'chunks'
+ }/remoteEntry.js`,
+ }),
+ });
+
+ expect(normalized.mode).toBe('hybrid');
+ expect(normalized.filename).toBe('static/chunks/remoteEntry.js');
+ expect(normalized.pages.pageMapFormat).toBe('routes-v2');
+
+ const resolvedServerRemotes = resolveFederationRemotes(normalized, {
+ isServer: true,
+ compilerName: 'server',
+ nextRuntime: 'nodejs',
+ }) as Record;
+
+ expect(resolvedServerRemotes['shop']).toContain('/ssr/remoteEntry.js');
+ });
+
+ it('throws NMF005 for legacy extraOptions usage', () => {
+ expect(() =>
+ normalizeNextFederationOptions({
+ name: 'legacy',
+ extraOptions: {
+ exposePages: true,
+ },
+ } as any),
+ ).toThrow('[NMF005]');
+ });
+});
diff --git a/packages/nextjs-mf/src/core/options.ts b/packages/nextjs-mf/src/core/options.ts
new file mode 100644
index 00000000000..b56f7f6767f
--- /dev/null
+++ b/packages/nextjs-mf/src/core/options.ts
@@ -0,0 +1,164 @@
+import type { moduleFederationPlugin } from '@module-federation/sdk';
+import { createNextFederationError } from './errors';
+import type {
+ NextFederationCompilerContext,
+ NextFederationOptionsV9,
+ ResolvedNextFederationOptions,
+} from '../types';
+
+function isTruthy(value: string | undefined): boolean {
+ if (!value) {
+ return false;
+ }
+
+ return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
+}
+
+function getNextCommandArgs(): string[] {
+ const nextArgIndex = process.argv.findIndex((arg) => {
+ return (
+ /(^|[/\\])next(\.js)?$/.test(arg) || arg.includes('next/dist/bin/next')
+ );
+ });
+
+ if (nextArgIndex < 0) {
+ return [];
+ }
+
+ return process.argv.slice(nextArgIndex + 1);
+}
+
+export function isNextBuildOrDevCommand(): boolean {
+ const commandArgs = getNextCommandArgs();
+ return commandArgs.includes('build') || commandArgs.includes('dev');
+}
+
+export function assertWebpackBuildInvocation(): void {
+ if (!isNextBuildOrDevCommand()) {
+ return;
+ }
+
+ const commandArgs = getNextCommandArgs();
+ const hasWebpackFlag = commandArgs.includes('--webpack');
+ const hasTurboFlag =
+ commandArgs.includes('--turbo') || commandArgs.includes('--turbopack');
+
+ if (process.env['NEXT_RSPACK']) {
+ throw createNextFederationError(
+ 'NMF001',
+ 'Rspack mode is unsupported for nextjs-mf v9. Use webpack mode.',
+ );
+ }
+
+ if (hasTurboFlag || !hasWebpackFlag) {
+ throw createNextFederationError('NMF001');
+ }
+}
+
+export function assertLocalWebpackEnabled(): void {
+ if (!isTruthy(process.env['NEXT_PRIVATE_LOCAL_WEBPACK'])) {
+ throw createNextFederationError('NMF002');
+ }
+}
+
+function assertNoLegacyOptions(options: Record): void {
+ if (!('extraOptions' in options)) {
+ return;
+ }
+
+ throw createNextFederationError(
+ 'NMF005',
+ 'Legacy extraOptions are no longer supported. Migrate to pages/app/runtime/sharing/diagnostics options.',
+ );
+}
+
+function assertMode(mode: string): asserts mode is 'pages' | 'app' | 'hybrid' {
+ if (mode === 'pages' || mode === 'app' || mode === 'hybrid') {
+ return;
+ }
+
+ throw new Error(`Invalid next federation mode: ${mode}`);
+}
+
+export function normalizeNextFederationOptions(
+ input: NextFederationOptionsV9,
+): ResolvedNextFederationOptions {
+ const unknownInput = input as unknown as Record;
+ assertNoLegacyOptions(unknownInput);
+
+ if (!input.name) {
+ throw new Error('nextjs-mf v9 requires a "name" option.');
+ }
+
+ const {
+ mode: rawMode,
+ pages: rawPages,
+ app: rawApp,
+ runtime: rawRuntime,
+ sharing: rawSharing,
+ diagnostics: rawDiagnostics,
+ remotes,
+ ...federation
+ } = input;
+
+ const mode = rawMode || 'hybrid';
+ assertMode(mode);
+
+ if (rawRuntime?.environment && rawRuntime.environment !== 'node') {
+ throw createNextFederationError('NMF003');
+ }
+
+ const remotesResolver = typeof remotes === 'function' ? remotes : undefined;
+
+ const staticRemotes = typeof remotes === 'function' ? undefined : remotes;
+
+ const runtime = {
+ environment: 'node' as const,
+ onRemoteFailure: rawRuntime?.onRemoteFailure || 'error',
+ runtimePlugins: rawRuntime?.runtimePlugins || [],
+ };
+
+ const sharing = {
+ includeNextInternals: rawSharing?.includeNextInternals ?? true,
+ strategy: rawSharing?.strategy || 'loaded-first',
+ };
+
+ const resolvedOptions: ResolvedNextFederationOptions = {
+ mode,
+ filename: input.filename || 'static/chunks/remoteEntry.js',
+ pages: {
+ exposePages: rawPages?.exposePages ?? false,
+ pageMapFormat: rawPages?.pageMapFormat || 'routes-v2',
+ },
+ app: {
+ enableClientComponents:
+ rawApp?.enableClientComponents ?? (mode === 'app' || mode === 'hybrid'),
+ enableRsc: rawApp?.enableRsc ?? (mode === 'app' || mode === 'hybrid'),
+ },
+ runtime,
+ sharing,
+ diagnostics: {
+ level: rawDiagnostics?.level || 'warn',
+ },
+ federation: {
+ ...federation,
+ remotes: staticRemotes as
+ | moduleFederationPlugin.ModuleFederationPluginOptions['remotes']
+ | undefined,
+ },
+ remotesResolver,
+ };
+
+ return resolvedOptions;
+}
+
+export function resolveFederationRemotes(
+ resolved: ResolvedNextFederationOptions,
+ context: NextFederationCompilerContext,
+): moduleFederationPlugin.ModuleFederationPluginOptions['remotes'] {
+ if (!resolved.remotesResolver) {
+ return resolved.federation.remotes;
+ }
+
+ return resolved.remotesResolver(context);
+}
diff --git a/packages/nextjs-mf/src/core/runtime.ts b/packages/nextjs-mf/src/core/runtime.ts
new file mode 100644
index 00000000000..872285e7080
--- /dev/null
+++ b/packages/nextjs-mf/src/core/runtime.ts
@@ -0,0 +1,26 @@
+import type { ResolvedNextFederationOptions } from '../types';
+
+export function buildRuntimePlugins(
+ resolved: ResolvedNextFederationOptions,
+ isServer: boolean,
+): (string | [string, Record])[] {
+ const plugins: (string | [string, Record])[] = [];
+
+ if (isServer) {
+ plugins.push(require.resolve('@module-federation/node/runtimePlugin'));
+ }
+
+ plugins.push([
+ require.resolve('./runtimePlugin'),
+ {
+ onRemoteFailure: resolved.runtime.onRemoteFailure,
+ resolveCoreShares: resolved.mode !== 'app',
+ },
+ ]);
+
+ if (resolved.runtime.runtimePlugins.length > 0) {
+ plugins.push(...resolved.runtime.runtimePlugins);
+ }
+
+ return plugins;
+}
diff --git a/packages/nextjs-mf/src/core/runtimePlugin.test.ts b/packages/nextjs-mf/src/core/runtimePlugin.test.ts
new file mode 100644
index 00000000000..1e89b5045bc
--- /dev/null
+++ b/packages/nextjs-mf/src/core/runtimePlugin.test.ts
@@ -0,0 +1,236 @@
+import nextMfRuntimePlugin from './runtimePlugin';
+
+describe('core/runtimePlugin', () => {
+ afterEach(() => {
+ delete (globalThis as any).__webpack_require__;
+ delete (globalThis as any).moduleGraphDirty;
+ });
+
+ it('returns lifecycle args when null fallback is disabled', () => {
+ const plugin = nextMfRuntimePlugin({ onRemoteFailure: 'error' }) as any;
+ const args = {
+ lifecycle: 'beforeRequest' as const,
+ id: 'shop/menu',
+ error: new Error('boom'),
+ from: 'runtime' as const,
+ options: {},
+ origin: {},
+ };
+
+ expect(plugin.errorLoadRemote?.(args as any)).toBe(args);
+ });
+
+ it('returns null fallback module for onLoad failures', () => {
+ const plugin = nextMfRuntimePlugin({
+ onRemoteFailure: 'null-fallback',
+ }) as any;
+ const fallbackFactory = plugin.errorLoadRemote?.({
+ lifecycle: 'onLoad',
+ id: 'shop/menu',
+ error: new Error('boom'),
+ from: 'runtime',
+ origin: {},
+ } as any) as
+ | (() => { __esModule: boolean; default: () => null })
+ | undefined;
+
+ expect(typeof fallbackFactory).toBe('function');
+ expect(fallbackFactory?.()).toMatchObject({
+ __esModule: true,
+ default: expect.any(Function),
+ });
+ expect(fallbackFactory?.().default()).toBeNull();
+ });
+
+ it('preserves lifecycle args for non-onLoad failures in null fallback mode', () => {
+ const plugin = nextMfRuntimePlugin({
+ onRemoteFailure: 'null-fallback',
+ }) as any;
+ const args = {
+ lifecycle: 'beforeRequest' as const,
+ id: 'shop/menu',
+ error: new Error('boom'),
+ from: 'runtime' as const,
+ options: {},
+ origin: {},
+ };
+
+ expect(plugin.errorLoadRemote?.(args as any)).toBe(args);
+ });
+
+ it('pins react shares to host instance when available', () => {
+ const plugin = nextMfRuntimePlugin({ onRemoteFailure: 'error' }) as any;
+ const args: any = {
+ pkgName: 'react',
+ scope: 'default',
+ version: '19.0.0',
+ shareScopeMap: {
+ default: {
+ react: {
+ '19.0.0': { from: 'remote' },
+ },
+ },
+ },
+ shareInfo: {
+ from: 'shop',
+ },
+ GlobalFederation: {
+ __INSTANCES__: [
+ {
+ options: {
+ name: 'home_app',
+ shared: {
+ react: [{ from: 'other-host' }],
+ },
+ },
+ },
+ {
+ options: {
+ name: 'shop',
+ shared: {
+ react: [{ from: 'host' }],
+ },
+ },
+ },
+ ],
+ },
+ };
+
+ const resolved = plugin.resolveShare?.(args);
+ expect(resolved).toBe(args);
+ expect(typeof args.resolver).toBe('function');
+ const result = args.resolver();
+ expect(result).toMatchObject({
+ useTreesShaking: false,
+ shared: { from: 'other-host' },
+ });
+ expect(args.shareScopeMap.default.react['19.0.0']).toEqual({
+ from: 'other-host',
+ });
+ });
+
+ it('prefers the current federation runtime instance when resolving host shares', () => {
+ (globalThis as any).__webpack_require__ = {
+ federation: {
+ instance: {
+ name: 'host_b',
+ },
+ },
+ };
+
+ const plugin = nextMfRuntimePlugin({ onRemoteFailure: 'error' }) as any;
+ const args: any = {
+ pkgName: 'react',
+ scope: 'default',
+ version: '19.0.0',
+ shareScopeMap: {
+ default: {
+ react: {
+ '19.0.0': { from: 'remote' },
+ },
+ },
+ },
+ shareInfo: {
+ from: 'shop',
+ },
+ GlobalFederation: {
+ __INSTANCES__: [
+ {
+ options: {
+ name: 'host_a',
+ shared: {
+ react: [{ from: 'host-a-share' }],
+ },
+ },
+ },
+ {
+ options: {
+ name: 'host_b',
+ shared: {
+ react: [{ from: 'host-b-share' }],
+ },
+ },
+ },
+ ],
+ },
+ };
+
+ plugin.resolveShare?.(args);
+ const result = args.resolver();
+
+ expect(result.shared).toEqual({ from: 'host-b-share' });
+ });
+
+ it('marks module graph dirty when remote loading errors occur', () => {
+ (globalThis as any).moduleGraphDirty = false;
+ const plugin = nextMfRuntimePlugin({ onRemoteFailure: 'error' }) as any;
+
+ const args = {
+ lifecycle: 'beforeRequest' as const,
+ id: 'shop/menu',
+ error: new Error('boom'),
+ from: 'runtime' as const,
+ options: {},
+ origin: {},
+ };
+
+ expect(plugin.errorLoadRemote?.(args as any)).toBe(args);
+ expect((globalThis as any).moduleGraphDirty).toBe(true);
+ });
+
+ it('passes through non-core packages in resolveShare', () => {
+ const plugin = nextMfRuntimePlugin({ onRemoteFailure: 'error' }) as any;
+ const args: any = {
+ pkgName: 'lodash',
+ scope: 'default',
+ version: '4.17.21',
+ shareScopeMap: {
+ default: {
+ lodash: {
+ '4.17.21': { from: 'remote' },
+ },
+ },
+ },
+ };
+
+ expect(plugin.resolveShare?.(args)).toBe(args);
+ expect(args.resolver).toBeUndefined();
+ });
+
+ it('can disable core share resolution overrides', () => {
+ const plugin = nextMfRuntimePlugin({
+ onRemoteFailure: 'error',
+ resolveCoreShares: false,
+ }) as any;
+ const args: any = {
+ pkgName: 'react',
+ scope: 'default',
+ version: '18.3.1',
+ shareScopeMap: {
+ default: {
+ react: {
+ '18.3.1': { from: 'remote' },
+ },
+ },
+ },
+ shareInfo: {
+ from: 'shop',
+ },
+ GlobalFederation: {
+ __INSTANCES__: [
+ {
+ options: {
+ name: 'home_app',
+ shared: {
+ react: [{ from: 'host' }],
+ },
+ },
+ },
+ ],
+ },
+ };
+
+ expect(plugin.resolveShare?.(args)).toBe(args);
+ expect(args.resolver).toBeUndefined();
+ });
+});
diff --git a/packages/nextjs-mf/src/core/runtimePlugin.ts b/packages/nextjs-mf/src/core/runtimePlugin.ts
new file mode 100644
index 00000000000..9be761ee26e
--- /dev/null
+++ b/packages/nextjs-mf/src/core/runtimePlugin.ts
@@ -0,0 +1,310 @@
+import type { ModuleFederationRuntimePlugin } from '@module-federation/runtime/types';
+
+interface NextMfRuntimePluginOptions {
+ onRemoteFailure?: 'error' | 'null-fallback';
+ resolveCoreShares?: boolean;
+}
+
+type RuntimeShare = {
+ shareKey?: string;
+ request?: string;
+ layer?: string | null;
+ shareConfig?: {
+ layer?: string | null;
+ };
+};
+
+type RuntimeInstance = {
+ options?: {
+ name?: string;
+ shared?: Record;
+ };
+};
+
+function createNullFallbackModule() {
+ const NullComponent = () => null;
+
+ return {
+ __esModule: true,
+ default: NullComponent,
+ };
+}
+
+function isCoreShare(pkgName: string): boolean {
+ return (
+ pkgName === 'react' ||
+ pkgName === 'react-dom' ||
+ pkgName.startsWith('react/') ||
+ pkgName.startsWith('react-dom/') ||
+ pkgName.startsWith('next/')
+ );
+}
+
+function getShareLayer(entry: RuntimeShare): string | null {
+ return entry.shareConfig?.layer ?? entry.layer ?? null;
+}
+
+function toShareEntries(value: unknown): RuntimeShare[] {
+ if (!value) {
+ return [];
+ }
+
+ if (Array.isArray(value)) {
+ return value.filter(
+ (entry): entry is RuntimeShare => !!entry && typeof entry === 'object',
+ );
+ }
+
+ if (typeof value === 'object') {
+ return [value as RuntimeShare];
+ }
+
+ return [];
+}
+
+function getInstanceName(instance: RuntimeInstance): string {
+ const name = instance?.options?.name;
+ return typeof name === 'string' ? name : '';
+}
+
+function getCurrentFederationInstanceName(): string {
+ try {
+ const runtime = (globalThis as any).__webpack_require__;
+ const name = runtime?.federation?.instance?.name;
+ return typeof name === 'string' ? name : '';
+ } catch {
+ return '';
+ }
+}
+
+function getMatchingShareEntries(
+ instance: RuntimeInstance,
+ pkgName: string,
+): RuntimeShare[] {
+ const shared = instance?.options?.shared;
+ if (!shared || typeof shared !== 'object') {
+ return [];
+ }
+
+ const directMatches = toShareEntries(shared[pkgName]);
+ if (directMatches.length > 0) {
+ return directMatches;
+ }
+
+ const matchedEntries: RuntimeShare[] = [];
+ Object.values(shared).forEach((candidate) => {
+ for (const entry of toShareEntries(candidate)) {
+ if (entry.shareKey === pkgName || entry.request === pkgName) {
+ matchedEntries.push(entry);
+ }
+ }
+ });
+ return matchedEntries;
+}
+
+function selectHostInstance(
+ args: any,
+ instances: RuntimeInstance[],
+ pkgName: string,
+): RuntimeInstance | undefined {
+ const matchingInstances = instances.filter(
+ (instance) => getMatchingShareEntries(instance, pkgName).length > 0,
+ );
+
+ if (matchingInstances.length === 0) {
+ return instances[0];
+ }
+
+ const currentInstanceName = getCurrentFederationInstanceName();
+ if (currentInstanceName) {
+ const currentHost = matchingInstances.find(
+ (instance) => getInstanceName(instance) === currentInstanceName,
+ );
+ if (currentHost) {
+ return currentHost;
+ }
+ }
+
+ const consumerName =
+ typeof args?.shareInfo?.from === 'string' ? args.shareInfo.from : '';
+ if (consumerName) {
+ const nonConsumerHost = matchingInstances.find(
+ (instance) => getInstanceName(instance) !== consumerName,
+ );
+ if (nonConsumerHost) {
+ return nonConsumerHost;
+ }
+
+ const consumerHost = matchingInstances.find(
+ (instance) => getInstanceName(instance) === consumerName,
+ );
+ if (consumerHost) {
+ return consumerHost;
+ }
+ }
+
+ return matchingInstances[0];
+}
+
+function getHostSharedEntries(args: any): RuntimeShare[] {
+ const instances = args?.GlobalFederation?.__INSTANCES__ as
+ | RuntimeInstance[]
+ | undefined;
+ if (!Array.isArray(instances) || instances.length === 0) {
+ return [];
+ }
+
+ const pkgName = args?.pkgName as string | undefined;
+ if (!pkgName) {
+ return [];
+ }
+
+ const host = selectHostInstance(args, instances, pkgName);
+ if (!host) {
+ return [];
+ }
+
+ return getMatchingShareEntries(host, pkgName);
+}
+
+function pickLayeredShareEntry(
+ args: any,
+ entries: RuntimeShare[],
+): RuntimeShare {
+ const requestedLayer =
+ args?.shareInfo?.shareConfig?.layer ?? args?.shareInfo?.layer ?? null;
+
+ if (!requestedLayer) {
+ return entries.find((entry) => getShareLayer(entry) === null) || entries[0];
+ }
+
+ return (
+ entries.find((entry) => getShareLayer(entry) === requestedLayer) ||
+ entries.find((entry) => getShareLayer(entry) === null) ||
+ entries[0]
+ );
+}
+
+export default function nextMfRuntimePlugin(
+ options?: NextMfRuntimePluginOptions,
+): ModuleFederationRuntimePlugin {
+ const shouldResolveCoreShares = options?.resolveCoreShares !== false;
+ const markModuleGraphDirty = () => {
+ if (typeof window === 'undefined') {
+ (globalThis as { moduleGraphDirty?: boolean }).moduleGraphDirty = true;
+ }
+ };
+
+ return {
+ name: 'nextjs-mf-v9-runtime-plugin',
+ createScript(args: any) {
+ if (typeof window === 'undefined') {
+ return undefined;
+ }
+
+ const script = document.createElement('script');
+ script.src = args.url;
+ script.async = true;
+
+ if (args.attrs) {
+ delete args.attrs['crossorigin'];
+ }
+
+ return { script, timeout: 8000 };
+ },
+ loadRemoteSnapshot(args: any) {
+ const from = args['from'];
+ const remoteSnapshot = args['remoteSnapshot'] as Record;
+ const manifestUrl = args['manifestUrl'];
+ const options = args['options'] as { inBrowser?: boolean } | undefined;
+
+ if (
+ from !== 'manifest' ||
+ !remoteSnapshot ||
+ typeof manifestUrl !== 'string' ||
+ !('publicPath' in remoteSnapshot)
+ ) {
+ return args;
+ }
+
+ const publicPath = String(remoteSnapshot['publicPath']);
+ if (options?.inBrowser && publicPath.includes('/_next/')) {
+ remoteSnapshot['publicPath'] = publicPath.slice(
+ 0,
+ publicPath.lastIndexOf('/_next/') + 7,
+ );
+ } else {
+ remoteSnapshot['publicPath'] = manifestUrl.slice(
+ 0,
+ manifestUrl.lastIndexOf('/') + 1,
+ );
+ }
+
+ return args;
+ },
+ resolveShare(args: any) {
+ if (!shouldResolveCoreShares) {
+ return args;
+ }
+
+ if (!isCoreShare(args?.pkgName || '')) {
+ return args;
+ }
+
+ const hostShareEntries = getHostSharedEntries(args);
+ if (hostShareEntries.length === 0) {
+ return args;
+ }
+
+ const requestedLayer =
+ args?.shareInfo?.shareConfig?.layer ?? args?.shareInfo?.layer ?? null;
+ const hasLayeredEntries = hostShareEntries.some((entry) => {
+ return getShareLayer(entry) !== null;
+ });
+
+ // App Router shares can be layer-specific. If we cannot infer a concrete
+ // layer for the current request, defer to runtime-core's default resolver.
+ if (hasLayeredEntries && !requestedLayer) {
+ return args;
+ }
+
+ const selectedShare = pickLayeredShareEntry(args, hostShareEntries);
+ args.resolver = function () {
+ const scope = args?.scope;
+ const pkgName = args?.pkgName;
+ const version = args?.version;
+
+ if (
+ scope &&
+ pkgName &&
+ version &&
+ args?.shareScopeMap?.[scope]?.[pkgName]
+ ) {
+ args.shareScopeMap[scope][pkgName][version] = selectedShare;
+ }
+
+ return {
+ shared: selectedShare,
+ useTreesShaking: false,
+ };
+ };
+
+ return args;
+ },
+ errorLoadRemote(args: any) {
+ if (args?.error) {
+ markModuleGraphDirty();
+ }
+
+ if (options?.onRemoteFailure !== 'null-fallback') {
+ return args;
+ }
+
+ if (args?.lifecycle === 'onLoad') {
+ return () => createNullFallbackModule();
+ }
+
+ return args;
+ },
+ };
+}
diff --git a/packages/nextjs-mf/src/core/sharing.test.ts b/packages/nextjs-mf/src/core/sharing.test.ts
new file mode 100644
index 00000000000..4e5b1247af1
--- /dev/null
+++ b/packages/nextjs-mf/src/core/sharing.test.ts
@@ -0,0 +1,95 @@
+import { normalizeNextFederationOptions } from './options';
+import { getDefaultShared } from './sharing';
+
+describe('sharing', () => {
+ it('provides pages router core singletons for server', () => {
+ const resolved = normalizeNextFederationOptions({
+ name: 'home',
+ mode: 'pages',
+ });
+
+ const shared = getDefaultShared(resolved, true);
+ const reactFallback = shared['react'] as Record;
+ const routerFallback = shared['next/router'] as Record;
+ const reactDomClient = shared['react-dom/client'] as Record<
+ string,
+ unknown
+ >;
+
+ expect(reactFallback['layer']).toBeUndefined();
+ expect(reactFallback['issuerLayer']).toBeUndefined();
+ expect(reactFallback['import']).toBe(false);
+
+ expect(routerFallback['layer']).toBeUndefined();
+ expect(routerFallback['issuerLayer']).toBeUndefined();
+ expect(reactDomClient['singleton']).toBe(true);
+ });
+
+ it('browserizes pages shares without forcing eager mode', () => {
+ const resolved = normalizeNextFederationOptions({
+ name: 'home',
+ mode: 'pages',
+ });
+
+ const shared = getDefaultShared(resolved, false);
+ const reactFallback = shared['react'] as Record;
+ const routerFallback = shared['next/router'] as Record;
+
+ expect(reactFallback['layer']).toBeUndefined();
+ expect(reactFallback['issuerLayer']).toBeUndefined();
+ expect(reactFallback['import']).toBeUndefined();
+ expect(reactFallback['eager']).toBeUndefined();
+
+ expect(routerFallback['layer']).toBeUndefined();
+ expect(routerFallback['issuerLayer']).toBeUndefined();
+ expect(routerFallback['import']).toBeUndefined();
+ expect(routerFallback['eager']).toBeUndefined();
+ });
+
+ it('uses layered app-router aliases on the server compiler', () => {
+ const resolved = normalizeNextFederationOptions({
+ name: 'app',
+ mode: 'app',
+ app: {
+ enableRsc: true,
+ },
+ });
+
+ const shared = getDefaultShared(resolved, true);
+ const appReactLayer = shared['react-rsc'] as Record;
+ const appReactFallback = shared['react'] as Record;
+
+ expect(appReactLayer['layer']).toBe('rsc');
+ expect(appReactLayer['issuerLayer']).toBe('rsc');
+ expect(appReactLayer['request']).toBe(
+ 'next/dist/server/route-modules/app-page/vendored/rsc/react',
+ );
+
+ expect(appReactFallback['request']).toBe('next/dist/compiled/react');
+ expect(shared['next/link']).toBeUndefined();
+ expect(shared['next/navigation']).toBeUndefined();
+ });
+
+ it('keeps app-router layered entries on the browser compiler', () => {
+ const resolved = normalizeNextFederationOptions({
+ name: 'app',
+ mode: 'app',
+ app: {
+ enableRsc: true,
+ },
+ });
+
+ const shared = getDefaultShared(resolved, false);
+ const appReactLayer = shared['react-rsc'] as Record;
+ const appReactFallback = shared['react'] as Record;
+
+ expect(appReactLayer['layer']).toBe('rsc');
+ expect(appReactLayer['issuerLayer']).toBe('rsc');
+ expect(appReactLayer['request']).toBe(
+ 'next/dist/server/route-modules/app-page/vendored/rsc/react',
+ );
+ expect(appReactFallback['layer']).toBeUndefined();
+ expect(appReactFallback['issuerLayer']).toBeUndefined();
+ expect(appReactFallback['request']).toBe('next/dist/compiled/react');
+ });
+});
diff --git a/packages/nextjs-mf/src/core/sharing.ts b/packages/nextjs-mf/src/core/sharing.ts
new file mode 100644
index 00000000000..12efd8121c7
--- /dev/null
+++ b/packages/nextjs-mf/src/core/sharing.ts
@@ -0,0 +1,389 @@
+import type { moduleFederationPlugin } from '@module-federation/sdk';
+import type { ResolvedNextFederationOptions } from '../types';
+
+type SharedConfig = moduleFederationPlugin.SharedConfig & {
+ layer?: string;
+ issuerLayer?: string | string[];
+ request?: string;
+ shareKey?: string;
+};
+
+const APP_ROUTER_LAYERS = ['rsc', 'ssr', 'app-pages-browser'] as const;
+type AppRouterLayer = (typeof APP_ROUTER_LAYERS)[number];
+
+function createLayeredShareEntries(
+ baseKey: string,
+ shareKey: string,
+ requestByLayer: Record,
+ fallbackRequest: string,
+ layers: readonly AppRouterLayer[] = APP_ROUTER_LAYERS,
+ includeFallback = true,
+ packageName?: string,
+): moduleFederationPlugin.SharedObject {
+ const layeredEntries = layers.reduce((acc, layer) => {
+ const request = requestByLayer[layer];
+ acc[`${baseKey}-${layer}`] = {
+ singleton: true,
+ requiredVersion: false,
+ import: undefined,
+ shareKey,
+ request,
+ layer,
+ issuerLayer: layer,
+ packageName,
+ } as SharedConfig;
+ return acc;
+ }, {} as moduleFederationPlugin.SharedObject);
+
+ if (!includeFallback) {
+ return layeredEntries;
+ }
+
+ layeredEntries[shareKey] = {
+ singleton: true,
+ requiredVersion: false,
+ import: undefined,
+ shareKey,
+ request: fallbackRequest,
+ issuerLayer: undefined,
+ packageName,
+ } as SharedConfig;
+
+ return layeredEntries;
+}
+
+const NEXT_INTERNAL_SHARED: moduleFederationPlugin.SharedObject = {
+ 'next/dynamic': {
+ singleton: true,
+ requiredVersion: undefined,
+ },
+ 'next/head': {
+ singleton: true,
+ requiredVersion: undefined,
+ },
+ 'next/link': {
+ singleton: true,
+ requiredVersion: undefined,
+ },
+ 'next/router': {
+ singleton: true,
+ requiredVersion: false,
+ import: undefined,
+ },
+ 'next/compat/router': {
+ singleton: true,
+ requiredVersion: false,
+ import: undefined,
+ },
+ 'next/navigation': {
+ singleton: true,
+ requiredVersion: undefined,
+ },
+ 'next/image': {
+ singleton: true,
+ requiredVersion: undefined,
+ },
+ 'next/script': {
+ singleton: true,
+ requiredVersion: undefined,
+ },
+ react: {
+ singleton: true,
+ requiredVersion: false,
+ import: false,
+ },
+ 'react/': {
+ singleton: true,
+ requiredVersion: false,
+ import: false,
+ },
+ 'react-dom': {
+ singleton: true,
+ requiredVersion: false,
+ import: false,
+ },
+ 'react-dom/': {
+ singleton: true,
+ requiredVersion: false,
+ import: false,
+ },
+ 'react-dom/client': {
+ singleton: true,
+ requiredVersion: false,
+ },
+ 'react/jsx-runtime': {
+ singleton: true,
+ requiredVersion: false,
+ },
+ 'react/jsx-dev-runtime': {
+ singleton: true,
+ requiredVersion: false,
+ },
+ 'styled-jsx': {
+ singleton: true,
+ requiredVersion: false,
+ },
+ 'styled-jsx/style': {
+ singleton: true,
+ requiredVersion: false,
+ import: false,
+ },
+ 'styled-jsx/css': {
+ singleton: true,
+ requiredVersion: undefined,
+ },
+};
+
+const NEXT_COMPILED_REACT_SHARED: moduleFederationPlugin.SharedObject = {
+ 'next/dist/compiled/react': {
+ singleton: true,
+ requiredVersion: false,
+ import: 'react',
+ shareKey: 'react',
+ packageName: 'react',
+ },
+ 'next/dist/compiled/react/jsx-runtime': {
+ singleton: true,
+ requiredVersion: false,
+ import: 'react/jsx-runtime',
+ shareKey: 'react/jsx-runtime',
+ packageName: 'react',
+ },
+ 'next/dist/compiled/react/jsx-dev-runtime': {
+ singleton: true,
+ requiredVersion: false,
+ import: 'react/jsx-dev-runtime',
+ shareKey: 'react/jsx-dev-runtime',
+ packageName: 'react',
+ },
+ 'next/dist/compiled/react/compiler-runtime': {
+ singleton: true,
+ requiredVersion: false,
+ import: 'react/compiler-runtime',
+ shareKey: 'react/compiler-runtime',
+ packageName: 'react',
+ },
+ 'next/dist/compiled/react-dom': {
+ singleton: true,
+ requiredVersion: false,
+ import: 'react-dom',
+ shareKey: 'react-dom',
+ packageName: 'react-dom',
+ },
+ 'next/dist/compiled/react-dom/client': {
+ singleton: true,
+ requiredVersion: false,
+ import: 'react-dom/client',
+ shareKey: 'react-dom/client',
+ packageName: 'react-dom',
+ },
+};
+
+function getAppCompiledReactShared(): moduleFederationPlugin.SharedObject {
+ return Object.entries(NEXT_COMPILED_REACT_SHARED).reduce(
+ (acc, [key, value]) => {
+ const resolved = value as SharedConfig;
+ acc[key] = {
+ ...resolved,
+ import: key,
+ };
+ return acc;
+ },
+ {} as moduleFederationPlugin.SharedObject,
+ );
+}
+
+const APP_ROUTER_INTERNAL_SHARED: moduleFederationPlugin.SharedObject = {
+ 'styled-jsx': {
+ singleton: true,
+ requiredVersion: false,
+ },
+ 'styled-jsx/style': {
+ singleton: true,
+ requiredVersion: false,
+ import: undefined,
+ },
+ 'styled-jsx/css': {
+ singleton: true,
+ requiredVersion: false,
+ },
+};
+
+const APP_ROUTER_REACT_ALIASES = {
+ rsc: {
+ react: 'next/dist/server/route-modules/app-page/vendored/rsc/react',
+ reactDom: 'next/dist/server/route-modules/app-page/vendored/rsc/react-dom',
+ reactJsxRuntime:
+ 'next/dist/server/route-modules/app-page/vendored/rsc/react-jsx-runtime',
+ reactJsxDevRuntime:
+ 'next/dist/server/route-modules/app-page/vendored/rsc/react-jsx-dev-runtime',
+ reactDomClient: 'next/dist/compiled/react-dom/client',
+ },
+ ssr: {
+ react: 'next/dist/server/route-modules/app-page/vendored/ssr/react',
+ reactDom: 'next/dist/server/route-modules/app-page/vendored/ssr/react-dom',
+ reactJsxRuntime:
+ 'next/dist/server/route-modules/app-page/vendored/ssr/react-jsx-runtime',
+ reactJsxDevRuntime:
+ 'next/dist/server/route-modules/app-page/vendored/ssr/react-jsx-dev-runtime',
+ reactDomClient: 'next/dist/compiled/react-dom/client',
+ },
+ 'app-pages-browser': {
+ react: 'next/dist/compiled/react',
+ reactDom: 'next/dist/compiled/react-dom',
+ reactJsxRuntime: 'next/dist/compiled/react/jsx-runtime',
+ reactJsxDevRuntime: 'next/dist/compiled/react/jsx-dev-runtime',
+ reactDomClient: 'next/dist/compiled/react-dom/client',
+ },
+} as const satisfies Record<
+ AppRouterLayer,
+ {
+ react: string;
+ reactDom: string;
+ reactJsxRuntime: string;
+ reactJsxDevRuntime: string;
+ reactDomClient: string;
+ }
+>;
+
+function getAppRouterShared(): moduleFederationPlugin.SharedObject {
+ return {
+ ...APP_ROUTER_INTERNAL_SHARED,
+ ...createLayeredShareEntries(
+ 'react',
+ 'react',
+ {
+ rsc: APP_ROUTER_REACT_ALIASES.rsc.react,
+ ssr: APP_ROUTER_REACT_ALIASES.ssr.react,
+ 'app-pages-browser':
+ APP_ROUTER_REACT_ALIASES['app-pages-browser'].react,
+ },
+ APP_ROUTER_REACT_ALIASES['app-pages-browser'].react,
+ APP_ROUTER_LAYERS,
+ true,
+ 'react',
+ ),
+ ...createLayeredShareEntries(
+ 'react-dom',
+ 'react-dom',
+ {
+ rsc: APP_ROUTER_REACT_ALIASES.rsc.reactDom,
+ ssr: APP_ROUTER_REACT_ALIASES.ssr.reactDom,
+ 'app-pages-browser':
+ APP_ROUTER_REACT_ALIASES['app-pages-browser'].reactDom,
+ },
+ APP_ROUTER_REACT_ALIASES['app-pages-browser'].reactDom,
+ APP_ROUTER_LAYERS,
+ true,
+ 'react-dom',
+ ),
+ ...createLayeredShareEntries(
+ 'react-jsx-runtime',
+ 'react/jsx-runtime',
+ {
+ rsc: APP_ROUTER_REACT_ALIASES.rsc.reactJsxRuntime,
+ ssr: APP_ROUTER_REACT_ALIASES.ssr.reactJsxRuntime,
+ 'app-pages-browser':
+ APP_ROUTER_REACT_ALIASES['app-pages-browser'].reactJsxRuntime,
+ },
+ APP_ROUTER_REACT_ALIASES['app-pages-browser'].reactJsxRuntime,
+ APP_ROUTER_LAYERS,
+ true,
+ 'react',
+ ),
+ ...createLayeredShareEntries(
+ 'react-jsx-dev-runtime',
+ 'react/jsx-dev-runtime',
+ {
+ rsc: APP_ROUTER_REACT_ALIASES.rsc.reactJsxDevRuntime,
+ ssr: APP_ROUTER_REACT_ALIASES.ssr.reactJsxDevRuntime,
+ 'app-pages-browser':
+ APP_ROUTER_REACT_ALIASES['app-pages-browser'].reactJsxDevRuntime,
+ },
+ APP_ROUTER_REACT_ALIASES['app-pages-browser'].reactJsxDevRuntime,
+ APP_ROUTER_LAYERS,
+ true,
+ 'react',
+ ),
+ ...createLayeredShareEntries(
+ 'react-dom-client',
+ 'react-dom/client',
+ {
+ rsc: APP_ROUTER_REACT_ALIASES.rsc.reactDomClient,
+ ssr: APP_ROUTER_REACT_ALIASES.ssr.reactDomClient,
+ 'app-pages-browser':
+ APP_ROUTER_REACT_ALIASES['app-pages-browser'].reactDomClient,
+ },
+ APP_ROUTER_REACT_ALIASES['app-pages-browser'].reactDomClient,
+ ['app-pages-browser'],
+ true,
+ 'react-dom',
+ ),
+ };
+}
+
+function browserizeShared(
+ shared: moduleFederationPlugin.SharedObject,
+): moduleFederationPlugin.SharedObject {
+ return Object.entries(shared).reduce((acc, [key, value]) => {
+ const resolved = value as moduleFederationPlugin.SharedConfig;
+
+ acc[key] = {
+ ...resolved,
+ import: undefined,
+ };
+ return acc;
+ }, {} as moduleFederationPlugin.SharedObject);
+}
+
+export function getDefaultShared(
+ resolved: ResolvedNextFederationOptions,
+ isServer: boolean,
+): moduleFederationPlugin.SharedObject {
+ const shouldUseAppLayers =
+ (resolved.mode === 'app' || resolved.mode === 'hybrid') &&
+ resolved.app.enableRsc;
+
+ const shared: moduleFederationPlugin.SharedObject = shouldUseAppLayers
+ ? {
+ ...getAppRouterShared(),
+ }
+ : {
+ ...NEXT_INTERNAL_SHARED,
+ };
+
+ if (isServer) {
+ return shouldUseAppLayers
+ ? {
+ ...shared,
+ ...getAppCompiledReactShared(),
+ }
+ : shared;
+ }
+
+ const browserShared = browserizeShared(shared);
+ return shouldUseAppLayers
+ ? {
+ ...browserShared,
+ ...getAppCompiledReactShared(),
+ }
+ : {
+ ...browserShared,
+ ...NEXT_COMPILED_REACT_SHARED,
+ };
+}
+
+export function buildSharedConfig(
+ resolved: ResolvedNextFederationOptions,
+ isServer: boolean,
+ userShared: moduleFederationPlugin.ModuleFederationPluginOptions['shared'],
+): moduleFederationPlugin.ModuleFederationPluginOptions['shared'] {
+ if (!resolved.sharing.includeNextInternals) {
+ return userShared || {};
+ }
+
+ return {
+ ...getDefaultShared(resolved, isServer),
+ ...(userShared || {}),
+ };
+}
diff --git a/packages/nextjs-mf/src/federation-noop.ts b/packages/nextjs-mf/src/federation-noop.ts
deleted file mode 100644
index f3233b36772..00000000000
--- a/packages/nextjs-mf/src/federation-noop.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-require('next/head');
-require('next/router');
-require('next/link');
-require('next/script');
-require('next/image');
-require('next/dynamic');
-require('next/error');
-require('next/amp');
-require('styled-jsx');
-require('styled-jsx/style');
-require('next/image');
-// require('react/jsx-dev-runtime');
-require('react/jsx-runtime');
diff --git a/packages/nextjs-mf/src/index.ts b/packages/nextjs-mf/src/index.ts
index c1e239a0c8a..a0628c7c943 100644
--- a/packages/nextjs-mf/src/index.ts
+++ b/packages/nextjs-mf/src/index.ts
@@ -1,7 +1,13 @@
-import NextFederationPlugin from './plugins/NextFederationPlugin';
+import withNextFederation from './withNextFederation';
-export { NextFederationPlugin };
-export default NextFederationPlugin;
+export type {
+ NextFederationCompilerContext,
+ NextFederationMode,
+ NextFederationOptionsV9,
+} from './types';
-module.exports = NextFederationPlugin;
-module.exports.NextFederationPlugin = NextFederationPlugin;
+export { withNextFederation };
+export default withNextFederation;
+
+module.exports = withNextFederation;
+module.exports.withNextFederation = withNextFederation;
diff --git a/packages/nextjs-mf/src/internal.ts b/packages/nextjs-mf/src/internal.ts
deleted file mode 100644
index cbc95fab16a..00000000000
--- a/packages/nextjs-mf/src/internal.ts
+++ /dev/null
@@ -1,297 +0,0 @@
-import type { moduleFederationPlugin } from '@module-federation/sdk';
-
-// Extend the SharedConfig type to include layer properties
-type ExtendedSharedConfig = moduleFederationPlugin.SharedConfig & {
- layer?: string;
- issuerLayer?: string | string[];
- request?: string;
- shareKey?: string;
-};
-
-const WEBPACK_LAYERS_NAMES = {
- /**
- * The layer for the shared code between the client and server bundles.
- */
- shared: 'shared',
- /**
- * The layer for server-only runtime and picking up `react-server` export conditions.
- * Including app router RSC pages and app router custom routes and metadata routes.
- */
- reactServerComponents: 'rsc',
- /**
- * Server Side Rendering layer for app (ssr).
- */
- serverSideRendering: 'ssr',
- /**
- * The browser client bundle layer for actions.
- */
- actionBrowser: 'action-browser',
- /**
- * The layer for the API routes.
- */
- api: 'api',
- /**
- * The layer for the middleware code.
- */
- middleware: 'middleware',
- /**
- * The layer for the instrumentation hooks.
- */
- instrument: 'instrument',
- /**
- * The layer for assets on the edge.
- */
- edgeAsset: 'edge-asset',
- /**
- * The browser client bundle layer for App directory.
- */
- appPagesBrowser: 'app-pages-browser',
-} as const;
-
-const createSharedConfig = (
- name: string,
- layers: (string | undefined)[],
- options: { request?: string; import?: false | undefined } = {},
-) => {
- return layers.reduce(
- (acc, layer) => {
- const key = layer ? `${name}-${layer}` : name;
- acc[key] = {
- singleton: true,
- requiredVersion: false,
- import: layer ? undefined : (options.import ?? false),
- shareKey: options.request ?? name,
- request: options.request ?? name,
- layer,
- issuerLayer: layer,
- };
- return acc;
- },
- {} as Record,
- );
-};
-
-const defaultLayers = [
- WEBPACK_LAYERS_NAMES.reactServerComponents,
- WEBPACK_LAYERS_NAMES.serverSideRendering,
- undefined,
-];
-
-const navigationLayers = [
- WEBPACK_LAYERS_NAMES.reactServerComponents,
- WEBPACK_LAYERS_NAMES.serverSideRendering,
-];
-
-const reactShares = createSharedConfig('react', defaultLayers);
-const reactDomShares = createSharedConfig('react', defaultLayers, {
- request: 'react-dom',
-});
-const jsxRuntimeShares = createSharedConfig('react/', navigationLayers, {
- request: 'react/',
- import: undefined,
-});
-const nextNavigationShares = createSharedConfig(
- 'next-navigation',
- navigationLayers,
- { request: 'next/navigation' },
-);
-
-/**
- * @typedef SharedObject
- * @type {object}
- * @property {object} [key] - The key representing the shared object's package name.
- * @property {boolean} key.singleton - Whether the shared object should be a singleton.
- * @property {boolean} key.requiredVersion - Whether a specific version of the shared object is required.
- * @property {boolean} key.eager - Whether the shared object should be eagerly loaded.
- * @property {boolean} key.import - Whether the shared object should be imported or not.
- * @property {string} key.layer - The webpack layer this shared module belongs to.
- * @property {string|string[]} key.issuerLayer - The webpack layer that can import this shared module.
- */
-export const DEFAULT_SHARE_SCOPE: moduleFederationPlugin.SharedObject = {
- // ...reactShares,
- // ...reactDomShares,
- // ...nextNavigationShares,
- // ...jsxRuntimeShares,
- 'next/dynamic': {
- requiredVersion: undefined,
- singleton: true,
- import: undefined,
- },
- 'next/head': {
- requiredVersion: undefined,
- singleton: true,
- import: undefined,
- },
- 'next/link': {
- requiredVersion: undefined,
- singleton: true,
- import: undefined,
- },
- 'next/router': {
- requiredVersion: false,
- singleton: true,
- import: undefined,
- },
- 'next/image': {
- requiredVersion: undefined,
- singleton: true,
- import: undefined,
- },
- 'next/script': {
- requiredVersion: undefined,
- singleton: true,
- import: undefined,
- },
- react: {
- singleton: true,
- requiredVersion: false,
- import: false,
- },
- 'react/': {
- singleton: true,
- requiredVersion: false,
- import: false,
- },
- 'react-dom/': {
- singleton: true,
- requiredVersion: false,
- import: false,
- },
- 'react-dom': {
- singleton: true,
- requiredVersion: false,
- import: false,
- },
- 'react/jsx-dev-runtime': {
- singleton: true,
- requiredVersion: false,
- },
- 'react/jsx-runtime': {
- singleton: true,
- requiredVersion: false,
- },
- 'styled-jsx': {
- singleton: true,
- import: undefined,
- version: require('styled-jsx/package.json').version,
- requiredVersion: '^' + require('styled-jsx/package.json').version,
- },
- 'styled-jsx/style': {
- singleton: true,
- import: false,
- version: require('styled-jsx/package.json').version,
- requiredVersion: '^' + require('styled-jsx/package.json').version,
- },
- 'styled-jsx/css': {
- singleton: true,
- import: undefined,
- version: require('styled-jsx/package.json').version,
- requiredVersion: '^' + require('styled-jsx/package.json').version,
- },
-};
-
-/**
- * Defines a default share scope for the browser environment.
- * This function takes the DEFAULT_SHARE_SCOPE and sets eager to undefined and import to undefined for all entries.
- * For 'react', 'react-dom', 'next/router', and 'next/link', it sets eager to true.
- * The module hoisting system relocates these modules into the right runtime and out of the remote.
- *
- * @type {SharedObject}
- * @returns {SharedObject} - The modified share scope for the browser environment.
- */
-
-export const DEFAULT_SHARE_SCOPE_BROWSER: moduleFederationPlugin.SharedObject =
- Object.entries(DEFAULT_SHARE_SCOPE).reduce((acc, item) => {
- const [key, value] = item as [string, moduleFederationPlugin.SharedConfig];
-
- // Set eager and import to undefined for all entries, except for the ones specified above
- acc[key] = { ...value, import: undefined };
-
- return acc;
- }, {} as moduleFederationPlugin.SharedObject);
-
-/**
- * Checks if the remote value is an internal or promise delegate module reference.
- *
- * @param {string} value - The remote value to check.
- * @returns {boolean} - True if the value is an internal or promise delegate module reference, false otherwise.
- */
-const isInternalOrPromise = (value: string): boolean =>
- ['internal ', 'promise '].some((prefix) => value.startsWith(prefix));
-
-/**
- * Parses the remotes object and checks if they are using a custom promise template or not.
- * If it's a custom promise template, the remote syntax is parsed to get the module name and version number.
- * If the remote value is using the standard remote syntax, a delegated module is created.
- *
- * @param {Record} remotes - The remotes object to be parsed.
- * @returns {Record} - The parsed remotes object with either the original value,
- * the value for internal or promise delegate module reference, or the created delegated module.
- */
-export const parseRemotes = (
- remotes: Record,
-): Record => {
- return Object.entries(remotes).reduce(
- (acc, [key, value]) => {
- if (isInternalOrPromise(value)) {
- // If the value is an internal or promise delegate module reference, keep the original value
- return { ...acc, [key]: value };
- }
-
- return { ...acc, [key]: value };
- },
- {} as Record,
- );
-};
-/**
- * Checks if the remote value is an internal delegate module reference.
- * An internal delegate module reference starts with the string 'internal '.
- *
- * @param {string} value - The remote value to check.
- * @returns {boolean} - Returns true if the value is an internal delegate module reference, otherwise returns false.
- */
-const isInternalDelegate = (value: string): boolean => {
- return value.startsWith('internal ');
-};
-/**
- * Extracts the delegate modules from the provided remotes object.
- * This function iterates over the remotes object and checks if each remote value is an internal delegate module reference.
- * If it is, the function adds it to the returned object.
- *
- * @param {Record} remotes - The remotes object containing delegate module references.
- * @returns {Record} - An object containing only the delegate modules from the remotes object.
- */
-export const getDelegates = (
- remotes: Record,
-): Record =>
- Object.entries(remotes).reduce(
- (acc, [key, value]) =>
- isInternalDelegate(value) ? { ...acc, [key]: value } : acc,
- {},
- );
-
-/**
- * Takes an error object and formats it into a displayable string.
- * If the error object contains a stack trace, it is appended to the error message.
- *
- * @param {Error} error - The error object to be formatted.
- * @returns {string} - The formatted error message string. If a stack trace is present in the error object, it is appended to the error message.
- */
-const formatError = (error: Error): string => {
- let { message } = error;
- if (error.stack) {
- message += `\n${error.stack}`;
- }
- return message;
-};
-
-/**
- * Transforms an array of Error objects into a single string. Each error message is formatted using the 'formatError' function.
- * The resulting error messages are then joined together, separated by newline characters.
- *
- * @param {Error[]} err - An array of Error objects that need to be formatted and combined.
- * @returns {string} - A single string containing all the formatted error messages, separated by newline characters.
- */
-export const toDisplayErrors = (err: Error[]): string => {
- return err.map(formatError).join('\n');
-};
diff --git a/packages/nextjs-mf/src/loaders/fixImageLoader.ts b/packages/nextjs-mf/src/loaders/fixImageLoader.ts
deleted file mode 100644
index 910fed1768e..00000000000
--- a/packages/nextjs-mf/src/loaders/fixImageLoader.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import type { LoaderContext } from 'webpack';
-import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-path';
-const { Template } = require(
- normalizeWebpackPath('webpack'),
-) as typeof import('webpack');
-import path from 'path';
-
-/**
- * This loader is specifically created for tuning the next-image-loader result.
- * It modifies the regular string output of the next-image-loader.
- * For server-side rendering (SSR), it injects the remote scope of a specific remote URL.
- * For client-side rendering (CSR), it injects the document.currentScript.src.
- * After these injections, it selects the full URI before _next.
- *
- * @example
- * http://localhost:1234/test/test2/_next/static/media/ssl.e3019f0e.svg
- * will become
- * http://localhost:1234/test/test2
- *
- * @param {LoaderContext>} this - The loader context.
- * @param {string} remaining - The remaining part of the resource path.
- * @returns {string} The modified source code with the injected code.
- */
-export async function fixImageLoader(
- this: LoaderContext>,
- remaining: string,
-) {
- this.cacheable(true);
-
- const isServer = this._compiler?.options?.name !== 'client';
- const publicPath = this._compiler?.webpack?.RuntimeGlobals?.publicPath ?? '';
-
- const result = await this.importModule(
- `${this.resourcePath}.webpack[javascript/auto]!=!${remaining}`,
- );
-
- const content = (result.default || result) as Record;
-
- const computedAssetPrefix = isServer
- ? `${Template.asString([
- 'function getSSRImagePath(){',
- //TODO: use auto public path plugin instead
- `const pubpath = ${publicPath};`,
- Template.asString([
- 'try {',
- Template.indent([
- "const globalThisVal = new Function('return globalThis')();",
- 'const name = __webpack_require__.federation.instance.name',
- `const container = globalThisVal['__FEDERATION__']['__INSTANCES__'].find(
- (instance) => {
- if(!instance) return;
- if (!instance.moduleCache.has(name)) return;
- const container = instance.moduleCache.get(name);
- if (!container.remoteInfo) return;
- return container.remoteInfo.entry;
- },
- );`,
- 'if(!container) return "";',
- 'const cache = container.moduleCache',
- 'const remote = cache.get(name).remoteInfo',
- `const remoteEntry = remote.entry;`,
- `if (remoteEntry) {`,
- Template.indent([
- `const splitted = remoteEntry.split('/_next')`,
- `return splitted.length === 2 ? splitted[0] : '';`,
- ]),
- `}`,
- `return '';`,
- ]),
- '} catch (e) {',
- Template.indent([
- `console.error('failed generating SSR image path', e);`,
- 'return "";',
- ]),
- '}',
- ]),
- '}()',
- ])}`
- : `${Template.asString([
- 'function getCSRImagePath(){',
- Template.indent([
- 'try {',
- Template.indent([
- `const splitted = ${publicPath} ? ${publicPath}.split('/_next') : '';`,
- `return splitted.length === 2 ? splitted[0] : '';`,
- ]),
- '} catch (e) {',
- Template.indent([
- `const path = document.currentScript && document.currentScript.src;`,
- `console.error('failed generating CSR image path', e, path);`,
- 'return "";',
- ]),
- '}',
- ]),
- '}()',
- ])}`;
-
- const constructedObject = Object.entries(content).reduce(
- (acc, [key, value]) => {
- if (key === 'src') {
- if (value && !value.includes('://')) {
- value = path.join(value);
- }
- acc.push(
- `${key}: computedAssetsPrefixReference + ${JSON.stringify(value)}`,
- );
- return acc;
- }
- acc.push(`${key}: ${JSON.stringify(value)}`);
- return acc;
- },
- [] as string[],
- );
-
- return Template.asString([
- "let computedAssetsPrefixReference = '';",
- 'try {',
- Template.indent(`computedAssetsPrefixReference = ${computedAssetPrefix};`),
- '} catch (e) {}',
- 'export default {',
- Template.indent(constructedObject.join(',\n')),
- '}',
- ]);
-}
-
-/**
- * The pitch function of the loader, which is the same as the fixImageLoader function.
- */
-export const pitch = fixImageLoader;
diff --git a/packages/nextjs-mf/src/loaders/fixUrlLoader.ts b/packages/nextjs-mf/src/loaders/fixUrlLoader.ts
deleted file mode 100644
index 4f04835372a..00000000000
--- a/packages/nextjs-mf/src/loaders/fixUrlLoader.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * `fixUrlLoader` is a custom loader designed to modify the output of the url-loader.
- * It injects the PUBLIC_PATH from the webpack runtime into the output.
- * The output format is: `export default __webpack_require__.p + "/static/media/ssl.e3019f0e.svg"`
- *
- * `__webpack_require__.p` is a global variable in the webpack container that contains the publicPath.
- * For example, it could be: http://localhost:3000/_next
- *
- * @param {string} content - The original output from the url-loader.
- * @returns {string} The modified output with the injected PUBLIC_PATH.
- */
-export function fixUrlLoader(content: string) {
- // This regular expression extracts the hostname from the publicPath.
- // For example, it transforms http://localhost:3000/_next/... into http://localhost:3000
- const currentHostnameCode =
- "__webpack_require__.p.replace(/(.+\\:\\/\\/[^\\/]+){0,1}\\/.*/i, '$1')";
-
- // Replace the default export path in the content with the modified path that includes the hostname.
- return content.replace(
- 'export default "/',
- `export default ${currentHostnameCode}+"/`,
- );
-}
-
-// Export the fixUrlLoader function as the default export of this module.
-export default fixUrlLoader;
diff --git a/packages/nextjs-mf/src/loaders/helpers.ts b/packages/nextjs-mf/src/loaders/helpers.ts
deleted file mode 100644
index 4ede889487a..00000000000
--- a/packages/nextjs-mf/src/loaders/helpers.ts
+++ /dev/null
@@ -1,192 +0,0 @@
-import type {
- RuleSetRule,
- RuleSetCondition,
- RuleSetConditionAbsolute,
- RuleSetUseItem,
-} from 'webpack';
-
-export function injectRuleLoader(rule: any, loader: RuleSetUseItem = {}) {
- if (rule !== '...') {
- const _rule = rule as {
- loader?: string;
- use?: (RuleSetUseItem | string)[];
- options?: any;
- };
- if (_rule.loader) {
- _rule.use = [loader, { loader: _rule.loader, options: _rule.options }];
- delete _rule.loader;
- delete _rule.options;
- } else if (_rule.use) {
- _rule.use = [loader, ...(_rule.use as any[])];
- }
- }
-}
-
-/**
- * This function checks if the current module rule has a loader with the provided name.
- *
- * @param {RuleSetRule} rule - The current module rule.
- * @param {string} loaderName - The name of the loader to check.
- * @returns {boolean} Returns true if the current module rule has a loader with the provided name, otherwise false.
- */
-export function hasLoader(rule: RuleSetRule, loaderName: string) {
- //@ts-ignore
- if (rule !== '...') {
- const _rule = rule as {
- loader?: string;
- use?: (RuleSetUseItem | string)[];
- options?: any;
- };
- if (_rule.loader === loaderName) {
- return true;
- } else if (_rule.use && Array.isArray(_rule.use)) {
- for (let i = 0; i < _rule.use.length; i++) {
- const loader = _rule.use[i];
- if (
- typeof loader !== 'string' &&
- typeof loader !== 'function' &&
- loader.loader &&
- (loader.loader === loaderName ||
- loader.loader.includes(`/${loaderName}/`))
- ) {
- return true;
- } else if (typeof loader === 'string') {
- if (loader === loaderName || loader.includes(`/${loaderName}/`)) {
- return true;
- }
- }
- }
- }
- }
- return false;
-}
-
-interface Resource {
- path: string;
- layer?: string;
- issuerLayer?: string;
-}
-
-function matchesCondition(
- condition:
- | RuleSetCondition
- | RuleSetConditionAbsolute
- | RuleSetRule
- | undefined,
- resource: Resource,
- currentPath: string,
-): boolean {
- if (condition instanceof RegExp) {
- return condition.test(resource.path);
- } else if (typeof condition === 'string') {
- return resource.path.includes(condition);
- } else if (typeof condition === 'function') {
- return condition(resource.path);
- } else if (typeof condition === 'object') {
- if ('test' in condition && condition.test) {
- const tests = Array.isArray(condition.test)
- ? condition.test
- : [condition.test];
- if (
- !tests.some((test: RuleSetCondition) =>
- matchesCondition(test, resource, currentPath),
- )
- ) {
- return false;
- }
- }
- if ('include' in condition && condition.include) {
- const includes = Array.isArray(condition.include)
- ? condition.include
- : [condition.include];
- if (
- !includes.some((include: RuleSetCondition) =>
- matchesCondition(include, resource, currentPath),
- )
- ) {
- return false;
- }
- }
- if ('exclude' in condition && condition.exclude) {
- const excludes = Array.isArray(condition.exclude)
- ? condition.exclude
- : [condition.exclude];
- if (
- excludes.some((exclude: RuleSetCondition) =>
- matchesCondition(exclude, resource, currentPath),
- )
- ) {
- return false;
- }
- }
- if ('and' in condition && condition.and) {
- return condition.and.every((cond: RuleSetCondition) =>
- matchesCondition(cond, resource, currentPath),
- );
- }
- if ('or' in condition && condition.or) {
- return condition.or.some((cond: RuleSetCondition) =>
- matchesCondition(cond, resource, currentPath),
- );
- }
- if ('not' in condition && condition.not) {
- return !matchesCondition(condition.not, resource, currentPath);
- }
- if ('layer' in condition && condition.layer) {
- if (
- !resource.layer ||
- !matchesCondition(
- condition.layer,
- { path: resource.layer },
- currentPath,
- )
- ) {
- return false;
- }
- }
- if ('issuerLayer' in condition && condition.issuerLayer) {
- if (
- !resource.issuerLayer ||
- !matchesCondition(
- condition.issuerLayer,
- { path: resource.issuerLayer },
- currentPath,
- )
- ) {
- return false;
- }
- }
- }
- return true;
-}
-
-export function findLoaderForResource(
- rules: RuleSetRule[],
- resource: Resource,
- path: string[] = [],
-): RuleSetRule | null {
- let lastMatchedRule: RuleSetRule | null = null;
- for (let i = 0; i < rules.length; i++) {
- const rule = rules[i];
- const currentPath = [...path, `rules[${i}]`];
- if (rule.oneOf) {
- for (let j = 0; j < rule.oneOf.length; j++) {
- const subRule = rule.oneOf[j];
- const subPath = [...currentPath, `oneOf[${j}]`];
-
- if (
- subRule &&
- matchesCondition(subRule, resource, subPath.join('->'))
- ) {
- return subRule;
- }
- }
- } else if (
- rule &&
- matchesCondition(rule, resource, currentPath.join(' -> '))
- ) {
- lastMatchedRule = rule;
- }
- }
- return lastMatchedRule;
-}
diff --git a/packages/nextjs-mf/src/loaders/nextPageMapLoader.ts b/packages/nextjs-mf/src/loaders/nextPageMapLoader.ts
deleted file mode 100644
index 7d40af81478..00000000000
--- a/packages/nextjs-mf/src/loaders/nextPageMapLoader.ts
+++ /dev/null
@@ -1,190 +0,0 @@
-import type { LoaderContext } from 'webpack';
-
-import fg from 'fast-glob';
-import fs from 'fs';
-
-import { UrlNode } from '../../client/UrlNode';
-
-/**
- * Webpack loader which prepares MF map for NextJS pages.
- * This function is the main entry point for the loader.
- * It gets the options passed to the loader and prepares the pages map.
- * If the 'v2' option is passed, it prepares the pages map using the 'preparePageMapV2' function.
- * Otherwise, it uses the 'preparePageMap' function.
- * Finally, it calls the loader's callback function with the prepared pages map.
- *
- * @param {LoaderContext>} this - The loader context.
- */
-export default function nextPageMapLoader(
- this: LoaderContext>,
-) {
- // const [pagesRoot] = getNextPagesRoot(this.rootContext);
- // this.addContextDependency(pagesRoot);
- const opts = this.getOptions();
- const pages = getNextPages(this.rootContext);
-
- let result = {};
-
- if (Object.hasOwnProperty.call(opts, 'v2')) {
- result = preparePageMapV2(pages);
- } else {
- result = preparePageMap(pages);
- }
-
- this.callback(
- null,
- `module.exports = { default: ${JSON.stringify(result)} };`,
- );
-}
-
-/**
- * Webpack config generator for `exposes` option.
- * This function generates the webpack config for the 'exposes' option.
- * It creates a map of pages to modules and returns an object with the pages map and the pages map v2.
- *
- * @param {string} cwd - The current working directory.
- * @returns {Record} The webpack config for the 'exposes' option.
- */
-export function exposeNextjsPages(cwd: string) {
- const pages = getNextPages(cwd);
-
- const pageModulesMap = {} as Record;
- pages.forEach((page) => {
- // Creating a map of pages to modules
- // './pages/storage/index': './pages/storage/index.tsx',
- // './pages/storage/[...slug]': './pages/storage/[...slug].tsx',
- pageModulesMap['./' + sanitizePagePath(page)] = `./${page}`;
- });
-
- return {
- './pages-map': `${__filename}!${__filename}`,
- './pages-map-v2': `${__filename}?v2!${__filename}`,
- ...pageModulesMap,
- };
-}
-
-/**
- * This function gets the root directory of the NextJS pages.
- * It checks if the 'src/pages/' directory exists.
- * If it does, it returns the absolute path and the relative path to this directory.
- * If it doesn't, it returns the absolute path and the relative path to the 'pages/' directory.
- *
- * @param {string} appRoot - The root directory of the application.
- * @returns {[string, string]} The absolute path and the relative path to the pages directory.
- */
-function getNextPagesRoot(appRoot: string) {
- let pagesDir = 'src/pages/';
- let absPageDir = `${appRoot}/${pagesDir}`;
- if (!fs.existsSync(absPageDir)) {
- pagesDir = 'pages/';
- absPageDir = `${appRoot}/${pagesDir}`;
- }
-
- return [absPageDir, pagesDir];
-}
-
-/**
- * This function scans the pages directory and returns a list of user defined pages.
- * It excludes special pages like '_app', '_document', '_error', '404', '500', and federation pages.
- *
- * @param {string} rootDir - The root directory of the application.
- * @returns {string[]} The list of user defined pages.
- */
-function getNextPages(rootDir: string) {
- const [cwd, pagesDir] = getNextPagesRoot(rootDir);
-
- // scan all files in pages folder except pages/api
- let pageList = fg.sync('**/*.{ts,tsx,js,jsx}', {
- cwd,
- onlyFiles: true,
- ignore: ['api/**'],
- });
-
- // remove specific nextjs pages
- const exclude = [
- /^_app\..*/, // _app.tsx
- /^_document\..*/, // _document.tsx
- /^_error\..*/, // _error.tsx
- /^404\..*/, // 404.tsx
- /^500\..*/, // 500.tsx
- /^\[\.\.\..*\]\..*/, // /[...federationPage].tsx
- ];
- pageList = pageList.filter((page) => {
- return !exclude.some((r) => r.test(page));
- });
-
- pageList = pageList.map((page) => `${pagesDir}${page}`);
-
- return pageList;
-}
-
-/**
- * This function sanitizes a page path.
- * It removes the 'src/pages/' prefix and the file extension.
- *
- * @param {string} item - The page path to sanitize.
- * @returns {string} The sanitized page path.
- */
-function sanitizePagePath(item: string) {
- return item
- .replace(/^src\/pages\//i, 'pages/')
- .replace(/\.(ts|tsx|js|jsx)$/, '');
-}
-
-/**
- * This function creates a MF map from a list of NextJS pages.
- * It sanitizes the page paths and sorts them using the 'UrlNode' class.
- * Then, it creates a map with the sorted page paths as keys and the original page paths as values.
- *
- * @param {string[]} pages - The list of NextJS pages.
- * @returns {Record} The MF map.
- */
-function preparePageMap(pages: string[]) {
- const result = {} as Record;
-
- const clearedPages = pages.map((p) => `/${sanitizePagePath(p)}`);
-
- // getSortedRoutes @see https://github.com/vercel/next.js/blob/canary/packages/next/shared/lib/router/utils/sorted-routes.ts
- const root = new UrlNode();
- clearedPages.forEach((pagePath) => root.insert(pagePath));
- // Smoosh will then sort those sublevels up to the point where you get the correct route definition priority
- const sortedPages = root.smoosh();
-
- sortedPages.forEach((page) => {
- let key = page
- .replace(/\[\.\.\.[^\]]+\]/gi, '*')
- .replace(/\[([^\]]+)\]/gi, ':$1');
- key = key.replace(/^\/pages\//, '/').replace(/\/index$/, '') || '/';
- result[key] = `.${page}`;
- });
-
- return result;
-}
-
-/**
- * This function creates a MF list from a list of NextJS pages.
- * It sanitizes the page paths and sorts them using the 'UrlNode' class.
- * Then, it creates a map with the sorted page paths as keys and the original page paths as values.
- * Unlike the 'preparePageMap' function, this function does not replace the '[...]' and '[]' parts in the page paths.
- *
- * @param {string[]} pages - The list of NextJS pages.
- * @returns {Record} The MF list.
- */
-function preparePageMapV2(pages: string[]) {
- const result = {} as Record;
-
- const clearedPages = pages.map((p) => `/${sanitizePagePath(p)}`);
-
- // getSortedRoutes @see https://github.com/vercel/next.js/blob/canary/packages/next/shared/lib/router/utils/sorted-routes.ts
- const root = new UrlNode();
- clearedPages.forEach((pagePath) => root.insert(pagePath));
- // Smoosh will then sort those sublevels up to the point where you get the correct route definition priority
- const sortedPages = root.smoosh();
-
- sortedPages.forEach((page) => {
- const key = page.replace(/^\/pages\//, '/').replace(/\/index$/, '') || '/';
- result[key] = `.${page}`;
- });
-
- return result;
-}
diff --git a/packages/nextjs-mf/src/logger.ts b/packages/nextjs-mf/src/logger.ts
index e9ae35d0ae9..5d17b9a21ca 100644
--- a/packages/nextjs-mf/src/logger.ts
+++ b/packages/nextjs-mf/src/logger.ts
@@ -1,13 +1,24 @@
-import {
- createInfrastructureLogger,
- createLogger,
-} from '@module-federation/sdk';
+const PREFIX = '[nextjs-mf]';
-const createBundlerLogger: typeof createLogger =
- typeof createInfrastructureLogger === 'function'
- ? (createInfrastructureLogger as unknown as typeof createLogger)
- : createLogger;
+function prefix(args: unknown[]): unknown[] {
+ return [PREFIX, ...args];
+}
-const logger = createBundlerLogger('[ nextjs-mf ]');
+const logger = {
+ error(...args: unknown[]): void {
+ console.error(...prefix(args));
+ },
+ warn(...args: unknown[]): void {
+ console.warn(...prefix(args));
+ },
+ info(...args: unknown[]): void {
+ console.info(...prefix(args));
+ },
+ debug(...args: unknown[]): void {
+ if (process.env['NEXTJS_MF_DEBUG'] === '1') {
+ console.debug(...prefix(args));
+ }
+ },
+};
export default logger;
diff --git a/packages/nextjs-mf/src/plugins/AddRuntimeRequirementToPromiseExternalPlugin.ts b/packages/nextjs-mf/src/plugins/AddRuntimeRequirementToPromiseExternalPlugin.ts
deleted file mode 100644
index 17002c92357..00000000000
--- a/packages/nextjs-mf/src/plugins/AddRuntimeRequirementToPromiseExternalPlugin.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import type { Compiler, WebpackPluginInstance } from 'webpack';
-
-export class AddRuntimeRequirementToPromiseExternal
- implements WebpackPluginInstance
-{
- apply(compiler: Compiler) {
- compiler.hooks.compilation.tap(
- 'AddRuntimeRequirementToPromiseExternal',
- (compilation) => {
- const { RuntimeGlobals } = compiler.webpack;
- compilation.hooks.additionalModuleRuntimeRequirements.tap(
- 'AddRuntimeRequirementToPromiseExternal',
- (module, set) => {
- if ((module as any).externalType === 'promise') {
- set.add(RuntimeGlobals.loadScript);
- set.add(RuntimeGlobals.require);
- }
- },
- );
- },
- );
- }
-}
-
-export default AddRuntimeRequirementToPromiseExternal;
diff --git a/packages/nextjs-mf/src/plugins/CopyFederationPlugin.ts b/packages/nextjs-mf/src/plugins/CopyFederationPlugin.ts
deleted file mode 100644
index 6388ea524d6..00000000000
--- a/packages/nextjs-mf/src/plugins/CopyFederationPlugin.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { promises as fs } from 'fs';
-import path from 'path';
-import type { Compilation, Compiler, WebpackPluginInstance } from 'webpack';
-import { bindLoggerToCompiler } from '@module-federation/sdk';
-import logger from '../logger';
-
-/**
- * Plugin to copy build output files.
- * @class
- */
-class CopyBuildOutputPlugin implements WebpackPluginInstance {
- private isServer: boolean;
-
- /**
- * @param {boolean} isServer - Indicates if the current environment is server.
- * @constructor
- */
- constructor(isServer: boolean) {
- this.isServer = isServer;
- }
-
- /**
- * Applies the plugin to the compiler.
- * @param {Compiler} compiler - The webpack compiler object.
- * @method
- */
- apply(compiler: Compiler): void {
- bindLoggerToCompiler(logger, compiler, 'CopyBuildOutputPlugin');
- /**
- * Copies files from source to destination.
- * @param {string} source - The source directory.
- * @param {string} destination - The destination directory.
- * @async
- * @function
- */
- const copyFiles = async (
- source: string,
- destination: string,
- ): Promise => {
- const files = await fs.readdir(source);
-
- await Promise.all(
- files.map(async (file) => {
- const sourcePath = path.join(source, file);
- const destinationPath = path.join(destination, file);
-
- if ((await fs.lstat(sourcePath)).isDirectory()) {
- await fs.mkdir(destinationPath, { recursive: true });
- await copyFiles(sourcePath, destinationPath);
- } else {
- await fs.copyFile(sourcePath, destinationPath);
- }
- }),
- );
- };
-
- compiler.hooks.afterEmit.tapPromise(
- 'CopyBuildOutputPlugin',
- async (compilation: Compilation) => {
- const { outputPath } = compiler;
- const outputString = outputPath.split('server')[0];
- const isProd = compiler.options.mode === 'production';
-
- if (!isProd && !this.isServer) {
- return;
- }
-
- const serverLoc = path.join(
- outputString,
- this.isServer && isProd ? '/ssr' : '/static/ssr',
- );
- const servingLoc = path.join(outputPath, 'ssr');
-
- await fs.mkdir(serverLoc, { recursive: true });
-
- const sourcePath = this.isServer ? outputPath : servingLoc;
-
- try {
- await fs.access(sourcePath);
- // If the promise resolves, the file exists and you can proceed with copying.
- await copyFiles(sourcePath, serverLoc);
- } catch (error) {
- // If the promise rejects, the file does not exist.
- logger.error(`File at ${sourcePath} does not exist.`);
- }
- },
- );
- }
-}
-
-export default CopyBuildOutputPlugin;
diff --git a/packages/nextjs-mf/src/plugins/NextFederationPlugin/apply-client-plugins.ts b/packages/nextjs-mf/src/plugins/NextFederationPlugin/apply-client-plugins.ts
deleted file mode 100644
index 1a3a53e76a6..00000000000
--- a/packages/nextjs-mf/src/plugins/NextFederationPlugin/apply-client-plugins.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import type { Compiler } from 'webpack';
-import { ChunkCorrelationPlugin } from '@module-federation/node';
-import InvertedContainerPlugin from '../container/InvertedContainerPlugin';
-import type { moduleFederationPlugin } from '@module-federation/sdk';
-import type { NextFederationPluginExtraOptions } from './next-fragments';
-import logger from '../../logger';
-
-/**
- * Applies client-specific plugins.
- *
- * @param compiler - The Webpack compiler instance.
- * @param options - The ModuleFederationPluginOptions instance.
- * @param extraOptions - The NextFederationPluginExtraOptions instance.
- *
- * @remarks
- * This function applies plugins to the Webpack compiler instance that are specific to the client build of
- * a Next.js application with Module Federation enabled. These plugins include the following:
- *
- * - ChunkCorrelationPlugin: Collects metadata on chunks to enable proper module loading across different runtimes.
- * - InvertedContainerPlugin: Adds custom runtime modules to the container runtime to allow a host to expose its
- * own remote interface at startup.
-
- * If automatic page stitching is enabled, a warning is logged indicating that it is disabled in v7.
- * If a custom library is specified in the options, an error is logged. The options.library property is
- * also set to `{ type: 'window', name: options.name }`.
- */
-export function applyClientPlugins(
- compiler: Compiler,
- options: moduleFederationPlugin.ModuleFederationPluginOptions,
- extraOptions: NextFederationPluginExtraOptions,
-): void {
- const { name } = options;
-
- // Adjust the public path if it is set to the default Next.js path
- if (compiler.options.output.publicPath === '/_next/') {
- compiler.options.output.publicPath = 'auto';
- }
-
- // Log a warning if automatic page stitching is enabled, as it is disabled in v7
- if (extraOptions.automaticPageStitching) {
- logger.warn('automatic page stitching is disabled in v7');
- }
-
- // Log an error if a custom library is set, as it is not allowed
- if (options.library) {
- logger.error('you cannot set custom library');
- }
-
- // Set the library option to be a window object with the name of the module federation plugin
- options.library = {
- type: 'window',
- name,
- };
-
- // Apply the ChunkCorrelationPlugin to collect metadata on chunks
- new ChunkCorrelationPlugin({
- filename: [
- 'static/chunks/federated-stats.json',
- 'server/federated-stats.json',
- ],
- }).apply(compiler);
-
- // Apply the InvertedContainerPlugin to add custom runtime modules to the container runtime
- new InvertedContainerPlugin().apply(compiler);
-}
diff --git a/packages/nextjs-mf/src/plugins/NextFederationPlugin/apply-server-plugins.ts b/packages/nextjs-mf/src/plugins/NextFederationPlugin/apply-server-plugins.ts
deleted file mode 100644
index 2ab7dccd0bb..00000000000
--- a/packages/nextjs-mf/src/plugins/NextFederationPlugin/apply-server-plugins.ts
+++ /dev/null
@@ -1,264 +0,0 @@
-import type { WebpackOptionsNormalized, Compiler } from 'webpack';
-import type ExternalModuleFactoryPlugin from 'webpack/lib/ExternalModuleFactoryPlugin';
-import type { moduleFederationPlugin } from '@module-federation/sdk';
-import path from 'path';
-import InvertedContainerPlugin from '../container/InvertedContainerPlugin';
-import UniverseEntryChunkTrackerPlugin from '@module-federation/node/universe-entry-chunk-tracker-plugin';
-
-type EntryStaticNormalized = Awaited<
- ReturnType any>>
->;
-
-interface ModifyEntryOptions {
- compiler: Compiler;
- prependEntry?: (entry: EntryStaticNormalized) => void;
- staticEntry?: EntryStaticNormalized;
-}
-
-type ExternalItemFunction =
- ExternalModuleFactoryPlugin.ExternalItemFunctionCallback;
-type ExternalItemFunctionData =
- ExternalModuleFactoryPlugin.ExternalItemFunctionData;
-type ExternalItemValue = ExternalModuleFactoryPlugin.ExternalItemValue;
-
-const isExternalItemValue = (value: unknown): value is ExternalItemValue => {
- return (
- typeof value === 'string' ||
- typeof value === 'boolean' ||
- Array.isArray(value) ||
- (!!value && typeof value === 'object')
- );
-};
-
-const runExternalFunction = async (
- external: ExternalItemFunction,
- data: ExternalItemFunctionData,
-): Promise => {
- return new Promise((resolve, reject) => {
- let settled = false;
- const settle = (err?: Error | null, result?: unknown) => {
- if (settled) {
- return;
- }
- settled = true;
- if (err) {
- reject(err);
- return;
- }
- if (isExternalItemValue(result)) {
- resolve(result);
- return;
- }
- resolve(undefined);
- };
-
- const maybePromise: unknown = external(data, (err, result) => {
- settle(err, result);
- });
-
- if (maybePromise !== undefined) {
- Promise.resolve(maybePromise)
- .then((result) => {
- settle(undefined, result);
- })
- .catch((error: unknown) => {
- const normalizedError =
- error instanceof Error ? error : new Error(String(error));
- settle(normalizedError);
- });
- }
- });
-};
-
-const isExternalItemFunction = (
- external: unknown,
-): external is ExternalItemFunction => {
- return typeof external === 'function';
-};
-
-const isSharedImportEnabled = (sharedConfigValue: unknown): boolean => {
- if (!sharedConfigValue || typeof sharedConfigValue !== 'object') {
- return true;
- }
- if (!Object.prototype.hasOwnProperty.call(sharedConfigValue, 'import')) {
- return true;
- }
- return Reflect.get(sharedConfigValue, 'import') !== false;
-};
-
-// Modifies the Webpack entry configuration
-export function modifyEntry(options: ModifyEntryOptions): void {
- const { compiler, staticEntry, prependEntry } = options;
- const operator = (
- oriEntry: EntryStaticNormalized,
- newEntry: EntryStaticNormalized,
- ): EntryStaticNormalized => Object.assign(oriEntry, newEntry);
-
- // If the entry is a function, wrap it to modify the result
- if (typeof compiler.options.entry === 'function') {
- const prevEntryFn = compiler.options.entry;
- compiler.options.entry = async () => {
- let res = await prevEntryFn();
- if (staticEntry) {
- res = operator(res, staticEntry);
- }
- if (prependEntry) {
- prependEntry(res);
- }
- return res;
- };
- } else {
- // If the entry is an object, directly modify it
- if (staticEntry) {
- compiler.options.entry = operator(compiler.options.entry, staticEntry);
- }
- if (prependEntry) {
- prependEntry(compiler.options.entry);
- }
- }
-}
-
-/**
- * Applies server-specific plugins to the webpack compiler.
- *
- * @param {Compiler} compiler - The Webpack compiler instance.
- * @param {moduleFederationPlugin.ModuleFederationPluginOptions} options - The ModuleFederationPluginOptions instance.
- */
-export function applyServerPlugins(
- compiler: Compiler,
- options: moduleFederationPlugin.ModuleFederationPluginOptions,
-): void {
- const chunkFileName = compiler.options?.output?.chunkFilename;
- const uniqueName = compiler?.options?.output?.uniqueName || options.name;
- const suffix = `-[contenthash].js`;
-
- // Modify chunk filename to include a unique suffix if not already present
- if (
- typeof chunkFileName === 'string' &&
- uniqueName &&
- !chunkFileName.includes(uniqueName)
- ) {
- compiler.options.output.chunkFilename = chunkFileName.replace(
- '.js',
- suffix,
- );
- }
- new UniverseEntryChunkTrackerPlugin().apply(compiler);
- new InvertedContainerPlugin().apply(compiler);
-}
-
-/**
- * Configures server-specific library and filename options.
- *
- * @param {ModuleFederationPluginOptions} options - The ModuleFederationPluginOptions instance.
- */
-export function configureServerLibraryAndFilename(
- options: moduleFederationPlugin.ModuleFederationPluginOptions,
-): void {
- // Set the library option to "commonjs-module" format with the name from the options
- options.library = {
- type: 'commonjs-module',
- name: options.name,
- };
-
- // Set the filename option to the basename of the current filename
- if (typeof options.filename === 'string') {
- options.filename = path.basename(options.filename);
- }
-}
-
-/**
- * Patches Next.js' default externals function to ensure shared modules are bundled and not treated as external.
- *
- * @param {Compiler} compiler - The Webpack compiler instance.
- * @param {ModuleFederationPluginOptions} options - The ModuleFederationPluginOptions instance.
- */
-export function handleServerExternals(
- compiler: Compiler,
- options: moduleFederationPlugin.ModuleFederationPluginOptions,
-): void {
- if (Array.isArray(compiler.options.externals)) {
- const functionIndex = compiler.options.externals.findIndex((external) =>
- isExternalItemFunction(external),
- );
-
- if (functionIndex !== -1) {
- const originalExternals = compiler.options.externals[functionIndex];
- if (!isExternalItemFunction(originalExternals)) {
- return;
- }
-
- compiler.options.externals[functionIndex] = async (
- ctx: ExternalItemFunctionData,
- ): Promise => {
- const fromNext = await runExternalFunction(originalExternals, ctx);
- if (typeof fromNext !== 'string') {
- return fromNext;
- }
-
- const req = fromNext.split(' ')[1];
- if (!req) {
- return;
- }
-
- const sharedEntries =
- options.shared &&
- !Array.isArray(options.shared) &&
- typeof options.shared === 'object'
- ? Object.entries(options.shared)
- : [];
- const isSharedRequest = sharedEntries.some(
- ([key, sharedConfigValue]) => {
- if (!isSharedImportEnabled(sharedConfigValue)) {
- return false;
- }
- return key.endsWith('/') ? req.includes(key) : req === key;
- },
- );
-
- if (
- ctx.request &&
- (ctx.request.includes('@module-federation/utilities') ||
- isSharedRequest ||
- ctx.request.includes('@module-federation/'))
- ) {
- return;
- }
-
- if (
- req.startsWith('next') ||
- req.startsWith('react/') ||
- req.startsWith('react-dom/') ||
- req === 'react' ||
- req === 'styled-jsx/style' ||
- req === 'react-dom'
- ) {
- return fromNext;
- }
- return;
- };
- }
- }
-}
-
-/**
- * Configures server-specific compiler options.
- *
- * @param {Compiler} compiler - The Webpack compiler instance.
- */
-export function configureServerCompilerOptions(compiler: Compiler): void {
- // Disable the global option in node builds and set the target to "async-node"
- compiler.options.node = {
- ...compiler.options.node,
- global: false,
- };
- // Set the compiler target to 'async-node' for server-side rendering compatibility
- // Set the target to 'async-node' for server-side builds
- compiler.options.target = 'async-node';
-
- // Runtime chunk creation is currently disabled
- // Uncomment if separate runtime chunk is needed for specific use cases
- // compiler.options.optimization.runtimeChunk = {
- // name: 'webpack-runtime',
- // };
-}
diff --git a/packages/nextjs-mf/src/plugins/NextFederationPlugin/index.ts b/packages/nextjs-mf/src/plugins/NextFederationPlugin/index.ts
deleted file mode 100644
index 80534b3798c..00000000000
--- a/packages/nextjs-mf/src/plugins/NextFederationPlugin/index.ts
+++ /dev/null
@@ -1,241 +0,0 @@
-/**
- * MIT License http://www.opensource.org/licenses/mit-license.php
- * Author Zackary Jackson @ScriptedAlchemy
- * This module contains the NextFederationPlugin class which is a webpack plugin that handles Next.js application federation using Module Federation.
- */
-'use strict';
-
-import type {
- NextFederationPluginExtraOptions,
- NextFederationPluginOptions,
-} from './next-fragments';
-import type { Compiler, WebpackPluginInstance } from 'webpack';
-import { getWebpackPath } from '@module-federation/sdk/normalize-webpack-path';
-import CopyFederationPlugin from '../CopyFederationPlugin';
-import { exposeNextjsPages } from '../../loaders/nextPageMapLoader';
-import { retrieveDefaultShared, applyPathFixes } from './next-fragments';
-import { setOptions } from './set-options';
-import {
- validateCompilerOptions,
- validatePluginOptions,
-} from './validate-options';
-import {
- applyServerPlugins,
- configureServerCompilerOptions,
- configureServerLibraryAndFilename,
- handleServerExternals,
-} from './apply-server-plugins';
-import { applyClientPlugins } from './apply-client-plugins';
-import { ModuleFederationPlugin } from '@module-federation/enhanced/webpack';
-import { bindLoggerToCompiler } from '@module-federation/sdk';
-import type { moduleFederationPlugin } from '@module-federation/sdk';
-import logger from '../../logger';
-
-import path from 'path';
-/**
- * NextFederationPlugin is a webpack plugin that handles Next.js application federation using Module Federation.
- */
-export class NextFederationPlugin {
- private _options: moduleFederationPlugin.ModuleFederationPluginOptions;
- private _extraOptions: NextFederationPluginExtraOptions;
- public name: string;
- /**
- * Constructs the NextFederationPlugin with the provided options.
- *
- * @param options The options to configure the plugin.
- */
- constructor(options: NextFederationPluginOptions) {
- const { mainOptions, extraOptions } = setOptions(options);
- this._options = mainOptions;
- this._extraOptions = extraOptions;
- this.name = 'ModuleFederationPlugin';
- }
-
- /**
- * The apply method is called by the webpack compiler and allows the plugin to hook into the webpack process.
- * @param compiler The webpack compiler object.
- */
- apply(compiler: Compiler) {
- bindLoggerToCompiler(logger, compiler, 'NextFederationPlugin');
- process.env['FEDERATION_WEBPACK_PATH'] =
- process.env['FEDERATION_WEBPACK_PATH'] ||
- getWebpackPath(compiler, { framework: 'nextjs' });
- if (!this.validateOptions(compiler)) return;
- const isServer = this.isServerCompiler(compiler);
- new CopyFederationPlugin(isServer).apply(compiler);
- const normalFederationPluginOptions = this.getNormalFederationPluginOptions(
- compiler,
- isServer,
- );
- this._options = normalFederationPluginOptions;
- this.applyConditionalPlugins(compiler, isServer);
-
- new ModuleFederationPlugin(normalFederationPluginOptions).apply(compiler);
-
- const noop = this.getNoopPath();
-
- if (!this._extraOptions.skipSharingNextInternals) {
- compiler.hooks.make.tapAsync(
- 'NextFederationPlugin',
- (compilation, callback) => {
- const dep = compiler.webpack.EntryPlugin.createDependency(
- noop,
- 'noop',
- );
- compilation.addEntry(
- compiler.context,
- dep,
- { name: 'noop' },
- (err, module) => {
- if (err) {
- return callback(err);
- }
- callback();
- },
- );
- },
- );
- }
-
- if (!compiler.options.ignoreWarnings) {
- compiler.options.ignoreWarnings = [
- //@ts-ignore
- (message) => /your target environment does not appear/.test(message),
- ];
- }
- }
-
- private validateOptions(compiler: Compiler): boolean {
- const manifestPlugin = compiler.options.plugins.find(
- (p): p is WebpackPluginInstance =>
- p?.constructor?.name === 'BuildManifestPlugin',
- );
-
- if (manifestPlugin) {
- //@ts-ignore
- if (manifestPlugin?.appDirEnabled) {
- throw new Error(
- 'App Directory is not supported by nextjs-mf. Use only pages directory, do not open git issues about this',
- );
- }
- }
-
- const compilerValid = validateCompilerOptions(compiler);
- const pluginValid = validatePluginOptions(this._options);
- const envValid = process.env['NEXT_PRIVATE_LOCAL_WEBPACK'];
- if (compilerValid === undefined) logger.error('Compiler validation failed');
- if (pluginValid === undefined) logger.error('Plugin validation failed');
- const validCompilerTarget =
- compiler.options.name === 'server' || compiler.options.name === 'client';
- if (!envValid)
- throw new Error(
- 'process.env.NEXT_PRIVATE_LOCAL_WEBPACK is not set to true, please set it to true, and "npm install webpack"',
- );
- return (
- compilerValid !== undefined &&
- pluginValid !== undefined &&
- validCompilerTarget
- );
- }
-
- private isServerCompiler(compiler: Compiler): boolean {
- return compiler.options.name === 'server';
- }
-
- private applyConditionalPlugins(compiler: Compiler, isServer: boolean) {
- compiler.options.output.uniqueName = this._options.name;
- compiler.options.output.environment = {
- ...compiler.options.output.environment,
- asyncFunction: true,
- };
-
- // Add layer rules for resource queries
- if (!compiler.options.module.rules) {
- compiler.options.module.rules = [];
- }
-
- // Add layer rules for RSC, client and SSR
- compiler.options.module.rules.push({
- resourceQuery: /\?rsc/,
- layer: 'rsc',
- });
-
- compiler.options.module.rules.push({
- resourceQuery: /\?client/,
- layer: 'client',
- });
-
- compiler.options.module.rules.push({
- resourceQuery: /\?ssr/,
- layer: 'ssr',
- });
-
- applyPathFixes(compiler, this._options, this._extraOptions);
- if (this._extraOptions.debug) {
- compiler.options.devtool = false;
- }
-
- if (isServer) {
- configureServerCompilerOptions(compiler);
- configureServerLibraryAndFilename(this._options);
- applyServerPlugins(compiler, this._options);
- handleServerExternals(compiler, {
- ...this._options,
- shared: { ...retrieveDefaultShared(isServer), ...this._options.shared },
- });
- } else {
- applyClientPlugins(compiler, this._options, this._extraOptions);
- }
- }
-
- private getNormalFederationPluginOptions(
- compiler: Compiler,
- isServer: boolean,
- ): moduleFederationPlugin.ModuleFederationPluginOptions {
- const defaultShared = this._extraOptions.skipSharingNextInternals
- ? {}
- : retrieveDefaultShared(isServer);
-
- return {
- ...this._options,
- runtime: false,
- remoteType: 'script',
- runtimePlugins: [
- ...(isServer
- ? [require.resolve('@module-federation/node/runtimePlugin')]
- : []),
- require.resolve(path.join(__dirname, '../container/runtimePlugin.cjs')),
- ...(this._options.runtimePlugins || []),
- ].map((plugin) => plugin + '?runtimePlugin'),
- //@ts-ignore
- exposes: {
- ...this._options.exposes,
- ...(this._extraOptions.exposePages
- ? exposeNextjsPages(compiler.options.context as string)
- : {}),
- },
- remotes: {
- ...this._options.remotes,
- },
- shared: {
- ...defaultShared,
- ...this._options.shared,
- },
- ...(isServer
- ? { manifest: { filePath: '' } }
- : { manifest: { filePath: '/static/chunks' } }),
- // nextjs project needs to add config.watchOptions = ['**/node_modules/**', '**/@mf-types/**'] to prevent loop types update
- dts: this._options.dts ?? false,
- shareStrategy: this._options.shareStrategy ?? 'loaded-first',
- experiments: {
- asyncStartup: true,
- },
- };
- }
-
- private getNoopPath(): string {
- return require.resolve('../../federation-noop.cjs');
- }
-}
-
-export default NextFederationPlugin;
diff --git a/packages/nextjs-mf/src/plugins/NextFederationPlugin/next-fragments.ts b/packages/nextjs-mf/src/plugins/NextFederationPlugin/next-fragments.ts
deleted file mode 100644
index 7327edba53e..00000000000
--- a/packages/nextjs-mf/src/plugins/NextFederationPlugin/next-fragments.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import type { Compiler, RuleSetRule } from 'webpack';
-import type {
- moduleFederationPlugin,
- sharePlugin,
-} from '@module-federation/sdk';
-import {
- DEFAULT_SHARE_SCOPE,
- DEFAULT_SHARE_SCOPE_BROWSER,
-} from '../../internal';
-import {
- hasLoader,
- injectRuleLoader,
- findLoaderForResource,
-} from '../../loaders/helpers';
-import path from 'path';
-/**
- * Set up default shared values based on the environment.
- * @param {boolean} isServer - Boolean indicating if the code is running on the server.
- * @returns {SharedObject} The default share scope based on the environment.
- */
-export const retrieveDefaultShared = (
- isServer: boolean,
-): moduleFederationPlugin.SharedObject => {
- // If the code is running on the server, treat some Next.js internals as import false to make them external
- // This is because they will be provided by the server environment and not by the remote container
- if (isServer) {
- return DEFAULT_SHARE_SCOPE;
- }
- // If the code is running on the client/browser, always bundle Next.js internals
- return DEFAULT_SHARE_SCOPE_BROWSER;
-};
-export const applyPathFixes = (
- compiler: Compiler,
- pluginOptions: moduleFederationPlugin.ModuleFederationPluginOptions,
- options: any,
-) => {
- const match = findLoaderForResource(
- compiler.options.module.rules as RuleSetRule[],
- {
- path: path.join(compiler.context, '/something/thing.js'),
- issuerLayer: undefined,
- layer: undefined,
- },
- );
-
- compiler.options.module.rules.forEach((rule) => {
- if (typeof rule === 'object' && rule !== null) {
- const typedRule = rule as RuleSetRule;
- // next-image-loader fix which adds remote's hostname to the assets url
- if (
- options.enableImageLoaderFix &&
- hasLoader(typedRule, 'next-image-loader')
- ) {
- injectRuleLoader(typedRule, {
- loader: require.resolve('../../loaders/fixImageLoader'),
- });
- }
-
- if (options.enableUrlLoaderFix && hasLoader(typedRule, 'url-loader')) {
- injectRuleLoader(typedRule, {
- loader: require.resolve('../../loaders/fixUrlLoader'),
- });
- }
- }
- });
-
- if (match) {
- let matchCopy: RuleSetRule;
- if (match.use) {
- matchCopy = { ...match };
- if (Array.isArray(match.use)) {
- matchCopy.use = match.use.filter((loader: any) => {
- return (
- typeof loader === 'object' &&
- loader.loader &&
- !loader.loader.includes('react')
- );
- });
- } else if (typeof match.use === 'string') {
- matchCopy.use = match.use.includes('react') ? '' : match.use;
- } else if (typeof match.use === 'object' && match.use !== null) {
- matchCopy.use =
- match.use.loader && match.use.loader.includes('react')
- ? {}
- : match.use;
- }
- } else {
- matchCopy = { ...match };
- }
-
- const descriptionDataRule: RuleSetRule = {
- ...matchCopy,
- descriptionData: {
- name: /^(@module-federation)/,
- },
- exclude: undefined,
- include: undefined,
- };
-
- const testRule: RuleSetRule = {
- ...matchCopy,
- resourceQuery: /runtimePlugin/,
- exclude: undefined,
- include: undefined,
- };
-
- const oneOfRule = compiler.options.module.rules.find(
- (rule): rule is RuleSetRule => {
- return !!rule && typeof rule === 'object' && 'oneOf' in rule;
- },
- ) as RuleSetRule | undefined;
-
- if (!oneOfRule) {
- compiler.options.module.rules.unshift({
- oneOf: [descriptionDataRule, testRule],
- });
- } else if (oneOfRule.oneOf) {
- oneOfRule.oneOf.unshift(descriptionDataRule, testRule);
- }
- }
-};
-
-export interface NextFederationPluginExtraOptions {
- enableImageLoaderFix?: boolean;
- enableUrlLoaderFix?: boolean;
- exposePages?: boolean;
- skipSharingNextInternals?: boolean;
- automaticPageStitching?: boolean;
- debug?: boolean;
-}
-
-export interface NextFederationPluginOptions
- extends moduleFederationPlugin.ModuleFederationPluginOptions {
- extraOptions: NextFederationPluginExtraOptions;
-}
diff --git a/packages/nextjs-mf/src/plugins/NextFederationPlugin/regex-equal.test.ts b/packages/nextjs-mf/src/plugins/NextFederationPlugin/regex-equal.test.ts
deleted file mode 100644
index ea98b3ad1e7..00000000000
--- a/packages/nextjs-mf/src/plugins/NextFederationPlugin/regex-equal.test.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { regexEqual } from './regex-equal';
-
-describe('regexEqual', () => {
- it('should return true for equal regex patterns', () => {
- const regex1 = /abc/i;
- const regex2 = /abc/i;
-
- const result = regexEqual(regex1, regex2);
-
- expect(result).toBe(true);
- });
-
- it('should return false for different regex patterns', () => {
- const regex1 = /abc/i;
- const regex2 = /def/i;
-
- const result = regexEqual(regex1, regex2);
-
- expect(result).toBe(false);
- });
-
- it('should return false for regex patterns with different flags', () => {
- const regex1 = /abc/i;
- const regex2 = /abc/g;
-
- const result = regexEqual(regex1, regex2);
-
- expect(result).toBe(false);
- });
-
- it('should return false for non-RegExp parameters', () => {
- const regex1 = 'abc';
- const regex2 = /abc/i;
-
- const result = regexEqual(regex1, regex2);
-
- expect(result).toBe(false);
- });
-
- it('should return false for undefined parameters', () => {
- const regex1 = undefined;
- const regex2 = /abc/i;
-
- const result = regexEqual(regex1, regex2);
-
- expect(result).toBe(false);
- });
-});
diff --git a/packages/nextjs-mf/src/plugins/NextFederationPlugin/regex-equal.ts b/packages/nextjs-mf/src/plugins/NextFederationPlugin/regex-equal.ts
deleted file mode 100644
index f8f864eb171..00000000000
--- a/packages/nextjs-mf/src/plugins/NextFederationPlugin/regex-equal.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { RuleSetConditionAbsolute } from 'webpack';
-
-/**
- * Compares two regular expressions or other types of conditions to see if they are equal.
- *
- * @param x - The first condition to compare. It can be a string, a RegExp, a function that takes a string and returns a boolean, an array of RuleSetConditionAbsolute, or undefined.
- * @param y - The second condition to compare. It is always a RegExp.
- * @returns True if the conditions are equal, false otherwise.
- *
- * @remarks
- * This function compares two conditions to see if they are equal in terms of their source,
- * global, ignoreCase, and multiline properties. It is used to check if two conditions match
- * the same pattern. If the first condition is not a RegExp, the function will always return false.
- */
-export const regexEqual = (
- x:
- | string
- | RegExp
- | ((value: string) => boolean)
- | RuleSetConditionAbsolute[]
- | undefined,
- y: RegExp,
-): boolean => {
- return (
- x instanceof RegExp &&
- y instanceof RegExp &&
- x.source === y.source &&
- x.global === y.global &&
- x.ignoreCase === y.ignoreCase &&
- x.multiline === y.multiline
- );
-};
diff --git a/packages/nextjs-mf/src/plugins/NextFederationPlugin/remove-unnecessary-shared-keys.test.ts b/packages/nextjs-mf/src/plugins/NextFederationPlugin/remove-unnecessary-shared-keys.test.ts
deleted file mode 100644
index 6f9bd3348bb..00000000000
--- a/packages/nextjs-mf/src/plugins/NextFederationPlugin/remove-unnecessary-shared-keys.test.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import logger from '../../logger';
-import { removeUnnecessarySharedKeys } from './remove-unnecessary-shared-keys';
-
-describe('removeUnnecessarySharedKeys', () => {
- beforeEach(() => {
- jest.spyOn(logger, 'warn').mockImplementation(jest.fn());
- });
-
- afterEach(() => {
- jest.restoreAllMocks();
- });
-
- it('should remove unnecessary shared keys from the given object', () => {
- const shared: Record = {
- react: '17.0.0',
- 'react-dom': '17.0.0',
- lodash: '4.17.21',
- };
-
- removeUnnecessarySharedKeys(shared);
-
- expect(shared).toEqual({ lodash: '4.17.21' });
- expect(logger.warn).toHaveBeenCalled();
- });
-
- it('should not remove keys that are not in the default share scope', () => {
- const shared: Record = {
- lodash: '4.17.21',
- axios: '0.21.1',
- };
-
- removeUnnecessarySharedKeys(shared);
-
- expect(shared).toEqual({ lodash: '4.17.21', axios: '0.21.1' });
- expect(logger.warn).not.toHaveBeenCalled();
- });
-
- it('should not remove keys from an empty object', () => {
- const shared: Record = {};
-
- removeUnnecessarySharedKeys(shared);
-
- expect(shared).toEqual({});
- expect(logger.warn).not.toHaveBeenCalled();
- });
-});
diff --git a/packages/nextjs-mf/src/plugins/NextFederationPlugin/remove-unnecessary-shared-keys.ts b/packages/nextjs-mf/src/plugins/NextFederationPlugin/remove-unnecessary-shared-keys.ts
deleted file mode 100644
index c479eb52822..00000000000
--- a/packages/nextjs-mf/src/plugins/NextFederationPlugin/remove-unnecessary-shared-keys.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * Utility function to remove unnecessary shared keys from the default share scope.
- * It checks each key in the shared object against the default share scope.
- * If a key is found in the default share scope, a warning is logged and the key is removed from the shared object.
- *
- * @param {Record} shared - The shared object to be checked.
- */
-import { DEFAULT_SHARE_SCOPE } from '../../internal';
-import logger from '../../logger';
-
-/**
- * Function to remove unnecessary shared keys from the default share scope.
- * It iterates over each key in the shared object and checks against the default share scope.
- * If a key is found in the default share scope, a warning is logged and the key is removed from the shared object.
- *
- * @param {Record} shared - The shared object to be checked.
- */
-export function removeUnnecessarySharedKeys(
- shared: Record,
-): void {
- Object.keys(shared).forEach((key: string) => {
- /**
- * If the key is found in the default share scope, log a warning and remove the key from the shared object.
- */
- if (DEFAULT_SHARE_SCOPE[key]) {
- logger.warn(
- `You are sharing ${key} from the default share scope. This is not necessary and can be removed.`,
- );
- delete (shared as { [key: string]: unknown })[key];
- }
- });
-}
diff --git a/packages/nextjs-mf/src/plugins/NextFederationPlugin/set-options.ts b/packages/nextjs-mf/src/plugins/NextFederationPlugin/set-options.ts
deleted file mode 100644
index bae02d0b794..00000000000
--- a/packages/nextjs-mf/src/plugins/NextFederationPlugin/set-options.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import type { moduleFederationPlugin } from '@module-federation/sdk';
-
-export interface NextFederationPluginExtraOptions {
- enableImageLoaderFix?: boolean;
- enableUrlLoaderFix?: boolean;
- exposePages?: boolean;
- skipSharingNextInternals?: boolean;
- automaticPageStitching?: boolean;
- debug?: boolean;
-}
-
-export interface NextFederationPluginOptions
- extends moduleFederationPlugin.ModuleFederationPluginOptions {
- extraOptions: NextFederationPluginExtraOptions;
-}
-
-export function setOptions(options: NextFederationPluginOptions): {
- mainOptions: moduleFederationPlugin.ModuleFederationPluginOptions;
- extraOptions: NextFederationPluginExtraOptions;
-} {
- const { extraOptions, ...mainOpts } = options;
-
- /**
- * Default extra options for NextFederationPlugin.
- * @type {NextFederationPluginExtraOptions}
- */
- const defaultExtraOptions: NextFederationPluginExtraOptions = {
- automaticPageStitching: false,
- enableImageLoaderFix: false,
- enableUrlLoaderFix: false,
- skipSharingNextInternals: false,
- debug: false,
- };
-
- return {
- mainOptions: mainOpts,
- extraOptions: { ...defaultExtraOptions, ...extraOptions },
- };
-}
diff --git a/packages/nextjs-mf/src/plugins/NextFederationPlugin/validate-options.ts b/packages/nextjs-mf/src/plugins/NextFederationPlugin/validate-options.ts
deleted file mode 100644
index 2d2407df177..00000000000
--- a/packages/nextjs-mf/src/plugins/NextFederationPlugin/validate-options.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import type { Compiler } from 'webpack';
-import type { moduleFederationPlugin } from '@module-federation/sdk';
-
-/**
- * Validates the compiler options.
- *
- * @param {Compiler} compiler - The Webpack compiler instance.
- * @returns {boolean} - Returns true if the compiler options are valid, false otherwise.
- *
- * @throws Will throw an error if the name option is not defined in the options.
- * @remarks
- * This function validates the options passed to the Webpack compiler. It checks if the name option is set to either "server" or
- * "client", as Module Federation is only applied to the main server and client builds in Next.js.
- */
-export function validateCompilerOptions(compiler: Compiler): boolean {
- // Throw an error if the name option is not defined in the options
- if (!compiler.options.name) {
- throw new Error('name is not defined in Compiler options');
- }
-
- // Only apply Module Federation to the main server and client builds in Next.js
- return ['server', 'client'].includes(compiler.options.name);
-}
-
-/**
- * Validates the NextFederationPlugin options.
- *
- * @param {moduleFederationPlugin.ModuleFederationPluginOptions} options - The ModuleFederationPluginOptions instance.
- *
- * @throws Will throw an error if the filename option is not defined in the options or if the name option is not specified.
- * @remarks
- * This function validates the options passed to NextFederationPlugin. It ensures that the filename and name options are defined,
- * as they are required for using Module Federation.
- */
-export function validatePluginOptions(
- options: moduleFederationPlugin.ModuleFederationPluginOptions,
-): boolean | void {
- // Throw an error if the filename option is not defined in the options
- if (!options.filename) {
- throw new Error('filename is not defined in NextFederation options');
- }
-
- // A requirement for using Module Federation is that a name must be specified
- if (!options.name) {
- throw new Error('Module federation "name" option must be specified');
- }
- return true;
-}
diff --git a/packages/nextjs-mf/src/plugins/container/RemoveEagerModulesFromRuntimePlugin.ts b/packages/nextjs-mf/src/plugins/container/RemoveEagerModulesFromRuntimePlugin.ts
deleted file mode 100644
index b5e3740833e..00000000000
--- a/packages/nextjs-mf/src/plugins/container/RemoveEagerModulesFromRuntimePlugin.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import type { Compiler, Compilation, Chunk, Module } from 'webpack';
-import { bindLoggerToCompiler } from '@module-federation/sdk';
-import logger from '../../logger';
-
-/**
- * This plugin removes eager modules from the runtime.
- * @class RemoveEagerModulesFromRuntimePlugin
- */
-class RemoveEagerModulesFromRuntimePlugin {
- private container: string | undefined;
- private debug: boolean;
- private modulesToProcess: Set;
-
- /**
- * Creates an instance of RemoveEagerModulesFromRuntimePlugin.
- * @param {Object} options - The options for the plugin.
- * @param {string} options.container - The container to remove modules from.
- * @param {boolean} options.debug - Whether to log debug information.
- */
- constructor(options: { container?: string; debug?: boolean }) {
- this.container = options.container;
- this.debug = options.debug || false;
- this.modulesToProcess = new Set();
- }
-
- /**
- * Applies the plugin to the compiler.
- * @param {Compiler} compiler - The webpack compiler.
- */
- apply(compiler: Compiler) {
- if (!this.container) {
- logger.warn(
- `RemoveEagerModulesFromRuntimePlugin container is not defined: ${this.container}`,
- );
- return;
- }
-
- bindLoggerToCompiler(
- logger,
- compiler,
- 'RemoveEagerModulesFromRuntimePlugin',
- );
-
- compiler.hooks.thisCompilation.tap(
- 'RemoveEagerModulesFromRuntimePlugin',
- (compilation: Compilation) => {
- compilation.hooks.optimizeChunkModules.tap(
- 'RemoveEagerModulesFromRuntimePlugin',
- (chunks: Iterable, modules: Iterable) => {
- for (const chunk of chunks) {
- if (chunk.hasRuntime() && chunk.name === this.container) {
- this.processModules(compilation, chunk, modules);
- }
- }
- },
- );
- },
- );
- }
-
- /**
- * Processes the modules in the chunk.
- * @param {Compilation} compilation - The webpack compilation.
- * @param {Chunk} chunk - The chunk to process.
- * @param {Iterable} modules - The modules in the chunk.
- */
- private processModules(
- compilation: Compilation,
- chunk: Chunk,
- modules: Iterable,
- ) {
- for (const module of modules) {
- if (!compilation.chunkGraph.isModuleInChunk(module, chunk)) {
- continue;
- }
-
- if (module.constructor.name === 'NormalModule') {
- this.modulesToProcess.add(module);
- }
- }
-
- this.removeModules(compilation, chunk);
- }
-
- /**
- * Removes the modules from the chunk.
- * @param {Compilation} compilation - The webpack compilation.
- * @param {Chunk} chunk - The chunk to remove modules from.
- */
- private removeModules(compilation: Compilation, chunk: Chunk) {
- for (const moduleToRemove of this.modulesToProcess) {
- if (this.debug) {
- logger.info(`removing ${moduleToRemove.constructor.name}`);
- }
-
- if (compilation.chunkGraph.isModuleInChunk(moduleToRemove, chunk)) {
- compilation.chunkGraph.disconnectChunkAndModule(chunk, moduleToRemove);
- }
- }
- }
-}
-
-export default RemoveEagerModulesFromRuntimePlugin;
diff --git a/packages/nextjs-mf/src/plugins/container/runtimePlugin.ts b/packages/nextjs-mf/src/plugins/container/runtimePlugin.ts
deleted file mode 100644
index 3eeddcfe3fd..00000000000
--- a/packages/nextjs-mf/src/plugins/container/runtimePlugin.ts
+++ /dev/null
@@ -1,254 +0,0 @@
-import { ModuleFederationRuntimePlugin } from '@module-federation/runtime';
-
-export default function (): ModuleFederationRuntimePlugin {
- return {
- name: 'next-internal-plugin',
- createScript: function (args: {
- url: string;
- attrs?: Record;
- }) {
- const url = args.url;
- const attrs = args.attrs;
- if (typeof window !== 'undefined') {
- const script = document.createElement('script');
- script.src = url;
- script.async = true;
- delete attrs?.['crossorigin'];
-
- return { script: script, timeout: 8000 };
- }
- return undefined;
- },
- errorLoadRemote: function (args: {
- id: string;
- error: any;
- from: string;
- origin: any;
- }) {
- const id = args.id;
- const error = args.error;
- const from = args.from;
- //@ts-ignore
- globalThis.moduleGraphDirty = true;
- console.error(id, 'offline');
- const pg = function () {
- console.error(id, 'offline', error);
- return null;
- };
-
- (pg as any).getInitialProps = function (ctx: any) {
- return {};
- };
- let mod;
- if (from === 'build') {
- mod = function () {
- return {
- __esModule: true,
- default: pg,
- getServerSideProps: function () {
- return { props: {} };
- },
- };
- };
- } else {
- mod = {
- default: pg,
- getServerSideProps: function () {
- return { props: {} };
- },
- };
- }
-
- return mod;
- },
- beforeInit: function (args) {
- if (!globalThis.usedChunks) globalThis.usedChunks = new Set();
- if (
- typeof __webpack_runtime_id__ === 'string' &&
- !__webpack_runtime_id__.startsWith('webpack')
- ) {
- return args;
- }
-
- const moduleCache = args.origin.moduleCache;
- const name = args.origin.name;
- let gs;
- try {
- gs = new Function('return globalThis')();
- } catch (e) {
- gs = globalThis; // fallback for browsers without 'unsafe-eval' CSP policy enabled
- }
- //@ts-ignore
- const attachedRemote = gs[name];
- if (attachedRemote) {
- moduleCache.set(name, attachedRemote);
- }
-
- return args;
- },
- init: function (args: any) {
- return args;
- },
- beforeRequest: function (args: any) {
- const options = args.options;
- const id = args.id;
- const remoteName = id.split('/').shift();
- const remote = options.remotes.find(function (remote: any) {
- return remote.name === remoteName;
- });
- if (!remote) return args;
- if (remote && remote.entry && remote.entry.includes('?t=')) {
- return args;
- }
- remote.entry = remote.entry + '?t=' + Date.now();
- return args;
- },
- afterResolve: function (args: any) {
- return args;
- },
- onLoad: function (args: any) {
- const exposeModuleFactory = args.exposeModuleFactory;
- const exposeModule = args.exposeModule;
- const id = args.id;
- const moduleOrFactory = exposeModuleFactory || exposeModule;
- if (!moduleOrFactory) return args;
-
- if (typeof window === 'undefined') {
- let exposedModuleExports: any;
- try {
- exposedModuleExports = moduleOrFactory();
- } catch (e) {
- exposedModuleExports = moduleOrFactory;
- }
-
- const handler: ProxyHandler = {
- get: function (target, prop, receiver) {
- if (
- target === exposedModuleExports &&
- typeof exposedModuleExports[prop] === 'function'
- ) {
- return function (this: unknown) {
- globalThis.usedChunks.add(id);
- //eslint-disable-next-line
- return exposedModuleExports[prop].apply(this, arguments);
- };
- }
-
- const originalMethod = target[prop];
- if (typeof originalMethod === 'function') {
- const proxiedFunction = function (this: unknown) {
- globalThis.usedChunks.add(id);
- //eslint-disable-next-line
- return originalMethod.apply(this, arguments);
- };
-
- Object.keys(originalMethod).forEach(function (prop) {
- Object.defineProperty(proxiedFunction, prop, {
- value: originalMethod[prop],
- writable: true,
- enumerable: true,
- configurable: true,
- });
- });
-
- return proxiedFunction;
- }
-
- return Reflect.get(target, prop, receiver);
- },
- };
-
- if (typeof exposedModuleExports === 'function') {
- exposedModuleExports = new Proxy(exposedModuleExports, handler);
-
- const staticProps = Object.getOwnPropertyNames(exposedModuleExports);
- staticProps.forEach(function (prop) {
- if (typeof exposedModuleExports[prop] === 'function') {
- exposedModuleExports[prop] = new Proxy(
- exposedModuleExports[prop],
- handler,
- );
- }
- });
- return function () {
- return exposedModuleExports;
- };
- } else {
- exposedModuleExports = new Proxy(exposedModuleExports, handler);
- }
-
- return exposedModuleExports;
- }
-
- return args;
- },
- loadRemoteSnapshot(args) {
- const { from, remoteSnapshot, manifestUrl, manifestJson, options } = args;
-
- // ensure snapshot is loaded from manifest
- if (
- from !== 'manifest' ||
- !manifestUrl ||
- !manifestJson ||
- !('publicPath' in remoteSnapshot)
- ) {
- return args;
- }
-
- // re-assign publicPath based on remoteEntry location if in browser nextjs remote
- const { publicPath } = remoteSnapshot;
- if (options.inBrowser && publicPath.includes('/_next/')) {
- remoteSnapshot.publicPath = publicPath.substring(
- 0,
- publicPath.lastIndexOf('/_next/') + 7,
- );
- } else {
- const serverPublicPath = manifestUrl.substring(
- 0,
- manifestUrl.indexOf('mf-manifest.json'),
- );
- remoteSnapshot.publicPath = serverPublicPath;
- }
-
- if ('publicPath' in manifestJson.metaData) {
- manifestJson.metaData.publicPath = remoteSnapshot.publicPath;
- }
-
- return args;
- },
- resolveShare: function (args: any) {
- if (
- args.pkgName !== 'react' &&
- args.pkgName !== 'react-dom' &&
- !args.pkgName.startsWith('next/')
- ) {
- return args;
- }
- const shareScopeMap = args.shareScopeMap;
- const scope = args.scope;
- const pkgName = args.pkgName;
- const version = args.version;
- const GlobalFederation = args.GlobalFederation;
- const host = GlobalFederation['__INSTANCES__'][0];
- if (!host) {
- return args;
- }
-
- if (!host.options.shared[pkgName]) {
- return args;
- }
- args.resolver = function () {
- shareScopeMap[scope][pkgName][version] =
- host.options.shared[pkgName][0];
- return {
- shared: shareScopeMap[scope][pkgName][version],
- useTreesShaking: false,
- };
- };
- return args;
- },
- beforeLoadShare: async function (args: any) {
- return args;
- },
- };
-}
diff --git a/packages/nextjs-mf/src/plugins/container/types.ts b/packages/nextjs-mf/src/plugins/container/types.ts
deleted file mode 100644
index 896758194e2..00000000000
--- a/packages/nextjs-mf/src/plugins/container/types.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import type { container } from 'webpack';
-
-export type ModuleFederationPluginOptions = ConstructorParameters<
- typeof container.ModuleFederationPlugin
->['0'];
diff --git a/packages/nextjs-mf/src/types.ts b/packages/nextjs-mf/src/types.ts
index aacca07ec28..89b7c5bb80a 100644
--- a/packages/nextjs-mf/src/types.ts
+++ b/packages/nextjs-mf/src/types.ts
@@ -1,35 +1,78 @@
-export declare interface WatchOptions {
- /**
- * Delay the rebuilt after the first change. Value is a time in ms.
- */
- aggregateTimeout?: number;
+import type { moduleFederationPlugin } from '@module-federation/sdk';
- /**
- * Resolve symlinks and watch symlink and real file. This is usually not needed as webpack already resolves symlinks ('resolve.symlinks').
- */
- followSymlinks?: boolean;
+export type NextFederationMode = 'pages' | 'app' | 'hybrid';
- /**
- * Ignore some files from watching (glob pattern or regexp).
- */
- ignored?: string | RegExp | string[];
+export type FederationRemotes =
+ moduleFederationPlugin.ModuleFederationPluginOptions['remotes'];
- /**
- * Enable polling mode for watching.
- */
- poll?: number | boolean;
+export interface NextFederationCompilerContext {
+ isServer: boolean;
+ nextRuntime?: 'nodejs' | 'edge';
+ compilerName?: string;
+}
+
+export type NextFederationRemotesResolver = (
+ context: NextFederationCompilerContext,
+) => FederationRemotes;
- /**
- * Stop watching when stdin stream has ended.
- */
- stdin?: boolean;
+export interface NextFederationOptionsV9
+ extends Omit<
+ moduleFederationPlugin.ModuleFederationPluginOptions,
+ 'remotes' | 'runtime'
+ > {
+ filename?: string;
+ mode?: NextFederationMode;
+ remotes?: FederationRemotes | NextFederationRemotesResolver;
+ pages?: {
+ exposePages?: boolean;
+ pageMapFormat?: 'legacy' | 'routes-v2';
+ };
+ app?: {
+ enableClientComponents?: boolean;
+ enableRsc?: boolean;
+ };
+ runtime?: {
+ environment?: 'node';
+ onRemoteFailure?: 'error' | 'null-fallback';
+ runtimePlugins?: (string | [string, Record])[];
+ };
+ sharing?: {
+ includeNextInternals?: boolean;
+ strategy?: 'loaded-first' | 'version-first';
+ };
+ diagnostics?: {
+ level?: 'error' | 'warn' | 'info' | 'debug';
+ };
}
-export declare interface CallbackFunction {
- (err?: null | Error, result?: T): any;
+export interface ResolvedNextFederationOptions {
+ mode: NextFederationMode;
+ filename: string;
+ pages: {
+ exposePages: boolean;
+ pageMapFormat: 'legacy' | 'routes-v2';
+ };
+ app: {
+ enableClientComponents: boolean;
+ enableRsc: boolean;
+ };
+ runtime: {
+ environment: 'node';
+ onRemoteFailure: 'error' | 'null-fallback';
+ runtimePlugins: (string | [string, Record])[];
+ };
+ sharing: {
+ includeNextInternals: boolean;
+ strategy: 'loaded-first' | 'version-first';
+ };
+ diagnostics: {
+ level: 'error' | 'warn' | 'info' | 'debug';
+ };
+ federation: moduleFederationPlugin.ModuleFederationPluginOptions;
+ remotesResolver?: NextFederationRemotesResolver;
}
-declare global {
- //eslint-disable-next-line
- var usedChunks: Set;
+export interface RouterPresence {
+ hasPages: boolean;
+ hasApp: boolean;
}
diff --git a/packages/nextjs-mf/src/types/btoa.d.ts b/packages/nextjs-mf/src/types/btoa.d.ts
index 8aebed8e393..b226f5be30d 100644
--- a/packages/nextjs-mf/src/types/btoa.d.ts
+++ b/packages/nextjs-mf/src/types/btoa.d.ts
@@ -1,4 +1 @@
-declare module 'btoa' {
- function btoa(str: string): string;
- export = btoa;
-}
+declare module 'btoa';
diff --git a/packages/nextjs-mf/src/withNextFederation.ts b/packages/nextjs-mf/src/withNextFederation.ts
new file mode 100644
index 00000000000..1f748b5c9e0
--- /dev/null
+++ b/packages/nextjs-mf/src/withNextFederation.ts
@@ -0,0 +1,554 @@
+import path from 'path';
+import fs from 'fs';
+import { createRequire } from 'module';
+import type { NextConfig } from 'next';
+import type { Configuration, WebpackPluginInstance } from 'webpack';
+import { getWebpackPath } from '@module-federation/sdk/normalize-webpack-path';
+import type { moduleFederationPlugin } from '@module-federation/sdk';
+import {
+ assertLocalWebpackEnabled,
+ assertWebpackBuildInvocation,
+ isNextBuildOrDevCommand,
+ normalizeNextFederationOptions,
+ resolveFederationRemotes,
+} from './core/options';
+import {
+ assertModeRouterCompatibility,
+ assertUnsupportedAppRouterTargets,
+ detectRouterPresence,
+} from './core/features/app';
+import { buildPagesExposes } from './core/features/pages';
+import { buildSharedConfig } from './core/sharing';
+import { buildRuntimePlugins } from './core/runtime';
+import { applyFederatedAssetLoaderFixes } from './core/loaders/patchLoaders';
+import { configureServerCompiler } from './core/compilers/server';
+import { configureClientCompiler } from './core/compilers/client';
+import type {
+ NextFederationCompilerContext,
+ NextFederationOptionsV9,
+} from './types';
+
+interface NextWebpackContext {
+ dir: string;
+ isServer: boolean;
+ nextRuntime?: 'nodejs' | 'edge';
+ webpack?: (...args: unknown[]) => unknown;
+}
+
+class EnsureCompilerWebpackPlugin {
+ apply(compiler: import('webpack').Compiler): void {
+ if (compiler.webpack) {
+ if (!compiler.webpack.sources) {
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const webpack = require(
+ process.env['FEDERATION_WEBPACK_PATH'] || 'webpack',
+ );
+ if (webpack?.sources) {
+ (compiler.webpack as any).sources = webpack.sources;
+ }
+ } catch {
+ // ignore fallback failures
+ }
+ }
+ return;
+ }
+
+ const webpackPath = process.env['FEDERATION_WEBPACK_PATH'] || 'webpack';
+
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const webpack = require(webpackPath);
+ compiler.webpack = webpack;
+ } catch {
+ // ignore fallback failures
+ }
+ }
+}
+
+function isTruthy(value: string | undefined): boolean {
+ if (!value) {
+ return false;
+ }
+ return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
+}
+
+function getModuleFederationPluginCtor() {
+ const enhancedWebpack =
+ require('@module-federation/enhanced/webpack') as typeof import('@module-federation/enhanced/webpack');
+ return enhancedWebpack.ModuleFederationPlugin;
+}
+
+function resolveWebpackFromNodeModules(root: string): string {
+ const webpackDir = path.join(root, 'node_modules', 'webpack');
+
+ try {
+ const webpackRealPath = fs.realpathSync(webpackDir);
+ const libIndexPath = path.join(webpackRealPath, 'lib', 'index.js');
+ if (fs.existsSync(libIndexPath)) {
+ return libIndexPath;
+ }
+ } catch {
+ return '';
+ }
+
+ return '';
+}
+
+function resolveLocalWebpackPath(contextDir?: string): string {
+ const entryRoots = [contextDir, process.cwd()].filter(
+ (candidate): candidate is string => Boolean(candidate),
+ );
+ const searchRoots: string[] = [];
+ const seenRoots = new Set();
+
+ for (const entryRoot of entryRoots) {
+ let currentRoot = path.resolve(entryRoot);
+
+ while (!seenRoots.has(currentRoot)) {
+ seenRoots.add(currentRoot);
+ searchRoots.push(currentRoot);
+
+ const parentRoot = path.dirname(currentRoot);
+ if (parentRoot === currentRoot) {
+ break;
+ }
+ currentRoot = parentRoot;
+ }
+ }
+
+ for (const root of searchRoots) {
+ const fsResolvedPath = resolveWebpackFromNodeModules(root);
+ if (fsResolvedPath) {
+ return fsResolvedPath;
+ }
+
+ try {
+ const requireFromRoot = createRequire(path.join(root, 'package.json'));
+ return requireFromRoot.resolve('webpack');
+ } catch (_error) {
+ continue;
+ }
+ }
+
+ try {
+ return require.resolve('webpack');
+ } catch (_error) {
+ return '';
+ }
+}
+
+function patchNextRequireHookForLocalWebpack(contextDir?: string): void {
+ if (!isTruthy(process.env['NEXT_PRIVATE_LOCAL_WEBPACK'])) {
+ return;
+ }
+
+ const localWebpackPath = resolveLocalWebpackPath(contextDir);
+ if (!localWebpackPath) {
+ return;
+ }
+
+ const webpackRoot = path.dirname(path.dirname(localWebpackPath));
+ let webpackSourcesPath = '';
+ let webpackSourcesPackageJson = '';
+ const webpackPackageJsonPath = path.join(webpackRoot, 'package.json');
+ const webpackLibPath = path.join(webpackRoot, 'lib', 'webpack.js');
+ const webpackAliases: [string, string][] = [
+ ['webpack', localWebpackPath],
+ ['webpack/package', webpackPackageJsonPath],
+ ['webpack/package.json', webpackPackageJsonPath],
+ ['webpack/lib/webpack', webpackLibPath],
+ ['webpack/lib/webpack.js', webpackLibPath],
+ [
+ 'webpack/lib/node/NodeEnvironmentPlugin',
+ path.join(webpackRoot, 'lib', 'node', 'NodeEnvironmentPlugin.js'),
+ ],
+ [
+ 'webpack/lib/node/NodeEnvironmentPlugin.js',
+ path.join(webpackRoot, 'lib', 'node', 'NodeEnvironmentPlugin.js'),
+ ],
+ [
+ 'webpack/lib/BasicEvaluatedExpression',
+ path.join(
+ webpackRoot,
+ 'lib',
+ 'javascript',
+ 'BasicEvaluatedExpression.js',
+ ),
+ ],
+ [
+ 'webpack/lib/BasicEvaluatedExpression.js',
+ path.join(
+ webpackRoot,
+ 'lib',
+ 'javascript',
+ 'BasicEvaluatedExpression.js',
+ ),
+ ],
+ [
+ 'webpack/lib/node/NodeTargetPlugin',
+ path.join(webpackRoot, 'lib', 'node', 'NodeTargetPlugin.js'),
+ ],
+ [
+ 'webpack/lib/node/NodeTargetPlugin.js',
+ path.join(webpackRoot, 'lib', 'node', 'NodeTargetPlugin.js'),
+ ],
+ [
+ 'webpack/lib/node/NodeTemplatePlugin',
+ path.join(webpackRoot, 'lib', 'node', 'NodeTemplatePlugin.js'),
+ ],
+ [
+ 'webpack/lib/node/NodeTemplatePlugin.js',
+ path.join(webpackRoot, 'lib', 'node', 'NodeTemplatePlugin.js'),
+ ],
+ [
+ 'webpack/lib/LibraryTemplatePlugin',
+ path.join(webpackRoot, 'lib', 'LibraryTemplatePlugin.js'),
+ ],
+ [
+ 'webpack/lib/LibraryTemplatePlugin.js',
+ path.join(webpackRoot, 'lib', 'LibraryTemplatePlugin.js'),
+ ],
+ [
+ 'webpack/lib/SingleEntryPlugin',
+ path.join(webpackRoot, 'lib', 'SingleEntryPlugin.js'),
+ ],
+ [
+ 'webpack/lib/SingleEntryPlugin.js',
+ path.join(webpackRoot, 'lib', 'SingleEntryPlugin.js'),
+ ],
+ [
+ 'webpack/lib/optimize/LimitChunkCountPlugin',
+ path.join(webpackRoot, 'lib', 'optimize', 'LimitChunkCountPlugin.js'),
+ ],
+ [
+ 'webpack/lib/optimize/LimitChunkCountPlugin.js',
+ path.join(webpackRoot, 'lib', 'optimize', 'LimitChunkCountPlugin.js'),
+ ],
+ [
+ 'webpack/lib/webworker/WebWorkerTemplatePlugin',
+ path.join(webpackRoot, 'lib', 'webworker', 'WebWorkerTemplatePlugin.js'),
+ ],
+ [
+ 'webpack/lib/webworker/WebWorkerTemplatePlugin.js',
+ path.join(webpackRoot, 'lib', 'webworker', 'WebWorkerTemplatePlugin.js'),
+ ],
+ [
+ 'webpack/lib/ExternalsPlugin',
+ path.join(webpackRoot, 'lib', 'ExternalsPlugin.js'),
+ ],
+ [
+ 'webpack/lib/ExternalsPlugin.js',
+ path.join(webpackRoot, 'lib', 'ExternalsPlugin.js'),
+ ],
+ [
+ 'webpack/lib/web/FetchCompileWasmTemplatePlugin',
+ path.join(webpackRoot, 'lib', 'web', 'FetchCompileWasmTemplatePlugin.js'),
+ ],
+ [
+ 'webpack/lib/web/FetchCompileWasmTemplatePlugin.js',
+ path.join(webpackRoot, 'lib', 'web', 'FetchCompileWasmTemplatePlugin.js'),
+ ],
+ [
+ 'webpack/lib/web/FetchCompileWasmPlugin',
+ path.join(webpackRoot, 'lib', 'web', 'FetchCompileWasmPlugin.js'),
+ ],
+ [
+ 'webpack/lib/web/FetchCompileWasmPlugin.js',
+ path.join(webpackRoot, 'lib', 'web', 'FetchCompileWasmPlugin.js'),
+ ],
+ [
+ 'webpack/lib/web/FetchCompileAsyncWasmPlugin',
+ path.join(webpackRoot, 'lib', 'web', 'FetchCompileAsyncWasmPlugin.js'),
+ ],
+ [
+ 'webpack/lib/web/FetchCompileAsyncWasmPlugin.js',
+ path.join(webpackRoot, 'lib', 'web', 'FetchCompileAsyncWasmPlugin.js'),
+ ],
+ [
+ 'webpack/lib/ModuleFilenameHelpers',
+ path.join(webpackRoot, 'lib', 'ModuleFilenameHelpers.js'),
+ ],
+ [
+ 'webpack/lib/ModuleFilenameHelpers.js',
+ path.join(webpackRoot, 'lib', 'ModuleFilenameHelpers.js'),
+ ],
+ [
+ 'webpack/lib/GraphHelpers',
+ path.join(webpackRoot, 'lib', 'GraphHelpers.js'),
+ ],
+ [
+ 'webpack/lib/GraphHelpers.js',
+ path.join(webpackRoot, 'lib', 'GraphHelpers.js'),
+ ],
+ [
+ 'webpack/lib/NormalModule',
+ path.join(webpackRoot, 'lib', 'NormalModule.js'),
+ ],
+ ];
+ const webpackSourcesFsCandidate = path.join(
+ webpackRoot,
+ '..',
+ 'webpack-sources',
+ 'lib',
+ 'index.js',
+ );
+
+ try {
+ const requireFromWebpack = createRequire(
+ path.join(webpackRoot, 'package.json'),
+ );
+ try {
+ webpackSourcesPackageJson = requireFromWebpack.resolve(
+ 'webpack-sources/package.json',
+ );
+ } catch {
+ webpackSourcesPackageJson = '';
+ }
+
+ if (webpackSourcesPackageJson) {
+ const webpackSourcesRoot = path.dirname(webpackSourcesPackageJson);
+ const webpackSourcesIndex = path.join(
+ webpackSourcesRoot,
+ 'lib',
+ 'index.js',
+ );
+ if (fs.existsSync(webpackSourcesIndex)) {
+ webpackSourcesPath = webpackSourcesIndex;
+ }
+ }
+
+ if (!webpackSourcesPath) {
+ webpackSourcesPath = requireFromWebpack.resolve('webpack-sources');
+ }
+ } catch {
+ return;
+ }
+
+ const aliases: [string, string][] = [
+ ...webpackAliases,
+ ['webpack-sources', webpackSourcesPath],
+ ['webpack-sources/lib', webpackSourcesPath],
+ ['webpack-sources/lib/index', webpackSourcesPath],
+ ['webpack-sources/lib/index.js', webpackSourcesPath],
+ ].filter(
+ (entry): entry is [string, string] =>
+ Boolean(entry[1]) && fs.existsSync(entry[1]),
+ );
+
+ const requireBaseDirs = [contextDir, process.cwd()].filter(
+ (candidate): candidate is string => Boolean(candidate),
+ );
+
+ for (const requireBaseDir of requireBaseDirs) {
+ try {
+ const requireFromBase = createRequire(
+ path.join(requireBaseDir, 'package.json'),
+ );
+ const hook = requireFromBase('next/dist/server/require-hook') as {
+ addHookAliases?: (aliases: [string, string][]) => void;
+ hookPropertyMap?: Map;
+ };
+ hook.addHookAliases?.(aliases);
+ } catch {
+ // ignore missing hooks for this base dir
+ }
+ }
+
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const webpackModule = require(localWebpackPath) as typeof import('webpack');
+ if (
+ webpackModule?.Compiler &&
+ !(webpackModule.Compiler as typeof webpackModule.Compiler).prototype
+ .webpack
+ ) {
+ (webpackModule.Compiler as any).prototype.webpack = webpackModule;
+ }
+ } catch {
+ // ignore runtime patch failures
+ }
+}
+
+function ensureFederationWebpackPath(context: NextWebpackContext): void {
+ if (process.env['FEDERATION_WEBPACK_PATH']) {
+ return;
+ }
+
+ let inferredPath = '';
+ const localWebpackPath = resolveLocalWebpackPath(context.dir);
+
+ if (typeof context.webpack === 'function') {
+ inferredPath = getWebpackPath(
+ { webpack: context.webpack } as unknown as import('webpack').Compiler,
+ { framework: 'nextjs' },
+ );
+ }
+
+ process.env['FEDERATION_WEBPACK_PATH'] =
+ localWebpackPath ||
+ inferredPath ||
+ process.env['FEDERATION_WEBPACK_PATH'] ||
+ '';
+}
+
+function inferCompilerName(
+ config: Configuration,
+ context: NextWebpackContext,
+): string {
+ if (typeof config.name === 'string' && config.name.length > 0) {
+ return config.name;
+ }
+
+ if (!context.isServer) {
+ return 'client';
+ }
+
+ return context.nextRuntime === 'edge' ? 'edge-server' : 'server';
+}
+
+function toCompilerContext(
+ compilerName: string,
+ context: NextWebpackContext,
+): NextFederationCompilerContext {
+ return {
+ isServer: compilerName === 'server',
+ nextRuntime: context.nextRuntime,
+ compilerName,
+ };
+}
+
+function applyPlugin(
+ config: Configuration,
+ plugin: WebpackPluginInstance,
+): void {
+ const plugins = config.plugins || [];
+ plugins.push(plugin);
+ config.plugins = plugins;
+}
+
+function normalizeOutputPath(config: Configuration): void {
+ if (!config.output) {
+ config.output = {};
+ }
+
+ if (!config.output.path) {
+ config.output.path = path.resolve(process.cwd(), '.next');
+ }
+}
+
+function normalizeExposes(
+ exposes: moduleFederationPlugin.ModuleFederationPluginOptions['exposes'],
+): Record {
+ if (!exposes || Array.isArray(exposes)) {
+ return {};
+ }
+
+ return exposes as Record;
+}
+
+export function withNextFederation(
+ nextConfig: NextConfig,
+ federationOptions: NextFederationOptionsV9,
+): NextConfig {
+ patchNextRequireHookForLocalWebpack(process.cwd());
+ assertWebpackBuildInvocation();
+ const resolved = normalizeNextFederationOptions(federationOptions);
+ if (isNextBuildOrDevCommand() && resolved.mode !== 'app') {
+ assertLocalWebpackEnabled();
+ }
+ const userWebpack = nextConfig.webpack;
+ let hasValidatedAppExposes = false;
+
+ return {
+ ...nextConfig,
+ webpack(config: Configuration, context: NextWebpackContext): Configuration {
+ patchNextRequireHookForLocalWebpack(
+ context.dir || (config.context as string | undefined) || process.cwd(),
+ );
+
+ const userConfig =
+ typeof userWebpack === 'function'
+ ? (userWebpack(config, context as never) as Configuration) || config
+ : config;
+
+ normalizeOutputPath(userConfig);
+
+ const compilerName = inferCompilerName(userConfig, context);
+
+ if (compilerName === 'edge-server' || context.nextRuntime === 'edge') {
+ // v9 intentionally skips federation in edge compiler.
+ return userConfig;
+ }
+
+ ensureFederationWebpackPath(context);
+ userConfig.plugins = userConfig.plugins || [];
+ userConfig.plugins.unshift(new EnsureCompilerWebpackPlugin());
+ applyFederatedAssetLoaderFixes(userConfig);
+
+ const cwd = context.dir || userConfig.context || process.cwd();
+
+ const routerPresence = detectRouterPresence(cwd);
+ assertModeRouterCompatibility(resolved.mode, routerPresence.hasApp);
+
+ if (
+ !hasValidatedAppExposes &&
+ (resolved.mode === 'app' || resolved.mode === 'hybrid')
+ ) {
+ assertUnsupportedAppRouterTargets(cwd, resolved.federation.exposes);
+ hasValidatedAppExposes = true;
+ }
+
+ const federationContext = toCompilerContext(compilerName, context);
+ const isServer = federationContext.isServer;
+
+ const remotes = resolveFederationRemotes(resolved, federationContext);
+ const pagesExposes = resolved.pages.exposePages
+ ? buildPagesExposes(cwd, resolved.pages.pageMapFormat)
+ : {};
+ const shared = buildSharedConfig(
+ resolved,
+ isServer,
+ resolved.federation.shared,
+ );
+ const mergedExposes = {
+ ...normalizeExposes(resolved.federation.exposes),
+ ...pagesExposes,
+ } as moduleFederationPlugin.ModuleFederationPluginOptions['exposes'];
+
+ const nextFederationConfig: moduleFederationPlugin.ModuleFederationPluginOptions =
+ {
+ ...resolved.federation,
+ runtime: false,
+ filename: resolved.filename,
+ remotes,
+ exposes: mergedExposes,
+ shared,
+ remoteType: 'script' as const,
+ runtimePlugins: buildRuntimePlugins(resolved, isServer),
+ dts: resolved.federation.dts ?? false,
+ shareStrategy: resolved.sharing.strategy,
+ experiments: {
+ asyncStartup: true,
+ ...(resolved.federation.experiments || {}),
+ },
+ manifest: isServer
+ ? { filePath: '' }
+ : { filePath: '/static/chunks' },
+ };
+
+ if (isServer) {
+ configureServerCompiler(userConfig, nextFederationConfig);
+ } else {
+ configureClientCompiler(userConfig, nextFederationConfig);
+ }
+
+ const ModuleFederationPlugin = getModuleFederationPluginCtor();
+ applyPlugin(userConfig, new ModuleFederationPlugin(nextFederationConfig));
+
+ return userConfig;
+ },
+ };
+}
+
+export default withNextFederation;
diff --git a/packages/nextjs-mf/tsconfig.lib.json b/packages/nextjs-mf/tsconfig.lib.json
index a76f2ea3a7d..4ddd840a68b 100644
--- a/packages/nextjs-mf/tsconfig.lib.json
+++ b/packages/nextjs-mf/tsconfig.lib.json
@@ -3,6 +3,6 @@
"compilerOptions": {
"outDir": "dist"
},
- "include": ["src/**/*.ts", "utils/**/*.ts", "*.ts"],
+ "include": ["src/**/*.ts", "src/**/*.d.ts", "node.ts"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}
diff --git a/packages/nextjs-mf/utils/flushedChunks.ts b/packages/nextjs-mf/utils/flushedChunks.ts
deleted file mode 100644
index 191c33fca8e..00000000000
--- a/packages/nextjs-mf/utils/flushedChunks.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import * as React from 'react';
-
-/**
- * FlushedChunks component.
- * This component creates script and link elements for each chunk.
- *
- * @param {FlushedChunksProps} props - The properties of the component.
- * @param {string[]} props.chunks - The chunks to be flushed.
- * @returns {React.ReactElement} The created script and link elements.
- */
-export const FlushedChunks = ({ chunks = [] }: FlushedChunksProps) => {
- const scripts = chunks
- .filter((c) => {
- // TODO: host shouldnt flush its own remote out
- // if(c.includes('?')) {
- // return c.split('?')[0].endsWith('.js')
- // }
- return c.endsWith('.js');
- })
- .map((chunk) => {
- if (!chunk.includes('?') && chunk.includes('remoteEntry')) {
- chunk = chunk + '?t=' + Date.now();
- }
- return React.createElement(
- 'script',
- {
- key: chunk,
- src: chunk,
- async: true,
- },
- null,
- );
- });
-
- const css = chunks
- .filter((c) => c.endsWith('.css'))
- .map((chunk) => {
- return React.createElement(
- 'link',
- {
- key: chunk,
- href: chunk,
- rel: 'stylesheet',
- },
- null,
- );
- });
-
- return React.createElement(React.Fragment, null, css, scripts);
-};
-
-/**
- * FlushedChunksProps interface.
- * This interface represents the properties of the FlushedChunks component.
- *
- * @interface
- * @property {string[]} chunks - The chunks to be flushed.
- */
-export interface FlushedChunksProps {
- chunks: string[];
-}
diff --git a/packages/nextjs-mf/utils/index.ts b/packages/nextjs-mf/utils/index.ts
deleted file mode 100644
index cf867c8cf65..00000000000
--- a/packages/nextjs-mf/utils/index.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * Flushes chunks from the module federation node utilities.
- * @module @module-federation/node/utils
- */
-export { flushChunks } from '@module-federation/node/utils';
-
-/**
- * Exports the FlushedChunks component from the current directory.
- */
-export { FlushedChunks } from './flushedChunks';
-
-/**
- * Exports the FlushedChunksProps type from the current directory.
- */
-export type { FlushedChunksProps } from './flushedChunks';
-
-/**
- * Revalidates the current state.
- * If the function is called on the client side, it logs an error and returns a resolved promise with false.
- * If the function is called on the server side, it imports the revalidate function from the module federation node utilities and returns the result of calling that function.
- * @returns {Promise} A promise that resolves with a boolean.
- */
-export const revalidate = function (
- fetchModule: any = undefined,
- force = false,
-): Promise {
- if (typeof window !== 'undefined') {
- console.error('revalidate should only be called server-side');
- return Promise.resolve(false);
- } else {
- return import('@module-federation/node/utils').then(function (utils) {
- return utils.revalidate(fetchModule, force);
- });
- }
-};
diff --git a/packages/node/README.md b/packages/node/README.md
index 10f8d78cc75..4def42a8598 100644
--- a/packages/node/README.md
+++ b/packages/node/README.md
@@ -173,6 +173,35 @@ revalidate().then((shouldReload) => {
});
```
+### ensureRemoteHotReload (recommended for Next SSR production)
+
+`revalidate()` is synchronous from the request path perspective if you `await` it in `_document` or middleware.
+For production SSR servers with steady traffic, a better pattern is to run revalidation in the background and only "touch" it from requests.
+
+```js
+import {
+ ensureRemoteHotReload,
+ flushChunks,
+} from '@module-federation/node/utils';
+
+const hotReloadController = ensureRemoteHotReload({
+ intervalMs: Number(process.env.MF_REMOTE_REVALIDATE_INTERVAL_MS || 10_000),
+ immediate: true,
+});
+
+// next/pages _document
+MyDocument.getInitialProps = async (ctx) => {
+ // non-blocking: no request latency from remote hash checks
+ hotReloadController.touch();
+
+ const initialProps = await Document.getInitialProps(ctx);
+ const chunks = await flushChunks();
+ return { ...initialProps, chunks };
+};
+```
+
+This approach avoids request-time fetch storms while still updating the in-memory federation graph when remote entries change.
+
_Note_: To ensure that changes made to files in remotes are picked up `revalidate`, you can set the remotes webpack [output.filename](https://webpack.js.org/configuration/output/#outputfilename) to `[name]-[contenthash].js` (or similar). This will cause the remoteEntry.js file to be regenerated with a unique hash every time a new build occurs. The revalidate method intelligently detects changes by comparing the hashes of the remoteEntry.js files. By incorporating [contenthash] into the remote's webpack configuration, you enable the shell to seamlessly incorporate the updated files from the remotes.
**Hot reloading Express.js**
diff --git a/packages/node/global.d.ts b/packages/node/global.d.ts
index 0aa6da239ea..ec4cf6e8ce2 100644
--- a/packages/node/global.d.ts
+++ b/packages/node/global.d.ts
@@ -31,6 +31,12 @@ declare global {
moduleCache?: Map;
}>;
};
+ __MF_REMOTE_HOT_RELOAD_CONTROLLER__?: {
+ start: () => void;
+ stop: () => void;
+ touch: (force?: boolean) => void;
+ check: (force?: boolean) => Promise;
+ };
}
}
var usedChunks: Set;
@@ -40,4 +46,12 @@ declare global {
moduleCache?: Map;
}>;
};
+ var __MF_REMOTE_HOT_RELOAD_CONTROLLER__:
+ | {
+ start: () => void;
+ stop: () => void;
+ touch: (force?: boolean) => void;
+ check: (force?: boolean) => Promise;
+ }
+ | undefined;
}
diff --git a/packages/node/src/plugins/CommonJsChunkLoadingPlugin.ts b/packages/node/src/plugins/CommonJsChunkLoadingPlugin.ts
index d98cbb7e2bd..f2668c09cb2 100644
--- a/packages/node/src/plugins/CommonJsChunkLoadingPlugin.ts
+++ b/packages/node/src/plugins/CommonJsChunkLoadingPlugin.ts
@@ -1,9 +1,5 @@
import type { Chunk, Compiler, Compilation, ChunkGraph } from 'webpack';
-import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-path';
import type { ModuleFederationPluginOptions } from '../types';
-const StartupChunkDependenciesPlugin = require(
- normalizeWebpackPath('webpack/lib/runtime/StartupChunkDependenciesPlugin'),
-) as typeof import('webpack/lib/runtime/StartupChunkDependenciesPlugin');
import ChunkLoadingRuntimeModule from './DynamicFilesystemChunkLoadingRuntimeModule';
import AutoPublicPathRuntimeModule from './RemotePublicPathRuntimeModule';
@@ -28,6 +24,16 @@ class DynamicFilesystemChunkLoadingPlugin {
apply(compiler: Compiler) {
const { RuntimeGlobals } = compiler.webpack;
+ const StartupChunkDependenciesPlugin =
+ // Next's bundled webpack object can expose runtime plugin constructors.
+ (
+ compiler.webpack as Compiler['webpack'] & {
+ runtime?: {
+ StartupChunkDependenciesPlugin?: typeof import('webpack/lib/runtime/StartupChunkDependenciesPlugin');
+ };
+ }
+ ).runtime?.StartupChunkDependenciesPlugin ||
+ require('webpack/lib/runtime/StartupChunkDependenciesPlugin');
const chunkLoadingValue = this._asyncChunkLoading
? 'async-node'
: 'require';
diff --git a/packages/node/src/plugins/EntryChunkTrackerPlugin.ts b/packages/node/src/plugins/EntryChunkTrackerPlugin.ts
index 5e817ab55db..b5da087c614 100644
--- a/packages/node/src/plugins/EntryChunkTrackerPlugin.ts
+++ b/packages/node/src/plugins/EntryChunkTrackerPlugin.ts
@@ -13,6 +13,9 @@ import type { SyncWaterfallHook } from 'tapable';
const SortableSet = require(
normalizeWebpackPath('webpack/lib/util/SortableSet'),
) as typeof import('webpack/lib/util/SortableSet');
+const JavascriptModulesPlugin = require(
+ normalizeWebpackPath('webpack/lib/javascript/JavascriptModulesPlugin'),
+) as typeof import('webpack/lib/javascript/JavascriptModulesPlugin');
type CompilationHooksJavascriptModulesPlugin = ReturnType<
typeof javascript.JavascriptModulesPlugin.getCompilationHooks
@@ -48,28 +51,46 @@ class EntryChunkTrackerPlugin {
},
);
}
+
+ private _getJavascriptModulesPlugin(
+ compiler: Compiler,
+ ): typeof import('webpack/lib/javascript/JavascriptModulesPlugin') {
+ const maybePlugin = (
+ compiler.webpack as Compiler['webpack'] & {
+ javascript?: {
+ JavascriptModulesPlugin?: typeof import('webpack/lib/javascript/JavascriptModulesPlugin');
+ };
+ }
+ ).javascript?.JavascriptModulesPlugin;
+
+ return maybePlugin || JavascriptModulesPlugin;
+ }
private _handleRenderStartup(compiler: Compiler, compilation: Compilation) {
- compiler.webpack.javascript.JavascriptModulesPlugin.getCompilationHooks(
- compilation,
- ).renderStartup.tap(
- 'EntryChunkTrackerPlugin',
- (
- source: sources.Source,
- _renderContext: Module,
- upperContext: StartupRenderContext,
- ) => {
- if (
- this._options.excludeChunk &&
- this._options.excludeChunk(upperContext.chunk)
- ) {
- return source;
- }
+ this._getJavascriptModulesPlugin(compiler)
+ .getCompilationHooks(compilation)
+ .renderStartup.tap(
+ 'EntryChunkTrackerPlugin',
+ (
+ source: sources.Source,
+ _renderContext: Module,
+ upperContext: StartupRenderContext,
+ ) => {
+ if (
+ this._options.excludeChunk &&
+ this._options.excludeChunk(upperContext.chunk)
+ ) {
+ return source;
+ }
- const templateString = this._getTemplateString(compiler, source);
+ const templateString = this._getTemplateString(compiler, source);
- return new compiler.webpack.sources.ConcatSource(templateString);
- },
- );
+ const webpackSources =
+ compiler.webpack?.sources ||
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ require('webpack').sources;
+ return new webpackSources.ConcatSource(templateString);
+ },
+ );
}
private _getTemplateString(compiler: Compiler, source: sources.Source) {
diff --git a/packages/node/src/utils/flush-chunks.ts b/packages/node/src/utils/flush-chunks.ts
index c767a5a6d59..98122d24f17 100644
--- a/packages/node/src/utils/flush-chunks.ts
+++ b/packages/node/src/utils/flush-chunks.ts
@@ -97,8 +97,11 @@ const createShareMap = () => {
// @ts-ignore
const processChunk = async (chunk, shareMap, hostStats) => {
const chunks = new Set();
- const [remote, req] = chunk.split('/');
- const request = './' + req;
+ const normalizedChunk = chunk.includes('->')
+ ? chunk.replace('->', '/')
+ : chunk;
+ const [remote, req] = normalizedChunk.split('/');
+ const request = req?.startsWith('./') ? req : './' + req;
const knownRemotes = getAllKnownRemotes();
//@ts-ignore
if (!knownRemotes[remote]) {
diff --git a/packages/node/src/utils/hot-reload.test.ts b/packages/node/src/utils/hot-reload.test.ts
new file mode 100644
index 00000000000..9e53ea500a5
--- /dev/null
+++ b/packages/node/src/utils/hot-reload.test.ts
@@ -0,0 +1,66 @@
+import {
+ checkFakeRemote,
+ checkMedusaConfigChange,
+ fetchRemote,
+} from './hot-reload';
+
+describe('hot-reload utilities', () => {
+ beforeEach(() => {
+ globalThis.mfHashMap = {};
+ });
+
+ it('detects medusa config version changes asynchronously', async () => {
+ const remoteScope = {
+ _medusa: {
+ 'https://example.com/medusa.json': { version: '1.0.0' },
+ },
+ };
+ const fetchModule = jest.fn().mockResolvedValue({
+ json: async () => ({ version: '1.1.0' }),
+ });
+
+ await expect(
+ checkMedusaConfigChange(remoteScope, fetchModule),
+ ).resolves.toBe(true);
+ });
+
+ it('resolves async fake remote factories', async () => {
+ const remoteScope = {
+ _config: {
+ shop: async () => ({ fake: true }),
+ },
+ };
+
+ await expect(checkFakeRemote(remoteScope)).resolves.toBe(true);
+ });
+
+ it('skips malformed remotes without entry url', async () => {
+ const fetchModule = jest.fn();
+
+ await expect(fetchRemote({ invalid: {} }, fetchModule)).resolves.toBe(
+ false,
+ );
+ expect(fetchModule).not.toHaveBeenCalled();
+ });
+
+ it('marks reload when a remote entry hash changes', async () => {
+ const remoteScope = {
+ shop: { entry: 'https://example.com/remoteEntry.js' },
+ };
+ const fetchModule = jest
+ .fn()
+ .mockResolvedValueOnce({
+ ok: true,
+ text: async () => 'remote-entry-v1',
+ headers: { get: () => 'text/javascript' },
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ text: async () => 'remote-entry-v2',
+ headers: { get: () => 'text/javascript' },
+ });
+
+ await expect(fetchRemote(remoteScope, fetchModule)).resolves.toBe(false);
+ await expect(fetchRemote(remoteScope, fetchModule)).resolves.toBe(true);
+ });
+});
diff --git a/packages/node/src/utils/hot-reload.ts b/packages/node/src/utils/hot-reload.ts
index caed2d3bab8..c4fd40c712a 100644
--- a/packages/node/src/utils/hot-reload.ts
+++ b/packages/node/src/utils/hot-reload.ts
@@ -8,6 +8,14 @@ declare global {
var moduleGraphDirty: boolean;
}
+function getHashMap(): Record {
+ if (!globalThis.mfHashMap) {
+ globalThis.mfHashMap = {};
+ }
+
+ return globalThis.mfHashMap;
+}
+
const getRequire = (): NodeRequire => {
//@ts-ignore
return typeof __non_webpack_require__ !== 'undefined'
@@ -15,6 +23,16 @@ const getRequire = (): NodeRequire => {
: eval('require');
};
+const shouldLogHotReloadInfo = (): boolean =>
+ process.env['NODE_ENV'] === 'development' ||
+ process.env['MF_REMOTE_HOT_RELOAD_DEBUG'] === 'true';
+
+const logHotReloadInfo = (...args: unknown[]): void => {
+ if (shouldLogHotReloadInfo()) {
+ console.log(...args);
+ }
+};
+
function callsites(): any[] {
const _prepareStackTrace = Error.prepareStackTrace;
try {
@@ -125,7 +143,6 @@ const searchCache = function (
}
};
-const hashmap = globalThis.mfHashMap || ({} as Record);
globalThis.moduleGraphDirty = false;
const requireCacheRegex =
@@ -193,50 +210,60 @@ export const checkUnreachableRemote = (remoteScope: any): boolean => {
return false;
};
-export const checkMedusaConfigChange = (
+export const checkMedusaConfigChange = async (
remoteScope: any,
fetchModule: any,
-): boolean => {
+): Promise => {
//@ts-ignore
if (remoteScope._medusa) {
//@ts-ignore
for (const property in remoteScope._medusa) {
- fetchModule(property)
- .then((res: Response) => res.json())
- .then((medusaResponse: any): void | boolean => {
- if (
- medusaResponse.version !==
- //@ts-ignore
- remoteScope?._medusa[property].version
- ) {
- console.log(
- 'medusa config changed',
- property,
- 'hot reloading to refetch',
- );
- performReload(true);
- return true;
- }
- });
+ try {
+ const res = (await fetchModule(property)) as Response;
+ const medusaResponse = await res.json();
+
+ if (
+ medusaResponse.version !==
+ //@ts-ignore
+ remoteScope?._medusa[property].version
+ ) {
+ logHotReloadInfo(
+ 'medusa config changed',
+ property,
+ 'hot reloading to refetch',
+ );
+ return true;
+ }
+ } catch (e) {
+ console.error('Medusa config check failed for', property, e);
+ }
}
}
return false;
};
-export const checkFakeRemote = (remoteScope: any): boolean => {
+export const checkFakeRemote = async (remoteScope: any): Promise => {
+ if (!remoteScope || !remoteScope._config) {
+ return false;
+ }
+
for (const property in remoteScope._config) {
let remote = remoteScope._config[property];
- const resolveRemote = async () => {
- remote = await remote();
- };
-
if (typeof remote === 'function') {
- resolveRemote();
+ try {
+ remote = await remote();
+ } catch (e) {
+ console.error('Unable to resolve fake remote config for', property, e);
+ }
}
- if (remote.fake) {
- console.log('fake remote found', property, 'hot reloading to refetch');
+ if (remote?.fake) {
+ logHotReloadInfo(
+ 'fake remote found',
+ property,
+ 'hot reloading to refetch',
+ );
return true;
}
}
@@ -273,18 +300,23 @@ export const fetchRemote = (
remoteScope: any,
fetchModule: any,
): Promise => {
+ const hashmap = getHashMap();
const fetches: Promise[] = [];
let needReload = false;
for (const property in remoteScope) {
const name = property;
const container = remoteScope[property];
- const url = container.entry;
+ const url = container?.entry;
+ if (typeof url !== 'string' || !url) {
+ continue;
+ }
+
const fetcher = createFetcher(url, fetchModule, name, (hash) => {
if (hashmap[name]) {
if (hashmap[name] !== hash) {
hashmap[name] = hash;
needReload = true;
- console.log(name, 'hash is different - must hot reload server');
+ logHotReloadInfo(name, 'hash is different - must hot reload server');
}
} else {
hashmap[name] = hash;
@@ -302,32 +334,25 @@ export const revalidate = async (
fetchModule: any = getFetchModule() || (() => {}),
force: boolean = false,
): Promise => {
+ const hashmap = getHashMap();
if (globalThis.moduleGraphDirty) {
force = true;
}
const remotesFromAPI = getAllKnownRemotes();
- //@ts-ignore
- return new Promise((res) => {
- if (force) {
- if (Object.keys(hashmap).length !== 0) {
- res(true);
- return;
- }
- }
- if (checkMedusaConfigChange(remotesFromAPI, fetchModule)) {
- res(true);
- }
+ if (force && Object.keys(hashmap).length !== 0) {
+ return performReload(true);
+ }
- if (checkFakeRemote(remotesFromAPI)) {
- res(true);
- }
+ if (await checkMedusaConfigChange(remotesFromAPI, fetchModule)) {
+ return performReload(true);
+ }
- fetchRemote(remotesFromAPI, fetchModule).then((val) => {
- res(val);
- });
- }).then((shouldReload: unknown) => {
- return performReload(shouldReload as boolean);
- });
+ if (await checkFakeRemote(remotesFromAPI)) {
+ return performReload(true);
+ }
+
+ const shouldReload = await fetchRemote(remotesFromAPI, fetchModule);
+ return performReload(shouldReload);
};
export function getFetchModule(): any {
diff --git a/packages/node/src/utils/index.ts b/packages/node/src/utils/index.ts
index 6d45be672c6..5b516f943f4 100644
--- a/packages/node/src/utils/index.ts
+++ b/packages/node/src/utils/index.ts
@@ -1,2 +1,3 @@
export * from './hot-reload';
export * from './flush-chunks';
+export * from './remote-hot-reload';
diff --git a/packages/node/src/utils/remote-hot-reload.test.ts b/packages/node/src/utils/remote-hot-reload.test.ts
new file mode 100644
index 00000000000..11d98f40239
--- /dev/null
+++ b/packages/node/src/utils/remote-hot-reload.test.ts
@@ -0,0 +1,141 @@
+import {
+ createRemoteHotReloadController,
+ ensureRemoteHotReload,
+ stopRemoteHotReload,
+ touchRemoteHotReload,
+} from './remote-hot-reload';
+
+const flushMicrotasks = async (): Promise => {
+ await Promise.resolve();
+ await Promise.resolve();
+};
+
+describe('remote-hot-reload controller', () => {
+ afterEach(() => {
+ stopRemoteHotReload();
+ jest.useRealTimers();
+ });
+
+ it('runs checks in the background and throttles request touches', async () => {
+ jest.useFakeTimers();
+ const revalidateFn = jest.fn().mockResolvedValue(false);
+
+ const controller = createRemoteHotReloadController({
+ immediate: false,
+ intervalMs: 1_000,
+ fetchModule: jest.fn(),
+ revalidateFn,
+ });
+
+ controller.start();
+ expect(revalidateFn).toHaveBeenCalledTimes(0);
+
+ controller.touch();
+ await flushMicrotasks();
+ expect(revalidateFn).toHaveBeenCalledTimes(1);
+
+ controller.touch();
+ await flushMicrotasks();
+ expect(revalidateFn).toHaveBeenCalledTimes(1);
+
+ jest.advanceTimersByTime(1_000);
+ await flushMicrotasks();
+ expect(revalidateFn).toHaveBeenCalledTimes(2);
+
+ controller.stop();
+ });
+
+ it('tracks reload state when revalidate reports changes', async () => {
+ const revalidateFn = jest.fn().mockResolvedValue(true);
+ const controller = createRemoteHotReloadController({
+ immediate: false,
+ fetchModule: jest.fn(),
+ revalidateFn,
+ });
+
+ const didReload = await controller.check(true);
+ const state = controller.getState();
+
+ expect(didReload).toBe(true);
+ expect(state.lastReloadAt).toBeGreaterThan(0);
+ expect(state.lastCheckAt).toBeGreaterThan(0);
+ });
+
+ it('deduplicates concurrent checks', async () => {
+ let resolveCheck: ((value: boolean) => void) | undefined;
+ const revalidateFn = jest.fn().mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveCheck = resolve;
+ }),
+ );
+
+ const controller = createRemoteHotReloadController({
+ immediate: false,
+ fetchModule: jest.fn(),
+ revalidateFn,
+ });
+
+ const first = controller.check(true);
+ const second = controller.check(true);
+
+ expect(revalidateFn).toHaveBeenCalledTimes(1);
+
+ resolveCheck?.(false);
+ await expect(first).resolves.toBe(false);
+ await expect(second).resolves.toBe(false);
+ });
+
+ it('creates a singleton via ensureRemoteHotReload', async () => {
+ const revalidateFn = jest.fn().mockResolvedValue(false);
+
+ const first = ensureRemoteHotReload({
+ immediate: false,
+ intervalMs: 5_000,
+ fetchModule: jest.fn(),
+ revalidateFn,
+ });
+ const second = ensureRemoteHotReload({
+ immediate: false,
+ intervalMs: 5_000,
+ fetchModule: jest.fn(),
+ revalidateFn,
+ });
+
+ expect(first).toBe(second);
+
+ touchRemoteHotReload({}, true);
+ await flushMicrotasks();
+
+ expect(revalidateFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('recreates a disabled singleton when later enabled', async () => {
+ const disabledRevalidate = jest.fn().mockResolvedValue(false);
+ const disabled = ensureRemoteHotReload({
+ enabled: false,
+ immediate: false,
+ fetchModule: jest.fn(),
+ revalidateFn: disabledRevalidate,
+ });
+
+ expect(disabled.getState().running).toBe(false);
+
+ const enabledRevalidate = jest.fn().mockResolvedValue(false);
+ const enabled = ensureRemoteHotReload({
+ enabled: true,
+ immediate: false,
+ fetchModule: jest.fn(),
+ revalidateFn: enabledRevalidate,
+ });
+
+ expect(enabled).not.toBe(disabled);
+ expect(enabled.getState().running).toBe(true);
+
+ touchRemoteHotReload({}, true);
+ await flushMicrotasks();
+
+ expect(enabledRevalidate).toHaveBeenCalledTimes(1);
+ expect(disabledRevalidate).toHaveBeenCalledTimes(0);
+ });
+});
diff --git a/packages/node/src/utils/remote-hot-reload.ts b/packages/node/src/utils/remote-hot-reload.ts
new file mode 100644
index 00000000000..ada59113630
--- /dev/null
+++ b/packages/node/src/utils/remote-hot-reload.ts
@@ -0,0 +1,256 @@
+import { getFetchModule, revalidate } from './hot-reload';
+
+export interface RemoteHotReloadOptions {
+ /**
+ * Enables remote polling + reload checks.
+ * Defaults to true.
+ */
+ enabled?: boolean;
+ /**
+ * Minimum time between checks in milliseconds.
+ * Defaults to 10 seconds.
+ */
+ intervalMs?: number;
+ /**
+ * Runs an initial check as soon as controller starts.
+ * Defaults to true.
+ */
+ immediate?: boolean;
+ /**
+ * Forces revalidation on startup.
+ * Defaults to false.
+ */
+ forceOnStart?: boolean;
+ /**
+ * Optional custom fetch implementation for remote probing.
+ */
+ fetchModule?: any;
+ /**
+ * Optional logger override.
+ */
+ logger?: {
+ warn?: (...args: unknown[]) => void;
+ };
+ /**
+ * Internal/test override for the revalidate implementation.
+ */
+ revalidateFn?: typeof revalidate;
+}
+
+export interface RemoteHotReloadState {
+ running: boolean;
+ inFlight: boolean;
+ lastCheckAt: number;
+ lastReloadAt: number;
+ intervalMs: number;
+}
+
+export interface RemoteHotReloadController {
+ start(): void;
+ stop(): void;
+ touch(force?: boolean): void;
+ check(force?: boolean): Promise;
+ getState(): RemoteHotReloadState;
+}
+
+declare global {
+ // eslint-disable-next-line no-var
+ var __MF_REMOTE_HOT_RELOAD_CONTROLLER__:
+ | RemoteHotReloadController
+ | undefined;
+}
+
+const DEFAULT_INTERVAL_MS = 10_000;
+
+type NormalizedRemoteHotReloadOptions = {
+ enabled: boolean;
+ intervalMs: number;
+ immediate: boolean;
+ forceOnStart: boolean;
+ fetchModule?: any;
+ logger: {
+ warn: (...args: unknown[]) => void;
+ };
+ revalidateFn: typeof revalidate;
+};
+
+class RemoteHotReloadControllerImpl implements RemoteHotReloadController {
+ private readonly options: NormalizedRemoteHotReloadOptions;
+ private running = false;
+ private lastCheckAt = 0;
+ private lastReloadAt = 0;
+ private timer: ReturnType | null = null;
+ private inFlight: Promise | null = null;
+
+ constructor(options: NormalizedRemoteHotReloadOptions) {
+ this.options = options;
+ }
+
+ start(): void {
+ if (!this.options.enabled || this.running) {
+ return;
+ }
+
+ this.running = true;
+
+ if (this.options.immediate) {
+ this.touch(this.options.forceOnStart);
+ }
+
+ this.schedule();
+ }
+
+ stop(): void {
+ this.running = false;
+ if (this.timer) {
+ clearTimeout(this.timer);
+ this.timer = null;
+ }
+ }
+
+ touch(force = false): void {
+ if (!this.running) {
+ this.start();
+ }
+
+ void this.check(force);
+ }
+
+ async check(force = false): Promise {
+ if (!this.options.enabled) {
+ return false;
+ }
+
+ if (this.inFlight) {
+ return this.inFlight;
+ }
+
+ const now = Date.now();
+ const elapsedMs = now - this.lastCheckAt;
+ if (!force && this.lastCheckAt > 0 && elapsedMs < this.options.intervalMs) {
+ return false;
+ }
+
+ this.lastCheckAt = now;
+
+ const fetchModule = this.options.fetchModule ?? getFetchModule();
+ this.inFlight = this.options
+ .revalidateFn(fetchModule, force)
+ .then((didReload) => {
+ if (didReload) {
+ this.lastReloadAt = Date.now();
+ }
+ return didReload;
+ })
+ .catch((error: unknown) => {
+ this.options.logger.warn?.(
+ '[module-federation] remote hot-reload check failed:',
+ error,
+ );
+ return false;
+ })
+ .finally(() => {
+ this.inFlight = null;
+ });
+
+ return this.inFlight;
+ }
+
+ getState(): RemoteHotReloadState {
+ return {
+ running: this.running,
+ inFlight: Boolean(this.inFlight),
+ lastCheckAt: this.lastCheckAt,
+ lastReloadAt: this.lastReloadAt,
+ intervalMs: this.options.intervalMs,
+ };
+ }
+
+ private schedule(): void {
+ if (!this.running || !this.options.enabled) {
+ return;
+ }
+
+ this.timer = setTimeout(() => {
+ this.timer = null;
+ void this.check(false).finally(() => {
+ this.schedule();
+ });
+ }, this.options.intervalMs);
+
+ if (typeof this.timer === 'object' && this.timer?.unref) {
+ this.timer.unref();
+ }
+ }
+}
+
+function normalizeOptions(
+ options: RemoteHotReloadOptions,
+): NormalizedRemoteHotReloadOptions {
+ const intervalMs = Number(options.intervalMs);
+ const normalizedInterval =
+ Number.isFinite(intervalMs) && intervalMs > 0
+ ? intervalMs
+ : DEFAULT_INTERVAL_MS;
+
+ return {
+ enabled: options.enabled !== false,
+ intervalMs: normalizedInterval,
+ immediate: options.immediate !== false,
+ forceOnStart: options.forceOnStart === true,
+ fetchModule: options.fetchModule,
+ logger: {
+ warn: options.logger?.warn || console.warn,
+ },
+ revalidateFn: options.revalidateFn || revalidate,
+ };
+}
+
+export function createRemoteHotReloadController(
+ options: RemoteHotReloadOptions = {},
+): RemoteHotReloadController {
+ return new RemoteHotReloadControllerImpl(normalizeOptions(options));
+}
+
+export function ensureRemoteHotReload(
+ options: RemoteHotReloadOptions = {},
+): RemoteHotReloadController {
+ if (!globalThis.__MF_REMOTE_HOT_RELOAD_CONTROLLER__) {
+ globalThis.__MF_REMOTE_HOT_RELOAD_CONTROLLER__ =
+ createRemoteHotReloadController(options);
+ }
+
+ let controller = globalThis.__MF_REMOTE_HOT_RELOAD_CONTROLLER__;
+ if (options.enabled === false) {
+ controller.stop();
+ } else {
+ controller.start();
+
+ // The controller captures `enabled` at creation time. If it was originally
+ // created with `enabled: false`, start() is a no-op; recreate with current options.
+ if (!controller.getState().running) {
+ controller = createRemoteHotReloadController(options);
+ globalThis.__MF_REMOTE_HOT_RELOAD_CONTROLLER__ = controller;
+ controller.start();
+ }
+ }
+
+ return controller;
+}
+
+export function touchRemoteHotReload(
+ options: RemoteHotReloadOptions = {},
+ force = false,
+): void {
+ const controller = ensureRemoteHotReload(options);
+ controller.touch(force);
+}
+
+export function stopRemoteHotReload(): void {
+ if (!globalThis.__MF_REMOTE_HOT_RELOAD_CONTROLLER__) {
+ return;
+ }
+
+ globalThis.__MF_REMOTE_HOT_RELOAD_CONTROLLER__.stop();
+ delete globalThis.__MF_REMOTE_HOT_RELOAD_CONTROLLER__;
+}
diff --git a/packages/runtime-core/__tests__/instance.spec.ts b/packages/runtime-core/__tests__/instance.spec.ts
index 6b6ea691888..fe914ebbc62 100644
--- a/packages/runtime-core/__tests__/instance.spec.ts
+++ b/packages/runtime-core/__tests__/instance.spec.ts
@@ -66,4 +66,48 @@ describe('ModuleFederation', () => {
expect(module.initing).toBe(false);
expect((module as any).initPromise).toBeUndefined();
});
+ it('cleans init promise state after init failure and allows retry', async () => {
+ const initSpy = vi
+ .fn()
+ .mockRejectedValueOnce(new Error('init failed once'))
+ .mockResolvedValueOnce(undefined);
+
+ const GM = new ModuleFederation({
+ name: '@federation/instance',
+ version: '1.0.1',
+ remotes: [],
+ });
+
+ const module = new Module({
+ remoteInfo: {
+ name: '@test/remote',
+ entry:
+ 'http://localhost:1111/resources/main/federation-remote-entry.js',
+ type: 'global',
+ entryGlobalName: '__test_remote__',
+ shareScope: 'default',
+ },
+ host: GM,
+ });
+
+ module.remoteEntryExports = {
+ init: initSpy,
+ get: vi.fn(),
+ } as any;
+
+ await expect(module.init('first-attempt')).rejects.toThrow(
+ 'init failed once',
+ );
+ expect(module.inited).toBe(false);
+ expect(module.initing).toBe(false);
+ expect((module as any).initPromise).toBeUndefined();
+
+ await expect(module.init('retry-attempt')).resolves.toBe(
+ module.remoteEntryExports,
+ );
+ expect(initSpy).toHaveBeenCalledTimes(2);
+ expect(module.inited).toBe(true);
+ expect(module.initing).toBe(false);
+ expect((module as any).initPromise).toBeUndefined();
+ });
});
diff --git a/packages/sdk/src/normalize-webpack-path.ts b/packages/sdk/src/normalize-webpack-path.ts
index 929d7b073f2..ec40461ffa6 100644
--- a/packages/sdk/src/normalize-webpack-path.ts
+++ b/packages/sdk/src/normalize-webpack-path.ts
@@ -31,13 +31,29 @@ export function getWebpackPath(
}
export const normalizeWebpackPath = (fullPath: string): string => {
+ const federationWebpackPath = process.env['FEDERATION_WEBPACK_PATH'];
+
+ // Next.js webpack bridge points to its compiled bundle entry. For deep webpack
+ // internals we should keep native requests so Node/Next hook resolution can
+ // pick the best available target (Next-compiled alias or local webpack).
+ if (
+ federationWebpackPath &&
+ federationWebpackPath.includes('/next/dist/compiled/webpack/')
+ ) {
+ if (fullPath === 'webpack') {
+ return federationWebpackPath;
+ }
+
+ return fullPath;
+ }
+
if (fullPath === 'webpack') {
- return process.env['FEDERATION_WEBPACK_PATH'] || fullPath;
+ return federationWebpackPath || fullPath;
}
- if (process.env['FEDERATION_WEBPACK_PATH']) {
+ if (federationWebpackPath) {
return path.resolve(
- process.env['FEDERATION_WEBPACK_PATH'],
+ federationWebpackPath,
fullPath.replace('webpack', '../../'),
);
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0ac76907956..9c0944ca9f6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -527,8 +527,8 @@ importers:
specifier: 4.17.23
version: 4.17.23
next:
- specifier: 14.2.35
- version: 14.2.35(@babel/core@7.28.6)(@playwright/test@1.57.0)(@swc/core@1.7.26(@swc/helpers@0.5.13))(babel-plugin-macros@3.1.0)(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.97.3)(webpack-cli@5.1.4)
+ specifier: 16.1.5
+ version: 16.1.5(@babel/core@7.28.6)(@playwright/test@1.57.0)(@swc/core@1.7.26(@swc/helpers@0.5.13))(babel-plugin-macros@3.1.0)(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.97.3)(webpack-cli@5.1.4)
react:
specifier: 18.3.1
version: 18.3.1
@@ -564,8 +564,8 @@ importers:
specifier: 4.17.23
version: 4.17.23
next:
- specifier: 14.2.35
- version: 14.2.35(@babel/core@7.28.6)(@playwright/test@1.57.0)(@swc/core@1.7.26(@swc/helpers@0.5.13))(babel-plugin-macros@3.1.0)(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.97.3)(webpack-cli@5.1.4)
+ specifier: 16.1.5
+ version: 16.1.5(@babel/core@7.28.6)(@playwright/test@1.57.0)(@swc/core@1.7.26(@swc/helpers@0.5.13))(babel-plugin-macros@3.1.0)(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.97.3)(webpack-cli@5.1.4)
react:
specifier: 18.3.1
version: 18.3.1
@@ -604,8 +604,8 @@ importers:
specifier: 4.17.23
version: 4.17.23
next:
- specifier: 14.2.35
- version: 14.2.35(@babel/core@7.28.6)(@playwright/test@1.57.0)(@swc/core@1.7.26(@swc/helpers@0.5.13))(babel-plugin-macros@3.1.0)(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.97.3)(webpack-cli@5.1.4)
+ specifier: 16.1.5
+ version: 16.1.5(@babel/core@7.28.6)(@playwright/test@1.57.0)(@swc/core@1.7.26(@swc/helpers@0.5.13))(babel-plugin-macros@3.1.0)(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.97.3)(webpack-cli@5.1.4)
react:
specifier: 18.3.1
version: 18.3.1
@@ -3754,20 +3754,17 @@ importers:
'@module-federation/sdk':
specifier: workspace:*
version: link:../sdk
- '@module-federation/webpack-bundler-runtime':
- specifier: workspace:*
- version: link:../webpack-bundler-runtime
fast-glob:
specifier: ^3.2.11
version: 3.3.2
next:
- specifier: ^12 || ^13 || ^14 || ^15
- version: 14.2.16(@babel/core@7.28.6)(@playwright/test@1.57.0)(@swc/core@1.7.26(@swc/helpers@0.5.13))(babel-plugin-macros@3.1.0)(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.97.3)(webpack-cli@5.1.4)
+ specifier: '>=16.0.0'
+ version: 16.1.5(@babel/core@7.28.6)(@playwright/test@1.57.0)(@swc/core@1.7.26(@swc/helpers@0.5.13))(babel-plugin-macros@3.1.0)(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.97.3)(webpack-cli@5.1.4)
react:
- specifier: ^17 || ^18 || ^19
+ specifier: ^18 || ^19
version: 18.3.1
react-dom:
- specifier: ^17 || ^18 || ^19
+ specifier: ^18 || ^19
version: 18.3.1(react@18.3.1)
styled-jsx:
specifier: '*'
@@ -35223,7 +35220,8 @@ snapshots:
'@next/env@14.2.16': {}
- '@next/env@14.2.35': {}
+ '@next/env@14.2.35':
+ optional: true
'@next/env@16.1.5': {}
@@ -54288,7 +54286,7 @@ snapshots:
- uglify-js
- webpack-cli
- next@14.2.35(@babel/core@7.28.6)(@playwright/test@1.57.0)(@swc/core@1.7.26(@swc/helpers@0.5.13))(babel-plugin-macros@3.1.0)(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.97.3)(webpack-cli@5.1.4):
+ next@14.2.35(@babel/core@7.28.6)(@playwright/test@1.57.0)(@swc/core@1.7.26(@swc/helpers@0.5.13))(babel-plugin-macros@3.1.0)(esbuild@0.25.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)(webpack-cli@5.1.4):
dependencies:
'@next/env': 14.2.35
'@swc/helpers': 0.5.5
@@ -54296,9 +54294,9 @@ snapshots:
caniuse-lite: 1.0.30001766
graceful-fs: 4.2.11
postcss: 8.4.31
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- styled-jsx: 5.1.1(@babel/core@7.28.6)(babel-plugin-macros@3.1.0)(react@18.3.1)
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ styled-jsx: 5.1.1(@babel/core@7.28.6)(babel-plugin-macros@3.1.0)(react@19.2.4)
webpack: 5.104.1(@swc/core@1.7.26(@swc/helpers@0.5.13))(esbuild@0.25.0)(webpack-cli@5.1.4)
optionalDependencies:
'@next/swc-darwin-arm64': 14.2.33
@@ -54319,31 +54317,31 @@ snapshots:
- esbuild
- uglify-js
- webpack-cli
+ optional: true
- next@14.2.35(@babel/core@7.28.6)(@playwright/test@1.57.0)(@swc/core@1.7.26(@swc/helpers@0.5.13))(babel-plugin-macros@3.1.0)(esbuild@0.25.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)(webpack-cli@5.1.4):
+ next@16.1.5(@babel/core@7.28.6)(@playwright/test@1.57.0)(@swc/core@1.15.10(@swc/helpers@0.5.18))(babel-plugin-macros@3.1.0)(esbuild@0.25.0)(react-dom@19.0.0-rc-cd22717c-20241013(react@19.0.0-rc-cd22717c-20241013))(react@19.0.0-rc-cd22717c-20241013)(sass@1.97.3)(webpack-cli@5.1.4(webpack-dev-server@5.2.3)(webpack@5.104.1)):
dependencies:
- '@next/env': 14.2.35
- '@swc/helpers': 0.5.5
- busboy: 1.6.0
- caniuse-lite: 1.0.30001766
- graceful-fs: 4.2.11
+ '@next/env': 16.1.5
+ '@swc/helpers': 0.5.15
+ baseline-browser-mapping: 2.9.19
+ caniuse-lite: 1.0.30001769
postcss: 8.4.31
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
- styled-jsx: 5.1.1(@babel/core@7.28.6)(babel-plugin-macros@3.1.0)(react@19.2.4)
- webpack: 5.104.1(@swc/core@1.7.26(@swc/helpers@0.5.13))(esbuild@0.25.0)(webpack-cli@5.1.4)
+ react: 19.0.0-rc-cd22717c-20241013
+ react-dom: 19.0.0-rc-cd22717c-20241013(react@19.0.0-rc-cd22717c-20241013)
+ styled-jsx: 5.1.6(@babel/core@7.28.6)(babel-plugin-macros@3.1.0)(react@19.0.0-rc-cd22717c-20241013)
+ webpack: 5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.25.0)(webpack-cli@5.1.4(webpack-dev-server@5.2.3)(webpack@5.104.1))
optionalDependencies:
- '@next/swc-darwin-arm64': 14.2.33
- '@next/swc-darwin-x64': 14.2.33
- '@next/swc-linux-arm64-gnu': 14.2.33
- '@next/swc-linux-arm64-musl': 14.2.33
- '@next/swc-linux-x64-gnu': 14.2.33
- '@next/swc-linux-x64-musl': 14.2.33
- '@next/swc-win32-arm64-msvc': 14.2.33
- '@next/swc-win32-ia32-msvc': 14.2.33
- '@next/swc-win32-x64-msvc': 14.2.33
+ '@next/swc-darwin-arm64': 16.1.5
+ '@next/swc-darwin-x64': 16.1.5
+ '@next/swc-linux-arm64-gnu': 16.1.5
+ '@next/swc-linux-arm64-musl': 16.1.5
+ '@next/swc-linux-x64-gnu': 16.1.5
+ '@next/swc-linux-x64-musl': 16.1.5
+ '@next/swc-win32-arm64-msvc': 16.1.5
+ '@next/swc-win32-x64-msvc': 16.1.5
'@playwright/test': 1.57.0
sass: 1.97.3
+ sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
- '@swc/core'
@@ -54351,19 +54349,18 @@ snapshots:
- esbuild
- uglify-js
- webpack-cli
- optional: true
- next@16.1.5(@babel/core@7.28.6)(@playwright/test@1.57.0)(@swc/core@1.15.10(@swc/helpers@0.5.18))(babel-plugin-macros@3.1.0)(esbuild@0.25.0)(react-dom@19.0.0-rc-cd22717c-20241013(react@19.0.0-rc-cd22717c-20241013))(react@19.0.0-rc-cd22717c-20241013)(sass@1.97.3)(webpack-cli@5.1.4(webpack-dev-server@5.2.3)(webpack@5.104.1)):
+ next@16.1.5(@babel/core@7.28.6)(@playwright/test@1.57.0)(@swc/core@1.7.26(@swc/helpers@0.5.13))(babel-plugin-macros@3.1.0)(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.97.3)(webpack-cli@5.1.4):
dependencies:
'@next/env': 16.1.5
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.9.19
- caniuse-lite: 1.0.30001766
+ caniuse-lite: 1.0.30001769
postcss: 8.4.31
- react: 19.0.0-rc-cd22717c-20241013
- react-dom: 19.0.0-rc-cd22717c-20241013(react@19.0.0-rc-cd22717c-20241013)
- styled-jsx: 5.1.6(@babel/core@7.28.6)(babel-plugin-macros@3.1.0)(react@19.0.0-rc-cd22717c-20241013)
- webpack: 5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.25.0)(webpack-cli@5.1.4(webpack-dev-server@5.2.3)(webpack@5.104.1))
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ styled-jsx: 5.1.6(@babel/core@7.28.6)(babel-plugin-macros@3.1.0)(react@18.3.1)
+ webpack: 5.104.1(@swc/core@1.7.26(@swc/helpers@0.5.13))(esbuild@0.25.0)(webpack-cli@5.1.4)
optionalDependencies:
'@next/swc-darwin-arm64': 16.1.5
'@next/swc-darwin-x64': 16.1.5
@@ -60636,6 +60633,14 @@ snapshots:
babel-plugin-macros: 3.1.0
optional: true
+ styled-jsx@5.1.6(@babel/core@7.28.6)(babel-plugin-macros@3.1.0)(react@18.3.1):
+ dependencies:
+ client-only: 0.0.1
+ react: 18.3.1
+ optionalDependencies:
+ '@babel/core': 7.28.6
+ babel-plugin-macros: 3.1.0
+
styled-jsx@5.1.6(@babel/core@7.28.6)(babel-plugin-macros@3.1.0)(react@19.0.0-rc-cd22717c-20241013):
dependencies:
client-only: 0.0.1
diff --git a/tools/scripts/run-next-app-router-e2e.mjs b/tools/scripts/run-next-app-router-e2e.mjs
new file mode 100644
index 00000000000..e5a183cdda2
--- /dev/null
+++ b/tools/scripts/run-next-app-router-e2e.mjs
@@ -0,0 +1,429 @@
+#!/usr/bin/env node
+import { spawn } from 'node:child_process';
+
+process.env.NX_TUI = 'false';
+
+const APPS = [
+ {
+ name: 'next-app-router-4000',
+ port: 4000,
+ cwd: 'apps/next-app-router/next-app-router-4000',
+ },
+ {
+ name: 'next-app-router-4001',
+ port: 4001,
+ cwd: 'apps/next-app-router/next-app-router-4001',
+ },
+];
+
+const MODES = new Set(['dev', 'prod', 'all']);
+const DETACHED_PROCESS_GROUP = Symbol('detachedProcessGroup');
+
+function shouldUseXvfb() {
+ return process.platform === 'linux' && !process.env.CYPRESS_NO_XVFB;
+}
+
+function wrapWithXvfb(command, args) {
+ if (!shouldUseXvfb()) {
+ return { command, args };
+ }
+ return { command: 'xvfb-run', args: ['-a', command, ...args] };
+}
+
+function withNextWebpackEnv(extraEnv = {}) {
+ return {
+ ...process.env,
+ ...(process.env.NEXT_PRIVATE_LOCAL_WEBPACK
+ ? { NEXT_PRIVATE_LOCAL_WEBPACK: process.env.NEXT_PRIVATE_LOCAL_WEBPACK }
+ : {}),
+ ...extraEnv,
+ };
+}
+
+async function main() {
+ const modeArg = process.argv.find((arg) => arg.startsWith('--mode='));
+ const mode = modeArg ? modeArg.split('=')[1] : 'all';
+
+ if (!MODES.has(mode)) {
+ console.error(
+ `Unknown mode "${mode}". Expected one of ${Array.from(MODES).join(', ')}`,
+ );
+ process.exitCode = 1;
+ return;
+ }
+
+ const scenarioModes = mode === 'all' ? ['dev', 'prod'] : [mode];
+ for (const scenarioMode of scenarioModes) {
+ await runScenario(scenarioMode);
+ }
+}
+
+async function runScenario(mode) {
+ console.log(`\n[next-app-router-e2e] Starting ${mode} scenario`);
+ await runKillPort();
+
+ if (mode === 'prod') {
+ await buildApps();
+ }
+
+ const servers = startServers(mode);
+ const firstServerExit = waitForFirstServerExit(servers);
+ let shutdownRequested = false;
+
+ try {
+ await runGuardedCommand(
+ 'wait for app-router servers',
+ firstServerExit,
+ () => {
+ const waitTargets = APPS.map((app) => `tcp:${app.port}`);
+ return spawnWithPromise('npx', ['wait-on', ...waitTargets]);
+ },
+ () => shutdownRequested,
+ );
+
+ if (mode === 'prod') {
+ await warmProductionRoutes(firstServerExit, () => shutdownRequested);
+ }
+
+ for (const app of APPS) {
+ const { command, args } = wrapWithXvfb('npx', [
+ 'nx',
+ 'run',
+ `${app.name}:e2e`,
+ '--output-style=static',
+ ]);
+ await runGuardedCommand(
+ `run ${app.name}:e2e`,
+ firstServerExit,
+ () => spawnWithPromise(command, args),
+ () => shutdownRequested,
+ );
+ }
+ } finally {
+ shutdownRequested = true;
+ let stopError = null;
+ try {
+ await stopServers(servers);
+ } catch (error) {
+ console.error(
+ '[next-app-router-e2e] Failed to stop server processes:',
+ error,
+ );
+ stopError = error;
+ }
+ await runKillPort();
+ if (stopError) {
+ throw stopError;
+ }
+ }
+
+ console.log(`[next-app-router-e2e] Finished ${mode} scenario`);
+}
+
+async function buildApps() {
+ console.log('[next-app-router-e2e] Building app-router apps...');
+ for (const app of APPS) {
+ await spawnWithPromise('npx', ['next', 'build', '--webpack'], {
+ cwd: app.cwd,
+ env: withNextWebpackEnv(),
+ }).promise;
+ }
+}
+
+function startServers(mode) {
+ return APPS.map((app) => {
+ const args =
+ mode === 'prod'
+ ? ['next', 'start', '-p', String(app.port)]
+ : ['next', 'dev', '--webpack', '-p', String(app.port)];
+
+ const child = spawn('npx', args, {
+ stdio: 'inherit',
+ cwd: app.cwd,
+ env: withNextWebpackEnv(),
+ detached: true,
+ });
+ child[DETACHED_PROCESS_GROUP] = true;
+
+ const exitPromise = new Promise((resolve, reject) => {
+ child.on('exit', (code, signal) => {
+ resolve({ app: app.name, code, signal });
+ });
+ child.on('error', reject);
+ });
+
+ return { app, child, exitPromise };
+ });
+}
+
+function waitForFirstServerExit(servers) {
+ return Promise.race(servers.map(({ exitPromise }) => exitPromise));
+}
+
+async function stopServers(servers) {
+ await Promise.all(
+ servers.map(({ child, exitPromise }) =>
+ shutdownProcess(child, exitPromise),
+ ),
+ );
+}
+
+async function warmProductionRoutes(serverExitPromise, isShutdownRequested) {
+ const urls = APPS.flatMap((app) => [
+ `http://localhost:${app.port}/`,
+ `http://localhost:${app.port}/_next/static/chunks/mf-manifest.json`,
+ ]);
+
+ await warmUrls({
+ urls,
+ label: 'route',
+ maxAttempts: 5,
+ delayMs: 900,
+ serverExitPromise,
+ isShutdownRequested,
+ });
+}
+
+async function runKillPort() {
+ const ports = APPS.map((app) => String(app.port));
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ try {
+ await spawnWithPromise('npx', ['kill-port', ...ports]).promise;
+ return;
+ } catch (error) {
+ const isFinalAttempt = attempt === 2;
+ console.warn(
+ `[next-app-router-e2e] kill-port attempt ${attempt + 1} failed: ${error.message}`,
+ );
+
+ if (isFinalAttempt) {
+ return;
+ }
+ await sleep(700);
+ }
+ }
+}
+
+function spawnWithPromise(cmd, args, options = {}) {
+ const child = spawn(cmd, args, {
+ stdio: 'inherit',
+ ...options,
+ });
+ if (options.detached) {
+ child[DETACHED_PROCESS_GROUP] = true;
+ }
+
+ const promise = new Promise((resolve, reject) => {
+ child.on('exit', (code, signal) => {
+ if (code === 0) {
+ resolve({ code, signal });
+ } else {
+ reject(
+ new Error(
+ `${cmd} ${args.join(' ')} exited with ${formatExit({ code, signal })}`,
+ ),
+ );
+ }
+ });
+ child.on('error', reject);
+ });
+
+ return { child, promise };
+}
+
+async function runGuardedCommand(
+ label,
+ serverExitPromise,
+ commandFactory,
+ isShutdownRequested,
+) {
+ if (isShutdownRequested()) {
+ return;
+ }
+
+ const { child, promise } = commandFactory();
+ const serverExitError = serverExitPromise.then((info) => {
+ if (isShutdownRequested()) {
+ return info;
+ }
+
+ if (child.exitCode === null && child.signalCode === null) {
+ sendSignal(child, 'SIGINT');
+ }
+
+ const error = new Error(
+ `[next-app-router-e2e] Server exited while trying to ${label}: ${formatExit(info)}`,
+ );
+ error.name = 'ServeExitError';
+ throw error;
+ });
+
+ try {
+ return await Promise.race([promise, serverExitError]);
+ } finally {
+ serverExitError.catch(() => {});
+ if (child.exitCode === null && child.signalCode === null) {
+ sendSignal(child, 'SIGINT');
+ }
+ }
+}
+
+function formatExit(info) {
+ if (!info) {
+ return 'unknown';
+ }
+ if (info.signal) {
+ return `signal ${info.signal}`;
+ }
+ if (typeof info.code === 'number') {
+ return `code ${info.code}`;
+ }
+ return 'unknown';
+}
+
+async function warmUrls({
+ urls,
+ label,
+ maxAttempts,
+ delayMs,
+ serverExitPromise,
+ isShutdownRequested,
+}) {
+ const warmedUrls = new Set();
+
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
+ for (const url of urls) {
+ if (warmedUrls.has(url)) {
+ continue;
+ }
+
+ try {
+ await runGuardedCommand(
+ `warm ${label} ${url}`,
+ serverExitPromise,
+ () => spawnWithPromise('curl', ['-sf', '-o', '/dev/null', url]),
+ isShutdownRequested,
+ );
+ warmedUrls.add(url);
+ } catch (error) {
+ if (error?.name === 'ServeExitError') {
+ throw error;
+ }
+ console.warn(
+ `[next-app-router-e2e] warmup attempt ${attempt + 1} failed for ${url}: ${error.message}`,
+ );
+ }
+ }
+
+ if (warmedUrls.size === urls.length) {
+ return;
+ }
+
+ await sleep(delayMs);
+ }
+
+ const missing = urls.filter((url) => !warmedUrls.has(url));
+ throw new Error(
+ `[next-app-router-e2e] Failed to warm ${missing.length} ${label} URL(s): ${missing.join(', ')}`,
+ );
+}
+
+async function shutdownProcess(proc, exitPromise) {
+ if (proc.exitCode !== null || proc.signalCode !== null) {
+ return exitPromise;
+ }
+
+ const sequence = [
+ { signal: 'SIGINT', timeoutMs: 8000 },
+ { signal: 'SIGTERM', timeoutMs: 5000 },
+ { signal: 'SIGKILL', timeoutMs: 3000 },
+ ];
+
+ for (const { signal, timeoutMs } of sequence) {
+ if (proc.exitCode !== null || proc.signalCode !== null) {
+ break;
+ }
+
+ sendSignal(proc, signal);
+
+ try {
+ await waitWithTimeout(exitPromise, timeoutMs);
+ break;
+ } catch (error) {
+ if (error.name !== 'TimeoutError') {
+ throw error;
+ }
+ }
+ }
+
+ return exitPromise;
+}
+
+function sendSignal(proc, signal) {
+ if (proc.exitCode !== null || proc.signalCode !== null) {
+ return;
+ }
+
+ if (proc[DETACHED_PROCESS_GROUP]) {
+ try {
+ process.kill(-proc.pid, signal);
+ return;
+ } catch (error) {
+ if (error.code !== 'ESRCH' && error.code !== 'EPERM') {
+ throw error;
+ }
+ }
+ }
+
+ try {
+ proc.kill(signal);
+ } catch (error) {
+ if (error.code !== 'ESRCH') {
+ throw error;
+ }
+ }
+}
+
+function waitWithTimeout(promise, timeoutMs) {
+ return new Promise((resolve, reject) => {
+ let settled = false;
+
+ const timer = setTimeout(() => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ const timeoutError = new Error(`Timed out after ${timeoutMs}ms`);
+ timeoutError.name = 'TimeoutError';
+ reject(timeoutError);
+ }, timeoutMs);
+
+ promise.then(
+ (value) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ clearTimeout(timer);
+ resolve(value);
+ },
+ (error) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ clearTimeout(timer);
+ reject(error);
+ },
+ );
+ });
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+main().catch((error) => {
+ console.error('\n[next-app-router-e2e] Failed:', error);
+ process.exitCode = 1;
+});
diff --git a/tools/scripts/run-next-e2e.mjs b/tools/scripts/run-next-e2e.mjs
index f297f84fa24..13fe26f0457 100755
--- a/tools/scripts/run-next-e2e.mjs
+++ b/tools/scripts/run-next-e2e.mjs
@@ -24,6 +24,7 @@ const SCENARIOS = {
'--configuration=development',
`--projects=${E2E_APPS.join(',')}`,
'--parallel=3',
+ '--output-style=static',
],
e2eApps: E2E_APPS,
waitTargets: NEXT_WAIT_TARGETS,
@@ -38,6 +39,7 @@ const SCENARIOS = {
'--configuration=production',
`--projects=${E2E_APPS.join(',')}`,
'--parallel=3',
+ '--output-style=static',
],
serveCmd: [
'npx',
@@ -47,6 +49,7 @@ const SCENARIOS = {
'--configuration=production',
`--projects=${E2E_APPS.join(',')}`,
'--parallel=3',
+ '--output-style=static',
],
e2eApps: E2E_APPS,
waitTargets: NEXT_WAIT_TARGETS,
@@ -55,6 +58,17 @@ const SCENARIOS = {
const VALID_MODES = new Set(['dev', 'prod', 'all']);
+function shouldUseXvfb() {
+ return process.platform === 'linux' && !process.env.CYPRESS_NO_XVFB;
+}
+
+function wrapWithXvfb(command, args) {
+ if (!shouldUseXvfb()) {
+ return { command, args };
+ }
+ return { command: 'xvfb-run', args: ['-a', command, ...args] };
+}
+
async function main() {
const modeArg = process.argv.find((arg) => arg.startsWith('--mode='));
const mode = modeArg ? modeArg.split('=')[1] : 'all';
@@ -129,13 +143,24 @@ async function runScenario(name) {
() => shutdownRequested,
);
+ if (name === 'prod') {
+ await warmProductionRemotes(serveExitPromise, () => shutdownRequested);
+ }
+
// Run e2e tests for each app sequentially
for (const app of scenario.e2eApps) {
console.log(`\n[next-e2e] Running e2e tests for ${app}`);
+ const { command, args } = wrapWithXvfb('npx', [
+ 'nx',
+ 'run',
+ `${app}:e2e`,
+ '--output-style=static',
+ '--skip-nx-cache',
+ ]);
await runGuardedCommand(
`running e2e tests for ${app}`,
serveExitPromise,
- () => spawnWithPromise('npx', ['nx', 'run', `${app}:e2e`]),
+ () => spawnWithPromise(command, args),
() => shutdownRequested,
);
console.log(`[next-e2e] Finished e2e tests for ${app}`);
@@ -167,6 +192,92 @@ async function runScenario(name) {
console.log(`[next-e2e] Finished ${scenario.label}`);
}
+async function warmProductionRemotes(serveExitPromise, isShutdownRequested) {
+ const remoteEntryUrls = [
+ 'http://localhost:3000/_next/static/chunks/remoteEntry.js',
+ 'http://localhost:3001/_next/static/chunks/remoteEntry.js',
+ 'http://localhost:3002/_next/static/chunks/remoteEntry.js',
+ ];
+ const pageUrls = [
+ 'http://localhost:3000/',
+ 'http://localhost:3000/shop',
+ 'http://localhost:3000/checkout',
+ 'http://localhost:3001/',
+ 'http://localhost:3001/shop',
+ 'http://localhost:3001/checkout',
+ 'http://localhost:3002/',
+ 'http://localhost:3002/shop',
+ 'http://localhost:3002/checkout',
+ ];
+
+ await warmUrls({
+ urls: remoteEntryUrls,
+ label: 'remote entry',
+ maxAttempts: 5,
+ delayMs: 1000,
+ serveExitPromise,
+ isShutdownRequested,
+ });
+
+ // Production `next start` can report a listening port before all MF containers are
+ // initialized. Prime key routes to avoid startup hydration races in E2E.
+ await warmUrls({
+ urls: pageUrls,
+ label: 'page',
+ maxAttempts: 6,
+ delayMs: 1300,
+ serveExitPromise,
+ isShutdownRequested,
+ });
+}
+
+async function warmUrls({
+ urls,
+ label,
+ maxAttempts,
+ delayMs,
+ serveExitPromise,
+ isShutdownRequested,
+}) {
+ const warmedUrls = new Set();
+
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
+ for (const url of urls) {
+ if (warmedUrls.has(url)) {
+ continue;
+ }
+
+ try {
+ await runGuardedCommand(
+ `warming ${label} ${url}`,
+ serveExitPromise,
+ () => spawnWithPromise('curl', ['-sf', '-o', '/dev/null', url]),
+ isShutdownRequested,
+ );
+ warmedUrls.add(url);
+ } catch (error) {
+ if (error?.name === 'ServeExitError') {
+ throw error;
+ }
+ console.warn(
+ `[next-e2e] warmup attempt ${attempt + 1} failed for ${url}: ${error.message}`,
+ );
+ }
+ }
+
+ if (warmedUrls.size === urls.length) {
+ return;
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+ }
+
+ const missing = urls.filter((url) => !warmedUrls.has(url));
+ throw new Error(
+ `[next-e2e] Failed to warm ${missing.length} ${label} URL(s): ${missing.join(', ')}`,
+ );
+}
+
async function runKillPort() {
const { promise } = spawnWithPromise(
KILL_PORT_ARGS[0],
@@ -364,9 +475,11 @@ async function runGuardedCommand(
if (child.exitCode === null && child.signalCode === null) {
sendSignal(child, 'SIGINT');
}
- throw new Error(
+ const error = new Error(
`Serve process exited while ${description}: ${formatExit(info)}`,
);
+ error.name = 'ServeExitError';
+ throw error;
});
try {