From f88bbb34756872a4498877543b85ba4b91f35a2f Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Wed, 5 Aug 2026 23:01:50 +0200 Subject: [PATCH 1/8] fix: load source maps from federated bundles --- .../fetchSourceMapFromBundle.test.ts | 100 ++++++++++++++ .../common/fetchSourceMapFromBundle.ts | 126 ++++++++++++++++++ packages/repack/src/commands/common/index.ts | 1 + packages/repack/src/commands/rspack/start.ts | 15 ++- packages/repack/src/commands/webpack/start.ts | 15 ++- 5 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 packages/repack/src/commands/common/__tests__/fetchSourceMapFromBundle.test.ts create mode 100644 packages/repack/src/commands/common/fetchSourceMapFromBundle.ts diff --git a/packages/repack/src/commands/common/__tests__/fetchSourceMapFromBundle.test.ts b/packages/repack/src/commands/common/__tests__/fetchSourceMapFromBundle.test.ts new file mode 100644 index 000000000..cfd0d3894 --- /dev/null +++ b/packages/repack/src/commands/common/__tests__/fetchSourceMapFromBundle.test.ts @@ -0,0 +1,100 @@ +import { + fetchSourceMapFromBundle, + toHttpUrl, +} from '../fetchSourceMapFromBundle.js'; + +const VALID_SOURCE_MAP = JSON.stringify({ + version: 3, + sources: ['[projectRoot]/src/App.tsx'], + names: [], + mappings: 'AAAA', +}); + +function mockFetch(responses: Record) { + return jest.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = input.toString(); + const response = responses[url]; + if (!response) { + throw new Error(`Unexpected fetch: ${url}`); + } + return { + ok: response.ok ?? true, + arrayBuffer: async () => new TextEncoder().encode(response.body).buffer, + } as Response; + }); +} + +describe('toHttpUrl', () => { + it.each([ + [ + 'http://localhost:8082/ios/remote.chunk.bundle', + 'http://localhost:8082/ios/remote.chunk.bundle', + ], + [ + 'localhost:8082/ios/remote.chunk.bundle', + 'http://localhost:8082/ios/remote.chunk.bundle', + ], + [ + '10.0.2.2:8082/android/remote.chunk.bundle', + 'http://10.0.2.2:8082/android/remote.chunk.bundle', + ], + ])('normalizes %s', (input, expected) => { + expect(toHttpUrl(input)?.href).toBe(expected); + }); + + it.each([ + 'remote.chunk.bundle', + '/data/user/0/com.example/files/index.android.bundle', + '[native code]', + ])('rejects non-fetchable value %s', (input) => { + expect(toHttpUrl(input)).toBeUndefined(); + }); +}); + +describe('fetchSourceMapFromBundle', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('fetches and validates the source map declared by a foreign bundle', async () => { + const bundleUrl = + 'http://localhost:8082/ios/foreign-1.chunk.bundle?platform=ios'; + const mapUrl = + 'http://localhost:8082/ios/foreign-1.chunk.bundle.map?platform=ios'; + const fetchMock = mockFetch({ + [bundleUrl]: { + body: 'code();\n//# sourceMappingURL=foreign-1.chunk.bundle.map?platform=ios', + }, + [mapUrl]: { body: VALID_SOURCE_MAP }, + }); + + await expect(fetchSourceMapFromBundle(bundleUrl)).resolves.toEqual( + Buffer.from(VALID_SOURCE_MAP) + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('rejects a response that is not a source map', async () => { + const bundleUrl = 'http://localhost:8082/ios/foreign-2.chunk.bundle'; + mockFetch({ + [bundleUrl]: { + body: 'code();\n//# sourceMappingURL=foreign-2.chunk.bundle.map', + }, + [`${bundleUrl}.map`]: { body: 'not a source map' }, + }); + + await expect(fetchSourceMapFromBundle(bundleUrl)).resolves.toBeUndefined(); + }); + + it('caches repeated lookups, including misses', async () => { + const bundleUrl = 'http://localhost:8082/ios/foreign-3.chunk.bundle'; + const fetchMock = mockFetch({ + [bundleUrl]: { body: 'code without a source map comment' }, + }); + + await fetchSourceMapFromBundle(bundleUrl); + await fetchSourceMapFromBundle(bundleUrl); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/repack/src/commands/common/fetchSourceMapFromBundle.ts b/packages/repack/src/commands/common/fetchSourceMapFromBundle.ts new file mode 100644 index 000000000..be2c9462f --- /dev/null +++ b/packages/repack/src/commands/common/fetchSourceMapFromBundle.ts @@ -0,0 +1,126 @@ +const FETCH_TIMEOUT_MS = 2_000; +const CACHE_TTL_MS = 10_000; + +interface CacheEntry { + expiresAt: number; + value: Promise; +} + +const cache = new Map(); + +/** + * Convert a stack-frame file value into a fetchable HTTP(S) URL. + * React Native can omit the scheme for development-server URLs. + */ +export function toHttpUrl(fileUrl: string): URL | undefined { + const candidates = [ + fileUrl, + fileUrl.startsWith('//') ? `http:${fileUrl}` : `http://${fileUrl}`, + ]; + + for (const candidate of candidates) { + let url: URL; + try { + url = new URL(candidate); + } catch { + continue; + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + continue; + } + + // A coerced value must look like a development-server address, not a + // bundle filename that happened to parse as a hostname. + if ( + candidate !== fileUrl && + url.port === '' && + url.hostname !== 'localhost' + ) { + continue; + } + + return url; + } + + return undefined; +} + +function looksLikeSourceMap(buffer: Buffer): boolean { + try { + const map = JSON.parse(buffer.toString('utf8')) as { + version?: unknown; + mappings?: unknown; + sections?: unknown; + }; + return ( + map?.version === 3 && + (typeof map.mappings === 'string' || Array.isArray(map.sections)) + ); + } catch { + return false; + } +} + +async function fetchBuffer(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 lookupSourceMap(fileUrl: string): Promise { + const bundleUrl = toHttpUrl(fileUrl); + if (!bundleUrl) { + return undefined; + } + + const bundle = await fetchBuffer(bundleUrl); + if (!bundle) { + return undefined; + } + + const bundleText = bundle.toString('utf8'); + const sourceMappingUrlIndex = bundleText.lastIndexOf('sourceMappingURL='); + if (sourceMappingUrlIndex === -1) { + return undefined; + } + + const declaredSourceMap = bundleText + .slice(sourceMappingUrlIndex + 'sourceMappingURL='.length) + .match(/^(\S+)/)?.[1] + ?.replace(/\*\/$/, ''); + if (!declaredSourceMap) { + return undefined; + } + + const sourceMapUrl = new URL(declaredSourceMap, bundleUrl); + if (sourceMapUrl.protocol !== 'http:' && sourceMapUrl.protocol !== 'https:') { + return undefined; + } + + const sourceMap = await fetchBuffer(sourceMapUrl); + return sourceMap && looksLikeSourceMap(sourceMap) ? sourceMap : undefined; +} + +/** + * Fetch the source map explicitly declared by a bundle served by another + * development server. Results and misses are cached briefly because React + * Native usually sends call-stack and component-stack requests together. + */ +export async function fetchSourceMapFromBundle( + fileUrl: string +): Promise { + const now = Date.now(); + const cached = cache.get(fileUrl); + if (cached && cached.expiresAt > now) { + return cached.value; + } + + const value = lookupSourceMap(fileUrl).catch(() => undefined); + cache.set(fileUrl, { expiresAt: now + CACHE_TTL_MS, value }); + return value; +} diff --git a/packages/repack/src/commands/common/index.ts b/packages/repack/src/commands/common/index.ts index 2d8bbb858..98792731d 100644 --- a/packages/repack/src/commands/common/index.ts +++ b/packages/repack/src/commands/common/index.ts @@ -1,4 +1,5 @@ export * from './config/makeCompilerConfig.js'; +export * from './fetchSourceMapFromBundle.js'; export * from './getDevMiddleware.js'; export * from './getMaxWorkers.js'; export * from './getMimeType.js'; diff --git a/packages/repack/src/commands/rspack/start.ts b/packages/repack/src/commands/rspack/start.ts index c4f53f8b7..9a426d7a6 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 { + fetchSourceMapFromBundle, getDevMiddleware, getMaxWorkers, getMimeType, @@ -170,9 +171,17 @@ 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) { + const remoteSourceMap = await fetchSourceMapFromBundle(url); + if (remoteSourceMap) { + return remoteSourceMap; + } + 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 bea994b5f..5e5a24107 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 { + fetchSourceMapFromBundle, getDevMiddleware, getMimeType, parseUrl, @@ -204,9 +205,17 @@ 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) { + const remoteSourceMap = await fetchSourceMapFromBundle(url); + if (remoteSourceMap) { + return remoteSourceMap; + } + throw error; + } }, shouldIncludeFrame: (frame) => { // If the frame points to internal bootstrap/module system logic, skip the code frame. From f107cabaf01d39ff5d626c84ae5aac09433147e9 Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Wed, 5 Aug 2026 23:02:37 +0200 Subject: [PATCH 2/8] fix: improve symbolication for federated stack frames --- .../src/plugins/symbolicate/Symbolicator.ts | 135 ++++++---- .../__tests__/Symbolicator.test.ts | 248 ++++++++++++++++++ .../symbolicate/logSymbolicatedStackFrame.ts | 41 +++ .../plugins/symbolicate/sybmolicatePlugin.ts | 2 + .../dev-server/src/utils/symbolication.ts | 78 ++++++ 5 files changed, 449 insertions(+), 55 deletions(-) create mode 100644 packages/dev-server/src/plugins/symbolicate/__tests__/Symbolicator.test.ts create mode 100644 packages/dev-server/src/plugins/symbolicate/logSymbolicatedStackFrame.ts create mode 100644 packages/dev-server/src/utils/symbolication.ts diff --git a/packages/dev-server/src/plugins/symbolicate/Symbolicator.ts b/packages/dev-server/src/plugins/symbolicate/Symbolicator.ts index 97f845e7d..a69053743 100644 --- a/packages/dev-server/src/plugins/symbolicate/Symbolicator.ts +++ b/packages/dev-server/src/plugins/symbolicate/Symbolicator.ts @@ -2,6 +2,11 @@ import { URL } from 'node:url'; import { codeFrameColumns } from '@babel/code-frame'; import type { FastifyBaseLogger } from 'fastify'; import { SourceMapConsumer } from 'source-map'; +import { + isGeneratedBundleFrame, + isSymbolicatableFrame, + normalizeInvalidWebpackSourceUrls, +} from '../../utils/symbolication.js'; import type { CodeFrame, InputStackFrame, @@ -45,11 +50,6 @@ export class Symbolicator { } } - /** - * Cache with initialized `SourceMapConsumer` to improve symbolication performance. - */ - sourceMapConsumerCache: Record = {}; - /** * Constructs new `Symbolicator` instance. * @@ -75,58 +75,72 @@ export class Symbolicator { ): 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); - } - } + const frames = stack.filter(isSymbolicatableFrame); + // A Symbolicator instance is shared by the route. Keep consumers local to + // one request so concurrent call-stack and component-stack requests cannot + // destroy or read each other's source maps. + const sourceMapConsumers = new Map(); 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, - }); + try { + if (!sourceMapConsumers.has(frame.file)) { + logger.debug({ + msg: 'Loading raw source map data', + fileUrl: frame.file, + }); + + const rawSourceMap = await this.delegate.getSourceMap(frame.file); - const rawSourceMap = await this.delegate.getSourceMap(frame.file); + logger.debug({ + msg: 'Creating source map instance', + fileUrl: frame.file, + sourceMapLength: rawSourceMap.length, + }); + const sourceMapConsumer = await new SourceMapConsumer( + normalizeInvalidWebpackSourceUrls(rawSourceMap) + ); + + logger.debug({ + msg: 'Saving source map instance into cache', + fileUrl: frame.file, + }); + sourceMapConsumers.set(frame.file, sourceMapConsumer); + } logger.debug({ - msg: 'Creating source map instance', - fileUrl: frame.file, - sourceMapLength: rawSourceMap.length, + msg: 'Symbolicating frame', + frame, }); - const sourceMapConsumer = await new SourceMapConsumer( - rawSourceMap.toString() - ); + const processedFrame = this.processFrame(frame, sourceMapConsumers); logger.debug({ - msg: 'Saving source map instance into cache', + msg: 'Finished symbolicating frame', + frame, + }); + processedFrames.push(processedFrame); + } catch (error) { + // Match Metro's best-effort behavior: one unavailable or malformed + // source map must not discard frames that can still be symbolicated. + logger.debug({ + msg: 'Failed to symbolicate frame', fileUrl: frame.file, + error: (error as Error).message, }); - this.sourceMapConsumerCache[frame.file] = sourceMapConsumer; + processedFrames.push({ ...frame, collapse: false }); } - - logger.debug({ - msg: 'Symbolicating frame', - frame, - }); - const processedFrame = this.processFrame(frame); - - logger.debug({ - msg: 'Finished symbolicating frame', - frame, - }); - processedFrames.push(processedFrame); } const codeFrame = - (await this.getCodeFrame(logger, processedFrames)) ?? null; + (await this.getCodeFrame( + logger, + processedFrames, + frames, + sourceMapConsumers + )) ?? null; logger.debug({ msg: 'Finished symbolicating frames', @@ -134,27 +148,26 @@ export class Symbolicator { codeFrame, }); - return { - stack: processedFrames, - codeFrame, - }; + return { stack: processedFrames, codeFrame }; } finally { - for (const key in this.sourceMapConsumerCache) { - this.sourceMapConsumerCache[key].destroy(); - delete this.sourceMapConsumerCache[key]; + for (const consumer of sourceMapConsumers.values()) { + consumer.destroy(); } } } - private processFrame(frame: InputStackFrame): StackFrame { - if (!frame.lineNumber || !frame.column) { + private processFrame( + frame: InputStackFrame, + sourceMapConsumers: Map + ): StackFrame { + if (frame.lineNumber == null || frame.column == null) { return { ...frame, collapse: false, }; } - const consumer = this.sourceMapConsumerCache[frame.file]; + const consumer = sourceMapConsumers.get(frame.file); if (!consumer) { return { ...frame, @@ -186,8 +199,8 @@ export class Symbolicator { } return { - lineNumber: lookup.line || frame.lineNumber, - column: lookup.column || frame.column, + lineNumber: lookup.line ?? frame.lineNumber, + column: lookup.column ?? frame.column, file: lookup.source, methodName: lookup.name || frame.methodName, collapse: false, @@ -196,10 +209,16 @@ export class Symbolicator { private async getCodeFrame( logger: FastifyBaseLogger, - processedFrames: StackFrame[] + processedFrames: StackFrame[], + inputFrames: InputStackFrame[], + sourceMapConsumers: Map ): Promise { - for (const frame of processedFrames) { - if (frame.collapse || !frame.lineNumber || !frame.column) { + for (const [index, frame] of processedFrames.entries()) { + if (frame.collapse || frame.lineNumber == null || frame.column == null) { + continue; + } + + if (isGeneratedBundleFrame(frame)) { continue; } @@ -213,9 +232,15 @@ export class Symbolicator { }); try { + const consumer = sourceMapConsumers.get(inputFrames[index]?.file); + const embeddedSource = consumer?.sourceContentFor(frame.file, true); + const source = + embeddedSource ?? + (await this.delegate.getSource(frame.file)).toString(); + return { content: codeFrameColumns( - (await this.delegate.getSource(frame.file)).toString(), + source, { start: { column: frame.column, line: frame.lineNumber }, }, 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..769ca8f08 --- /dev/null +++ b/packages/dev-server/src/plugins/symbolicate/__tests__/Symbolicator.test.ts @@ -0,0 +1,248 @@ +import type { FastifyBaseLogger } from 'fastify'; +import { describe, expect, it, vi } from 'vitest'; +import { logSymbolicatedStackFrame } from '../logSymbolicatedStackFrame.js'; +import { Symbolicator } from '../Symbolicator.js'; +import type { + ReactNativeStackFrame, + SymbolicatorDelegate, + SymbolicatorResults, +} from '../types.js'; + +const logger = { + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), +} as unknown as FastifyBaseLogger; + +function createSourceMap(source: string, content: string) { + return JSON.stringify({ + version: 3, + sources: [source], + sourcesContent: [content], + names: [], + mappings: 'AAAA', + }); +} + +function createDelegate( + getSourceMap: SymbolicatorDelegate['getSourceMap'] +): SymbolicatorDelegate { + return { + getSourceMap, + getSource: vi.fn(async () => { + throw new Error('Source is not available from the host compiler'); + }), + shouldIncludeFrame: () => true, + }; +} + +describe('Symbolicator', () => { + it('symbolicates remaining frames when one source map is unavailable', async () => { + const remoteUrl = 'http://localhost:8082/remote.chunk.bundle'; + const stack: ReactNativeStackFrame[] = [ + { + file: 'http://localhost:8082/missing.chunk.bundle', + lineNumber: 1, + column: 1, + methodName: 'missing', + }, + { + file: remoteUrl, + lineNumber: 1, + column: 1, + methodName: 'RemoteScreen', + }, + ]; + const symbolicator = new Symbolicator( + createDelegate(async (url) => { + if (url !== remoteUrl) { + throw new Error('Source map is missing'); + } + return createSourceMap( + '[projectRoot]/src/RemoteScreen.tsx', + "throw new Error('REMOTE ERROR');" + ); + }) + ); + + const result = await symbolicator.process(logger, stack); + + expect(result.stack).toHaveLength(2); + expect(result.stack[0]?.file).toBe(stack[0]?.file); + expect(result.stack[1]).toMatchObject({ + file: '[projectRoot]/src/RemoteScreen.tsx', + lineNumber: 1, + column: 0, + }); + expect(result.codeFrame?.content).toContain('REMOTE ERROR'); + }); + + it('normalizes malformed webpack ignored-module source URLs', async () => { + const symbolicator = new Symbolicator( + createDelegate(async () => + createSourceMap('webpack://ignored|/buffer', 'module.exports = {};') + ) + ); + + const result = await symbolicator.process(logger, [ + { + file: 'http://localhost:8082/ignored.chunk.bundle', + lineNumber: 1, + column: 1, + methodName: 'ignored', + }, + ]); + + expect(result.stack[0]?.file).toBe('webpack://ignored/buffer'); + }); + + it('keeps valid application mappings when another webpack source URL is invalid', async () => { + const symbolicator = new Symbolicator( + createDelegate(async () => + JSON.stringify({ + version: 3, + sources: [ + 'webpack://=="undefined"};generated federation runtime', + '[projectRoot]/src/App.tsx', + ], + sourcesContent: ['generated runtime', 'const app = 1;'], + names: [], + mappings: 'ACAA', + }) + ) + ); + + const result = await symbolicator.process(logger, [ + { + file: 'http://localhost:8081/index.bundle?platform=ios', + lineNumber: 1, + column: 0, + methodName: 'App', + }, + ]); + + expect(result.stack[0]).toMatchObject({ + file: '[projectRoot]/src/App.tsx', + lineNumber: 1, + column: 0, + }); + }); + + it('supports generated and original column zero', async () => { + const symbolicator = new Symbolicator( + createDelegate(async () => + createSourceMap('[projectRoot]/src/App.tsx', 'const app = 1;') + ) + ); + + const result = await symbolicator.process(logger, [ + { + file: 'http://localhost:8082/zero.chunk.bundle', + lineNumber: 1, + column: 0, + methodName: 'App', + }, + ]); + + expect(result.stack[0]).toMatchObject({ + file: '[projectRoot]/src/App.tsx', + lineNumber: 1, + column: 0, + }); + }); +}); + +describe('logSymbolicatedStackFrame', () => { + const results: SymbolicatorResults = { + stack: [ + { + file: '[projectRoot]/src/RemoteScreen.tsx', + lineNumber: 42, + column: 18, + methodName: 'RemoteScreen', + collapse: false, + }, + ], + codeFrame: null, + }; + + it('logs the first useful frame for a runtime error', () => { + const info = vi.fn(); + const runtimeLogger = { info } as unknown as FastifyBaseLogger; + + logSymbolicatedStackFrame( + runtimeLogger, + [ + { + file: 'http://localhost:8082/remote.chunk.bundle', + lineNumber: 100, + column: 20, + methodName: 'RemoteScreen', + }, + { + file: 'http://localhost:8081/index.bundle?platform=ios', + lineNumber: 200, + column: 30, + methodName: 'renderWithHooks', + }, + ], + results + ); + + expect(info).toHaveBeenCalledWith({ + msg: 'Symbolicated stack frame: src/RemoteScreen.tsx:42:18', + methodName: 'RemoteScreen', + }); + }); + + it('does not log component-stack-only symbolication', () => { + const info = vi.fn(); + const runtimeLogger = { info } as unknown as FastifyBaseLogger; + + logSymbolicatedStackFrame( + runtimeLogger, + [ + { + file: 'http://localhost:8082/remote.chunk.bundle', + lineNumber: 100, + column: 20, + methodName: 'RemoteScreen', + }, + ], + results + ); + + expect(info).not.toHaveBeenCalled(); + }); + + it('does not report a generated bundle frame as symbolicated', () => { + const info = vi.fn(); + const runtimeLogger = { info } as unknown as FastifyBaseLogger; + + logSymbolicatedStackFrame( + runtimeLogger, + [ + { + file: 'http://localhost:8081/index.bundle?platform=ios', + lineNumber: 100, + column: 20, + methodName: 'renderWithHooks', + }, + ], + { + stack: [ + { + file: 'http://localhost:8081/index.bundle?platform=ios', + lineNumber: 100, + column: 20, + methodName: 'App', + collapse: false, + }, + ], + codeFrame: null, + } + ); + + expect(info).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dev-server/src/plugins/symbolicate/logSymbolicatedStackFrame.ts b/packages/dev-server/src/plugins/symbolicate/logSymbolicatedStackFrame.ts new file mode 100644 index 000000000..79b27ef95 --- /dev/null +++ b/packages/dev-server/src/plugins/symbolicate/logSymbolicatedStackFrame.ts @@ -0,0 +1,41 @@ +import type { FastifyBaseLogger } from 'fastify'; +import { isGeneratedBundleFrame } from '../../utils/symbolication.js'; +import type { ReactNativeStackFrame, SymbolicatorResults } from './types.js'; + +const RUNTIME_ERROR_METHODS = new Set([ + 'react-stack-bottom-frame', + 'renderWithHooks', + 'beginWork', + 'performUnitOfWork', +]); + +function isRuntimeErrorStack(stack: ReactNativeStackFrame[]) { + return stack.some((frame) => RUNTIME_ERROR_METHODS.has(frame.methodName)); +} + +function getPrintableFile(file: string) { + return file.replace(/^\[projectRoot(?:\^\d+)?\][\\/]/, ''); +} + +export function logSymbolicatedStackFrame( + logger: FastifyBaseLogger, + inputStack: ReactNativeStackFrame[], + results: SymbolicatorResults +) { + if (!isRuntimeErrorStack(inputStack)) { + return; + } + + const frame = results.stack.find( + (stackFrame) => !isGeneratedBundleFrame(stackFrame) + ); + if (!frame?.file || frame.lineNumber == null) { + return; + } + + const file = getPrintableFile(frame.file); + logger.info({ + msg: `Symbolicated stack frame: ${file}:${frame.lineNumber}:${frame.column ?? 0}`, + methodName: frame.methodName, + }); +} diff --git a/packages/dev-server/src/plugins/symbolicate/sybmolicatePlugin.ts b/packages/dev-server/src/plugins/symbolicate/sybmolicatePlugin.ts index 089219db5..bdd6b9c7d 100644 --- a/packages/dev-server/src/plugins/symbolicate/sybmolicatePlugin.ts +++ b/packages/dev-server/src/plugins/symbolicate/sybmolicatePlugin.ts @@ -1,6 +1,7 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'; import fastifyPlugin from 'fastify-plugin'; import type { Server } from '../../types.js'; +import { logSymbolicatedStackFrame } from './logSymbolicatedStackFrame.js'; import { Symbolicator } from './Symbolicator.js'; import type { ReactNativeStackFrame } from './types.js'; @@ -42,6 +43,7 @@ async function symbolicatePlugin( } else { request.log.debug({ msg: 'Starting symbolication', platform, stack }); const results = await symbolicator.process(request.log, stack); + logSymbolicatedStackFrame(request.log, stack, results); reply.send(results); } } catch (error) { diff --git a/packages/dev-server/src/utils/symbolication.ts b/packages/dev-server/src/utils/symbolication.ts new file mode 100644 index 000000000..5151ce1e4 --- /dev/null +++ b/packages/dev-server/src/utils/symbolication.ts @@ -0,0 +1,78 @@ +import { URL } from 'node:url'; + +interface StackFrameLike { + file: string | null; +} + +export function normalizeInvalidWebpackSourceUrls( + rawSourceMap: string | Buffer +) { + const sourceMapText = rawSourceMap.toString(); + if (!sourceMapText.includes('webpack://')) { + return sourceMapText; + } + + const sourceMap = JSON.parse(sourceMapText) as { + sources?: unknown[]; + sections?: Array<{ map?: unknown }>; + }; + + let invalidSourceIndex = 0; + const normalize = (map: unknown) => { + if (!map || typeof map !== 'object') { + return; + } + + const current = map as { + sources?: unknown[]; + sections?: Array<{ map?: unknown }>; + }; + if (Array.isArray(current.sources)) { + current.sources = current.sources.map((source) => { + if (typeof source !== 'string') { + return source; + } + + const normalizedSource = source.replace( + /^webpack:\/\/([^/|]+)\|\/?/, + 'webpack://$1/' + ); + if (!normalizedSource.startsWith('webpack://')) { + return normalizedSource; + } + + try { + new URL(normalizedSource); + return normalizedSource; + } catch { + // Some generated Module Federation runtime modules use their source + // text as a webpack URL. A single invalid URL makes source-map reject + // the complete map, including otherwise valid application sources. + return `webpack://invalid-source/${invalidSourceIndex++}`; + } + }); + } + for (const section of current.sections ?? []) { + normalize(section.map); + } + }; + + normalize(sourceMap); + return JSON.stringify(sourceMap); +} + +export function isGeneratedBundleFrame(frame: StackFrameLike) { + return Boolean( + frame.file && + (frame.file.includes('.bundle') || frame.file.includes('.hot-update.js')) + ); +} + +export function isSymbolicatableFrame( + frame: T +): frame is T & { file: string } { + return Boolean( + frame.file && + (frame.file.startsWith('http') || isGeneratedBundleFrame(frame)) + ); +} From 29e968727cbaf0daa42ad7387faff2ab25650c7c Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Wed, 5 Aug 2026 23:02:46 +0200 Subject: [PATCH 3/8] chore: add changeset for federated source map fixes --- .changeset/fix-federated-source-maps.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/fix-federated-source-maps.md diff --git a/.changeset/fix-federated-source-maps.md b/.changeset/fix-federated-source-maps.md new file mode 100644 index 000000000..bbf996a1e --- /dev/null +++ b/.changeset/fix-federated-source-maps.md @@ -0,0 +1,6 @@ +--- +"@callstack/repack": patch +"@callstack/repack-dev-server": patch +--- + +Fix development symbolication for Module Federation host and remote bundles. The host now follows a remote bundle's declared source map, invalid generated webpack source URLs no longer invalidate an otherwise usable map, symbolication continues when an individual frame cannot be mapped, and code frames use the matching source map's embedded source content. The dev server also logs the first useful symbolicated runtime frame as a fallback when opening the source file from the device is delayed. From 08d12d8b4f86cf3c1d9f13bfb16367bc6fa1d0f0 Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Fri, 7 Aug 2026 13:09:45 +0200 Subject: [PATCH 4/8] tests: update Symbolicator tests in dev-server --- .../__tests__/Symbolicator.test.ts | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/dev-server/src/plugins/symbolicate/__tests__/Symbolicator.test.ts b/packages/dev-server/src/plugins/symbolicate/__tests__/Symbolicator.test.ts index 769ca8f08..96d5a3475 100644 --- a/packages/dev-server/src/plugins/symbolicate/__tests__/Symbolicator.test.ts +++ b/packages/dev-server/src/plugins/symbolicate/__tests__/Symbolicator.test.ts @@ -1,5 +1,5 @@ import type { FastifyBaseLogger } from 'fastify'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { logSymbolicatedStackFrame } from '../logSymbolicatedStackFrame.js'; import { Symbolicator } from '../Symbolicator.js'; import type { @@ -14,6 +14,10 @@ const logger = { info: vi.fn(), } as unknown as FastifyBaseLogger; +beforeEach(() => { + vi.clearAllMocks(); +}); + function createSourceMap(source: string, content: string) { return JSON.stringify({ version: 3, @@ -36,6 +40,21 @@ function createDelegate( }; } +function getMockResults(): SymbolicatorResults { + return { + stack: [ + { + file: '[projectRoot]/src/RemoteScreen.tsx', + lineNumber: 42, + column: 18, + methodName: 'RemoteScreen', + collapse: false, + }, + ], + codeFrame: null, + }; +} + describe('Symbolicator', () => { it('symbolicates remaining frames when one source map is unavailable', async () => { const remoteUrl = 'http://localhost:8082/remote.chunk.bundle'; @@ -153,19 +172,6 @@ describe('Symbolicator', () => { }); describe('logSymbolicatedStackFrame', () => { - const results: SymbolicatorResults = { - stack: [ - { - file: '[projectRoot]/src/RemoteScreen.tsx', - lineNumber: 42, - column: 18, - methodName: 'RemoteScreen', - collapse: false, - }, - ], - codeFrame: null, - }; - it('logs the first useful frame for a runtime error', () => { const info = vi.fn(); const runtimeLogger = { info } as unknown as FastifyBaseLogger; @@ -186,7 +192,7 @@ describe('logSymbolicatedStackFrame', () => { methodName: 'renderWithHooks', }, ], - results + getMockResults() ); expect(info).toHaveBeenCalledWith({ @@ -209,7 +215,7 @@ describe('logSymbolicatedStackFrame', () => { methodName: 'RemoteScreen', }, ], - results + getMockResults() ); expect(info).not.toHaveBeenCalled(); From 1a1b7a1d7f300563837fde493c6a816916dd18e7 Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Mon, 10 Aug 2026 15:31:11 +0200 Subject: [PATCH 5/8] perf: avoid reparsing normalized source maps --- packages/dev-server/src/utils/symbolication.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/dev-server/src/utils/symbolication.ts b/packages/dev-server/src/utils/symbolication.ts index 5151ce1e4..0fce9fc2b 100644 --- a/packages/dev-server/src/utils/symbolication.ts +++ b/packages/dev-server/src/utils/symbolication.ts @@ -1,4 +1,5 @@ import { URL } from 'node:url'; +import type { RawIndexMap, RawSourceMap } from 'source-map'; interface StackFrameLike { file: string | null; @@ -6,7 +7,7 @@ interface StackFrameLike { export function normalizeInvalidWebpackSourceUrls( rawSourceMap: string | Buffer -) { +): string | RawSourceMap | RawIndexMap { const sourceMapText = rawSourceMap.toString(); if (!sourceMapText.includes('webpack://')) { return sourceMapText; @@ -58,7 +59,9 @@ export function normalizeInvalidWebpackSourceUrls( }; normalize(sourceMap); - return JSON.stringify(sourceMap); + // SourceMapConsumer accepts parsed maps. Returning the object avoids + // serializing it here only for the consumer to parse it again. + return sourceMap as RawSourceMap | RawIndexMap; } export function isGeneratedBundleFrame(frame: StackFrameLike) { From 59cb1a46e0f588803048d3cddb9dfad464a275c2 Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Mon, 10 Aug 2026 15:31:17 +0200 Subject: [PATCH 6/8] test: cover symbolicator cache lifecycle --- .../__tests__/Symbolicator.test.ts | 145 +++++++++++++++++- 1 file changed, 141 insertions(+), 4 deletions(-) diff --git a/packages/dev-server/src/plugins/symbolicate/__tests__/Symbolicator.test.ts b/packages/dev-server/src/plugins/symbolicate/__tests__/Symbolicator.test.ts index 96d5a3475..ca537c363 100644 --- a/packages/dev-server/src/plugins/symbolicate/__tests__/Symbolicator.test.ts +++ b/packages/dev-server/src/plugins/symbolicate/__tests__/Symbolicator.test.ts @@ -29,17 +29,35 @@ function createSourceMap(source: string, content: string) { } function createDelegate( - getSourceMap: SymbolicatorDelegate['getSourceMap'] + getSourceMap: SymbolicatorDelegate['getSourceMap'], + getSource: SymbolicatorDelegate['getSource'] = vi.fn(async () => { + throw new Error('Source is not available from the host compiler'); + }) ): SymbolicatorDelegate { return { getSourceMap, - getSource: vi.fn(async () => { - throw new Error('Source is not available from the host compiler'); - }), + getSource, shouldIncludeFrame: () => true, }; } +function createSourceMapWithoutContent(source: string) { + return JSON.stringify({ + version: 3, + sources: [source], + names: [], + mappings: 'AAAA', + }); +} + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + function getMockResults(): SymbolicatorResults { return { stack: [ @@ -169,6 +187,125 @@ describe('Symbolicator', () => { column: 0, }); }); + + it('loads a source map once for repeated frames in one request', async () => { + const bundleUrl = 'http://localhost:8082/repeated.chunk.bundle'; + const getSourceMap = vi.fn(async () => + createSourceMap( + '[projectRoot]/src/Repeated.tsx', + 'export const repeated = true;' + ) + ); + const symbolicator = new Symbolicator(createDelegate(getSourceMap)); + + const result = await symbolicator.process(logger, [ + { + file: bundleUrl, + lineNumber: 1, + column: 0, + methodName: 'FirstFrame', + }, + { + file: bundleUrl, + lineNumber: 1, + column: 0, + methodName: 'SecondFrame', + }, + ]); + + expect(getSourceMap).toHaveBeenCalledTimes(1); + expect(result.stack).toHaveLength(2); + expect( + result.stack.every((frame) => frame.file.endsWith('Repeated.tsx')) + ).toBe(true); + }); + + it('loads a fresh source map for each request', async () => { + const bundleUrl = 'http://localhost:8082/rebuilt.chunk.bundle'; + const getSourceMap = vi + .fn() + .mockResolvedValueOnce( + createSourceMap( + '[projectRoot]/src/BeforeRebuild.tsx', + 'export const version = 1;' + ) + ) + .mockResolvedValueOnce( + createSourceMap( + '[projectRoot]/src/AfterRebuild.tsx', + 'export const version = 2;' + ) + ); + const symbolicator = new Symbolicator(createDelegate(getSourceMap)); + const stack = [ + { + file: bundleUrl, + lineNumber: 1, + column: 0, + methodName: 'App', + }, + ]; + + const beforeRebuild = await symbolicator.process(logger, stack); + const afterRebuild = await symbolicator.process(logger, stack); + + expect(getSourceMap).toHaveBeenCalledTimes(2); + expect(beforeRebuild.stack[0]?.file).toBe( + '[projectRoot]/src/BeforeRebuild.tsx' + ); + expect(afterRebuild.stack[0]?.file).toBe( + '[projectRoot]/src/AfterRebuild.tsx' + ); + }); + + it('isolates source map consumers between concurrent requests', async () => { + const bundleUrl = 'http://localhost:8082/concurrent.chunk.bundle'; + const firstSourceRequested = createDeferred(); + const releaseFirstSource = createDeferred(); + let getSourceCallCount = 0; + const getSource = vi.fn(async () => { + getSourceCallCount += 1; + if (getSourceCallCount === 1) { + firstSourceRequested.resolve(); + return releaseFirstSource.promise; + } + return 'export const request = 2;'; + }); + const getSourceMap = vi + .fn() + .mockResolvedValueOnce( + createSourceMapWithoutContent('[projectRoot]/src/FirstRequest.tsx') + ) + .mockResolvedValueOnce( + createSourceMapWithoutContent('[projectRoot]/src/SecondRequest.tsx') + ); + const symbolicator = new Symbolicator( + createDelegate(getSourceMap, getSource) + ); + const stack = [ + { + file: bundleUrl, + lineNumber: 1, + column: 0, + methodName: 'App', + }, + ]; + + const firstRequest = symbolicator.process(logger, stack); + await firstSourceRequested.promise; + + const secondResult = await symbolicator.process(logger, stack); + releaseFirstSource.resolve('export const request = 1;'); + const firstResult = await firstRequest; + + expect(getSourceMap).toHaveBeenCalledTimes(2); + expect(firstResult.stack[0]?.file).toBe( + '[projectRoot]/src/FirstRequest.tsx' + ); + expect(secondResult.stack[0]?.file).toBe( + '[projectRoot]/src/SecondRequest.tsx' + ); + }); }); describe('logSymbolicatedStackFrame', () => { From f10d027f618f9e808500033cdf3575523f8bfc25 Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Tue, 11 Aug 2026 20:03:34 +0200 Subject: [PATCH 7/8] fix: preserve ownership of federated source frames --- .../src/plugins/devtools/devtoolsPlugin.ts | 10 +- packages/dev-server/src/types.ts | 10 ++ .../fetchSourceMapFromBundle.test.ts | 91 +++++++++++++++++- .../common/fetchSourceMapFromBundle.ts | 96 +++++++++++++++++-- packages/repack/src/commands/rspack/start.ts | 2 + packages/repack/src/commands/webpack/start.ts | 2 + 6 files changed, 196 insertions(+), 15 deletions(-) diff --git a/packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts b/packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts index 7ffe06063..a19015718 100644 --- a/packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts +++ b/packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts @@ -44,8 +44,14 @@ async function devtoolsPlugin( const { file, lineNumber } = parseRequestBody( request.body ); - const filepath = delegate.devTools?.resolveProjectPath(file) ?? file; - launchEditor(`${filepath}:${lineNumber}`, process.env.REACT_EDITOR); + const openedRemotely = await delegate.devTools?.openStackFrame?.( + file, + lineNumber + ); + if (!openedRemotely) { + const filepath = delegate.devTools?.resolveProjectPath(file) ?? file; + launchEditor(`${filepath}:${lineNumber}`, process.env.REACT_EDITOR); + } reply.send('OK'); }, }); diff --git a/packages/dev-server/src/types.ts b/packages/dev-server/src/types.ts index d273664e6..b7e7f0d0b 100644 --- a/packages/dev-server/src/types.ts +++ b/packages/dev-server/src/types.ts @@ -191,6 +191,16 @@ export namespace Server { * @returns The resolved project path. */ resolveProjectPath: (filepath: string) => string; + + /** + * Open a stack frame owned by another development server. + * + * @returns Whether the frame was handled remotely. + */ + openStackFrame?: ( + filepath: string, + lineNumber: number + ) => boolean | Promise; } /** diff --git a/packages/repack/src/commands/common/__tests__/fetchSourceMapFromBundle.test.ts b/packages/repack/src/commands/common/__tests__/fetchSourceMapFromBundle.test.ts index cfd0d3894..2dd9ba71d 100644 --- a/packages/repack/src/commands/common/__tests__/fetchSourceMapFromBundle.test.ts +++ b/packages/repack/src/commands/common/__tests__/fetchSourceMapFromBundle.test.ts @@ -1,5 +1,7 @@ import { fetchSourceMapFromBundle, + getRemoteSource, + openRemoteStackFrame, toHttpUrl, } from '../fetchSourceMapFromBundle.js'; @@ -68,12 +70,46 @@ describe('fetchSourceMapFromBundle', () => { [mapUrl]: { body: VALID_SOURCE_MAP }, }); - await expect(fetchSourceMapFromBundle(bundleUrl)).resolves.toEqual( - Buffer.from(VALID_SOURCE_MAP) - ); + const sourceMap = await fetchSourceMapFromBundle(bundleUrl); + + expect(JSON.parse(sourceMap!.toString())).toMatchObject({ + sources: [ + 'http://localhost:8082/__repack_source__/[projectRoot]/src/App.tsx', + ], + }); expect(fetchMock).toHaveBeenCalledTimes(2); }); + it('preserves the remote owner for project sources in indexed maps', async () => { + const bundleUrl = 'http://localhost:8083/android/remote.chunk.bundle'; + const sourceMap = JSON.stringify({ + version: 3, + sections: [ + { + offset: { line: 0, column: 0 }, + map: { + version: 3, + sources: ['[projectRoot^1]/shared/App.tsx'], + names: [], + mappings: 'AAAA', + }, + }, + ], + }); + mockFetch({ + [bundleUrl]: { + body: 'code();\n//# sourceMappingURL=remote.chunk.bundle.map', + }, + [`${bundleUrl}.map`]: { body: sourceMap }, + }); + + const result = await fetchSourceMapFromBundle(bundleUrl); + + expect(JSON.parse(result!.toString()).sections[0].map.sources).toEqual([ + 'http://localhost:8083/__repack_source__/[projectRoot^1]/shared/App.tsx', + ]); + }); + it('rejects a response that is not a source map', async () => { const bundleUrl = 'http://localhost:8082/ios/foreign-2.chunk.bundle'; mockFetch({ @@ -98,3 +134,52 @@ describe('fetchSourceMapFromBundle', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); }); + +describe('remote source frames', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('extracts the owning dev server and project-relative file', () => { + expect( + getRemoteSource( + 'http://localhost:8082/__repack_source__/[projectRoot]/src/App.tsx' + ) + ).toEqual({ + file: '[projectRoot]/src/App.tsx', + origin: 'http://localhost:8082', + }); + }); + + it('forwards editor navigation to the owning dev server', async () => { + const fetchMock = mockFetch({ + 'http://localhost:8082/open-stack-frame': { body: 'OK' }, + }); + + await expect( + openRemoteStackFrame( + 'http://localhost:8082/__repack_source__/[projectRoot]/src/App.tsx', + 13 + ) + ).resolves.toBe(true); + expect(fetchMock).toHaveBeenCalledWith( + new URL('http://localhost:8082/open-stack-frame'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + file: '[projectRoot]/src/App.tsx', + lineNumber: 13, + }), + }) + ); + }); + + it('leaves local source frames to the current dev server', async () => { + const fetchMock = jest.spyOn(globalThis, 'fetch'); + + await expect( + openRemoteStackFrame('[projectRoot]/src/App.tsx', 13) + ).resolves.toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/repack/src/commands/common/fetchSourceMapFromBundle.ts b/packages/repack/src/commands/common/fetchSourceMapFromBundle.ts index be2c9462f..cc71a1720 100644 --- a/packages/repack/src/commands/common/fetchSourceMapFromBundle.ts +++ b/packages/repack/src/commands/common/fetchSourceMapFromBundle.ts @@ -1,5 +1,14 @@ const FETCH_TIMEOUT_MS = 2_000; const CACHE_TTL_MS = 10_000; +const REMOTE_SOURCE_PATH_PREFIX = '/__repack_source__/'; +const PROJECT_ROOT_SOURCE_PATTERN = /^\[projectRoot(?:\^\d+)?\][\\/]/; + +interface SourceMapLike { + version?: unknown; + mappings?: unknown; + sources?: unknown[]; + sections?: Array<{ map?: SourceMapLike }>; +} interface CacheEntry { expiresAt: number; @@ -46,20 +55,87 @@ export function toHttpUrl(fileUrl: string): URL | undefined { return undefined; } -function looksLikeSourceMap(buffer: Buffer): boolean { +function prepareSourceMap(buffer: Buffer, bundleUrl: URL): Buffer | undefined { try { - const map = JSON.parse(buffer.toString('utf8')) as { - version?: unknown; - mappings?: unknown; - sections?: unknown; + const map = JSON.parse(buffer.toString('utf8')) as SourceMapLike; + if ( + map?.version !== 3 || + (typeof map.mappings !== 'string' && !Array.isArray(map.sections)) + ) { + return undefined; + } + + const addRemoteOrigin = (sourceMap: SourceMapLike) => { + if (Array.isArray(sourceMap.sources)) { + sourceMap.sources = sourceMap.sources.map((source) => { + if ( + typeof source !== 'string' || + !PROJECT_ROOT_SOURCE_PATTERN.test(source) + ) { + return source; + } + + const sourceUrl = new URL(bundleUrl.origin); + sourceUrl.pathname = `${REMOTE_SOURCE_PATH_PREFIX}${source}`; + return sourceUrl.href; + }); + } + + for (const section of sourceMap.sections ?? []) { + if (section.map) { + addRemoteOrigin(section.map); + } + } }; - return ( - map?.version === 3 && - (typeof map.mappings === 'string' || Array.isArray(map.sections)) - ); + + addRemoteOrigin(map); + return Buffer.from(JSON.stringify(map)); } catch { + return undefined; + } +} + +export function getRemoteSource(fileUrl: string): + | { + file: string; + origin: string; + } + | undefined { + const sourceUrl = toHttpUrl(fileUrl); + if (!sourceUrl?.pathname.startsWith(REMOTE_SOURCE_PATH_PREFIX)) { + return undefined; + } + + const file = decodeURIComponent( + sourceUrl.pathname.slice(REMOTE_SOURCE_PATH_PREFIX.length) + ); + if (!PROJECT_ROOT_SOURCE_PATTERN.test(file)) { + return undefined; + } + + return { file, origin: sourceUrl.origin }; +} + +export async function openRemoteStackFrame( + fileUrl: string, + lineNumber: number +): Promise { + const source = getRemoteSource(fileUrl); + if (!source) { return false; } + + const response = await fetch(new URL('/open-stack-frame', source.origin), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ file: source.file, lineNumber }), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`Remote dev server returned ${response.status}`); + } + + return true; } async function fetchBuffer(url: URL): Promise { @@ -103,7 +179,7 @@ async function lookupSourceMap(fileUrl: string): Promise { } const sourceMap = await fetchBuffer(sourceMapUrl); - return sourceMap && looksLikeSourceMap(sourceMap) ? sourceMap : undefined; + return sourceMap ? prepareSourceMap(sourceMap, bundleUrl) : undefined; } /** diff --git a/packages/repack/src/commands/rspack/start.ts b/packages/repack/src/commands/rspack/start.ts index 9a426d7a6..4ba481c23 100644 --- a/packages/repack/src/commands/rspack/start.ts +++ b/packages/repack/src/commands/rspack/start.ts @@ -15,6 +15,7 @@ import { getDevMiddleware, getMaxWorkers, getMimeType, + openRemoteStackFrame, parseUrl, resetPersistentCache, resolveProjectPath, @@ -164,6 +165,7 @@ export async function start( resolveProjectPath: (filepath) => { return resolveProjectPath(filepath, cliConfig.root); }, + openStackFrame: openRemoteStackFrame, }, symbolicator: { getSource: (url) => { diff --git a/packages/repack/src/commands/webpack/start.ts b/packages/repack/src/commands/webpack/start.ts index 5e5a24107..d3aae80cf 100644 --- a/packages/repack/src/commands/webpack/start.ts +++ b/packages/repack/src/commands/webpack/start.ts @@ -16,6 +16,7 @@ import { fetchSourceMapFromBundle, getDevMiddleware, getMimeType, + openRemoteStackFrame, parseUrl, resetPersistentCache, resolveProjectPath, @@ -198,6 +199,7 @@ export async function start( resolveProjectPath: (filepath) => { return resolveProjectPath(filepath, cliConfig.root); }, + openStackFrame: openRemoteStackFrame, }, symbolicator: { getSource: (url) => { From 9f0555adaac192fc731d0dba315b3526f428b4d4 Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Tue, 11 Aug 2026 20:03:58 +0200 Subject: [PATCH 8/8] fix: identify remote owners in symbolication logs --- .../__tests__/Symbolicator.test.ts | 40 +++++++++++++++++++ .../symbolicate/logSymbolicatedStackFrame.ts | 32 +++++++++++++-- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/packages/dev-server/src/plugins/symbolicate/__tests__/Symbolicator.test.ts b/packages/dev-server/src/plugins/symbolicate/__tests__/Symbolicator.test.ts index ca537c363..db258be73 100644 --- a/packages/dev-server/src/plugins/symbolicate/__tests__/Symbolicator.test.ts +++ b/packages/dev-server/src/plugins/symbolicate/__tests__/Symbolicator.test.ts @@ -358,6 +358,46 @@ describe('logSymbolicatedStackFrame', () => { expect(info).not.toHaveBeenCalled(); }); + it('includes the remote name for a federated source frame', () => { + const info = vi.fn(); + const runtimeLogger = { info } as unknown as FastifyBaseLogger; + + logSymbolicatedStackFrame( + runtimeLogger, + [ + { + file: 'http://localhost:9007/android/__federation_expose_RegistrationNavigator.registration.chunk.bundle', + lineNumber: 100, + column: 20, + methodName: 'App', + }, + { + file: 'http://localhost:8081/index.bundle?platform=android', + lineNumber: 200, + column: 30, + methodName: 'renderWithHooks', + }, + ], + { + stack: [ + { + file: 'http://localhost:9007/__repack_source__/[projectRoot]/src/App.tsx', + lineNumber: 13, + column: 17, + methodName: 'App', + collapse: false, + }, + ], + codeFrame: null, + } + ); + + expect(info).toHaveBeenCalledWith({ + msg: 'Symbolicated stack frame: registration/src/App.tsx:13:17', + methodName: 'App', + }); + }); + it('does not report a generated bundle frame as symbolicated', () => { const info = vi.fn(); const runtimeLogger = { info } as unknown as FastifyBaseLogger; diff --git a/packages/dev-server/src/plugins/symbolicate/logSymbolicatedStackFrame.ts b/packages/dev-server/src/plugins/symbolicate/logSymbolicatedStackFrame.ts index 79b27ef95..55cffc2f9 100644 --- a/packages/dev-server/src/plugins/symbolicate/logSymbolicatedStackFrame.ts +++ b/packages/dev-server/src/plugins/symbolicate/logSymbolicatedStackFrame.ts @@ -1,5 +1,8 @@ import type { FastifyBaseLogger } from 'fastify'; -import { isGeneratedBundleFrame } from '../../utils/symbolication.js'; +import { + isGeneratedBundleFrame, + isSymbolicatableFrame, +} from '../../utils/symbolication.js'; import type { ReactNativeStackFrame, SymbolicatorResults } from './types.js'; const RUNTIME_ERROR_METHODS = new Set([ @@ -13,7 +16,26 @@ function isRuntimeErrorStack(stack: ReactNativeStackFrame[]) { return stack.some((frame) => RUNTIME_ERROR_METHODS.has(frame.methodName)); } -function getPrintableFile(file: string) { +const REMOTE_SOURCE_PATH_PREFIX = '/__repack_source__/'; + +function getRemoteName(file: string | null | undefined) { + if (!file) { + return undefined; + } + + const filename = new URL(file, 'file://').pathname.split('/').pop() ?? ''; + return filename.match(/\.([^.]+)\.chunk\.bundle$/)?.[1]; +} + +function getPrintableFile(file: string, inputFile?: string | null) { + const sourceUrl = new URL(file, 'file://'); + if (sourceUrl.pathname.startsWith(REMOTE_SOURCE_PATH_PREFIX)) { + const source = decodeURIComponent( + sourceUrl.pathname.slice(REMOTE_SOURCE_PATH_PREFIX.length) + ).replace(/^\[projectRoot(?:\^\d+)?\][\\/]/, ''); + return `${getRemoteName(inputFile) ?? sourceUrl.host}/${source}`; + } + return file.replace(/^\[projectRoot(?:\^\d+)?\][\\/]/, ''); } @@ -26,14 +48,16 @@ export function logSymbolicatedStackFrame( return; } - const frame = results.stack.find( + const frameIndex = results.stack.findIndex( (stackFrame) => !isGeneratedBundleFrame(stackFrame) ); + const frame = results.stack[frameIndex]; if (!frame?.file || frame.lineNumber == null) { return; } - const file = getPrintableFile(frame.file); + const inputFrames = inputStack.filter(isSymbolicatableFrame); + const file = getPrintableFile(frame.file, inputFrames[frameIndex]?.file); logger.info({ msg: `Symbolicated stack frame: ${file}:${frame.lineNumber}:${frame.column ?? 0}`, methodName: frame.methodName,