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
5 changes: 5 additions & 0 deletions .changeset/fuzzy-geese-wonder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@module-federation/devtools": patch
---

fix(chrome-devtool): Avoid message crashes in devtools by converting functions and other unsafe values into safe placeholders before forwarding module data.
50 changes: 50 additions & 0 deletions packages/chrome-devtools/__tests__/safe-post-message.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';

import { sanitizePostMessagePayload } from '../src/utils/chrome/safe-post-message';

describe('sanitizePostMessagePayload', () => {
it('replaces functions and unsupported values with safe content', () => {
const source = {
fn: () => 'ok',
nested: {
value: 1,
handler: function namedHandler() {
return true;
},
},
list: [undefined, Symbol('token'), 1n],
error: new Error('boom'),
regex: /mf/g,
};

expect(sanitizePostMessagePayload(source)).toMatchObject({
fn: 'function(){}',
nested: {
value: 1,
handler: 'function(){}',
},
list: ['[undefined]', 'Symbol(token)', '1'],
error: {
name: 'Error',
message: 'boom',
},
regex: '/mf/g',
});
});

it('handles circular data without throwing', () => {
const source: Record<string, unknown> = {
name: 'root',
};
source.self = source;
source.map = new Map([['self', source]]);
source.set = new Set([source]);

expect(sanitizePostMessagePayload(source)).toEqual({
name: 'root',
self: '[circular]',
map: [['self', '[circular]']],
set: ['[circular]'],
});
});
});
15 changes: 8 additions & 7 deletions packages/chrome-devtools/src/utils/chrome/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { GlobalModuleInfo } from '@module-federation/sdk';
import { FormID } from '../../template/constant';
import { definePropertyGlobalVal } from '../sdk';
import { sanitizePostMessagePayload } from './safe-post-message';

export * from './storage';

