Skip to content

(metro-core): add manifest SHA-256 hashes and optional bundle cache layer integration#4576

Merged
zackarychapple merged 31 commits into
module-federation:mainfrom
zhongwuzw:features/metro-cache
Apr 29, 2026
Merged

(metro-core): add manifest SHA-256 hashes and optional bundle cache layer integration#4576
zackarychapple merged 31 commits into
module-federation:mainfrom
zhongwuzw:features/metro-cache

Conversation

@zhongwuzw

@zhongwuzw zhongwuzw commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

Description

  • Adds SHA-256 bundle hashes to federation manifest during bundle-remote build (metaData.buildInfo.hash, exposes[].hash, shared[].hash).
  • Introduces an optional runtime cache integration via global MFE_CACHE_LAYER (new ICacheLayer contract).
  • asyncRequire now routes remote bundle loading through the cache layer when enabled (production by default), with inflight dedup and safe fallback to loadBundleAsync on skipped.
  • metroCorePlugin.afterResolve extracts resolved bundle URLs (dev/prod compatible) from manifest and registers expected hashes + manifest source to the cache layer (best-effort).

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.

@netlify

netlify Bot commented Mar 20, 2026

Copy link
Copy Markdown

Deploy Preview for module-federation-docs ready!

Name Link
🔨 Latest commit 46c1181
🔍 Latest deploy log https://app.netlify.com/projects/module-federation-docs/deploys/69d8a611a0b7a20008ab5809
😎 Deploy Preview https://deploy-preview-4576--module-federation-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@changeset-bot

changeset-bot Bot commented Mar 20, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 46c1181

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@zhongwuzw zhongwuzw changed the title [metro-cache] metro-cache preview [metro-core] metro-core add cache Mar 26, 2026
@zhongwuzw zhongwuzw changed the title [metro-core] metro-core add cache (metro-core): add manifest SHA-256 hashes and optional bundle cache layer integration Mar 26, 2026

@jbroma jbroma left a comment

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.

hey @zhongwuzw , nice job with the implementation so far, I have few questions & one ideas about how to make this even better, let me know what you think!

Comment thread pnpm-lock.yaml

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.

since there are no package.json changes in this PR, this is probably a leftover from previous changes, could you please verify this?

Comment on lines +375 to +378
const bundleContent = await fs.readFile(
saveBundleOpts.bundleOutput,
'utf-8',
);

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.

perhaps we can use bundle directly here? I don't see any benefit of reading it again from the filesystem

@zhongwuzw zhongwuzw Apr 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@jbroma Hi, I see we have --bundle-encoding in bundle-mf-remote — do we actually support non-UTF-8? https://github.com/zhongwuzw/core/blob/b2472772250dad91947eefa31b724582441f95e9/packages/metro-core/src/commands/bundle-remote/index.ts#L335

The option accepts utf8 | utf16le | ascii, but encoding info is never passed to the upload layer or stored in the manifest. The server/CDN has no idea what encoding the bundle was written in, and the client always decodes as UTF-8? If a user passes --bundle-encoding utf16le, the bundle will silently break at runtime I think. Did I miss anything?

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.

Probably not, it's fine to assume utf-8 for now - ideally we would store this information somewhere so we dont have to guess

