Skip to content

Improve support for esbuild + esm with DD_BUILD_ESM env var #5888

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Jun 17, 2025
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env node
/* eslint-disable no-console */

import esbuild from 'esbuild'
import { spawnSync } from 'child_process'
import commonConfig from './build.esm.common-config.js'

await esbuild.build({
...commonConfig,
banner: {
js: `import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);`
}
})

const { status, stdout, stderr } = spawnSync('node', ['out.mjs'])
if (stdout.length) {
console.log(stdout.toString())
}
if (stderr.length) {
console.error(stderr.toString())
}
if (status) {
throw new Error('generated script failed to run')
}
console.log('ok')
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env node
/* eslint-disable no-console */

import esbuild from 'esbuild'
import { spawnSync } from 'child_process'
import commonConfig from './build.esm.common-config.js'

await esbuild.build({
...commonConfig,
banner: {
js: `import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
globalThis.require ??= createRequire(import.meta.url);
globalThis.__filename ??= fileURLToPath(import.meta.url);
globalThis.__dirname ??= dirname(globalThis.__filename);`
}
})

const { status, stdout, stderr } = spawnSync('node', ['out.mjs'])
if (stdout.length) {
console.log(stdout.toString())
}
if (stderr.length) {
console.error(stderr.toString())
}
if (status) {
throw new Error('generated script failed to run')
}
console.log('ok')
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env node
/* eslint-disable no-console */

import esbuild from 'esbuild'
import { spawnSync } from 'child_process'
import commonConfig from './build.esm.common-config.js'

await esbuild.build({
...commonConfig,
format: undefined
})

const { status, stdout, stderr } = spawnSync('node', ['out.mjs'])
if (stdout.length) {
console.log(stdout.toString())
}
if (stderr.length) {
console.error(stderr.toString())
}
if (status) {
throw new Error('generated script failed to run')
}
console.log('ok')
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env node
/* eslint-disable no-console */

import esbuild from 'esbuild'
import { spawnSync } from 'child_process'
import { renameSync } from 'fs'
import commonConfig from './build.esm.common-config.js'

await esbuild.build({
...commonConfig,
outfile: 'out.js'
})

// to force being executed as module
renameSync('./out.js', './out.mjs')

const { status, stdout, stderr } = spawnSync('node', ['out.mjs'])
if (stdout.length) {
console.log(stdout.toString())
}
if (stderr.length) {
console.error(stderr.toString())
}
if (status) {
throw new Error('generated script failed to run')
}
console.log('ok')
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env node
/* eslint-disable no-console */

import esbuild from 'esbuild'
import { spawnSync } from 'child_process'
import commonConfig from './build.esm.common-config.js'

// output => basic-test.mjs
await esbuild.build({
...commonConfig,
outfile: undefined,
format: undefined,
outdir: './',
outExtension: { '.js': '.mjs' }
})

const { status, stdout, stderr } = spawnSync('node', ['basic-test.mjs'])
if (stdout.length) {
console.log(stdout.toString())
}
if (stderr.length) {
console.error(stderr.toString())
}
if (status) {
throw new Error('generated script failed to run')
}
console.log('ok')
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env node
/* eslint-disable no-console */

import esbuild from 'esbuild'
import { spawnSync } from 'child_process'
import commonConfig from './build.esm.common-config.js'

await esbuild.build({
...commonConfig,
banner: {
js: '/* js test */'
}
})

const { status, stdout, stderr } = spawnSync('node', ['out.mjs'])
if (stdout.length) {
console.log(stdout.toString())
}
if (stderr.length) {
console.error(stderr.toString())
}
if (status) {
throw new Error('generated script failed to run')
}
console.log('ok')
21 changes: 21 additions & 0 deletions integration-tests/esbuild/build.esm.common-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const ddPlugin = require('../../esbuild')
module.exports = {
format: 'esm',
entryPoints: ['basic-test.js'],
bundle: true,
outfile: 'out.mjs',
plugins: [ddPlugin],
platform: 'node',
target: ['node18'],
external: [
// dead code paths introduced by knex
'pg',
'mysql2',
'better-sqlite3',
'sqlite3',
'mysql',
'oracledb',
'pg-query-stream',
'tedious'
]
}
7 changes: 7 additions & 0 deletions integration-tests/esbuild/build.esm.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env node

import esbuild from 'esbuild'

import commonConfig from './build.esm.common-config.js'

await esbuild.build(commonConfig)
89 changes: 89 additions & 0 deletions integration-tests/esbuild/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
const chproc = require('child_process')
const path = require('path')
const fs = require('fs')
const { assert } = require('chai')

const TEST_DIR = path.join(__dirname, '.')
process.chdir(TEST_DIR)
Expand Down Expand Up @@ -75,5 +76,93 @@ esbuildVersions.forEach((version) => {
timeout: 1000 * 30
})
})

