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
18 changes: 17 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<moduleName>/<filePath>`.
*/
sourceFile?: string;
}

/**
* The kind of function
*/
Expand Down Expand Up @@ -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;
}
25 changes: 14 additions & 11 deletions lib/transformer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<moduleName>/<filePath>`.
* @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 }

Expand Down Expand Up @@ -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 }
Expand Down
97 changes: 96 additions & 1 deletion tests/tests.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -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', () => {
Expand Down
Loading