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/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..96d5a3475 --- /dev/null +++ b/packages/dev-server/src/plugins/symbolicate/__tests__/Symbolicator.test.ts @@ -0,0 +1,254 @@ +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'] +): SymbolicatorDelegate { + return { + getSourceMap, + getSource: vi.fn(async () => { + throw new Error('Source is not available from the host compiler'); + }), + shouldIncludeFrame: () => true, + }; +} + +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, + }); + }); +}); + +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('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)) + ); +} 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.