Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/metro-083-compat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@module-federation/metro": patch
---

Add Metro 0.83 compatibility layer. Metro 0.83 introduced a restrictive `exports` field that only allows `metro/private/*` paths instead of direct `metro/src/*` imports. This adds a `metro-compat` utility that dynamically resolves the correct import path, ensuring compatibility with both Metro 0.82 and 0.83+.
8 changes: 5 additions & 3 deletions packages/metro-core/src/commands/bundle-host/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import path from 'node:path';
import util from 'node:util';
import Server from 'metro/src/Server';
import type { RequestOptions } from 'metro/src/shared/types';
import type { ModuleFederationConfigNormalized } from '../../types';
import { CLIError } from '../../utils/errors';
import { type RequestOptions, Server } from '../../utils/metro-compat';
import type { Config } from '../types';
import { createResolver } from '../utils/create-resolver';
import { getCommunityCliPlugin } from '../utils/get-community-plugin';
Expand Down Expand Up @@ -59,7 +58,10 @@ async function bundleFederatedHost(
communityCliPlugin.unstable_buildBundleWithConfig;

return buildBundleWithConfig(args, config, {
build: async (server: Server, requestOpts: RequestOptions) => {
build: async (
server: InstanceType<typeof Server>,
requestOpts: RequestOptions,
) => {
// setup enhance middleware to trigger virtual modules setup
config.server.enhanceMiddleware(server.processRequest, server);
const resolver = await createResolver(server, args.platform);
Expand Down
12 changes: 9 additions & 3 deletions packages/metro-core/src/commands/bundle-remote/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import path from 'node:path';
import { pathToFileURL } from 'node:url';
import util from 'node:util';
import { mergeConfig } from 'metro';
import Server from 'metro/src/Server';
import type { OutputOptions, RequestOptions } from 'metro/src/shared/types';
import type { ModuleFederationConfigNormalized } from '../../types';
import { CLIError } from '../../utils/errors';
import {
type OutputOptions,
type RequestOptions,
Server,
} from '../../utils/metro-compat';
import type { Config } from '../types';
import { createModulePathRemapper } from '../utils/create-module-path-remapper';
import { createResolver } from '../utils/create-resolver';
Expand Down Expand Up @@ -43,7 +46,10 @@ interface BundleRequestOptions extends RequestOptions {
sourceUrl: string;
}

async function buildBundle(server: Server, requestOpts: BundleRequestOptions) {
async function buildBundle(
server: InstanceType<typeof Server>,
requestOpts: BundleRequestOptions,
) {
const bundle = await server.build({
...Server.DEFAULT_BUNDLE_OPTIONS,
...requestOpts,
Expand Down
7 changes: 5 additions & 2 deletions packages/metro-core/src/commands/utils/create-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type Server from 'metro/src/Server';
import type { Server } from '../../utils/metro-compat';

/**
* Creates a resolver utility that mirrors Metro's bundling resolution behavior.
Expand All @@ -14,7 +14,10 @@ import type Server from 'metro/src/Server';
* @param platform - The target platform for resolution (e.g., 'ios', 'android', null for default)
* @returns The resolver object with a resolve method that takes source and target paths
*/
export async function createResolver(server: Server, platform: string | null) {
export async function createResolver(
server: InstanceType<typeof Server>,
platform: string | null,
) {
const bundler = server.getBundler().getBundler();
const depGraph = await bundler.getDependencyGraph();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { ConfigT } from 'metro-config';
import type Server from 'metro/src/Server';
import type { OutputOptions, RequestOptions } from 'metro/src/shared/types';
import { CLIError } from '../../utils/errors';
import type {
OutputOptions,
RequestOptions,
Server,
} from '../../utils/metro-compat';

interface Command {
name: string;
Expand All @@ -21,7 +24,7 @@ interface CommunityCliPlugin {
config: ConfigT,
bundleImpl: {
build: (
server: Server,
server: InstanceType<typeof Server>,
requestOpts: RequestOptions,
) => Promise<{ code: string; map: string }>;
save: (
Expand Down
6 changes: 4 additions & 2 deletions packages/metro-core/src/commands/utils/save-bundle-and-map.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { promises as fs } from 'node:fs';
import util from 'node:util';
import type { MixedSourceMap } from 'metro-source-map';
import relativizeSourceMapInline from 'metro/src/lib/relativizeSourceMap';
import type { OutputOptions } from 'metro/src/shared/types';
import {
type OutputOptions,
relativizeSourceMapInline,
} from '../../utils/metro-compat';

function relativizeSerializedMap(
map: string,
Expand Down
8 changes: 5 additions & 3 deletions packages/metro-core/src/plugin/serializer.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import path from 'node:path';
import type { Module, ReadOnlyGraph, SerializerOptions } from 'metro';
import type { SerializerConfigT } from 'metro-config';
import baseJSBundle from 'metro/src/DeltaBundler/Serializers/baseJSBundle';
import CountingSet from 'metro/src/lib/CountingSet';
import bundleToString from 'metro/src/lib/bundleToString';
import type { ModuleFederationConfigNormalized, Shared } from '../types';
import { ConfigError } from '../utils/errors';
import {
CountingSet,
baseJSBundle,
bundleToString,
} from '../utils/metro-compat';

type CustomSerializer = SerializerConfigT['customSerializer'];

Expand Down
66 changes: 66 additions & 0 deletions packages/metro-core/src/utils/metro-compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Metro Compatibility Layer
*
* Provides backwards-compatible imports for Metro 0.82 and 0.83+
*
* Metro 0.83 introduced a restrictive `exports` field that only allows
* `metro/private/*` paths instead of direct `metro/src/*` imports.
*
* This module dynamically resolves the correct import path based on the
* installed Metro version, ensuring compatibility with both versions.
*/

/**
* Attempts to import from Metro 0.83 format first, falls back to 0.82 format
*/
function tryImport(metro83Path: string, metro82Path: string) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we narrow this catch a bit? Right now we fallback on any error. If metro/private/* exists but throws for another reason, we’ll mask the real issue. Maybe only fallback for MODULE_NOT_FOUND for that private path ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

good point, we could change this to require.resolve followed by require 👍

try {
return require(metro83Path);
} catch {
return require(metro82Path);
}
}

function getDefaultExport(mod: any) {
// CJS interop: if the module has a .default that is a function or object, use it
// Otherwise return the module itself (already the direct export)
if (mod != null && typeof mod === 'object' && 'default' in mod) {
return mod.default;
}
return mod;
}

// Server class
export const Server = getDefaultExport(
tryImport('metro/private/Server', 'metro/src/Server'),
);

// DeltaBundler Serializers
export const baseJSBundle = getDefaultExport(
tryImport(
'metro/private/DeltaBundler/Serializers/baseJSBundle',
'metro/src/DeltaBundler/Serializers/baseJSBundle',
),
);

// Utility classes
export const CountingSet = getDefaultExport(
tryImport('metro/private/lib/CountingSet', 'metro/src/lib/CountingSet'),
);

// Bundle utilities
export const bundleToString = getDefaultExport(
tryImport('metro/private/lib/bundleToString', 'metro/src/lib/bundleToString'),
);

const relativizeSourceMapModule = tryImport(
'metro/private/lib/relativizeSourceMap',
'metro/src/lib/relativizeSourceMap',
);
export const relativizeSourceMapInline =
relativizeSourceMapModule.relativizeSourceMapInline ||
getDefaultExport(relativizeSourceMapModule);

// Re-export types - these come from metro/src/shared/types in both versions
// The types themselves are available through the main 'metro' package export
export type { RequestOptions, OutputOptions } from 'metro/src/shared/types';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We still import types from metro/src/shared/types here. Metro 0.83 blocks metro/src/* via exports, so this can break consumers on Node16/NodeNext resolution. Should we switch this to a public/exported type path ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Actually after taking a closer look at this:

  1. MF core repo uses "moduleResolution": "node" so for types, exports are ignored
  2. metro-compat module is internal and not exposed to the consumers of MF metro

we should be fine with this as it is 👍

16 changes: 11 additions & 5 deletions packages/metro-core/src/utils/vm-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import type {
TransformerConfigT,
} from 'metro-config';
import type { FileSystem } from 'metro-file-map';
import type MetroServer from 'metro/src/Server';
import type { Server as MetroServer } from './metro-compat';

type EnhanceMiddleware = ServerConfigT['enhanceMiddleware'];
type GetTransformOptions = TransformerConfigT['getTransformOptions'];
type Bundler = ReturnType<ReturnType<MetroServer['getBundler']>['getBundler']>;
type Bundler = ReturnType<
ReturnType<InstanceType<typeof MetroServer>['getBundler']>['getBundler']
>;

export class VirtualModuleManager {
private setupFinished: Promise<boolean> | null = null;
Expand Down Expand Up @@ -69,8 +71,8 @@ export class VirtualModuleManager {
setup(bundler: Bundler) {
this.setupFinished = (async () => {
const graph = await bundler.getDependencyGraph();
// @ts-expect-error incomplete types
this.ensureFileSystemPatched(graph._fileSystem);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.ensureFileSystemPatched((graph as any)._fileSystem);
this.ensureBundlerPatched(bundler);
return true;
})();
Expand Down Expand Up @@ -104,7 +106,11 @@ export class VirtualModuleManager {
}
const transformFile = bundler.transformFile.bind(bundler);

bundler.transformFile = async (filePath, transformOptions, fileBuffer) => {
bundler.transformFile = async (
filePath: string,
transformOptions: any,
fileBuffer?: Buffer,
) => {
let buffer = fileBuffer;
const virtualModule = this.virtualModules.get(filePath);

Expand Down