Skip to content
Draft
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
70 changes: 65 additions & 5 deletions rsbuild.shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

import { ModuleFederationOptions, pluginModuleFederation } from '@module-federation/rsbuild-plugin';
import { mergeRsbuildConfig, RsbuildConfig } from '@rsbuild/core';

Expand Down Expand Up @@ -73,7 +76,7 @@ interface PluginConfigOptions {
* });
* ```
*/
export function createConfigForPlugin(options: PluginConfigOptions) {
export function createConfigForPlugin(options: PluginConfigOptions): RsbuildConfig {
const { name, rsbuildConfig = {}, moduleFederation = {} } = options;

const mfConfig: ModuleFederationOptions = {
Expand All @@ -91,12 +94,31 @@ export function createConfigForPlugin(options: PluginConfigOptions) {
);
}

function getAssetPrefix(name: string): string {
return `${PLUGINS_PATH}/${name}/`;
function getAssetPrefix(name: string, version?: string): string {
// The Perses server serves plugin files from `/plugins/<name>[~<version>[~<registry>]]/`. Including the version makes
// each version's assets resolve from its own directory: without it, every request lands on `/plugins/<name>/`, which
// the server resolves to the *latest* installed version, so any non-latest (e.g. pinned/locked) version tries to load
// the latest version's files and fails.
const identity = version ? `${name}~${version}` : name;
return `${PLUGINS_PATH}/${identity}/`;
}

/**
* Reads the plugin version from the `package.json` of the plugin currently being built. Returns `undefined` when it
* cannot be determined, in which case asset paths fall back to the version-less prefix.
*/
function getPluginVersion(): string | undefined {
try {
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf-8'));
return typeof pkg.version === 'string' && pkg.version.length > 0 ? pkg.version : undefined;
} catch {
return undefined;
}
}

function getRsbuildConfig(name: string): RsbuildConfig {
const assetPrefix = getAssetPrefix(name);
const version = process.env.NODE_ENV === 'development' ? undefined : getPluginVersion();

return {
server: {
Expand Down Expand Up @@ -131,17 +153,55 @@ function getRsbuildConfig(name: string): RsbuildConfig {
if (process.env.NODE_ENV !== 'development') {
config.output.publicPath = 'auto';
}
// Isolate each version's webpack runtime.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this probably is not needed as we should use the path, regardless if the chunk has the same name

//
// `chunk_<uniqueName>` is the global array async chunks register into. When two versions of the same plugin
// share it, the version loaded second pushes its chunks into the first one's runtime, and because module IDs are
// deterministic they collide, so both versions end up resolving to the first one's modules (two different
// versions rendering identically). `chunkLoadingGlobal` is derived before this hook runs, so it has to be set
// explicitly rather than relying on `uniqueName`.
if (version) {
const uniqueName = toGlobalName(name, version);
config.output.uniqueName = uniqueName;
config.output.chunkLoadingGlobal = `chunk_${uniqueName}`;
}
return config;
},
},
};
}

function getBaseModuleFederationConfig(name: string): ModuleFederationOptions {
return {
const config: ModuleFederationOptions = {
name,
dts: false,
runtime: false,
getPublicPath: `function() { const prefix = window.PERSES_PLUGIN_ASSETS_PATH || window.PERSES_APP_CONFIG?.api_prefix || ""; return prefix + "${getAssetPrefix(name)}"; }`,
};

// In development the plugin is served by the rsbuild dev server and proxied by Perses, which strips the whole
// `/plugins/<segment>` prefix, so the version-less prefix is what works there.
//
// For production builds the prefix embeds the plugin version (`/plugins/<name>~<version>/`) so that each installed
// version loads its own assets. Without it every request goes to `/plugins/<name>/`, which the server resolves to the
// latest installed version, so a pinned/older version ends up requesting the latest version's hashed files (404).
const version = process.env.NODE_ENV === 'development' ? undefined : getPluginVersion();
config.getPublicPath = `function() { const prefix = window.PERSES_PLUGIN_ASSETS_PATH || window.PERSES_APP_CONFIG?.api_prefix || ""; return prefix + "${getAssetPrefix(name, version)}"; }`;

// Give each version its own global container name.
//
// The Module Federation runtime resolves a remote's container through `globalThis[globalName]`, where `globalName`
// comes from this library name via the manifest (see `assignRemoteInfo` in `@module-federation/runtime-core`). It also
// early-returns an already-registered container: `if (remoteEntryExports) return remoteEntryExports`. So when several
// versions of the same plugin share the global name, the first version loaded wins and every other version silently
// reuses its container instead of loading its own entry, which makes two different versions render identically.
if (version) {
config.library = { type: 'global', name: toGlobalName(name, version) };
}

return config;
}

/** Build a JS-identifier-safe global container name that is unique per plugin version. */
function toGlobalName(name: string, version: string): string {
return `${name}_${version}`.replace(/[^a-zA-Z0-9_$]/g, '_');
}
Loading