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 all builtin libraries #18131

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
30 changes: 28 additions & 2 deletions doc/api/esm.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,26 @@ 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
```

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.

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

```js
import { readFile } from 'fs';
readFile('./foo.txt', (err, body) => {
if (err) {
console.error(err);
} else {
Expand All @@ -105,6 +123,14 @@ fs.readFile('./foo.txt', (err, body) => {
});
```

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

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

fs.readFileSync === readFileSync;
```

## Loader hooks

<!-- type=misc -->
Expand Down
123 changes: 123 additions & 0 deletions lib/internal/bootstrap/loaders.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,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 +195,40 @@
'\n});'
];

const { isProxy } = internalBinding('types');
const {
apply: ReflectApply,
has: ReflectHas,
get: ReflectGet,
set: ReflectSet,
defineProperty: ReflectDefineProperty,
deleteProperty: ReflectDeleteProperty,
getOwnPropertyDescriptor: ReflectGetOwnPropertyDescriptor,
} = Reflect;
const {
toString: ObjectToString,
} = Object.prototype;
let isNative;
{
const { toString } = Function.prototype;
const re = toString.call(toString)
.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
.replace(/toString|(function ).*?(?=\\\()/g, '$1.*?');
const nativeRegExp = new RegExp(`^${re}$`);
isNative = (fn) => {
if (typeof fn === 'function') {
try {
if (nativeRegExp.test(toString.call(fn))) {
const { name } = fn;
if (typeof name !== 'string' || !/^bound /.test(name))
return !isProxy(fn);
}
} catch (e) {}
}
return false;
};
}

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

if (config.experimentalModules) {
this.exportKeys = Object.keys(this.exports);

const update = (property, value) => {
if (this.reflect !== undefined && this.exportKeys.includes(property))
Copy link
Member

Choose a reason for hiding this comment

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

is it ok that delete Array.prototype.includes can break this code?

If not, you could copy Array.prototype.includes to be an own property on this.exportKeys, perhaps?

(same question on has/get/set on collection instances)

Copy link
Member

@jdalton jdalton Apr 29, 2018

Choose a reason for hiding this comment

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

@ljharb There's another issue (here) for isolating internals to avoid ad hoc primordial scaffolding in each module. Trying to isolate includes, Object.keys, Reflect or other is probably out of scope for this specific PR (esp. since it's experimental and the issue is larger than this PR).

Copy link
Member

Choose a reason for hiding this comment

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

Fair point, just wanted to call it out :-)

this.reflect.exports[property].set(value);
};

const methodWrapMap = new WeakMap();

const wrap = (target, name, value) => {
if (typeof value !== 'function' || !isNative(value)) {
return value;
}

if (methodWrapMap.has(value))
return methodWrapMap.get(value);

const p = new Proxy(value, {
apply: (t, thisArg, args) => {
if (thisArg === proxy || (this.reflect !== undefined &&
this.reflect.namespace !== undefined &&
thisArg === this.reflect.namespace)) {
thisArg = target;
}
return ReflectApply(t, thisArg, args);
},
__proto__: null,
});

methodWrapMap.set(value, p);

return p;
};

const proxy = new Proxy(this.exports, {
set: (target, prop, value, receiver) => {
if (receiver === proxy || (this.reflect !== undefined &&
this.reflect.namespace !== undefined &&
receiver === this.reflect.namespace))
receiver = target;
if (ReflectSet(target, prop, value, receiver)) {
update(prop, ReflectGet(target, prop, receiver));
return true;
}
return false;
},
defineProperty: (target, prop, descriptor) => {
if (ReflectDefineProperty(target, prop, descriptor)) {
update(prop, ReflectGet(target, prop));
return true;
}
return false;
},
deleteProperty: (target, prop) => {
if (ReflectDeleteProperty(target, prop)) {
update(prop, undefined);
return true;
}
return false;
},
getOwnPropertyDescriptor: (target, prop) => {
const descriptor = ReflectGetOwnPropertyDescriptor(target, prop);
if (descriptor && ReflectHas(descriptor, 'value'))
descriptor.value = wrap(target, prop, descriptor.value);
return descriptor;
},
get: (target, prop, receiver) => {
if (receiver === proxy || (this.reflect !== undefined &&
this.reflect.namespace !== undefined &&
receiver === this.reflect.namespace)) {
receiver = target;
}
const value = ReflectGet(target, prop, receiver);
if (prop === Symbol.toStringTag &&
typeof target !== 'function' &&
typeof value !== 'string') {
const toStringTag = ObjectToString.call(target).slice(8, -1);
return toStringTag === 'Object' ? value : toStringTag;
}
return wrap(target, prop, value);
},
__proto__: null,
});
this.exports = proxy;
}

this.loaded = true;
} finally {
this.loading = false;
Expand Down
2 changes: 1 addition & 1 deletion lib/internal/bootstrap/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@
// If global console has the same method as inspector console,
// then wrap these two methods into one. Native wrapper will preserve
// the original stack.
wrappedConsole[key] = consoleCall.bind(wrappedConsole,
wrappedConsole[key] = consoleCall.bind(null,
originalConsole[key],
wrappedConsole[key],
config);
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);
return createDynamicModule(
[...module.exportKeys, 'default'], url, (reflect) => {
Copy link
Member

Choose a reason for hiding this comment

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

i'm not sure if the ordering matters here at all - is default always last?

https://tc39.github.io/ecma262/#sec-modulenamespacecreate step 7 suggests that all export keys, including "default" if present, should be alphabetically sorted. (i do see at least one test that validates the ordering, but i'm not sure if that test covers this code or not)

Copy link
Member Author

Choose a reason for hiding this comment

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

the order doesn't matter there actually as it just gets injected into a generated source text

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
42 changes: 42 additions & 0 deletions test/es-module/test-esm-live-binding.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Flags: --experimental-modules

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

import fs, { readFile } from 'fs';

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

delete fs.readFile;
assert.strictEqual(fs.readFile, undefined);
assert.strictEqual(readFile, undefined);

fs.readFile = fn;

assert.strictEqual(fs.readFile(), s);
assert.strictEqual(readFile(), s);

Reflect.deleteProperty(fs, 'readFile');

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

assert.strictEqual(fs.readFile(), s);
assert.strictEqual(readFile(), s);

Reflect.deleteProperty(fs, 'readFile');
assert.strictEqual(fs.readFile, undefined);
assert.strictEqual(readFile, undefined);

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

assert.strictEqual(fs.readFile(), s);
assert.strictEqual(readFile(), s);
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);
7 changes: 4 additions & 3 deletions test/fixtures/es-module-loaders/js-loader.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import _url from 'url';
import { URL } from 'url';
Copy link
Member

Choose a reason for hiding this comment

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

No need to import URL at all in Node.js v10.x :)

Copy link
Member Author

Choose a reason for hiding this comment

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

you never know where this might be backported to


const builtins = new Set(
Object.keys(process.binding('natives')).filter(str =>
/^(?!(?:internal|node|v8)\/)/.test(str))
)

const baseURL = new _url.URL('file://');
const baseURL = new URL('file://');
baseURL.pathname = process.cwd() + '/';

export function resolve (specifier, base = baseURL) {
Expand All @@ -15,7 +16,7 @@ export function resolve (specifier, base = baseURL) {
};
}
// load all dependencies as esm, regardless of file extension
const url = new _url.URL(specifier, base).href;
const url = new URL(specifier, base).href;
return {
url,
format: 'esm'
Expand Down