describe('ESM', () => {
afterEach(() => {
fs.rmSync('./out.mjs', { force: true })
fs.rmSync('./out.js', { force: true })
fs.rmSync('./basic-test.mjs', { force: true })
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we move the cleanup to the file that runs the test by using a finally block, we'd not have to list them here.
That feels a tad cleaner.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some tests that are checking the content of the files, like this one: https://github.com/DataDog/dd-trace-js/pull/5888/files/13e26af2feb6a6d3a14d1677cdbebe29bfd08a07

I don't want to move these checks to the file running the tests, imo the test logic is better here. imho build & run are more than enough to have in separate files.

})

it('works', () => {
console.log('npm run build:esm')
chproc.execSync('npm run build:esm')
console.log('npm run built:esm')
chproc.execSync('npm run built:esm', {
timeout: 1000 * 30
})
})

it('should not override existing js banner', () => {
const command = 'node ./build-and-run.esm-unrelated-js-banner.mjs'
console.log(command)
chproc.execSync(command, {
timeout: 1000 * 30
})

const builtFile = fs.readFileSync('./out.mjs').toString()
assert.include(builtFile, '/* js test */')
})

it('should contain the definitions when esm is inferred from outfile', () => {
const command = 'node ./build-and-run.esm-relying-in-extension.mjs'
console.log(command)
chproc.execSync(command, {
timeout: 1000 * 30
})

const builtFile = fs.readFileSync('./out.mjs').toString()
assert.include(builtFile, 'globalThis.__filename ??= $dd_fileURLToPath(import.meta.url);')
})

it('should contain the definitions when esm is inferred from format', () => {
const command = 'node ./build-and-run.esm-relying-in-format.mjs'
console.log(command)
chproc.execSync(command, {
timeout: 1000 * 30
})

const builtFile = fs.readFileSync('./out.mjs').toString()
assert.include(builtFile, 'globalThis.__filename ??= $dd_fileURLToPath(import.meta.url);')
})

it('should contain the definitions when format is inferred from out extension', () => {
const command = 'node ./build-and-run.esm-relying-in-out-extension.mjs'
console.log(command)
chproc.execSync(command, {
timeout: 1000 * 30
})

const builtFile = fs.readFileSync('./basic-test.mjs').toString()
assert.include(builtFile, 'globalThis.__filename ??= $dd_fileURLToPath(import.meta.url);')
})

it('should not contain the definitions when no esm is specified', () => {
const command = 'node ./build.js'
console.log(command)
chproc.execSync(command, {
timeout: 1000 * 30
})

const builtFile = fs.readFileSync('./out.js').toString()
assert.notInclude(builtFile, 'globalThis.__filename ??= $dd_fileURLToPath(import.meta.url);')
})

it('should not crash when it is already patched using global', () => {
const command = 'node ./build-and-run.esm-patched-global-banner.mjs'
console.log(command)
chproc.execSync(command, {
timeout: 1000 * 30
})
})

it('should not crash when it is already patched using const', () => {
const command = 'node ./build-and-run.esm-patched-const-banner.mjs'
console.log(command)
chproc.execSync(command, {
timeout: 1000 * 30
})
})
})
})
})
2 changes: 2 additions & 0 deletions integration-tests/esbuild/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
"main": "app.js",
"scripts": {
"build": "DD_TRACE_DEBUG=true node ./build.js",
"build:esm": "DD_TRACE_DEBUG=true node ./build.esm.mjs",
"built": "DD_TRACE_DEBUG=true node ./out.js",
"built:esm": "DD_TRACE_DEBUG=true node ./out.mjs",
"raw": "DD_TRACE_DEBUG=true node ./app.js",
"link": "pushd ../.. && yarn link && popd && yarn link dd-trace",
"request": "curl http://localhost:3000 | jq"
Expand Down
22 changes: 22 additions & 0 deletions packages/datadog-esbuild/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,30 @@ for (const pkg of INSTRUMENTED) {

module.exports.name = 'datadog-esbuild'

function isESMBuild (build) {
// check toLowerCase? to be safe if unexpected object is there instead of a string
const format = build.initialOptions.format?.toLowerCase?.()
const outputFile = build.initialOptions.outfile?.toLowerCase?.()
const outExtension = build.initialOptions.outExtension?.['.js']
return format === 'esm' || outputFile?.endsWith('.mjs') || outExtension === '.mjs'
}

module.exports.setup = function (build) {
const externalModules = new Set(build.initialOptions.external || [])
if (isESMBuild(build)) {
build.initialOptions.banner ??= {}
build.initialOptions.banner.js ??= ''
if (!build.initialOptions.banner.js.includes('import { createRequire as $dd_createRequire } from \'module\'')) {
build.initialOptions.banner.js = `import { createRequire as $dd_createRequire } from 'module';
import { fileURLToPath as $dd_fileURLToPath } from 'url';
import { dirname as $dd_dirname } from 'path';
globalThis.require ??= $dd_createRequire(import.meta.url);
globalThis.__filename ??= $dd_fileURLToPath(import.meta.url);
globalThis.__dirname ??= $dd_dirname(globalThis.__filename);
${build.initialOptions.banner.js}`
}
}

build.onResolve({ filter: /.*/ }, args => {
if (externalModules.has(args.path)) {
// Internal Node.js packages will still be instrumented via require()
Expand Down