Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/actions/receive-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ import {
import { doSourceMapSymbolication } from './source-map-symbolication';
import type { SourceMapSymbolicationResult } from './source-map-symbolication';

import type { RawSourceMap } from 'source-map';
import type { SourceMap } from 'firefox-profiler/profile-logic/source-maps/store';
import type {
RequestedLib,
ImplementationFilter,
Expand Down Expand Up @@ -849,7 +849,7 @@ async function doResolveSourceMaps(
browserConnection: BrowserConnection,
dispatch: Dispatch
): Promise<{
resolvedSourceMaps: Map<IndexIntoSourceTable, RawSourceMap>;
resolvedSourceMaps: Map<IndexIntoSourceTable, SourceMap>;
compiledSources: Map<IndexIntoSourceTable, string>;
}> {
const { sources, stringArray } = profile.shared;
Expand All @@ -872,7 +872,7 @@ async function doResolveSourceMaps(
}

// Fetch source maps and compiled sources in parallel, ignoring individual failures.
const resolvedSourceMaps: Map<IndexIntoSourceTable, RawSourceMap> = new Map();
const resolvedSourceMaps: Map<IndexIntoSourceTable, SourceMap> = new Map();
const compiledSources: Map<IndexIntoSourceTable, string> = new Map();

dispatch({ type: 'START_SOURCE_MAP_FETCHING' });
Expand Down
4 changes: 2 additions & 2 deletions src/actions/source-map-symbolication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type {
EligibleSource,
SourceMapAmbiguityReason,
} from 'firefox-profiler/profile-logic/source-maps/matching';
import type { RawSourceMap } from 'source-map';
import type { SourceMap } from 'firefox-profiler/profile-logic/source-maps/store';
import { assertExhaustiveCheck } from 'firefox-profiler/utils/types';

/**
Expand All @@ -40,7 +40,7 @@ import { assertExhaustiveCheck } from 'firefox-profiler/utils/types';
export type SourceMapSymbolicationResult = 'applied' | 'no-match' | 'error';

export function doSourceMapSymbolication(
resolvedSourceMaps: Map<IndexIntoSourceTable, RawSourceMap>,
resolvedSourceMaps: Map<IndexIntoSourceTable, SourceMap>,
compiledSources: Map<IndexIntoSourceTable, string>,
run: SourceMapRunner = _runSourceMapWorker
): ThunkAction<Promise<SourceMapSymbolicationResult>> {
Expand Down
6 changes: 3 additions & 3 deletions src/app-logic/browser-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
getJSSourcesViaWebChannelV7,
getSourceMapViaWebChannel,
} from './web-channel';
import type { RawSourceMap } from 'source-map';
import type { SourceMap } from 'firefox-profiler/profile-logic/source-maps/store';
import type {
Milliseconds,
FaviconData,
Expand Down Expand Up @@ -92,7 +92,7 @@ export interface BrowserConnection {

// Get source map of the given source directly from the browser.
// Requires WebChannel version 7+.
getSourceMap(sourceId: string): Promise<RawSourceMap>;
getSourceMap(sourceId: string): Promise<SourceMap>;

// True when the browser exposes GET_SOURCE_MAP (WebChannel version 7+).
// Callers use this to gate source-map-based features.
Expand Down Expand Up @@ -247,7 +247,7 @@ class BrowserConnectionImpl implements BrowserConnection {
return [];
}

async getSourceMap(sourceId: string): Promise<RawSourceMap> {
async getSourceMap(sourceId: string): Promise<SourceMap> {
if (!this.supportsGetSourceMap) {
throw new Error(
"Can't use getSourceMap in Firefox versions with the old WebChannel."
Expand Down
4 changes: 2 additions & 2 deletions src/app-logic/web-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
ExternalMarkersData,
FaviconData,
} from 'firefox-profiler/types';
import type { RawSourceMap } from 'source-map';
import type { SourceMap } from 'firefox-profiler/profile-logic/source-maps/store';

/**
* This file is in charge of handling the message managing between profiler.firefox.com
Expand Down Expand Up @@ -162,7 +162,7 @@ type GetPageFaviconsResponse = Array<FaviconData | null>;
type OpenScriptInTabDebuggerResponse = void;
type GetJSSourceReponseItem = { sourceText: string } | { error: string };
type GetJSSourcesResponse = Array<GetJSSourceReponseItem>;
type GetSourceMapResponse = RawSourceMap;
type GetSourceMapResponse = SourceMap;

// TypeScript function overloads for request/response pairs.
function _sendMessageWithResponse(
Expand Down
6 changes: 3 additions & 3 deletions src/profile-logic/embed-js-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { parseSourceMapFileContents } from 'firefox-profiler/profile-logic/sourc
import { base64StringToBytes } from 'firefox-profiler/utils/base64';
import { StringTable } from 'firefox-profiler/utils/string-table';

import type { RawSourceMap } from 'source-map';
import type { SourceMap } from 'firefox-profiler/profile-logic/source-maps/store';
import type { SourceMapRunner } from 'firefox-profiler/profile-logic/source-maps/worker-types';
import type { IndexIntoSourceTable, Profile } from 'firefox-profiler/types';

Expand Down Expand Up @@ -70,7 +70,7 @@ function _resolveSourceMapURL(
async function _readSourceMap(
resolvedSourceMapURL: string,
readSource: SourceReader
): Promise<RawSourceMap | null> {
): Promise<SourceMap | null> {
const isInline = resolvedSourceMapURL.startsWith('data:');
const sourceMapText = isInline
? _decodeDataUrl(resolvedSourceMapURL)
Expand Down Expand Up @@ -119,7 +119,7 @@ export async function embedJsSources(
const stringTable = StringTable.withBackingArray(stringArray);
let embeddedCount = 0;
const compiledSources = new Map<IndexIntoSourceTable, string>();
const resolvedSourceMaps = new Map<IndexIntoSourceTable, RawSourceMap>();
const resolvedSourceMaps = new Map<IndexIntoSourceTable, SourceMap>();
// Gecko keys source rows by their ScriptSource id, so a script that's loaded
// more than once can have several rows with the same URL.
const readSourceOnce = _cacheByKey(readSource);
Expand Down
88 changes: 56 additions & 32 deletions src/profile-logic/source-maps/matching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import type { IndexIntoSourceTable, SourceTable } from 'firefox-profiler/types';
import type { RawSourceMap } from 'source-map';
import type { SourceMap } from './store';

/**
* A source row that already carries a sourceMapURL, and is therefore eligible
Expand Down Expand Up @@ -60,45 +60,69 @@ export function getSourcesWithSourceMapURL(
return eligible;
}

/**
* Parse the text contents of a `.map` file into a RawSourceMap. Returns null
* for invalid JSON, for JSON that isn't a source map (missing version /
* mappings / sources), and for index maps.
*
* An index map replaces the top-level `mappings` string with a `sections`
* array, each section pairing a generated-code offset with an embedded source
* map (ECMA-426 section 10: https://tc39.es/ecma426/#sec-index-source-map).
* We skip them because no mainstream web bundler emits one, and we currently
* don't support it.
*/
export function parseSourceMapFileContents(text: string): RawSourceMap | null {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return null;
function _isPosition(value: unknown): boolean {
if (value === null || typeof value !== 'object') {
return false;
}
const position = value as Record<string, unknown>;
return (
typeof position.line === 'number' && typeof position.column === 'number'
);
}

if (parsed === null || typeof parsed !== 'object') {
return null;
function _isRegularSourceMap(value: unknown): boolean {
if (value === null || typeof value !== 'object') {
return false;
}
const map = value as Record<string, unknown>;
return (
typeof map.version === 'number' &&
typeof map.mappings === 'string' &&
Array.isArray(map.sources)
);
}

const map = parsed as Record<string, unknown>;
// An index map replaces the top-level `mappings` string with a `sections`
// array, each section pairing a generated-code offset with an embedded source
// map (ECMA-426 section 10: https://tc39.es/ecma426/#sec-index-source-map).
// The embedded maps must be regular source maps: the spec decodes them with
// DecodeSourceMap, so index maps can't be nested. Sections that reference
// their map by `url` instead are not supported by the source-map library.
function _isIndexSourceMap(map: Record<string, unknown>): boolean {
return (
typeof map.version === 'number' &&
Array.isArray(map.sections) &&
map.sections.every(
(section) =>
section !== null &&
typeof section === 'object' &&
_isPosition(section.offset) &&
_isRegularSourceMap(section.map)
)
);
}

// Index maps use `sections` instead of top-level `mappings`. Reject them.
if ('sections' in map) {
return null;
function _isSourceMap(value: unknown): value is SourceMap {
if (value === null || typeof value !== 'object') {
return false;
}
const map = value as Record<string, unknown>;
return 'sections' in map ? _isIndexSourceMap(map) : _isRegularSourceMap(map);
}

if (
typeof map.version !== 'number' ||
typeof map.mappings !== 'string' ||
!Array.isArray(map.sources)
) {
/**
* Parse the text contents of a `.map` file into a source map. Returns null
* for invalid JSON, and for JSON that isn't a source map (missing version /
* mappings / sources, or malformed index map sections).
*/
export function parseSourceMapFileContents(text: string): SourceMap | null {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return null;
}

return parsed as RawSourceMap;
return _isSourceMap(parsed) ? parsed : null;
}

/**
Expand Down Expand Up @@ -158,7 +182,7 @@ function matchByBasename(
* one, which we match unconditionally regardless of name.
*/
export function matchSourceMapToSource(
map: RawSourceMap,
map: SourceMap,
uploadedFileName: string,
eligible: EligibleSource[]
): SourceMapMatchResult {
Expand Down
9 changes: 7 additions & 2 deletions src/profile-logic/source-maps/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { SourceMapConsumer as LibSourceMapConsumer } from 'source-map';
import type { RawSourceMap } from 'source-map';
import type { RawIndexMap, RawSourceMap } from 'source-map';
import type { IndexIntoSourceTable } from 'firefox-profiler/types';

export type SourceMapConsumer = LibSourceMapConsumer;

/**
* A parsed source map, either a regular one or an index map made of sections.
*/
export type SourceMap = RawSourceMap | RawIndexMap;

/**
* Holds pre-parsed SourceMapConsumer instances keyed by IndexIntoSourceTable.
* Initialized from the resolvedSourceMaps already fetched at profile load time.
Expand All @@ -20,7 +25,7 @@ export class SourceMapStore {
}

static async create(
resolvedSourceMaps: Map<IndexIntoSourceTable, RawSourceMap>,
resolvedSourceMaps: Map<IndexIntoSourceTable, SourceMap>,
wasmUrl: string
): Promise<SourceMapStore> {
const consumers = new Map<IndexIntoSourceTable, SourceMapConsumer>();
Expand Down
4 changes: 2 additions & 2 deletions src/profile-logic/source-maps/worker-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ import type {
IndexIntoFrameTable,
IndexIntoSourceTable,
} from 'firefox-profiler/types';
import type { RawSourceMap } from 'source-map';
import type { SourceMap } from './store';
import type { SourceMapSymbolicationInput } from './symbolication';

/**
* Data sent from the main thread to the source map symbolication worker.
* All values are structured-cloned (copied) across the thread boundary.
*/
export type WorkerInput = SourceMapSymbolicationInput & {
resolvedSourceMaps: Map<IndexIntoSourceTable, RawSourceMap>;
resolvedSourceMaps: Map<IndexIntoSourceTable, SourceMap>;
// Compiled source texts keyed by bundle source index.
compiledSources: Map<IndexIntoSourceTable, string>;
};
Expand Down
4 changes: 2 additions & 2 deletions src/profile-query/function-annotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
import { fetchAssembly } from 'firefox-profiler/utils/fetch-assembly';
import { fetchSource } from 'firefox-profiler/utils/fetch-source';
import type { ExternalCommunicationDelegate } from 'firefox-profiler/utils/query-api';
import type { RawSourceMap } from 'source-map';
import type { SourceMap } from 'firefox-profiler/profile-logic/source-maps/store';
import type {
Profile,
IndexIntoFuncTable,
Expand Down Expand Up @@ -64,7 +64,7 @@ class NodeExternalCommunicationDelegate implements ExternalCommunicationDelegate
throw new Error('No browser connection available in profiler-cli');
}

async fetchSourceMapFromBrowser(_sourceId: string): Promise<RawSourceMap> {
async fetchSourceMapFromBrowser(_sourceId: string): Promise<SourceMap> {
throw new Error('No browser connection available in profiler-cli');
}
}
Expand Down
37 changes: 37 additions & 0 deletions src/test/unit/embed-js-sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,43 @@ describe('embedJsSources', function () {
});
});

it('applies an index source map', async function () {
// The bundle is a concatenation of two chunks, and the function is in the
// second one, on the bundle's second line.
const firstChunk = 'var c=1;';
const firstChunkMap = new SourceMapGenerator({ file: 'first.js' });
firstChunkMap.addMapping({
source: '../src/first.js',
original: { line: 1, column: 0 },
generated: { line: 1, column: 0 },
});
const indexMap = JSON.stringify({
version: 3,
file: 'bundle.js',
sections: [
{ offset: { line: 0, column: 0 }, map: firstChunkMap.toJSON() },
{ offset: { line: 1, column: 0 }, map: JSON.parse(buildSourceMap()) },
],
});
const profile = makeProfile(BUNDLE_URL, { sourceMapURL: 'bundle.js.map' });
profile.shared.funcTable.lineNumber[0] = 2;
profile.shared.frameTable.line[0] = 2;
await embedJsSources(
profile,
makeReader({
[BUNDLE_URL]: firstChunk + '\n' + BUNDLE_SOURCE,
[BUNDLE_URL + '.map']: indexMap,
}),
runSourceMapSymbolicationNode
);
expect(getOriginalLocationOfFunc(profile)).toEqual({
name: 'greet',
filename: ORIGINAL_FILENAME,
line: 1,
content: ORIGINAL_SOURCE,
});
});

it('falls back to the sourceMappingURL comment', async function () {
const profile = makeProfile(BUNDLE_URL);
const bundleWithComment =
Expand Down
32 changes: 29 additions & 3 deletions src/test/unit/source-map-matching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,35 @@ describe('parseSourceMapFileContents', function () {
expect(parseSourceMapFileContents('null')).toBeNull();
});

it('rejects index maps (with a sections field)', function () {
const indexMap = JSON.stringify({ version: 3, sections: [] });
expect(parseSourceMapFileContents(indexMap)).toBeNull();
it('parses an index map', function () {
const indexMap = {
version: 3,
file: 'bundle.js',
sections: [
{ offset: { line: 0, column: 0 }, map: makeMap() },
{ offset: { line: 10, column: 0 }, map: makeMap() },
],
};
expect(parseSourceMapFileContents(JSON.stringify(indexMap))).toEqual(
indexMap
);
});

it('returns null for malformed index maps', function () {
const section = { offset: { line: 0, column: 0 }, map: makeMap() };
for (const indexMap of [
{ version: 3, sections: 'not an array' },
{ version: 3, sections: [{ ...section, offset: { line: 0 } }] },
{ version: 3, sections: [{ ...section, map: { version: 3 } }] },
{ version: 3, sections: [{ offset: section.offset, url: 'a.map' }] },
// Nested index maps are not allowed.
{
version: 3,
sections: [{ ...section, map: { version: 3, sections: [section] } }],
},
]) {
expect(parseSourceMapFileContents(JSON.stringify(indexMap))).toBeNull();
}
});
});

Expand Down
Loading