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: doc & validate source values for formats #32202

Closed
wants to merge 6 commits 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
35 changes: 26 additions & 9 deletions doc/api/esm.md
Original file line number Diff line number Diff line change
Expand Up @@ -1196,16 +1196,27 @@ export async function resolve(specifier, context, defaultResolve) {
> signature may change. Do not rely on the API described below.

The `getFormat` hook provides a way to define a custom method of determining how
a URL should be interpreted. This can be one of the following:
a URL should be interpreted. The `format` returned also affects what the
acceptable forms of source values are for a module when parsing. This can be one
of the following:

| `format` | Description |
| --- | --- |
| `'builtin'` | Load a Node.js builtin module |
| `'commonjs'` | Load a Node.js CommonJS module |
| `'dynamic'` | Use a [dynamic instantiate hook][] |
| `'json'` | Load a JSON file |
| `'module'` | Load a standard JavaScript module (ES module) |
| `'wasm'` | Load a WebAssembly module |
| `format` | Description | Acceptable Types For `source` Returned by `getSource` or `transformSource` |
| --- | --- | --- |
| `'builtin'` | Load a Node.js builtin module | Not applicable |
| `'commonjs'` | Load a Node.js CommonJS module | Not applicable |
| `'dynamic'` | Use a [dynamic instantiate hook][] | Not applicable |
| `'json'` | Load a JSON file | { [ArrayBuffer][], [string][], [TypedArray][] } |
| `'module'` | Load an ES module | { [ArrayBuffer][], [string][], [TypedArray][] } |
| `'wasm'` | Load a WebAssembly module | { [ArrayBuffer][], [string][], [TypedArray][] } |

Note: These types all correspond to classes defined in ECMAScript.

* The specific [ArrayBuffer][] object is a [SharedArrayBuffer][].
* The specific [string][] object is not the class constructor, but an instance.
* The specific [TypedArray][] object is a [Uint8Array][].

Note: If the source value of a text-based format (i.e., `'json'`, `'module'`) is
not a string, it will be converted to a string using [`util.TextDecoder`][].

```js
/**
Expand Down Expand Up @@ -1841,6 +1852,12 @@ success!
[`module.createRequire()`]: modules.html#modules_module_createrequire_filename
[`module.syncBuiltinESMExports()`]: modules.html#modules_module_syncbuiltinesmexports
[`transformSource` hook]: #esm_code_transformsource_code_hook
[ArrayBuffer]: http://www.ecma-international.org/ecma-262/6.0/#sec-arraybuffer-constructor
[SharedArrayBuffer]: https://tc39.es/ecma262/#sec-sharedarraybuffer-constructor
[string]: http://www.ecma-international.org/ecma-262/6.0/#sec-string-constructor
[TypedArray]: http://www.ecma-international.org/ecma-262/6.0/#sec-typedarray-objects
[Uint8Array]: http://www.ecma-international.org/ecma-262/6.0/#sec-uint8array
[`util.TextDecoder`]: util.html#util_class_util_textdecoder
bmeck marked this conversation as resolved.
Show resolved Hide resolved
[dynamic instantiate hook]: #esm_code_dynamicinstantiate_code_hook
[import an ES or CommonJS module for its side effects only]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#Import_a_module_for_its_side_effects_only
[special scheme]: https://url.spec.whatwg.org/#special-scheme
Expand Down
43 changes: 40 additions & 3 deletions lib/internal/modules/esm/translators.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ const {
StringPrototypeReplace,
} = primordials;

let _TYPES = null;
function lazyTypes() {
if (_TYPES !== null) return _TYPES;
return _TYPES = require('internal/util/types');
}

const {
stripBOM,
loadNativeModule
Expand All @@ -24,7 +30,10 @@ const createDynamicModule = require(
const { fileURLToPath, URL } = require('url');
const { debuglog } = require('internal/util/debuglog');
const { emitExperimentalWarning } = require('internal/util');
const { ERR_UNKNOWN_BUILTIN_MODULE } = require('internal/errors').codes;
const {
ERR_UNKNOWN_BUILTIN_MODULE,
ERR_INVALID_RETURN_PROPERTY_VALUE
} = require('internal/errors').codes;
const { maybeCacheSourceMap } = require('internal/source_map/source_map_cache');
const moduleWrap = internalBinding('module_wrap');
const { ModuleWrap } = moduleWrap;
Expand All @@ -37,6 +46,30 @@ const debug = debuglog('esm');
const translators = new SafeMap();
exports.translators = translators;

let DECODER = null;
function assertBufferSource(body, allowString, hookName) {
if (allowString && typeof body === 'string') {
return;
}
const { isArrayBufferView, isAnyArrayBuffer } = lazyTypes();
if (isArrayBufferView(body) || isAnyArrayBuffer(body)) {
return;
}
throw new ERR_INVALID_RETURN_PROPERTY_VALUE(
`${allowString ? 'string, ' : ''}array buffer, or typed array`,
hookName,
'source',
body
);
}

function stringify(body) {
if (typeof body === 'string') return body;
assertBufferSource(body, false, 'transformSource');
DECODER = DECODER === null ? new TextDecoder() : DECODER;
return DECODER.decode(body);
}

function errPath(url) {
const parsed = new URL(url);
if (parsed.protocol === 'file:') {
Expand Down Expand Up @@ -73,9 +106,10 @@ function initializeImportMeta(meta, { url }) {
translators.set('module', async function moduleStrategy(url) {
let { source } = await this._getSource(
url, { format: 'module' }, defaultGetSource);
source = `${source}`;
assertBufferSource(source, true, 'getSource');
({ source } = await this._transformSource(
source, { url, format: 'module' }, defaultTransformSource));
source = stringify(source);
maybeCacheSourceMap(url, source);
debug(`Translating StandardModule ${url}`);
const module = new ModuleWrap(url, undefined, source, 0, 0);
Expand Down Expand Up @@ -150,9 +184,10 @@ translators.set('json', async function jsonStrategy(url) {
}
let { source } = await this._getSource(
url, { format: 'json' }, defaultGetSource);
source = `${source}`;
assertBufferSource(source, true, 'getSource');
({ source } = await this._transformSource(
source, { url, format: 'json' }, defaultTransformSource));
source = stringify(source);
if (pathname) {
// A require call could have been called on the same file during loading and
// that resolves synchronously. To make sure we always return the identical
Expand Down Expand Up @@ -193,8 +228,10 @@ translators.set('wasm', async function(url) {
emitExperimentalWarning('Importing Web Assembly modules');
let { source } = await this._getSource(
url, { format: 'wasm' }, defaultGetSource);
assertBufferSource(source, false, 'getSource');
({ source } = await this._transformSource(
source, { url, format: 'wasm' }, defaultTransformSource));
assertBufferSource(source, false, 'transformSource');
debug(`Translating WASMModule ${url}`);
let compiled;
try {
Expand Down
50 changes: 50 additions & 0 deletions test/es-module/test-esm-loader-stringify-text.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Flags: --experimental-loader ./test/fixtures/es-module-loaders/string-sources.mjs
import { mustCall, mustNotCall } from '../common/index.mjs';
import assert from 'assert';

import('test:Array').then(
mustNotCall('Should not accept Arrays'),
mustCall((e) => {
assert.strictEqual(e.code, 'ERR_INVALID_RETURN_PROPERTY_VALUE');
})
);
import('test:ArrayBuffer').then(
mustCall(),
mustNotCall('Should accept ArrayBuffers'),
);
import('test:null').then(
mustNotCall('Should not accept null'),
mustCall((e) => {
assert.strictEqual(e.code, 'ERR_INVALID_RETURN_PROPERTY_VALUE');
})
);
import('test:Object').then(
mustNotCall('Should not stringify or valueOf Objects'),
mustCall((e) => {
assert.strictEqual(e.code, 'ERR_INVALID_RETURN_PROPERTY_VALUE');
})
);
import('test:SharedArrayBuffer').then(
mustCall(),
mustNotCall('Should accept SharedArrayBuffers'),
);
import('test:string').then(
mustCall(),
mustNotCall('Should accept strings'),
);
import('test:String').then(
mustNotCall('Should not accept wrapper Strings'),
mustCall((e) => {
assert.strictEqual(e.code, 'ERR_INVALID_RETURN_PROPERTY_VALUE');
})
);
import('test:Uint8Array').then(
mustCall(),
mustNotCall('Should accept Uint8Arrays'),
);
import('test:undefined').then(
mustNotCall('Should not accept undefined'),
mustCall((e) => {
assert.strictEqual(e.code, 'ERR_INVALID_RETURN_PROPERTY_VALUE');
})
);
30 changes: 30 additions & 0 deletions test/fixtures/es-module-loaders/string-sources.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const SOURCES = {
__proto__: null,
'test:Array': ['1', '2'], // both `1,2` and `12` are valid ESM
'test:ArrayBuffer': new ArrayBuffer(0),
'test:null': null,
'test:Object': {},
'test:SharedArrayBuffer': new SharedArrayBuffer(0),
'test:string': '',
'test:String': new String(''),
'test:Uint8Array': new Uint8Array(0),
'test:undefined': undefined,
}
export function resolve(specifier, context, defaultFn) {
if (specifier.startsWith('test:')) {
return { url: specifier };
}
return defaultFn(specifier, context);
}
export function getFormat(href, context, defaultFn) {
if (href.startsWith('test:')) {
return { format: 'module' };
}
return defaultFn(href, context);
}
export function getSource(href, context, defaultFn) {
if (href.startsWith('test:')) {
return { source: SOURCES[href] };
}
return defaultFn(href, context);
}
5 changes: 4 additions & 1 deletion test/fixtures/es-module-loaders/transform-source.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
export async function transformSource(
source, { url, format }, defaultTransformSource) {
if (source && source.replace) {
if (format === 'module') {
if (typeof source !== 'string') {
source = new TextDecoder().decode(source);
}
return {
source: source.replace(`'A message';`, `'A message'.toUpperCase();`)
};
Expand Down