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. 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/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..db258be73 --- /dev/null +++ b/packages/dev-server/src/plugins/symbolicate/__tests__/Symbolicator.test.ts @@ -0,0 +1,431 @@ +import type { FastifyBaseLogger } from 'fastify'; +import { beforeEach, 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; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function createSourceMap(source: string, content: string) { + return JSON.stringify({ + version: 3, + sources: [source], + sourcesContent: [content], + names: [], + mappings: 'AAAA', + }); +} + +function createDelegate( + getSourceMap: SymbolicatorDelegate['getSourceMap'], + getSource: SymbolicatorDelegate['getSource'] = vi.fn(async () => { + throw new Error('Source is not available from the host compiler'); + }) +): SymbolicatorDelegate { + return { + getSourceMap, + 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: [ + { + 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'; + 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, + }); + }); + + 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', () => { + 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', + }, + ], + getMockResults() + ); + + 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', + }, + ], + getMockResults() + ); + + 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; + + 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..55cffc2f9 --- /dev/null +++ b/packages/dev-server/src/plugins/symbolicate/logSymbolicatedStackFrame.ts @@ -0,0 +1,65 @@ +import type { FastifyBaseLogger } from 'fastify'; +import { + isGeneratedBundleFrame, + isSymbolicatableFrame, +} 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)); +} + +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+)?\][\\/]/, ''); +} + +export function logSymbolicatedStackFrame( + logger: FastifyBaseLogger, + inputStack: ReactNativeStackFrame[], + results: SymbolicatorResults +) { + if (!isRuntimeErrorStack(inputStack)) { + return; + } + + const frameIndex = results.stack.findIndex( + (stackFrame) => !isGeneratedBundleFrame(stackFrame) + ); + const frame = results.stack[frameIndex]; + if (!frame?.file || frame.lineNumber == null) { + return; + } + + 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, + }); +} 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/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/dev-server/src/utils/symbolication.ts b/packages/dev-server/src/utils/symbolication.ts new file mode 100644 index 000000000..0fce9fc2b --- /dev/null +++ b/packages/dev-server/src/utils/symbolication.ts @@ -0,0 +1,81 @@ +import { URL } from 'node:url'; +import type { RawIndexMap, RawSourceMap } from 'source-map'; + +interface StackFrameLike { + file: string | null; +} + +export function normalizeInvalidWebpackSourceUrls( + rawSourceMap: string | Buffer +): string | RawSourceMap | RawIndexMap { + 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); + // 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) { + 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)) + ); +} 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..2dd9ba71d --- /dev/null +++ b/packages/repack/src/commands/common/__tests__/fetchSourceMapFromBundle.test.ts @@ -0,0 +1,185 @@ +import { + fetchSourceMapFromBundle, + getRemoteSource, + openRemoteStackFrame, + 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 }, + }); + + 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({ + [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); + }); +}); + +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 new file mode 100644 index 000000000..cc71a1720 --- /dev/null +++ b/packages/repack/src/commands/common/fetchSourceMapFromBundle.ts @@ -0,0 +1,202 @@ +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; + 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 prepareSourceMap(buffer: Buffer, bundleUrl: URL): Buffer | undefined { + try { + 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); + } + } + }; + + 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 { + 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 ? prepareSourceMap(sourceMap, bundleUrl) : 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..4ba481c23 100644 --- a/packages/repack/src/commands/rspack/start.ts +++ b/packages/repack/src/commands/rspack/start.ts @@ -11,9 +11,11 @@ import { } from '../../logging/index.js'; import { makeCompilerConfig } from '../common/config/makeCompilerConfig.js'; import { + fetchSourceMapFromBundle, getDevMiddleware, getMaxWorkers, getMimeType, + openRemoteStackFrame, parseUrl, resetPersistentCache, resolveProjectPath, @@ -163,6 +165,7 @@ export async function start( resolveProjectPath: (filepath) => { return resolveProjectPath(filepath, cliConfig.root); }, + openStackFrame: openRemoteStackFrame, }, symbolicator: { getSource: (url) => { @@ -170,9 +173,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..d3aae80cf 100644 --- a/packages/repack/src/commands/webpack/start.ts +++ b/packages/repack/src/commands/webpack/start.ts @@ -13,8 +13,10 @@ import { import type { HMRMessage } from '../../types.js'; import { makeCompilerConfig } from '../common/config/makeCompilerConfig.js'; import { + fetchSourceMapFromBundle, getDevMiddleware, getMimeType, + openRemoteStackFrame, parseUrl, resetPersistentCache, resolveProjectPath, @@ -197,6 +199,7 @@ export async function start( resolveProjectPath: (filepath) => { return resolveProjectPath(filepath, cliConfig.root); }, + openStackFrame: openRemoteStackFrame, }, symbolicator: { getSource: (url) => { @@ -204,9 +207,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.