From c0a0cb84e74b155a6c03e3814bacd8a1a7991df1 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Fri, 28 Aug 2026 20:36:00 +0200 Subject: [PATCH 1/3] feat(libdatadog): add dedicated remote config WASM Remote Config pulls in TUF, cryptography, and HTTP dependencies that existing users do not need. Keeping its artifact separate preserves the existing universal WASM load and compile cost. --- README.md | 3 + packages/libdatadog/README.md | 8 +- packages/libdatadog/package.json | 15 ++- packages/libdatadog/remote-config.d.ts | 1 + packages/libdatadog/remote-config.js | 3 + packages/libdatadog/remote-config.mjs | 8 ++ packages/libdatadog/scripts/inline-wasm.js | 14 ++- packages/libdatadog/test/bundlers.test.js | 86 ++++++++----- .../libdatadog/test/package-contents.test.js | 21 +++- packages/libdatadog/test/package.test.js | 46 ++++++- .../libdatadog/test/remote-config.test.js | 119 ++++++++++++++++++ packages/libdatadog/test/types.test.ts | 26 ++++ packages/libdatadog/wasm/package.json | 4 +- packages/libdatadog/wasm/remote-config.d.ts | 35 ++++++ packages/libdatadog/wasm/remote-config.js | 3 + scripts/check-dependencies.js | 52 ++++++-- test/dependencies.js | 65 ++++++++++ 17 files changed, 454 insertions(+), 55 deletions(-) create mode 100644 packages/libdatadog/remote-config.d.ts create mode 100644 packages/libdatadog/remote-config.js create mode 100644 packages/libdatadog/remote-config.mjs create mode 100644 packages/libdatadog/test/remote-config.test.js create mode 100644 packages/libdatadog/wasm/remote-config.d.ts create mode 100644 packages/libdatadog/wasm/remote-config.js diff --git a/README.md b/README.md index 0ec459a..6dd4dd8 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,9 @@ Zstandard compression, DDSketch, and the agentless data pipeline use a wasm-bindgen backend whose WebAssembly bytes are embedded in JavaScript. No raw `.wasm` asset or native extension is published. +Remote configuration is available from `@datadog/libdatadog/remote-config`. +It uses a dedicated wasm-bindgen artifact that loads only with this entry point. + See the [package README](packages/libdatadog/README.md) for implementation and packaging details. diff --git a/packages/libdatadog/README.md b/packages/libdatadog/README.md index a37bcc7..ed1cdbe 100644 --- a/packages/libdatadog/README.md +++ b/packages/libdatadog/README.md @@ -10,10 +10,14 @@ DDSketch, and the agentless data pipeline are maintained in the root `crates/libdatadog-wasm` workspace crate. Optional libdatadog functionality is published separately as `@datadog/libdatadog-extras`. +Remote configuration is available from `@datadog/libdatadog/remote-config`. +It uses a dedicated wasm-bindgen artifact that loads only with this entry point. + The package accepts Datadog v0.4 MessagePack payloads and exports them to an agentless intake. The package uses a wasm-bindgen backend with the WebAssembly bytes embedded in JavaScript. The canonical inlined output is published as the regular -`@datadog/libdatadog-wasm` dependency from the `wasm` workspace. No raw `.wasm` -asset or native extension is published. +`@datadog/libdatadog-wasm` dependency from the `wasm` workspace. The WASM +package also contains the separate remote configuration artifact. No raw +`.wasm` asset or native extension is published. diff --git a/packages/libdatadog/package.json b/packages/libdatadog/package.json index 3ffb9b9..efe2cff 100644 --- a/packages/libdatadog/package.json +++ b/packages/libdatadog/package.json @@ -18,12 +18,21 @@ "import": "./wasm.mjs", "require": "./wasm.js", "default": "./wasm.js" + }, + "./remote-config": { + "types": "./remote-config.d.ts", + "import": "./remote-config.mjs", + "require": "./remote-config.js", + "default": "./remote-config.js" } }, "files": [ "README.md", "index.js", "index.mjs", + "remote-config.js", + "remote-config.mjs", + "remote-config.d.ts", "wasm.js", "wasm.mjs", "index.d.ts", @@ -36,10 +45,12 @@ "node": ">=18" }, "scripts": { - "build:wasm": "npm run build:wasm:binary && npm run inline:wasm", + "build:wasm": "npm run build:wasm:binary && npm run build:remote-config:binary && npm run inline:wasm && npm run inline:remote-config", "build:wasm:binary": "node ../../scripts/build-wasm.js ../../crates/libdatadog-wasm wasm/dist", + "build:remote-config:binary": "node ../../scripts/build-wasm.js ../../crates/remote_config wasm/dist/remote-config", "size:profile": "node ../../scripts/build-wasm.js ../../crates/libdatadog-wasm ../../target/size --profiling", - "inline:wasm": "node scripts/inline-wasm.js", + "inline:wasm": "node scripts/inline-wasm.js libdatadog_wasm wasm/dist", + "inline:remote-config": "node scripts/inline-wasm.js remote_config wasm/dist/remote-config", "report:wasm-size": "node scripts/report-wasm-size.js", "build": "npm run build:wasm", "test": "node scripts/run-tests.js && npm run test:types", diff --git a/packages/libdatadog/remote-config.d.ts b/packages/libdatadog/remote-config.d.ts new file mode 100644 index 0000000..3e8e78f --- /dev/null +++ b/packages/libdatadog/remote-config.d.ts @@ -0,0 +1 @@ +export * from '@datadog/libdatadog-wasm/remote-config' diff --git a/packages/libdatadog/remote-config.js b/packages/libdatadog/remote-config.js new file mode 100644 index 0000000..68d2f55 --- /dev/null +++ b/packages/libdatadog/remote-config.js @@ -0,0 +1,3 @@ +'use strict' + +module.exports = require('@datadog/libdatadog-wasm/remote-config') diff --git a/packages/libdatadog/remote-config.mjs b/packages/libdatadog/remote-config.mjs new file mode 100644 index 0000000..e8382f6 --- /dev/null +++ b/packages/libdatadog/remote-config.mjs @@ -0,0 +1,8 @@ +import remoteConfig from './remote-config.js' + +export const { + RemoteConfigFetcher, + setStorage, +} = remoteConfig + +export { default } from './remote-config.js' diff --git a/packages/libdatadog/scripts/inline-wasm.js b/packages/libdatadog/scripts/inline-wasm.js index 4fc004f..fe164f1 100644 --- a/packages/libdatadog/scripts/inline-wasm.js +++ b/packages/libdatadog/scripts/inline-wasm.js @@ -4,9 +4,15 @@ const fs = require('node:fs') const path = require('node:path') const { constants, brotliCompressSync } = require('node:zlib') -const outputDirectory = path.join(__dirname, '..', 'wasm', 'dist') -const gluePath = path.join(outputDirectory, 'libdatadog_wasm.js') -const wasmPath = path.join(outputDirectory, 'libdatadog_wasm_bg.wasm') +const [moduleName, relativeOutputDirectory] = process.argv.slice(2) + +if (!moduleName || !relativeOutputDirectory) { + throw new Error('usage: node scripts/inline-wasm.js ') +} + +const outputDirectory = path.join(__dirname, '..', relativeOutputDirectory) +const gluePath = path.join(outputDirectory, `${moduleName}.js`) +const wasmPath = path.join(outputDirectory, `${moduleName}_bg.wasm`) const glue = fs.readFileSync(gluePath, 'utf8') const wasm = fs.readFileSync(wasmPath) const encodedWasm = brotliCompressSync(wasm, { @@ -15,7 +21,7 @@ const encodedWasm = brotliCompressSync(wasm, { }, }).toString('base64') const loader = [ - 'const wasmPath = `${__dirname}/libdatadog_wasm_bg.wasm`;', + `const wasmPath = \`\${__dirname}/${moduleName}_bg.wasm\`;`, 'const wasmBytes = require(\'fs\').readFileSync(wasmPath);', ].join('\n') diff --git a/packages/libdatadog/test/bundlers.test.js b/packages/libdatadog/test/bundlers.test.js index ade9565..037af0f 100644 --- a/packages/libdatadog/test/bundlers.test.js +++ b/packages/libdatadog/test/bundlers.test.js @@ -5,48 +5,39 @@ const fs = require('node:fs') const os = require('node:os') const path = require('node:path') const { test } = require('node:test') +const { promisify } = require('node:util') const esbuild = require('esbuild') const webpack = require('webpack') const packageRoot = path.join(__dirname, '..') -const entry = path.join(packageRoot, 'index.js') - -test('esbuild bundles the package entry point without emitting an asset', async () => { - await assertBundle(async (output) => { - await esbuild.build({ - bundle: true, - entryPoints: [entry], - outfile: output, - platform: 'node', - }) +const webpackAsync = promisify(webpack) +const entries = new Map([ + ['package', path.join(packageRoot, 'index.js')], + ['WASM', path.join(packageRoot, 'wasm.js')], + ['remote config', path.join(packageRoot, 'remote-config.js')], +]) + +for (const [name, entry] of entries) { + test(`esbuild bundles the ${name} entry point without emitting an asset`, async () => { + await assertBundle(entry, bundleWithEsbuild) + }) + + test(`webpack bundles the ${name} entry point without emitting an asset`, async () => { + await assertBundle(entry, bundleWithWebpack) }) -}) - -test('webpack bundles the package entry point without emitting an asset', async () => { - await assertBundle(output => new Promise((resolve, reject) => { - webpack({ - entry, - mode: 'production', - output: { - filename: path.basename(output), - path: path.dirname(output), - }, - target: 'node', - }, (error, stats) => { - if (error) return reject(error) - if (stats.hasErrors()) return reject(new Error(stats.toString({ all: false, errors: true }))) - resolve() - }) - })) -}) - -async function assertBundle (bundle) { +} + +/** + * @param {string} entry + * @param {(entry: string, output: string) => Promise} bundle + */ +async function assertBundle (entry, bundle) { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'libdatadog-bundle-')) const output = path.join(directory, 'bundle.cjs') try { - await bundle(output) + await bundle(entry, output) const files = fs.readdirSync(directory) const contents = fs.readFileSync(output, 'utf8') @@ -56,3 +47,34 @@ async function assertBundle (bundle) { fs.rmSync(directory, { force: true, recursive: true }) } } + +/** + * @param {string} entry + * @param {string} output + */ +async function bundleWithEsbuild (entry, output) { + await esbuild.build({ + bundle: true, + entryPoints: [entry], + outfile: output, + platform: 'node', + }) +} + +/** + * @param {string} entry + * @param {string} output + */ +async function bundleWithWebpack (entry, output) { + const stats = await webpackAsync({ + entry, + mode: 'production', + output: { + filename: path.basename(output), + path: path.dirname(output), + }, + target: 'node', + }) + + if (stats.hasErrors()) throw new Error(stats.toString({ all: false, errors: true })) +} diff --git a/packages/libdatadog/test/package-contents.test.js b/packages/libdatadog/test/package-contents.test.js index d67e9f4..13186c6 100644 --- a/packages/libdatadog/test/package-contents.test.js +++ b/packages/libdatadog/test/package-contents.test.js @@ -40,6 +40,13 @@ test('published packages contain only the intended artifacts', () => { `packages must not contain standalone WASM files: ${standaloneWasm.join(', ')}`) assert(wasmNames.includes('dist/libdatadog_wasm.js'), 'WASM package must contain the inline-WASM JavaScript fallback') + assert(wasmNames.includes('dist/remote-config/remote_config.js'), + 'WASM package must contain the dedicated remote config artifact') + assert(wasmNames.includes('remote-config.js')) + assert(wasmNames.includes('remote-config.d.ts')) + assert(names.includes('remote-config.js')) + assert(names.includes('remote-config.mjs')) + assert(names.includes('remote-config.d.ts')) assert.strictEqual(libdatadogWasm.name, '@datadog/libdatadog-wasm') assert.strictEqual( metapackageJson.dependencies['@datadog/libdatadog-wasm'], @@ -93,6 +100,10 @@ test('installed package uses its WASM dependency', () => { const libdatadog = requireInstalled('@datadog/libdatadog') const explicitWasm = requireInstalled('@datadog/libdatadog/wasm') + assert.strictEqual(libdatadog.RemoteConfigFetcher, undefined) + assert.strictEqual(explicitWasm.RemoteConfigFetcher, undefined) + const remoteConfig = requireInstalled('@datadog/libdatadog/remote-config') + assert.strictEqual(typeof remoteConfig.RemoteConfigFetcher, 'function') assert.strictEqual(libdatadog.backend(), 'wasm') assert.strictEqual(explicitWasm.backend(), 'wasm') assert(libdatadog.zstd_compress(Buffer.alloc(16), 3) instanceof Uint8Array) @@ -122,7 +133,15 @@ function assertEsmImports (installRoot, environment) { DDSketch as WasmDDSketch, zstd_compress as wasmCompress, } from '@datadog/libdatadog/wasm' - + import remoteConfig, { + RemoteConfigFetcher, + setStorage, + } from '@datadog/libdatadog/remote-config' + + assert.strictEqual(libdatadog.RemoteConfigFetcher, undefined) + assert.strictEqual(wasm.RemoteConfigFetcher, undefined) + assert.strictEqual(remoteConfig.RemoteConfigFetcher, RemoteConfigFetcher) + assert.strictEqual(remoteConfig.setStorage, setStorage) assert.strictEqual(backend(), 'wasm') assert.strictEqual(libdatadog.backend, backend) assert.strictEqual(libdatadog.createAgentlessExporter, createAgentlessExporter) diff --git a/packages/libdatadog/test/package.test.js b/packages/libdatadog/test/package.test.js index f57e4e7..b880831 100644 --- a/packages/libdatadog/test/package.test.js +++ b/packages/libdatadog/test/package.test.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { spawnSync } = require('node:child_process') const fs = require('node:fs') const path = require('node:path') const { brotliDecompressSync } = require('node:zlib') @@ -14,6 +15,7 @@ test('publishes the universal libdatadog package', () => { assert.strictEqual(packageJson.name, '@datadog/libdatadog') assert.strictEqual(packageJson.exports['./wasm'].require, './wasm.js') + assert.strictEqual(packageJson.exports['./remote-config'].require, './remote-config.js') }) test('uses the libdatadog release version', () => { @@ -38,17 +40,49 @@ test('root entry point uses the WASM backend', () => { }) test('embeds a Brotli-compressed WASM fallback below the size budgets', () => { - const gluePath = path.join( + const { glue, wasm } = readInlineWasm(path.join( packageRoot, 'wasm', 'dist', 'libdatadog_wasm.js', - ) + )) + + assert.ok(Buffer.byteLength(glue) < 200 * 1024) + assert.ok(wasm.length < 500 * 1024) +}) + +test('embeds dedicated remote config WASM below the size budgets', () => { + const { glue, wasm } = readInlineWasm(path.join( + packageRoot, + 'wasm', + 'dist', + 'remote-config', + 'remote_config.js', + )) + + assert.ok(Buffer.byteLength(glue) < 450 * 1024) + assert.ok(wasm.length < 1024 * 1024) +}) + +test('requires an artifact name and output directory when inlining WASM', () => { + const result = spawnSync(process.execPath, [ + path.join(packageRoot, 'scripts', 'inline-wasm.js'), + ]) + + assert.notStrictEqual(result.status, 0) + assert.match(result.stderr.toString(), /usage: node scripts\/inline-wasm\.js/) +}) + +/** + * @param {string} gluePath + */ +function readInlineWasm (gluePath) { const glue = fs.readFileSync(gluePath, 'utf8') const encodedWasm = glue.match(/brotliDecompressSync\(Buffer\.from\('([^']+)', 'base64'\)\)/)?.[1] assert.ok(encodedWasm, 'WASM must be embedded as a Brotli-compressed base64 string') - const compressedWasm = Buffer.from(encodedWasm, 'base64') - assert.ok(Buffer.byteLength(glue) < 200 * 1024) - assert.ok(brotliDecompressSync(compressedWasm).length < 500 * 1024) -}) + return { + glue, + wasm: brotliDecompressSync(Buffer.from(encodedWasm, 'base64')), + } +} diff --git a/packages/libdatadog/test/remote-config.test.js b/packages/libdatadog/test/remote-config.test.js new file mode 100644 index 0000000..25ff445 --- /dev/null +++ b/packages/libdatadog/test/remote-config.test.js @@ -0,0 +1,119 @@ +'use strict' + +/* eslint-disable unicorn/prefer-event-target -- Node stream mocks use EventEmitter. */ + +const assert = require('node:assert/strict') +const { EventEmitter } = require('node:events') +const https = require('node:https') +const { test } = require('node:test') + +const { RemoteConfigFetcher, setStorage } = require('../remote-config') + +const CONFIG_PATH = 'datadog/2/ASM_FEATURES/asm-features-1/config' + +/** @typedef {import('../remote-config').RemoteConfigFetcherOptions} RemoteConfigFetcherOptions */ + +/** + * @param {Partial} [overrides] + */ +function fetcherOptions (overrides = {}) { + return { + clientId: 'client-id', + runtimeId: 'runtime-id', + service: 'service', + env: 'env', + appVersion: '1.0.0', + tags: [], + processTags: [], + language: 'nodejs', + tracerVersion: '1.0.0', + url: 'https://datadoghq.com', + timeoutMs: 5000, + apiKey: 'api-key', + hostname: 'host', + ...overrides, + } +} + +test('keeps remote config out of the universal WASM entry point', () => { + const wasm = require('../wasm') + + assert.strictEqual(wasm.RemoteConfigFetcher, undefined) + assert.strictEqual(wasm.setStorage, undefined) + assert.strictEqual(typeof RemoteConfigFetcher, 'function') +}) + +test('exports agentless remote config from the dedicated entry point', async () => { + const requests = [] + const originalRequest = https.request + + /** + * @param {import('node:https').RequestOptions} options + * @param {(response: import('node:http').IncomingMessage) => void} onResponse + */ + function request (options, onResponse) { + const outgoing = new EventEmitter() + const chunks = [] + + /** + * @param {string | Uint8Array} chunk + */ + outgoing.write = function write (chunk) { + chunks.push(Buffer.from(chunk)) + } + outgoing.end = () => { + requests.push({ body: Buffer.concat(chunks), options }) + queueMicrotask(() => { + const response = new EventEmitter() + response.statusCode = 200 + response.rawHeaders = [] + onResponse(response) + response.emit('end') + }) + } + return outgoing + } + https.request = request + + try { + const fetcher = new RemoteConfigFetcher(fetcherOptions()) + assert.deepStrictEqual( + fetcher.setProductCapabilities(['ASM_FEATURES'], ['ASM_ACTIVATION']), + [], + ) + fetcher.setExtraServices(['extra-service']) + + await assert.rejects(fetcher.fetchChanges()) + + let configRequest + for (const request of requests) { + if (request.options.path === '/api/v0.1/configurations') { + configRequest = request + break + } + } + assert(configRequest) + assert.strictEqual(configRequest.options.headers['dd-api-key'], 'api-key') + assert.strictEqual(configRequest.options.method, 'POST') + assert(configRequest.body.length > 0) + } finally { + https.request = originalRequest + } +}) + +test('keeps the WASM remote config validation contract', () => { + const fetcher = new RemoteConfigFetcher(fetcherOptions()) + + assert.deepStrictEqual( + fetcher.setProductCapabilities( + ['ASM_FEATURES', 'NOT_A_PRODUCT'], + ['ASM_ACTIVATION', 'NOT_A_CAPABILITY'], + ), + ['NOT_A_PRODUCT', 'NOT_A_CAPABILITY'], + ) + assert.throws( + () => fetcher.setConfigState(CONFIG_PATH, 42), + /Unknown apply state 42/, + ) + assert.strictEqual(typeof setStorage, 'function') +}) diff --git a/packages/libdatadog/test/types.test.ts b/packages/libdatadog/test/types.test.ts index d4bc12b..a742c8d 100644 --- a/packages/libdatadog/test/types.test.ts +++ b/packages/libdatadog/test/types.test.ts @@ -3,6 +3,10 @@ import { DDSketch, zstd_compress, } from '@datadog/libdatadog' +import { + RemoteConfigFetcher, + setStorage, +} from '@datadog/libdatadog/remote-config' import * as wasm from '@datadog/libdatadog/wasm' const selectedBackend: 'wasm' = backend() @@ -17,6 +21,27 @@ const encoded: Uint8Array = sketch.encode() const wasmBackend: typeof backend = wasm.backend const wasmSketch: typeof DDSketch = wasm.DDSketch const wasmCompress: typeof zstd_compress = wasm.zstd_compress +const remoteConfigFetcher = new RemoteConfigFetcher({ + clientId: 'client-id', + runtimeId: 'runtime-id', + service: 'service', + env: 'env', + appVersion: '1.0.0', + tags: [], + processTags: [], + language: 'nodejs', + tracerVersion: '1.0.0', + url: 'http://127.0.0.1:8126', + timeoutMs: 5000, +}) + +remoteConfigFetcher.setExtraServices([]) +remoteConfigFetcher.setProductCapabilities([], []) +setStorage(runInStorage) + +function runInStorage (callback: () => void): void { + callback() +} void selectedBackend void compressed @@ -25,3 +50,4 @@ void encoded void wasmBackend void wasmSketch void wasmCompress +void remoteConfigFetcher diff --git a/packages/libdatadog/wasm/package.json b/packages/libdatadog/wasm/package.json index bc26de4..a0221e9 100644 --- a/packages/libdatadog/wasm/package.json +++ b/packages/libdatadog/wasm/package.json @@ -7,7 +7,9 @@ "main": "dist/libdatadog_wasm.js", "types": "dist/libdatadog_wasm.d.ts", "files": [ - "dist/" + "dist/", + "remote-config.js", + "remote-config.d.ts" ], "engines": { "node": ">=18" diff --git a/packages/libdatadog/wasm/remote-config.d.ts b/packages/libdatadog/wasm/remote-config.d.ts new file mode 100644 index 0000000..e182181 --- /dev/null +++ b/packages/libdatadog/wasm/remote-config.d.ts @@ -0,0 +1,35 @@ +export interface RemoteConfigFetcherOptions { + clientId: string + runtimeId: string + service: string + env: string + appVersion: string + tags: string[] + processTags: string[] + language: string + tracerVersion: string + url: string + timeoutMs: number + apiKey?: string + hostname?: string +} + +export interface RemoteConfigChange { + kind: 'add' | 'update' | 'remove' + path: string + product: string + configId: string + name: string + version: number + contents?: string +} + +export class RemoteConfigFetcher { + constructor(options: RemoteConfigFetcherOptions) + fetchChanges(): Promise + setConfigState(path: string, applyState: number, applyError?: string): void + setExtraServices(services: string[]): void + setProductCapabilities(products: string[], capabilities: string[]): string[] +} + +export function setStorage(storage: (callback: () => void) => void): void diff --git a/packages/libdatadog/wasm/remote-config.js b/packages/libdatadog/wasm/remote-config.js new file mode 100644 index 0000000..460b36b --- /dev/null +++ b/packages/libdatadog/wasm/remote-config.js @@ -0,0 +1,3 @@ +'use strict' + +module.exports = require('./dist/remote-config/remote_config') diff --git a/scripts/check-dependencies.js b/scripts/check-dependencies.js index b2d2212..bf7f619 100644 --- a/scripts/check-dependencies.js +++ b/scripts/check-dependencies.js @@ -6,7 +6,31 @@ const { execFileSync } = require('node:child_process') const repositoryRoot = path.join(__dirname, '..') const trees = [ { package: 'libdatadog-wasm', target: 'wasm32-unknown-unknown' }, + { package: 'remote-config', target: 'wasm32-unknown-unknown' }, ] +// TODO: Remove these exceptions when libdd-remote-config aligns its TUF dependency versions. +const remoteConfigDuplicatePackages = new Map([ + ['http', new Set(['0.2.12', '1.5.0'])], + ['itoa', new Set(['0.4.8', '1.0.18'])], + ['syn', new Set(['2.0.119', '3.0.4'])], + ['thiserror', new Set(['1.0.69', '2.0.20'])], + ['thiserror-impl', new Set(['1.0.69', '2.0.20'])], + ['untrusted', new Set(['0.7.1', '0.9.0'])], +]) +const remoteConfigTokioPackages = new Set(['tokio', 'tokio-macros', 'tokio-util']) + +/** + * @param {Set} actual + * @param {Set} expected + */ +function setsEqual (actual, expected) { + if (actual.size !== expected.size) return false + + for (const value of actual) { + if (!expected.has(value)) return false + } + return true +} function parseCargoTree (output) { const paths = [] @@ -26,7 +50,11 @@ function parseCargoTree (output) { return paths } -function findDuplicateVersions (dependencies) { +/** + * @param {{ name: string, version: string }[]} dependencies + * @param {{ package?: string }} [tree] + */ +function findDuplicateVersions (dependencies, tree = {}) { const versionsByPackage = new Map() const failures = [] @@ -37,7 +65,11 @@ function findDuplicateVersions (dependencies) { } for (const [name, versions] of versionsByPackage) { - if (versions.size > 1) { + const allowedVersions = remoteConfigDuplicatePackages.get(name) + const allowedRemoteConfigDuplicate = tree.package === 'remote-config' + && allowedVersions !== undefined + && setsEqual(versions, allowedVersions) + if (versions.size > 1 && !allowedRemoteConfigDuplicate) { failures.push({ name, versions: [...versions] }) } } @@ -47,15 +79,21 @@ function findDuplicateVersions (dependencies) { /** * @template {{ name: string }} Dependency * @param {Dependency[]} dependencies + * @param {{ package?: string }} [tree] * @returns {Dependency[]} */ -function findForbiddenDependencies (dependencies) { +function findForbiddenDependencies (dependencies, tree = {}) { const failures = [] for (const dependency of dependencies) { const isTokio = dependency.name === 'tokio' const isTokioCompanion = dependency.name.startsWith('tokio-') - if (isTokio || isTokioCompanion) failures.push(dependency) + if (!isTokio && !isTokioCompanion) continue + + const allowedRemoteConfigRuntime = tree.package === 'remote-config' + && remoteConfigTokioPackages.has(dependency.name) + && dependency.path.includes('libdd-remote-config') + if (!allowedRemoteConfigRuntime) failures.push(dependency) } return failures @@ -80,10 +118,10 @@ function checkTrees () { }) const dependencies = parseCargoTree(output) - for (const failure of findDuplicateVersions(dependencies)) { + for (const failure of findDuplicateVersions(dependencies, tree)) { duplicateFailures.push({ ...failure, ...tree }) } - for (const failure of findForbiddenDependencies(dependencies)) { + for (const failure of findForbiddenDependencies(dependencies, tree)) { forbiddenFailures.push({ ...failure, ...tree }) } } @@ -109,7 +147,7 @@ function checkTrees () { ) } } else { - console.log('Tokio is absent from WASM.') + console.log('Tokio is limited to the dedicated remote config artifact.') } if (duplicateFailures.length > 0 || forbiddenFailures.length > 0) { diff --git a/test/dependencies.js b/test/dependencies.js index e64c38e..af299a2 100644 --- a/test/dependencies.js +++ b/test/dependencies.js @@ -62,6 +62,25 @@ test('dependency validation reports forbidden packages from Cargo', () => { } }) +test('dependency validation allows Tokio only through remote config', () => { + const dependencies = parseCargoTree([ + '0remote-config v0.1.0', + '1libdd-remote-config v3.0.0', + '2tokio v1.53.1', + '3tokio-macros v2.7.2', + '2tokio-util v0.7.19', + '1tokio v1.53.1', + ].join('\n')) + const tree = { package: 'remote-config' } + const names = [] + + for (const { name } of findForbiddenDependencies(dependencies, tree)) { + names.push(name) + } + + assert.deepStrictEqual(names, ['tokio']) +}) + test('dependency validation still finds multiple versions in one artifact tree', () => { const dependencies = parseCargoTree([ '0libdatadog v0.1.0', @@ -75,3 +94,49 @@ test('dependency validation still finds multiple versions in one artifact tree', versions: ['1.10.0', '1.11.0'], }]) }) + +test('dependency validation scopes duplicate exceptions to remote config', () => { + const dependencies = parseCargoTree([ + '0remote-config v0.1.0', + '1syn v2.0.119', + '1libdd-remote-config v3.0.0', + '2syn v3.0.4', + ].join('\n')) + const unexpectedVersion = parseCargoTree([ + '0remote-config v0.1.0', + '1syn v2.0.119', + '1libdd-remote-config v3.0.0', + '2syn v3.0.4', + '2other-dependency v1.0.0', + '3syn v1.0.109', + ].join('\n')) + const replacementVersion = parseCargoTree([ + '0remote-config v0.1.0', + '1syn v2.0.119', + '1libdd-remote-config v3.0.0', + '2syn v1.0.109', + ].join('\n')) + + assert.deepStrictEqual( + findDuplicateVersions(dependencies, { package: 'remote-config' }), + [], + ) + assert.deepStrictEqual(findDuplicateVersions(dependencies), [{ + name: 'syn', + versions: ['2.0.119', '3.0.4'], + }]) + assert.deepStrictEqual( + findDuplicateVersions(unexpectedVersion, { package: 'remote-config' }), + [{ + name: 'syn', + versions: ['2.0.119', '3.0.4', '1.0.109'], + }], + ) + assert.deepStrictEqual( + findDuplicateVersions(replacementVersion, { package: 'remote-config' }), + [{ + name: 'syn', + versions: ['2.0.119', '1.0.109'], + }], + ) +}) From d902785e0348781cb5b76cb3d5dd3df697c1e5ff Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Fri, 28 Aug 2026 21:10:07 +0200 Subject: [PATCH 2/3] ci(libdatadog): validate remote config entry point The lint job runs before generated Remote Config modules exist, so fresh checkouts reject both wrapper imports. The request test also verifies that the storage hook wraps the HTTP request. --- eslint.config.js | 2 ++ .../libdatadog/test/remote-config.test.js | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/eslint.config.js b/eslint.config.js index c362da0..048acc0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -90,8 +90,10 @@ module.exports = [ // generated during its build; root lint cannot resolve them. files: [ 'packages/libdatadog/lib/wasm.js', + 'packages/libdatadog/remote-config.js', 'packages/libdatadog/test/bundlers.test.js', 'packages/libdatadog/test/exporter.test.js', + 'packages/libdatadog/wasm/remote-config.js', ], rules: { 'n/no-missing-require': 'off', diff --git a/packages/libdatadog/test/remote-config.test.js b/packages/libdatadog/test/remote-config.test.js index 25ff445..3ff4ac0 100644 --- a/packages/libdatadog/test/remote-config.test.js +++ b/packages/libdatadog/test/remote-config.test.js @@ -3,6 +3,7 @@ /* eslint-disable unicorn/prefer-event-target -- Node stream mocks use EventEmitter. */ const assert = require('node:assert/strict') +const { AsyncLocalStorage } = require('node:async_hooks') const { EventEmitter } = require('node:events') const https = require('node:https') const { test } = require('node:test') @@ -45,13 +46,22 @@ test('keeps remote config out of the universal WASM entry point', () => { test('exports agentless remote config from the dedicated entry point', async () => { const requests = [] + const storage = new AsyncLocalStorage() + const storageValue = {} + const observedStorageValues = [] const originalRequest = https.request + /** @param {() => void} callback */ + function runInStorage (callback) { + storage.run(storageValue, callback) + } + /** * @param {import('node:https').RequestOptions} options * @param {(response: import('node:http').IncomingMessage) => void} onResponse */ function request (options, onResponse) { + observedStorageValues.push(storage.getStore()) const outgoing = new EventEmitter() const chunks = [] @@ -74,6 +84,7 @@ test('exports agentless remote config from the dedicated entry point', async () return outgoing } https.request = request + setStorage(runInStorage) try { const fetcher = new RemoteConfigFetcher(fetcherOptions()) @@ -84,6 +95,8 @@ test('exports agentless remote config from the dedicated entry point', async () fetcher.setExtraServices(['extra-service']) await assert.rejects(fetcher.fetchChanges()) + assert.strictEqual(observedStorageValues.some(value => value !== storageValue), false) + assert(observedStorageValues.length > 0) let configRequest for (const request of requests) { @@ -97,10 +110,16 @@ test('exports agentless remote config from the dedicated entry point', async () assert.strictEqual(configRequest.options.method, 'POST') assert(configRequest.body.length > 0) } finally { + setStorage(runWithoutStorage) https.request = originalRequest } }) +/** @param {() => void} callback */ +function runWithoutStorage (callback) { + callback() +} + test('keeps the WASM remote config validation contract', () => { const fetcher = new RemoteConfigFetcher(fetcherOptions()) From ce856bd5b171172a2d320e027614fe80d8e5d5e8 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Mon, 31 Aug 2026 18:30:50 +0200 Subject: [PATCH 3/3] refactor(libdatadog): simplify remote config packaging Package export maps can target the canonical CommonJS artifacts directly, so forwarding files own no format or compatibility boundary. --- eslint.config.js | 1 - packages/libdatadog/package.json | 3 +- packages/libdatadog/remote-config.mjs | 8 --- .../libdatadog/test/package-contents.test.js | 13 ++++- packages/libdatadog/test/package.test.js | 52 +++++++++++++++++-- packages/libdatadog/wasm/package.json | 16 +++++- packages/libdatadog/wasm/remote-config.js | 3 -- scripts/check-dependencies.js | 2 +- test/dependencies.js | 30 +++++++++++ 9 files changed, 105 insertions(+), 23 deletions(-) delete mode 100644 packages/libdatadog/remote-config.mjs delete mode 100644 packages/libdatadog/wasm/remote-config.js diff --git a/eslint.config.js b/eslint.config.js index 048acc0..daf48de 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -93,7 +93,6 @@ module.exports = [ 'packages/libdatadog/remote-config.js', 'packages/libdatadog/test/bundlers.test.js', 'packages/libdatadog/test/exporter.test.js', - 'packages/libdatadog/wasm/remote-config.js', ], rules: { 'n/no-missing-require': 'off', diff --git a/packages/libdatadog/package.json b/packages/libdatadog/package.json index efe2cff..37ad762 100644 --- a/packages/libdatadog/package.json +++ b/packages/libdatadog/package.json @@ -21,7 +21,7 @@ }, "./remote-config": { "types": "./remote-config.d.ts", - "import": "./remote-config.mjs", + "import": "./remote-config.js", "require": "./remote-config.js", "default": "./remote-config.js" } @@ -31,7 +31,6 @@ "index.js", "index.mjs", "remote-config.js", - "remote-config.mjs", "remote-config.d.ts", "wasm.js", "wasm.mjs", diff --git a/packages/libdatadog/remote-config.mjs b/packages/libdatadog/remote-config.mjs deleted file mode 100644 index e8382f6..0000000 --- a/packages/libdatadog/remote-config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import remoteConfig from './remote-config.js' - -export const { - RemoteConfigFetcher, - setStorage, -} = remoteConfig - -export { default } from './remote-config.js' diff --git a/packages/libdatadog/test/package-contents.test.js b/packages/libdatadog/test/package-contents.test.js index 13186c6..9856df0 100644 --- a/packages/libdatadog/test/package-contents.test.js +++ b/packages/libdatadog/test/package-contents.test.js @@ -42,10 +42,10 @@ test('published packages contain only the intended artifacts', () => { 'WASM package must contain the inline-WASM JavaScript fallback') assert(wasmNames.includes('dist/remote-config/remote_config.js'), 'WASM package must contain the dedicated remote config artifact') - assert(wasmNames.includes('remote-config.js')) + assert.strictEqual(wasmNames.includes('remote-config.js'), false) assert(wasmNames.includes('remote-config.d.ts')) assert(names.includes('remote-config.js')) - assert(names.includes('remote-config.mjs')) + assert.strictEqual(names.includes('remote-config.mjs'), false) assert(names.includes('remote-config.d.ts')) assert.strictEqual(libdatadogWasm.name, '@datadog/libdatadog-wasm') assert.strictEqual( @@ -99,11 +99,13 @@ test('installed package uses its WASM dependency', () => { const requireInstalled = createRequire(path.join(installRoot, 'package.json')) const libdatadog = requireInstalled('@datadog/libdatadog') const explicitWasm = requireInstalled('@datadog/libdatadog/wasm') + const directRemoteConfig = requireInstalled('@datadog/libdatadog-wasm/remote-config') assert.strictEqual(libdatadog.RemoteConfigFetcher, undefined) assert.strictEqual(explicitWasm.RemoteConfigFetcher, undefined) const remoteConfig = requireInstalled('@datadog/libdatadog/remote-config') assert.strictEqual(typeof remoteConfig.RemoteConfigFetcher, 'function') + assert.strictEqual(remoteConfig.RemoteConfigFetcher, directRemoteConfig.RemoteConfigFetcher) assert.strictEqual(libdatadog.backend(), 'wasm') assert.strictEqual(explicitWasm.backend(), 'wasm') assert(libdatadog.zstd_compress(Buffer.alloc(16), 3) instanceof Uint8Array) @@ -137,11 +139,18 @@ function assertEsmImports (installRoot, environment) { RemoteConfigFetcher, setStorage, } from '@datadog/libdatadog/remote-config' + import directRemoteConfig, { + RemoteConfigFetcher as DirectRemoteConfigFetcher, + setStorage as setDirectStorage, + } from '@datadog/libdatadog-wasm/remote-config' assert.strictEqual(libdatadog.RemoteConfigFetcher, undefined) assert.strictEqual(wasm.RemoteConfigFetcher, undefined) assert.strictEqual(remoteConfig.RemoteConfigFetcher, RemoteConfigFetcher) assert.strictEqual(remoteConfig.setStorage, setStorage) + assert.strictEqual(directRemoteConfig.RemoteConfigFetcher, DirectRemoteConfigFetcher) + assert.strictEqual(directRemoteConfig.setStorage, setDirectStorage) + assert.strictEqual(RemoteConfigFetcher, DirectRemoteConfigFetcher) assert.strictEqual(backend(), 'wasm') assert.strictEqual(libdatadog.backend, backend) assert.strictEqual(libdatadog.createAgentlessExporter, createAgentlessExporter) diff --git a/packages/libdatadog/test/package.test.js b/packages/libdatadog/test/package.test.js index b880831..37ccf99 100644 --- a/packages/libdatadog/test/package.test.js +++ b/packages/libdatadog/test/package.test.js @@ -15,7 +15,16 @@ test('publishes the universal libdatadog package', () => { assert.strictEqual(packageJson.name, '@datadog/libdatadog') assert.strictEqual(packageJson.exports['./wasm'].require, './wasm.js') + assert.strictEqual(packageJson.exports['./remote-config'].import, './remote-config.js') assert.strictEqual(packageJson.exports['./remote-config'].require, './remote-config.js') + + const wasmPackageJson = JSON.parse(fs.readFileSync( + path.join(packageRoot, 'wasm', 'package.json'), + )) + assert.strictEqual( + wasmPackageJson.exports['./remote-config'].require, + './dist/remote-config/remote_config.js', + ) }) test('uses the libdatadog release version', () => { @@ -65,12 +74,45 @@ test('embeds dedicated remote config WASM below the size budgets', () => { }) test('requires an artifact name and output directory when inlining WASM', () => { - const result = spawnSync(process.execPath, [ - path.join(packageRoot, 'scripts', 'inline-wasm.js'), - ]) + const script = path.join(packageRoot, 'scripts', 'inline-wasm.js') - assert.notStrictEqual(result.status, 0) - assert.match(result.stderr.toString(), /usage: node scripts\/inline-wasm\.js/) + for (const scriptArguments of [[], ['fixture']]) { + const result = spawnSync(process.execPath, [script, ...scriptArguments]) + + assert.notStrictEqual(result.status, 0) + assert.match(result.stderr.toString(), /usage: node scripts\/inline-wasm\.js/) + } +}) + +test('inlines a named WASM artifact into its generated module', () => { + const outputDirectory = fs.mkdtempSync(path.join(packageRoot, '.inline-wasm-')) + const moduleName = 'fixture' + const wasm = Buffer.from('fixture WASM') + const loader = [ + `const wasmPath = \`\${__dirname}/${moduleName}_bg.wasm\`;`, + 'const wasmBytes = require(\'fs\').readFileSync(wasmPath);', + ].join('\n') + + try { + fs.writeFileSync(path.join(outputDirectory, `${moduleName}.js`), loader) + fs.writeFileSync(path.join(outputDirectory, `${moduleName}_bg.wasm`), wasm) + fs.writeFileSync(path.join(outputDirectory, '.gitignore'), '') + fs.writeFileSync(path.join(outputDirectory, 'package.json'), '{}') + + const result = spawnSync(process.execPath, [ + path.join(packageRoot, 'scripts', 'inline-wasm.js'), + moduleName, + path.relative(packageRoot, outputDirectory), + ]) + + assert.strictEqual(result.status, 0, result.stderr.toString()) + assert.deepStrictEqual(readInlineWasm(path.join(outputDirectory, `${moduleName}.js`)).wasm, wasm) + assert.strictEqual(fs.existsSync(path.join(outputDirectory, `${moduleName}_bg.wasm`)), false) + assert.strictEqual(fs.existsSync(path.join(outputDirectory, '.gitignore')), false) + assert.strictEqual(fs.existsSync(path.join(outputDirectory, 'package.json')), false) + } finally { + fs.rmSync(outputDirectory, { force: true, recursive: true }) + } }) /** diff --git a/packages/libdatadog/wasm/package.json b/packages/libdatadog/wasm/package.json index a0221e9..6687aac 100644 --- a/packages/libdatadog/wasm/package.json +++ b/packages/libdatadog/wasm/package.json @@ -6,9 +6,23 @@ "type": "commonjs", "main": "dist/libdatadog_wasm.js", "types": "dist/libdatadog_wasm.d.ts", + "exports": { + ".": { + "types": "./dist/libdatadog_wasm.d.ts", + "import": "./dist/libdatadog_wasm.js", + "require": "./dist/libdatadog_wasm.js", + "default": "./dist/libdatadog_wasm.js" + }, + "./remote-config": { + "types": "./remote-config.d.ts", + "import": "./dist/remote-config/remote_config.js", + "require": "./dist/remote-config/remote_config.js", + "default": "./dist/remote-config/remote_config.js" + }, + "./*": "./*" + }, "files": [ "dist/", - "remote-config.js", "remote-config.d.ts" ], "engines": { diff --git a/packages/libdatadog/wasm/remote-config.js b/packages/libdatadog/wasm/remote-config.js deleted file mode 100644 index 460b36b..0000000 --- a/packages/libdatadog/wasm/remote-config.js +++ /dev/null @@ -1,3 +0,0 @@ -'use strict' - -module.exports = require('./dist/remote-config/remote_config') diff --git a/scripts/check-dependencies.js b/scripts/check-dependencies.js index bf7f619..b02fe0d 100644 --- a/scripts/check-dependencies.js +++ b/scripts/check-dependencies.js @@ -8,7 +8,7 @@ const trees = [ { package: 'libdatadog-wasm', target: 'wasm32-unknown-unknown' }, { package: 'remote-config', target: 'wasm32-unknown-unknown' }, ] -// TODO: Remove these exceptions when libdd-remote-config aligns its TUF dependency versions. +// libdd-tuf 0.3.1 uses older dependency majors. Exact sets keep unrelated duplicates failing validation. const remoteConfigDuplicatePackages = new Map([ ['http', new Set(['0.2.12', '1.5.0'])], ['itoa', new Set(['0.4.8', '1.0.18'])], diff --git a/test/dependencies.js b/test/dependencies.js index af299a2..8ad2c6b 100644 --- a/test/dependencies.js +++ b/test/dependencies.js @@ -140,3 +140,33 @@ test('dependency validation scopes duplicate exceptions to remote config', () => }], ) }) + +test('dependency validation reports unexpected remote config duplicate versions', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'libdatadog-cargo-')) + const cargoPath = path.join(directory, 'cargo') + + try { + fs.writeFileSync(cargoPath, [ + '#!/usr/bin/env node', + `const output = process.argv.includes('remote-config')`, + String.raw` ? '0remote-config v0.1.0\n1syn v2.0.119\n1syn v3.0.4\n1syn v1.0.109\n'`, + String.raw` : '0fixture v1.0.0\n'`, + 'process.stdout.write(output)', + ].join('\n'), { mode: 0o755 }) + + const result = spawnSync(process.execPath, [ + path.join(__dirname, '..', 'scripts', 'check-dependencies.js'), + ], { + env: { + ...process.env, + PATH: `${directory}${path.delimiter}${process.env.PATH}`, + }, + }) + + assert.strictEqual(result.status, 1) + assert.match(result.stderr.toString(), /Dependencies with multiple versions found:/) + assert.match(result.stderr.toString(), /syn 2\.0\.119, 3\.0\.4, 1\.0\.109/) + } finally { + fs.rmSync(directory, { force: true, recursive: true }) + } +})