// Inject container bundle hash into metaData.buildInfo.hash
const containerFilename = federationConfig.filename;
if (bundleHashMap.has(containerFilename)) {
rawManifest.metaData.buildInfo.hash =

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.

is this typed in the MF core or is this a non-standard field?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

shared[].hash is already typed in MF core (StatsShared.hash: string) — Metro generates it as an empty string and I populate it post-build. metaData.buildInfo.hash and expose[].hash are non-standard — StatsBuildInfo and StatsExpose don't have a hash field. Happy to add hash?: string to the core types if you'd like to formalize it.

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.

yes, let's standardize those then 👍

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

Comment on lines +121 to +157
afterResolve: (args) => {
// Register bundle hashes with cache layer for integrity verification
try {
const cacheLayer = (globalThis as any).__MFE_CACHE_LAYER__ as
| ICacheLayer
| undefined;
if (!cacheLayer) return args;

const __loadBundleAsync =
globalThis[`${__METRO_GLOBAL_PREFIX__ ?? ''}__loadBundleAsync`];
const { origin, remoteInfo, remote } = args;
const manifestUrl =
'entry' in remote ? (remote as any).entry : undefined;
if (manifestUrl && origin.snapshotHandler?.manifestCache) {
const manifest =
origin.snapshotHandler.manifestCache.get(manifestUrl);
if (manifest) {
// Container bundle hash
const containerHash = (manifest.metaData?.buildInfo as any)?.hash;
if (containerHash && remoteInfo.entry) {
cacheLayer.registerBundleHash(remoteInfo.entry, containerHash);
}

const loadBundleAsync =
__loadBundleAsync as typeof globalThis.__loadBundleAsync;
// Exposed + shared bundle hashes
const hashes = extractBundleHashes(manifest, manifestUrl);
for (const [url, hash] of hashes) {
// Strip query params — loadBundle looks up hashes by bare URL
cacheLayer.registerBundleHash(url.split('?')[0], hash);
}

if (!loadBundleAsync) {
throw new Error('loadBundleAsync is not defined');
}
// Register manifest source for polling
cacheLayer.registerManifestSource(manifestUrl, extractBundleHashes);
}
}
} catch {
// non-critical — hash validation is best-effort
}
return args;
},

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.

I think we could extract this to the cache package and make it a separate runtime plugin instead - do you think it's possible?

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.

bump

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@jbroma Hey.

I think keeping it in metroCorePlugin is the better fit here. Two reasons:
Separation of concerns — the afterResolve hook parses MF manifest structure and builds Metro-specific URLs, then passes clean (url, hash) pairs to the cache layer via registerBundleHash(). Moving it to the cache package would couple cache with MF manifest types and Metro URL format.
Zero-config — currently it's a no-op when cache isn't registered (if (!cacheLayer) return args). If extracted to a separate runtime plugin, every app that resolves remotes (host + nested remotes) would need to add it to runtimePlugins.

What do u think?

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.

oh yeah, very good point, we need to keep this here then 👍

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.

perhaps we could make this PR more complete by providing a default in-memory implementation of this cache interface and always pipe everything through cache - this way we would just swap cache backends later, there would be no need for branching

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@jbroma

Just to make sure I understand — are you suggesting providing a no-op default ICacheLayer so afterResolve always pipes through the cache interface without the if (!cacheLayer) return args guard?If so, my concern is that it would still execute the manifest parsing, hash extraction, and URL resolution on every afterResolve call — only to feed the results into empty no-op functions. The early return may avoids that unnecessary work when no cache backend is registered.

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.

yes, on second thought even tho it streamlines the process and keeps things branchless, there is a lot of work to do that's totally skippable, just an idea I had, thank you for taking time to think this through.

@jbroma jbroma Apr 9, 2026

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.

btw I've found out about MF global plugins:

All instances on a page share a singleton array at window.FEDERATION.GLOBAL_PLUGIN

export const registerGlobalPlugins = (
plugins: Array<ModuleFederationRuntimePlugin>,
): void => {
const { __GLOBAL_PLUGIN__ } = nativeGlobal.__FEDERATION__;
plugins.forEach((plugin) => {
if (__GLOBAL_PLUGIN__.findIndex((p) => p.name === plugin.name) === -1) {
__GLOBAL_PLUGIN__.push(plugin);
} else {
warn(`The plugin ${plugin.name} has been registered.`);
}
});
};
export const getGlobalHostPlugins = (): Array<ModuleFederationRuntimePlugin> =>
nativeGlobal.__FEDERATION__.__GLOBAL_PLUGIN__;

When any instance initializes — host or remote - it includes the global plugins

export function registerPlugins(
plugins: UserOptions['plugins'],
instance: ModuleFederation,
) {
const globalPlugins = getGlobalHostPlugins();
const hookInstances = [
instance.hooks,
instance.remoteHandler.hooks,
instance.sharedHandler.hooks,
instance.snapshotHandler.hooks,
instance.loaderHook,
instance.bridgeHook,
];
// Incorporate global plugins
if (globalPlugins.length > 0) {
globalPlugins.forEach((plugin) => {
if (plugins?.find((item) => item.name !== plugin.name)) {
plugins.push(plugin);
}
});
}
if (plugins && plugins.length > 0) {
plugins.forEach((plugin) => {
hookInstances.forEach((hookInstance) => {
hookInstance.applyPlugin(plugin, instance);
});
});
}
return plugins;
}

  • registerGlobalPlugins([plugin]) → applies to every instance (host + all remotes)

so the actual approach with just host using this is actually valid 👍

Comment on lines +102 to +123
// For remote split bundles with cache enabled, convert relative paths to
// full URLs so they enter the same cache path as container bundles.
// In dev mode, getBundlePath returns relative paths unchanged, but we need
// full URLs for the cache layer (download + eval).
if (isSplitBundle && cacheLayer && publicPath && !isUrl(bundlePath)) {
bundlePath = joinComponents(publicPath, bundlePath);
}

// --- Cache layer: intercept bundles with full URLs (containers + remote split bundles) ---
if (cacheLayer && isUrl(bundlePath)) {
const { status } = await cacheLayer.loadBundle(bundlePath);
if (status === 'skipped') {
// Cache layer skipped — fall back to network load
const encodedBundlePath = bundlePath.replaceAll('../', '..%2F');
await loadBundleAsync(encodedBundlePath);
}
// else: 'cache-hit' or 'downloaded' — bundle already eval'd by cache layer
} else {
// No cache: host split bundles (no publicPath), cache disabled, or native-cache not installed
const encodedBundlePath = bundlePath.replaceAll('../', '..%2F');
await loadBundleAsync(encodedBundlePath);
}

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.

This looks good but to me it feels out of place - with an optional layer we are introducing a lot of complexity into this module - do you think it would be possible to create wrapper around loadBundleAsync that introduces the cache layer and then load the MF wrapper?

I think we could rework the asyncRequire implementation, so that it would be possible to modify the actual loadBundleAsync and then add MF wrapper on top of it:

  1. InitializeCore runs -> __loadBundleAsync gets initialized with default implementation
  2. Cache wrapper runs -> enhances __loadBundleAsync with caching capabilities
  3. MF metro-core wrapper runs -> adapts __loadBundleAsync to work with MF

This approach would most likely require splitting asyncRequire into two modules, something like this:

  • mf:init-async-require -> injects expo impl of async require if missing
  • mf:adapt-async-require -> adapts existing impl of async require to work with federation

in the cache plugin you could then modify the resolver for either of those to ensure module with cache wrapper runs after init and before adapt

mf:async-require, left over for compatibility could be then just:

import `mf:init-async-require`;
import `mf:adapt-async-require`;

let me know what you think & if that makes sense to you

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@zhongwuzw zhongwuzw requested a review from jbroma April 6, 2026 15:19
const isSplitBundle = !isUrl(originalBundlePath);

// Cache handler registered externally (e.g. by zephyr-native-cache register()).
const cacheHandler = (globalThis as any).__MFE_CACHE__ as

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.

lets group this under globalThis.__FEDERATION__.__NATIVE__ like globalThis.__FEDERATION__.__NATIVE__.__CACHE__ - important to note to not use any scope qualifier using __METRO_GLOBAL_PREFIX__ in order to make this a singleton entity.

Comment on lines -76 to -78
// entry is always in the root directory of assets associated with remote
// based on that, we extract the public path from the origin URL
// e.g. http://example.com/a/b/c/mf-manfiest.json -> http://example.com/a/b/c

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.

keep those comments please

bundlePath = joinComponents(publicPath, bundlePath);
}

