source-map-resolver.js•10.8 kB
import { SourceMapConsumer } from 'source-map';
/**
* LRU Cache implementation for source maps
*/
class LRUCache {
cache = new Map();
maxSize;
constructor(maxSize) {
this.maxSize = maxSize;
}
get(key) {
const value = this.cache.get(key);
if (value !== undefined) {
// Move to end (most recently used)
this.cache.delete(key);
this.cache.set(key, value);
}
return value;
}
set(key, value) {
// Remove if exists (to update position)
if (this.cache.has(key)) {
this.cache.delete(key);
}
// Add to end
this.cache.set(key, value);
// Evict oldest if over capacity
if (this.cache.size > this.maxSize) {
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) {
this.cache.delete(firstKey);
}
}
}
has(key) {
return this.cache.has(key);
}
clear() {
this.cache.clear();
}
}
/**
* Resolves minified JavaScript locations back to original source files
* using source maps.
*/
export class SourceMapResolver {
sourceMapCache;
page = null;
initialized = false;
sourceMapUrls = new Map(); // JS URL -> source map URL
constructor(cacheSize = 50) {
this.sourceMapCache = new LRUCache(cacheSize);
}
/**
* Initialize the resolver with a Playwright page.
* Sets up request interception to discover and fetch source maps.
*/
async initialize(page) {
if (this.initialized) {
return;
}
this.page = page;
// Intercept responses to discover source map URLs
page.on('response', async (response) => {
try {
const url = response.url();
const contentType = response.headers()['content-type'] || '';
// Only process JavaScript files
if (!contentType.includes('javascript') && !url.endsWith('.js')) {
return;
}
// Check for source map header
const sourceMapHeader = response.headers()['sourcemappingurl'];
if (sourceMapHeader) {
const sourceMapUrl = this.resolveSourceMapUrl(url, sourceMapHeader);
this.sourceMapUrls.set(url, sourceMapUrl);
return;
}
// Check for inline source map comment in the response body
const text = await response.text().catch(() => '');
const inlineMatch = text.match(/\/\/[@#]\s*sourceMappingURL=(.+)/);
if (inlineMatch) {
const sourceMapUrl = this.resolveSourceMapUrl(url, inlineMatch[1].trim());
this.sourceMapUrls.set(url, sourceMapUrl);
}
}
catch (error) {
// Silently ignore errors during source map discovery
// We don't want to break the page load
}
});
this.initialized = true;
}
/**
* Resolve a location in minified code to its original source location.
*
* @param url - The URL of the minified JavaScript file
* @param line - Line number in the minified file (1-indexed)
* @param column - Column number in the minified file (0-indexed)
* @returns Original source location or null if mapping fails
*/
async resolveLocation(url, line, column) {
if (!this.initialized || !this.page) {
throw new Error('SourceMapResolver not initialized. Call initialize() first.');
}
const startTime = Date.now();
try {
// Get or load the source map
const cacheEntry = await this.getOrLoadSourceMap(url);
if (!cacheEntry) {
return null;
}
const { consumer, sourceContent } = cacheEntry;
// Look up the original position
const originalPosition = consumer.originalPositionFor({
line,
column,
});
if (!originalPosition.source) {
return null;
}
// Extract source context (±3 lines)
const content = this.extractSourceContext(sourceContent, originalPosition.source, originalPosition.line || 1);
const result = {
file: originalPosition.source,
line: originalPosition.line || 1,
column: originalPosition.column || 0,
name: originalPosition.name || undefined,
content,
};
// Performance check - warn if slow
const elapsed = Date.now() - startTime;
if (elapsed > 100) {
console.warn(`SourceMapResolver: Slow resolution (${elapsed}ms) for ${url}`);
}
return result;
}
catch (error) {
console.error(`SourceMapResolver: Failed to resolve location in ${url}:`, error);
return null;
}
}
/**
* Get source map from cache or load it
*/
async getOrLoadSourceMap(url) {
// Check cache first
if (this.sourceMapCache.has(url)) {
return this.sourceMapCache.get(url);
}
// Find the source map URL
const sourceMapUrl = this.sourceMapUrls.get(url);
if (!sourceMapUrl) {
return null;
}
try {
// Fetch and parse the source map
const sourceMapData = await this.fetchSourceMap(sourceMapUrl);
if (!sourceMapData) {
return null;
}
const consumer = await new SourceMapConsumer(sourceMapData);
// Extract and process source content
const sourceContent = new Map();
const sources = consumer.sources;
for (const source of sources) {
const content = consumer.sourceContentFor(source, true);
if (content) {
// Split into lines for fast lookup
sourceContent.set(source, content.split('\n'));
}
}
const cacheEntry = {
consumer,
sourceContent,
timestamp: Date.now(),
};
this.sourceMapCache.set(url, cacheEntry);
return cacheEntry;
}
catch (error) {
console.error(`SourceMapResolver: Failed to load source map for ${url}:`, error);
return null;
}
}
/**
* Fetch a source map from a URL
*/
async fetchSourceMap(url) {
if (!this.page) {
return null;
}
try {
// Handle data URLs (inline source maps)
if (url.startsWith('data:')) {
const base64Match = url.match(/base64,(.+)/);
if (base64Match) {
const decoded = Buffer.from(base64Match[1], 'base64').toString('utf-8');
return JSON.parse(decoded);
}
return null;
}
// Fetch via Playwright's context to maintain cookies/headers
const response = await this.page.context().request.get(url);
if (!response.ok()) {
return null;
}
const text = await response.text();
return JSON.parse(text);
}
catch (error) {
console.error(`SourceMapResolver: Failed to fetch source map from ${url}:`, error);
return null;
}
}
/**
* Resolve a source map URL relative to the JavaScript file URL
*/
resolveSourceMapUrl(jsUrl, sourceMapPath) {
// Handle absolute URLs
if (sourceMapPath.startsWith('http://') || sourceMapPath.startsWith('https://')) {
return sourceMapPath;
}
// Handle data URLs
if (sourceMapPath.startsWith('data:')) {
return sourceMapPath;
}
// Handle protocol-relative URLs
if (sourceMapPath.startsWith('//')) {
const protocol = jsUrl.split('://')[0];
return `${protocol}:${sourceMapPath}`;
}
// Handle absolute paths
if (sourceMapPath.startsWith('/')) {
const urlObj = new URL(jsUrl);
return `${urlObj.protocol}//${urlObj.host}${sourceMapPath}`;
}
// Handle relative paths
const jsUrlObj = new URL(jsUrl);
const basePath = jsUrlObj.pathname.substring(0, jsUrlObj.pathname.lastIndexOf('/'));
return `${jsUrlObj.protocol}//${jsUrlObj.host}${basePath}/${sourceMapPath}`;
}
/**
* Extract source code context around a specific line
*/
extractSourceContext(sourceContent, sourcePath, targetLine, contextLines = 3) {
const lines = sourceContent.get(sourcePath);
if (!lines) {
return undefined;
}
const startLine = Math.max(0, targetLine - contextLines - 1);
const endLine = Math.min(lines.length, targetLine + contextLines);
const contextLines_array = lines.slice(startLine, endLine);
return contextLines_array.join('\n');
}
/**
* Clear all cached source maps
*/
clearCache() {
this.sourceMapCache.clear();
this.sourceMapUrls.clear();
}
/**
* Get source file content from any loaded source map
* Searches through all cached source maps to find the file
*/
getSourceContent(filePath) {
// Search through all cached source maps
for (const [, cacheEntry] of this.sourceMapCache.cache) {
const content = cacheEntry.sourceContent.get(filePath);
if (content) {
return content.join('\n');
}
}
return null;
}
/**
* Get all source files from loaded source maps
*/
getAllSourceFiles() {
const files = new Set();
for (const [, cacheEntry] of this.sourceMapCache.cache) {
for (const [filePath] of cacheEntry.sourceContent) {
files.add(filePath);
}
}
return Array.from(files);
}
/**
* Get source map consumer for a specific URL
* Used for advanced source map operations
*/
async getSourceMapConsumer(url) {
const cacheEntry = await this.getOrLoadSourceMap(url);
return cacheEntry ? cacheEntry.consumer : null;
}
/**
* Cleanup resources
*/
async destroy() {
this.clearCache();
this.page = null;
this.initialized = false;
}
}
//# sourceMappingURL=source-map-resolver.js.map