Skip to content

Commit 7fdfa15

Browse files
bmeckjkrems
andcommitted
esm: support loading data URLs
Co-Authored-By: Jan Olaf Krems <jan.krems@gmail.com>
1 parent 5ff00db commit 7fdfa15

File tree

5 files changed

+179
-29
lines changed

5 files changed

+179
-29
lines changed

doc/api/esm.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,13 +312,38 @@ There are four types of specifiers:
312312
Bare specifiers, and the bare specifier portion of deep import specifiers, are
313313
strings; but everything else in a specifier is a URL.
314314

315-
Only `file://` URLs are supported. A specifier like
315+
Only `file:` and `data:` URLs are supported. A specifier like
316316
`'https://example.com/app.js'` may be supported by browsers but it is not
317317
supported in Node.js.
318318

319319
Specifiers may not begin with `/` or `//`. These are reserved for potential
320320
future use. The root of the current volume may be referenced via `file:///`.
321321

322+
#### `data:` Imports
323+
324+
<!-- YAML
325+
added: REPLACEME
326+
-->
327+
328+
[`data:` URLs][] are supported for importing with the following MIME types:
329+
330+
* `text/javascript` for ES Modules
331+
* `application/json` for JSON
332+
* `application/wasm` for WASM.
333+
334+
`data:` URLs only resolve [_Bare specifiers_][Terminology] for builtin modules
335+
and [_Absolute specifiers_][Terminology]. Resolving
336+
[_Relative specifiers_][Terminology] will not work because `data:` is not a
337+
[special scheme][]. For example, attempting to load `./foo`
338+
from `data:text/javascript,import "./foo";` will fail to resolve since there
339+
is no concept of relative resolution for `data:` URLs. An example of a `data:`
340+
URLs being used is:
341+
342+
```mjs
343+
import 'data:text/javascript,console.log("hello!");'
344+
import _ from 'data:application/json,"world!"'
345+
```
346+
322347
## import.meta
323348

