esm: support loading data URLs

Co-Authored-By: Jan Olaf Krems <jan.krems@gmail.com>

PR-URL: https://github.com/nodejs/node/pull/28614
Reviewed-By: Jan Krems <jan.krems@gmail.com>
This commit is contained in:
Bradley Farias 2019-07-09 16:03:07 -05:00
parent 317fa3a757
commit 9fd9efa492
5 changed files with 179 additions and 29 deletions

View File

@ -312,13 +312,38 @@ There are four types of specifiers:
Bare specifiers, and the bare specifier portion of deep import specifiers, are
strings; but everything else in a specifier is a URL.
Only `file://` URLs are supported. A specifier like
Only `file:` and `data:` URLs are supported. A specifier like
`'https://example.com/app.js'` may be supported by browsers but it is not
supported in Node.js.
Specifiers may not begin with `/` or `//`. These are reserved for potential
future use. The root of the current volume may be referenced via `file:///`.
#### `data:` Imports
<!-- YAML
added: REPLACEME
-->
[`data:` URLs][] are supported for importing with the following MIME types:
* `text/javascript` for ES Modules
* `application/json` for JSON
* `application/wasm` for WASM.
`data:` URLs only resolve [_Bare specifiers_][Terminology] for builtin modules
and [_Absolute specifiers_][Terminology]. Resolving
[_Relative specifiers_][Terminology] will not work because `data:` is not a
[special scheme][]. For example, attempting to load `./foo`
from `data:text/javascript,import "./foo";` will fail to resolve since there
is no concept of relative resolution for `data:` URLs. An example of a `data:`
URLs being used is:
```mjs
import 'data:text/javascript,console.log("hello!");'
import _ from 'data:application/json,"world!"'
```
## import.meta
* {Object}
@ -869,6 +894,8 @@ $ node --experimental-modules --es-module-specifier-resolution=node index
success!
```
[Terminology]: #esm_terminology
[`data:` URLs]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
[`export`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export
[`import`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
[`import()`]: #esm_import-expressions
@ -877,6 +904,7 @@ success!
[CommonJS]: modules.html
[ECMAScript-modules implementation]: https://github.com/nodejs/modules/blob/master/doc/plan-for-new-modules-implementation.md
[Node.js EP for ES Modules]: https://github.com/nodejs/node-eps/blob/master/002-es-modules.md
[special scheme]: https://url.spec.whatwg.org/#special-scheme
[WHATWG JSON modules specification]: https://html.spec.whatwg.org/#creating-a-json-module-script
[ES Module Integration Proposal for Web Assembly]: https://github.com/webassembly/esm-integration
[dynamic instantiate hook]: #esm_dynamic_instantiate_hook

View File

@ -12,7 +12,7 @@ const typeFlag = getOptionValue('--input-type');
const experimentalWasmModules = getOptionValue('--experimental-wasm-modules');
const { resolve: moduleWrapResolve,
getPackageType } = internalBinding('module_wrap');
const { pathToFileURL, fileURLToPath } = require('internal/url');
const { URL, pathToFileURL, fileURLToPath } = require('internal/url');
const { ERR_INPUT_TYPE_NOT_ALLOWED,
ERR_UNKNOWN_FILE_EXTENSION } = require('internal/errors').codes;
@ -45,12 +45,32 @@ if (experimentalWasmModules)
extensionFormatMap['.wasm'] = legacyExtensionFormatMap['.wasm'] = 'wasm';
function resolve(specifier, parentURL) {
try {
const parsed = new URL(specifier);
if (parsed.protocol === 'data:') {
const [ , mime ] = /^([^/]+\/[^;,]+)(;base64)?,/.exec(parsed.pathname) || [ null, null, null ];
const format = ({
'__proto__': null,
'text/javascript': 'module',
'application/json': 'json',
'application/wasm': experimentalWasmModules ? 'wasm' : null
})[mime] || null;
return {
url: specifier,
format
};
}
} catch {}
if (NativeModule.canBeRequiredByUsers(specifier)) {
return {
url: specifier,
format: 'builtin'
};
}
if (parentURL && parentURL.startsWith('data:')) {
// This is gonna blow up, we want the error
new URL(specifier, parentURL);
}
const isMain = parentURL === undefined;
if (isMain)

View File

@ -102,9 +102,12 @@ class Loader {
}
}
if (format !== 'dynamic' && !url.startsWith('file:'))
if (format !== 'dynamic' &&
!url.startsWith('file:') &&
!url.startsWith('data:')
)
throw new ERR_INVALID_RETURN_PROPERTY(
'file: url', 'loader resolve', 'url', url
'file: or data: url', 'loader resolve', 'url', url
);
return { url, format };

View File

@ -9,6 +9,8 @@ const {
StringPrototype
} = primordials;
const { Buffer } = require('buffer');
const {
stripBOM,
loadNativeModule
@ -23,6 +25,8 @@ const { debuglog } = require('internal/util/debuglog');
const { promisify } = require('internal/util');
const esmLoader = require('internal/process/esm_loader');
const {
ERR_INVALID_URL,
ERR_INVALID_URL_SCHEME,
ERR_UNKNOWN_BUILTIN_MODULE
} = require('internal/errors').codes;
const readFileAsync = promisify(fs.readFile);
@ -33,6 +37,31 @@ const debug = debuglog('esm');
const translators = new SafeMap();
exports.translators = translators;
const DATA_URL_PATTERN = /^[^/]+\/[^,;]+(;base64)?,([\s\S]*)$/;
function getSource(url) {
const parsed = new URL(url);
if (parsed.protocol === 'file:') {
return readFileAsync(parsed);
} else if (parsed.protocol === 'data:') {
const match = DATA_URL_PATTERN.exec(parsed.pathname);
if (!match) {
throw new ERR_INVALID_URL(url);
}
const [ , base64, body ] = match;
return Buffer.from(body, base64 ? 'base64' : 'utf8');
} else {
throw new ERR_INVALID_URL_SCHEME(['file', 'data']);
}
}
function errPath(url) {
const parsed = new URL(url);
if (parsed.protocol === 'file:') {
return fileURLToPath(parsed);
}
return url;
}
function initializeImportMeta(meta, { url }) {
meta.url = url;
}
@ -44,7 +73,7 @@ async function importModuleDynamically(specifier, { url }) {
// Strategy for loading a standard JavaScript module
translators.set('module', async function moduleStrategy(url) {
const source = `${await readFileAsync(new URL(url))}`;
const source = `${await getSource(url)}`;
debug(`Translating StandardModule ${url}`);
const { ModuleWrap, callbackMap } = internalBinding('module_wrap');
const module = new ModuleWrap(source, url);
@ -111,26 +140,32 @@ translators.set('builtin', async function builtinStrategy(url) {
translators.set('json', async function jsonStrategy(url) {
debug(`Translating JSONModule ${url}`);
debug(`Loading JSONModule ${url}`);
const pathname = fileURLToPath(url);
const modulePath = isWindows ?
StringPrototype.replace(pathname, winSepRegEx, '\\') : pathname;
let module = CJSModule._cache[modulePath];
if (module && module.loaded) {
const exports = module.exports;
return createDynamicModule([], ['default'], url, (reflect) => {
reflect.exports.default.set(exports);
});
const pathname = url.startsWith('file:') ? fileURLToPath(url) : null;
let modulePath;
let module;
if (pathname) {
modulePath = isWindows ?
StringPrototype.replace(pathname, winSepRegEx, '\\') : pathname;
module = CJSModule._cache[modulePath];
if (module && module.loaded) {
const exports = module.exports;
return createDynamicModule([], ['default'], url, (reflect) => {
reflect.exports.default.set(exports);
});
}
}
const content = await readFileAsync(pathname, 'utf-8');
// 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
// export, we have to check again if the module already exists or not.
module = CJSModule._cache[modulePath];
if (module && module.loaded) {
const exports = module.exports;
return createDynamicModule(['default'], url, (reflect) => {
reflect.exports.default.set(exports);
});
const content = `${await getSource(url)}`;
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
// export, we have to check again if the module already exists or not.
module = CJSModule._cache[modulePath];
if (module && module.loaded) {
const exports = module.exports;
return createDynamicModule(['default'], url, (reflect) => {
reflect.exports.default.set(exports);
});
}
}
try {
const exports = JsonParse(stripBOM(content));
@ -143,10 +178,12 @@ translators.set('json', async function jsonStrategy(url) {
// parse error instead of just manipulating the original error message.
// That would allow to add further properties and maybe additional
// debugging information.
err.message = pathname + ': ' + err.message;
err.message = errPath(url) + ': ' + err.message;
throw err;
}
CJSModule._cache[modulePath] = module;
if (pathname) {
CJSModule._cache[modulePath] = module;
}
return createDynamicModule([], ['default'], url, (reflect) => {
debug(`Parsing JSONModule ${url}`);
reflect.exports.default.set(module.exports);
@ -155,14 +192,13 @@ translators.set('json', async function jsonStrategy(url) {
// Strategy for loading a wasm module
translators.set('wasm', async function(url) {
const pathname = fileURLToPath(url);
const buffer = await readFileAsync(pathname);
const buffer = await getSource(url);
debug(`Translating WASMModule ${url}`);
let compiled;
try {
compiled = await WebAssembly.compile(buffer);
} catch (err) {
err.message = pathname + ': ' + err.message;
err.message = errPath(url) + ': ' + err.message;
throw err;
}

View File

@ -0,0 +1,63 @@
// Flags: --experimental-modules
'use strict';
const common = require('../common');
const assert = require('assert');
function createURL(mime, body) {
return `data:${mime},${body}`;
}
function createBase64URL(mime, body) {
return `data:${mime};base64,${Buffer.from(body).toString('base64')}`;
}
(async () => {
{
const body = 'export default {a:"aaa"};';
const plainESMURL = createURL('text/javascript', body);
const ns = await import(plainESMURL);
assert.deepStrictEqual(Object.keys(ns), ['default']);
assert.deepStrictEqual(ns.default.a, 'aaa');
const importerOfURL = createURL(
'text/javascript',
`export {default as default} from ${JSON.stringify(plainESMURL)}`
);
assert.strictEqual(
(await import(importerOfURL)).default,
ns.default
);
const base64ESMURL = createBase64URL('text/javascript', body);
assert.notStrictEqual(
await import(base64ESMURL),
ns
);
}
{
const body = 'export default import.meta.url;';
const plainESMURL = createURL('text/javascript', body);
const ns = await import(plainESMURL);
assert.deepStrictEqual(Object.keys(ns), ['default']);
assert.deepStrictEqual(ns.default, plainESMURL);
}
{
const body = '{"x": 1}';
const plainESMURL = createURL('application/json', body);
const ns = await import(plainESMURL);
assert.deepStrictEqual(Object.keys(ns), ['default']);
assert.deepStrictEqual(ns.default.x, 1);
}
{
const body = '{"default": 2}';
const plainESMURL = createURL('application/json', body);
const ns = await import(plainESMURL);
assert.deepStrictEqual(Object.keys(ns), ['default']);
assert.deepStrictEqual(ns.default.default, 2);
}
{
const body = 'null';
const plainESMURL = createURL('invalid', body);
try {
await import(plainESMURL);
common.mustNotCall()();
} catch (e) {
assert.strictEqual(e.code, 'ERR_INVALID_RETURN_PROPERTY_VALUE');
}
}
})().then(common.mustCall());