diff --git a/.changeset/symbolication-stack-frames.md b/.changeset/symbolication-stack-frames.md new file mode 100644 index 000000000..ac1a5769b --- /dev/null +++ b/.changeset/symbolication-stack-frames.md @@ -0,0 +1,14 @@ +--- +"@callstack/repack-dev-server": patch +"@callstack/repack": patch +--- + +Fix stack-frame symbolication and open-in-editor for Module Federation setups: + +- Symbolication no longer fails the whole request when one frame's source map is unavailable (e.g. a federated remote chunk served by another dev server) — failed frames pass through unchanged, mirroring Metro, and the response stack is always 1:1 with the request. +- Source maps for bundles the local compiler didn't build are now fetched via the `//# sourceMappingURL=` comment the bundle declares, resolved relative to the bundle URL and validated before use. +- Symbolication survives corrupt source names that Module Federation v2 emits into host bundle maps (previously `Invalid URL` failed every stack on MFv2 hosts). +- Frames are collapsed by resolved source path (like React Native's default Metro config) and the source map `ignoreList`/`x_google_ignoreList` field, instead of not at all. +- `/open-stack-frame` now decodes URL-encoded `[projectRoot^N]` tokens (returned as `[projectRoot%5E2]/...` by symbolication), so tapping frames that resolve into `node_modules` opens the editor instead of silently doing nothing. +- Editor launch failures are logged with an actionable hint (set `REACT_EDITOR`) instead of being silently swallowed; frames at generated column 0 symbolicate correctly. +- The dev server now blocks cross-origin browser requests (restoring the `securityHeadersMiddleware` behavior of the React Native community CLI), so a website the developer visits cannot drive dev-only endpoints such as `/open-stack-frame` and `/open-url` to open arbitrary files or URLs on their machine. diff --git a/apps/tester-federation-v2/configs/rspack.mini-app.mts b/apps/tester-federation-v2/configs/rspack.mini-app.mts index dd45a8cf9..045e08b0d 100644 --- a/apps/tester-federation-v2/configs/rspack.mini-app.mts +++ b/apps/tester-federation-v2/configs/rspack.mini-app.mts @@ -15,6 +15,7 @@ export default Repack.defineRspackConfig((env) => { output: { path: '[context]/build/mini-app/[platform]', uniqueName: 'MF2Tester-MiniApp', + chunkFilename: '[name].MiniApp.chunk.bundle', }, module: { rules: [ diff --git a/packages/dev-server/src/createServer.ts b/packages/dev-server/src/createServer.ts index 5fba02391..345540571 100644 --- a/packages/dev-server/src/createServer.ts +++ b/packages/dev-server/src/createServer.ts @@ -14,6 +14,7 @@ import wssPlugin from './plugins/wss/wssPlugin.js'; import { Internal, type Middleware, type Server } from './types.js'; import { handleCustomNetworkLoadResource } from './utils/networkLoadResourceHandler.js'; import { normalizeOptions } from './utils/normalizeOptions.js'; +import { registerOriginGuard } from './utils/originGuard.js'; /** * Create instance of development server, powered by Fastify. @@ -44,6 +45,11 @@ export async function createServer(config: Server.Config) { ...(options.https ? { https: options.https } : {}), }); + // Block cross-origin browser requests before anything else runs, so a + // malicious website cannot drive dev-only endpoints (e.g. opening files or + // URLs on the developer's machine) via the browser. + registerOriginGuard(instance, options.host); + delegate = config.delegate({ options, log: instance.log, diff --git a/packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts b/packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts index 7ffe06063..aa12d6814 100644 --- a/packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts +++ b/packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import path from 'node:path'; import type { FastifyInstance } from 'fastify'; import fastifyPlugin from 'fastify-plugin'; import launchEditor from 'launch-editor'; @@ -19,10 +21,17 @@ function parseRequestBody(body: unknown): T { throw new Error(`Unsupported body type: ${typeof body}`); } +const SAME_FRAME_SUPPRESSION_MS = 1000; + async function devtoolsPlugin( instance: FastifyInstance, { delegate }: { delegate: Server.Delegate } ) { + // Repeated identical open-stack-frame requests (double taps, a client + // stuck retrying) would each spawn an editor process; suppress launches + // of the same frame in quick succession. + let lastLaunchedFrame: string | undefined; + let lastLaunchedAt = 0; // reference implementation in `@react-native-community/cli-server-api`: // https://github.com/react-native-community/cli/blob/46436a12478464752999d34ed86adf3212348007/packages/cli-server-api/src/openURLMiddleware.ts instance.route({ @@ -44,8 +53,51 @@ async function devtoolsPlugin( const { file, lineNumber } = parseRequestBody( request.body ); + if ( + typeof file !== 'string' || + file.includes('\0') || + !Number.isFinite(Number(lineNumber)) + ) { + reply.badRequest('Invalid open-stack-frame request body'); + return; + } const filepath = delegate.devTools?.resolveProjectPath(file) ?? file; - launchEditor(`${filepath}:${lineNumber}`, process.env.REACT_EDITOR); + + const frame = `${filepath}:${lineNumber}`; + if ( + frame === lastLaunchedFrame && + Date.now() - lastLaunchedAt < SAME_FRAME_SUPPRESSION_MS + ) { + reply.send('OK'); + return; + } + lastLaunchedFrame = frame; + lastLaunchedAt = Date.now(); + + // launch-editor silently ignores files that don't exist, so surface + // resolution failures here — otherwise tapping a stack frame does + // nothing with no trace of why. + if (!fs.existsSync(filepath)) { + request.log.warn( + `Could not open ${file} in your editor: it resolved to ` + + `${filepath}, which does not exist.` + ); + reply.send('OK'); + return; + } + + launchEditor( + `${filepath}:${lineNumber}`, + process.env.REACT_EDITOR, + (fileName, errorMessage) => { + request.log.warn( + `Could not open ${path.basename(fileName)} in your editor` + + `${errorMessage ? `: ${errorMessage}` : ''}. Set the ` + + 'REACT_EDITOR environment variable (e.g. REACT_EDITOR=code) ' + + 'and restart the dev server.' + ); + } + ); reply.send('OK'); }, }); @@ -62,5 +114,5 @@ async function devtoolsPlugin( export default fastifyPlugin(devtoolsPlugin, { name: 'devtools-plugin', - dependencies: ['wss-plugin'], + dependencies: ['@fastify/sensible', 'wss-plugin'], }); diff --git a/packages/dev-server/src/plugins/symbolicate/Symbolicator.ts b/packages/dev-server/src/plugins/symbolicate/Symbolicator.ts index 97f845e7d..e5ad9dae6 100644 --- a/packages/dev-server/src/plugins/symbolicate/Symbolicator.ts +++ b/packages/dev-server/src/plugins/symbolicate/Symbolicator.ts @@ -5,12 +5,90 @@ import { SourceMapConsumer } from 'source-map'; import type { CodeFrame, InputStackFrame, + PassthroughStackFrame, ReactNativeStackFrame, StackFrame, SymbolicatorDelegate, SymbolicatorResults, } from './types.js'; +/** + * Frames whose symbolicated source path matches this pattern are marked with + * `collapse: true`, hiding them by default in LogBox. Mirrors the approach of + * React Native's default Metro config (`INTERNAL_CALLSITES_REGEX` in + * `@react-native/metro-config`), which collapses by resolved file path — + * never by method name, so user code with a coincidental name is unaffected. + */ +const INTERNAL_CALLSITES = new RegExp( + [ + 'node_modules/react-native/Libraries/BatchedBridge/MessageQueue\\.js$', + 'node_modules/react-native/Libraries/Core/.+', + 'node_modules/react-native/Libraries/LogBox/.+', + 'node_modules/react-native/Libraries/Renderer/implementations/.+', + 'node_modules/react-native/Libraries/Utilities/HMRClient\\.js$', + 'node_modules/react/cjs/.+', + 'node_modules/scheduler/.+', + 'node_modules/metro-runtime/.+', + '^\\[native code\\]$', + ] + .map((pattern) => pattern.replaceAll('/', '[/\\\\]')) + .join('|') +); + +interface SourceMapCacheEntry { + consumer: SourceMapConsumer; + /** Original sources listed in the map's `ignoreList`/`x_google_ignoreList`. */ + ignoredSources: Set; +} + +type ProcessedFrame = + | { frame: StackFrame; symbolicated: true } + | { frame: PassthroughStackFrame; symbolicated: false }; + +/** + * Replaces source names that are not URL-parseable so `SourceMapConsumer` + * construction cannot fail on them. Module Federation v2 is known to emit a + * corrupt `webpack://` source name (containing raw runtime code) into host + * bundle maps, which makes the consumer throw `Invalid URL` — taking down + * symbolication of every frame in the bundle. Indices are preserved so + * mappings stay aligned. + */ +export function sanitizeRawSourceMap(rawSourceMap: string): { + sourceMap: string; + ignoredSources: Set; +} { + const map: { sources?: unknown[]; [key: string]: unknown } = + JSON.parse(rawSourceMap); + + const sources = Array.isArray(map.sources) ? map.sources : []; + map.sources = sources.map((source, index) => { + if (typeof source !== 'string') { + return `unparseable-source-${index}`; + } + try { + new URL(source, 'file:///'); + return source; + } catch { + return `unparseable-source-${index}`; + } + }); + + const ignoreList = Array.isArray(map.ignoreList) + ? map.ignoreList + : Array.isArray(map.x_google_ignoreList) + ? map.x_google_ignoreList + : []; + const ignoredSources = new Set(); + for (const index of ignoreList) { + const source = typeof index === 'number' ? map.sources[index] : undefined; + if (typeof source === 'string') { + ignoredSources.add(source); + } + } + + return { sourceMap: JSON.stringify(map), ignoredSources }; +} + /** * Class for transforming stack traces from React Native application with using Source Map. * Raw stack frames produced by React Native, points to some location from the bundle @@ -48,7 +126,13 @@ export class Symbolicator { /** * Cache with initialized `SourceMapConsumer` to improve symbolication performance. */ - sourceMapConsumerCache: Record = {}; + sourceMapConsumerCache: Record = {}; + + /** + * Files whose source map failed to load during the current `process` call, + * so repeated frames from the same file don't repeat the failing lookup. + */ + private failedSourceMapFiles = new Set(); /** * Constructs new `Symbolicator` instance. @@ -65,6 +149,10 @@ export class Symbolicator { * For example out of 10 frames, it's possible that only first 7 will be symbolicated and the * remaining 3 will be unchanged. * + * A failure to symbolicate one frame (e.g. a Module Federation remote chunk + * whose source map is unavailable) never fails the whole request — the + * failed frame passes through unchanged, mirroring Metro's behavior. + * * @param logger Fastify logger instance. * @param stack Raw stack frames. * @returns Symbolicated stack frames. @@ -73,56 +161,22 @@ export class Symbolicator { logger: FastifyBaseLogger, stack: ReactNativeStackFrame[] ): Promise { - logger.debug({ msg: 'Filtering out unnecessary frames' }); - - const frames: InputStackFrame[] = []; - for (const frame of stack) { - const { file } = frame; - if (file?.startsWith('http')) { - frames.push(frame as InputStackFrame); - } - } - try { - logger.debug({ msg: 'Processing frames', frames }); - - const processedFrames: StackFrame[] = []; - for (const frame of frames) { - if (!this.sourceMapConsumerCache[frame.file]) { - logger.debug({ - msg: 'Loading raw source map data', - fileUrl: frame.file, - }); - - const rawSourceMap = await this.delegate.getSourceMap(frame.file); + logger.debug({ msg: 'Processing frames', frames: stack }); - logger.debug({ - msg: 'Creating source map instance', - fileUrl: frame.file, - sourceMapLength: rawSourceMap.length, + const processedFrames: ProcessedFrame[] = []; + for (const frame of stack) { + if (!frame.file) { + // Keep the response 1:1 with the request — LogBox rejects + // symbolication results with a different number of frames. + processedFrames.push({ + frame: { ...frame, collapse: false }, + symbolicated: false, }); - const sourceMapConsumer = await new SourceMapConsumer( - rawSourceMap.toString() - ); - - logger.debug({ - msg: 'Saving source map instance into cache', - fileUrl: frame.file, - }); - this.sourceMapConsumerCache[frame.file] = sourceMapConsumer; + continue; } - - logger.debug({ - msg: 'Symbolicating frame', - frame, - }); - const processedFrame = this.processFrame(frame); - - logger.debug({ - msg: 'Finished symbolicating frame', - frame, - }); - processedFrames.push(processedFrame); + const inputFrame: InputStackFrame = { ...frame, file: frame.file }; + processedFrames.push(await this.processFrameSafe(logger, inputFrame)); } const codeFrame = @@ -135,33 +189,85 @@ export class Symbolicator { }); return { - stack: processedFrames, + stack: processedFrames.map(({ frame }) => frame), codeFrame, }; } finally { + this.failedSourceMapFiles.clear(); for (const key in this.sourceMapConsumerCache) { - this.sourceMapConsumerCache[key].destroy(); + this.sourceMapConsumerCache[key].consumer.destroy(); delete this.sourceMapConsumerCache[key]; } } } - private processFrame(frame: InputStackFrame): StackFrame { - if (!frame.lineNumber || !frame.column) { - return { - ...frame, - collapse: false, - }; + /** + * Only frames that plausibly come from a served bundle get a source-map + * lookup; everything else (native frames, unrelated files) passes through. + */ + private shouldAttemptSymbolication(frame: InputStackFrame): boolean { + if (frame.lineNumber == null || frame.column == null) { + return false; + } + return ( + frame.file.startsWith('http') || + frame.file.includes('.bundle') || + frame.file.includes('.hot-update.js') + ); + } + + private async processFrameSafe( + logger: FastifyBaseLogger, + frame: InputStackFrame + ): Promise { + const passthrough: ProcessedFrame = { + frame: { ...frame, collapse: INTERNAL_CALLSITES.test(frame.file) }, + symbolicated: false, + }; + + if ( + !this.shouldAttemptSymbolication(frame) || + this.failedSourceMapFiles.has(frame.file) + ) { + return passthrough; + } + + try { + if (!this.sourceMapConsumerCache[frame.file]) { + logger.debug({ + msg: 'Loading raw source map data', + fileUrl: frame.file, + }); + const rawSourceMap = await this.delegate.getSourceMap(frame.file); + + const { sourceMap, ignoredSources } = sanitizeRawSourceMap( + rawSourceMap.toString() + ); + const consumer = await new SourceMapConsumer(sourceMap); + this.sourceMapConsumerCache[frame.file] = { consumer, ignoredSources }; + } + + return this.processFrame(frame); + } catch (error) { + this.failedSourceMapFiles.add(frame.file); + logger.warn({ + msg: `Failed to symbolicate ${frame.file} — returning the frame unsymbolicated`, + error: error instanceof Error ? error.message : String(error), + }); + return passthrough; } + } - const consumer = this.sourceMapConsumerCache[frame.file]; - if (!consumer) { + private processFrame(frame: InputStackFrame): ProcessedFrame { + const entry = this.sourceMapConsumerCache[frame.file]; + if (entry == null || frame.lineNumber == null || frame.column == null) { return { - ...frame, - collapse: false, + frame: { ...frame, collapse: false }, + symbolicated: false, }; } + const { consumer, ignoredSources } = entry; let lookup = consumer.originalPositionFor({ line: frame.lineNumber, column: frame.column, @@ -180,26 +286,39 @@ export class Symbolicator { // return the original frame when both lookups fail if (!lookup.source) { return { - ...frame, - collapse: false, + frame: { ...frame, collapse: false }, + symbolicated: false, }; } return { - lineNumber: lookup.line || frame.lineNumber, - column: lookup.column || frame.column, - file: lookup.source, - methodName: lookup.name || frame.methodName, - collapse: false, + frame: { + lineNumber: lookup.line ?? frame.lineNumber, + column: lookup.column ?? frame.column, + file: lookup.source, + methodName: lookup.name ?? frame.methodName, + collapse: + ignoredSources.has(lookup.source) || + INTERNAL_CALLSITES.test(lookup.source), + }, + symbolicated: true, }; } private async getCodeFrame( logger: FastifyBaseLogger, - processedFrames: StackFrame[] + processedFrames: ProcessedFrame[] ): Promise { - for (const frame of processedFrames) { - if (frame.collapse || !frame.lineNumber || !frame.column) { + for (const { frame, symbolicated } of processedFrames) { + // Only frames resolved to an original source can anchor the code + // frame — rendering a raw bundle at generated coordinates produces + // a garbage snippet. + if ( + !symbolicated || + frame.collapse || + frame.lineNumber == null || + frame.column == null + ) { continue; } @@ -230,7 +349,7 @@ export class Symbolicator { } catch (error) { logger.error({ msg: 'Failed to create code frame', - error: (error as Error).message, + error: error instanceof Error ? error.message : String(error), }); } diff --git a/packages/dev-server/src/plugins/symbolicate/__tests__/Symbolicator.test.ts b/packages/dev-server/src/plugins/symbolicate/__tests__/Symbolicator.test.ts new file mode 100644 index 000000000..32fd20412 --- /dev/null +++ b/packages/dev-server/src/plugins/symbolicate/__tests__/Symbolicator.test.ts @@ -0,0 +1,247 @@ +import type { FastifyBaseLogger } from 'fastify'; +import { SourceMapGenerator } from 'source-map'; +import { describe, expect, it, vi } from 'vitest'; +import { Symbolicator, sanitizeRawSourceMap } from '../Symbolicator.js'; +import type { ReactNativeStackFrame } from '../types.js'; + +const HOST_BUNDLE = 'http://localhost:8081/index.bundle?platform=ios'; +const REMOTE_BUNDLE = 'http://localhost:8082/ios/MiniApp.chunk.bundle'; +const APP_SOURCE = '[projectRoot]/src/App.tsx'; + +// A source name that is not URL-parseable, mirroring the corrupt +// `webpack://` source name Module Federation v2 emits into host maps. +const CORRUPT_SOURCE = 'webpack://{"raw runtime code"}/'; + +function createLogger(): FastifyBaseLogger { + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + trace: vi.fn(), + silent: vi.fn(), + level: 'debug', + child: () => logger, + }; + return logger as unknown as FastifyBaseLogger; +} + +function createSourceMap({ + ignoreListField, + extraSources = [], +}: { + ignoreListField?: 'ignoreList' | 'x_google_ignoreList'; + extraSources?: string[]; +} = {}): string { + const generator = new SourceMapGenerator({ file: 'index.bundle' }); + generator.addMapping({ + generated: { line: 1, column: 10 }, + original: { line: 5, column: 3 }, + source: APP_SOURCE, + name: 'render', + }); + // A mapping at generated column 0 — a valid position that falsy checks + // would incorrectly treat as missing. + generator.addMapping({ + generated: { line: 2, column: 0 }, + original: { line: 7, column: 0 }, + source: APP_SOURCE, + }); + + const map = JSON.parse(generator.toString()); + map.sources.push(...extraSources); + if (ignoreListField) { + map[ignoreListField] = [0]; + } + return JSON.stringify(map); +} + +function createSymbolicator(sourceMaps: Record) { + const getSourceMap = vi.fn(async (fileUrl: string) => { + const entry = sourceMaps[fileUrl]; + if (entry === undefined) { + throw new Error(`Source map for ${fileUrl} is missing`); + } + if (entry instanceof Error) { + throw entry; + } + return entry; + }); + const getSource = vi.fn(async (fileUrl: string) => `source of ${fileUrl}`); + const symbolicator = new Symbolicator({ + getSource, + getSourceMap, + shouldIncludeFrame: () => true, + }); + return { symbolicator, getSourceMap, getSource }; +} + +function frame( + file: string | null, + lineNumber: number | null = 1, + column: number | null = 10, + methodName = 'anonymous' +): ReactNativeStackFrame { + return { file, lineNumber, column, methodName }; +} + +describe('sanitizeRawSourceMap', () => { + it('replaces source names that are not URL-parseable', () => { + const raw = createSourceMap({ extraSources: [CORRUPT_SOURCE] }); + const { sourceMap } = sanitizeRawSourceMap(raw); + const parsed = JSON.parse(sourceMap); + + expect(parsed.sources).toContain(APP_SOURCE); + expect(parsed.sources).not.toContain(CORRUPT_SOURCE); + expect(parsed.sources).toContain('unparseable-source-1'); + }); + + it.each(['ignoreList', 'x_google_ignoreList'] as const)( + 'collects ignored sources from %s', + (field) => { + const raw = createSourceMap({ ignoreListField: field }); + const { ignoredSources } = sanitizeRawSourceMap(raw); + expect(ignoredSources).toEqual(new Set([APP_SOURCE])); + } + ); +}); + +describe('Symbolicator', () => { + it('symbolicates frames from bundles with a source map', async () => { + const { symbolicator } = createSymbolicator({ + [HOST_BUNDLE]: createSourceMap(), + }); + + const results = await symbolicator.process(createLogger(), [ + frame(HOST_BUNDLE, 1, 10), + ]); + + expect(results.stack).toEqual([ + { + file: APP_SOURCE, + lineNumber: 5, + column: 3, + methodName: 'render', + collapse: false, + }, + ]); + }); + + it('symbolicates frames at generated column 0', async () => { + const { symbolicator } = createSymbolicator({ + [HOST_BUNDLE]: createSourceMap(), + }); + + const results = await symbolicator.process(createLogger(), [ + frame(HOST_BUNDLE, 2, 0), + ]); + + expect(results.stack[0].file).toBe(APP_SOURCE); + expect(results.stack[0].lineNumber).toBe(7); + }); + + it('survives a corrupt source name in the map', async () => { + const { symbolicator } = createSymbolicator({ + [HOST_BUNDLE]: createSourceMap({ extraSources: [CORRUPT_SOURCE] }), + }); + + const results = await symbolicator.process(createLogger(), [ + frame(HOST_BUNDLE, 1, 10), + ]); + + expect(results.stack[0].file).toBe(APP_SOURCE); + }); + + it('returns a failed frame unchanged without failing the request', async () => { + const logger = createLogger(); + const { symbolicator } = createSymbolicator({ + [HOST_BUNDLE]: createSourceMap(), + }); + + const results = await symbolicator.process(logger, [ + frame(REMOTE_BUNDLE, 3, 7, 'remoteMethod'), + frame(HOST_BUNDLE, 1, 10), + ]); + + expect(results.stack).toHaveLength(2); + expect(results.stack[0]).toEqual({ + file: REMOTE_BUNDLE, + lineNumber: 3, + column: 7, + methodName: 'remoteMethod', + collapse: false, + }); + expect(results.stack[1].file).toBe(APP_SOURCE); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('keeps the response stack 1:1 with the request', async () => { + const { symbolicator } = createSymbolicator({ + [HOST_BUNDLE]: createSourceMap(), + }); + + const results = await symbolicator.process(createLogger(), [ + frame(null), + frame('[native code]', null, null), + frame(HOST_BUNDLE, 1, 10), + ]); + + expect(results.stack).toHaveLength(3); + expect(results.stack[0].file).toBeNull(); + expect(results.stack[1]).toMatchObject({ + file: '[native code]', + collapse: true, + }); + expect(results.stack[2].file).toBe(APP_SOURCE); + }); + + it('collapses frames whose original source is in the ignore list', async () => { + const { symbolicator } = createSymbolicator({ + [HOST_BUNDLE]: createSourceMap({ + ignoreListField: 'x_google_ignoreList', + }), + }); + + const results = await symbolicator.process(createLogger(), [ + frame(HOST_BUNDLE, 1, 10), + ]); + + expect(results.stack[0]).toMatchObject({ + file: APP_SOURCE, + collapse: true, + }); + }); + + it('never anchors the code frame on a frame that failed to symbolicate', async () => { + const { symbolicator, getSource } = createSymbolicator({ + [HOST_BUNDLE]: createSourceMap(), + }); + + const results = await symbolicator.process(createLogger(), [ + frame(REMOTE_BUNDLE, 3, 7), + frame(HOST_BUNDLE, 1, 10), + ]); + + expect(results.codeFrame?.fileName).toBe(APP_SOURCE); + expect(getSource).toHaveBeenCalledTimes(1); + expect(getSource).toHaveBeenCalledWith(APP_SOURCE); + }); + + it('loads the source map once per file within a request', async () => { + const { symbolicator, getSourceMap } = createSymbolicator({ + [HOST_BUNDLE]: createSourceMap(), + }); + + await symbolicator.process(createLogger(), [ + frame(HOST_BUNDLE, 1, 10), + frame(HOST_BUNDLE, 2, 0), + frame(REMOTE_BUNDLE, 1, 1), + frame(REMOTE_BUNDLE, 2, 2), + ]); + + // One successful load for the host bundle, one failed attempt for the + // remote bundle — repeated frames reuse the cached result either way. + expect(getSourceMap).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/dev-server/src/plugins/symbolicate/types.ts b/packages/dev-server/src/plugins/symbolicate/types.ts index babd2ceb7..2569690ae 100644 --- a/packages/dev-server/src/plugins/symbolicate/types.ts +++ b/packages/dev-server/src/plugins/symbolicate/types.ts @@ -22,6 +22,16 @@ export interface StackFrame extends InputStackFrame { collapse: boolean; } +/** + * Stack frame returned unchanged when symbolication was not attempted or + * failed (e.g. a native frame, or a bundle whose source map is unavailable). + * The response stack is always 1:1 with the request — React Native's LogBox + * rejects symbolication results with a different number of frames. + */ +export type PassthroughStackFrame = ReactNativeStackFrame & { + collapse: boolean; +}; + /** * Represents [@babel/core-frame](https://babeljs.io/docs/en/babel-code-frame). */ @@ -39,7 +49,7 @@ export interface CodeFrame { */ export interface SymbolicatorResults { codeFrame: CodeFrame | null; - stack: StackFrame[]; + stack: PassthroughStackFrame[]; } /** diff --git a/packages/dev-server/src/utils/__tests__/originGuard.test.ts b/packages/dev-server/src/utils/__tests__/originGuard.test.ts new file mode 100644 index 000000000..b0eb88b56 --- /dev/null +++ b/packages/dev-server/src/utils/__tests__/originGuard.test.ts @@ -0,0 +1,88 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, describe, expect, it } from 'vitest'; +import { isOriginAllowed, registerOriginGuard } from '../originGuard.js'; + +describe('isOriginAllowed', () => { + it.each([ + // Same host, any port (dev tools may run on another local port). + ['http://localhost:8081', 'localhost', true], + ['http://localhost:3000', 'localhost', true], + // Loopback aliases are the same machine as localhost. + ['http://127.0.0.1:8081', 'localhost', true], + ['http://[::1]:8081', 'localhost', true], + // Chrome DevTools. + ['devtools://devtools', 'localhost', true], + // Server bound to a LAN IP, browsed from that IP. + ['http://192.168.1.10:8081', '192.168.1.10', true], + // Cross-origin drive-by attempts. + ['http://evil.com', 'localhost', false], + ['https://evil.com', 'localhost', false], + // Suffix/prefix tricks must not match. + ['http://localhost.evil.com:8081', 'localhost', false], + ['http://notlocalhost:8081', 'localhost', false], + // Opaque / non-http origins. + ['null', 'localhost', false], + ['file://', 'localhost', false], + ['devtools://devtools-evil', 'localhost', false], + ])('%s (host %s) → %s', (origin, host, expected) => { + expect(isOriginAllowed(origin, host)).toBe(expected); + }); +}); + +describe('registerOriginGuard', () => { + let app: FastifyInstance | undefined; + + afterEach(async () => { + await app?.close(); + app = undefined; + }); + + async function makeServer() { + const instance = Fastify(); + registerOriginGuard(instance, 'localhost'); + instance.post('/open-stack-frame', async () => 'OK'); + await instance.ready(); + return instance; + } + + it('allows requests with no Origin header (native React Native app)', async () => { + app = await makeServer(); + const response = await app.inject({ + method: 'POST', + url: '/open-stack-frame', + }); + expect(response.statusCode).toBe(200); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + }); + + it('allows same-origin browser requests', async () => { + app = await makeServer(); + const response = await app.inject({ + method: 'POST', + url: '/open-stack-frame', + headers: { origin: 'http://localhost:8081' }, + }); + expect(response.statusCode).toBe(200); + }); + + it('blocks cross-origin browser requests', async () => { + app = await makeServer(); + const response = await app.inject({ + method: 'POST', + url: '/open-stack-frame', + headers: { origin: 'http://evil.com' }, + }); + expect(response.statusCode).toBe(403); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + }); + + it('allows the Chrome DevTools origin', async () => { + app = await makeServer(); + const response = await app.inject({ + method: 'POST', + url: '/open-stack-frame', + headers: { origin: 'devtools://devtools' }, + }); + expect(response.statusCode).toBe(200); + }); +}); diff --git a/packages/dev-server/src/utils/originGuard.ts b/packages/dev-server/src/utils/originGuard.ts new file mode 100644 index 000000000..a9e8e8f9a --- /dev/null +++ b/packages/dev-server/src/utils/originGuard.ts @@ -0,0 +1,61 @@ +import type { FastifyInstance } from 'fastify'; + +// 127.0.0.1 and [::1] are the same machine as `localhost`; a request whose +// Origin is any of these came from a page already served locally, which is +// inside the dev server's trust boundary. +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); + +/** + * Whether a browser `Origin` is permitted to talk to the dev server. + * + * The React Native app (LogBox, bundle requests) uses native networking and + * sends no `Origin` header, so only browser-issued requests carry one. A + * malicious website the developer happens to have open can issue cross-origin + * requests to `localhost`, which is how a drive-by page could reach dev-only + * endpoints that open arbitrary files or URLs on the developer's machine + * (`/open-stack-frame`, `/open-url`). Blocking mismatched origins closes that + * vector while leaving native clients unaffected. + * + * Mirrors `securityHeadersMiddleware` in `@react-native-community/cli-server-api`. + */ +export function isOriginAllowed(origin: string, host: string): boolean { + // Chrome DevTools connects from this opaque origin. + if (origin === 'devtools://devtools') { + return true; + } + + let hostname: string; + try { + const url = new URL(origin); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return false; + } + hostname = url.hostname; + } catch { + return false; + } + + return hostname === host || LOOPBACK_HOSTNAMES.has(hostname); +} + +/** + * Registers a global hook that rejects cross-origin browser requests and + * disables MIME sniffing, matching the React Native community CLI dev server. + * Applied to every route so the file/URL-opening endpoints cannot be driven + * cross-origin. + */ +export function registerOriginGuard(instance: FastifyInstance, host: string) { + instance.addHook('onRequest', async (request, reply) => { + const { origin } = request.headers; + if (typeof origin === 'string' && !isOriginAllowed(origin, host)) { + reply.header('X-Content-Type-Options', 'nosniff'); + reply + .code(403) + .send( + `Cross-origin request from ${origin} was blocked by the React Native dev server.` + ); + return reply; + } + reply.header('X-Content-Type-Options', 'nosniff'); + }); +} diff --git a/packages/repack/src/commands/common/__tests__/fetchDeclaredSourceMap.test.ts b/packages/repack/src/commands/common/__tests__/fetchDeclaredSourceMap.test.ts new file mode 100644 index 000000000..5b35ee20a --- /dev/null +++ b/packages/repack/src/commands/common/__tests__/fetchDeclaredSourceMap.test.ts @@ -0,0 +1,143 @@ +import { + coerceToHttpUrl, + fetchDeclaredSourceMap, +} from '../fetchDeclaredSourceMap.js'; + +describe('coerceToHttpUrl', () => { + const expectCoerced = (input: string, expected: string | undefined) => { + expect(coerceToHttpUrl(input)?.href).toBe(expected); + }; + + it('accepts http(s) URLs as-is', () => { + expectCoerced( + 'http://localhost:8081/index.bundle?platform=ios', + 'http://localhost:8081/index.bundle?platform=ios' + ); + expectCoerced( + 'https://dev.example.com/index.bundle', + 'https://dev.example.com/index.bundle' + ); + }); + + it('coerces scheme-less host:port URLs', () => { + // `new URL('localhost:9000/x')` parses successfully with `localhost:` + // as the scheme, so coercion must not rely on the parse throwing. + expectCoerced( + 'localhost:9000/app.container.js.bundle', + 'http://localhost:9000/app.container.js.bundle' + ); + expectCoerced( + '10.0.2.2:8081/index.bundle?platform=android', + 'http://10.0.2.2:8081/index.bundle?platform=android' + ); + expectCoerced( + '//localhost:8081/index.bundle', + 'http://localhost:8081/index.bundle' + ); + }); + + it('rejects values that are not fetchable URLs', () => { + expectCoerced('/data/user/0/com.app/files/index.android.bundle', undefined); + expectCoerced('webpack-internal:///./src/index.js', undefined); + expectCoerced('[native code]', undefined); + expectCoerced('index.bundle', undefined); + expectCoerced( + '__federation_expose_MFE1Navigator.mfe1.chunk.bundle', + undefined + ); + }); +}); + +describe('fetchDeclaredSourceMap', () => { + const VALID_MAP = JSON.stringify({ + version: 3, + sources: ['src/App.tsx'], + names: [], + mappings: 'AAAA', + }); + + // Module-level memoization persists across tests, so give each test its + // own bundle URL. + let testId = 0; + const uniqueBundleUrl = () => + `http://localhost:8082/ios/bundle-${testId++}.chunk.bundle`; + + const mockFetch = ( + responses: Record + ) => { + const fetchMock = jest.fn(async (input: string | URL | Request) => { + const url = input.toString(); + const response = responses[url]; + if (!response) { + throw new Error(`Unexpected fetch: ${url}`); + } + return { + ok: response.ok, + // TextEncoder produces an unpooled buffer — `Buffer.from(...).buffer` + // would expose the whole shared Buffer pool, not just the body. + arrayBuffer: async () => new TextEncoder().encode(response.body).buffer, + } as Response; + }); + jest.spyOn(globalThis, 'fetch').mockImplementation(fetchMock); + return fetchMock; + }; + + it('fetches the map declared by the bundle, resolved relative to it', async () => { + const bundleUrl = uniqueBundleUrl(); + const mapUrl = `${bundleUrl}.map?platform=ios`; + mockFetch({ + [bundleUrl]: { + ok: true, + body: `console.log(1);\n//# sourceMappingURL=${bundleUrl.split('/').pop()}.map?platform=ios`, + }, + [mapUrl]: { ok: true, body: VALID_MAP }, + }); + + const result = await fetchDeclaredSourceMap(bundleUrl); + expect(result?.toString()).toBe(VALID_MAP); + }); + + it('returns undefined when the bundle declares no source map', async () => { + const bundleUrl = uniqueBundleUrl(); + mockFetch({ + [bundleUrl]: { ok: true, body: 'console.log(1);' }, + }); + + await expect(fetchDeclaredSourceMap(bundleUrl)).resolves.toBeUndefined(); + }); + + it('rejects responses that are not source maps', async () => { + const bundleUrl = uniqueBundleUrl(); + mockFetch({ + [bundleUrl]: { + ok: true, + body: '//# sourceMappingURL=app.map', + }, + [new URL('app.map', bundleUrl).href]: { + ok: true, + body: 'catch-all', + }, + }); + + await expect(fetchDeclaredSourceMap(bundleUrl)).resolves.toBeUndefined(); + }); + + it('returns undefined for URLs that cannot be fetched', async () => { + const fetchMock = mockFetch({}); + await expect( + fetchDeclaredSourceMap('/data/user/0/com.app/files/index.android.bundle') + ).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('memoizes lookups so repeated symbolication does not refetch', async () => { + const bundleUrl = uniqueBundleUrl(); + const fetchMock = mockFetch({ + [bundleUrl]: { ok: true, body: 'no comment here' }, + }); + + await fetchDeclaredSourceMap(bundleUrl); + await fetchDeclaredSourceMap(bundleUrl); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/repack/src/commands/common/__tests__/resolveProjectPath.test.ts b/packages/repack/src/commands/common/__tests__/resolveProjectPath.test.ts index 89d2e06c2..73d04ba95 100644 --- a/packages/repack/src/commands/common/__tests__/resolveProjectPath.test.ts +++ b/packages/repack/src/commands/common/__tests__/resolveProjectPath.test.ts @@ -26,6 +26,24 @@ describe('resolveProjectPath', () => { ); }); + it('should resolve URL-encoded [projectRoot%5EN] prefixes', () => { + // Bundlers percent-encode `^` in source map paths, so /symbolicate + // returns parent-escape tokens as e.g. `[projectRoot%5E2]/...`. + expectResolved( + '[projectRoot%5E2]/node_modules/.pnpm/react@19.0.0/node_modules/react/index.js', + '/node_modules/.pnpm/react@19.0.0/node_modules/react/index.js' + ); + expectResolved('[projectRoot%5E1]/src/index.js', '/project/src/index.js'); + expectResolved('[projectRoot%5e1]/src/index.js', '/project/src/index.js'); + }); + + it('should not decode percent sequences outside the token', () => { + expectResolved( + '[projectRoot]/src/file%5Ename.js', + '/project/root/src/file%5Ename.js' + ); + }); + it('should resolve [projectRoot^N] prefix with up-level navigation', () => { expectResolved('[projectRoot^1]/src/index.js', '/project/src/index.js'); expectResolved('[projectRoot^2]/shared/utils.js', '/shared/utils.js'); diff --git a/packages/repack/src/commands/common/fetchDeclaredSourceMap.ts b/packages/repack/src/commands/common/fetchDeclaredSourceMap.ts new file mode 100644 index 000000000..dba6702b5 --- /dev/null +++ b/packages/repack/src/commands/common/fetchDeclaredSourceMap.ts @@ -0,0 +1,146 @@ +const FETCH_TIMEOUT_MS = 2000; +const CACHE_TTL_MS = 10_000; + +interface CacheEntry { + expiresAt: number; + sourceMap: Buffer | undefined; +} + +const cache = new Map(); + +/** + * Interprets a stack-frame file value as an HTTP(S) URL. Stack frames can + * carry scheme-less URLs (e.g. `localhost:8081/index.bundle` on iOS + * simulators or `10.0.2.2:8081/index.bundle` on Android emulators); note that + * `new URL('localhost:8081/...')` parses successfully with `localhost:` as + * the scheme, so coercion applies whenever the result is not http(s) — not + * only when parsing throws. + */ +export function coerceToHttpUrl(fileUrl: string): URL | undefined { + const candidates: Array<{ url: string; coerced: boolean }> = [ + { url: fileUrl, coerced: false }, + { + url: fileUrl.startsWith('//') ? `http:${fileUrl}` : `http://${fileUrl}`, + coerced: true, + }, + ]; + + for (const { url, coerced } of candidates) { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + continue; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + continue; + } + if (parsed.hostname !== 'localhost' && !parsed.hostname.includes('.')) { + continue; + } + // Scheme-less frame URLs always carry the dev server port; requiring it + // stops bare filenames (e.g. `some.chunk.bundle`) from being mistaken + // for hostnames. + if (coerced && parsed.port === '') { + continue; + } + return parsed; + } + + return undefined; +} + +/** A minimal shape check so an HTML catch-all response or a wrong file can never be used as a source map. */ +function looksLikeSourceMap(buffer: Buffer): boolean { + try { + const map = JSON.parse(buffer.toString('utf8')); + return ( + map !== null && + typeof map === 'object' && + map.version === 3 && + (typeof map.mappings === 'string' || Array.isArray(map.sections)) + ); + } catch { + return false; + } +} + +async function fetchWithTimeout(url: URL): Promise { + const response = await fetch(url, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + return undefined; + } + return Buffer.from(await response.arrayBuffer()); +} + +async function lookupDeclaredSourceMap( + fileUrl: string +): Promise { + const bundleUrl = coerceToHttpUrl(fileUrl); + if (!bundleUrl) { + return undefined; + } + + const bundle = await fetchWithTimeout(bundleUrl); + if (!bundle) { + return undefined; + } + + // The comment is appended at the end of the bundle, so search backwards. + const text = bundle.toString('utf8'); + const commentIndex = text.lastIndexOf('sourceMappingURL='); + if (commentIndex === -1) { + return undefined; + } + const declared = text + .slice(commentIndex + 'sourceMappingURL='.length) + .match(/^(\S+)/)?.[1]; + if (!declared) { + return undefined; + } + + const sourceMapUrl = new URL(declared, bundleUrl); + if (sourceMapUrl.protocol !== 'http:' && sourceMapUrl.protocol !== 'https:') { + return undefined; + } + + const sourceMap = await fetchWithTimeout(sourceMapUrl); + if (!sourceMap || !looksLikeSourceMap(sourceMap)) { + return undefined; + } + return sourceMap; +} + +/** + * Fetches the source map that a served bundle declares for itself via its + * `//# sourceMappingURL=` comment, resolved relative to the bundle URL. + * + * This is the fallback for bundles the local compiler did not build — e.g. + * Module Federation remote containers and chunks served by another dev + * server. It follows the standard resolution browsers and symbolication + * services use: only the declared reference is trusted; if a bundle declares + * no source map, the lookup fails rather than guessing filenames. + * + * Results (including misses) are memoized for a short interval so repeated + * symbolication requests don't refetch on every error report. + */ +export async function fetchDeclaredSourceMap( + fileUrl: string +): Promise { + const cached = cache.get(fileUrl); + if (cached && cached.expiresAt > Date.now()) { + return cached.sourceMap; + } + + let sourceMap: Buffer | undefined; + try { + sourceMap = await lookupDeclaredSourceMap(fileUrl); + } catch { + sourceMap = undefined; + } + + cache.set(fileUrl, { expiresAt: Date.now() + CACHE_TTL_MS, sourceMap }); + return sourceMap; +} diff --git a/packages/repack/src/commands/common/index.ts b/packages/repack/src/commands/common/index.ts index ce61289b7..2a54fb648 100644 --- a/packages/repack/src/commands/common/index.ts +++ b/packages/repack/src/commands/common/index.ts @@ -1,3 +1,4 @@ +export * from './fetchDeclaredSourceMap.js'; export * from './getDevMiddleware.js'; export * from './getMaxWorkers.js'; export * from './getMimeType.js'; diff --git a/packages/repack/src/commands/common/resolveProjectPath.ts b/packages/repack/src/commands/common/resolveProjectPath.ts index f5c293aa9..6b9a00b3d 100644 --- a/packages/repack/src/commands/common/resolveProjectPath.ts +++ b/packages/repack/src/commands/common/resolveProjectPath.ts @@ -9,11 +9,19 @@ function isProjectPath(filepath: string) { // Resolve [projectRoot] and [projectRoot^N] prefixes export function resolveProjectPath(filepath: string, rootDir: string) { - const match = isProjectPath(filepath); + // Bundlers percent-encode `^` in source map paths, so parent-escape tokens + // arrive as `[projectRoot%5E2]/...`. Decode only the token, not the whole + // path — file names may contain literal percent sequences. + const normalizedPath = filepath.replace( + /^\[projectRoot%5E(\d+)\]/i, + '[projectRoot^$1]' + ); + + const match = isProjectPath(normalizedPath); if (!match) return filepath; const [prefix, upLevels] = match; const upPath = '../'.repeat(Number(upLevels ?? 0)); const rootPath = path.join(rootDir, upPath); - return path.resolve(filepath.replace(prefix, rootPath)); + return path.resolve(normalizedPath.replace(prefix, rootPath)); } diff --git a/packages/repack/src/commands/rspack/start.ts b/packages/repack/src/commands/rspack/start.ts index dc915ab4f..2d67fbe2b 100644 --- a/packages/repack/src/commands/rspack/start.ts +++ b/packages/repack/src/commands/rspack/start.ts @@ -11,6 +11,7 @@ import { } from '../../logging/index.js'; import { makeCompilerConfig } from '../common/config/makeCompilerConfig.js'; import { + fetchDeclaredSourceMap, getDevMiddleware, getMaxWorkers, getMimeType, @@ -170,9 +171,20 @@ export async function start( resourcePath = resolveProjectPath(resourcePath, cliConfig.root); return compiler.getSource(resourcePath, platform); }, - getSourceMap: (url) => { - const { resourcePath, platform } = parseUrl(url, platforms); - return compiler.getSourceMap(resourcePath, platform); + getSourceMap: async (url) => { + try { + const { resourcePath, platform } = parseUrl(url, platforms); + return await compiler.getSourceMap(resourcePath, platform); + } catch (error) { + // The compiler only has maps for bundles it built. For anything + // else (e.g. Module Federation remotes served by another dev + // server) fall back to the map the bundle declares for itself. + const declaredSourceMap = await fetchDeclaredSourceMap(url); + if (declaredSourceMap) { + return declaredSourceMap; + } + throw error; + } }, shouldIncludeFrame: (frame) => { // If the frame points to internal bootstrap/module system logic, skip the code frame. diff --git a/packages/repack/src/commands/webpack/start.ts b/packages/repack/src/commands/webpack/start.ts index 9cb236def..5e9d69aa2 100644 --- a/packages/repack/src/commands/webpack/start.ts +++ b/packages/repack/src/commands/webpack/start.ts @@ -13,6 +13,7 @@ import { import type { HMRMessage } from '../../types.js'; import { makeCompilerConfig } from '../common/config/makeCompilerConfig.js'; import { + fetchDeclaredSourceMap, getDevMiddleware, getMimeType, parseUrl, @@ -204,9 +205,20 @@ export async function start( resourcePath = resolveProjectPath(resourcePath, cliConfig.root); return compiler.getSource(resourcePath, platform); }, - getSourceMap: (url) => { - const { resourcePath, platform } = parseUrl(url, platforms); - return compiler.getSourceMap(resourcePath, platform); + getSourceMap: async (url) => { + try { + const { resourcePath, platform } = parseUrl(url, platforms); + return await compiler.getSourceMap(resourcePath, platform); + } catch (error) { + // The compiler only has maps for bundles it built. For anything + // else (e.g. Module Federation remotes served by another dev + // server) fall back to the map the bundle declares for itself. + const declaredSourceMap = await fetchDeclaredSourceMap(url); + if (declaredSourceMap) { + return declaredSourceMap; + } + throw error; + } }, shouldIncludeFrame: (frame) => { // If the frame points to internal bootstrap/module system logic, skip the code frame. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af1b6547c..21bbb1cec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -755,6 +755,9 @@ importers: memfs: specifier: ^4.11.1 version: 4.17.0 + source-map: + specifier: ^0.7.6 + version: 0.7.6 typescript: specifier: 'catalog:' version: 5.9.3 diff --git a/tests/integration/e2e/fixtures/fake-editor.sh b/tests/integration/e2e/fixtures/fake-editor.sh new file mode 100755 index 000000000..e766684d0 --- /dev/null +++ b/tests/integration/e2e/fixtures/fake-editor.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# Fake editor used by symbolication E2E tests: records how it was invoked +# instead of opening anything. The log path comes from the dev server's env. +echo "$@" >> "$FAKE_EDITOR_LOG" diff --git a/tests/integration/e2e/symbolication.test.ts b/tests/integration/e2e/symbolication.test.ts new file mode 100644 index 000000000..4b83c32a4 --- /dev/null +++ b/tests/integration/e2e/symbolication.test.ts @@ -0,0 +1,409 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { SourceMapConsumer } from 'source-map'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +/** + * E2E coverage for dev-server stack-frame symbolication and open-stack-frame, + * exercised against the real tester-federation-v2 host + mini-app dev servers. + * + * The Module Federation scenarios mirror a real-world setup: a shell app + * whose error stacks contain frames from federated remote chunks served by a + * separate dev server (e.g. `__federation_expose_X..chunk.bundle`). + * + * Requires workspace packages to be built (`pnpm build`) beforehand. + */ + +const HOST_PORT = 38081; +const MINI_PORT = 38082; +const PLATFORM = 'ios'; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const appDir = path.resolve(dirname, '../../../apps/tester-federation-v2'); +const reactNativeBin = path.join(appDir, 'node_modules/.bin/react-native'); +const fakeEditor = path.join(dirname, 'fixtures/fake-editor.sh'); + +const REMOTE_CHUNK = + '__federation_expose_MiniAppNavigator.MiniApp.chunk.bundle'; + +interface StackFrame { + file: string; + methodName: string; + lineNumber: number; + column: number; +} + +interface SymbolicateResponse { + status: number; + body: { + stack?: Array & { collapse?: boolean }>; + codeFrame?: { content: string; fileName: string } | null; + }; +} + +let hostServer: ChildProcess; +let miniServer: ChildProcess; +let editorLog: string; +let hostAppFrame: StackFrame; +let miniNodeModulesFrame: StackFrame; +let remoteFrame: StackFrame; + +function startServer(configFile: string, port: number): ChildProcess { + const child = spawn( + reactNativeBin, + [ + 'webpack-start', + '--config', + configFile, + '--port', + String(port), + // Compile a single platform: the suite only exercises one, and the + // extra compilation is wasted load on shared CI runners. + '--platform', + PLATFORM, + ], + { + cwd: appDir, + env: { + ...process.env, + REACT_EDITOR: fakeEditor, + FAKE_EDITOR_LOG: editorLog, + }, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + } + ); + child.stdout?.setEncoding('utf8'); + child.stderr?.setEncoding('utf8'); + return child; +} + +function stopServer(child: ChildProcess | undefined) { + if (child?.pid) { + try { + // SIGKILL the whole process group: these are throwaway test servers, + // and a SIGTERM caught mid-compilation has been observed to leave an + // orphan behind that breaks subsequent runs. + process.kill(-child.pid, 'SIGKILL'); + } catch { + // Already gone. + } + } +} + +async function waitFor(url: string, timeoutMs: number) { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(5000), + }); + if (response.ok) return; + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + throw new Error(`Timed out waiting for ${url}: ${lastError}`); +} + +/** + * Finds the first generated position in a source map whose original source + * matches the predicate, so tests use genuinely mapped coordinates instead + * of guessed ones. + * + * Source names that are not URL-parseable are replaced before constructing + * the consumer: Module Federation v2 emits a corrupt `webpack://` source + * name (containing raw runtime code) into the host map, which makes + * SourceMapConsumer throw 'Invalid URL'. The dev server has the same + * problem — that bug is covered by the host-only symbolication test below. + */ +async function findMappedFrame( + mapUrl: string, + matchesSource: (source: string) => boolean, + methodName: string +): Promise { + const response = await fetch(mapUrl); + if (!response.ok) { + throw new Error(`Failed to fetch source map ${mapUrl}: ${response.status}`); + } + const rawMap = await response.json(); + rawMap.sources = (rawMap.sources ?? []).map( + (source: string, index: number) => { + try { + new URL(source, 'file:///'); + return source; + } catch { + return `unparseable-source-${index}`; + } + } + ); + const consumer = await new SourceMapConsumer(rawMap); + let found: { line: number; column: number } | undefined; + const stopIteration = new Error('stop'); + try { + consumer.eachMapping((mapping) => { + if ( + mapping.source && + mapping.originalLine != null && + mapping.originalLine > 1 && + mapping.generatedColumn > 0 && + matchesSource(mapping.source) + ) { + found = { + line: mapping.generatedLine, + column: mapping.generatedColumn, + }; + throw stopIteration; + } + }); + } catch (error) { + if (error !== stopIteration) throw error; + } finally { + consumer.destroy(); + } + if (!found) { + throw new Error(`No mapping matching predicate in ${mapUrl}`); + } + return { + file: mapUrl.replace(/\.map(\?[^?]*)?$/, '$1'), + methodName, + lineNumber: found.line, + column: found.column, + }; +} + +async function symbolicate( + port: number, + stack: StackFrame[] +): Promise { + const response = await fetch(`http://localhost:${port}/symbolicate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ stack }), + signal: AbortSignal.timeout(30_000), + }); + return { status: response.status, body: await response.json() }; +} + +async function openStackFrame(port: number, file: string, lineNumber: number) { + const response = await fetch(`http://localhost:${port}/open-stack-frame`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ file, lineNumber }), + signal: AbortSignal.timeout(10_000), + }); + return response.status; +} + +function readEditorLog(): string[] { + try { + return fs + .readFileSync(editorLog, 'utf8') + .split('\n') + .filter((line) => line.trim().length > 0); + } catch { + return []; + } +} + +/** Waits for the fake editor to record a new invocation, returning its argv. */ +async function waitForEditorInvocation( + sinceLength: number, + // Generous: under a fully parallel test run the editor process can take + // a while to spawn on a loaded machine. + timeoutMs = 20_000 +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const lines = readEditorLog(); + if (lines.length > sinceLength) { + return lines[lines.length - 1]; + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + return undefined; +} + +describe.skipIf(process.platform === 'win32')( + 'dev-server symbolication (tester-federation-v2)', + () => { + beforeAll(async () => { + // A server already listening here is a stale orphan from an aborted + // run — it would answer our requests with the wrong environment + // (e.g. a different editor log) and make failures unexplainable. + for (const port of [HOST_PORT, MINI_PORT]) { + const stale = await fetch(`http://localhost:${port}/`, { + signal: AbortSignal.timeout(1000), + }).catch(() => undefined); + if (stale) { + throw new Error( + `Port ${port} is already in use — kill the stale dev server ` + + `before running this suite (e.g. lsof -nP -iTCP:${port}).` + ); + } + } + + editorLog = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'repack-symbolication-e2e-')), + 'editor-invocations.log' + ); + + hostServer = startServer('config.host-app.mts', HOST_PORT); + miniServer = startServer('config.mini-app.mts', MINI_PORT); + + // afterAll doesn't run if the process is killed mid-run; make sure the + // detached server process groups never outlive the test runner. + process.on('exit', () => { + stopServer(hostServer); + stopServer(miniServer); + }); + + await Promise.all([ + waitFor( + `http://localhost:${HOST_PORT}/index.bundle?platform=${PLATFORM}`, + 180_000 + ), + waitFor( + `http://localhost:${MINI_PORT}/${PLATFORM}/${REMOTE_CHUNK}`, + 180_000 + ), + ]); + + // Shared dependencies are split into their own chunks under MFv2; + // the manifest tells us where they live. Use one as a source of + // node_modules-mapped frames. + const manifest = await ( + await fetch( + `http://localhost:${MINI_PORT}/${PLATFORM}/mf-manifest.json` + ) + ).json(); + const sharedChunk = manifest.shared?.flatMap( + (shared: { assets?: { js?: { sync?: string[] } } }) => + shared.assets?.js?.sync ?? [] + )[0]; + if (!sharedChunk) { + throw new Error('No shared chunk found in mf-manifest.json'); + } + + [hostAppFrame, miniNodeModulesFrame, remoteFrame] = await Promise.all([ + findMappedFrame( + `http://localhost:${HOST_PORT}/index.bundle.map?platform=${PLATFORM}`, + (source) => source.includes('/src/host/'), + 'HostComponent' + ), + findMappedFrame( + `http://localhost:${MINI_PORT}/${PLATFORM}/${sharedChunk}.map`, + (source) => source.includes('node_modules'), + 'internalHelper' + ), + findMappedFrame( + `http://localhost:${MINI_PORT}/${PLATFORM}/${REMOTE_CHUNK}.map`, + (source) => source.endsWith('.tsx'), + 'Screen1' + ), + ]); + }, 300_000); + + afterAll(() => { + stopServer(hostServer); + stopServer(miniServer); + }); + + it('symbolicates own frames on a server without corrupt map sources (control)', async () => { + // The mini-app dev server owns the remote chunk and its map is clean, + // so this exercises the baseline symbolication workflow. + const { status, body } = await symbolicate(MINI_PORT, [remoteFrame]); + + expect(status).toBe(200); + expect(body.stack).toHaveLength(1); + expect(body.stack?.[0].file).toMatch(/^\[projectRoot\]\/src\/mini\//); + expect(body.codeFrame).toBeTruthy(); + }); + + it('symbolicates a host-only stack on a Module Federation v2 host', async () => { + // MFv2 emits a corrupt `webpack://` source name (raw runtime code) + // into the host map; symbolication must survive it instead of + // failing the whole request with 'Invalid URL'. + const { status, body } = await symbolicate(HOST_PORT, [hostAppFrame]); + + expect(status).toBe(200); + expect(body.stack).toHaveLength(1); + expect(body.stack?.[0].file).toMatch(/^\[projectRoot\]\/src\/host\//); + }); + + it('symbolicates a federated remote chunk frame on the host server', async () => { + // Mirrors the reported failure: the shell's dev server is asked to + // symbolicate a frame from a remote served by another dev server. + const { status, body } = await symbolicate(HOST_PORT, [remoteFrame]); + + expect(status).toBe(200); + expect(body.stack?.[0].file).toMatch(/^\[projectRoot\]\/src\/mini\//); + expect(body.stack?.[0].lineNumber).toBeGreaterThan(1); + }); + + it('symbolicates host frames even when the stack contains a remote frame', async () => { + // One foreign frame must not poison symbolication of frames the + // host compiler owns. + const { status, body } = await symbolicate(HOST_PORT, [ + remoteFrame, + hostAppFrame, + ]); + + expect(status).toBe(200); + const hostFrame = body.stack?.find((frame) => + frame.file?.startsWith('[projectRoot]/src/host/') + ); + expect(hostFrame).toBeTruthy(); + }); + + it('opens the editor for a [projectRoot] stack frame', async () => { + const logLength = readEditorLog().length; + + const status = await openStackFrame( + HOST_PORT, + '[projectRoot]/src/host/App.tsx', + 1 + ); + expect(status).toBe(200); + + const invocation = await waitForEditorInvocation(logLength); + expect(invocation).toBeTruthy(); + expect(invocation).toContain(path.join(appDir, 'src/host/App.tsx')); + }); + + it('opens the editor for node_modules frames as returned by /symbolicate', async () => { + // Whatever file value /symbolicate produces must be a valid input for + // /open-stack-frame. node_modules frames symbolicate to parent-escape + // tokens (e.g. [projectRoot^2]/node_modules/...) which are returned + // URL-encoded ([projectRoot%5E2]/...) and must still resolve to a + // real file on disk. + const { status, body } = await symbolicate(MINI_PORT, [ + miniNodeModulesFrame, + ]); + expect(status).toBe(200); + const symbolicatedFile = body.stack?.[0].file; + expect(symbolicatedFile).toContain('node_modules'); + + const logLength = readEditorLog().length; + const openStatus = await openStackFrame( + MINI_PORT, + symbolicatedFile as string, + body.stack?.[0].lineNumber ?? 1 + ); + expect(openStatus).toBe(200); + + const invocation = await waitForEditorInvocation(logLength); + expect( + invocation, + `expected the editor to be invoked for ${symbolicatedFile}, but it never was` + ).toBeTruthy(); + expect(fs.existsSync(invocation?.replace(/:\d+$/, '') ?? '')).toBe(true); + }); + } +); diff --git a/tests/integration/package.json b/tests/integration/package.json index 76b27843a..9fc28f8d2 100644 --- a/tests/integration/package.json +++ b/tests/integration/package.json @@ -19,6 +19,7 @@ "@rspack/core": "catalog:", "@types/node": "catalog:", "memfs": "^4.11.1", + "source-map": "^0.7.6", "typescript": "catalog:", "vitest": "catalog:", "webpack": "catalog:", diff --git a/tests/integration/vitest.config.ts b/tests/integration/vitest.config.ts index 6e2d2b80f..c28649b31 100644 --- a/tests/integration/vitest.config.ts +++ b/tests/integration/vitest.config.ts @@ -16,6 +16,15 @@ export default defineConfig({ ); }, projects: [ + { + test: { + name: 'dev-server-e2e', + include: ['e2e/**/*.test.ts'], + environment: 'node', + testTimeout: 60_000, + hookTimeout: 300_000, + }, + }, { test: { name: 'rspack',