Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/modern-v3-ssr-public-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@module-federation/modern-js-v3": patch
"@module-federation/rsbuild-plugin": patch
---

Ensure Node federation runtimes include the configured public path when loading asynchronous chunks over HTTP, including Modern v3 SSR applications and Rslib/Rsbuild Node or dual-target producers.
49 changes: 48 additions & 1 deletion packages/modernjs-v3/src/cli/ssrPlugin.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import path from 'path';
import { createRequire } from 'node:module';
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { describe, expect, it, rs } from '@rstest/core';

Expand Down Expand Up @@ -100,6 +100,53 @@ const runCompiler = (config: import('@rspack/core').Configuration) =>
});

describe('moduleFederationSSRPlugin', () => {
it('emits the configured public path for Node HTTP chunk loading without changing browser runtimes', async () => {
const outputDir = await mkdtemp(
path.join(tmpdir(), 'mf-ssr-runtime-path-'),
);
try {
await writeFile(
path.join(outputDir, 'entry.js'),
"module.exports = () => import('./chunk.js');",
);
await writeFile(path.join(outputDir, 'chunk.js'), 'module.exports = 42;');
const { rsbuildApi } = await createPluginHarness();
const modifyRspackConfig = rsbuildApi.modifyRspackConfig.mock.calls[0][0];
const browserConfig = {
output: { publicPath: 'https://cdn.example.com/app/' },
};
modifyRspackConfig(browserConfig, { environment: { name: 'web' } });
expect(browserConfig).toEqual({
output: { publicPath: 'https://cdn.example.com/app/' },
});

const config: import('@rspack/core').Configuration = {
mode: 'development',
context: outputDir,
target: 'async-node',
entry: './entry.js',
devtool: false,
output: {
path: path.join(outputDir, 'dist'),
filename: 'entry.js',
chunkLoading: 'async-node',
publicPath: 'https://cdn.example.com/app/',
},
};
modifyRspackConfig(config, { environment: { name: 'node' } });
await runCompiler(config);
const output = await readFile(
path.join(outputDir, 'dist/entry.js'),
'utf8',
);
expect(output).toContain(
'__webpack_require__.p = "https://cdn.example.com/app/bundles/"',
);
} finally {
await rm(outputDir, { recursive: true, force: true });
}
});

it('keeps SSR JavaScript under bundles while asset/resource URLs use the browser static path', async () => {
const outputDir = await mkdtemp(
path.join(tmpdir(), 'modern-js-v3-ssr-public-path-'),
Expand Down
5 changes: 5 additions & 0 deletions packages/modernjs-v3/src/cli/ssrPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import logger from '../logger';
import { isDev } from './utils';
import {
updateStatsAndManifest,
SSRPublicPathPlugin,
type StatsAssetResource,
} from '@module-federation/rsbuild-plugin/utils';
import {
Expand Down Expand Up @@ -187,6 +188,10 @@ const mfSSRRsbuildPlugin = (
};
api.modifyRspackConfig((config, utils) => {
modifySSRPublicPath(config, utils);
if (ssrEnv === utils.environment.name) {
config.plugins ||= [];
config.plugins.push(new SSRPublicPathPlugin());
}
return config;
});

Expand Down
2 changes: 1 addition & 1 deletion packages/rsbuild-plugin/src/cli/node-target.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ describe('pluginModuleFederation node target environment behavior', () => {
type: 'commonjs-module',
});
expect(bundlerConfig.optimization?.runtimeChunk).toBeUndefined();
expect(bundlerConfig.plugins).toHaveLength(1);
expect(bundlerConfig.plugins).toHaveLength(2);
expect(warningSpy.mock.calls.flat().map(String).join('\n')).toContain(
'optimization.runtimeChunk',
);
Expand Down
64 changes: 64 additions & 0 deletions packages/rsbuild-plugin/src/cli/ssr-public-path.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { createRslib } from '@rslib/core';
import { describe, expect, it } from '@rstest/core';
import { pluginModuleFederation } from './index';

// Build the real Rslib integration: async-node does not otherwise require publicPath.
describe('Rslib SSR publicPath runtime', () => {
it.each(['node', 'dual'] as const)(
'emits the configured publicPath for target=%s',
async (target) => {
const root = await mkdtemp(path.join(tmpdir(), 'mf-rslib-public-path-'));
try {
await writeFile(
path.join(root, 'package.json'),
JSON.stringify({ name: 'ssr-public-path-fixture', private: true }),
);
await mkdir(path.join(root, 'src'));
await writeFile(
path.join(root, 'src/index.js'),
'export const value = 42;',
);
const rslib = await createRslib({
cwd: root,
config: {
lib: [{ format: 'mf', dts: false }],
output: {
assetPrefix: 'https://cdn.example.com/library/',
minify: false,
distPath: { root: path.join(root, 'dist') },
},
plugins: [
pluginModuleFederation(
{
name: 'ssr_public_path',
filename: 'remoteEntry.js',
dts: false,
manifest: false,
exposes: { './Value': './src/index.js' },
},
{ target },
),
],
},
});
await rslib.build();
const nodeDir = target === 'dual' ? 'dist/ssr' : 'dist';
const output = await readFile(
path.join(root, nodeDir, 'remoteEntry.js'),
'utf8',
);
const expected =
target === 'dual'
? 'https://cdn.example.com/library/ssr/'
: 'https://cdn.example.com/library/';
expect(output).toContain(`__webpack_require__.p = "${expected}"`);
expect(output).toContain('readFile');
} finally {
await rm(root, { recursive: true, force: true });
}
},
);
});
17 changes: 17 additions & 0 deletions packages/rsbuild-plugin/src/utils/SSRPublicPathPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Rspack } from '@rsbuild/core';

const PLUGIN_NAME = 'ModuleFederationSSRPublicPathPlugin';

export class SSRPublicPathPlugin {
apply(compiler: Rspack.Compiler) {
compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
compilation.hooks.additionalTreeRuntimeRequirements.tap(
PLUGIN_NAME,
(_chunk, requirements) => {
// The MF Node runtime loads chunks over HTTP, unlike the native filesystem loader.
requirements.add(compiler.webpack.RuntimeGlobals.publicPath);
},
);
});
}
}
2 changes: 2 additions & 0 deletions packages/rsbuild-plugin/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@ export {
SSR_DIR,
SSR_ENV_NAME,
} from './ssr';

export { SSRPublicPathPlugin } from './SSRPublicPathPlugin';
21 changes: 18 additions & 3 deletions packages/rsbuild-plugin/src/utils/ssr.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { afterEach, describe, it, expect, rs } from '@rstest/core';
import { createSSRMFConfig, patchSSRRspackConfig, SSR_DIR } from './ssr';
import {
createSSRMFConfig,
patchNodeConfig,
patchSSRRspackConfig,
SSR_DIR,
} from './ssr';
import { SSRPublicPathPlugin } from './SSRPublicPathPlugin';
import type { Rspack } from '@rsbuild/core';
import type { moduleFederationPlugin } from '@module-federation/sdk';

Expand Down Expand Up @@ -70,6 +76,15 @@ describe('createSSRMFConfig', () => {
});

describe('patchSSRRspackConfig', () => {
it('adds the publicPath requirement once when Node config is patched repeatedly', () => {
const config: Rspack.Configuration = {};
patchNodeConfig(config, { name: 'testApp' });
patchNodeConfig(config, { name: 'testApp' });
expect(
config.plugins?.filter((plugin) => plugin instanceof SSRPublicPathPlugin),
).toHaveLength(1);
});

const baseConfig: Rspack.Configuration = {
output: {
publicPath: '/test/',
Expand Down Expand Up @@ -114,9 +129,9 @@ describe('patchSSRRspackConfig', () => {
rs.stubEnv('NODE_ENV', 'development');
const config = JSON.parse(JSON.stringify(baseConfig));
const patchedConfig = patchSSRRspackConfig(config, baseMfConfig, 'ssr');
expect(patchedConfig.plugins).toHaveLength(1);
expect(patchedConfig.plugins).toHaveLength(2);
// @ts-expect-error default is a class
expect(patchedConfig.plugins?.[0].constructor.name).toBe(
expect(patchedConfig.plugins?.[1].constructor.name).toBe(
'UniverseEntryChunkTrackerPlugin',
);
});
Expand Down
4 changes: 4 additions & 0 deletions packages/rsbuild-plugin/src/utils/ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from 'path';
import { createRequire } from 'node:module';
import { encodeName } from '@module-federation/sdk';
import { CALL_NAME_MAP } from '../constant';
import { SSRPublicPathPlugin } from './SSRPublicPathPlugin';

import type {
EnvironmentConfig,
Expand Down Expand Up @@ -76,6 +77,9 @@ export function patchNodeConfig(
const UniverseEntryChunkTrackerPlugin =
UniverseEntryChunkTrackerPluginModule?.default;
config.plugins ||= [];
if (!config.plugins.some((plugin) => plugin instanceof SSRPublicPathPlugin)) {
config.plugins.push(new SSRPublicPathPlugin());
}
if (isDev() && UniverseEntryChunkTrackerPlugin) {
config.plugins.push(new UniverseEntryChunkTrackerPlugin());
}
Expand Down
Loading