Skip to content

Commit 45bd1f8

Browse files
feat(modernjs-v3): finalize plugin-first MF+RSC bridge flow
1 parent b5c40da commit 45bd1f8

15 files changed

Lines changed: 873 additions & 1066 deletions

packages/modernjs-v3/src/cli/configPlugin.spec.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,11 @@ describe('patchMFConfig', async () => {
109109
).toBe(true);
110110
expect(patchedConfig.exposes).toBeDefined();
111111
const widgetExposeConfig = (patchedConfig.exposes as any)['./Widget'];
112-
expect(Array.isArray(widgetExposeConfig.import)).toBe(true);
113-
expect(widgetExposeConfig.import).toContain('./src/widget.ts');
114-
expect(String(widgetExposeConfig.import[0])).toMatch(
115-
/rsc-client-callback-bootstrap\.(mjs|js)$/,
112+
expect(widgetExposeConfig.import).toEqual(
113+
expect.arrayContaining([
114+
expect.stringMatching(/rsc-client-callback-bootstrap\.(mjs|js)$/),
115+
'./src/widget.ts',
116+
]),
116117
);
117118
expect((patchedConfig.exposes as any)['./Widget']).toMatchObject({
118119
layer: 'react-server-components',
@@ -189,7 +190,7 @@ describe('patchMFConfig', async () => {
189190
);
190191
});
191192

192-
it('patchMFConfig: rsc exposes inject runtime bridge plugin', async () => {
193+
it('patchMFConfig: rsc exposes inject bridge expose + bootstrap without runtime bridge plugin', async () => {
193194
const patchedConfig = {
194195
name: 'rsc-remote',
195196
exposes: {
@@ -209,12 +210,13 @@ describe('patchMFConfig', async () => {
209210
typeof runtimePlugin === 'string' ? runtimePlugin : runtimePlugin[0],
210211
),
211212
),
212-
).toBe(true);
213+
).toBe(false);
213214
const widgetExposeConfig = (patchedConfig.exposes as any)['./Widget'];
214-
expect(Array.isArray(widgetExposeConfig.import)).toBe(true);
215-
expect(widgetExposeConfig.import).toContain('./src/widget.ts');
216-
expect(String(widgetExposeConfig.import[0])).toMatch(
217-
/rsc-client-callback-bootstrap\.(mjs|js)$/,
215+
expect(widgetExposeConfig.import).toEqual(
216+
expect.arrayContaining([
217+
expect.stringMatching(/rsc-client-callback-bootstrap\.(mjs|js)$/),
218+
'./src/widget.ts',
219+
]),
218220
);
219221
expect(
220222
(patchedConfig.exposes as any)['./__rspack_rsc_bridge__'],

packages/modernjs-v3/src/cli/configPlugin.ts

Lines changed: 212 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,11 @@ const RSC_CLIENT_CALLBACK_BOOTSTRAP_MODULE = resolveFirstExistingPath(
6969
path.resolve(__dirname, '../esm/runtime/rsc-client-callback-bootstrap.mjs'),
7070
path.resolve(
7171
path.dirname(RSC_BRIDGE_EXPOSE_MODULE),
72-
'../../esm/runtime/rsc-client-callback-bootstrap.mjs',
72+
'rsc-client-callback-bootstrap.js',
7373
),
7474
path.resolve(
7575
path.dirname(RSC_BRIDGE_EXPOSE_MODULE),
76-
'../../cjs/runtime/rsc-client-callback-bootstrap.js',
76+
'rsc-client-callback-bootstrap.mjs',
7777
),
7878
],
7979
path.resolve(__dirname, '../runtime/rsc-client-callback-bootstrap.js'),
@@ -285,7 +285,7 @@ const setRscExposeConfig = (
285285
const importList = Array.isArray(normalizedConfig.import)
286286
? [...normalizedConfig.import]
287287
: [normalizedConfig.import];
288-
const normalizedImport =
288+
const normalizedImportList =
289289
exposeKey === RSC_BRIDGE_EXPOSE
290290
? importList
291291
: [
@@ -294,7 +294,10 @@ const setRscExposeConfig = (
294294
importPath => importPath !== RSC_CLIENT_CALLBACK_BOOTSTRAP_MODULE,
295295
),
296296
];
297-
297+
const normalizedImport =
298+
normalizedImportList.length === 1
299+
? normalizedImportList[0]
300+
: normalizedImportList;
298301
normalizedExposes[exposeKey] = {
299302
...normalizedConfig,
300303
import: normalizedImport,
@@ -450,10 +453,7 @@ export const patchMFConfig = (
450453
);
451454
}
452455

453-
if (
454-
rscEnabled &&
455-
(hasRemotes(mfConfig.remotes) || hasExposes(mfConfig.exposes))
456-
) {
456+
if (rscEnabled && hasRemotes(mfConfig.remotes)) {
457457
injectRuntimePlugins(RSC_BRIDGE_RUNTIME_PLUGIN, runtimePlugins);
458458
}
459459

@@ -528,6 +528,177 @@ function patchIgnoreWarning(chain: BundlerChainConfig) {
528528
chain.ignoreWarnings(ignoreWarnings);
529529
}
530530

531+
const patchProjectNodeModulesResolution = (chain: BundlerChainConfig) => {
532+
// Keep federation + RSC resolution rooted in the app workspace to avoid
533+
// divergent hoisted dependency paths between client/server manifests.
534+
const projectNodeModulesPath = path.resolve(process.cwd(), 'node_modules');
535+
if (!fs.existsSync(projectNodeModulesPath)) {
536+
return;
537+
}
538+
539+
const resolveModules = chain.resolve.modules as {
540+
values?: () => string[];
541+
clear: () => unknown;
542+
add: (value: string) => unknown;
543+
};
544+
resolveModules.clear();
545+
resolveModules.add(projectNodeModulesPath);
546+
resolveModules.add('node_modules');
547+
};
548+
549+
const patchServerOnlyAlias = (chain: BundlerChainConfig) => {
550+
// Align server-only package behavior for federated remote exposes across
551+
// both build targets.
552+
const serverOnlyEmptyPath = path.resolve(
553+
process.cwd(),
554+
'node_modules/server-only/empty.js',
555+
);
556+
if (!fs.existsSync(serverOnlyEmptyPath)) {
557+
return;
558+
}
559+
560+
const aliasChain = chain.resolve.alias as {
561+
has?: (key: string) => boolean;
562+
set: (key: string, value: string) => unknown;
563+
};
564+
const hasServerOnlyAlias =
565+
typeof aliasChain.has === 'function' ? aliasChain.has('server-only$') : false;
566+
if (!hasServerOnlyAlias) {
567+
aliasChain.set('server-only$', serverOnlyEmptyPath);
568+
}
569+
};
570+
571+
const resolveProjectDependency = (request: string) => {
572+
try {
573+
return require.resolve(request, { paths: [process.cwd()] });
574+
} catch {
575+
try {
576+
return require.resolve(request);
577+
} catch {
578+
return undefined;
579+
}
580+
}
581+
};
582+
583+
const patchRscServerRuntimeAliases = (chain: BundlerChainConfig) => {
584+
const reactPackagePath = resolveProjectDependency('react/package.json');
585+
if (!reactPackagePath) {
586+
return;
587+
}
588+
589+
const reactDir = path.dirname(reactPackagePath);
590+
const reactJsxRuntimeServerPath = path.join(
591+
reactDir,
592+
'jsx-runtime.react-server.js',
593+
);
594+
const reactJsxDevRuntimeServerPath = path.join(
595+
reactDir,
596+
'jsx-dev-runtime.react-server.js',
597+
);
598+
599+
if (fs.existsSync(reactJsxRuntimeServerPath)) {
600+
chain.resolve.alias.set('react/jsx-runtime$', reactJsxRuntimeServerPath);
601+
}
602+
if (fs.existsSync(reactJsxDevRuntimeServerPath)) {
603+
chain.resolve.alias.set(
604+
'react/jsx-dev-runtime$',
605+
reactJsxDevRuntimeServerPath,
606+
);
607+
}
608+
};
609+
610+
const getExposeImports = (
611+
exposeConfig: moduleFederationPlugin.ExposesObject[string],
612+
): string[] => {
613+
if (typeof exposeConfig === 'string') {
614+
return [exposeConfig];
615+
}
616+
if (Array.isArray(exposeConfig)) {
617+
return exposeConfig.filter(
618+
(importPath): importPath is string => typeof importPath === 'string',
619+
);
620+
}
621+
if (
622+
exposeConfig &&
623+
typeof exposeConfig === 'object' &&
624+
'import' in exposeConfig
625+
) {
626+
const exposeImport = (exposeConfig as { import?: unknown }).import;
627+
if (typeof exposeImport === 'string') {
628+
return [exposeImport];
629+
}
630+
if (Array.isArray(exposeImport)) {
631+
return exposeImport.filter(
632+
(importPath): importPath is string => typeof importPath === 'string',
633+
);
634+
}
635+
}
636+
return [];
637+
};
638+
639+
const collectExposeImportDirectories = (
640+
exposes: moduleFederationPlugin.ModuleFederationPluginOptions['exposes'],
641+
) => {
642+
if (!exposes) {
643+
return [];
644+
}
645+
646+
const exposeEntries = Array.isArray(exposes)
647+
? exposes.flatMap(exposeItem =>
648+
typeof exposeItem === 'string'
649+
? [[exposeItem, exposeItem] as const]
650+
: (Object.entries(exposeItem || {}) as Array<
651+
readonly [
652+
string,
653+
moduleFederationPlugin.ExposesObject[string],
654+
]
655+
>),
656+
)
657+
: (Object.entries(exposes) as Array<
658+
readonly [string, moduleFederationPlugin.ExposesObject[string]]
659+
>);
660+
661+
const directories = new Set<string>();
662+
for (const [exposeKey, exposeConfig] of exposeEntries) {
663+
if (exposeKey === RSC_BRIDGE_EXPOSE) {
664+
continue;
665+
}
666+
for (const importPath of getExposeImports(exposeConfig)) {
667+
if (!importPath || !importPath.startsWith('.')) {
668+
continue;
669+
}
670+
directories.add(path.dirname(path.resolve(process.cwd(), importPath)));
671+
}
672+
}
673+
674+
return Array.from(directories);
675+
};
676+
677+
const patchRscRemoteComponentLayer = (
678+
chain: BundlerChainConfig,
679+
mfConfig: moduleFederationPlugin.ModuleFederationPluginOptions,
680+
) => {
681+
// Derive layer coverage from actual expose imports instead of fixture-specific
682+
// source path conventions.
683+
const includeDirectories = collectExposeImportDirectories(mfConfig.exposes);
684+
if (includeDirectories.length === 0) {
685+
return;
686+
}
687+
688+
const ruleChain = chain.module
689+
.rule('rsc-mf-remote-components-layer')
690+
.test(/\.[cm]?[jt]sx?$/);
691+
692+
for (const includeDirectory of includeDirectories) {
693+
ruleChain.include.add(includeDirectory);
694+
}
695+
696+
ruleChain.layer(RSC_LAYER);
697+
};
698+
699+
const normalizePublicPath = (publicPath: string) =>
700+
publicPath.endsWith('/') ? publicPath.slice(0, -1) : publicPath;
701+
531702
export function addMyTypes2Ignored(
532703
chain: BundlerChainConfig,
533704
mfConfig: moduleFederationPlugin.ModuleFederationPluginOptions,
@@ -588,11 +759,44 @@ export function patchBundlerConfig(options: {
588759
enableSSR: boolean;
589760
}) {
590761
const { chain, modernjsConfig, isServer, mfConfig, enableSSR } = options;
762+
const rscMfEnabled = isRscMfEnabled(mfConfig);
591763

592764
chain.optimization.delete('runtimeChunk');
593765

594766
patchIgnoreWarning(chain);
595767

768+
if (rscMfEnabled && isServer) {
769+
chain.resolve.conditionNames
770+
.clear()
771+
.add('require')
772+
.add('import')
773+
.add('default');
774+
if (hasExposes(mfConfig.exposes)) {
775+
chain.resolve.conditionNames.add('react-server');
776+
}
777+
}
778+
779+
if (rscMfEnabled && hasExposes(mfConfig.exposes)) {
780+
patchProjectNodeModulesResolution(chain);
781+
patchServerOnlyAlias(chain);
782+
783+
const assetPrefix = modernjsConfig.output?.assetPrefix;
784+
if (typeof assetPrefix === 'string' && assetPrefix.trim()) {
785+
const normalizedAssetPrefix = normalizePublicPath(assetPrefix.trim());
786+
chain.output.publicPath(
787+
isServer
788+
? `${normalizedAssetPrefix}/bundles/`
789+
: `${normalizedAssetPrefix}/`,
790+
);
791+
}
792+
if (!isServer) {
793+
chain.optimization.splitChunks(false);
794+
} else {
795+
patchRscServerRuntimeAliases(chain);
796+
patchRscRemoteComponentLayer(chain, mfConfig);
797+
}
798+
}
799+
596800
if (!chain.output.get('chunkLoadingGlobal')) {
597801
chain.output.chunkLoadingGlobal(`chunk_${mfConfig.name}`);
598802
}

0 commit comments

Comments
 (0)