// ../../node_modules/ -> ..%2F..%2Fnode_modules/ so that it's not automatically sanitized

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.

keep

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.

This interface is now redundant since we we have changed requirements in asyncRequire which no longer depend on this type and the afterResolve is scheduled to be moved to the cache impl. itself

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment on lines +121 to +157
afterResolve: (args) => {
// Register bundle hashes with cache layer for integrity verification
try {
const cacheLayer = (globalThis as any).__MFE_CACHE_LAYER__ as
| ICacheLayer
| undefined;
if (!cacheLayer) return args;

const __loadBundleAsync =
globalThis[`${__METRO_GLOBAL_PREFIX__ ?? ''}__loadBundleAsync`];
const { origin, remoteInfo, remote } = args;
const manifestUrl =
'entry' in remote ? (remote as any).entry : undefined;
if (manifestUrl && origin.snapshotHandler?.manifestCache) {
const manifest =
origin.snapshotHandler.manifestCache.get(manifestUrl);
if (manifest) {
// Container bundle hash
const containerHash = (manifest.metaData?.buildInfo as any)?.hash;
if (containerHash && remoteInfo.entry) {
cacheLayer.registerBundleHash(remoteInfo.entry, containerHash);
}

const loadBundleAsync =
__loadBundleAsync as typeof globalThis.__loadBundleAsync;
// Exposed + shared bundle hashes
const hashes = extractBundleHashes(manifest, manifestUrl);
for (const [url, hash] of hashes) {
// Strip query params — loadBundle looks up hashes by bare URL
cacheLayer.registerBundleHash(url.split('?')[0], hash);
}

if (!loadBundleAsync) {
throw new Error('loadBundleAsync is not defined');
}
// Register manifest source for polling
cacheLayer.registerManifestSource(manifestUrl, extractBundleHashes);
}
}
} catch {
// non-critical — hash validation is best-effort
}
return args;
},

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.

