From d1a7a39b84f295d0ba321f58b173005deb3988ff Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 11 Mar 2026 18:33:55 -0700 Subject: [PATCH 01/22] feat(runtime): extract webpack compat substrate --- .../src/lib/container/AsyncBoundaryPlugin.ts | 202 +++++++++--------- .../lib/container/ModuleFederationPlugin.ts | 22 +- .../runtime/ChildCompilationRuntimePlugin.ts | 5 +- .../runtime/EmbedFederationRuntimePlugin.ts | 11 +- .../runtime/FederationRuntimePlugin.ts | 6 +- .../tree-shaking/IndependentSharedPlugin.ts | 88 +++++--- .../SharedUsedExportsOptimizerPlugin.ts | 7 +- .../MfStartupChunkDependenciesPlugin.ts | 11 +- .../src/lib/startup/StartupHelpers.ts | 12 +- packages/enhanced/src/lib/webpackCompat.ts | 44 ++++ .../enhanced/test/ConfigTestCases.rstest.ts | 1 - packages/manifest/src/StatsPlugin.ts | 20 +- packages/manifest/src/utils.ts | 8 +- .../src/plugins/CommonJsChunkLoadingPlugin.ts | 17 +- .../src/plugins/EntryChunkTrackerPlugin.ts | 59 +++-- packages/node/src/utils/flush-chunks.ts | 7 +- packages/node/src/utils/hot-reload.test.ts | 66 ++++++ packages/node/src/utils/hot-reload.ts | 125 ++++++----- packages/sdk/src/normalize-webpack-path.ts | 34 +-- 19 files changed, 495 insertions(+), 250 deletions(-) create mode 100644 packages/enhanced/src/lib/webpackCompat.ts create mode 100644 packages/node/src/utils/hot-reload.test.ts 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 f308f576e25..93e5f68dbaf 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 { @@ -137,6 +137,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) { @@ -182,6 +198,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 f83ca81f55b..c61ecf3b570 100644 --- a/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts +++ b/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts @@ -134,6 +134,10 @@ class FederationRuntimePlugin { '}', ]); + const installInitialConsumesCall = options.experiments?.asyncStartup + ? `${federationGlobal}.installInitialConsumes({ asyncLoad: true })` + : `${federationGlobal}.installInitialConsumes()`; + return Template.asString([ `import federation from '${normalizedBundlerRuntimePath}';`, runtimePluginTemplates, @@ -159,7 +163,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..62681a3509c 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[] }; @@ -30,9 +31,7 @@ function isImportDependency(dependency: Dependency) { return dependency.type === 'import()'; } -export default class SharedUsedExportsOptimizerPlugin - implements WebpackPluginInstance -{ +export default class SharedUsedExportsOptimizerPlugin implements WebpackPluginInstance { name = 'SharedUsedExportsOptimizerPlugin'; sharedReferencedExports: ReferencedExports; @@ -315,7 +314,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 012cf1d1449..fb5c5e304d8 100644 --- a/packages/enhanced/test/ConfigTestCases.rstest.ts +++ b/packages/enhanced/test/ConfigTestCases.rstest.ts @@ -375,7 +375,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/node/src/plugins/CommonJsChunkLoadingPlugin.ts b/packages/node/src/plugins/CommonJsChunkLoadingPlugin.ts index d98cbb7e2bd..73136e34576 100644 --- a/packages/node/src/plugins/CommonJsChunkLoadingPlugin.ts +++ b/packages/node/src/plugins/CommonJsChunkLoadingPlugin.ts @@ -1,14 +1,9 @@ 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'; -interface DynamicFilesystemChunkLoadingOptions - extends ModuleFederationPluginOptions { +interface DynamicFilesystemChunkLoadingOptions extends ModuleFederationPluginOptions { baseURI: Compiler['options']['output']['publicPath']; promiseBaseURI?: string; remotes: Record; @@ -28,6 +23,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/sdk/src/normalize-webpack-path.ts b/packages/sdk/src/normalize-webpack-path.ts index 5797e89c6ce..ec40461ffa6 100644 --- a/packages/sdk/src/normalize-webpack-path.ts +++ b/packages/sdk/src/normalize-webpack-path.ts @@ -1,16 +1,10 @@ import type webpack from 'webpack'; -import { resolve } from 'node:path'; +import path from 'path'; export function getWebpackPath( compiler: webpack.Compiler, options: { framework: 'nextjs' | 'other' } = { framework: 'other' }, ): string { - const resolveWithContext = new Function( - 'id', - 'options', - 'return typeof require === "undefined" ? "" : require.resolve(id, options)', - ) as (id: string, options?: { paths?: string[] }) => string; - try { // @ts-ignore just throw err compiler.webpack(); @@ -32,18 +26,34 @@ export function getWebpackPath( } return ''; } - return resolveWithContext('webpack', { paths: [webpackPath] }); + return require.resolve('webpack', { paths: [webpackPath] }); } } 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']) { - return resolve( - process.env['FEDERATION_WEBPACK_PATH'], + if (federationWebpackPath) { + return path.resolve( + federationWebpackPath, fullPath.replace('webpack', '../../'), ); } From b4c255dfa2572779e9235d04b90d08c7b968f44c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 11 Mar 2026 19:01:31 -0700 Subject: [PATCH 02/22] fix(sdk): avoid hoisting require into browser runtime --- packages/sdk/src/normalize-webpack-path.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/normalize-webpack-path.ts b/packages/sdk/src/normalize-webpack-path.ts index ec40461ffa6..866dbfd0d53 100644 --- a/packages/sdk/src/normalize-webpack-path.ts +++ b/packages/sdk/src/normalize-webpack-path.ts @@ -5,6 +5,12 @@ export function getWebpackPath( compiler: webpack.Compiler, options: { framework: 'nextjs' | 'other' } = { framework: 'other' }, ): string { + const resolveWithContext = new Function( + 'id', + 'options', + 'return typeof require === "undefined" ? "" : require.resolve(id, options)', + ) as (id: string, options?: { paths?: string[] }) => string; + try { // @ts-ignore just throw err compiler.webpack(); @@ -26,7 +32,7 @@ export function getWebpackPath( } return ''; } - return require.resolve('webpack', { paths: [webpackPath] }); + return resolveWithContext('webpack', { paths: [webpackPath] }); } } From b20735b90234db22df9fcc59bcc6e0e2b4a715c3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 11 Mar 2026 18:46:15 -0700 Subject: [PATCH 03/22] feat(nextjs-mf): extract v9 core rewrite --- packages/nextjs-mf/MIGRATION-v9.md | 103 ++++ packages/nextjs-mf/README.md | 83 ++- packages/nextjs-mf/client/UrlNode.ts | 199 ------- packages/nextjs-mf/client/helpers.ts | 100 ---- packages/nextjs-mf/package.json | 54 +- .../nextjs-mf/src/core/compilers/client.ts | 48 ++ .../nextjs-mf/src/core/compilers/server.ts | 245 ++++++++ .../core/container/InvertedContainerPlugin.ts | 51 ++ .../InvertedContainerRuntimeModule.ts | 86 +++ packages/nextjs-mf/src/core/errors.ts | 36 ++ .../nextjs-mf/src/core/features/app.test.ts | 103 ++++ packages/nextjs-mf/src/core/features/app.ts | 209 +++++++ .../core/features/pages-map-loader.test.ts | 74 +++ .../src/core/features/pages-map-loader.ts | 175 ++++++ packages/nextjs-mf/src/core/features/pages.ts | 8 + .../core/loaders/asset-loader-fixes.test.ts | 115 ++++ .../src/core/loaders/fixNextImageLoader.ts | 210 +++++++ .../src/core/loaders/fixUrlLoader.ts | 42 ++ .../src/core/loaders/patchLoaders.ts | 175 ++++++ packages/nextjs-mf/src/core/options.test.ts | 87 +++ packages/nextjs-mf/src/core/options.ts | 164 ++++++ packages/nextjs-mf/src/core/runtime.ts | 26 + .../nextjs-mf/src/core/runtimePlugin.test.ts | 236 ++++++++ packages/nextjs-mf/src/core/runtimePlugin.ts | 310 ++++++++++ packages/nextjs-mf/src/core/sharing.test.ts | 95 +++ packages/nextjs-mf/src/core/sharing.ts | 389 ++++++++++++ packages/nextjs-mf/src/federation-noop.ts | 13 - packages/nextjs-mf/src/index.ts | 22 +- packages/nextjs-mf/src/internal.ts | 297 ---------- .../nextjs-mf/src/loaders/fixImageLoader.ts | 129 ---- .../nextjs-mf/src/loaders/fixUrlLoader.ts | 26 - packages/nextjs-mf/src/loaders/helpers.ts | 192 ------ .../src/loaders/nextPageMapLoader.ts | 190 ------ packages/nextjs-mf/src/logger.ts | 29 +- ...ntimeRequirementToPromiseExternalPlugin.ts | 25 - .../src/plugins/CopyFederationPlugin.ts | 91 --- .../apply-client-plugins.ts | 65 -- .../apply-server-plugins.ts | 264 --------- .../src/plugins/NextFederationPlugin/index.ts | 273 --------- .../NextFederationPlugin/next-fragments.ts | 153 ----- .../NextFederationPlugin/regex-equal.test.ts | 48 -- .../NextFederationPlugin/regex-equal.ts | 32 - .../remove-unnecessary-shared-keys.test.ts | 46 -- .../remove-unnecessary-shared-keys.ts | 32 - .../NextFederationPlugin/set-options.ts | 39 -- .../NextFederationPlugin/validate-options.ts | 48 -- .../RemoveEagerModulesFromRuntimePlugin.ts | 103 ---- .../src/plugins/container/runtimePlugin.ts | 254 -------- .../nextjs-mf/src/plugins/container/types.ts | 5 - packages/nextjs-mf/src/types.ts | 94 ++- packages/nextjs-mf/src/types/btoa.d.ts | 5 +- packages/nextjs-mf/src/withNextFederation.ts | 554 ++++++++++++++++++ packages/nextjs-mf/tsconfig.lib.json | 2 +- packages/nextjs-mf/tsdown.config.mts | 12 +- packages/nextjs-mf/utils/flushedChunks.ts | 61 -- packages/nextjs-mf/utils/index.ts | 35 -- 56 files changed, 3744 insertions(+), 2818 deletions(-) create mode 100644 packages/nextjs-mf/MIGRATION-v9.md delete mode 100644 packages/nextjs-mf/client/UrlNode.ts delete mode 100644 packages/nextjs-mf/client/helpers.ts create mode 100644 packages/nextjs-mf/src/core/compilers/client.ts create mode 100644 packages/nextjs-mf/src/core/compilers/server.ts create mode 100644 packages/nextjs-mf/src/core/container/InvertedContainerPlugin.ts create mode 100644 packages/nextjs-mf/src/core/container/InvertedContainerRuntimeModule.ts create mode 100644 packages/nextjs-mf/src/core/errors.ts create mode 100644 packages/nextjs-mf/src/core/features/app.test.ts create mode 100644 packages/nextjs-mf/src/core/features/app.ts create mode 100644 packages/nextjs-mf/src/core/features/pages-map-loader.test.ts create mode 100644 packages/nextjs-mf/src/core/features/pages-map-loader.ts create mode 100644 packages/nextjs-mf/src/core/features/pages.ts create mode 100644 packages/nextjs-mf/src/core/loaders/asset-loader-fixes.test.ts create mode 100644 packages/nextjs-mf/src/core/loaders/fixNextImageLoader.ts create mode 100644 packages/nextjs-mf/src/core/loaders/fixUrlLoader.ts create mode 100644 packages/nextjs-mf/src/core/loaders/patchLoaders.ts create mode 100644 packages/nextjs-mf/src/core/options.test.ts create mode 100644 packages/nextjs-mf/src/core/options.ts create mode 100644 packages/nextjs-mf/src/core/runtime.ts create mode 100644 packages/nextjs-mf/src/core/runtimePlugin.test.ts create mode 100644 packages/nextjs-mf/src/core/runtimePlugin.ts create mode 100644 packages/nextjs-mf/src/core/sharing.test.ts create mode 100644 packages/nextjs-mf/src/core/sharing.ts delete mode 100644 packages/nextjs-mf/src/federation-noop.ts delete mode 100644 packages/nextjs-mf/src/internal.ts delete mode 100644 packages/nextjs-mf/src/loaders/fixImageLoader.ts delete mode 100644 packages/nextjs-mf/src/loaders/fixUrlLoader.ts delete mode 100644 packages/nextjs-mf/src/loaders/helpers.ts delete mode 100644 packages/nextjs-mf/src/loaders/nextPageMapLoader.ts delete mode 100644 packages/nextjs-mf/src/plugins/AddRuntimeRequirementToPromiseExternalPlugin.ts delete mode 100644 packages/nextjs-mf/src/plugins/CopyFederationPlugin.ts delete mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/apply-client-plugins.ts delete mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/apply-server-plugins.ts delete mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/index.ts delete mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/next-fragments.ts delete mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/regex-equal.test.ts delete mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/regex-equal.ts delete mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/remove-unnecessary-shared-keys.test.ts delete mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/remove-unnecessary-shared-keys.ts delete mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/set-options.ts delete mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/validate-options.ts delete mode 100644 packages/nextjs-mf/src/plugins/container/RemoveEagerModulesFromRuntimePlugin.ts delete mode 100644 packages/nextjs-mf/src/plugins/container/runtimePlugin.ts delete mode 100644 packages/nextjs-mf/src/plugins/container/types.ts create mode 100644 packages/nextjs-mf/src/withNextFederation.ts delete mode 100644 packages/nextjs-mf/utils/flushedChunks.ts delete mode 100644 packages/nextjs-mf/utils/index.ts 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..45b33a59a8d 100644 --- a/packages/nextjs-mf/README.md +++ b/packages/nextjs-mf/README.md @@ -1,5 +1,82 @@ -# 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 f328b33cc54..aabc3fa7ca8 100644 --- a/packages/nextjs-mf/package.json +++ b/packages/nextjs-mf/package.json @@ -1,12 +1,11 @@ { "name": "@module-federation/nextjs-mf", - "version": "8.8.57", + "version": "9.0.0", "license": "MIT", "main": "dist/src/index.js", - "module": "dist/src/index.mjs", "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", @@ -18,45 +17,27 @@ ], "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.\"", "build": "tsdown --config tsdown.config.mts", "lint": "ESLINT_USE_FLAT_CONFIG=false pnpm exec eslint --ignore-pattern node_modules \"**/*.js\" \"**/*.ts\"", "test": "pnpm exec jest --config jest.config.js --passWithNoTests", "pre-release": "pnpm run test && pnpm run build && rm -f ./dist/package.json" }, "exports": { - ".": { - "import": { - "types": "./dist/src/index.d.mts", - "default": "./dist/src/index.mjs" - }, - "require": { - "types": "./dist/src/index.d.ts", - "default": "./dist/src/index.js" - } - }, - "./utils": { - "import": { - "types": "./dist/utils/index.d.mts", - "default": "./dist/utils/index.mjs" - }, - "require": { - "types": "./dist/utils/index.d.ts", - "default": "./dist/utils/index.js" - } - }, - "./*": "./*" + ".": "./dist/src/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" ] } }, @@ -64,12 +45,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", @@ -78,11 +58,11 @@ "tsdown": "0.20.3" }, "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/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/core/container/InvertedContainerPlugin.ts b/packages/nextjs-mf/src/core/container/InvertedContainerPlugin.ts new file mode 100644 index 00000000000..9f447e8954c --- /dev/null +++ b/packages/nextjs-mf/src/core/container/InvertedContainerPlugin.ts @@ -0,0 +1,51 @@ +import type { Compilation, Compiler } from 'webpack'; +import InvertedContainerRuntimeModule from './InvertedContainerRuntimeModule'; + +type EnhancedModuleExports = typeof import('@module-federation/enhanced'); + +const loadEnhanced = (): EnhancedModuleExports => { + const enhancedModule = require('@module-federation/enhanced') as + | EnhancedModuleExports + | { default: EnhancedModuleExports }; + + return (enhancedModule as { default?: EnhancedModuleExports }).default + ? (enhancedModule as { default: EnhancedModuleExports }).default + : (enhancedModule as EnhancedModuleExports); +}; + +class InvertedContainerPlugin { + public apply(compiler: Compiler): void { + const { FederationModulesPlugin, dependencies } = loadEnhanced(); + + compiler.hooks.thisCompilation.tap( + 'InvertedContainerPlugin', + (compilation: Compilation) => { + const hooks = FederationModulesPlugin.getCompilationHooks(compilation); + const containers = new Set(); + + hooks.addContainerEntryDependency.tap( + 'InvertedContainerPlugin', + (dependency) => { + if (dependency instanceof dependencies.ContainerEntryDependency) { + containers.add(dependency); + } + }, + ); + + compilation.hooks.additionalTreeRuntimeRequirements.tap( + 'InvertedContainerPlugin', + (chunk) => { + compilation.addRuntimeModule( + chunk, + new InvertedContainerRuntimeModule({ + containers, + }), + ); + }, + ); + }, + ); + } +} + +export default InvertedContainerPlugin; diff --git a/packages/nextjs-mf/src/core/container/InvertedContainerRuntimeModule.ts b/packages/nextjs-mf/src/core/container/InvertedContainerRuntimeModule.ts new file mode 100644 index 00000000000..5c994b829de --- /dev/null +++ b/packages/nextjs-mf/src/core/container/InvertedContainerRuntimeModule.ts @@ -0,0 +1,86 @@ +import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-path'; + +const { RuntimeModule, Template, RuntimeGlobals } = require( + normalizeWebpackPath('webpack'), +) as typeof import('webpack'); + +interface InvertedContainerRuntimeModuleOptions { + containers: Set; +} + +class InvertedContainerRuntimeModule extends RuntimeModule { + private options: InvertedContainerRuntimeModuleOptions; + + constructor(options: InvertedContainerRuntimeModuleOptions) { + super('inverted container startup', RuntimeModule.STAGE_TRIGGER); + this.options = options; + } + + override generate(): string { + const { compilation, chunk, chunkGraph } = this; + if (!compilation || !chunk || !chunkGraph) { + return ''; + } + + if (chunk.runtime === 'webpack-api-runtime') { + return ''; + } + + let containerEntryModule: any; + for (const containerDep of this.options.containers) { + const mod = compilation.moduleGraph.getModule(containerDep); + if (!mod) { + continue; + } + if (chunkGraph.isModuleInChunk(mod, chunk)) { + containerEntryModule = mod; + } + } + + if (!containerEntryModule) { + return ''; + } + + if ( + compilation.chunkGraph.isEntryModuleInChunk(containerEntryModule, chunk) + ) { + return ''; + } + + const initRuntimeModuleGetter = compilation.runtimeTemplate.moduleRaw({ + module: containerEntryModule, + chunkGraph, + weak: false, + runtimeRequirements: new Set(), + }); + const nameJSON = JSON.stringify(containerEntryModule._name); + + return Template.asString([ + `var prevStartup = ${RuntimeGlobals.startup};`, + 'var hasRun = false;', + 'var cachedRemote;', + `${RuntimeGlobals.startup} = ${compilation.runtimeTemplate.basicFunction( + '', + Template.asString([ + 'if (!hasRun) {', + Template.indent( + Template.asString([ + '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();'), + '}', + ]), + )};`, + ]); + } +} + +export default InvertedContainerRuntimeModule; 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 9580f9fed9c..a0628c7c943 100644 --- a/packages/nextjs-mf/src/index.ts +++ b/packages/nextjs-mf/src/index.ts @@ -1,13 +1,13 @@ -import NextFederationPlugin from './plugins/NextFederationPlugin'; +import withNextFederation from './withNextFederation'; -export { NextFederationPlugin }; -export default NextFederationPlugin; +export type { + NextFederationCompilerContext, + NextFederationMode, + NextFederationOptionsV9, +} from './types'; -if ( - process.env.IS_ESM_BUILD !== 'true' && - typeof module !== 'undefined' && - typeof module.exports !== 'undefined' -) { - 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 ca327dabedd..00000000000 --- a/packages/nextjs-mf/src/plugins/NextFederationPlugin/index.ts +++ /dev/null @@ -1,273 +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 path from 'path'; -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'; - -const resolveRuntimePluginPath = (): string => - process.env.IS_ESM_BUILD === 'true' - ? require.resolve( - '@module-federation/nextjs-mf/dist/src/plugins/container/runtimePlugin.mjs', - ) - : require.resolve( - '@module-federation/nextjs-mf/dist/src/plugins/container/runtimePlugin.js', - ); - -const resolveNoopPath = (): string => - process.env.IS_ESM_BUILD === 'true' - ? require.resolve( - '@module-federation/nextjs-mf/dist/src/federation-noop.mjs', - ) - : require.resolve( - '@module-federation/nextjs-mf/dist/src/federation-noop.js', - ); - -const resolveNodeRuntimePluginPath = (): string => { - const nodePackageRoot = path.dirname( - require.resolve('@module-federation/node/package.json'), - ); - - return require.resolve( - path.join( - nodePackageRoot, - process.env.IS_ESM_BUILD === 'true' - ? 'dist/src/runtimePlugin.mjs' - : 'dist/src/runtimePlugin.js', - ), - ); -}; -/** - * 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 ? [resolveNodeRuntimePluginPath()] : []), - resolveRuntimePluginPath(), - ...(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, - }, - manifest: { - ...(this._options.manifest ?? {}), - filePath: isServer ? '' : '/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 resolveNoopPath(); - } -} - -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 964577ba9a6..00000000000 --- a/packages/nextjs-mf/src/plugins/NextFederationPlugin/next-fragments.ts +++ /dev/null @@ -1,153 +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'; - -const resolveFixImageLoaderPath = (): string => - process.env.IS_ESM_BUILD === 'true' - ? require.resolve( - '@module-federation/nextjs-mf/dist/src/loaders/fixImageLoader.mjs', - ) - : require.resolve( - '@module-federation/nextjs-mf/dist/src/loaders/fixImageLoader.js', - ); - -const resolveFixUrlLoaderPath = (): string => - process.env.IS_ESM_BUILD === 'true' - ? require.resolve( - '@module-federation/nextjs-mf/dist/src/loaders/fixUrlLoader.mjs', - ) - : require.resolve( - '@module-federation/nextjs-mf/dist/src/loaders/fixUrlLoader.js', - ); -/** - * 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: resolveFixImageLoaderPath(), - }); - } - - if (options.enableUrlLoaderFix && hasLoader(typedRule, 'url-loader')) { - injectRuleLoader(typedRule, { - loader: resolveFixUrlLoaderPath(), - }); - } - } - }); - - 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..e97a09a795b 100644 --- a/packages/nextjs-mf/src/types.ts +++ b/packages/nextjs-mf/src/types.ts @@ -1,35 +1,77 @@ -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/tsdown.config.mts b/packages/nextjs-mf/tsdown.config.mts index 4a727da89d7..63c9b32d182 100644 --- a/packages/nextjs-mf/tsdown.config.mts +++ b/packages/nextjs-mf/tsdown.config.mts @@ -13,13 +13,11 @@ export default defineConfig([ packageDir, entry: { 'src/index': 'src/index.ts', - 'src/federation-noop': 'src/federation-noop.ts', - 'src/loaders/fixImageLoader': 'src/loaders/fixImageLoader.ts', - 'src/loaders/nextPageMapLoader': 'src/loaders/nextPageMapLoader.ts', - 'src/loaders/fixUrlLoader': 'src/loaders/fixUrlLoader.ts', - 'src/plugins/container/runtimePlugin': - 'src/plugins/container/runtimePlugin.ts', - 'utils/index': 'utils/index.ts', + node: 'node.ts', + 'src/core/loaders/fixNextImageLoader': + 'src/core/loaders/fixNextImageLoader.ts', + 'src/core/loaders/fixUrlLoader': 'src/core/loaders/fixUrlLoader.ts', + 'src/core/runtimePlugin': 'src/core/runtimePlugin.ts', }, external: [ '@module-federation/*', 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); - }); - } -}; From b3b7649724943f760a38f4722d615664ee9b63b6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 11 Mar 2026 18:55:19 -0700 Subject: [PATCH 04/22] chore(nextjs-mf): sync v9 core lockfile --- pnpm-lock.yaml | 246 +++++++++++-------------------------------------- 1 file changed, 53 insertions(+), 193 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fb786377f1..7e268fc58b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1104,7 +1104,7 @@ importers: version: 7.28.2 '@modern-js/runtime': specifier: 3.0.1 - version: 3.0.1(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0)(react-dom@18.3.1(react@18.3.1))(react-server-dom-webpack@19.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4)))(react@18.3.1) + version: 3.0.1(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0)(react-dom@18.3.1(react@18.3.1))(react-server-dom-webpack@19.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4(webpack-dev-server@5.2.3)(webpack@5.104.1))))(react@18.3.1) '@module-federation/modern-js-v3': specifier: workspace:* version: link:../../../packages/modernjs-v3 @@ -1123,7 +1123,7 @@ importers: version: 2.59.0(typescript@5.0.4) '@modern-js/app-tools': specifier: 3.0.1 - version: 3.0.1(@module-federation/runtime-tools@2.1.0)(@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@2.1.0)(@swc/helpers@0.5.18))(core-js@3.48.0)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.57.0)(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@20.19.5)(typescript@5.0.4))(tsconfig-paths@4.2.0)(tslib@2.8.1)(typescript@5.0.4)(webpack-hot-middleware@2.26.1)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4)) + version: 3.0.1(@module-federation/runtime-tools@2.1.0)(@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@2.1.0)(@swc/helpers@0.5.18))(core-js@3.48.0)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.57.0)(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@20.19.5)(typescript@5.0.4))(tsconfig-paths@4.2.0)(tslib@2.8.1)(typescript@5.0.4)(webpack-hot-middleware@2.26.1)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4(webpack-dev-server@5.2.3)(webpack@5.104.1))) '@modern-js/eslint-config': specifier: 2.59.0 version: 2.59.0(typescript@5.0.4) @@ -1162,7 +1162,7 @@ importers: version: 7.28.2 '@modern-js/runtime': specifier: 3.0.1 - version: 3.0.1(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0)(react-dom@18.3.1(react@18.3.1))(react-server-dom-webpack@19.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4)))(react@18.3.1) + version: 3.0.1(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0)(react-dom@18.3.1(react@18.3.1))(react-server-dom-webpack@19.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4(webpack-dev-server@5.2.3)(webpack@5.104.1))))(react@18.3.1) '@module-federation/modern-js-v3': specifier: workspace:* version: link:../../../packages/modernjs-v3 @@ -1181,7 +1181,7 @@ importers: version: 2.59.0(typescript@5.0.4) '@modern-js/app-tools': specifier: 3.0.1 - version: 3.0.1(@module-federation/runtime-tools@2.1.0)(@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@2.1.0)(@swc/helpers@0.5.18))(core-js@3.48.0)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.57.0)(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@20.19.5)(typescript@5.0.4))(tsconfig-paths@4.2.0)(tslib@2.8.1)(typescript@5.0.4)(webpack-hot-middleware@2.26.1)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4)) + version: 3.0.1(@module-federation/runtime-tools@2.1.0)(@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@2.1.0)(@swc/helpers@0.5.18))(core-js@3.48.0)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.57.0)(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@20.19.5)(typescript@5.0.4))(tsconfig-paths@4.2.0)(tslib@2.8.1)(typescript@5.0.4)(webpack-hot-middleware@2.26.1)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4(webpack-dev-server@5.2.3)(webpack@5.104.1))) '@modern-js/eslint-config': specifier: 2.59.0 version: 2.59.0(typescript@5.0.4) @@ -3849,20 +3849,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.27.3)(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.27.3)(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: '*' @@ -4084,7 +4081,7 @@ importers: version: 21.2.3(@babel/traverse@7.29.0)(@swc-node/register@1.10.10(@swc/core@1.15.10(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.10(@swc/helpers@0.5.18))(@swc/helpers@0.5.18)(esbuild@0.25.0)(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.27.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)(webpack-cli@5.1.4))(nx@21.2.3(@swc-node/register@1.10.10(@swc/core@1.15.10(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.10(@swc/helpers@0.5.18)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(verdaccio@6.1.2(encoding@0.1.13)(typanion@3.14.0))(vue-tsc@2.2.12(typescript@5.9.3))(webpack-cli@5.1.4) '@rsbuild/core': specifier: 2.0.0-beta.2 - version: 2.0.0-beta.2(@module-federation/runtime-tools@0.23.0)(core-js@3.48.0) + version: 2.0.0-beta.2(@module-federation/runtime-tools@0.15.0)(core-js@3.48.0) '@storybook/core': specifier: ^8.4.6 version: 8.6.14(prettier@3.8.1)(storybook@8.6.17(prettier@3.8.1)) @@ -31664,57 +31661,6 @@ snapshots: - webpack - webpack-hot-middleware - '@modern-js/app-tools@3.0.1(@module-federation/runtime-tools@2.1.0)(@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@2.1.0)(@swc/helpers@0.5.18))(core-js@3.48.0)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.57.0)(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@20.19.5)(typescript@5.0.4))(tsconfig-paths@4.2.0)(tslib@2.8.1)(typescript@5.0.4)(webpack-hot-middleware@2.26.1)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4))': - dependencies: - '@babel/parser': 7.29.0 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@modern-js/builder': 3.0.1(@module-federation/runtime-tools@2.1.0)(@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@2.1.0)(@swc/helpers@0.5.18))(esbuild@0.25.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tslib@2.8.1)(typescript@5.0.4)(webpack-hot-middleware@2.26.1)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4)) - '@modern-js/i18n-utils': 3.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@modern-js/plugin': 3.0.1(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@modern-js/plugin-data-loader': 3.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@modern-js/prod-server': 3.0.1(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@modern-js/server': 3.0.1(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@20.19.5)(typescript@5.0.4))(tsconfig-paths@4.2.0) - '@modern-js/server-core': 3.0.1(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@modern-js/server-utils': 3.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@modern-js/types': 3.0.1 - '@modern-js/utils': 3.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@rsbuild/core': 2.0.0-beta.2(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0) - '@swc/helpers': 0.5.18 - es-module-lexer: 1.7.0 - esbuild: 0.25.5 - esbuild-register: 3.6.0(esbuild@0.25.5) - flatted: 3.3.3 - mlly: 1.8.0 - ndepe: 0.1.13(encoding@0.1.13)(rollup@4.57.0) - pkg-types: 1.3.1 - std-env: 3.10.0 - optionalDependencies: - ts-node: 10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@20.19.5)(typescript@5.0.4) - tsconfig-paths: 4.2.0 - transitivePeerDependencies: - - '@module-federation/runtime-tools' - - '@parcel/css' - - '@rspack/core' - - '@swc/css' - - bufferutil - - clean-css - - core-js - - csso - - debug - - devcert - - encoding - - lightningcss - - react - - react-dom - - rollup - - supports-color - - tslib - - typescript - - utf-8-validate - - webpack - - webpack-hot-middleware - '@modern-js/app-tools@3.0.1(@module-federation/runtime-tools@2.1.0)(@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@2.1.0)(@swc/helpers@0.5.18))(core-js@3.48.0)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.57.0)(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.4.0)(typescript@5.9.3))(tsconfig-paths@4.2.0)(tslib@2.8.1)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.25.5)(webpack-cli@5.1.4))': dependencies: '@babel/parser': 7.29.0 @@ -31949,57 +31895,6 @@ snapshots: - webpack - webpack-hot-middleware - '@modern-js/builder@3.0.1(@module-federation/runtime-tools@2.1.0)(@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@2.1.0)(@swc/helpers@0.5.18))(esbuild@0.25.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tslib@2.8.1)(typescript@5.0.4)(webpack-hot-middleware@2.26.1)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4))': - dependencies: - '@modern-js/flight-server-transform-plugin': 3.0.1 - '@modern-js/utils': 3.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@rsbuild/core': 2.0.0-beta.2(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0) - '@rsbuild/plugin-assets-retry': 1.5.1(@rsbuild/core@2.0.0-beta.2(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0)) - '@rsbuild/plugin-check-syntax': 1.6.1(@rsbuild/core@2.0.0-beta.2(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0)) - '@rsbuild/plugin-css-minimizer': 1.1.1(@rsbuild/core@2.0.0-beta.2(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0))(esbuild@0.25.5)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4)) - '@rsbuild/plugin-less': 1.6.0(@rsbuild/core@2.0.0-beta.2(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0)) - '@rsbuild/plugin-react': 1.4.4(@rsbuild/core@2.0.0-beta.2(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0))(webpack-hot-middleware@2.26.1) - '@rsbuild/plugin-rem': 1.0.5(@rsbuild/core@2.0.0-beta.2(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0)) - '@rsbuild/plugin-sass': 1.5.0(@rsbuild/core@2.0.0-beta.2(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0)) - '@rsbuild/plugin-source-build': 1.0.4(@rsbuild/core@2.0.0-beta.2(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0)) - '@rsbuild/plugin-svgr': 1.3.0(@rsbuild/core@2.0.0-beta.2(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0))(typescript@5.0.4)(webpack-hot-middleware@2.26.1) - '@rsbuild/plugin-type-check': 1.3.3(@rsbuild/core@2.0.0-beta.2(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0))(@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@2.1.0)(@swc/helpers@0.5.18))(tslib@2.8.1)(typescript@5.0.4) - '@rsbuild/plugin-typed-css-modules': 1.2.1(@rsbuild/core@2.0.0-beta.2(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0)) - '@swc/core': 1.15.10(@swc/helpers@0.5.18) - '@swc/helpers': 0.5.18 - autoprefixer: 10.4.24(postcss@8.5.6) - browserslist: 4.28.1 - core-js: 3.48.0 - cssnano: 6.1.2(postcss@8.5.6) - html-minifier-terser: 7.2.0 - lodash: 4.17.23 - postcss: 8.5.6 - postcss-custom-properties: 13.3.12(postcss@8.5.6) - postcss-flexbugs-fixes: 5.0.2(postcss@8.5.6) - postcss-font-variant: 5.0.0(postcss@8.5.6) - postcss-initial: 4.0.1(postcss@8.5.6) - postcss-media-minmax: 5.0.0(postcss@8.5.6) - postcss-nesting: 12.1.5(postcss@8.5.6) - postcss-page-break: 3.0.4(postcss@8.5.6) - rspack-manifest-plugin: 5.2.1(@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@2.1.0)(@swc/helpers@0.5.18)) - ts-deepmerge: 7.0.3 - transitivePeerDependencies: - - '@module-federation/runtime-tools' - - '@parcel/css' - - '@rspack/core' - - '@swc/css' - - clean-css - - csso - - esbuild - - lightningcss - - react - - react-dom - - supports-color - - tslib - - typescript - - webpack - - webpack-hot-middleware - '@modern-js/builder@3.0.1(@module-federation/runtime-tools@2.1.0)(@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@2.1.0)(@swc/helpers@0.5.18))(esbuild@0.25.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tslib@2.8.1)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.25.5)(webpack-cli@5.1.4))': dependencies: '@modern-js/flight-server-transform-plugin': 3.0.1 @@ -32405,15 +32300,6 @@ snapshots: react-dom: 18.3.1(react@18.3.1) react-server-dom-webpack: 19.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4(webpack-dev-server@5.2.3)(webpack@5.104.1))) - '@modern-js/render@3.0.1(react-dom@18.3.1(react@18.3.1))(react-server-dom-webpack@19.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4)))(react@18.3.1)': - dependencies: - '@modern-js/types': 3.0.1 - '@modern-js/utils': 3.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@swc/helpers': 0.5.18 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-server-dom-webpack: 19.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4)) - '@modern-js/rsbuild-plugin-esbuild@2.70.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(webpack-cli@5.1.4)': dependencies: '@swc/helpers': 0.5.18 @@ -32608,35 +32494,6 @@ snapshots: - core-js - react-server-dom-webpack - '@modern-js/runtime@3.0.1(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0)(react-dom@18.3.1(react@18.3.1))(react-server-dom-webpack@19.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4)))(react@18.3.1)': - dependencies: - '@loadable/component': 5.16.7(react@18.3.1) - '@loadable/server': 5.16.7(@loadable/component@5.16.7(react@18.3.1))(react@18.3.1) - '@modern-js/plugin': 3.0.1(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@modern-js/plugin-data-loader': 3.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@modern-js/render': 3.0.1(react-dom@18.3.1(react@18.3.1))(react-server-dom-webpack@19.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4)))(react@18.3.1) - '@modern-js/runtime-utils': 3.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@modern-js/types': 3.0.1 - '@modern-js/utils': 3.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@swc/helpers': 0.5.18 - '@swc/plugin-loadable-components': 11.5.0 - '@types/loadable__component': 5.13.10 - '@types/react-helmet': 6.1.11 - cookie: 0.7.2 - entities: 7.0.1 - es-module-lexer: 1.7.0 - esbuild: 0.25.5 - invariant: 2.2.4 - isbot: 3.8.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-helmet: 6.1.0(react@18.3.1) - react-is: 18.3.1 - transitivePeerDependencies: - - '@module-federation/runtime-tools' - - core-js - - react-server-dom-webpack - '@modern-js/server-core@2.70.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@modern-js/plugin': 2.70.2 @@ -37426,9 +37283,9 @@ snapshots: core-js: 3.47.0 jiti: 2.6.1 - '@rsbuild/core@2.0.0-beta.2(@module-federation/runtime-tools@0.23.0)(core-js@3.48.0)': + '@rsbuild/core@2.0.0-beta.2(@module-federation/runtime-tools@0.15.0)(core-js@3.48.0)': dependencies: - '@rspack/core': 2.0.0-beta.0(@module-federation/runtime-tools@0.23.0)(@swc/helpers@0.5.18) + '@rspack/core': 2.0.0-beta.0(@module-federation/runtime-tools@0.15.0)(@swc/helpers@0.5.18) '@swc/helpers': 0.5.18 jiti: 2.6.1 optionalDependencies: @@ -37629,21 +37486,6 @@ snapshots: - lightningcss - webpack - '@rsbuild/plugin-css-minimizer@1.1.1(@rsbuild/core@2.0.0-beta.2(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0))(esbuild@0.25.5)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4))': - dependencies: - css-minimizer-webpack-plugin: 7.0.2(esbuild@0.25.5)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4)) - reduce-configs: 1.1.1 - optionalDependencies: - '@rsbuild/core': 2.0.0-beta.2(@module-federation/runtime-tools@2.1.0)(core-js@3.48.0) - transitivePeerDependencies: - - '@parcel/css' - - '@swc/css' - - clean-css - - csso - - esbuild - - lightningcss - - webpack - '@rsbuild/plugin-less@1.5.0(@rsbuild/core@1.7.2)': dependencies: '@rsbuild/core': 1.7.2 @@ -38692,12 +38534,12 @@ snapshots: optionalDependencies: '@swc/helpers': 0.5.18 - '@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@0.23.0)(@swc/helpers@0.5.18)': + '@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@0.15.0)(@swc/helpers@0.5.18)': dependencies: '@rspack/binding': 2.0.0-beta.0 '@rspack/lite-tapable': 1.1.0 optionalDependencies: - '@module-federation/runtime-tools': 0.23.0 + '@module-federation/runtime-tools': 0.15.0 '@swc/helpers': 0.5.18 '@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@2.1.0)(@swc/helpers@0.5.18)': @@ -45056,18 +44898,6 @@ snapshots: optionalDependencies: esbuild: 0.25.5 - css-minimizer-webpack-plugin@7.0.2(esbuild@0.25.5)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4)): - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - cssnano: 7.1.2(postcss@8.4.49) - jest-worker: 29.7.0 - postcss: 8.4.49 - schema-utils: 4.3.3 - serialize-javascript: 6.0.2 - webpack: 5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4) - optionalDependencies: - esbuild: 0.25.5 - css-select@4.3.0: dependencies: boolbase: 1.0.0 @@ -51950,7 +51780,7 @@ snapshots: '@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) @@ -51976,6 +51806,37 @@ snapshots: - uglify-js - webpack-cli + 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.27.3)(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.30001769 + postcss: 8.4.31 + 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.27.3)(webpack-cli@5.1.4) + optionalDependencies: + '@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' + - babel-plugin-macros + - esbuild + - uglify-js + - webpack-cli + no-case@3.0.4: dependencies: lower-case: 2.0.2 @@ -55707,15 +55568,6 @@ snapshots: webpack: 5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4(webpack-dev-server@5.2.3)(webpack@5.104.1)) webpack-sources: 3.3.4 - react-server-dom-webpack@19.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4)): - dependencies: - acorn-loose: 8.5.2 - neo-async: 2.6.2 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - webpack: 5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.27.3)(webpack-cli@5.1.4) - webpack-sources: 3.3.4 - react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1(@swc/core@1.15.10(@swc/helpers@0.5.18))(esbuild@0.18.20)(webpack-cli@5.1.4)): dependencies: acorn-loose: 8.5.2 @@ -57709,6 +57561,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 From c38f21098d9ac2b8faa74886d39a74bb7e1e0be4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 11 Mar 2026 19:43:34 -0700 Subject: [PATCH 05/22] fix(nextjs-mf): preserve legacy plugin entrypoint --- packages/nextjs-mf/src/index.ts | 90 ++++++++++- packages/nextjs-mf/src/withNextFederation.ts | 161 ++++++++++--------- 2 files changed, 175 insertions(+), 76 deletions(-) diff --git a/packages/nextjs-mf/src/index.ts b/packages/nextjs-mf/src/index.ts index a0628c7c943..847c0e5c8c6 100644 --- a/packages/nextjs-mf/src/index.ts +++ b/packages/nextjs-mf/src/index.ts @@ -1,4 +1,89 @@ -import withNextFederation from './withNextFederation'; +import type { Compiler } from 'webpack'; +import type { moduleFederationPlugin } from '@module-federation/sdk'; +import { getWebpackPath } from '@module-federation/sdk/normalize-webpack-path'; +import withNextFederation, { + applyResolvedNextFederationConfig, +} from './withNextFederation'; +import { + assertLocalWebpackEnabled, + isNextBuildOrDevCommand, + normalizeNextFederationOptions, +} from './core/options'; +import type { + NextFederationCompilerContext, + NextFederationMode, + NextFederationOptionsV9, + ResolvedNextFederationOptions, +} from './types'; + +type LegacyExtraOptions = { + exposePages?: boolean; + skipSharingNextInternals?: boolean; + debug?: boolean; + automaticPageStitching?: boolean; + enableImageLoaderFix?: boolean; + enableUrlLoaderFix?: boolean; +}; + +type LegacyNextFederationPluginOptions = + moduleFederationPlugin.ModuleFederationPluginOptions & { + extraOptions?: LegacyExtraOptions; + }; + +function toLegacyCompatOptions( + input: LegacyNextFederationPluginOptions, +): NextFederationOptionsV9 { + const { extraOptions, ...federation } = input; + + return { + ...federation, + mode: 'pages', + pages: { + exposePages: extraOptions?.exposePages ?? false, + }, + sharing: { + includeNextInternals: !extraOptions?.skipSharingNextInternals, + }, + diagnostics: extraOptions?.debug ? { level: 'debug' } : undefined, + }; +} + +export class NextFederationPlugin { + private readonly resolved: ResolvedNextFederationOptions; + + public readonly name = 'ModuleFederationPlugin'; + + constructor(private readonly options: LegacyNextFederationPluginOptions) { + this.resolved = normalizeNextFederationOptions( + toLegacyCompatOptions(options), + ); + } + + apply(compiler: Compiler): void { + if (isNextBuildOrDevCommand() && this.resolved.mode !== 'app') { + assertLocalWebpackEnabled(); + } + + if (!process.env['FEDERATION_WEBPACK_PATH']) { + process.env['FEDERATION_WEBPACK_PATH'] = getWebpackPath(compiler, { + framework: 'nextjs', + }); + } + + applyResolvedNextFederationConfig( + compiler.options, + { + dir: compiler.context, + isServer: compiler.options.name === 'server', + nextRuntime: + compiler.options.name === 'edge-server' ? 'edge' : 'nodejs', + webpack: compiler.webpack, + }, + this.resolved, + false, + ); + } +} export type { NextFederationCompilerContext, @@ -9,5 +94,6 @@ export type { export { withNextFederation }; export default withNextFederation; -module.exports = withNextFederation; +module.exports = NextFederationPlugin; +module.exports.NextFederationPlugin = NextFederationPlugin; module.exports.withNextFederation = withNextFederation; diff --git a/packages/nextjs-mf/src/withNextFederation.ts b/packages/nextjs-mf/src/withNextFederation.ts index 1f748b5c9e0..9171788d5f7 100644 --- a/packages/nextjs-mf/src/withNextFederation.ts +++ b/packages/nextjs-mf/src/withNextFederation.ts @@ -447,6 +447,87 @@ function normalizeExposes( return exposes as Record; } +export function applyResolvedNextFederationConfig( + userConfig: Configuration, + context: NextWebpackContext, + resolved: ResolvedNextFederationOptions, + hasValidatedAppExposes: boolean, +): { config: Configuration; hasValidatedAppExposes: boolean } { + normalizeOutputPath(userConfig); + + const compilerName = inferCompilerName(userConfig, context); + + if (compilerName === 'edge-server' || context.nextRuntime === 'edge') { + // v9 intentionally skips federation in edge compiler. + return { config: userConfig, hasValidatedAppExposes }; + } + + 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 { config: userConfig, hasValidatedAppExposes }; +} + export function withNextFederation( nextConfig: NextConfig, federationOptions: NextFederationOptionsV9, @@ -471,82 +552,14 @@ export function withNextFederation( 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( + const applied = applyResolvedNextFederationConfig( + userConfig, + context, resolved, - isServer, - resolved.federation.shared, + hasValidatedAppExposes, ); - 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; + hasValidatedAppExposes = applied.hasValidatedAppExposes; + return applied.config; }, }; } From e8352f1479ef6c4af8552c84afbf2658ac4a9503 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 11 Mar 2026 19:47:45 -0700 Subject: [PATCH 06/22] fix(sdk): resolve next compiled webpack internals --- packages/sdk/src/normalize-webpack-path.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/normalize-webpack-path.ts b/packages/sdk/src/normalize-webpack-path.ts index 866dbfd0d53..fd5813f79e6 100644 --- a/packages/sdk/src/normalize-webpack-path.ts +++ b/packages/sdk/src/normalize-webpack-path.ts @@ -50,7 +50,10 @@ export const normalizeWebpackPath = (fullPath: string): string => { return federationWebpackPath; } - return fullPath; + return path.resolve( + path.dirname(federationWebpackPath), + fullPath.replace(/^webpack\//, ''), + ); } if (fullPath === 'webpack') { From ad4539020f5af9b1ded05d2afd3a95feec2c4930 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 11 Mar 2026 19:47:45 -0700 Subject: [PATCH 07/22] fix(sdk): resolve next compiled webpack internals --- packages/sdk/src/normalize-webpack-path.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/normalize-webpack-path.ts b/packages/sdk/src/normalize-webpack-path.ts index 866dbfd0d53..fd5813f79e6 100644 --- a/packages/sdk/src/normalize-webpack-path.ts +++ b/packages/sdk/src/normalize-webpack-path.ts @@ -50,7 +50,10 @@ export const normalizeWebpackPath = (fullPath: string): string => { return federationWebpackPath; } - return fullPath; + return path.resolve( + path.dirname(federationWebpackPath), + fullPath.replace(/^webpack\//, ''), + ); } if (fullPath === 'webpack') { From d1d7790d734648a33a51ca1390c0f01cc1a89199 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 11 Mar 2026 20:07:00 -0700 Subject: [PATCH 08/22] fix(nextjs-mf): preserve pages map loader build entry --- packages/nextjs-mf/tsdown.config.mts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/nextjs-mf/tsdown.config.mts b/packages/nextjs-mf/tsdown.config.mts index 63c9b32d182..d13ddccc3c4 100644 --- a/packages/nextjs-mf/tsdown.config.mts +++ b/packages/nextjs-mf/tsdown.config.mts @@ -14,6 +14,8 @@ export default defineConfig([ entry: { 'src/index': 'src/index.ts', node: 'node.ts', + 'src/core/features/pages-map-loader': + 'src/core/features/pages-map-loader.ts', 'src/core/loaders/fixNextImageLoader': 'src/core/loaders/fixNextImageLoader.ts', 'src/core/loaders/fixUrlLoader': 'src/core/loaders/fixUrlLoader.ts', From 348d071b2c4a20e339638298bf882ab474263c08 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 11 Mar 2026 20:29:28 -0700 Subject: [PATCH 09/22] fix(node): restore stable next dev revalidation --- packages/node/src/utils/flush-chunks.ts | 10 +- packages/node/src/utils/hot-reload.test.ts | 66 ----------- packages/node/src/utils/hot-reload.ts | 127 +++++++++------------ 3 files changed, 61 insertions(+), 142 deletions(-) delete mode 100644 packages/node/src/utils/hot-reload.test.ts diff --git a/packages/node/src/utils/flush-chunks.ts b/packages/node/src/utils/flush-chunks.ts index 98122d24f17..49729e17dc6 100644 --- a/packages/node/src/utils/flush-chunks.ts +++ b/packages/node/src/utils/flush-chunks.ts @@ -100,7 +100,15 @@ const processChunk = async (chunk, shareMap, hostStats) => { const normalizedChunk = chunk.includes('->') ? chunk.replace('->', '/') : chunk; - const [remote, req] = normalizedChunk.split('/'); + const remoteSeparatorIndex = normalizedChunk.indexOf('/'); + const remote = + remoteSeparatorIndex === -1 + ? normalizedChunk + : normalizedChunk.slice(0, remoteSeparatorIndex); + const req = + remoteSeparatorIndex === -1 + ? '' + : normalizedChunk.slice(remoteSeparatorIndex + 1); const request = req?.startsWith('./') ? req : './' + req; const knownRemotes = getAllKnownRemotes(); //@ts-ignore diff --git a/packages/node/src/utils/hot-reload.test.ts b/packages/node/src/utils/hot-reload.test.ts deleted file mode 100644 index 9e53ea500a5..00000000000 --- a/packages/node/src/utils/hot-reload.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -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 c4fd40c712a..1a259b5a95a 100644 --- a/packages/node/src/utils/hot-reload.ts +++ b/packages/node/src/utils/hot-reload.ts @@ -8,14 +8,6 @@ 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' @@ -23,16 +15,6 @@ 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 { @@ -145,6 +127,9 @@ const searchCache = function ( globalThis.moduleGraphDirty = false; +const hashmap = globalThis.mfHashMap || ({} as Record); +globalThis.moduleGraphDirty = false; + const requireCacheRegex = /(remote|server|hot-reload|react-loadable-manifest|runtime|styled-jsx)/; @@ -210,60 +195,50 @@ export const checkUnreachableRemote = (remoteScope: any): boolean => { return false; }; -export const checkMedusaConfigChange = async ( +export const checkMedusaConfigChange = ( remoteScope: any, fetchModule: any, -): Promise => { +): boolean => { //@ts-ignore if (remoteScope._medusa) { //@ts-ignore for (const property in remoteScope._medusa) { - 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); - } + 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; + } + }); } } return false; }; -export const checkFakeRemote = async (remoteScope: any): Promise => { - if (!remoteScope || !remoteScope._config) { - return false; - } - +export const checkFakeRemote = (remoteScope: any): boolean => { for (const property in remoteScope._config) { let remote = remoteScope._config[property]; + const resolveRemote = async () => { + remote = await remote(); + }; + if (typeof remote === 'function') { - try { - remote = await remote(); - } catch (e) { - console.error('Unable to resolve fake remote config for', property, e); - } + resolveRemote(); } - if (remote?.fake) { - logHotReloadInfo( - 'fake remote found', - property, - 'hot reloading to refetch', - ); + if (remote.fake) { + console.log('fake remote found', property, 'hot reloading to refetch'); return true; } } @@ -300,23 +275,18 @@ 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; - if (typeof url !== 'string' || !url) { - continue; - } - + const url = container.entry; const fetcher = createFetcher(url, fetchModule, name, (hash) => { if (hashmap[name]) { if (hashmap[name] !== hash) { hashmap[name] = hash; needReload = true; - logHotReloadInfo(name, 'hash is different - must hot reload server'); + console.log(name, 'hash is different - must hot reload server'); } } else { hashmap[name] = hash; @@ -334,25 +304,32 @@ export const revalidate = async ( fetchModule: any = getFetchModule() || (() => {}), force: boolean = false, ): Promise => { - const hashmap = getHashMap(); if (globalThis.moduleGraphDirty) { force = true; } const remotesFromAPI = getAllKnownRemotes(); - if (force && Object.keys(hashmap).length !== 0) { - return performReload(true); - } - - if (await checkMedusaConfigChange(remotesFromAPI, fetchModule)) { - return performReload(true); - } + //@ts-ignore + return new Promise((res) => { + if (force) { + if (Object.keys(hashmap).length !== 0) { + res(true); + return; + } + } + if (checkMedusaConfigChange(remotesFromAPI, fetchModule)) { + res(true); + } - if (await checkFakeRemote(remotesFromAPI)) { - return performReload(true); - } + if (checkFakeRemote(remotesFromAPI)) { + res(true); + } - const shouldReload = await fetchRemote(remotesFromAPI, fetchModule); - return performReload(shouldReload); + fetchRemote(remotesFromAPI, fetchModule).then((val) => { + res(val); + }); + }).then((shouldReload: unknown) => { + return performReload(shouldReload as boolean); + }); }; export function getFetchModule(): any { From 92d03b54ce4d5687c7ef736f3cb1d78319d91cee Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 11 Mar 2026 20:29:28 -0700 Subject: [PATCH 10/22] fix(node): restore stable next dev revalidation --- packages/node/src/utils/flush-chunks.ts | 10 +- packages/node/src/utils/hot-reload.test.ts | 66 ----------- packages/node/src/utils/hot-reload.ts | 127 +++++++++------------ 3 files changed, 61 insertions(+), 142 deletions(-) delete mode 100644 packages/node/src/utils/hot-reload.test.ts diff --git a/packages/node/src/utils/flush-chunks.ts b/packages/node/src/utils/flush-chunks.ts index 98122d24f17..49729e17dc6 100644 --- a/packages/node/src/utils/flush-chunks.ts +++ b/packages/node/src/utils/flush-chunks.ts @@ -100,7 +100,15 @@ const processChunk = async (chunk, shareMap, hostStats) => { const normalizedChunk = chunk.includes('->') ? chunk.replace('->', '/') : chunk; - const [remote, req] = normalizedChunk.split('/'); + const remoteSeparatorIndex = normalizedChunk.indexOf('/'); + const remote = + remoteSeparatorIndex === -1 + ? normalizedChunk + : normalizedChunk.slice(0, remoteSeparatorIndex); + const req = + remoteSeparatorIndex === -1 + ? '' + : normalizedChunk.slice(remoteSeparatorIndex + 1); const request = req?.startsWith('./') ? req : './' + req; const knownRemotes = getAllKnownRemotes(); //@ts-ignore diff --git a/packages/node/src/utils/hot-reload.test.ts b/packages/node/src/utils/hot-reload.test.ts deleted file mode 100644 index 9e53ea500a5..00000000000 --- a/packages/node/src/utils/hot-reload.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -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 c4fd40c712a..1a259b5a95a 100644 --- a/packages/node/src/utils/hot-reload.ts +++ b/packages/node/src/utils/hot-reload.ts @@ -8,14 +8,6 @@ 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' @@ -23,16 +15,6 @@ 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 { @@ -145,6 +127,9 @@ const searchCache = function ( globalThis.moduleGraphDirty = false; +const hashmap = globalThis.mfHashMap || ({} as Record); +globalThis.moduleGraphDirty = false; + const requireCacheRegex = /(remote|server|hot-reload|react-loadable-manifest|runtime|styled-jsx)/; @@ -210,60 +195,50 @@ export const checkUnreachableRemote = (remoteScope: any): boolean => { return false; }; -export const checkMedusaConfigChange = async ( +export const checkMedusaConfigChange = ( remoteScope: any, fetchModule: any, -): Promise => { +): boolean => { //@ts-ignore if (remoteScope._medusa) { //@ts-ignore for (const property in remoteScope._medusa) { - 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); - } + 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; + } + }); } } return false; }; -export const checkFakeRemote = async (remoteScope: any): Promise => { - if (!remoteScope || !remoteScope._config) { - return false; - } - +export const checkFakeRemote = (remoteScope: any): boolean => { for (const property in remoteScope._config) { let remote = remoteScope._config[property]; + const resolveRemote = async () => { + remote = await remote(); + }; + if (typeof remote === 'function') { - try { - remote = await remote(); - } catch (e) { - console.error('Unable to resolve fake remote config for', property, e); - } + resolveRemote(); } - if (remote?.fake) { - logHotReloadInfo( - 'fake remote found', - property, - 'hot reloading to refetch', - ); + if (remote.fake) { + console.log('fake remote found', property, 'hot reloading to refetch'); return true; } } @@ -300,23 +275,18 @@ 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; - if (typeof url !== 'string' || !url) { - continue; - } - + const url = container.entry; const fetcher = createFetcher(url, fetchModule, name, (hash) => { if (hashmap[name]) { if (hashmap[name] !== hash) { hashmap[name] = hash; needReload = true; - logHotReloadInfo(name, 'hash is different - must hot reload server'); + console.log(name, 'hash is different - must hot reload server'); } } else { hashmap[name] = hash; @@ -334,25 +304,32 @@ export const revalidate = async ( fetchModule: any = getFetchModule() || (() => {}), force: boolean = false, ): Promise => { - const hashmap = getHashMap(); if (globalThis.moduleGraphDirty) { force = true; } const remotesFromAPI = getAllKnownRemotes(); - if (force && Object.keys(hashmap).length !== 0) { - return performReload(true); - } - - if (await checkMedusaConfigChange(remotesFromAPI, fetchModule)) { - return performReload(true); - } + //@ts-ignore + return new Promise((res) => { + if (force) { + if (Object.keys(hashmap).length !== 0) { + res(true); + return; + } + } + if (checkMedusaConfigChange(remotesFromAPI, fetchModule)) { + res(true); + } - if (await checkFakeRemote(remotesFromAPI)) { - return performReload(true); - } + if (checkFakeRemote(remotesFromAPI)) { + res(true); + } - const shouldReload = await fetchRemote(remotesFromAPI, fetchModule); - return performReload(shouldReload); + fetchRemote(remotesFromAPI, fetchModule).then((val) => { + res(val); + }); + }).then((shouldReload: unknown) => { + return performReload(shouldReload as boolean); + }); }; export function getFetchModule(): any { From 9f6b16b3932387316ec06386004476e5f92ceed8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 11 Mar 2026 22:19:51 -0700 Subject: [PATCH 11/22] fix(sdk): prefer local webpack in next mode --- packages/sdk/src/normalize-webpack-path.ts | 68 ++++++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/normalize-webpack-path.ts b/packages/sdk/src/normalize-webpack-path.ts index fd5813f79e6..c6bb4c29958 100644 --- a/packages/sdk/src/normalize-webpack-path.ts +++ b/packages/sdk/src/normalize-webpack-path.ts @@ -1,6 +1,41 @@ import type webpack from 'webpack'; +import fs from 'fs'; import path from 'path'; +function resolveWebpackFromRoot(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 resolveWebpackFromCurrentProject(): string { + let currentRoot = path.resolve(process.cwd()); + + while (true) { + const webpackPath = resolveWebpackFromRoot(currentRoot); + if (webpackPath) { + return webpackPath; + } + + const parentRoot = path.dirname(currentRoot); + if (parentRoot === currentRoot) { + return ''; + } + currentRoot = parentRoot; + } +} + export function getWebpackPath( compiler: webpack.Compiler, options: { framework: 'nextjs' | 'other' } = { framework: 'other' }, @@ -11,6 +46,13 @@ export function getWebpackPath( 'return typeof require === "undefined" ? "" : require.resolve(id, options)', ) as (id: string, options?: { paths?: string[] }) => string; + if ( + options?.framework === 'nextjs' && + process.env['NEXT_PRIVATE_LOCAL_WEBPACK'] + ) { + return resolveWebpackFromCurrentProject() || 'webpack'; + } + try { // @ts-ignore just throw err compiler.webpack(); @@ -38,6 +80,27 @@ export function getWebpackPath( export const normalizeWebpackPath = (fullPath: string): string => { const federationWebpackPath = process.env['FEDERATION_WEBPACK_PATH']; + const useNextLocalWebpack = Boolean( + process.env['NEXT_PRIVATE_LOCAL_WEBPACK'], + ); + const localWebpackPath = useNextLocalWebpack + ? resolveWebpackFromCurrentProject() + : federationWebpackPath || ''; + + if (useNextLocalWebpack) { + if (fullPath === 'webpack') { + return localWebpackPath || fullPath; + } + + if (localWebpackPath) { + return path.resolve( + localWebpackPath, + fullPath.replace('webpack', '../../'), + ); + } + + return fullPath; + } // 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 @@ -50,10 +113,7 @@ export const normalizeWebpackPath = (fullPath: string): string => { return federationWebpackPath; } - return path.resolve( - path.dirname(federationWebpackPath), - fullPath.replace(/^webpack\//, ''), - ); + return fullPath; } if (fullPath === 'webpack') { From 0faad029871efe02ffb56449d24f5796db03d3e5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 11 Mar 2026 22:19:51 -0700 Subject: [PATCH 12/22] fix(sdk): prefer local webpack in next mode --- packages/sdk/src/normalize-webpack-path.ts | 68 ++++++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/normalize-webpack-path.ts b/packages/sdk/src/normalize-webpack-path.ts index fd5813f79e6..c6bb4c29958 100644 --- a/packages/sdk/src/normalize-webpack-path.ts +++ b/packages/sdk/src/normalize-webpack-path.ts @@ -1,6 +1,41 @@ import type webpack from 'webpack'; +import fs from 'fs'; import path from 'path'; +function resolveWebpackFromRoot(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 resolveWebpackFromCurrentProject(): string { + let currentRoot = path.resolve(process.cwd()); + + while (true) { + const webpackPath = resolveWebpackFromRoot(currentRoot); + if (webpackPath) { + return webpackPath; + } + + const parentRoot = path.dirname(currentRoot); + if (parentRoot === currentRoot) { + return ''; + } + currentRoot = parentRoot; + } +} + export function getWebpackPath( compiler: webpack.Compiler, options: { framework: 'nextjs' | 'other' } = { framework: 'other' }, @@ -11,6 +46,13 @@ export function getWebpackPath( 'return typeof require === "undefined" ? "" : require.resolve(id, options)', ) as (id: string, options?: { paths?: string[] }) => string; + if ( + options?.framework === 'nextjs' && + process.env['NEXT_PRIVATE_LOCAL_WEBPACK'] + ) { + return resolveWebpackFromCurrentProject() || 'webpack'; + } + try { // @ts-ignore just throw err compiler.webpack(); @@ -38,6 +80,27 @@ export function getWebpackPath( export const normalizeWebpackPath = (fullPath: string): string => { const federationWebpackPath = process.env['FEDERATION_WEBPACK_PATH']; + const useNextLocalWebpack = Boolean( + process.env['NEXT_PRIVATE_LOCAL_WEBPACK'], + ); + const localWebpackPath = useNextLocalWebpack + ? resolveWebpackFromCurrentProject() + : federationWebpackPath || ''; + + if (useNextLocalWebpack) { + if (fullPath === 'webpack') { + return localWebpackPath || fullPath; + } + + if (localWebpackPath) { + return path.resolve( + localWebpackPath, + fullPath.replace('webpack', '../../'), + ); + } + + return fullPath; + } // 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 @@ -50,10 +113,7 @@ export const normalizeWebpackPath = (fullPath: string): string => { return federationWebpackPath; } - return path.resolve( - path.dirname(federationWebpackPath), - fullPath.replace(/^webpack\//, ''), - ); + return fullPath; } if (fullPath === 'webpack') { From d766048b06007a4e3fe12d14405ce409c62bc93b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 12 Mar 2026 00:32:25 -0700 Subject: [PATCH 13/22] chore(gitignore): ignore codex plan artifacts --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 14967688977..936494b0b48 100644 --- a/.gitignore +++ b/.gitignore @@ -99,6 +99,9 @@ packages/enhanced/.codex/** packages/enhanced/generated/** **/.codex/ !/.codex/ +.codex/plan-graph/ +.codex/plan-graphs/ +.codex/plans/ !/.codex/skills/ !/.codex/skills/turborepo/ !/.codex/skills/turborepo/** From 6bf4c7e94e362e7fd1697e55e74d09b6135791c5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 12 Mar 2026 11:32:08 -0700 Subject: [PATCH 14/22] fix(enhanced): rollback webpack compat helper --- .../src/lib/container/AsyncBoundaryPlugin.ts | 203 +++++++++--------- .../lib/container/ModuleFederationPlugin.ts | 16 -- .../runtime/ChildCompilationRuntimePlugin.ts | 7 +- .../runtime/EmbedFederationRuntimePlugin.ts | 12 +- .../tree-shaking/IndependentSharedPlugin.ts | 3 +- .../SharedUsedExportsOptimizerPlugin.ts | 3 +- .../MfStartupChunkDependenciesPlugin.ts | 12 +- .../src/lib/startup/StartupHelpers.ts | 12 +- packages/enhanced/src/lib/webpackCompat.ts | 44 ---- packages/sdk/src/normalize-webpack-path.ts | 14 -- 10 files changed, 118 insertions(+), 208 deletions(-) delete mode 100644 packages/enhanced/src/lib/webpackCompat.ts diff --git a/packages/enhanced/src/lib/container/AsyncBoundaryPlugin.ts b/packages/enhanced/src/lib/container/AsyncBoundaryPlugin.ts index a5910c3c7d3..d04f426aa11 100644 --- a/packages/enhanced/src/lib/container/AsyncBoundaryPlugin.ts +++ b/packages/enhanced/src/lib/container/AsyncBoundaryPlugin.ts @@ -4,10 +4,6 @@ */ import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-path'; import { moduleFederationPlugin } from '@module-federation/sdk'; -import { - getJavascriptModulesPlugin, - getWebpackSources, -} from '../webpackCompat'; import type { Compiler, Compilation, @@ -87,122 +83,119 @@ class AsyncEntryStartupPlugin { } private _handleRenderStartup(compiler: Compiler, compilation: Compilation) { - 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; - } + 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; } + } - if ( - this._options.excludeChunk && - this._options.excludeChunk(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; } - const runtime = this._getChunkRuntime(upperContext); + const requirements = + compilation.chunkGraph.getTreeRuntimeRequirements(runtimeItem); - let remotes = ''; - let shared = ''; + const entryOptions = upperContext.chunk.getEntryOptions(); + const chunkInitialsSet = new Set( + compilation.chunkGraph.getChunkEntryDependentChunksIterable( + upperContext.chunk, + ), + ); - for (const runtimeItem of runtime) { - if (!runtimeItem) { - continue; - } + chunkInitialsSet.add(upperContext.chunk); + const dependOn = entryOptions?.dependOn || []; + this.getChunkByName(compilation, dependOn, chunkInitialsSet); - 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; - } - } + const initialChunks = []; - remotes = this._getRemotes( - compiler.webpack.RuntimeGlobals, - requirements, - hasRemoteModules, - initialChunks, - remotes, - ); - - shared = this._getShared( - compiler.webpack.RuntimeGlobals, - requirements, - consumeShares, - initialChunks, - shared, - ); - } + let hasRemoteModules = false; + let consumeShares = false; - if (!remotes && !shared) { - return source; + 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; + } } - const initialEntryModules = this._getInitialEntryModules( - compilation, - upperContext, + remotes = this._getRemotes( + compiler.webpack.RuntimeGlobals, + requirements, + hasRemoteModules, + initialChunks, + remotes, ); - const templateString = this._getTemplateString( - compiler, - initialEntryModules, + + shared = this._getShared( + compiler.webpack.RuntimeGlobals, + requirements, + consumeShares, + initialChunks, shared, - remotes, - source, ); + } - const webpackSources = getWebpackSources(compiler); - return new webpackSources.ConcatSource(templateString); - }, - ); + if (!remotes && !shared) { + return source; + } + + const initialEntryModules = this._getInitialEntryModules( + compilation, + upperContext, + ); + const templateString = this._getTemplateString( + compiler, + initialEntryModules, + shared, + remotes, + source, + ); + + const { ConcatSource } = compiler.webpack.sources; + return new 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 93e5f68dbaf..254fd868d52 100644 --- a/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts +++ b/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts @@ -137,22 +137,6 @@ 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) { diff --git a/packages/enhanced/src/lib/container/runtime/ChildCompilationRuntimePlugin.ts b/packages/enhanced/src/lib/container/runtime/ChildCompilationRuntimePlugin.ts index b52a1604d06..bdc8dfa1e55 100644 --- a/packages/enhanced/src/lib/container/runtime/ChildCompilationRuntimePlugin.ts +++ b/packages/enhanced/src/lib/container/runtime/ChildCompilationRuntimePlugin.ts @@ -9,10 +9,8 @@ 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'; import { transformSync } from '@swc/core'; import { infrastructureLogger as logger } from '@module-federation/sdk'; @@ -46,7 +44,9 @@ class RuntimeModuleChunkPlugin { ); const hooks = - getJavascriptModulesPlugin(compiler).getCompilationHooks(compilation); + compiler.webpack.javascript.JavascriptModulesPlugin.getCompilationHooks( + compilation, + ); hooks.renderChunk.tap( 'ModuleChunkFormatPlugin', @@ -56,6 +56,7 @@ class RuntimeModuleChunkPlugin { ) => { const { chunk, chunkGraph } = renderContext; + const { ConcatSource } = compiler.webpack.sources; const source = new ConcatSource(); source.add('var federation = '); source.add(modules); diff --git a/packages/enhanced/src/lib/container/runtime/EmbedFederationRuntimePlugin.ts b/packages/enhanced/src/lib/container/runtime/EmbedFederationRuntimePlugin.ts index 3c70e1b0710..0f974f671fb 100644 --- a/packages/enhanced/src/lib/container/runtime/EmbedFederationRuntimePlugin.ts +++ b/packages/enhanced/src/lib/container/runtime/EmbedFederationRuntimePlugin.ts @@ -5,10 +5,6 @@ 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'), @@ -73,7 +69,9 @@ class EmbedFederationRuntimePlugin { (compilation: Compilation) => { // --- Part 1: Modify renderStartup to append a startup call when none is added automatically --- const { renderStartup } = - getJavascriptModulesPlugin(compiler).getCompilationHooks(compilation); + compiler.webpack.javascript.JavascriptModulesPlugin.getCompilationHooks( + compilation, + ); renderStartup.tap( PLUGIN_NAME, @@ -99,8 +97,8 @@ class EmbedFederationRuntimePlugin { } // Otherwise, append a startup call. - const webpackSources = getWebpackSources(compiler); - return new webpackSources.ConcatSource( + const { ConcatSource } = compiler.webpack.sources; + return new ConcatSource( startupSource, '\n// Custom hook: appended startup call because none was added automatically\n', `${RuntimeGlobals.startup}();\n`, diff --git a/packages/enhanced/src/lib/sharing/tree-shaking/IndependentSharedPlugin.ts b/packages/enhanced/src/lib/sharing/tree-shaking/IndependentSharedPlugin.ts index 6f903dddee1..0a3b2cc6e9e 100644 --- a/packages/enhanced/src/lib/sharing/tree-shaking/IndependentSharedPlugin.ts +++ b/packages/enhanced/src/lib/sharing/tree-shaking/IndependentSharedPlugin.ts @@ -24,7 +24,6 @@ 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'; @@ -206,7 +205,7 @@ export default class IndependentSharedPlugin { compilation.updateAsset( StatsFileName, - new (getWebpackSources(compiler).RawSource)( + new compiler.webpack.sources.RawSource( JSON.stringify(statsContent), ), ); diff --git a/packages/enhanced/src/lib/sharing/tree-shaking/SharedUsedExportsOptimizerPlugin.ts b/packages/enhanced/src/lib/sharing/tree-shaking/SharedUsedExportsOptimizerPlugin.ts index 62681a3509c..61a3b92644c 100644 --- a/packages/enhanced/src/lib/sharing/tree-shaking/SharedUsedExportsOptimizerPlugin.ts +++ b/packages/enhanced/src/lib/sharing/tree-shaking/SharedUsedExportsOptimizerPlugin.ts @@ -16,7 +16,6 @@ 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[] }; @@ -314,7 +313,7 @@ export default class SharedUsedExportsOptimizerPlugin implements WebpackPluginIn compilation.updateAsset( statsFileName, - new (getWebpackSources(compiler).RawSource)( + new compiler.webpack.sources.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 21e7278112e..af52dc9a700 100644 --- a/packages/enhanced/src/lib/startup/MfStartupChunkDependenciesPlugin.ts +++ b/packages/enhanced/src/lib/startup/MfStartupChunkDependenciesPlugin.ts @@ -6,10 +6,6 @@ import { generateEntryStartup, generateESMEntryStartup, } from './StartupHelpers'; -import { - getJavascriptModulesPlugin, - getWebpackSources, -} from '../webpackCompat'; import type { Compiler, Chunk } from 'webpack'; import ContainerEntryModule from '../container/ContainerEntryModule'; @@ -88,7 +84,9 @@ class StartupChunkDependenciesPlugin { // Replace the generated startup with a custom version if entry modules exist. const { renderStartup } = - getJavascriptModulesPlugin(compiler).getCompilationHooks(compilation); + compiler.webpack.javascript.JavascriptModulesPlugin.getCompilationHooks( + compilation, + ); renderStartup.tap( 'MfStartupChunkDependenciesPlugin', @@ -124,8 +122,8 @@ class StartupChunkDependenciesPlugin { ? generateESMEntryStartup : generateEntryStartup; - const webpackSources = getWebpackSources(compiler); - return new webpackSources.ConcatSource( + const { ConcatSource } = compiler.webpack.sources; + return new ConcatSource( entryGeneration( compilation, chunkGraph, diff --git a/packages/enhanced/src/lib/startup/StartupHelpers.ts b/packages/enhanced/src/lib/startup/StartupHelpers.ts index 0ae3f0285a8..abe51000232 100644 --- a/packages/enhanced/src/lib/startup/StartupHelpers.ts +++ b/packages/enhanced/src/lib/startup/StartupHelpers.ts @@ -9,10 +9,6 @@ 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'), @@ -164,10 +160,10 @@ export const generateESMEntryStartup = ( chunk: Chunk, passive: boolean, ): string => { - const { chunkHasJs, getChunkFilenameTemplate } = getJavascriptModulesPlugin( - compilation.compiler, - ); - const { ConcatSource } = getWebpackSources(compilation.compiler); + const { chunkHasJs, getChunkFilenameTemplate } = + compilation.compiler.webpack?.javascript?.JavascriptModulesPlugin || + compilation.compiler.webpack.JavascriptModulesPlugin; + const { ConcatSource } = compilation.compiler.webpack.sources; 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 deleted file mode 100644 index d93af7fb9b6..00000000000 --- a/packages/enhanced/src/lib/webpackCompat.ts +++ /dev/null @@ -1,44 +0,0 @@ -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/sdk/src/normalize-webpack-path.ts b/packages/sdk/src/normalize-webpack-path.ts index c6bb4c29958..e81fe01b196 100644 --- a/packages/sdk/src/normalize-webpack-path.ts +++ b/packages/sdk/src/normalize-webpack-path.ts @@ -102,20 +102,6 @@ export const normalizeWebpackPath = (fullPath: string): string => { return fullPath; } - // 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 federationWebpackPath || fullPath; } From c84aa5a97e60cf8d67b4085e8b6e727c7ae6df4d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 12 Mar 2026 11:41:31 -0700 Subject: [PATCH 15/22] chore(enhanced): drop nonessential compat churn --- .gitignore | 3 - .../src/lib/container/AsyncBoundaryPlugin.ts | 3 +- .../lib/container/ModuleFederationPlugin.ts | 6 +- .../runtime/ChildCompilationRuntimePlugin.ts | 2 +- .../runtime/EmbedFederationRuntimePlugin.ts | 3 +- .../runtime/FederationRuntimePlugin.ts | 6 +- .../tree-shaking/IndependentSharedPlugin.ts | 85 +++++++------------ .../MfStartupChunkDependenciesPlugin.ts | 3 +- .../enhanced/test/ConfigTestCases.rstest.ts | 1 + 9 files changed, 36 insertions(+), 76 deletions(-) diff --git a/.gitignore b/.gitignore index 936494b0b48..14967688977 100644 --- a/.gitignore +++ b/.gitignore @@ -99,9 +99,6 @@ packages/enhanced/.codex/** packages/enhanced/generated/** **/.codex/ !/.codex/ -.codex/plan-graph/ -.codex/plan-graphs/ -.codex/plans/ !/.codex/skills/ !/.codex/skills/turborepo/ !/.codex/skills/turborepo/** diff --git a/packages/enhanced/src/lib/container/AsyncBoundaryPlugin.ts b/packages/enhanced/src/lib/container/AsyncBoundaryPlugin.ts index d04f426aa11..cd0b61399f0 100644 --- a/packages/enhanced/src/lib/container/AsyncBoundaryPlugin.ts +++ b/packages/enhanced/src/lib/container/AsyncBoundaryPlugin.ts @@ -192,8 +192,7 @@ class AsyncEntryStartupPlugin { source, ); - const { ConcatSource } = compiler.webpack.sources; - return new ConcatSource(templateString); + return new compiler.webpack.sources.ConcatSource(templateString); }, ); } diff --git a/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts b/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts index 254fd868d52..f308f576e25 100644 --- a/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts +++ b/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts @@ -4,7 +4,7 @@ */ 'use strict'; -import type { DtsPlugin as DtsPluginType } from '@module-federation/dts-plugin'; +import { DtsPlugin } from '@module-federation/dts-plugin'; import { ContainerManager, utils } from '@module-federation/managers'; import { StatsPlugin } from '@module-federation/manifest'; import { @@ -182,10 +182,6 @@ 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 bdc8dfa1e55..acca3b52739 100644 --- a/packages/enhanced/src/lib/container/runtime/ChildCompilationRuntimePlugin.ts +++ b/packages/enhanced/src/lib/container/runtime/ChildCompilationRuntimePlugin.ts @@ -11,6 +11,7 @@ import type { Compiler, Compilation, Chunk, Module, ChunkGraph } from 'webpack'; import { getFederationGlobalScope } from './utils'; import fs from 'fs'; import path from 'path'; +import { ConcatSource } from 'webpack-sources'; import { transformSync } from '@swc/core'; import { infrastructureLogger as logger } from '@module-federation/sdk'; @@ -56,7 +57,6 @@ class RuntimeModuleChunkPlugin { ) => { const { chunk, chunkGraph } = renderContext; - const { ConcatSource } = compiler.webpack.sources; const source = new ConcatSource(); source.add('var federation = '); source.add(modules); diff --git a/packages/enhanced/src/lib/container/runtime/EmbedFederationRuntimePlugin.ts b/packages/enhanced/src/lib/container/runtime/EmbedFederationRuntimePlugin.ts index 0f974f671fb..c1fe93ee994 100644 --- a/packages/enhanced/src/lib/container/runtime/EmbedFederationRuntimePlugin.ts +++ b/packages/enhanced/src/lib/container/runtime/EmbedFederationRuntimePlugin.ts @@ -97,8 +97,7 @@ class EmbedFederationRuntimePlugin { } // Otherwise, append a startup call. - const { ConcatSource } = compiler.webpack.sources; - return new ConcatSource( + return new compiler.webpack.sources.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 baa8c185c75..e9e65ae9eca 100644 --- a/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts +++ b/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts @@ -200,10 +200,6 @@ class FederationRuntimePlugin { '}', ]); - const installInitialConsumesCall = options.experiments?.asyncStartup - ? `${federationGlobal}.installInitialConsumes({ asyncLoad: true })` - : `${federationGlobal}.installInitialConsumes()`; - return Template.asString([ `import federation from '${normalizedBundlerRuntimePath}';`, runtimePluginTemplates, @@ -229,7 +225,7 @@ class FederationRuntimePlugin { ]), '}', `if(${federationGlobal}.installInitialConsumes){`, - Template.indent([installInitialConsumesCall]), + Template.indent([`${federationGlobal}.installInitialConsumes()`]), '}', ]), 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 abfb71efbf3..d9e1973b49d 100644 --- a/packages/enhanced/src/lib/sharing/tree-shaking/IndependentSharedPlugin.ts +++ b/packages/enhanced/src/lib/sharing/tree-shaking/IndependentSharedPlugin.ts @@ -246,58 +246,35 @@ export default class IndependentSharedPlugin { if (!shareConfig.treeShaking) { return; } - - 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), + 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, + ]); + } + }), ); - 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, - ]); - } - } }), ); @@ -402,11 +379,7 @@ export default class IndependentSharedPlugin { // 输出配置 output: { path: fullOutputDir, - // 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, + clean: true, publicPath: parentConfig.output?.publicPath || 'auto', }, diff --git a/packages/enhanced/src/lib/startup/MfStartupChunkDependenciesPlugin.ts b/packages/enhanced/src/lib/startup/MfStartupChunkDependenciesPlugin.ts index af52dc9a700..46b8b563d81 100644 --- a/packages/enhanced/src/lib/startup/MfStartupChunkDependenciesPlugin.ts +++ b/packages/enhanced/src/lib/startup/MfStartupChunkDependenciesPlugin.ts @@ -122,8 +122,7 @@ class StartupChunkDependenciesPlugin { ? generateESMEntryStartup : generateEntryStartup; - const { ConcatSource } = compiler.webpack.sources; - return new ConcatSource( + return new compiler.webpack.sources.ConcatSource( entryGeneration( compilation, chunkGraph, diff --git a/packages/enhanced/test/ConfigTestCases.rstest.ts b/packages/enhanced/test/ConfigTestCases.rstest.ts index fb5c5e304d8..012cf1d1449 100644 --- a/packages/enhanced/test/ConfigTestCases.rstest.ts +++ b/packages/enhanced/test/ConfigTestCases.rstest.ts @@ -375,6 +375,7 @@ export const describeCases = (config: any) => { `${path.sep}tree-shaking-share${path.sep}`, ) ) { + nativeRequire('./scripts/ensure-reshake-fixtures'); ensureTreeShakingFixturesIfNeeded(); } options = prepareOptions( From 4eea8e6569dbfd552edd05c98c0219beb969bb0f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 12 Mar 2026 12:17:18 -0700 Subject: [PATCH 16/22] chore(manifest): drop extra compat cleanup --- packages/manifest/src/StatsPlugin.ts | 20 ++++++++------------ packages/manifest/src/utils.ts | 8 ++++---- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/packages/manifest/src/StatsPlugin.ts b/packages/manifest/src/StatsPlugin.ts index 2ab1fb6db54..fe104630332 100644 --- a/packages/manifest/src/StatsPlugin.ts +++ b/packages/manifest/src/StatsPlugin.ts @@ -78,13 +78,9 @@ 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 webpackSources.RawSource( + new compiler.webpack.sources.RawSource( JSON.stringify(updatedStats, null, 2), ), ); @@ -95,7 +91,7 @@ export class StatsPlugin implements WebpackPluginInstance { compiler, bundler: this._bundler, }); - const source = new webpackSources.RawSource( + const source = new compiler.webpack.sources.RawSource( JSON.stringify(updatedManifest, null, 2), ); compilation.updateAsset(this._manifestManager.fileName, source); @@ -130,17 +126,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 webpackSources.RawSource(JSON.stringify(stats, null, 2)), + new compiler.webpack.sources.RawSource( + JSON.stringify(stats, null, 2), + ), ); compilation.emitAsset( this._manifestManager.fileName, - new webpackSources.RawSource(JSON.stringify(manifest, null, 2)), + new compiler.webpack.sources.RawSource( + JSON.stringify(manifest, null, 2), + ), ); } }, diff --git a/packages/manifest/src/utils.ts b/packages/manifest/src/utils.ts index 3651b714c96..ddd5d05abce 100644 --- a/packages/manifest/src/utils.ts +++ b/packages/manifest/src/utils.ts @@ -14,6 +14,10 @@ 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'; @@ -235,10 +239,6 @@ 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), From ba44c29d5f9d971864c43aff8d61928298941809 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 12 Mar 2026 12:37:08 -0700 Subject: [PATCH 17/22] docs(skills): add gh pr metadata skill --- .codex/skills/gh-pr-metadata/SKILL.md | 92 +++++++++ .../skills/gh-pr-metadata/agents/openai.yaml | 4 + .../references/repo-pr-format.md | 45 ++++ .../scripts/validate_pr_metadata.py | 193 ++++++++++++++++++ 4 files changed, 334 insertions(+) create mode 100644 .codex/skills/gh-pr-metadata/SKILL.md create mode 100644 .codex/skills/gh-pr-metadata/agents/openai.yaml create mode 100644 .codex/skills/gh-pr-metadata/references/repo-pr-format.md create mode 100644 .codex/skills/gh-pr-metadata/scripts/validate_pr_metadata.py diff --git a/.codex/skills/gh-pr-metadata/SKILL.md b/.codex/skills/gh-pr-metadata/SKILL.md new file mode 100644 index 00000000000..5c52766571c --- /dev/null +++ b/.codex/skills/gh-pr-metadata/SKILL.md @@ -0,0 +1,92 @@ +--- +name: gh-pr-metadata +description: Update the current GitHub PR title and body for this repository so they match the repo's pull request template and conventional-commit title style. Use when a PR title is vague or misformatted, when the PR body is missing required sections or checklist items, when Codex needs to normalize PR metadata before review or merge, or when GitHub comments/checks indicate the PR title or body should be corrected. +--- + +# GH PR Metadata + +## Overview + +Normalize the current branch's GitHub PR metadata to this repo's expectations. Keep the title in conventional-commit style, keep the body aligned to `.github/pull_request_template.md`, and validate before handoff. + +Read [references/repo-pr-format.md](./references/repo-pr-format.md) when you need the exact section order, checklist items, or a title example. + +## Workflow + +1. Resolve the current branch PR. + +```bash +gh pr view --json number,title,body,url,headRefName,baseRefName +``` + +2. Validate the current title and body. + +```bash +python3 .codex/skills/gh-pr-metadata/scripts/validate_pr_metadata.py +``` + +3. If the PR body needs a clean template scaffold, print one: + +```bash +python3 .codex/skills/gh-pr-metadata/scripts/validate_pr_metadata.py --print-template +``` + +4. Rewrite the PR title in conventional-commit style. + +Rules: +- Prefer `type(scope): summary` +- Keep the title short and direct +- Use repo-typical types such as `fix`, `feat`, `docs`, `refactor`, `chore`, `test`, `ci`, `build`, `perf`, `revert` +- Keep the scope tight to the affected package or subsystem when useful +- Do not add prefixes like `[codex]` + +5. Rewrite the PR body to preserve the repo template structure: +- `## Description` +- `## Related Issue` +- `## Types of changes` +- `## Checklist` + +6. Update the PR with `gh`. + +Prefer writing the body to a temporary file first, then: + +```bash +gh pr edit --title "" --body-file /tmp/pr-body.md +``` + +7. Re-run validation and report whether the PR metadata is now compliant. + +```bash +python3 .codex/skills/gh-pr-metadata/scripts/validate_pr_metadata.py +``` + +## Body Guidance + +- Keep `Description` prose-first and specific to the branch. +- Put issue references in `Related Issue`; if there is no issue, say so plainly instead of deleting the section. +- In `Types of changes`, check only the boxes that actually apply. +- In `Checklist`, preserve all repo checklist items and mark only the items that are true. +- Do not remove required sections just because the PR is small. +- Keep the body concise; do not turn it into a changelog dump. + +## Title Guidance + +Good examples: +- `fix(node): normalize remote chunk parsing` +- `chore(manifest): drop extra compat cleanup` +- `docs(agents): prefer normalized webpack path requires` + +Bad examples: +- `update pr` +- `fix stuff` +- `[codex] cleanup` + +## Validation + +Use the helper script to detect: +- non-conventional PR titles +- missing or reordered template sections +- missing repo checklist items + +The script validates either the current PR from `gh` or explicit `--title` / `--body-file` input. + diff --git a/.codex/skills/gh-pr-metadata/agents/openai.yaml b/.codex/skills/gh-pr-metadata/agents/openai.yaml new file mode 100644 index 00000000000..178747d3983 --- /dev/null +++ b/.codex/skills/gh-pr-metadata/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: 'GH PR Metadata' + short_description: 'Normalize PR title and body' + default_prompt: "Use $gh-pr-metadata to update this repo's PR title and body to match the template and conventional-commit style." diff --git a/.codex/skills/gh-pr-metadata/references/repo-pr-format.md b/.codex/skills/gh-pr-metadata/references/repo-pr-format.md new file mode 100644 index 00000000000..cfe350d6ec2 --- /dev/null +++ b/.codex/skills/gh-pr-metadata/references/repo-pr-format.md @@ -0,0 +1,45 @@ +# Repo PR Format + +Source files: +- `.github/pull_request_template.md` +- `AGENTS.md` + +## Required PR Body Sections + +Keep these sections in this order: + +1. `## Description` +2. `## Related Issue` +3. `## Types of changes` +4. `## Checklist` + +## Required Checklist Items + +Types of changes: +- `- [ ] Docs change / refactoring / dependency upgrade` +- `- [ ] Bug fix (non-breaking change which fixes an issue)` +- `- [ ] New feature (non-breaking change which adds functionality)` + +Checklist: +- `- [ ] I have added tests to cover my changes.` +- `- [ ] All new and existing tests passed.` +- `- [ ] I have updated the documentation.` + +## Title Convention + +Prefer conventional-commit style: + +```text +type(scope): short summary +``` + +Examples: +- `fix(node): normalize remote chunk parsing` +- `docs(agents): prefer normalized webpack path requires` +- `chore(manifest): drop extra compat cleanup` + +Avoid: +- bracketed prefixes like `[codex]` +- vague summaries like `update pr` +- titles that do not describe the branch's actual change + diff --git a/.codex/skills/gh-pr-metadata/scripts/validate_pr_metadata.py b/.codex/skills/gh-pr-metadata/scripts/validate_pr_metadata.py new file mode 100644 index 00000000000..3f505e534c7 --- /dev/null +++ b/.codex/skills/gh-pr-metadata/scripts/validate_pr_metadata.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + + +TITLE_RE = ( + r"^(build|chore|ci|docs|feat|fix|perf|refactor|revert|test)" + r"(\([^)]+\))?!?: .+" +) + +REQUIRED_SECTIONS = [ + "## Description", + "## Related Issue", + "## Types of changes", + "## Checklist", +] + +REQUIRED_TYPE_LINES = [ + "- [ ] Docs change / refactoring / dependency upgrade", + "- [ ] Bug fix (non-breaking change which fixes an issue)", + "- [ ] New feature (non-breaking change which adds functionality)", +] + +REQUIRED_CHECKLIST_LINES = [ + "- [ ] I have added tests to cover my changes.", + "- [ ] All new and existing tests passed.", + "- [ ] I have updated the documentation.", +] + + +def run(cmd: list[str], cwd: Path) -> str: + proc = subprocess.run( + cmd, + cwd=str(cwd), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or proc.stdout.strip()) + return proc.stdout + + +def read_current_pr(cwd: Path) -> tuple[str, str]: + raw = run( + ["gh", "pr", "view", "--json", "title,body", "--jq", "{title: .title, body: .body}"], + cwd, + ) + data = json.loads(raw) + return data["title"], data["body"] or "" + + +def load_body(args: argparse.Namespace, cwd: Path) -> tuple[str, str]: + if args.title or args.body or args.body_file: + title = args.title or "" + if args.body_file: + body = Path(args.body_file).read_text() + else: + body = args.body or "" + return title, body + return read_current_pr(cwd) + + +def validate_title(title: str) -> list[str]: + import re + + errors: list[str] = [] + if not title: + errors.append("title is empty") + return errors + if not re.match(TITLE_RE, title): + errors.append( + "title does not match conventional format " + "(expected `type(scope): summary` or `type: summary`)" + ) + if title.startswith("["): + errors.append("title should not start with a bracketed prefix") + return errors + + +def validate_body(body: str) -> list[str]: + errors: list[str] = [] + positions: list[int] = [] + + for section in REQUIRED_SECTIONS: + idx = body.find(section) + if idx == -1: + errors.append(f"missing section: {section}") + positions.append(idx) + + valid_positions = [p for p in positions if p != -1] + if valid_positions and valid_positions != sorted(valid_positions): + errors.append("required sections are out of order") + + for line in REQUIRED_TYPE_LINES: + if line not in body: + errors.append(f"missing type checkbox: {line}") + + for line in REQUIRED_CHECKLIST_LINES: + if line not in body: + errors.append(f"missing checklist item: {line}") + + return errors + + +def print_template() -> None: + body = """## Description + + + + +## Related Issue + + + + + + +## Types of changes + +- [ ] Docs change / refactoring / dependency upgrade +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) + +## Checklist + +- [ ] I have added tests to cover my changes. +- [ ] All new and existing tests passed. +- [ ] I have updated the documentation. +""" + sys.stdout.write(body) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Validate PR title/body against repo conventions." + ) + parser.add_argument("--title", help="Explicit PR title to validate.") + parser.add_argument("--body", help="Explicit PR body to validate.") + parser.add_argument("--body-file", help="Path to a PR body file to validate.") + parser.add_argument( + "--repo-root", + default=".", + help="Repo root. Defaults to current working directory.", + ) + parser.add_argument( + "--print-template", + action="store_true", + help="Print a repo-compliant PR body scaffold and exit.", + ) + parser.add_argument( + "--format", + choices=["text", "json"], + default="text", + help="Output format.", + ) + args = parser.parse_args() + + if args.print_template: + print_template() + return 0 + + cwd = Path(args.repo_root).resolve() + title, body = load_body(args, cwd) + + errors = validate_title(title) + validate_body(body) + payload = { + "ok": not errors, + "title": title, + "errors": errors, + } + + if args.format == "json": + sys.stdout.write(json.dumps(payload, indent=2) + "\n") + else: + if errors: + sys.stdout.write("PR metadata validation failed:\n") + for error in errors: + sys.stdout.write(f"- {error}\n") + else: + sys.stdout.write("PR metadata validation passed.\n") + + return 0 if not errors else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 9dea0cd40e820b96fa28ae5730ab7e3c69cbdf0d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 12 Mar 2026 12:37:52 -0700 Subject: [PATCH 18/22] chore(gitignore): ignore codex skill cache files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 14967688977..5addad60b3e 100644 --- a/.gitignore +++ b/.gitignore @@ -102,6 +102,8 @@ packages/enhanced/generated/** !/.codex/skills/ !/.codex/skills/turborepo/ !/.codex/skills/turborepo/** +.codex/skills/**/__pycache__/ +.codex/skills/**/*.pyc .pnpm-store/ apps/rsc-demo/**/build/ apps/rsc-demo/e2e/test-results/ From 4a05338803dffe03960dbf6e224dc365c6b08565 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 12 Mar 2026 12:54:40 -0700 Subject: [PATCH 19/22] fix(node): align eslint with webpack path requires --- .../scripts/validate_pr_metadata.py | 126 +++++++++++------- packages/node/.eslintrc.json | 21 ++- .../src/plugins/CommonJsChunkLoadingPlugin.ts | 14 +- .../src/plugins/EntryChunkTrackerPlugin.ts | 59 +++----- 4 files changed, 117 insertions(+), 103 deletions(-) diff --git a/.codex/skills/gh-pr-metadata/scripts/validate_pr_metadata.py b/.codex/skills/gh-pr-metadata/scripts/validate_pr_metadata.py index 3f505e534c7..0ec85f28bcf 100644 --- a/.codex/skills/gh-pr-metadata/scripts/validate_pr_metadata.py +++ b/.codex/skills/gh-pr-metadata/scripts/validate_pr_metadata.py @@ -13,24 +13,30 @@ r"(\([^)]+\))?!?: .+" ) -REQUIRED_SECTIONS = [ - "## Description", - "## Related Issue", - "## Types of changes", - "## Checklist", -] - -REQUIRED_TYPE_LINES = [ - "- [ ] Docs change / refactoring / dependency upgrade", - "- [ ] Bug fix (non-breaking change which fixes an issue)", - "- [ ] New feature (non-breaking change which adds functionality)", -] - -REQUIRED_CHECKLIST_LINES = [ - "- [ ] I have added tests to cover my changes.", - "- [ ] All new and existing tests passed.", - "- [ ] I have updated the documentation.", -] +DEFAULT_TEMPLATE = """## Description + + + + +## Related Issue + + + + + + +## Types of changes + +- [ ] Docs change / refactoring / dependency upgrade +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) + +## Checklist + +- [ ] I have added tests to cover my changes. +- [ ] All new and existing tests passed. +- [ ] I have updated the documentation. +""" def run(cmd: list[str], cwd: Path) -> str: @@ -67,6 +73,35 @@ def load_body(args: argparse.Namespace, cwd: Path) -> tuple[str, str]: return read_current_pr(cwd) +def load_repo_template(cwd: Path) -> str: + template_path = cwd / ".github" / "pull_request_template.md" + if template_path.exists(): + return template_path.read_text() + return DEFAULT_TEMPLATE + + +def parse_template_requirements(template: str) -> tuple[list[str], list[str], list[str]]: + sections: list[str] = [] + type_lines: list[str] = [] + checklist_lines: list[str] = [] + current_section = "" + + for raw_line in template.splitlines(): + line = raw_line.strip() + if line.startswith("## "): + sections.append(line) + current_section = line + continue + if not line.startswith("- [ ] "): + continue + if current_section == "## Types of changes": + type_lines.append(line) + elif current_section == "## Checklist": + checklist_lines.append(line) + + return sections, type_lines, checklist_lines + + def validate_title(title: str) -> list[str]: import re @@ -84,11 +119,16 @@ def validate_title(title: str) -> list[str]: return errors -def validate_body(body: str) -> list[str]: +def validate_body( + body: str, + required_sections: list[str], + required_type_lines: list[str], + required_checklist_lines: list[str], +) -> list[str]: errors: list[str] = [] positions: list[int] = [] - for section in REQUIRED_SECTIONS: + for section in required_sections: idx = body.find(section) if idx == -1: errors.append(f"missing section: {section}") @@ -98,43 +138,19 @@ def validate_body(body: str) -> list[str]: if valid_positions and valid_positions != sorted(valid_positions): errors.append("required sections are out of order") - for line in REQUIRED_TYPE_LINES: + for line in required_type_lines: if line not in body: errors.append(f"missing type checkbox: {line}") - for line in REQUIRED_CHECKLIST_LINES: + for line in required_checklist_lines: if line not in body: errors.append(f"missing checklist item: {line}") return errors -def print_template() -> None: - body = """## Description - - - - -## Related Issue - - - - - - -## Types of changes - -- [ ] Docs change / refactoring / dependency upgrade -- [ ] Bug fix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) - -## Checklist - -- [ ] I have added tests to cover my changes. -- [ ] All new and existing tests passed. -- [ ] I have updated the documentation. -""" - sys.stdout.write(body) +def print_template(cwd: Path) -> None: + sys.stdout.write(load_repo_template(cwd)) def main() -> int: @@ -162,14 +178,22 @@ def main() -> int: ) args = parser.parse_args() + cwd = Path(args.repo_root).resolve() if args.print_template: - print_template() + print_template(cwd) return 0 - cwd = Path(args.repo_root).resolve() + required_sections, required_type_lines, required_checklist_lines = ( + parse_template_requirements(load_repo_template(cwd)) + ) title, body = load_body(args, cwd) - errors = validate_title(title) + validate_body(body) + errors = validate_title(title) + validate_body( + body, + required_sections, + required_type_lines, + required_checklist_lines, + ) payload = { "ok": not errors, "title": title, diff --git a/packages/node/.eslintrc.json b/packages/node/.eslintrc.json index 07fe19291a2..e7eaba61005 100644 --- a/packages/node/.eslintrc.json +++ b/packages/node/.eslintrc.json @@ -1,6 +1,6 @@ { "extends": ["../../.eslintrc.json"], - "ignorePatterns": ["!**/*"], + "ignorePatterns": ["!**/*", "dist/**/*", "**/*.d.ts"], "env": { "jest": true }, @@ -11,6 +11,7 @@ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": { + "@typescript-eslint/no-require-imports": "off", "@typescript-eslint/no-restricted-imports": [ "error", { @@ -41,7 +42,23 @@ }, { "files": ["*.js", "*.jsx"], - "rules": {} + "env": { + "node": true, + "es2021": true + }, + "rules": { + "no-undef": "off", + "no-unused-vars": "off" + } + }, + { + "files": [ + "src/plugins/AutomaticPublicPathPlugin.ts", + "src/plugins/StreamingTargetPlugin.ts" + ], + "rules": { + "@typescript-eslint/no-empty-object-type": "off" + } } ] } diff --git a/packages/node/src/plugins/CommonJsChunkLoadingPlugin.ts b/packages/node/src/plugins/CommonJsChunkLoadingPlugin.ts index 73136e34576..c7b42e48685 100644 --- a/packages/node/src/plugins/CommonJsChunkLoadingPlugin.ts +++ b/packages/node/src/plugins/CommonJsChunkLoadingPlugin.ts @@ -1,5 +1,9 @@ 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'; @@ -23,16 +27,6 @@ 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 b5da087c614..5e817ab55db 100644 --- a/packages/node/src/plugins/EntryChunkTrackerPlugin.ts +++ b/packages/node/src/plugins/EntryChunkTrackerPlugin.ts @@ -13,9 +13,6 @@ 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 @@ -51,46 +48,28 @@ 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) { - 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; - } + 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; + } - const templateString = this._getTemplateString(compiler, source); + const templateString = this._getTemplateString(compiler, source); - const webpackSources = - compiler.webpack?.sources || - // eslint-disable-next-line @typescript-eslint/no-var-requires - require('webpack').sources; - return new webpackSources.ConcatSource(templateString); - }, - ); + return new compiler.webpack.sources.ConcatSource(templateString); + }, + ); } private _getTemplateString(compiler: Compiler, source: sources.Source) { From bbf38362adc9890beb66e22164c92a1c4529fc70 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 12 Mar 2026 13:06:28 -0700 Subject: [PATCH 20/22] docs(skills): require live branch diff for pr metadata --- .codex/skills/gh-pr-metadata/SKILL.md | 33 +++++++++++++++++++++------ 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/.codex/skills/gh-pr-metadata/SKILL.md b/.codex/skills/gh-pr-metadata/SKILL.md index 5c52766571c..1bf4863da42 100644 --- a/.codex/skills/gh-pr-metadata/SKILL.md +++ b/.codex/skills/gh-pr-metadata/SKILL.md @@ -9,6 +9,8 @@ description: Update the current GitHub PR title and body for this repository so Normalize the current branch's GitHub PR metadata to this repo's expectations. Keep the title in conventional-commit style, keep the body aligned to `.github/pull_request_template.md`, and validate before handoff. +Ground the PR metadata in the live branch state, not stale branch names or old commit subjects. Always inspect the current branch diff versus its base before rewriting the PR title/body. + Read [references/repo-pr-format.md](./references/repo-pr-format.md) when you need the exact section order, checklist items, or a title example. ## Workflow @@ -19,19 +21,34 @@ Read [references/repo-pr-format.md](./references/repo-pr-format.md) when you nee gh pr view --json number,title,body,url,headRefName,baseRefName ``` -2. Validate the current title and body. +2. Inspect the current branch state against the PR base branch. + +At minimum, check: + +```bash +git diff --name-status origin/...HEAD +git diff --stat origin/...HEAD +git log --oneline --decorate --no-merges origin/..HEAD +``` + +Use these to answer: +- what files actually differ from the base right now +- which changes are functional versus cleanup/tooling/docs +- whether the existing PR title/body still matches the current branch after rebases, reverts, or scope narrowing + +3. Validate the current title and body. ```bash python3 .codex/skills/gh-pr-metadata/scripts/validate_pr_metadata.py ``` -3. If the PR body needs a clean template scaffold, print one: +4. If the PR body needs a clean template scaffold, print one: ```bash python3 .codex/skills/gh-pr-metadata/scripts/validate_pr_metadata.py --print-template ``` -4. Rewrite the PR title in conventional-commit style. +5. Rewrite the PR title in conventional-commit style. Rules: - Prefer `type(scope): summary` @@ -39,14 +56,15 @@ Rules: - Use repo-typical types such as `fix`, `feat`, `docs`, `refactor`, `chore`, `test`, `ci`, `build`, `perf`, `revert` - Keep the scope tight to the affected package or subsystem when useful - Do not add prefixes like `[codex]` +- Make sure the title describes the current branch diff, not the original branch intent if the branch was later narrowed or partially reverted -5. Rewrite the PR body to preserve the repo template structure: +6. Rewrite the PR body to preserve the repo template structure: - `## Description` - `## Related Issue` - `## Types of changes` - `## Checklist` -6. Update the PR with `gh`. +7. Update the PR with `gh`. Prefer writing the body to a temporary file first, then: @@ -54,7 +72,7 @@ Prefer writing the body to a temporary file first, then: gh pr edit --title "" --body-file /tmp/pr-body.md ``` -7. Re-run validation and report whether the PR metadata is now compliant. +8. Re-run validation and report whether the PR metadata is now compliant. ```bash python3 .codex/skills/gh-pr-metadata/scripts/validate_pr_metadata.py @@ -63,6 +81,8 @@ python3 .codex/skills/gh-pr-metadata/scripts/validate_pr_metadata.py ## Body Guidance - Keep `Description` prose-first and specific to the branch. +- Reflect the branch as it exists now, especially after rebases, cleanups, or partial reverts. +- Summarize the real file-level themes from the live diff instead of copying commit messages mechanically. - Put issue references in `Related Issue`; if there is no issue, say so plainly instead of deleting the section. - In `Types of changes`, check only the boxes that actually apply. - In `Checklist`, preserve all repo checklist items and mark only the items that are true. @@ -89,4 +109,3 @@ Use the helper script to detect: - missing repo checklist items The script validates either the current PR from `gh` or explicit `--title` / `--body-file` input. - From 40e2bf8700bfdcf9f38d4204e7e769954db48902 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 12 Mar 2026 13:12:53 -0700 Subject: [PATCH 21/22] fix(skills): harden changeset validation --- .changeset/tidy-shrimps-shop.md | 5 + .codex/skills/changeset-pr/SKILL.md | 19 +++- .../scripts/run_changeset_status.py | 97 +++++++++++++++++++ 3 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 .changeset/tidy-shrimps-shop.md create mode 100644 .codex/skills/changeset-pr/scripts/run_changeset_status.py diff --git a/.changeset/tidy-shrimps-shop.md b/.changeset/tidy-shrimps-shop.md new file mode 100644 index 00000000000..8a50319cd4d --- /dev/null +++ b/.changeset/tidy-shrimps-shop.md @@ -0,0 +1,5 @@ +--- +"@module-federation/node": patch +--- + +Fix node chunk parsing and align node webpack-path lint handling. diff --git a/.codex/skills/changeset-pr/SKILL.md b/.codex/skills/changeset-pr/SKILL.md index e7d9dade8a4..a867530f626 100644 --- a/.codex/skills/changeset-pr/SKILL.md +++ b/.codex/skills/changeset-pr/SKILL.md @@ -9,10 +9,12 @@ description: Create or update a `.changeset/*.md` file for the current branch or Create a repo-correct changeset for the current branch, or update an existing one without widening scope unnecessarily. Verify both syntax and package scope before handoff. +Ground the changeset in the live branch diff, not stale branch intent. Always inspect the current branch state against its base before choosing package scope or release type. + ## Workflow 1. Confirm whether a changeset is needed. -2. Identify the publishable package scope from the branch diff. +2. Identify the publishable package scope from the live branch diff. 3. Create or edit one `.changeset/*.md` file. 4. Validate the file against repo config and branch scope. 5. Report the exact commands run and any ambiguity that remains. @@ -27,6 +29,16 @@ Read [references/repo-conventions.md](./references/repo-conventions.md) when you ## Determine Scope +First inspect the live branch state: + +```bash +git diff --name-status origin/main...HEAD +git diff --stat origin/main...HEAD +git log --oneline --decorate --no-merges origin/main..HEAD +``` + +Use that to separate real publishable-package behavior changes from repo-local docs, skills, tooling, or cleanup. + Start with the helper script: ```bash @@ -82,13 +94,13 @@ python3 .codex/skills/changeset-pr/scripts/inspect_changeset_scope.py --base ori 2. Validate that Changesets can parse and plan the release: ```bash -pnpm exec changeset status --verbose +python3 .codex/skills/changeset-pr/scripts/run_changeset_status.py --verbose ``` 3. When machine-readable output is useful: ```bash -pnpm exec changeset status --output /tmp/changeset-status.json +python3 .codex/skills/changeset-pr/scripts/run_changeset_status.py --output /tmp/changeset-status.json ``` Interpretation: @@ -96,6 +108,7 @@ Interpretation: - `status` verifies parseability and computed release planning. - `status` does not prove the changeset is branch-local or minimal in this repo because other pending changesets may already exist. - Fixed-group packages can cause broader or higher bumps than the frontmatter alone suggests. +- Prefer the helper script over direct `pnpm exec changeset status` in Codex runs because shell wrappers in non-TTY sessions can add `/dev/tty` noise or otherwise make the raw CLI output unreliable. ## Update Existing Changesets diff --git a/.codex/skills/changeset-pr/scripts/run_changeset_status.py b/.codex/skills/changeset-pr/scripts/run_changeset_status.py new file mode 100644 index 00000000000..821036593c0 --- /dev/null +++ b/.codex/skills/changeset-pr/scripts/run_changeset_status.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + + +def run(cmd: list[str], cwd: Path) -> str: + result = subprocess.run( + cmd, + cwd=str(cwd), + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def find_repo_root(start: Path) -> Path: + return Path(run(["git", "rev-parse", "--show-toplevel"], start)) + + +def find_changeset_cli(repo_root: Path) -> Path: + candidates = [ + repo_root / "node_modules" / "@changesets" / "cli" / "bin.js", + repo_root / "node_modules" / ".bin" / "changeset", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + raise FileNotFoundError("Unable to locate Changesets CLI under node_modules") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Run Changesets status without shell wrappers so output is stable in non-TTY environments." + ) + parser.add_argument( + "--repo-root", + default=".", + help="Repo root. Defaults to current working directory.", + ) + parser.add_argument( + "--output", + help="Optional path to write Changesets JSON output.", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Pass --verbose to Changesets status.", + ) + parser.add_argument( + "--json", + action="store_true", + help="Emit a small JSON wrapper with command, exit code, stdout, and stderr.", + ) + args = parser.parse_args() + + repo_root = find_repo_root(Path(args.repo_root).resolve()) + cli = find_changeset_cli(repo_root) + + cmd = ["node", str(cli), "status"] + if args.verbose: + cmd.append("--verbose") + if args.output: + cmd.extend(["--output", str(Path(args.output).resolve())]) + + proc = subprocess.run( + cmd, + cwd=str(repo_root), + capture_output=True, + text=True, + check=False, + ) + + if args.json: + payload = { + "command": cmd, + "exit_code": proc.returncode, + "stdout": proc.stdout, + "stderr": proc.stderr, + } + sys.stdout.write(json.dumps(payload, indent=2) + "\n") + else: + if proc.stdout: + sys.stdout.write(proc.stdout) + if proc.stderr: + sys.stderr.write(proc.stderr) + + return proc.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) From a967e67fbbdcbd8d6c3be7a189983ba644f42ea6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 12 Mar 2026 15:40:36 -0700 Subject: [PATCH 22/22] revert(nextjs-mf): reset branch to base state --- packages/nextjs-mf/MIGRATION-v9.md | 103 ---- packages/nextjs-mf/README.md | 83 +-- packages/nextjs-mf/client/UrlNode.ts | 199 ++++++ packages/nextjs-mf/client/helpers.ts | 100 +++ packages/nextjs-mf/package.json | 54 +- .../nextjs-mf/src/core/compilers/client.ts | 48 -- .../nextjs-mf/src/core/compilers/server.ts | 245 -------- .../core/container/InvertedContainerPlugin.ts | 51 -- .../InvertedContainerRuntimeModule.ts | 86 --- packages/nextjs-mf/src/core/errors.ts | 36 -- .../nextjs-mf/src/core/features/app.test.ts | 103 ---- packages/nextjs-mf/src/core/features/app.ts | 209 ------- .../core/features/pages-map-loader.test.ts | 74 --- .../src/core/features/pages-map-loader.ts | 175 ------ packages/nextjs-mf/src/core/features/pages.ts | 8 - .../core/loaders/asset-loader-fixes.test.ts | 115 ---- .../src/core/loaders/fixNextImageLoader.ts | 210 ------- .../src/core/loaders/fixUrlLoader.ts | 42 -- .../src/core/loaders/patchLoaders.ts | 175 ------ packages/nextjs-mf/src/core/options.test.ts | 87 --- packages/nextjs-mf/src/core/options.ts | 164 ----- packages/nextjs-mf/src/core/runtime.ts | 26 - .../nextjs-mf/src/core/runtimePlugin.test.ts | 236 -------- packages/nextjs-mf/src/core/runtimePlugin.ts | 310 ---------- packages/nextjs-mf/src/core/sharing.test.ts | 95 --- packages/nextjs-mf/src/core/sharing.ts | 389 ------------ packages/nextjs-mf/src/federation-noop.ts | 13 + packages/nextjs-mf/src/index.ts | 110 +--- packages/nextjs-mf/src/internal.ts | 297 +++++++++ .../nextjs-mf/src/loaders/fixImageLoader.ts | 129 ++++ .../nextjs-mf/src/loaders/fixUrlLoader.ts | 26 + packages/nextjs-mf/src/loaders/helpers.ts | 192 ++++++ .../src/loaders/nextPageMapLoader.ts | 190 ++++++ packages/nextjs-mf/src/logger.ts | 29 +- ...ntimeRequirementToPromiseExternalPlugin.ts | 23 + .../src/plugins/CopyFederationPlugin.ts | 91 +++ .../apply-client-plugins.ts | 65 ++ .../apply-server-plugins.ts | 264 ++++++++ .../src/plugins/NextFederationPlugin/index.ts | 264 ++++++++ .../NextFederationPlugin/next-fragments.ts | 145 +++++ .../NextFederationPlugin/regex-equal.test.ts | 48 ++ .../NextFederationPlugin/regex-equal.ts | 32 + .../remove-unnecessary-shared-keys.test.ts | 46 ++ .../remove-unnecessary-shared-keys.ts | 32 + .../NextFederationPlugin/set-options.ts | 39 ++ .../NextFederationPlugin/validate-options.ts | 48 ++ .../RemoveEagerModulesFromRuntimePlugin.ts | 103 ++++ .../src/plugins/container/runtimePlugin.ts | 254 ++++++++ .../nextjs-mf/src/plugins/container/types.ts | 5 + packages/nextjs-mf/src/types.ts | 94 +-- packages/nextjs-mf/src/types/btoa.d.ts | 5 +- packages/nextjs-mf/src/withNextFederation.ts | 567 ------------------ packages/nextjs-mf/tsconfig.lib.json | 2 +- packages/nextjs-mf/tsdown.config.mts | 14 +- packages/nextjs-mf/utils/flushedChunks.ts | 61 ++ packages/nextjs-mf/utils/index.ts | 35 ++ pnpm-lock.yaml | 52 +- 57 files changed, 2808 insertions(+), 3890 deletions(-) delete mode 100644 packages/nextjs-mf/MIGRATION-v9.md create mode 100644 packages/nextjs-mf/client/UrlNode.ts create mode 100644 packages/nextjs-mf/client/helpers.ts delete mode 100644 packages/nextjs-mf/src/core/compilers/client.ts delete mode 100644 packages/nextjs-mf/src/core/compilers/server.ts delete mode 100644 packages/nextjs-mf/src/core/container/InvertedContainerPlugin.ts delete mode 100644 packages/nextjs-mf/src/core/container/InvertedContainerRuntimeModule.ts delete mode 100644 packages/nextjs-mf/src/core/errors.ts delete mode 100644 packages/nextjs-mf/src/core/features/app.test.ts delete mode 100644 packages/nextjs-mf/src/core/features/app.ts delete mode 100644 packages/nextjs-mf/src/core/features/pages-map-loader.test.ts delete mode 100644 packages/nextjs-mf/src/core/features/pages-map-loader.ts delete mode 100644 packages/nextjs-mf/src/core/features/pages.ts delete mode 100644 packages/nextjs-mf/src/core/loaders/asset-loader-fixes.test.ts delete mode 100644 packages/nextjs-mf/src/core/loaders/fixNextImageLoader.ts delete mode 100644 packages/nextjs-mf/src/core/loaders/fixUrlLoader.ts delete mode 100644 packages/nextjs-mf/src/core/loaders/patchLoaders.ts delete mode 100644 packages/nextjs-mf/src/core/options.test.ts delete mode 100644 packages/nextjs-mf/src/core/options.ts delete mode 100644 packages/nextjs-mf/src/core/runtime.ts delete mode 100644 packages/nextjs-mf/src/core/runtimePlugin.test.ts delete mode 100644 packages/nextjs-mf/src/core/runtimePlugin.ts delete mode 100644 packages/nextjs-mf/src/core/sharing.test.ts delete mode 100644 packages/nextjs-mf/src/core/sharing.ts create mode 100644 packages/nextjs-mf/src/federation-noop.ts create mode 100644 packages/nextjs-mf/src/internal.ts create mode 100644 packages/nextjs-mf/src/loaders/fixImageLoader.ts create mode 100644 packages/nextjs-mf/src/loaders/fixUrlLoader.ts create mode 100644 packages/nextjs-mf/src/loaders/helpers.ts create mode 100644 packages/nextjs-mf/src/loaders/nextPageMapLoader.ts create mode 100644 packages/nextjs-mf/src/plugins/AddRuntimeRequirementToPromiseExternalPlugin.ts create mode 100644 packages/nextjs-mf/src/plugins/CopyFederationPlugin.ts create mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/apply-client-plugins.ts create mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/apply-server-plugins.ts create mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/index.ts create mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/next-fragments.ts create mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/regex-equal.test.ts create mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/regex-equal.ts create mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/remove-unnecessary-shared-keys.test.ts create mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/remove-unnecessary-shared-keys.ts create mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/set-options.ts create mode 100644 packages/nextjs-mf/src/plugins/NextFederationPlugin/validate-options.ts create mode 100644 packages/nextjs-mf/src/plugins/container/RemoveEagerModulesFromRuntimePlugin.ts create mode 100644 packages/nextjs-mf/src/plugins/container/runtimePlugin.ts create mode 100644 packages/nextjs-mf/src/plugins/container/types.ts delete mode 100644 packages/nextjs-mf/src/withNextFederation.ts create mode 100644 packages/nextjs-mf/utils/flushedChunks.ts create mode 100644 packages/nextjs-mf/utils/index.ts diff --git a/packages/nextjs-mf/MIGRATION-v9.md b/packages/nextjs-mf/MIGRATION-v9.md deleted file mode 100644 index 5b1e20156a2..00000000000 --- a/packages/nextjs-mf/MIGRATION-v9.md +++ /dev/null @@ -1,103 +0,0 @@ -# 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 45b33a59a8d..ff3ff16b26a 100644 --- a/packages/nextjs-mf/README.md +++ b/packages/nextjs-mf/README.md @@ -1,82 +1,5 @@ -# nextjs-mf v9 (Next.js 16+) +# Next.js Support is in maintenance mode -`@module-federation/nextjs-mf` v9 is a clean rewrite for Next.js 16+. +Read about it [here](https://github.com/module-federation/core/issues/3153) -## 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. +Plugin Documentation: [here](https://module-federation.io/practice/frameworks/next/index.html) diff --git a/packages/nextjs-mf/client/UrlNode.ts b/packages/nextjs-mf/client/UrlNode.ts new file mode 100644 index 00000000000..dd7fb89a290 --- /dev/null +++ b/packages/nextjs-mf/client/UrlNode.ts @@ -0,0 +1,199 @@ +// 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 new file mode 100644 index 00000000000..85fc4295c56 --- /dev/null +++ b/packages/nextjs-mf/client/helpers.ts @@ -0,0 +1,100 @@ +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 aabc3fa7ca8..f328b33cc54 100644 --- a/packages/nextjs-mf/package.json +++ b/packages/nextjs-mf/package.json @@ -1,11 +1,12 @@ { "name": "@module-federation/nextjs-mf", - "version": "9.0.0", + "version": "8.8.57", "license": "MIT", "main": "dist/src/index.js", + "module": "dist/src/index.mjs", "types": "dist/src/index.d.ts", "type": "commonjs", - "description": "Module Federation for Next.js 16+ (webpack mode)", + "description": "Module Federation helper for NextJS", "repository": { "type": "git", "url": "git+https://github.com/module-federation/core.git", @@ -17,27 +18,45 @@ ], "files": [ "dist/", - "README.md", - "MIGRATION-v9.md" + "README.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.\"", "build": "tsdown --config tsdown.config.mts", "lint": "ESLINT_USE_FLAT_CONFIG=false pnpm exec eslint --ignore-pattern node_modules \"**/*.js\" \"**/*.ts\"", "test": "pnpm exec jest --config jest.config.js --passWithNoTests", "pre-release": "pnpm run test && pnpm run build && rm -f ./dist/package.json" }, "exports": { - ".": "./dist/src/index.js", - "./node": "./dist/node.js", - "./package.json": "./package.json" + ".": { + "import": { + "types": "./dist/src/index.d.mts", + "default": "./dist/src/index.mjs" + }, + "require": { + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + } + }, + "./utils": { + "import": { + "types": "./dist/utils/index.d.mts", + "default": "./dist/utils/index.mjs" + }, + "require": { + "types": "./dist/utils/index.d.ts", + "default": "./dist/utils/index.js" + } + }, + "./*": "./*" }, "typesVersions": { "*": { ".": [ "./dist/src/index.d.ts" ], - "node": [ - "./dist/node.d.ts" + "utils": [ + "./dist/utils/index.d.ts" ] } }, @@ -45,11 +64,12 @@ "access": "public" }, "dependencies": { - "@module-federation/enhanced": "workspace:*", - "@module-federation/node": "workspace:*", + "fast-glob": "^3.2.11", "@module-federation/runtime": "workspace:*", "@module-federation/sdk": "workspace:*", - "fast-glob": "^3.2.11" + "@module-federation/enhanced": "workspace:*", + "@module-federation/node": "workspace:*", + "@module-federation/webpack-bundler-runtime": "workspace:*" }, "devDependencies": { "@types/btoa": "^1.2.5", @@ -58,11 +78,11 @@ "tsdown": "0.20.3" }, "peerDependencies": { - "next": ">=16.0.0", - "react": "^18 || ^19", - "react-dom": "^18 || ^19", - "styled-jsx": "*", - "webpack": "^5.40.0" + "webpack": "^5.40.0", + "next": "^12 || ^13 || ^14 || ^15", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19", + "styled-jsx": "*" }, "peerDependenciesMeta": { "webpack": { diff --git a/packages/nextjs-mf/src/core/compilers/client.ts b/packages/nextjs-mf/src/core/compilers/client.ts deleted file mode 100644 index 26ff0bf0ccb..00000000000 --- a/packages/nextjs-mf/src/core/compilers/client.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 3977b9c9d89..00000000000 --- a/packages/nextjs-mf/src/core/compilers/server.ts +++ /dev/null @@ -1,245 +0,0 @@ -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/core/container/InvertedContainerPlugin.ts b/packages/nextjs-mf/src/core/container/InvertedContainerPlugin.ts deleted file mode 100644 index 9f447e8954c..00000000000 --- a/packages/nextjs-mf/src/core/container/InvertedContainerPlugin.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Compilation, Compiler } from 'webpack'; -import InvertedContainerRuntimeModule from './InvertedContainerRuntimeModule'; - -type EnhancedModuleExports = typeof import('@module-federation/enhanced'); - -const loadEnhanced = (): EnhancedModuleExports => { - const enhancedModule = require('@module-federation/enhanced') as - | EnhancedModuleExports - | { default: EnhancedModuleExports }; - - return (enhancedModule as { default?: EnhancedModuleExports }).default - ? (enhancedModule as { default: EnhancedModuleExports }).default - : (enhancedModule as EnhancedModuleExports); -}; - -class InvertedContainerPlugin { - public apply(compiler: Compiler): void { - const { FederationModulesPlugin, dependencies } = loadEnhanced(); - - compiler.hooks.thisCompilation.tap( - 'InvertedContainerPlugin', - (compilation: Compilation) => { - const hooks = FederationModulesPlugin.getCompilationHooks(compilation); - const containers = new Set(); - - hooks.addContainerEntryDependency.tap( - 'InvertedContainerPlugin', - (dependency) => { - if (dependency instanceof dependencies.ContainerEntryDependency) { - containers.add(dependency); - } - }, - ); - - compilation.hooks.additionalTreeRuntimeRequirements.tap( - 'InvertedContainerPlugin', - (chunk) => { - compilation.addRuntimeModule( - chunk, - new InvertedContainerRuntimeModule({ - containers, - }), - ); - }, - ); - }, - ); - } -} - -export default InvertedContainerPlugin; diff --git a/packages/nextjs-mf/src/core/container/InvertedContainerRuntimeModule.ts b/packages/nextjs-mf/src/core/container/InvertedContainerRuntimeModule.ts deleted file mode 100644 index 5c994b829de..00000000000 --- a/packages/nextjs-mf/src/core/container/InvertedContainerRuntimeModule.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-path'; - -const { RuntimeModule, Template, RuntimeGlobals } = require( - normalizeWebpackPath('webpack'), -) as typeof import('webpack'); - -interface InvertedContainerRuntimeModuleOptions { - containers: Set; -} - -class InvertedContainerRuntimeModule extends RuntimeModule { - private options: InvertedContainerRuntimeModuleOptions; - - constructor(options: InvertedContainerRuntimeModuleOptions) { - super('inverted container startup', RuntimeModule.STAGE_TRIGGER); - this.options = options; - } - - override generate(): string { - const { compilation, chunk, chunkGraph } = this; - if (!compilation || !chunk || !chunkGraph) { - return ''; - } - - if (chunk.runtime === 'webpack-api-runtime') { - return ''; - } - - let containerEntryModule: any; - for (const containerDep of this.options.containers) { - const mod = compilation.moduleGraph.getModule(containerDep); - if (!mod) { - continue; - } - if (chunkGraph.isModuleInChunk(mod, chunk)) { - containerEntryModule = mod; - } - } - - if (!containerEntryModule) { - return ''; - } - - if ( - compilation.chunkGraph.isEntryModuleInChunk(containerEntryModule, chunk) - ) { - return ''; - } - - const initRuntimeModuleGetter = compilation.runtimeTemplate.moduleRaw({ - module: containerEntryModule, - chunkGraph, - weak: false, - runtimeRequirements: new Set(), - }); - const nameJSON = JSON.stringify(containerEntryModule._name); - - return Template.asString([ - `var prevStartup = ${RuntimeGlobals.startup};`, - 'var hasRun = false;', - 'var cachedRemote;', - `${RuntimeGlobals.startup} = ${compilation.runtimeTemplate.basicFunction( - '', - Template.asString([ - 'if (!hasRun) {', - Template.indent( - Template.asString([ - '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();'), - '}', - ]), - )};`, - ]); - } -} - -export default InvertedContainerRuntimeModule; diff --git a/packages/nextjs-mf/src/core/errors.ts b/packages/nextjs-mf/src/core/errors.ts deleted file mode 100644 index a656640f02b..00000000000 --- a/packages/nextjs-mf/src/core/errors.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index c4e537a4e00..00000000000 --- a/packages/nextjs-mf/src/core/features/app.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index 40b39f0d403..00000000000 --- a/packages/nextjs-mf/src/core/features/app.ts +++ /dev/null @@ -1,209 +0,0 @@ -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 deleted file mode 100644 index 8ace4ed3142..00000000000 --- a/packages/nextjs-mf/src/core/features/pages-map-loader.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index f02f13b8f8b..00000000000 --- a/packages/nextjs-mf/src/core/features/pages-map-loader.ts +++ /dev/null @@ -1,175 +0,0 @@ -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 deleted file mode 100644 index 540d87c4a82..00000000000 --- a/packages/nextjs-mf/src/core/features/pages.ts +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 4bc53dde197..00000000000 --- a/packages/nextjs-mf/src/core/loaders/asset-loader-fixes.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -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 deleted file mode 100644 index 78001398620..00000000000 --- a/packages/nextjs-mf/src/core/loaders/fixNextImageLoader.ts +++ /dev/null @@ -1,210 +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'); - -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 deleted file mode 100644 index 8c954b99383..00000000000 --- a/packages/nextjs-mf/src/core/loaders/fixUrlLoader.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * 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 deleted file mode 100644 index aa06e9120e9..00000000000 --- a/packages/nextjs-mf/src/core/loaders/patchLoaders.ts +++ /dev/null @@ -1,175 +0,0 @@ -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 deleted file mode 100644 index 9585b33e96b..00000000000 --- a/packages/nextjs-mf/src/core/options.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index b56f7f6767f..00000000000 --- a/packages/nextjs-mf/src/core/options.ts +++ /dev/null @@ -1,164 +0,0 @@ -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 deleted file mode 100644 index 872285e7080..00000000000 --- a/packages/nextjs-mf/src/core/runtime.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 1e89b5045bc..00000000000 --- a/packages/nextjs-mf/src/core/runtimePlugin.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -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 deleted file mode 100644 index 9be761ee26e..00000000000 --- a/packages/nextjs-mf/src/core/runtimePlugin.ts +++ /dev/null @@ -1,310 +0,0 @@ -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 deleted file mode 100644 index 4e5b1247af1..00000000000 --- a/packages/nextjs-mf/src/core/sharing.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -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 deleted file mode 100644 index 12efd8121c7..00000000000 --- a/packages/nextjs-mf/src/core/sharing.ts +++ /dev/null @@ -1,389 +0,0 @@ -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 new file mode 100644 index 00000000000..f3233b36772 --- /dev/null +++ b/packages/nextjs-mf/src/federation-noop.ts @@ -0,0 +1,13 @@ +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 847c0e5c8c6..9580f9fed9c 100644 --- a/packages/nextjs-mf/src/index.ts +++ b/packages/nextjs-mf/src/index.ts @@ -1,99 +1,13 @@ -import type { Compiler } from 'webpack'; -import type { moduleFederationPlugin } from '@module-federation/sdk'; -import { getWebpackPath } from '@module-federation/sdk/normalize-webpack-path'; -import withNextFederation, { - applyResolvedNextFederationConfig, -} from './withNextFederation'; -import { - assertLocalWebpackEnabled, - isNextBuildOrDevCommand, - normalizeNextFederationOptions, -} from './core/options'; -import type { - NextFederationCompilerContext, - NextFederationMode, - NextFederationOptionsV9, - ResolvedNextFederationOptions, -} from './types'; - -type LegacyExtraOptions = { - exposePages?: boolean; - skipSharingNextInternals?: boolean; - debug?: boolean; - automaticPageStitching?: boolean; - enableImageLoaderFix?: boolean; - enableUrlLoaderFix?: boolean; -}; - -type LegacyNextFederationPluginOptions = - moduleFederationPlugin.ModuleFederationPluginOptions & { - extraOptions?: LegacyExtraOptions; - }; - -function toLegacyCompatOptions( - input: LegacyNextFederationPluginOptions, -): NextFederationOptionsV9 { - const { extraOptions, ...federation } = input; - - return { - ...federation, - mode: 'pages', - pages: { - exposePages: extraOptions?.exposePages ?? false, - }, - sharing: { - includeNextInternals: !extraOptions?.skipSharingNextInternals, - }, - diagnostics: extraOptions?.debug ? { level: 'debug' } : undefined, - }; -} - -export class NextFederationPlugin { - private readonly resolved: ResolvedNextFederationOptions; - - public readonly name = 'ModuleFederationPlugin'; - - constructor(private readonly options: LegacyNextFederationPluginOptions) { - this.resolved = normalizeNextFederationOptions( - toLegacyCompatOptions(options), - ); - } - - apply(compiler: Compiler): void { - if (isNextBuildOrDevCommand() && this.resolved.mode !== 'app') { - assertLocalWebpackEnabled(); - } - - if (!process.env['FEDERATION_WEBPACK_PATH']) { - process.env['FEDERATION_WEBPACK_PATH'] = getWebpackPath(compiler, { - framework: 'nextjs', - }); - } - - applyResolvedNextFederationConfig( - compiler.options, - { - dir: compiler.context, - isServer: compiler.options.name === 'server', - nextRuntime: - compiler.options.name === 'edge-server' ? 'edge' : 'nodejs', - webpack: compiler.webpack, - }, - this.resolved, - false, - ); - } +import NextFederationPlugin from './plugins/NextFederationPlugin'; + +export { NextFederationPlugin }; +export default NextFederationPlugin; + +if ( + process.env.IS_ESM_BUILD !== 'true' && + typeof module !== 'undefined' && + typeof module.exports !== 'undefined' +) { + module.exports = NextFederationPlugin; + module.exports.NextFederationPlugin = NextFederationPlugin; } - -export type { - NextFederationCompilerContext, - NextFederationMode, - NextFederationOptionsV9, -} from './types'; - -export { withNextFederation }; -export default withNextFederation; - -module.exports = NextFederationPlugin; -module.exports.NextFederationPlugin = NextFederationPlugin; -module.exports.withNextFederation = withNextFederation; diff --git a/packages/nextjs-mf/src/internal.ts b/packages/nextjs-mf/src/internal.ts new file mode 100644 index 00000000000..cbc95fab16a --- /dev/null +++ b/packages/nextjs-mf/src/internal.ts @@ -0,0 +1,297 @@ +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 new file mode 100644 index 00000000000..910fed1768e --- /dev/null +++ b/packages/nextjs-mf/src/loaders/fixImageLoader.ts @@ -0,0 +1,129 @@ +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 new file mode 100644 index 00000000000..4f04835372a --- /dev/null +++ b/packages/nextjs-mf/src/loaders/fixUrlLoader.ts @@ -0,0 +1,26 @@ +/** + * `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 new file mode 100644 index 00000000000..4ede889487a --- /dev/null +++ b/packages/nextjs-mf/src/loaders/helpers.ts @@ -0,0 +1,192 @@ +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 new file mode 100644 index 00000000000..7d40af81478 --- /dev/null +++ b/packages/nextjs-mf/src/loaders/nextPageMapLoader.ts @@ -0,0 +1,190 @@ +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 5d17b9a21ca..e9ae35d0ae9 100644 --- a/packages/nextjs-mf/src/logger.ts +++ b/packages/nextjs-mf/src/logger.ts @@ -1,24 +1,13 @@ -const PREFIX = '[nextjs-mf]'; +import { + createInfrastructureLogger, + createLogger, +} from '@module-federation/sdk'; -function prefix(args: unknown[]): unknown[] { - return [PREFIX, ...args]; -} +const createBundlerLogger: typeof createLogger = + typeof createInfrastructureLogger === 'function' + ? (createInfrastructureLogger as unknown as typeof createLogger) + : createLogger; -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)); - } - }, -}; +const logger = createBundlerLogger('[ nextjs-mf ]'); export default logger; diff --git a/packages/nextjs-mf/src/plugins/AddRuntimeRequirementToPromiseExternalPlugin.ts b/packages/nextjs-mf/src/plugins/AddRuntimeRequirementToPromiseExternalPlugin.ts new file mode 100644 index 00000000000..094437b9bd8 --- /dev/null +++ b/packages/nextjs-mf/src/plugins/AddRuntimeRequirementToPromiseExternalPlugin.ts @@ -0,0 +1,23 @@ +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 new file mode 100644 index 00000000000..6388ea524d6 --- /dev/null +++ b/packages/nextjs-mf/src/plugins/CopyFederationPlugin.ts @@ -0,0 +1,91 @@ +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 new file mode 100644 index 00000000000..1a3a53e76a6 --- /dev/null +++ b/packages/nextjs-mf/src/plugins/NextFederationPlugin/apply-client-plugins.ts @@ -0,0 +1,65 @@ +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 new file mode 100644 index 00000000000..2ab7dccd0bb --- /dev/null +++ b/packages/nextjs-mf/src/plugins/NextFederationPlugin/apply-server-plugins.ts @@ -0,0 +1,264 @@ +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 new file mode 100644 index 00000000000..3ce572676a4 --- /dev/null +++ b/packages/nextjs-mf/src/plugins/NextFederationPlugin/index.ts @@ -0,0 +1,264 @@ +/** + * 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 path from 'path'; +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'; + +const resolveRuntimePluginPath = (): string => + process.env.IS_ESM_BUILD === 'true' + ? require.resolve('@module-federation/nextjs-mf/dist/src/plugins/container/runtimePlugin.mjs') + : require.resolve('@module-federation/nextjs-mf/dist/src/plugins/container/runtimePlugin.js'); + +const resolveNoopPath = (): string => + process.env.IS_ESM_BUILD === 'true' + ? require.resolve('@module-federation/nextjs-mf/dist/src/federation-noop.mjs') + : require.resolve('@module-federation/nextjs-mf/dist/src/federation-noop.js'); + +const resolveNodeRuntimePluginPath = (): string => { + const nodePackageRoot = path.dirname( + require.resolve('@module-federation/node/package.json'), + ); + + return require.resolve( + path.join( + nodePackageRoot, + process.env.IS_ESM_BUILD === 'true' + ? 'dist/src/runtimePlugin.mjs' + : 'dist/src/runtimePlugin.js', + ), + ); +}; +/** + * 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 ? [resolveNodeRuntimePluginPath()] : []), + resolveRuntimePluginPath(), + ...(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, + }, + manifest: { + ...(this._options.manifest ?? {}), + filePath: isServer ? '' : '/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 resolveNoopPath(); + } +} + +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 new file mode 100644 index 00000000000..642443fe5fc --- /dev/null +++ b/packages/nextjs-mf/src/plugins/NextFederationPlugin/next-fragments.ts @@ -0,0 +1,145 @@ +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'; + +const resolveFixImageLoaderPath = (): string => + process.env.IS_ESM_BUILD === 'true' + ? require.resolve('@module-federation/nextjs-mf/dist/src/loaders/fixImageLoader.mjs') + : require.resolve('@module-federation/nextjs-mf/dist/src/loaders/fixImageLoader.js'); + +const resolveFixUrlLoaderPath = (): string => + process.env.IS_ESM_BUILD === 'true' + ? require.resolve('@module-federation/nextjs-mf/dist/src/loaders/fixUrlLoader.mjs') + : require.resolve('@module-federation/nextjs-mf/dist/src/loaders/fixUrlLoader.js'); +/** + * 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: resolveFixImageLoaderPath(), + }); + } + + if (options.enableUrlLoaderFix && hasLoader(typedRule, 'url-loader')) { + injectRuleLoader(typedRule, { + loader: resolveFixUrlLoaderPath(), + }); + } + } + }); + + 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 new file mode 100644 index 00000000000..ea98b3ad1e7 --- /dev/null +++ b/packages/nextjs-mf/src/plugins/NextFederationPlugin/regex-equal.test.ts @@ -0,0 +1,48 @@ +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 new file mode 100644 index 00000000000..f8f864eb171 --- /dev/null +++ b/packages/nextjs-mf/src/plugins/NextFederationPlugin/regex-equal.ts @@ -0,0 +1,32 @@ +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 new file mode 100644 index 00000000000..6f9bd3348bb --- /dev/null +++ b/packages/nextjs-mf/src/plugins/NextFederationPlugin/remove-unnecessary-shared-keys.test.ts @@ -0,0 +1,46 @@ +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 new file mode 100644 index 00000000000..c479eb52822 --- /dev/null +++ b/packages/nextjs-mf/src/plugins/NextFederationPlugin/remove-unnecessary-shared-keys.ts @@ -0,0 +1,32 @@ +/** + * 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 new file mode 100644 index 00000000000..bae02d0b794 --- /dev/null +++ b/packages/nextjs-mf/src/plugins/NextFederationPlugin/set-options.ts @@ -0,0 +1,39 @@ +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 new file mode 100644 index 00000000000..2d2407df177 --- /dev/null +++ b/packages/nextjs-mf/src/plugins/NextFederationPlugin/validate-options.ts @@ -0,0 +1,48 @@ +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 new file mode 100644 index 00000000000..b5e3740833e --- /dev/null +++ b/packages/nextjs-mf/src/plugins/container/RemoveEagerModulesFromRuntimePlugin.ts @@ -0,0 +1,103 @@ +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 new file mode 100644 index 00000000000..3eeddcfe3fd --- /dev/null +++ b/packages/nextjs-mf/src/plugins/container/runtimePlugin.ts @@ -0,0 +1,254 @@ +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 new file mode 100644 index 00000000000..896758194e2 --- /dev/null +++ b/packages/nextjs-mf/src/plugins/container/types.ts @@ -0,0 +1,5 @@ +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 e97a09a795b..aacca07ec28 100644 --- a/packages/nextjs-mf/src/types.ts +++ b/packages/nextjs-mf/src/types.ts @@ -1,77 +1,35 @@ -import type { moduleFederationPlugin } from '@module-federation/sdk'; +export declare interface WatchOptions { + /** + * Delay the rebuilt after the first change. Value is a time in ms. + */ + aggregateTimeout?: number; -export type NextFederationMode = 'pages' | 'app' | 'hybrid'; + /** + * Resolve symlinks and watch symlink and real file. This is usually not needed as webpack already resolves symlinks ('resolve.symlinks'). + */ + followSymlinks?: boolean; -export type FederationRemotes = - moduleFederationPlugin.ModuleFederationPluginOptions['remotes']; + /** + * Ignore some files from watching (glob pattern or regexp). + */ + ignored?: string | RegExp | string[]; -export interface NextFederationCompilerContext { - isServer: boolean; - nextRuntime?: 'nodejs' | 'edge'; - compilerName?: string; -} - -export type NextFederationRemotesResolver = ( - context: NextFederationCompilerContext, -) => FederationRemotes; + /** + * Enable polling mode for watching. + */ + poll?: number | 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'; - }; + /** + * Stop watching when stdin stream has ended. + */ + stdin?: boolean; } -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; +export declare interface CallbackFunction { + (err?: null | Error, result?: T): any; } -export interface RouterPresence { - hasPages: boolean; - hasApp: boolean; +declare global { + //eslint-disable-next-line + var usedChunks: Set; } diff --git a/packages/nextjs-mf/src/types/btoa.d.ts b/packages/nextjs-mf/src/types/btoa.d.ts index b226f5be30d..8aebed8e393 100644 --- a/packages/nextjs-mf/src/types/btoa.d.ts +++ b/packages/nextjs-mf/src/types/btoa.d.ts @@ -1 +1,4 @@ -declare module 'btoa'; +declare module 'btoa' { + function btoa(str: string): string; + export = btoa; +} diff --git a/packages/nextjs-mf/src/withNextFederation.ts b/packages/nextjs-mf/src/withNextFederation.ts deleted file mode 100644 index 9171788d5f7..00000000000 --- a/packages/nextjs-mf/src/withNextFederation.ts +++ /dev/null @@ -1,567 +0,0 @@ -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 applyResolvedNextFederationConfig( - userConfig: Configuration, - context: NextWebpackContext, - resolved: ResolvedNextFederationOptions, - hasValidatedAppExposes: boolean, -): { config: Configuration; hasValidatedAppExposes: boolean } { - normalizeOutputPath(userConfig); - - const compilerName = inferCompilerName(userConfig, context); - - if (compilerName === 'edge-server' || context.nextRuntime === 'edge') { - // v9 intentionally skips federation in edge compiler. - return { config: userConfig, hasValidatedAppExposes }; - } - - 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 { config: userConfig, hasValidatedAppExposes }; -} - -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; - const applied = applyResolvedNextFederationConfig( - userConfig, - context, - resolved, - hasValidatedAppExposes, - ); - hasValidatedAppExposes = applied.hasValidatedAppExposes; - return applied.config; - }, - }; -} - -export default withNextFederation; diff --git a/packages/nextjs-mf/tsconfig.lib.json b/packages/nextjs-mf/tsconfig.lib.json index 4ddd840a68b..a76f2ea3a7d 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", "src/**/*.d.ts", "node.ts"], + "include": ["src/**/*.ts", "utils/**/*.ts", "*.ts"], "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] } diff --git a/packages/nextjs-mf/tsdown.config.mts b/packages/nextjs-mf/tsdown.config.mts index d13ddccc3c4..4a727da89d7 100644 --- a/packages/nextjs-mf/tsdown.config.mts +++ b/packages/nextjs-mf/tsdown.config.mts @@ -13,13 +13,13 @@ export default defineConfig([ packageDir, entry: { 'src/index': 'src/index.ts', - node: 'node.ts', - 'src/core/features/pages-map-loader': - 'src/core/features/pages-map-loader.ts', - 'src/core/loaders/fixNextImageLoader': - 'src/core/loaders/fixNextImageLoader.ts', - 'src/core/loaders/fixUrlLoader': 'src/core/loaders/fixUrlLoader.ts', - 'src/core/runtimePlugin': 'src/core/runtimePlugin.ts', + 'src/federation-noop': 'src/federation-noop.ts', + 'src/loaders/fixImageLoader': 'src/loaders/fixImageLoader.ts', + 'src/loaders/nextPageMapLoader': 'src/loaders/nextPageMapLoader.ts', + 'src/loaders/fixUrlLoader': 'src/loaders/fixUrlLoader.ts', + 'src/plugins/container/runtimePlugin': + 'src/plugins/container/runtimePlugin.ts', + 'utils/index': 'utils/index.ts', }, external: [ '@module-federation/*', diff --git a/packages/nextjs-mf/utils/flushedChunks.ts b/packages/nextjs-mf/utils/flushedChunks.ts new file mode 100644 index 00000000000..191c33fca8e --- /dev/null +++ b/packages/nextjs-mf/utils/flushedChunks.ts @@ -0,0 +1,61 @@ +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 new file mode 100644 index 00000000000..cf867c8cf65 --- /dev/null +++ b/packages/nextjs-mf/utils/index.ts @@ -0,0 +1,35 @@ +/** + * 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/pnpm-lock.yaml b/pnpm-lock.yaml index 09b8fffe0cf..6cef5e6dfef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3840,17 +3840,20 @@ 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: '>=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.27.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.97.3)(webpack-cli@5.1.4) + 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.27.3)(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 || ^19 + specifier: ^17 || ^18 || ^19 version: 18.3.1 react-dom: - specifier: ^18 || ^19 + specifier: ^17 || ^18 || ^19 version: 18.3.1(react@18.3.1) styled-jsx: specifier: '*' @@ -50925,7 +50928,7 @@ snapshots: '@next/env': 16.1.5 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001769 + caniuse-lite: 1.0.30001766 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) @@ -50951,37 +50954,6 @@ snapshots: - uglify-js - webpack-cli - 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.27.3)(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.30001769 - postcss: 8.4.31 - 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.27.3)(webpack-cli@5.1.4) - optionalDependencies: - '@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' - - babel-plugin-macros - - esbuild - - uglify-js - - webpack-cli - no-case@3.0.4: dependencies: lower-case: 2.0.2 @@ -56680,14 +56652,6 @@ 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