Expand Down Expand Up @@ -108,8 +109,8 @@ export const getGlobalModuleInfo = async (
) => {
if (typeof window !== 'undefined' && window.__FEDERATION__?.moduleInfo) {
callback(
JSON.parse(
JSON.stringify(window.__FEDERATION__?.moduleInfo),
sanitizePostMessagePayload(
window.__FEDERATION__?.moduleInfo,
) as GlobalModuleInfo,
);
}
Expand All @@ -125,8 +126,8 @@ export const getGlobalModuleInfo = async (
definePropertyGlobalVal(window, '__FEDERATION__', {});
definePropertyGlobalVal(window, '__VMOK__', window.__FEDERATION__);
}
window.__FEDERATION__.originModuleInfo = JSON.parse(
JSON.stringify(data?.moduleInfo),
window.__FEDERATION__.originModuleInfo = sanitizePostMessagePayload(
data?.moduleInfo,
);
if (data?.updateModule) {
const moduleIds = Object.keys(window.__FEDERATION__.originModuleInfo);
Expand All @@ -145,10 +146,10 @@ export const getGlobalModuleInfo = async (
}
}
if (data?.share) {
window.__FEDERATION__.__SHARE__ = data.share;
window.__FEDERATION__.__SHARE__ = sanitizePostMessagePayload(data.share);
}
window.__FEDERATION__.moduleInfo = JSON.parse(
JSON.stringify(window.__FEDERATION__.originModuleInfo),
window.__FEDERATION__.moduleInfo = sanitizePostMessagePayload(
window.__FEDERATION__.originModuleInfo,
);
console.log('getGlobalModuleInfo window', window.__FEDERATION__);
callback(window.__FEDERATION__.moduleInfo);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { sanitizePostMessagePayload } from './safe-post-message';

if (window.moduleHandler) {
window.removeEventListener('message', window.moduleHandler);
} else {
Expand All @@ -10,11 +12,11 @@ if (window.moduleHandler) {
chrome.runtime
.sendMessage({
origin,
data: {
data: sanitizePostMessagePayload({
moduleInfo: data.moduleInfo,
updateModule: data.updateModule,
share: data.share,
},
}),
})
.catch(() => {
return false;
Expand Down
15 changes: 5 additions & 10 deletions packages/chrome-devtools/src/utils/chrome/post-message-start.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
import { sanitizePostMessagePayload } from './safe-post-message';

// The purpose of this script is: the global plug-in injection is very early, the post message sends the message, the devtools receives the message listener is not running, resulting in the initial data is not available
// To get the initial data, actively get the global variable to send the message
const moduleInfo = window?.__FEDERATION__?.moduleInfo;
window.postMessage(
{
sanitizePostMessagePayload({
moduleInfo,
share: JSON.parse(
JSON.stringify(window?.__FEDERATION__?.__SHARE__, (_key, value) => {
if (typeof value === 'function') {
return 'Function';
}
return value;
}),
),
},
share: window?.__FEDERATION__?.__SHARE__,
}),
'*',
);
5 changes: 3 additions & 2 deletions packages/chrome-devtools/src/utils/chrome/post-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import helpers from '@module-federation/runtime/helpers';
import type { ModuleFederationRuntimePlugin } from '@module-federation/runtime';

import { definePropertyGlobalVal } from '../sdk';
import { sanitizePostMessagePayload } from './safe-post-message';

type LoadRemoteSnapshotArgs = Parameters<
NonNullable<ModuleFederationRuntimePlugin['loadRemoteSnapshot']>
Expand All @@ -20,10 +21,10 @@ const getModuleInfo = (): ModuleFederationRuntimePlugin => {

if (!options || options.inBrowser) {
window.postMessage(
{
sanitizePostMessagePayload({
moduleInfo: globalSnapshot,
updateModule: moduleInfo,
},
}),
'*',
);
}
Expand Down
157 changes: 157 additions & 0 deletions packages/chrome-devtools/src/utils/chrome/safe-post-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
const FUNCTION_PLACEHOLDER = 'function(){}';
const UNDEFINED_PLACEHOLDER = '[undefined]';
const CIRCULAR_PLACEHOLDER = '[circular]';
const NON_SERIALIZABLE_PLACEHOLDER = '[unserializable]';

const toStringTag = (value: unknown) =>
Object.prototype.toString.call(value).slice(8, -1);

const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (!value || typeof value !== 'object') {
return false;
}

const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
};

const sanitizeValue = (
value: unknown,
seen: WeakMap<object, unknown>,
): unknown => {
if (
value === null ||
typeof value === 'string' ||
typeof value === 'number'
) {
return Number.isNaN(value) ? '[NaN]' : value;
}

if (typeof value === 'boolean') {
return value;
}

if (typeof value === 'function') {
return FUNCTION_PLACEHOLDER;
}

if (typeof value === 'undefined') {
return UNDEFINED_PLACEHOLDER;
}

if (typeof value === 'bigint' || typeof value === 'symbol') {
return String(value);
}

if (!(value instanceof Object)) {
return NON_SERIALIZABLE_PLACEHOLDER;
Comment on lines +46 to +47
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 Treat null-prototype records as serializable objects

The instanceof Object guard rejects Object.create(null) values before they reach the object-walk logic, so they are replaced with [unserializable] instead of being traversed. Webpack/module-federation metadata commonly uses null-prototype dictionaries, so this can drop large parts of moduleInfo/share payloads after this change.

Useful? React with 👍 / 👎.

}

if (seen.has(value)) {
return CIRCULAR_PLACEHOLDER;
}

if (Array.isArray(value)) {
const next: unknown[] = [];
seen.set(value, next);
value.forEach((item, index) => {
next[index] = sanitizeValue(item, seen);
});
return next;
}

if (value instanceof Date) {
return value.toISOString();
Comment on lines +63 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle invalid Date objects without throwing

toISOString() throws on invalid dates (for example new Date(NaN)), and this call is unconditional. In array/map/set/root paths that exception is not caught, so a single invalid Date can still crash sanitization and prevent postMessage forwarding, which defeats the purpose of this safety layer.

Useful? React with 👍 / 👎.

}

if (value instanceof RegExp) {
return value.toString();
}

if (value instanceof Error) {
const next = {
name: value.name,
message: value.message,
stack: value.stack || '',
};
seen.set(value, next);
return next;
}

if (value instanceof Map) {
const next: unknown[] = [];
seen.set(value, next);
next.push(
...Array.from(value.entries()).map(([key, item]) => [
sanitizeValue(key, seen),
sanitizeValue(item, seen),
]),
);
return next;
}

if (value instanceof Set) {
const next: unknown[] = [];
seen.set(value, next);
next.push(
...Array.from(value.values()).map((item) => sanitizeValue(item, seen)),
);
return next;
}

if (ArrayBuffer.isView(value)) {
return Array.from(new Uint8Array(value.buffer));
Comment on lines +102 to +103
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Serialize typed-array views with offset and length

For ArrayBuffer views, converting value.buffer serializes the full backing buffer and ignores byteOffset/byteLength. When the payload contains a subarray or DataView, this forwards extra bytes (possibly unrelated data) and produces incorrect message content.

Useful? React with 👍 / 👎.

}

if (value instanceof ArrayBuffer) {
return Array.from(new Uint8Array(value));
}

if (typeof Node !== 'undefined' && value instanceof Node) {
return `[${toStringTag(value)}]`;
}

if (typeof Window !== 'undefined' && value instanceof Window) {
return '[Window]';
}

if (typeof Document !== 'undefined' && value instanceof Document) {
return '[Document]';
}

const next: Record<string, unknown> = {};
seen.set(value, next);

const entries = isPlainObject(value)
? Object.keys(value).map((key) => {
try {
return [key, value[key]] as const;
} catch (_error) {
return [key, NON_SERIALIZABLE_PLACEHOLDER] as const;
}
})
: Reflect.ownKeys(value).map((key) => {
try {
return [
String(key),
(value as Record<PropertyKey, unknown>)[key],
] as const;
} catch (_error) {
return [String(key), NON_SERIALIZABLE_PLACEHOLDER] as const;
}
});

entries.forEach(([key, item]) => {
try {
next[key] = sanitizeValue(item, seen);
} catch (_error) {
next[key] = NON_SERIALIZABLE_PLACEHOLDER;
}
});

return next;
};

export const sanitizePostMessagePayload = <T>(payload: T): T => {
return sanitizeValue(payload, new WeakMap<object, unknown>()) as T;
};
Loading