bump

@zhongwuzw zhongwuzw requested a review from jbroma April 10, 2026 07:28
@zhongwuzw

Copy link
Copy Markdown
Contributor Author

@jbroma Hi. Please review again.

@jbroma jbroma left a comment

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.

Hey @zhongwuzw, thanks for addressing the latest round of feedback — the namespace migration and hash type standardization look great.

I've been testing this branch and experimenting with a few changes that could reduce the mf-core surface area. I'm keen on getting this PR wrapped up and merged — I'm building on top of it so getting the final shape nailed down will really help move things forward.

Build-time hashing in the serializer

While testing I found it useful to compute SHA-256 hashes directly in the serializer pipeline rather than post-build. Since the serializer already has the bundle code in hand, we can hash it right there and thread the hashes through manifest generation. This means hashes get populated in the manifest as bundles are produced — no disk round-trip needed.

The core addition to manifest.ts:

type BundleHashMap = Map<string, string>;

export function recordBundleHash(
  hashes: BundleHashMap,
  code: string,
  entryPoint: string,
  projectRoot: string,
  config: ModuleFederationConfigNormalized,
): void {
  const hash = crypto.createHash('sha256').update(code).digest('hex');
  const key = resolveBundleKey(entryPoint, projectRoot, config);
  if (key) hashes.set(key, hash);
}

resolveBundleKey maps each entryPoint to its manifest slot — container:X when the basename matches config.filename, expose:X when the relative path matches an expose config entry, or shared:X when the path contains a node_modules/ package name present in config.shared.

Then in serializer.ts, we accumulate hashes across serializer invocations and rewrite the manifest after each bundle:

export function getModuleFederationSerializer(
  mfConfig: ModuleFederationConfigNormalized,
  isUsingMFBundleCommand: boolean,
  manifestPath?: string,          // ← new param, threaded from augmentConfig
): CustomSerializer {
  const bundleHashes = new Map<string, string>();

  return async (entryPoint, preModules, graph, options) => {
    // ... existing serialization logic produces `code` ...

    if (manifestPath) {
      recordBundleHash(bundleHashes, code, entryPoint, options.projectRoot, mfConfig);
      updateManifest(manifestPath, mfConfig, bundleHashes);
    }

    return code;
  };
}

The manifest generation functions (generateMetaData, generateExposes, generateShared) each accept an optional hashes?: BundleHashMap and use it to populate the hash fields — e.g. hash: hashes?.get('container:${config.name}') ?? ''.

This also has the nice side effect of making hashes available during dev server builds, not just production bundle-remote runs.

Moving the runtime cache registration out of metroCorePlugin

Remember our earlier discussion about extracting the afterResolve hook to the cache package (comment thread starting at my initial suggestion)? At the time, you raised two very valid points — separation of concerns and zero-config — and I agreed to keep it in metroCorePlugin.

Since then though, I found the global plugins mechanism (__FEDERATION__.__GLOBAL_PLUGIN__, comment here), which addresses the zero-config concern. A consumer can provide their own runtime plugin that uses beforeInit to register into __GLOBAL_PLUGIN__ — add it once to the host config via runtimePlugins, and it automatically applies to all instances including nested remotes. No per-remote configuration needed.

With that in place, the afterResolve hook, extractBundleHashes, buildUrlForSplitBundle, and cache-interface.ts could all move out of mf-core, and metroCorePlugin would go back to its minimal form — just loadEntry + generatePreloadAssets. The asyncRequire changes would stay exactly as they are.

