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
13 changes: 13 additions & 0 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -1171,6 +1171,18 @@ passing a second `parentURL` argument for contextual resolution.

Previously gated the entire `import.meta.resolve` feature.

### `--experimental-import-text`

<!-- YAML
added:
- REPLACEME
-->

> Stability: 1.0 - Early development

Enable experimental support for importing modules with
`with { type: 'text' }`.

### `--experimental-inspector-network-resource`

<!-- YAML
Expand Down Expand Up @@ -3604,6 +3616,7 @@ one is included in the list below.
* `--experimental-detect-module`
* `--experimental-eventsource`
* `--experimental-import-meta-resolve`
* `--experimental-import-text`
* `--experimental-json-modules`
* `--experimental-loader`
* `--experimental-modules`
Expand Down
22 changes: 22 additions & 0 deletions doc/api/esm.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,8 +294,10 @@ Node.js only supports the `type` attribute, for which it supports the following
| Attribute `type` | Needed for |
| ---------------- | ---------------- |
| `'json'` | [JSON modules][] |
| `'text'` | [Text modules][] |

The `type: 'json'` attribute is mandatory when importing JSON modules.
The `type: 'text'` attribute is mandatory when importing text modules.

## Built-in modules

Expand Down Expand Up @@ -709,6 +711,25 @@ exports. A cache entry is created in the CommonJS cache to avoid duplication.
The same object is returned in CommonJS if the JSON module has already been
imported from the same path.

<i id="esm_experimental_text_modules"></i>

## Text modules

> Stability: 1.0 - Early development

Text modules are available behind the `--experimental-import-text` flag.

Text files can be referenced by `import`:

```js
import message from './message.txt' with { type: 'text' };
```

The `with { type: 'text' }` syntax is mandatory; see [Import Attributes][].

The imported text only exposes a `default` export whose value is the module
source as a string.

<i id="esm_experimental_wasm_modules"></i>

## Wasm modules
Expand Down Expand Up @@ -1308,6 +1329,7 @@ resolution for ESM specifiers is [commonjs-extension-resolution-loader][].
[Node.js Module Resolution And Loading Algorithm]: #resolution-algorithm-specification
[Source Phase Imports]: https://github.com/tc39/proposal-source-phase-imports
[Terminology]: #terminology
[Text modules]: #text-modules
[URL]: https://url.spec.whatwg.org/
[WebAssembly JS String Builtins Proposal]: https://github.com/WebAssembly/js-string-builtins
[`"exports"`]: packages.md#exports
Expand Down
6 changes: 6 additions & 0 deletions doc/node.1
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,10 @@ Enable experimental \fBimport.meta.resolve()\fR parent URL support, which allows
passing a second \fBparentURL\fR argument for contextual resolution.
Previously gated the entire \fBimport.meta.resolve\fR feature.
.
.It Fl -experimental-import-text
Enable experimental support for importing modules with
\fBwith { type: 'text' }\fR.
.
.It Fl -experimental-inspector-network-resource
Enable experimental support for inspector network resources.
.
Expand Down Expand Up @@ -1859,6 +1863,8 @@ one is included in the list below.
.It
\fB--experimental-import-meta-resolve\fR
.It
\fB--experimental-import-text\fR
.It
\fB--experimental-json-modules\fR
.It
\fB--experimental-loader\fR
Expand Down
13 changes: 12 additions & 1 deletion 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 @@ -29,8 +30,13 @@ const formatTypeMap = {
'commonjs': kImplicitTypeAttribute,
'json': 'json',
'module': kImplicitTypeAttribute,
'text': 'text',
'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
Comment on lines 32 to 34
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Since this map is never exported, I think it can have experimental formats? Then we won’t need to override it or check in so many places.

Suggested change
'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
'module': kImplicitTypeAttribute,
'text': 'text',
'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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yup, we can extend this directly, done

};
// 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


