diff --git a/lib/internal/modules/esm/assert.js b/lib/internal/modules/esm/assert.js index 902f95aaae21ac..0c927b8b6ed84c 100644 --- a/lib/internal/modules/esm/assert.js +++ b/lib/internal/modules/esm/assert.js @@ -8,6 +8,7 @@ const { ObjectValues, } = primordials; const { validateString } = require('internal/validators'); +const { getOptionValue } = require('internal/options'); const { ERR_IMPORT_ATTRIBUTE_TYPE_INCOMPATIBLE, @@ -32,6 +33,14 @@ const formatTypeMap = { '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 }; +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 * (for now); so `import './file.js'` is okay but @@ -42,7 +51,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. @@ -60,7 +68,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: @@ -101,7 +109,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); } diff --git a/lib/internal/modules/esm/get_format.js b/lib/internal/modules/esm/get_format.js index 4f334c7d88c336..e84983e78c355d 100644 --- a/lib/internal/modules/esm/get_format.js +++ b/lib/internal/modules/esm/get_format.js @@ -78,6 +78,25 @@ const protocolHandlers = { 'node:'() { return 'builtin'; }, }; +function getFormatFromImportAttributes(importAttributes) { + if ( + !importAttributes || + !ObjectPrototypeHasOwnProperty(importAttributes, 'type') || + typeof importAttributes.type !== 'string' + ) { + return undefined; + } + + if ( + getOptionValue('--experimental-import-text') && + importAttributes.type === 'text' + ) { + return 'text'; + } + + return undefined; +} + /** * Determine whether the given ambiguous source contains CommonJS or ES module syntax. * @param {string | Buffer | undefined} [source] @@ -238,10 +257,15 @@ function getFileProtocolModuleFormat(url, context = { __proto__: null }, ignoreE /** * @param {URL} url - * @param {{parentURL: string}} context + * @param {{parentURL: string, importAttributes?: Record}} context * @returns {Promise | string | undefined} only works when enabled */ function defaultGetFormatWithoutErrors(url, context) { + const format = getFormatFromImportAttributes(context?.importAttributes); + if (format !== undefined) { + return format; + } + const protocol = url.protocol; if (!ObjectPrototypeHasOwnProperty(protocolHandlers, protocol)) { return null; @@ -251,10 +275,15 @@ function defaultGetFormatWithoutErrors(url, context) { /** * @param {URL} url - * @param {{parentURL: string}} context + * @param {{parentURL: string, importAttributes?: Record}} context * @returns {Promise | string | undefined} only works when enabled */ function defaultGetFormat(url, context) { + const format = getFormatFromImportAttributes(context?.importAttributes); + if (format !== undefined) { + return format; + } + const protocol = url.protocol; if (!ObjectPrototypeHasOwnProperty(protocolHandlers, protocol)) { return null; diff --git a/lib/internal/modules/esm/load.js b/lib/internal/modules/esm/load.js index c284163fba86ec..a16449fc5f8225 100644 --- a/lib/internal/modules/esm/load.js +++ b/lib/internal/modules/esm/load.js @@ -91,7 +91,10 @@ function defaultLoad(url, context = kEmptyObject) { if (format == null) { // Now that we have the source for the module, run `defaultGetFormat` to detect its format. - format = defaultGetFormat(urlInstance, context); + format = defaultGetFormat(urlInstance, { + __proto__: context, + importAttributes, + }); if (format === 'commonjs') { // For backward compatibility reasons, we need to discard the source in @@ -155,7 +158,10 @@ function defaultLoadSync(url, context = kEmptyObject) { } // Now that we have the source for the module, run `defaultGetFormat` to detect its format. - format ??= defaultGetFormat(urlInstance, context); + format ??= defaultGetFormat(urlInstance, { + __proto__: context, + importAttributes, + }); // For backward compatibility reasons, we need to let go through Module._load // again. diff --git a/lib/internal/modules/esm/translators.js b/lib/internal/modules/esm/translators.js index b7b1843ff35572..1a228ff1c5b9cd 100644 --- a/lib/internal/modules/esm/translators.js +++ b/lib/internal/modules/esm/translators.js @@ -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); + }); +}); diff --git a/src/node_options.cc b/src/node_options.cc index d48641ae3ffe07..87ebbd07734006 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -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, diff --git a/src/node_options.h b/src/node_options.h index 2f0adb5ae491ec..b82073ce21a77c 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -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; diff --git a/test/es-module/test-esm-import-attributes-errors.js b/test/es-module/test-esm-import-attributes-errors.js index c8ffd9320ad566..5219e8d7b81ca1 100644 --- a/test/es-module/test-esm-import-attributes-errors.js +++ b/test/es-module/test-esm-import-attributes-errors.js @@ -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' } diff --git a/test/es-module/test-esm-import-attributes-errors.mjs b/test/es-module/test-esm-import-attributes-errors.mjs index 1168c109fdc4d0..d265e3591be2ab 100644 --- a/test/es-module/test-esm-import-attributes-errors.mjs +++ b/test/es-module/test-esm-import-attributes-errors.mjs @@ -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' } diff --git a/test/es-module/test-esm-import-attributes-validation-text.js b/test/es-module/test-esm-import-attributes-validation-text.js new file mode 100644 index 00000000000000..9b509a2d08e35b --- /dev/null +++ b/test/es-module/test-esm-import-attributes-validation-text.js @@ -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', +}); diff --git a/test/es-module/test-esm-import-text-disabled.mjs b/test/es-module/test-esm-import-text-disabled.mjs new file mode 100644 index 00000000000000..28084f0393b0d1 --- /dev/null +++ b/test/es-module/test-esm-import-text-disabled.mjs @@ -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' }, +); diff --git a/test/es-module/test-esm-import-text.mjs b/test/es-module/test-esm-import-text.mjs new file mode 100644 index 00000000000000..e8a71322b3b54d --- /dev/null +++ b/test/es-module/test-esm-import-text.mjs @@ -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' }, +);