Skip to content

fix(NODE-6074): Removes top-level await in bson with separate node and browser ESM bundles #749

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 2 commits into from
Apr 24, 2025
Merged
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
70 changes: 36 additions & 34 deletions etc/rollup/rollup-plugin-require-rewriter/require_rewriter.mjs
Original file line number Diff line number Diff line change
@@ -1,44 +1,46 @@
import MagicString from 'magic-string';

const CRYPTO_IMPORT_ESM_SRC = `const nodejsRandomBytes = await (async () => {
const CRYPTO_IMPORT_ESM_SRC = `import { randomBytes as nodejsRandomBytes } from 'crypto';`;
const BROWSER_ESM_SRC = `const nodejsRandomBytes = nodejsMathRandomBytes;`;
const CODE_TO_REPLACE = `const nodejsRandomBytes = (() => {
try {
return (await import('crypto')).randomBytes;`;

export class RequireRewriter {
/**
* Take the compiled source code input; types are expected to already have been removed
* Look for the function that depends on crypto, replace it with a top-level await
* and dynamic import for the crypto module.
*
* @param {string} code - source code of the module being transformed
* @param {string} id - module id (usually the source file name)
* @returns {{ code: string; map: import('magic-string').SourceMap }}
*/
transform(code, id) {
if (!id.includes('node_byte_utils')) {
return;
return require('crypto').randomBytes;
}
if (!code.includes('const nodejsRandomBytes')) {
throw new Error(`Unexpected! 'const nodejsRandomBytes' is missing from ${id}`);
catch {
return nodejsMathRandomBytes;
}
})();`;

const start = code.indexOf('const nodejsRandomBytes');
const endString = `return require('crypto').randomBytes;`;
const end = code.indexOf(endString) + endString.length;
export function requireRewriter({ isBrowser = false } = {}) {
return {
/**
* Take the compiled source code input; types are expected to already have been removed
* Look for the function that depends on crypto, replace it with a top-level await
* and dynamic import for the crypto module.
*
* @param {string} code - source code of the module being transformed
* @param {string} id - module id (usually the source file name)
* @returns {{ code: string; map: import('magic-string').SourceMap }}
*/
transform(code, id) {
if (!id.includes('node_byte_utils')) {
return;
}
const start = code.indexOf(CODE_TO_REPLACE);
if (start === -1) {
throw new Error(`Unexpected! Code meant to be replaced is missing from ${id}`);
}

if (start < 0 || end < 0) {
throw new Error(
`Unexpected! 'const nodejsRandomBytes' or 'return require('crypto').randomBytes;' not found`
);
}
const end = start + CODE_TO_REPLACE.length;

// MagicString lets us edit the source code and still generate an accurate source map
const magicString = new MagicString(code);
magicString.overwrite(start, end, CRYPTO_IMPORT_ESM_SRC);
// MagicString lets us edit the source code and still generate an accurate source map
const magicString = new MagicString(code);
magicString.overwrite(start, end, isBrowser ? BROWSER_ESM_SRC : CRYPTO_IMPORT_ESM_SRC);

return {
code: magicString.toString(),
map: magicString.generateMap({ hires: true })
};
}
return {
code: magicString.toString(),
map: magicString.generateMap({ hires: true })
};
}
};
}
16 changes: 8 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
@@ -75,18 +75,18 @@
"native": false
},
"main": "./lib/bson.cjs",
"module": "./lib/bson.mjs",
"module": "./lib/bson.node.mjs",
"exports": {
"import": {
"browser": {
"types": "./bson.d.ts",
"default": "./lib/bson.mjs"
},
"require": {
"types": "./bson.d.ts",
"default": "./lib/bson.cjs"
"default": "./lib/bson.browser.mjs"
},
"react-native": "./lib/bson.rn.cjs",
"browser": "./lib/bson.mjs"
"default": {
"types": "./bson.d.ts",
"import": "./lib/bson.node.mjs",
"require": "./lib/bson.cjs"
}
},
"compass:exports": {
"import": "./lib/bson.cjs",
19 changes: 16 additions & 3 deletions rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { nodeResolve } from '@rollup/plugin-node-resolve';
import typescript from '@rollup/plugin-typescript';
import { RequireRewriter } from './etc/rollup/rollup-plugin-require-rewriter/require_rewriter.mjs';
import { requireRewriter } from './etc/rollup/rollup-plugin-require-rewriter/require_rewriter.mjs';
import { RequireVendor } from './etc/rollup/rollup-plugin-require-vendor/require_vendor.mjs';

/** @type {typescript.RollupTypescriptOptions} */
@@ -55,9 +55,22 @@ const config = [
},
{
input,
plugins: [typescript(tsConfig), new RequireRewriter(), nodeResolve({ resolveOnly: [] })],
plugins: [
typescript(tsConfig),
requireRewriter({ isBrowser: true }),
nodeResolve({ resolveOnly: [] })
],
output: {
file: 'lib/bson.mjs',
file: 'lib/bson.browser.mjs',
format: 'esm',
sourcemap: true
}
},
{
input,
plugins: [typescript(tsConfig), requireRewriter(), nodeResolve({ resolveOnly: [] })],
output: {
file: 'lib/bson.node.mjs',
format: 'esm',
sourcemap: true
}
3 changes: 1 addition & 2 deletions test/bundling/webpack/readme.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Webpack BSON setup example

In order to use BSON with webpack there are two changes beyond the default config file needed:
- Set `experiments: { topLevelAwait: true }` in the top-level config object
In order to use BSON with webpack there is one change beyond the default config file needed:
- Set `resolve: { fallback: { crypto: false } }` in the top-level config object

## Testing
1 change: 0 additions & 1 deletion test/bundling/webpack/webpack.config.js
Original file line number Diff line number Diff line change
@@ -14,7 +14,6 @@ const config = {
// Add your plugins here
// Learn more about plugins from https://webpack.js.org/configuration/plugins/
],
experiments: { topLevelAwait: true },
module: {
rules: [
{
25 changes: 17 additions & 8 deletions test/load_bson.js
Original file line number Diff line number Diff line change
@@ -52,19 +52,28 @@ function loadCJSModuleBSON(globals) {
return { context, exports: context.exports };
}

async function loadESModuleBSON(globals) {
const filename = path.resolve(__dirname, `../lib/bson.mjs`);
async function loadESModuleBSON() {
const filename = path.resolve(__dirname, `../lib/bson.node.mjs`);
const code = await fs.promises.readFile(filename, { encoding: 'utf8' });

const context = vm.createContext({
...commonGlobals,
// Putting this last to allow caller to override default globals
...globals
});
const context = vm.createContext(commonGlobals);

const bsonMjs = new vm.SourceTextModule(code, { context });
const cryptoModule = new vm.SyntheticModule(
['randomBytes'],
function () {
this.setExport('randomBytes', crypto.randomBytes);
},
{ context }
);

await cryptoModule.link(() => {});

await bsonMjs.link(() => {});
await bsonMjs.link(specifier => {
if (specifier === 'crypto') {
return cryptoModule;
}
});
await bsonMjs.evaluate();

return { context, exports: bsonMjs.namespace };
4 changes: 2 additions & 2 deletions test/node/byte_utils.test.ts
Original file line number Diff line number Diff line change
@@ -694,11 +694,11 @@ describe('ByteUtils', () => {
});
});

describe('nodejs es module environment dynamically imports crypto', function () {
describe('nodejs es module environment imports crypto', function () {
let bsonImportedFromESMMod;

beforeEach(async function () {
const { exports } = await loadESModuleBSON({});
const { exports } = await loadESModuleBSON();
bsonImportedFromESMMod = exports;
});

16 changes: 8 additions & 8 deletions test/node/exports.test.ts
Original file line number Diff line number Diff line change
@@ -69,31 +69,31 @@ describe('bson entrypoint', () => {

it('maintains the order of keys in exports conditions', async () => {
expect(pkg).property('exports').is.a('object');
expect(pkg).nested.property('exports.import').is.a('object');
expect(pkg).nested.property('exports.require').is.a('object');
expect(pkg).nested.property('exports.browser').is.a('object');
expect(pkg).nested.property('exports.default').is.a('object');

expect(
Object.keys(pkg.exports),
'Order matters in the exports fields. import/require need to proceed the "bundler" targets (RN/browser) and react-native MUST proceed browser'
).to.deep.equal(['import', 'require', 'react-native', 'browser']);
).to.deep.equal(['browser', 'react-native', 'default']);

// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-7.html#packagejson-exports-imports-and-self-referencing
expect(
Object.keys(pkg.exports.import),
Object.keys(pkg.exports.browser),
'TS docs say that `types` should ALWAYS proceed `default`'
).to.deep.equal(['types', 'default']);
expect(
Object.keys(pkg.exports.require),
Object.keys(pkg.exports.default),
'TS docs say that `types` should ALWAYS proceed `default`'
).to.deep.equal(['types', 'default']);
).to.deep.equal(['types', 'import', 'require']);

expect(Object.keys(pkg['compass:exports'])).to.deep.equal(['import', 'require']);
});

it('has the equivalent "bson.d.ts" value for all "types" specifiers', () => {
expect(pkg).property('types', 'bson.d.ts');
expect(pkg).nested.property('exports.import.types', './bson.d.ts');
expect(pkg).nested.property('exports.require.types', './bson.d.ts');
expect(pkg).nested.property('exports.browser.types', './bson.d.ts');
expect(pkg).nested.property('exports.default.types', './bson.d.ts');
});
});
});