Skip to content
Open
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
18 changes: 15 additions & 3 deletions lib/internal/modules/esm/assert.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const {
ObjectValues,
} = primordials;
const { validateString } = require('internal/validators');
const { getOptionValue } = require('internal/options');

const {
ERR_IMPORT_ATTRIBUTE_TYPE_INCOMPATIBLE,
Expand All @@ -31,6 +32,17 @@ const formatTypeMap = {
'module': kImplicitTypeAttribute,
'wasm': kImplicitTypeAttribute, // It's unclear whether the HTML spec will require an type attribute or not for Wasm; see https://github.com/WebAssembly/esm-integration/issues/42
};
// NOTE: Don't add bytes support yet as it requires Uint8Arrays backed by immutable ArrayBuffers,
// which V8 does not support yet.
// see: https://github.com/nodejs/node/pull/62300#issuecomment-4079163816

function getFormatType(format) {
if (format === 'text' && getOptionValue('--experimental-import-text')) {
return 'text';
}

return formatTypeMap[format];
}

/**
* The HTML spec disallows the default type to be explicitly specified
Expand All @@ -42,7 +54,6 @@ const supportedTypeAttributes = ArrayPrototypeFilter(
ObjectValues(formatTypeMap),
(type) => type !== kImplicitTypeAttribute);


/**
* Test a module's import attributes.
* @param {string} url The URL of the imported module, for error reporting.
Expand All @@ -60,7 +71,7 @@ function validateAttributes(url, format,
throw new ERR_IMPORT_ATTRIBUTE_UNSUPPORTED(keys[i], importAttributes[keys[i]], url);
}
}
const validType = formatTypeMap[format];
const validType = getFormatType(format);

switch (validType) {
case undefined:
Expand Down Expand Up @@ -101,7 +112,8 @@ function handleInvalidType(url, type) {
validateString(type, 'type');

// `type` might not have been one of the types we understand.
if (!ArrayPrototypeIncludes(supportedTypeAttributes, type)) {
if (!ArrayPrototypeIncludes(supportedTypeAttributes, type) &&
!(type === 'text' && getOptionValue('--experimental-import-text'))) {
throw new ERR_IMPORT_ATTRIBUTE_UNSUPPORTED('type', type, url);
}

Expand Down
15 changes: 14 additions & 1 deletion lib/internal/modules/esm/load.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const {
const {
kEmptyObject,
} = require('internal/util');
const { getOptionValue } = require('internal/options');

const { defaultGetFormat } = require('internal/modules/esm/get_format');
const { validateAttributes, emitImportAssertionWarning } = require('internal/modules/esm/assert');
Expand Down Expand Up @@ -48,6 +49,10 @@ function getSourceSync(url, context) {
return { __proto__: null, responseURL, source };
}

function isExperimentalTextImport(importAttributes) {
return getOptionValue('--experimental-import-text') &&
importAttributes?.type === 'text';
}

/**
* Node.js default load hook.
Expand Down Expand Up @@ -90,8 +95,12 @@ function defaultLoad(url, context = kEmptyObject) {
}

if (format == null) {
if (isExperimentalTextImport(importAttributes)) {
format = 'text';
}

// Now that we have the source for the module, run `defaultGetFormat` to detect its format.
format = defaultGetFormat(urlInstance, context);
format ??= defaultGetFormat(urlInstance, context);

if (format === 'commonjs') {
// For backward compatibility reasons, we need to discard the source in
Expand Down Expand Up @@ -154,6 +163,10 @@ function defaultLoadSync(url, context = kEmptyObject) {
context = { __proto__: context, source };
}

if (format == null && isExperimentalTextImport(importAttributes)) {
format = 'text';
}

// Now that we have the source for the module, run `defaultGetFormat` to detect its format.
format ??= defaultGetFormat(urlInstance, context);

Expand Down
10 changes: 9 additions & 1 deletion lib/internal/modules/esm/resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -937,11 +937,13 @@ function throwIfInvalidParentURL(parentURL) {
* @param {string} specifier - The specifier to resolve.
* @param {object} [context] - The context object containing the parent URL and conditions.
* @param {string} [context.parentURL] - The URL of the parent module.
* @param {object} [context.importAttributes] - The import attributes for resolving the specifier.
* @param {string[]} [context.conditions] - The conditions for resolving the specifier.
* @returns {{url: string, format?: string}}
*/
function defaultResolve(specifier, context = {}) {
let { parentURL, conditions } = context;
const { importAttributes } = context;
throwIfInvalidParentURL(parentURL);

let parsedParentURL;
Expand Down Expand Up @@ -1004,12 +1006,18 @@ function defaultResolve(specifier, context = {}) {
throw error;
}

let format = defaultGetFormatWithoutErrors(url, context);
if (getOptionValue('--experimental-import-text') &&
importAttributes?.type === 'text') {
format = 'text';
}

return {
__proto__: null,
// Do NOT cast `url` to a string: that will work even when there are real
// problems, silencing them
url: url.href,
format: defaultGetFormatWithoutErrors(url, context),
format,
};
}

Expand Down
10 changes: 10 additions & 0 deletions lib/internal/modules/esm/translators.js
Original file line number Diff line number Diff line change
Expand Up @@ -642,3 +642,13 @@ translators.set('module-typescript', function(url, translateContext, parentURL)
translateContext.source = stripTypeScriptModuleTypes(stringify(source), url);
return FunctionPrototypeCall(translators.get('module'), this, url, translateContext, parentURL);
});