This would keep mf-core cache-agnostic while still providing all the hooks needed for external cache integration. I've been working with this approach and can push the mf-core side as commits on your branch if you'd like to see it in action — should help us get this across the finish line faster.

What do you think?

@zhongwuzw

Copy link
Copy Markdown
Contributor Author

Hey @zhongwuzw, thanks for addressing the latest round of feedback — the namespace migration and hash type standardization look great.

I've been testing this branch and experimenting with a few changes that could reduce the mf-core surface area. I'm keen on getting this PR wrapped up and merged — I'm building on top of it so getting the final shape nailed down will really help move things forward.

Build-time hashing in the serializer

While testing I found it useful to compute SHA-256 hashes directly in the serializer pipeline rather than post-build. Since the serializer already has the bundle code in hand, we can hash it right there and thread the hashes through manifest generation. This means hashes get populated in the manifest as bundles are produced — no disk round-trip needed.

The core addition to manifest.ts:

type BundleHashMap = Map<string, string>;

export function recordBundleHash(
  hashes: BundleHashMap,
  code: string,
  entryPoint: string,
  projectRoot: string,
  config: ModuleFederationConfigNormalized,
): void {
  const hash = crypto.createHash('sha256').update(code).digest('hex');
  const key = resolveBundleKey(entryPoint, projectRoot, config);
  if (key) hashes.set(key, hash);
}

resolveBundleKey maps each entryPoint to its manifest slot — container:X when the basename matches config.filename, expose:X when the relative path matches an expose config entry, or shared:X when the path contains a node_modules/ package name present in config.shared.

Then in serializer.ts, we accumulate hashes across serializer invocations and rewrite the manifest after each bundle:

export function getModuleFederationSerializer(
  mfConfig: ModuleFederationConfigNormalized,
  isUsingMFBundleCommand: boolean,
  manifestPath?: string,          // ← new param, threaded from augmentConfig
): CustomSerializer {
  const bundleHashes = new Map<string, string>();

  return async (entryPoint, preModules, graph, options) => {
    // ... existing serialization logic produces `code` ...

    if (manifestPath) {
      recordBundleHash(bundleHashes, code, entryPoint, options.projectRoot, mfConfig);
      updateManifest(manifestPath, mfConfig, bundleHashes);
    }

    return code;
  };
}

The manifest generation functions (generateMetaData, generateExposes, generateShared) each accept an optional hashes?: BundleHashMap and use it to populate the hash fields — e.g. hash: hashes?.get('container:${config.name}') ?? ''.

This also has the nice side effect of making hashes available during dev server builds, not just production bundle-remote runs.

Moving the runtime cache registration out of metroCorePlugin

Remember our earlier discussion about extracting the afterResolve hook to the cache package (comment thread starting at my initial suggestion)? At the time, you raised two very valid points — separation of concerns and zero-config — and I agreed to keep it in metroCorePlugin.

Since then though, I found the global plugins mechanism (__FEDERATION__.__GLOBAL_PLUGIN__, comment here), which addresses the zero-config concern. A consumer can provide their own runtime plugin that uses beforeInit to register into __GLOBAL_PLUGIN__ — add it once to the host config via runtimePlugins, and it automatically applies to all instances including nested remotes. No per-remote configuration needed.

With that in place, the afterResolve hook, extractBundleHashes, buildUrlForSplitBundle, and cache-interface.ts could all move out of mf-core, and metroCorePlugin would go back to its minimal form — just loadEntry + generatePreloadAssets. The asyncRequire changes would stay exactly as they are.

This would keep mf-core cache-agnostic while still providing all the hooks needed for external cache integration. I've been working with this approach and can push the mf-core side as commits on your branch if you'd like to see it in action — should help us get this across the finish line faster.

What do you think?

@jbroma Hey, thanks for your review,

Here are my thoughts — the serializer approach is a nice idea, but I'd lean towards keeping hashing in bundle-remote for now. It's the last point before saveBundleAndMap, so we're guaranteed to hash exactly what gets written — if any post-processing were ever added between serializer and disk write, serializer-based hashes could silently diverge. As for dev-mode hashing, I intentionally skipped it since local dev server loads are fast enough and caching/polling doesn't add much value there. That said, happy to revisit if you feel strongly about it!

