Skip to content
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

esm: provide named exports for builtin libs #20403

Closed
wants to merge 1 commit into from
Closed
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
33 changes: 30 additions & 3 deletions doc/api/esm.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,16 +95,43 @@ When loaded via `import` these modules will provide a single `default` export
representing the value of `module.exports` at the time they finished evaluating.

```js
import fs from 'fs';
fs.readFile('./foo.txt', (err, body) => {
// foo.js
module.exports = { one: 1 };

// bar.js
import foo from './foo.js';
foo.one === 1; // true
Copy link
Contributor

Choose a reason for hiding this comment

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

Ignorable nit: as this is a file, not a REPL, maybe add assert() or console.log() here?

```

Builtin modules will provide named exports of their public API, as well as a
default export which can be used for, among other things, modifying the named
exports. Named exports of builtin modules are updated when the corresponding
exports property is accessed, redefined, or deleted.

```js
import EventEmitter from 'events';
const e = new EventEmitter();
```

```js
import { readFile } from 'fs';
readFile('./foo.txt', (err, source) => {
if (err) {
console.error(err);
} else {
console.log(body);
console.log(source);
}
});
```

```js
import fs, { readFileSync } from 'fs';

fs.readFileSync = () => Buffer.from('Hello, ESM');

fs.readFileSync === readFileSync;
```

## Loader hooks

<!-- type=misc -->
Expand Down
84 changes: 81 additions & 3 deletions lib/internal/bootstrap/loaders.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,26 @@

(function bootstrapInternalLoaders(process, getBinding, getLinkedBinding,
getInternalBinding) {
const {
apply: ReflectApply,
deleteProperty: ReflectDeleteProperty,
get: ReflectGet,
getOwnPropertyDescriptor: ReflectGetOwnPropertyDescriptor,
has: ReflectHas,
set: ReflectSet,
} = Reflect;
const {
prototype: {
hasOwnProperty: ObjectHasOwnProperty,
},
create: ObjectCreate,
defineProperty: ObjectDefineProperty,
keys: ObjectKeys,
} = Object;

// Set up process.moduleLoadList
const moduleLoadList = [];
Object.defineProperty(process, 'moduleLoadList', {
ObjectDefineProperty(process, 'moduleLoadList', {
value: moduleLoadList,
configurable: true,
enumerable: true,
Expand All @@ -53,7 +69,7 @@

// Set up process.binding() and process._linkedBinding()
{
const bindingObj = Object.create(null);
const bindingObj = ObjectCreate(null);

process.binding = function binding(module) {
module = String(module);
Expand All @@ -77,7 +93,7 @@
// Set up internalBinding() in the closure
let internalBinding;
{
const bindingObj = Object.create(null);
const bindingObj = ObjectCreate(null);
internalBinding = function internalBinding(module) {
let mod = bindingObj[module];
if (typeof mod !== 'object') {
Expand All @@ -95,6 +111,8 @@
this.filename = `${id}.js`;
this.id = id;
this.exports = {};
this.reflect = undefined;
this.exportKeys = undefined;
this.loaded = false;
this.loading = false;
}
Expand Down Expand Up @@ -193,6 +211,12 @@
'\n});'
];

const getOwn = (target, property, receiver) => {
return ReflectApply(ObjectHasOwnProperty, target, [property]) ?
ReflectGet(target, property, receiver) :
undefined;
};

NativeModule.prototype.compile = function() {
let source = NativeModule.getSource(this.id);
source = NativeModule.wrap(source);
Expand All @@ -208,6 +232,60 @@
NativeModule.require;
fn(this.exports, requireFn, this, process);

if (config.experimentalModules && !NativeModule.isInternal(this.id)) {
this.exportKeys = ObjectKeys(this.exports);

const update = (property, value) => {
if (this.reflect !== undefined &&
ReflectApply(ObjectHasOwnProperty,
this.reflect.exports, [property]))
this.reflect.exports[property].set(value);
};

const handler = {
__proto__: null,
defineProperty: (target, prop, descriptor) => {
// Use `Object.defineProperty` instead of `Reflect.defineProperty`
// to throw the appropriate error if something goes wrong.
ObjectDefineProperty(target, prop, descriptor);
if (typeof descriptor.get === 'function' &&
!ReflectHas(handler, 'get')) {
handler.get = (target, prop, receiver) => {
const value = ReflectGet(target, prop, receiver);
if (ReflectApply(ObjectHasOwnProperty, target, [prop]))
update(prop, value);
return value;
};
}
update(prop, getOwn(target, prop));
return true;
},
deleteProperty: (target, prop) => {
if (ReflectDeleteProperty(target, prop)) {
update(prop, undefined);
return true;
}
return false;
},
set: (target, prop, value, receiver) => {
const descriptor = ReflectGetOwnPropertyDescriptor(target, prop);
if (ReflectSet(target, prop, value, receiver)) {
if (descriptor && typeof descriptor.set === 'function') {
for (const key of this.exportKeys) {
update(key, getOwn(target, key, receiver));
}
} else {
update(prop, getOwn(target, prop, receiver));
}
return true;
}
return false;
}
};

this.exports = new Proxy(this.exports, handler);
}

this.loaded = true;
} finally {
this.loading = false;
Expand Down
3 changes: 2 additions & 1 deletion lib/internal/modules/esm/create_dynamic_module.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@ const createDynamicModule = (exports, url = '', evaluate) => {
const module = new ModuleWrap(reexports, `${url}`);
module.link(async () => reflectiveModule);
module.instantiate();
reflect.namespace = module.namespace();
return {
module,
reflect
reflect,
};
};

Expand Down
17 changes: 12 additions & 5 deletions lib/internal/modules/esm/translators.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,18 @@ translators.set('cjs', async (url) => {
// through normal resolution
translators.set('builtin', async (url) => {
debug(`Translating BuiltinModule ${url}`);
return createDynamicModule(['default'], url, (reflect) => {
debug(`Loading BuiltinModule ${url}`);
const exports = NativeModule.require(url.slice(5));
reflect.exports.default.set(exports);
});
// slice 'node:' scheme
const id = url.slice(5);
NativeModule.require(id);
const module = NativeModule.getCached(id);
Copy link
Contributor

Choose a reason for hiding this comment

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

const module = NativeModule.require(id) should work here and avoid a double lookup :)

Copy link
Member Author

Choose a reason for hiding this comment

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

getCached returns the entire module object, require just gives the exports object

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah right, I see, we can reconsider the interface if necessary then for future work.

return createDynamicModule(
[...module.exportKeys, 'default'], url, (reflect) => {
debug(`Loading BuiltinModule ${url}`);
module.reflect = reflect;
for (const key of module.exportKeys)
reflect.exports[key].set(module.exports[key]);
reflect.exports.default.set(module.exports);
});
});

// Strategy for loading a node native module
Expand Down
1 change: 1 addition & 0 deletions test/es-module/test-esm-dynamic-import.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ function expectFsNamespace(result) {
Promise.resolve(result)
.then(common.mustCall(ns => {
assert.strictEqual(typeof ns.default.writeFile, 'function');
assert.strictEqual(typeof ns.writeFile, 'function');
}));
}

Expand Down
143 changes: 143 additions & 0 deletions test/es-module/test-esm-live-binding.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Flags: --experimental-modules

import '../common';
import assert from 'assert';

import fs, { readFile, readFileSync } from 'fs';
import events, { defaultMaxListeners } from 'events';
import util from 'util';

const readFileDescriptor = Reflect.getOwnPropertyDescriptor(fs, 'readFile');
const readFileSyncDescriptor =
Reflect.getOwnPropertyDescriptor(fs, 'readFileSync');

const s = Symbol();
const fn = () => s;

Reflect.deleteProperty(fs, 'readFile');

assert.deepStrictEqual([fs.readFile, readFile], [undefined, undefined]);

fs.readFile = fn;

assert.deepStrictEqual([fs.readFile(), readFile()], [s, s]);

Reflect.defineProperty(fs, 'readFile', {
value: fn,
configurable: true,
writable: true,
});

assert.deepStrictEqual([fs.readFile(), readFile()], [s, s]);

Reflect.deleteProperty(fs, 'readFile');

assert.deepStrictEqual([fs.readFile, readFile], [undefined, undefined]);

let count = 0;

Reflect.defineProperty(fs, 'readFile', {
get() { return count; },
configurable: true,
});

count++;

assert.deepStrictEqual([readFile, fs.readFile, readFile], [0, 1, 1]);

let otherValue;

Reflect.defineProperty(fs, 'readFile', { // eslint-disable-line accessor-pairs
set(value) {
Reflect.deleteProperty(fs, 'readFile');
otherValue = value;
},
configurable: true,
});

Reflect.defineProperty(fs, 'readFileSync', {
get() {
return otherValue;
},
configurable: true,
});

fs.readFile = 2;

assert.deepStrictEqual([readFile, readFileSync], [undefined, 2]);

Reflect.defineProperty(fs, 'readFile', readFileDescriptor);
Reflect.defineProperty(fs, 'readFileSync', readFileSyncDescriptor);

const originDefaultMaxListeners = events.defaultMaxListeners;
const utilProto = util.__proto__; // eslint-disable-line no-proto

count = 0;

Reflect.defineProperty(Function.prototype, 'defaultMaxListeners', {
configurable: true,
enumerable: true,
get: function() { return ++count; },
set: function(v) {
Reflect.defineProperty(this, 'defaultMaxListeners', {
configurable: true,
enumerable: true,
writable: true,
value: v,
});
},
});

assert.strictEqual(defaultMaxListeners, originDefaultMaxListeners);
assert.strictEqual(events.defaultMaxListeners, originDefaultMaxListeners);

assert.strictEqual(++events.defaultMaxListeners,
originDefaultMaxListeners + 1);

assert.strictEqual(defaultMaxListeners, originDefaultMaxListeners + 1);
assert.strictEqual(Function.prototype.defaultMaxListeners, 1);

Function.prototype.defaultMaxListeners = 'foo';

assert.strictEqual(Function.prototype.defaultMaxListeners, 'foo');
assert.strictEqual(events.defaultMaxListeners, originDefaultMaxListeners + 1);
assert.strictEqual(defaultMaxListeners, originDefaultMaxListeners + 1);

count = 0;

const p = {
get foo() { return ++count; },
set foo(v) {
Reflect.defineProperty(this, 'foo', {
configurable: true,
enumerable: true,
writable: true,
value: v,
});
},
};

util.__proto__ = p; // eslint-disable-line no-proto

assert.strictEqual(util.foo, 1);

util.foo = 'bar';

assert.strictEqual(count, 1);
assert.strictEqual(util.foo, 'bar');
assert.strictEqual(p.foo, 2);

p.foo = 'foo';

assert.strictEqual(p.foo, 'foo');

events.defaultMaxListeners = originDefaultMaxListeners;
util.__proto__ = utilProto; // eslint-disable-line no-proto

Reflect.deleteProperty(util, 'foo');
Reflect.deleteProperty(Function.prototype, 'defaultMaxListeners');

assert.throws(
() => Object.defineProperty(events, 'defaultMaxListeners', { value: 3 }),
/TypeError: Cannot redefine/
);
10 changes: 9 additions & 1 deletion test/es-module/test-esm-namespace.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,13 @@
import '../common';
import * as fs from 'fs';
import assert from 'assert';
import Module from 'module';

assert.deepStrictEqual(Object.keys(fs), ['default']);
const keys = Object.entries(
Object.getOwnPropertyDescriptors(new Module().require('fs')))
.filter(([name, d]) => d.enumerable)
.map(([name]) => name)
.concat('default')
.sort();

assert.deepStrictEqual(Object.keys(fs).sort(), keys);
Loading