// Strategy for loading source as text.
translators.set('text', function textStrategy(url, translateContext) {
let { source } = translateContext;
assertBufferSource(source, true, 'load');
source = stringify(source);
return new ModuleWrap(url, undefined, ['default'], function() {
this.setExport('default', source);
});
});
5 changes: 5 additions & 0 deletions src/node_options.cc
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,11 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
AddAlias("--loader", "--experimental-loader");
AddOption("--experimental-modules", "", NoOp{}, kAllowedInEnvvar);
AddOption("--experimental-wasm-modules", "", NoOp{}, kAllowedInEnvvar);
AddOption("--experimental-import-text",
"experimental support for importing source as text with import "
"attributes",
&EnvironmentOptions::experimental_import_text,
kAllowedInEnvvar);
AddOption("--experimental-import-meta-resolve",
"experimental ES Module import.meta.resolve() parentURL support",
&EnvironmentOptions::experimental_import_meta_resolve,
Expand Down
1 change: 1 addition & 0 deletions src/node_options.h
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ class EnvironmentOptions : public Options {
std::string localstorage_file;
bool experimental_global_navigator = true;
bool experimental_global_web_crypto = true;
bool experimental_import_text = false;
bool experimental_import_meta_resolve = false;
std::string input_type; // Value of --input-type
bool entry_is_url = false;
Expand Down
5 changes: 5 additions & 0 deletions test/es-module/test-esm-import-attributes-errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ async function test() {
{ code: 'ERR_IMPORT_ATTRIBUTE_TYPE_INCOMPATIBLE' }
);

await assert.rejects(
import(jsModuleDataUrl, { with: { type: 'text' } }),
{ code: 'ERR_IMPORT_ATTRIBUTE_UNSUPPORTED' }
);

await assert.rejects(
import(jsModuleDataUrl, { with: { type: 'json', other: 'unsupported' } }),
{ code: 'ERR_IMPORT_ATTRIBUTE_UNSUPPORTED' }
Expand Down
5 changes: 5 additions & 0 deletions test/es-module/test-esm-import-attributes-errors.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ await assert.rejects(
{ code: 'ERR_IMPORT_ATTRIBUTE_TYPE_INCOMPATIBLE' }
);

await assert.rejects(
import(jsModuleDataUrl, { with: { type: 'text' } }),
{ code: 'ERR_IMPORT_ATTRIBUTE_UNSUPPORTED' }
);

await assert.rejects(
import(jsModuleDataUrl, { with: { type: 'json', other: 'unsupported' } }),
{ code: 'ERR_IMPORT_ATTRIBUTE_UNSUPPORTED' }
Expand Down
19 changes: 19 additions & 0 deletions test/es-module/test-esm-import-attributes-validation-text.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Flags: --expose-internals --experimental-import-text
'use strict';
require('../common');

const assert = require('assert');

const { validateAttributes } = require('internal/modules/esm/assert');

const url = 'test://';

assert.ok(validateAttributes(url, 'text', { type: 'text' }));

assert.throws(() => validateAttributes(url, 'text', {}), {
code: 'ERR_IMPORT_ATTRIBUTE_MISSING',
});

assert.throws(() => validateAttributes(url, 'module', { type: 'text' }), {
code: 'ERR_IMPORT_ATTRIBUTE_TYPE_INCOMPATIBLE',
});
12 changes: 12 additions & 0 deletions test/es-module/test-esm-import-text-disabled.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import '../common/index.mjs';
import assert from 'assert';

await assert.rejects(
import('../fixtures/file-to-read-without-bom.txt', { with: { type: 'text' } }),
{ code: 'ERR_UNKNOWN_FILE_EXTENSION' },
);

await assert.rejects(
import('data:text/plain,hello%20world', { with: { type: 'text' } }),
{ code: 'ERR_UNKNOWN_MODULE_FORMAT' },
);
51 changes: 51 additions & 0 deletions test/es-module/test-esm-import-text.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Flags: --experimental-import-text
import '../common/index.mjs';
import assert from 'assert';

import staticText from '../fixtures/file-to-read-without-bom.txt' with { type: 'text' };
import staticTextWithBOM from '../fixtures/file-to-read-with-bom.txt' with { type: 'text' };

const expectedText = 'abc\ndef\nghi\n';

assert.strictEqual(staticText, expectedText);
assert.strictEqual(staticTextWithBOM, expectedText);

const dynamicText = await import('../fixtures/file-to-read-without-bom.txt', {
with: { type: 'text' },
});
assert.strictEqual(dynamicText.default, expectedText);

const dataText = await import('data:text/plain,hello%20world', {
with: { type: 'text' },
});
assert.strictEqual(dataText.default, 'hello world');

const dataJsAsText = await import('data:text/javascript,export{}', {
with: { type: 'text' },
});
assert.strictEqual(dataJsAsText.default, 'export{}');

const dataInvalidUtf8 = await import('data:text/plain,%66%6f%80%6f', {
with: { type: 'text' },
});
assert.strictEqual(dataInvalidUtf8.default, 'fo\ufffdo');

const jsAsText = await import('../fixtures/syntax/bad_syntax.js', {
with: { type: 'text' },
});
assert.match(jsAsText.default, /^var foo bar;/);

const jsonAsText = await import('../fixtures/invalid.json', {
with: { type: 'text' },
});
assert.match(jsonAsText.default, /"im broken"/);

await assert.rejects(
import('data:text/plain,hello%20world'),
{ code: 'ERR_UNKNOWN_MODULE_FORMAT' },
);

await assert.rejects(
import('../fixtures/file-to-read-without-bom.txt'),
{ code: 'ERR_UNKNOWN_FILE_EXTENSION' },
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Please add a test for what happens when you try to import a binary data file such as an image.png or mod.wasm.

I encourage Node.js to take a strict view here. The following code should throw an exception

import text from "image.png" with { type: "text" };

Loading