@zhongwuzw zhongwuzw marked this pull request as ready for review April 17, 2026 15:01
Copilot AI review requested due to automatic review settings April 17, 2026 15:01

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 46c118120e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +86 to +87
const cacheHandler = (globalThis as any).__FEDERATION__?.__NATIVE__
?.__CACHE__ as

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Read cache layer from the registered global key

afterResolve stores and uses the cache object at __FEDERATION__.__NATIVE__.__CACHE_LAYER__ (metroCorePlugin.ts), but buildLoadBundleAsyncWrapper looks for __FEDERATION__.__NATIVE__.__CACHE__ here. In environments that implement the new ICacheLayer contract, the loader never sees the cache layer, so split/entry bundle loads bypass caching and the hash registrations done in afterResolve are effectively unused. This makes the new cache integration non-functional unless consumers also set a second, undocumented global.

Useful? React with 👍 / 👎.

Copilot AI left a comment

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.

Pull request overview

Adds bundle integrity metadata (SHA-256) to Metro federation manifests and introduces a (global) optional runtime cache-layer integration intended to use these hashes for verified bundle loading.

Changes:

  • Compute SHA-256 hashes for container/exposed/shared bundles during bundle-remote and inject them into mf-manifest.json.
  • Add a cache-layer contract (ICacheLayer) and register expected bundle hashes / manifest sources in metroCorePlugin.afterResolve.
  • Route async bundle loading through an externally-registered cache handler in asyncRequire (with URL normalization for split bundles).

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/sdk/src/types/stats.ts Adds optional hash fields to stats types used by manifests/build info.
packages/metro-core/src/modules/metroCorePlugin.ts Extracts/registers bundle hashes from manifest into an optional cache layer during afterResolve.
packages/metro-core/src/modules/cache-interface.ts Introduces ICacheLayer contract and documents the intended global attachment point.
packages/metro-core/src/modules/asyncRequire.ts Adds cache-handler interception for __loadBundleAsync, with URL normalization for split bundles.
packages/metro-core/src/commands/bundle-remote/index.ts Computes SHA-256 hashes for emitted bundles and injects them into the manifest.
.gitignore Ignores additional native build artifacts.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


// entry is always in the root directory of assets associated with remote
// based on that, we extract the public path from the origin URL
// e.g. http://example.com/a/b/c/mf-manfiest.json -> http://example.com/a/b/c
Comment on lines +1 to +6
/**
* Interface for the global cache layer (`globalThis.__FEDERATION__.__NATIVE__.__CACHE_LAYER__`).
*
* Metro-core never imports native-cache directly — it only
* reads this global, keeping the two packages decoupled.
*/
Comment on lines +39 to +61
function addHashes(items: any[] | undefined, isContainer: boolean) {
if (!Array.isArray(items)) return;
for (const item of items) {
const hash = (item as any)?.hash;
const syncJs = item?.assets?.js?.sync;
if (hash && syncJs) {
for (const assetPath of syncJs) {
// In dev, asset paths use source extensions (.tsx/.ts) — normalize to .bundle
const bundlePath = assetPath.replace(/\.\w+$/, '.bundle');
const bareUrl = resolvedPublicPath
? `${resolvedPublicPath.replace(/\/+$/, '')}/${bundlePath.replace(/^\.?\//, '')}`
: bundlePath;
const fullUrl = isContainer
? buildUrlForEntryBundle(bareUrl)
: buildUrlForSplitBundle(bareUrl);
hashes.set(fullUrl, hash);
}
}
}
}

addHashes(manifest?.exposes, false);
addHashes(manifest?.shared, false);
Comment on lines +124 to +128
const cacheLayer = (globalThis as any).__FEDERATION__?.__NATIVE__
?.__CACHE_LAYER__ as
| ICacheLayer
| undefined;
if (!cacheLayer) return args;
Comment on lines +106 to +109
if (cacheHandler) {
await cacheHandler(loadBundleAsync, encodedBundlePath);
} else {
result = await loadBundleAsync(encodedBundlePath);
@zackarychapple zackarychapple merged commit e61734f into module-federation:main Apr 29, 2026
7 of 8 checks passed
@2heal1 2heal1 mentioned this pull request May 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants