diff --git a/index.d.ts b/index.d.ts index 2a2b391..6bb4573 100644 --- a/index.d.ts +++ b/index.d.ts @@ -19,6 +19,17 @@ export interface TransformOutput { map: string | undefined; } +/** + * Options for a transformation operation + */ +export interface TransformOptions { + /** + * Path of the file being transformed, used as the source name in the + * generated sourcemap. Defaults to `/`. + */ + sourceFile?: string; +} + /** * The kind of function */ @@ -172,5 +183,10 @@ export class Transformer { * # Errors * Returns an error if the transformation fails to find injection points. */ - transform(code: string | Buffer, moduleType: ModuleType, sourcemap?: string | object | null): TransformOutput; + transform( + code: string | Buffer, + moduleType: ModuleType, + sourcemap?: string | object | null, + options?: TransformOptions + ): TransformOutput; } diff --git a/lib/transformer.js b/lib/transformer.js index 6efcfde..d4ea09b 100644 --- a/lib/transformer.js +++ b/lib/transformer.js @@ -66,11 +66,16 @@ class Transformer { * @param {string|Buffer} code - Original JavaScript source, or a Buffer containing UTF-8 source. * @param {'esm'|'cjs'|'unknown'} moduleType - Whether the source is an ES module or CommonJS. * @param {string|object|null} [sourcemap] - Existing source map (raw string or object) to chain from. + * @param {object} [options] + * @param {string} [options.sourceFile] - Path of the file being transformed, used as the source + * name in the generated source map. Defaults to `/`. * @returns {{ code: string, map?: string }} * The transformed source and an optional updated source map. * @throws {Error} If no injection points are found for any config. */ - transform (code, moduleType, sourcemap) { + transform (code, moduleType, sourcemap, options) { + const sourceFile = options?.sourceFile || `${this.#moduleName}/${this.#filePath}` + if (Buffer.isBuffer(code)) code = code.toString() if (!code) return { code } @@ -146,20 +151,18 @@ class Transformer { SourceMapConsumer ??= require('source-map').SourceMapConsumer SourceMapGenerator ??= require('source-map').SourceMapGenerator - const file = `${this.#moduleName}/${this.#filePath}` - let generator + const generator = new SourceMapGenerator({ file: sourceFile }) + // Embed the source so the map works without the file on disk. + generator.setSourceContent(sourceFile, code) + + const generatedCode = generate(ast, { sourceMap: generator }) if (sourcemap) { - const consumer = new SourceMapConsumer(sourcemap) - consumer.file = file - generator = SourceMapGenerator.fromSourceMap(consumer) - } else { - generator = new SourceMapGenerator({ file }) + // Chain through the input map so positions resolve to the original source. + generator.applySourceMap(new SourceMapConsumer(sourcemap), sourceFile) } - - const code = generate(ast, { sourceMap: generator }) const map = generator.toString() - return { code, map } + return { code: generatedCode, map } } return { code } diff --git a/tests/tests.test.mjs b/tests/tests.test.mjs index 97ef14b..3ffb803 100644 --- a/tests/tests.test.mjs +++ b/tests/tests.test.mjs @@ -6,7 +6,7 @@ import { readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs' import { spawnSync } from 'node:child_process' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' -import { SourceMapConsumer } from 'source-map' +import { SourceMapConsumer, SourceMapGenerator } from 'source-map' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -775,6 +775,101 @@ describe('source_map', () => { assert.equal(JSON.parse(map).file, `${TEST_MODULE_NAME}/${TEST_MODULE_PATH}`) }) + + test('embeds the original source', () => { + const code = 'async function fetch (url) { return 42; }\nmodule.exports = { fetch };' + + const instrumentor = create([ + { + channelName: 'fetch_sm', + module: { name: TEST_MODULE_NAME, versionRange: '>=0.0.1', filePath: TEST_MODULE_PATH }, + functionQuery: { functionName: 'fetch', kind: 'Async' }, + }, + ]) + const transformer = instrumentor.getTransformer(TEST_MODULE_NAME, TEST_MODULE_VERSION, TEST_MODULE_PATH) + const { map } = transformer.transform(code, 'cjs') + + const { sources, sourcesContent } = JSON.parse(map) + assert.deepEqual(sources, [`${TEST_MODULE_NAME}/${TEST_MODULE_PATH}`]) + assert.deepEqual(sourcesContent, [code]) + }) + + test('uses `sourceFile` as the source name', () => { + const code = 'async function fetch (url) { return 42; }\nmodule.exports = { fetch };' + const sourceFile = `/app/node_modules/${TEST_MODULE_NAME}/${TEST_MODULE_PATH}` + + const instrumentor = create([ + { + channelName: 'fetch_sm', + module: { name: TEST_MODULE_NAME, versionRange: '>=0.0.1', filePath: TEST_MODULE_PATH }, + functionQuery: { functionName: 'fetch', kind: 'Async' }, + }, + ]) + const transformer = instrumentor.getTransformer(TEST_MODULE_NAME, TEST_MODULE_VERSION, TEST_MODULE_PATH) + const { map } = transformer.transform(code, 'cjs', null, { sourceFile }) + + const { sources, sourcesContent } = JSON.parse(map) + assert.deepEqual(sources, [sourceFile]) + assert.deepEqual(sourcesContent, [code]) + }) + + test('chains through an input sourcemap when `sourceFile` is set', () => { + // An earlier loader removed the two comment lines from `original`, + // so line N of `intermediate` is line N + 2 of `original`. + const original = [ + '// comment 1', + '// comment 2', + 'async function fetch (url) {', + ' return 42;', + '}', + 'module.exports = { fetch };', + ].join('\n') + const intermediate = original.split('\n').slice(2).join('\n') + const inputMap = new SourceMapGenerator({ file: 'intermediate.js' }) + intermediate.split('\n').forEach((line, i) => { + // Map every column, so any lookup gets an exact answer. + for (let column = 0; column < line.length; column++) { + inputMap.addMapping({ + source: 'original.js', + original: { line: i + 3, column }, + generated: { line: i + 1, column }, + }) + } + }) + inputMap.setSourceContent('original.js', original) + const sourceFile = `/app/node_modules/${TEST_MODULE_NAME}/${TEST_MODULE_PATH}` + + const instrumentor = create([ + { + channelName: 'fetch_sm', + module: { name: TEST_MODULE_NAME, versionRange: '>=0.0.1', filePath: TEST_MODULE_PATH }, + functionQuery: { functionName: 'fetch', kind: 'Async' }, + }, + ]) + const transformer = instrumentor.getTransformer(TEST_MODULE_NAME, TEST_MODULE_VERSION, TEST_MODULE_PATH) + const { code, map } = transformer.transform(intermediate, 'cjs', inputMap.toString(), { sourceFile }) + + const parsed = JSON.parse(map) + // `sourceFile` names the real file on disk, so the map must not + // claim that file holds the intermediate code. + const sourceFileIndex = parsed.sources.indexOf(sourceFile) + if (sourceFileIndex !== -1) { + assert.notEqual(parsed.sourcesContent?.[sourceFileIndex], intermediate) + } + + // `42` in the output must map to `42` in `original` (line 4, + // column 9), not to its position in `intermediate`. + const lines = code.split('\n') + const line = lines.findIndex(l => l.includes('return 42')) + 1 + assert.ok(line > 0) + const column = lines[line - 1].indexOf('42') + const consumer = new SourceMapConsumer(parsed) + const pos = consumer.originalPositionFor({ line, column }) + assert.deepEqual( + { source: pos.source, line: pos.line, column: pos.column }, + { source: 'original.js', line: 4, column: 9 } + ) + }) }) describe('wrap_promise_non_promise', () => {