diff --git a/lib/internal/modules/esm/assert.js b/lib/internal/modules/esm/assert.js index 902f95aaae21ac..908b89202de038 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, @@ -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 @@ -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. @@ -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: @@ -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); } diff --git a/lib/internal/modules/esm/load.js b/lib/internal/modules/esm/load.js index c284163fba86ec..52df6b831a4f78 100644 --- a/lib/internal/modules/esm/load.js +++ b/lib/internal/modules/esm/load.js @@ -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'); @@ -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. @@ -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 @@ -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); diff --git a/lib/internal/modules/esm/resolve.js b/lib/internal/modules/esm/resolve.js index cbdc120302443c..c1c59bede5c201 100644 --- a/lib/internal/modules/esm/resolve.js +++ b/lib/internal/modules/esm/resolve.js @@ -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; @@ -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, }; } 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' }, +);