324349
* {Object}
@@ -869,6 +894,8 @@ $ node --experimental-modules --es-module-specifier-resolution=node index
869894
success!
870895
```
871896
897+
[Terminology]: #esm_terminology
898+
[`data:` URLs]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
872899
[`export`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export
873900
[`import`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
874901
[`import()`]: #esm_import-expressions
@@ -877,6 +904,7 @@ success!
877904
[CommonJS]: modules.html
878905
[ECMAScript-modules implementation]: https://github.com/nodejs/modules/blob/master/doc/plan-for-new-modules-implementation.md
879906
[Node.js EP for ES Modules]: https://github.com/nodejs/node-eps/blob/master/002-es-modules.md
907+
[special scheme]: https://url.spec.whatwg.org/#special-scheme
880908
[WHATWG JSON modules specification]: https://html.spec.whatwg.org/#creating-a-json-module-script
881909
[ES Module Integration Proposal for Web Assembly]: https://github.com/webassembly/esm-integration
882910
[dynamic instantiate hook]: #esm_dynamic_instantiate_hook

lib/internal/modules/esm/default_resolve.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const typeFlag = getOptionValue('--input-type');
1212
const experimentalWasmModules = getOptionValue('--experimental-wasm-modules');
1313
const { resolve: moduleWrapResolve,
1414
getPackageType } = internalBinding('module_wrap');
15-
const { pathToFileURL, fileURLToPath } = require('internal/url');
15+
const { URL, pathToFileURL, fileURLToPath } = require('internal/url');
1616
const { ERR_INPUT_TYPE_NOT_ALLOWED,
1717
ERR_UNKNOWN_FILE_EXTENSION } = require('internal/errors').codes;
1818

@@ -45,12 +45,32 @@ if (experimentalWasmModules)
4545
extensionFormatMap['.wasm'] = legacyExtensionFormatMap['.wasm'] = 'wasm';
4646

4747
function resolve(specifier, parentURL) {
48+
try {
49+
const parsed = new URL(specifier);
50+
if (parsed.protocol === 'data:') {
51+
const [ , mime ] = /^([^/]+\/[^;,]+)(;base64)?,/.exec(parsed.pathname) || [ null, null, null ];
52+
const format = ({
53+
'__proto__': null,
54+
'text/javascript': 'module',
55+
'application/json': 'json',
56+
'application/wasm': experimentalWasmModules ? 'wasm' : null
57+
})[mime] || null;
58+
return {
59+
url: specifier,
60+
format
61+
};
62+
}
63+
} catch {}
4864
if (NativeModule.canBeRequiredByUsers(specifier)) {
4965
return {
5066
url: specifier,
5167
format: 'builtin'
5268
};
5369
}
70+
if (parentURL && parentURL.startsWith('data:')) {
71+
// This is gonna blow up, we want the error
72+
new URL(specifier, parentURL);
73+
}
5474

5575
const isMain = parentURL === undefined;
5676
if (isMain)

lib/internal/modules/esm/loader.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,12 @@ class Loader {
102102
}
103103
}
104104

105-
if (format !== 'dynamic' && !url.startsWith('file:'))
105+
if (format !== 'dynamic' &&
106+
!url.startsWith('file:') &&
107+
!url.startsWith('data:')
108+
)
106109
throw new ERR_INVALID_RETURN_PROPERTY(
107-
'file: url', 'loader resolve', 'url', url
110+
'file: or data: url', 'loader resolve', 'url', url
108111
);
109112

110113
return { url, format };

lib/internal/modules/esm/translators.js

Lines changed: 61 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ const {
99
StringPrototype
1010
} = primordials;
1111

12+
const { Buffer } = require('buffer');
13+
1214
const {
1315
stripBOM,
1416
loadNativeModule
@@ -23,6 +25,8 @@ const { debuglog } = require('internal/util/debuglog');
2325
const { promisify } = require('internal/util');
2426
const esmLoader = require('internal/process/esm_loader');
2527
const {
28+
ERR_INVALID_URL,
29+
ERR_INVALID_URL_SCHEME,
2630
ERR_UNKNOWN_BUILTIN_MODULE
2731
} = require('internal/errors').codes;
2832
const readFileAsync = promisify(fs.readFile);
@@ -33,6 +37,31 @@ const debug = debuglog('esm');
3337
const translators = new SafeMap();
3438
exports.translators = translators;
3539

40+
const DATA_URL_PATTERN = /^[^/]+\/[^,;]+(;base64)?,([\s\S]*)$/;
41+
function getSource(url) {
42+
const parsed = new URL(url);
43+
if (parsed.protocol === 'file:') {
44+
return readFileAsync(parsed);
45+
} else if (parsed.protocol === 'data:') {
46+
const match = DATA_URL_PATTERN.exec(parsed.pathname);
47+
if (!match) {
48+
throw new ERR_INVALID_URL(url);
49+
}
50+
const [ , base64, body ] = match;
51+
return Buffer.from(body, base64 ? 'base64' : 'utf8');
52+
} else {
53+
throw new ERR_INVALID_URL_SCHEME(['file', 'data']);
54+
}
55+
}
56+
57+
function errPath(url) {
58+
const parsed = new URL(url);
59+
if (parsed.protocol === 'file:') {
60+
return fileURLToPath(parsed);
61+
}
62+
return url;
63+
}
64+
3665
function initializeImportMeta(meta, { url }) {
3766
meta.url = url;
3867
}
@@ -44,7 +73,7 @@ async function importModuleDynamically(specifier, { url }) {
4473

4574
// Strategy for loading a standard JavaScript module
4675
translators.set('module', async function moduleStrategy(url) {
47-
const source = `${await readFileAsync(new URL(url))}`;
76+
const source = `${await getSource(url)}`;
4877
debug(`Translating StandardModule ${url}`);
4978
const { ModuleWrap, callbackMap } = internalBinding('module_wrap');
5079
const module = new ModuleWrap(source, url);
@@ -111,26 +140,32 @@ translators.set('builtin', async function builtinStrategy(url) {
111140
translators.set('json', async function jsonStrategy(url) {
112141
debug(`Translating JSONModule ${url}`);
113142
debug(`Loading JSONModule ${url}`);
114-
const pathname = fileURLToPath(url);
115-
const modulePath = isWindows ?
116-
StringPrototype.replace(pathname, winSepRegEx, '\\') : pathname;
117-
let module = CJSModule._cache[modulePath];
118-
if (module && module.loaded) {
119-
const exports = module.exports;
120-
return createDynamicModule([], ['default'], url, (reflect) => {
121-
reflect.exports.default.set(exports);
122-
});
143+
const pathname = url.startsWith('file:') ? fileURLToPath(url) : null;
144+
let modulePath;
145+
let module;
146+
if (pathname) {
147+
modulePath = isWindows ?
148+
StringPrototype.replace(pathname, winSepRegEx, '\\') : pathname;
149+
module = CJSModule._cache[modulePath];
150+
if (module && module.loaded) {
151+
const exports = module.exports;
152+
return createDynamicModule([], ['default'], url, (reflect) => {
153+
reflect.exports.default.set(exports);
154+
});
155+
}
123156
}
124-
const content = await readFileAsync(pathname, 'utf-8');
125-
// A require call could have been called on the same file during loading and
126-
// that resolves synchronously. To make sure we always return the identical
127-
// export, we have to check again if the module already exists or not.
128-
module = CJSModule._cache[modulePath];
129-
if (module && module.loaded) {
130-
const exports = module.exports;
131-
return createDynamicModule(['default'], url, (reflect) => {
132-
reflect.exports.default.set(exports);
133-
});
157+
const content = `${await getSource(url)}`;
158+
if (pathname) {
159+
// A require call could have been called on the same file during loading and
160+
// that resolves synchronously. To make sure we always return the identical
161+
// export, we have to check again if the module already exists or not.
162+
module = CJSModule._cache[modulePath];
163+
if (module && module.loaded) {
164+
const exports = module.exports;
165+
return createDynamicModule(['default'], url, (reflect) => {
166+
reflect.exports.default.set(exports);
167+
});
168+
}
134169
}
135170
try {
136171
const exports = JsonParse(stripBOM(content));
@@ -143,10 +178,12 @@ translators.set('json', async function jsonStrategy(url) {
143178
// parse error instead of just manipulating the original error message.
144179
// That would allow to add further properties and maybe additional
145180
// debugging information.
146-
err.message = pathname + ': ' + err.message;
181+
err.message = errPath(url) + ': ' + err.message;
147182
throw err;
148183
}
149-
CJSModule._cache[modulePath] = module;
184+
if (pathname) {
185+
CJSModule._cache[modulePath] = module;
186+
}
150187
return createDynamicModule([], ['default'], url, (reflect) => {
151188
debug(`Parsing JSONModule ${url}`);
152189
reflect.exports.default.set(module.exports);
@@ -155,14 +192,13 @@ translators.set('json', async function jsonStrategy(url) {
155192

156193
// Strategy for loading a wasm module
157194
translators.set('wasm', async function(url) {
158-
const pathname = fileURLToPath(url);
159-
const buffer = await readFileAsync(pathname);
195+
const buffer = await getSource(url);
160196
debug(`Translating WASMModule ${url}`);
161197
let compiled;
162198
try {
163199
compiled = await WebAssembly.compile(buffer);
164200
} catch (err) {
165-
err.message = pathname + ': ' + err.message;
201+
err.message = errPath(url) + ': ' + err.message;
166202
throw err;
167203
}
168204

test/es-module/test-esm-data-urls.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Flags: --experimental-modules
2+
'use strict';
3+
const common = require('../common');
4+
const assert = require('assert');
5+
function createURL(mime, body) {
6+
return `data:${mime},${body}`;
7+
}
8+
function createBase64URL(mime, body) {
9+
return `data:${mime};base64,${Buffer.from(body).toString('base64')}`;
10+
}
11+
(async () => {
12+
{
13+
const body = 'export default {a:"aaa"};';
14+
const plainESMURL = createURL('text/javascript', body);
15+
const ns = await import(plainESMURL);
16+
assert.deepStrictEqual(Object.keys(ns), ['default']);
17+
assert.deepStrictEqual(ns.default.a, 'aaa');
18+
const importerOfURL = createURL(
19+
'text/javascript',
20+
`export {default as default} from ${JSON.stringify(plainESMURL)}`
21+
);
22+
assert.strictEqual(
23+
(await import(importerOfURL)).default,
24+
ns.default
25+
);
26+
const base64ESMURL = createBase64URL('text/javascript', body);
27+
assert.notStrictEqual(
28+
await import(base64ESMURL),
29+
ns
30+
);
31+
}
32+
{
33+
const body = 'export default import.meta.url;';
34+
const plainESMURL = createURL('text/javascript', body);
35+
const ns = await import(plainESMURL);
36+
assert.deepStrictEqual(Object.keys(ns), ['default']);
37+
assert.deepStrictEqual(ns.default, plainESMURL);
38+
}
39+
{
40+
const body = '{"x": 1}';
41+
const plainESMURL = createURL('application/json', body);
42+
const ns = await import(plainESMURL);
43+
assert.deepStrictEqual(Object.keys(ns), ['default']);
44+
assert.deepStrictEqual(ns.default.x, 1);
45+
}
46+
{
47+
const body = '{"default": 2}';
48+
const plainESMURL = createURL('application/json', body);
49+
const ns = await import(plainESMURL);
50+
assert.deepStrictEqual(Object.keys(ns), ['default']);
51+
assert.deepStrictEqual(ns.default.default, 2);
52+
}
53+
{
54+
const body = 'null';
55+
const plainESMURL = createURL('invalid', body);
56+
try {
57+
await import(plainESMURL);
58+
common.mustNotCall()();
59+
} catch (e) {
60+
assert.strictEqual(e.code, 'ERR_INVALID_RETURN_PROPERTY_VALUE');
61+
}
62+
}
63+
})().then(common.mustCall());

0 commit comments

Comments
 (0)