diff --git a/packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts b/packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts index 7ffe06063..c23c748da 100644 --- a/packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts +++ b/packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts @@ -1,9 +1,19 @@ +import childProcess from 'node:child_process'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; import type { FastifyInstance } from 'fastify'; import fastifyPlugin from 'fastify-plugin'; import launchEditor from 'launch-editor'; import open from 'open'; import type { Server } from '../../types.js'; +const require = createRequire(import.meta.url); +const macosEditors = require('launch-editor/editor-info/macos.js') as Record< + string, + string +>; + interface OpenURLRequestBody { url: string; } @@ -19,10 +29,195 @@ function parseRequestBody(body: unknown): T { throw new Error(`Unsupported body type: ${typeof body}`); } +let cachedEditorCommand: string | undefined; + +const commonEditorBinDirs = [ + '/usr/local/bin', + '/opt/homebrew/bin', + process.env.HOME + ? path.join( + process.env.HOME, + 'Library/Application Support/JetBrains/Toolbox/scripts' + ) + : undefined, +].filter(Boolean) as string[]; + +function isPathInside(filepath: string, root: string): boolean { + const relativePath = path.relative(root, filepath); + return ( + relativePath === '' || + (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) + ); +} + +function getProjectRelativeFile(file: string): string | undefined { + if (!file.startsWith('[projectRoot]/')) { + return undefined; + } + + const relativeFile = path.normalize(file.slice('[projectRoot]/'.length)); + if ( + relativeFile === '.' || + path.isAbsolute(relativeFile) || + relativeFile.split(/[\\/]+/).includes('..') + ) { + return undefined; + } + + return relativeFile; +} + +function resolveStackFrameFile( + file: string, + delegate: Server.Delegate +): string { + const filepath = delegate.devTools?.resolveProjectPath(file) ?? file; + + if (!delegate.devTools?.resolveProjectPath) { + return filepath; + } + + const relativeFile = getProjectRelativeFile(file); + if (!relativeFile) { + return filepath; + } + + const shellRoot = delegate.devTools.resolveProjectPath('[projectRoot]'); + const projectFile = path.join(shellRoot, relativeFile); + if (isPathInside(projectFile, shellRoot) && fs.existsSync(projectFile)) { + return projectFile; + } + + return filepath; +} + +function findCommand(command: string | undefined): string | undefined { + if (!command) { + return undefined; + } + + if (path.isAbsolute(command)) { + return fs.existsSync(command) ? command : undefined; + } + + const result = childProcess.spawnSync( + 'sh', + ['-lc', 'command -v "$1"', 'sh', command], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 500, + } + ); + const commandPath = result.stdout?.trim().split('\n')[0]; + if (commandPath) { + return commandPath; + } + + for (const binDir of commonEditorBinDirs) { + const candidate = path.join(binDir, command); + if (fs.existsSync(candidate)) { + return candidate; + } + } +} + +function resolveLaunchCommand(launchCommand: string): string | undefined { + if (path.isAbsolute(launchCommand) && fs.existsSync(launchCommand)) { + return launchCommand; + } + + const cliCommand = findCommand(path.basename(launchCommand)); + if (cliCommand) { + return cliCommand; + } + + return findCommand(launchCommand); +} + +function quoteEditorCommand(command: string): string { + return command.includes(' ') ? JSON.stringify(command) : command; +} + +function getEditorCommand(editor: string | undefined): string | undefined { + if (editor || process.platform !== 'darwin') { + return editor; + } + + if (cachedEditorCommand !== undefined) { + return cachedEditorCommand; + } + + try { + const output = childProcess + .execSync('ps x -o comm=', { + stdio: ['pipe', 'pipe', 'ignore'], + timeout: 500, + }) + .toString(); + const processList = output.split('\n'); + + for (const [processName, launchCommand] of Object.entries(macosEditors)) { + const processNameWithoutApplications = processName.replace( + '/Applications', + '' + ); + const isRunning = + processList.includes(processName) || + output.includes(processNameWithoutApplications); + if (!isRunning) { + continue; + } + + const resolvedLaunchCommand = resolveLaunchCommand(launchCommand); + if (resolvedLaunchCommand) { + cachedEditorCommand = quoteEditorCommand(resolvedLaunchCommand); + return cachedEditorCommand; + } + + if (fs.existsSync(processName)) { + cachedEditorCommand = quoteEditorCommand(processName); + return cachedEditorCommand; + } + + const runningProcess = processList.find((procName) => + procName.endsWith(processNameWithoutApplications) + ); + if (runningProcess && fs.existsSync(runningProcess)) { + cachedEditorCommand = quoteEditorCommand(runningProcess); + return cachedEditorCommand; + } + + break; + } + } catch { + // Fall back to launch-editor's default editor detection. + } + + cachedEditorCommand = editor; + return cachedEditorCommand; +} + +function prewarmEditorCommand() { + if (process.env.REACT_EDITOR || process.platform !== 'darwin') { + return; + } + + setTimeout(() => { + try { + getEditorCommand(process.env.REACT_EDITOR); + } catch { + // Keep startup resilient; the next stack-frame tap can retry detection. + } + }, 0); +} + async function devtoolsPlugin( instance: FastifyInstance, { delegate }: { delegate: Server.Delegate } ) { + prewarmEditorCommand(); + // reference implementation in `@react-native-community/cli-server-api`: // https://github.com/react-native-community/cli/blob/46436a12478464752999d34ed86adf3212348007/packages/cli-server-api/src/openURLMiddleware.ts instance.route({ @@ -44,8 +239,11 @@ async function devtoolsPlugin( const { file, lineNumber } = parseRequestBody( request.body ); - const filepath = delegate.devTools?.resolveProjectPath(file) ?? file; - launchEditor(`${filepath}:${lineNumber}`, process.env.REACT_EDITOR); + const filepath = resolveStackFrameFile(file, delegate); + launchEditor( + `${filepath}:${lineNumber}`, + getEditorCommand(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..bc9483660 100644 --- a/packages/dev-server/src/plugins/symbolicate/Symbolicator.ts +++ b/packages/dev-server/src/plugins/symbolicate/Symbolicator.ts @@ -11,6 +11,24 @@ import type { SymbolicatorResults, } from './types.js'; +const REACT_RUNTIME_METHOD_NAMES = new Set([ + 'react-stack-bottom-frame', + 'renderWithHooks', + 'updateFunctionComponent', + 'beginWork', + 'runWithFiberInDEV', + 'performUnitOfWork', + 'workLoopSync', + 'renderRootSync', + 'performWorkOnRoot', + 'performSyncWorkOnRoot', + 'flushSyncWorkAcrossRoots_impl', + 'scheduleUpdateOnFiber', + 'dispatchSetState', + 'reportException', + 'handleException', +]); + /** * Class for transforming stack traces from React Native application with using Source Map. * Raw stack frames produced by React Native, points to some location from the bundle @@ -78,7 +96,12 @@ export class Symbolicator { const frames: InputStackFrame[] = []; for (const frame of stack) { const { file } = frame; - if (file?.startsWith('http')) { + if ( + file && + (file.startsWith('http') || + file.includes('.bundle') || + file.includes('.hot-update.js')) + ) { frames.push(frame as InputStackFrame); } } @@ -88,41 +111,61 @@ export class Symbolicator { const processedFrames: StackFrame[] = []; for (const frame of frames) { - if (!this.sourceMapConsumerCache[frame.file]) { - logger.debug({ - msg: 'Loading raw source map data', - fileUrl: frame.file, + if (this.shouldSkipSourceMap(frame)) { + processedFrames.push({ + ...frame, + collapse: true, }); + continue; + } + + try { + if (!this.sourceMapConsumerCache[frame.file]) { + logger.debug({ + msg: 'Loading raw source map data', + fileUrl: frame.file, + }); + + const rawSourceMap = await this.delegate.getSourceMap(frame.file); - const 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( + rawSourceMap.toString() + ); + + logger.debug({ + msg: 'Saving source map instance into cache', + fileUrl: frame.file, + }); + this.sourceMapConsumerCache[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); logger.debug({ - msg: 'Saving source map instance into cache', + msg: 'Finished symbolicating frame', + frame, + }); + processedFrames.push(processedFrame); + } catch (error) { + logger.debug({ + msg: 'Failed to symbolicate frame', fileUrl: frame.file, + error: (error as Error).message, + }); + processedFrames.push({ + ...frame, + collapse: false, }); - this.sourceMapConsumerCache[frame.file] = sourceMapConsumer; } - - logger.debug({ - msg: 'Symbolicating frame', - frame, - }); - const processedFrame = this.processFrame(frame); - - logger.debug({ - msg: 'Finished symbolicating frame', - frame, - }); - processedFrames.push(processedFrame); } const codeFrame = @@ -146,6 +189,10 @@ export class Symbolicator { } } + private shouldSkipSourceMap(frame: InputStackFrame): boolean { + return REACT_RUNTIME_METHOD_NAMES.has(frame.methodName ?? ''); + } + private processFrame(frame: InputStackFrame): StackFrame { if (!frame.lineNumber || !frame.column) { return { @@ -194,6 +241,19 @@ export class Symbolicator { }; } + private getSourceContent(frame: StackFrame): string | undefined { + for (const consumer of Object.values(this.sourceMapConsumerCache)) { + try { + const source = consumer.sourceContentFor(frame.file, true); + if (source) { + return source; + } + } catch { + // Try the next source map. + } + } + } + private async getCodeFrame( logger: FastifyBaseLogger, processedFrames: StackFrame[] @@ -213,9 +273,13 @@ export class Symbolicator { }); try { + const source = + this.getSourceContent(frame) ?? + (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/sybmolicatePlugin.ts b/packages/dev-server/src/plugins/symbolicate/sybmolicatePlugin.ts index 089219db5..0781813fb 100644 --- a/packages/dev-server/src/plugins/symbolicate/sybmolicatePlugin.ts +++ b/packages/dev-server/src/plugins/symbolicate/sybmolicatePlugin.ts @@ -22,6 +22,28 @@ function getStackFromRequestBody(request: FastifyRequest) { return body.stack; } +function getFirstUsefulFrame(stack: ReactNativeStackFrame[]) { + return ( + stack.find( + (frame) => + frame.file && + !frame.file.includes('.bundle') && + !frame.file.includes('.hot-update.js') + ) ?? stack[0] + ); +} + +function isRuntimeErrorStack(stack: ReactNativeStackFrame[]) { + return stack.some((frame) => + [ + 'react-stack-bottom-frame', + 'renderWithHooks', + 'beginWork', + 'performUnitOfWork', + ].includes(frame.methodName ?? '') + ); +} + async function symbolicatePlugin( instance: FastifyInstance, { @@ -42,6 +64,15 @@ async function symbolicatePlugin( } else { request.log.debug({ msg: 'Starting symbolication', platform, stack }); const results = await symbolicator.process(request.log, stack); + const frame = getFirstUsefulFrame(results.stack); + if (isRuntimeErrorStack(stack) && frame?.file && frame.lineNumber) { + const column = frame.column ?? 0; + + request.log.info({ + msg: `Symbolicated stack frame: ${frame.file}:${frame.lineNumber}:${column}`, + methodName: frame.methodName, + }); + } reply.send(results); } } catch (error) { diff --git a/packages/repack/src/commands/rspack/start.ts b/packages/repack/src/commands/rspack/start.ts index dc915ab4f..f4eae4c9b 100644 --- a/packages/repack/src/commands/rspack/start.ts +++ b/packages/repack/src/commands/rspack/start.ts @@ -26,6 +26,146 @@ import logo from '../common/logo.js'; import type { CliConfig, StartArguments } from '../types.js'; import { Compiler } from './Compiler.js'; +function isDigits(value: string): boolean { + for (const character of value) { + if (character < '0' || character > '9') { + return false; + } + } + + return value.length > 0; +} + +function isHostPortPath(value: string): boolean { + const pathStart = value.indexOf('/'); + if (pathStart === -1) { + return false; + } + + const authority = value.slice(0, pathStart); + const portStart = authority.lastIndexOf(':'); + if (portStart <= 0) { + return false; + } + + const hostname = authority.slice(0, portStart); + const port = authority.slice(portStart + 1); + return ( + !hostname.includes(':') && + (hostname === 'localhost' || hostname.includes('.')) && + isDigits(port) + ); +} + +function getFetchableSourceMapUrl(url: string): URL | undefined { + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + if (isHostPortPath(url)) { + parsedUrl = new URL(`http://${url}`); + } else if (url.startsWith('//')) { + parsedUrl = new URL(`http:${url}`); + } else { + return undefined; + } + } + + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + return undefined; + } + + if (!parsedUrl.pathname.endsWith('.map')) { + parsedUrl.pathname = `${parsedUrl.pathname}.map`; + } + + return parsedUrl; +} + +function getRemoteNameFromBundleUrl(url: string): string | undefined { + const filename = url.split(/[?#]/)[0].split('/').pop() ?? ''; + + return ( + filename.match(/\.([^.]+)\.chunk\.bundle(?:\.map)?$/)?.[1] ?? + filename.match(/^([^/.]+)\.container\.js\.bundle(?:\.map)?$/)?.[1] + ); +} + +function getSourceMapUrlsFromRemoteConfig( + url: string, + configs: Configuration[] +): URL[] { + const remoteName = getRemoteNameFromBundleUrl(url); + const filename = url.split(/[?#]/)[0].split('/').pop(); + if (!filename) { + return []; + } + + const sourceMapFilename = filename.endsWith('.map') + ? filename + : `${filename}.map`; + + return configs + .flatMap((config) => config.plugins ?? []) + .flatMap((plugin) => { + const remotes = + (plugin as { config?: { remotes?: Record } })?.config + ?.remotes ?? {}; + const remoteSpecs = remoteName + ? [remotes[remoteName]] + : Object.values(remotes); + const remoteValues = remoteSpecs.flatMap((remoteSpec) => + Array.isArray(remoteSpec) ? remoteSpec : [remoteSpec] + ); + + return remoteValues.flatMap((value) => { + if (typeof value !== 'string') { + return []; + } + + const remoteUrl = getFetchableSourceMapUrl( + value.includes('@') ? value.slice(value.indexOf('@') + 1) : value + ); + if (!remoteUrl) { + return []; + } + + remoteUrl.pathname = `${remoteUrl.pathname.replace( + /\/[^/]*$/, + '' + )}/${sourceMapFilename}`; + return [remoteUrl]; + }); + }); +} + +async function fetchSourceMapFromBundleServer( + url: string, + configs: Configuration[] +): Promise { + const sourceMapUrls = [ + getFetchableSourceMapUrl(url), + ...getSourceMapUrlsFromRemoteConfig(url, configs), + ].filter(Boolean) as URL[]; + + const results = await Promise.allSettled( + sourceMapUrls.map(async (sourceMapUrl) => { + const response = await fetch(sourceMapUrl, { + signal: AbortSignal.timeout(2000), + }); + if (response.ok) { + return Buffer.from(await response.arrayBuffer()); + } + }) + ); + + for (const result of results) { + if (result.status === 'fulfilled' && result.value) { + return result.value; + } + } +} + /** * Start command that runs a development server. * It runs `@callstack/repack-dev-server` to provide Development Server functionality @@ -172,7 +312,23 @@ export async function start( }, getSourceMap: (url) => { const { resourcePath, platform } = parseUrl(url, platforms); - return compiler.getSourceMap(resourcePath, platform); + return compiler + .getSourceMap(resourcePath, platform) + .catch(async (error) => { + try { + const sourceMap = await fetchSourceMapFromBundleServer( + url, + configs + ); + if (sourceMap) { + return sourceMap; + } + } catch { + // Preserve the original compiler error below. + } + + throw error; + }); }, shouldIncludeFrame: (frame) => { // If the frame points to internal bootstrap/module system logic, skip the code frame.