/**
* The HTML spec disallows the default type to be explicitly specified
Expand All @@ -42,7 +48,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 @@ -62,6 +67,12 @@ function validateAttributes(url, format,
}
const validType = formatTypeMap[format];

if (validType !== undefined &&
importAttributes.type === 'text' &&
!getOptionValue('--experimental-import-text')) {
throw new ERR_IMPORT_ATTRIBUTE_UNSUPPORTED('type', importAttributes.type, url);
}

switch (validType) {
case undefined:
// Ignore attributes for module formats we don't recognize, to allow new
Expand Down
25 changes: 23 additions & 2 deletions lib/internal/modules/esm/get_format.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ function mimeToFormat(mime) {
) { return 'module'; }
if (mime === 'application/json') { return 'json'; }
if (mime === 'application/wasm') { return 'wasm'; }
if (
getOptionValue('--experimental-import-text') &&
RegExpPrototypeExec(
/^\s*text\/plain\s*(;\s*charset=utf-?8\s*)?$/i,
mime,
) !== null
) { return 'text'; }
return null;
}

Expand Down Expand Up @@ -236,12 +243,22 @@ function getFileProtocolModuleFormat(url, context = { __proto__: null }, ignoreE
throw new ERR_UNKNOWN_FILE_EXTENSION(ext, filepath);
}

// If the caller explicitly requests text format via import attributes, honor it regardless of file extension.
function isExperimentalTextImport(importAttributes) {
return getOptionValue('--experimental-import-text') &&
importAttributes?.type === 'text';
}

/**
* @param {URL} url
* @param {{parentURL: string}} context
* @param {{parentURL: string, importAttributes?: Record<string, string>}} context
* @returns {Promise<string> | string | undefined} only works when enabled
*/
function defaultGetFormatWithoutErrors(url, context) {
if (isExperimentalTextImport(context?.importAttributes)) {
return 'text';
}

const protocol = url.protocol;
if (!ObjectPrototypeHasOwnProperty(protocolHandlers, protocol)) {
return null;
Expand All @@ -251,10 +268,14 @@ function defaultGetFormatWithoutErrors(url, context) {

/**
* @param {URL} url
* @param {{parentURL: string}} context
* @param {{parentURL: string, importAttributes?: Record<string, string>}} context
* @returns {Promise<string> | string | undefined} only works when enabled
*/
function defaultGetFormat(url, context) {
if (isExperimentalTextImport(context?.importAttributes)) {
return 'text';
}

const protocol = url.protocol;
if (!ObjectPrototypeHasOwnProperty(protocolHandlers, protocol)) {
return null;
Expand Down
2 changes: 1 addition & 1 deletion lib/internal/modules/esm/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ function getRaceMessage(filename, parentFilename, isForAsyncLoaderHookWorker) {
*/

/**
* @typedef {'builtin'|'commonjs'|'json'|'module'|'wasm'} ModuleFormat
* @typedef {'builtin'|'commonjs'|'json'|'module'|'text'|'wasm'} ModuleFormat
*/

/**
Expand Down
11 changes: 11 additions & 0 deletions lib/internal/modules/esm/translators.js
Original file line number Diff line number Diff line change
Expand Up @@ -642,3 +642,14 @@ 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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

When strip-types was experimental, how were these handled? Were the translators just never set at all if the flag wasn’t passed, or would they throw within the function if the flag wasn’t passed, or something else?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Looks like they were introduced in 35f92d9 and they were never gated, since it would fail earlier

I see now they emitted a warning with emitExperimentalWarning, I'm also adding a warning

emitExperimentalWarning('Text import');
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_IMPORT_ATTRIBUTE_MISSING' },
);

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" };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hmm, I'm not so sure. I would just allow any file to be read as text. You never know it might have a use case. And implementing a list of potential extensions which don't make sense could be a never ending process?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm really not sure why it should - if there's a text representation of a file, it should give it. If it's nonsense, that's the developer's problem to solve.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hmm, I'm not so sure. I would just allow any file to be read as text. You never know it might have a use case.

Replacement characters render the text form of a non-text file unusable and unrecoverable, so it's hard to think of a use case.

And implementing a list of potential extensions which don't make sense could be a never ending process?

I don't suggest filtering by extension. I suggest throwing an exception if any non-text byte is encountered when reading the file.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Which bytes are always, universally, non-text?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

but valid javascript strings can have lone surrogates, so i'm a bit confused why we'd want to do either one - meaning, throwing kills use cases, and silently substituting results in artificially well-formed utf8?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When reading files, it's always necessary to push them through a text decoder, except the unusual case where the file is stored on disk as UTF-16. There is no such thing as lone surrogates in UTF-8.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm a bit unsure about throwing here. Neither Deno nor Bun throw on invalid UTF-8 in this case, so I'm leaning toward keeping Node aligned.

Of course, I'm open to being challenged if there's a strong reason.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Do Deno and Bun ship this feature? It is not a standard feature yet; it is moving through committee. Node should do what's right, not what implementations of a non-standard feature have done.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It's unflagged in Bun since April 2024, and flagged in Deno since June 2025.

That doesn't necessarily mean anyone's depending on being able to import invalid UTF-8 with replacement characters instead of an error, though.

Loading