Skip to content

Commit

Permalink
Makes separate browser & node ESM bundles to avoid top async/await
Browse files Browse the repository at this point in the history
  • Loading branch information
lourd committed Feb 13, 2025
1 parent b5ad49a commit 0950224
Show file tree
Hide file tree
Showing 6 changed files with 87 additions and 63 deletions.
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 })
};
}
};
}
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
"import": "./lib/bson.browser.mjs"
},
"require": {
"node": {
"types": "./bson.d.ts",
"default": "./lib/bson.cjs"
"import": "./lib/bson.node.mjs",
"require": "./lib/bson.cjs"
},
"react-native": "./lib/bson.rn.cjs",
"browser": "./lib/bson.mjs"
"react-native": "./lib/bson.rn.cjs"
},
"compass:exports": {
"import": "./lib/bson.cjs",
Expand Down
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} */
Expand Down Expand Up @@ -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
}
Expand Down
25 changes: 17 additions & 8 deletions test/load_bson.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
4 changes: 2 additions & 2 deletions test/node/byte_utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});

Expand Down
18 changes: 9 additions & 9 deletions test/node/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.node').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', 'node', 'react-native']);

// 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']);
).to.deep.equal(['types', 'import']);
expect(
Object.keys(pkg.exports.require),
Object.keys(pkg.exports.node),
'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.node.types', './bson.d.ts');
});
});
});

0 comments on commit 0950224

Please sign in to comment.