Skip to content

Commit f69b684

Browse files
authored
feat: add TextEncoder/TextDecoder and atob/btoa on a lazy-global tier (#2026)
1 parent 5b16e19 commit f69b684

26 files changed

Lines changed: 1715 additions & 114 deletions

docs/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
layered on the runtime's `EventTarget`, the GC contract (weak timers and
1414
`any()` links, listener-driven persistence), and the `DOMException`
1515
stand-in (name-patched `Error` reasons).
16+
- [TextEncoder / TextDecoder and atob / btoa](text-encoding.md) — the WHATWG
17+
encoding and base64 globals (`TextEncoder`, `TextDecoder`, `atob`, `btoa`),
18+
the supported encodings with their label sets, streaming decode semantics,
19+
and the lazy-global tier that runs their builtins only on first use.
1620
- [Error handling](error-handling.md) — global `error`/`unhandledrejection` events, `reportError`, catching Java exceptions in JS (`error.nativeException`), forwarding JS throws to Java callers (`interop.escapeException`), JS stacks on Java exceptions (`com.tns.JavaScriptStackTrace`), configuration flags, and crash-reporter integration.
1721
- [structuredClone](structured-clone.md) — the WHATWG `structuredClone(value, { transfer })` global: what clones, how graph identity and cycles are preserved, `ArrayBuffer` transfer, and the `DataCloneError`-named `Error` that stands in for `DOMException`.
1822
- [Implementing additional Chrome DevTools protocol Domains](extending-inspector.md)

docs/ns-builtin-modules.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Rules:
5959
|---|---|
6060
| `inspect(value[, options])` | Formats any value for human consumption: depth-limited, output-capped, cycle-safe, never invokes getters (except a guarded `error.stack` read and custom `toString` overrides, which are honored). `options.depth` (number) overrides the default depth of 2. Other option keys are reserved. |
6161
| `format(fmt, ...args)` | Node-style printf formatting: `%s`, `%d`, `%i`, `%f`, `%j`, `%o`, `%O`, `%%`. Extra arguments are appended space-separated, objects rendered via `inspect`. When `fmt` is not a string or contains no substitutions, all arguments are formatted and joined with spaces. `console.*` routes its arguments through this, so `console.log("%d apples", 3)` works. |
62+
| `TextEncoder` / `TextDecoder` | The WHATWG encoding interfaces, **the very same class objects the globals of those names hold** (`require("ns:util").TextDecoder === globalThis.TextDecoder`). Reading either member is what materializes them, so requiring the module costs nothing extra. |
6263

6364
```js
6465
const { inspect, format } = require("ns:util");
@@ -78,6 +79,13 @@ format("%j", { ok: true }); // '{"ok":true}'
7879
format("100% sure", "extra"); // "100% sure extra" (no placeholder consumed)
7980
```
8081

82+
```js
83+
const { TextEncoder, TextDecoder } = require("ns:util");
84+
85+
TextDecoder === globalThis.TextDecoder; // true
86+
new TextDecoder().decode(new TextEncoder().encode("héllo")); // "héllo"
87+
```
88+
8189
**Stability caveat (verbatim from Node's contract):** the output of `inspect`
8290
(and therefore `format`'s object rendering) may change between runtime
8391
versions for readability; it is intended for humans and must not be parsed
@@ -400,7 +408,7 @@ unmodified where a shim exists:
400408
401409
| module | exports | notes |
402410
|---|---|---|
403-
| `node:util` | `inspect`, `format` | Re-exports `ns:util`'s members unchanged (`nodeUtil.inspect === nsUtil.inspect`) from a **distinct, separately frozen module object**. Documented as partial. |
411+
| `node:util` | `inspect`, `format`, `TextEncoder`, `TextDecoder` | Re-exports `ns:util`'s members unchanged (`nodeUtil.inspect === nsUtil.inspect`) from a **distinct, separately frozen module object**. `TextEncoder`/`TextDecoder` are the globals of those names, as they are in Node. Documented as partial. |
404412
| `node:url` | `fileURLToPath`, `pathToFileURL` | Node-strict converters between `file:` URLs and paths. Documented as partial — no `URL`/`URLSearchParams` re-exports (both are globals), no legacy `url.parse`/`format`/`resolve`. |
405413
| `node:module` | `createRequire` | Re-exports `ns:module`'s `createRequire` unchanged from a **distinct, separately frozen module object**. `createPumpingRequire` is deliberately absent: it has no Node counterpart, so code written against this shim keeps running on Node. `require.resolve`/`.cache`/`.main` are not implemented, and neither is any other `node:module` member (`Module`, `builtinModules`, `isBuiltin`, `register`, `syncBuiltinESMExports`). Documented as partial. |
406414

docs/text-encoding.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# TextEncoder / TextDecoder and atob / btoa
2+
3+
Native, WHATWG-conformant `TextEncoder`, `TextDecoder`
4+
([Encoding Standard](https://encoding.spec.whatwg.org)) and `atob` / `btoa`
5+
([HTML Standard §8.3](https://html.spec.whatwg.org/multipage/webappapis.html#atob))
6+
globals, and the **lazy-global tier** they ride on.
7+
8+
## Lazy globals
9+
10+
These globals are registered on the global template as lazy data properties
11+
(`LazyGlobals`, `test-app/runtime/src/main/cpp/LazyGlobals.cpp`): the builtin
12+
behind a name is not compiled, run, or allocated until app code first reads it,
13+
and V8 then replaces the property with a plain data property so later reads
14+
cost nothing. Sibling names from one builtin (`TextEncoder` + `TextDecoder`)
15+
share a single run per isolate. Workers get the same globals — the tier is
16+
registered in every isolate's template. Assigning over one of these names
17+
before its first read replaces the global, like any other writable global.
18+
19+
The tier is the intended home for further web globals (`Blob`, `fetch`,
20+
`crypto`, `DOMException`, …) with zero cost when unused; see
21+
`test-app/runtime/src/main/cpp/js/README.md` for the rules a lazy builtin
22+
lives by.
23+
24+
The per-isolate exports cache behind the tier (`BuiltinLoader::GetExports`) is
25+
shared with the `ns:`/`node:` module registry: `require("ns:util").TextDecoder`
26+
and `require("node:util").TextDecoder` are the very class objects the globals
27+
hold, whichever entry point is reached first
28+
(see [ns-builtin-modules](ns-builtin-modules.md)).
29+
30+
## TextEncoder / TextDecoder
31+
32+
Node's split: `js/text-encoding.js` owns the WebIDL surface (brand checks via
33+
private fields, enumerable prototype members, `Symbol.toStringTag`),
34+
`TextEncoding.cpp` owns the bytes.
35+
36+
- **Decoder encodings**: the `TextDecoder` constructor resolves utf-8,
37+
utf-16le, utf-16be and windows-1252, each with its complete WHATWG label
38+
set; an unknown label throws `RangeError`. (Precedent: Node without ICU
39+
ships utf-8/utf-16le; utf-16be and windows-1252 are cheap, and windows-1252
40+
covers the `ascii`/`latin1`/`iso-8859-1` aliases web code actually uses.)
41+
`TextEncoder` is UTF-8-only and takes no label, as the spec defines it.
42+
- **Streaming**: full `decode(…, { stream: true })` support. Incomplete
43+
sequences (split BOMs and split utf-16 code units included) carry across
44+
calls in a 16-byte `Uint8Array` the builtin owns — no per-instance native
45+
handle, no finalizer.
46+
- **Replacement semantics**: WHATWG utf-8 state machine with one U+FFFD per
47+
maximal invalid subpart; `fatal: true` throws `TypeError`; `ignoreBOM`
48+
honored.
49+
- `encode()` / `encodeInto()` with correct USV conversion and partial-write
50+
boundaries (`encodeInto` never splits an encoded code point).
51+
- **Fast paths**: pure-ASCII utf-8 and C1-free windows-1252 decode straight
52+
through `String::NewFromOneByte`; results downgrade to one-byte strings when
53+
possible. `encodeInto` registers a V8 Fast API overload
54+
(`NATIVESCRIPT_ENABLE_FAST_API`, default on), live once a call site tiers
55+
up.
56+
57+
## atob / btoa
58+
59+
WHATWG forgiving-base64 (`Base64.cpp`): whitespace stripping, padding rules,
60+
alphabet validation. With no `DOMException` in the runtime yet, failures throw
61+
the name-patched `Error` (`InvalidCharacterError`) stand-in the abort-signal
62+
and performance builtins already use; a follow-up will introduce
63+
`DOMException` and upgrade these.
64+
65+
## Tests
66+
67+
The shared suite (`test-app/app/src/main/assets/app/shared/TextEncoding`)
68+
holds the conformance specs, feature-detecting so runtimes without these
69+
globals report pending rather than failing; it was independently validated
70+
against Node 24 (full ICU) as a reference.

eslint.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const capturedStatics = [
3232

3333
// Captured constructors. A destructure from `primordials` shadows the global,
3434
// so these only fire on the unguarded reference.
35-
const restrictedGlobals = ['Date', 'FinalizationRegistry', 'Map', 'Number', 'Proxy', 'RangeError', 'Set', 'String', 'TypeError', 'WeakRef'].map((name) => ({
35+
const restrictedGlobals = ['Date', 'FinalizationRegistry', 'Map', 'Number', 'Proxy', 'RangeError', 'Set', 'String', 'TypeError', 'Uint8Array', 'Uint32Array', 'WeakRef'].map((name) => ({
3636
name,
3737
message: `Destructure ${name} from primordials — builtins must not read intrinsics off globals user code can replace.`,
3838
}));

test-app/app/src/main/assets/app/mainpage.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ shared.runRuntimeTests();
2020
shared.runWorkerTests();
2121
shared.runPerformanceTests();
2222
shared.runStructuredCloneTests();
23+
shared.runTextEncodingTests();
2324
require("./tests/testWebAssembly");
2425
require("./tests/testEventLoop");
2526
require("./tests/testMultithreadedJavascript");
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// A worker is a fresh isolate, which is what makes the access order testable:
2+
// the parent realm has already materialized TextEncoder/TextDecoder by the
3+
// time any spec runs. Nothing here may touch either name before the handler,
4+
// or the requested order is lost.
5+
onmessage = function (msg) {
6+
var order = msg.data;
7+
var results = { order: order };
8+
9+
if (order === "global-first") {
10+
var globalEncoder = globalThis.TextEncoder;
11+
var globalDecoder = globalThis.TextDecoder;
12+
var nsUtil = require("ns:util");
13+
var nodeUtil = require("node:util");
14+
results.encoder = nsUtil.TextEncoder === globalEncoder && nodeUtil.TextEncoder === globalEncoder;
15+
results.decoder = nsUtil.TextDecoder === globalDecoder && nodeUtil.TextDecoder === globalDecoder;
16+
results.roundTrip = new nsUtil.TextDecoder().decode(new nodeUtil.TextEncoder().encode("ok"));
17+
} else {
18+
var util = require("ns:util");
19+
var node = require("node:util");
20+
var utilEncoder = util.TextEncoder;
21+
var utilDecoder = node.TextDecoder;
22+
results.encoder = globalThis.TextEncoder === utilEncoder && node.TextEncoder === utilEncoder;
23+
results.decoder = globalThis.TextDecoder === utilDecoder && util.TextDecoder === utilDecoder;
24+
results.roundTrip = new node.TextDecoder().decode(new util.TextEncoder().encode("ok"));
25+
}
26+
27+
postMessage(results);
28+
};

test-app/app/src/main/assets/app/tests/testNsUtil.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,49 @@ describe("ns:util", function () {
1212
expect(require("ns:util")).toBe(util);
1313
});
1414

15+
it("exposes the encoding interfaces the globals expose", function () {
16+
expect(typeof util.TextEncoder).toBe("function");
17+
expect(typeof util.TextDecoder).toBe("function");
18+
// One run of the text-encoding builtin backs both entry points, so the
19+
// classes are identical objects no matter which is reached first.
20+
expect(util.TextEncoder).toBe(globalThis.TextEncoder);
21+
expect(util.TextDecoder).toBe(globalThis.TextDecoder);
22+
});
23+
24+
it("round trips text through the module's encoding interfaces", function () {
25+
var bytes = new util.TextEncoder().encode("héllo");
26+
expect(bytes instanceof Uint8Array).toBe(true);
27+
expect(bytes.length).toBe(6);
28+
expect(new util.TextDecoder().decode(bytes)).toBe("héllo");
29+
});
30+
31+
it("keeps the classes identical in a fresh isolate, whichever is touched first", function (done) {
32+
var orders = ["global-first", "util-first"];
33+
var replies = 0;
34+
orders.forEach(function (order) {
35+
var worker = new Worker("./nsUtilEncodingOrderWorker.js");
36+
worker.onmessage = function (msg) {
37+
expect(msg.data).toEqual({
38+
order: order,
39+
encoder: true,
40+
decoder: true,
41+
roundTrip: "ok",
42+
});
43+
worker.terminate();
44+
replies++;
45+
if (replies === orders.length) {
46+
done();
47+
}
48+
};
49+
worker.onerror = function (error) {
50+
fail("worker (" + order + ") failed: " + error.message);
51+
worker.terminate();
52+
done();
53+
};
54+
worker.postMessage(order);
55+
});
56+
});
57+
1558
it("throws for an unknown builtin", function () {
1659
expect(function () {
1760
require("ns:definitely-not-a-module");
@@ -158,6 +201,15 @@ describe("node:util", function () {
158201
expect(Object.isFrozen(nodeUtil)).toBe(true);
159202
expect(nodeUtil.inspect).toBe(util.inspect);
160203
expect(nodeUtil.format).toBe(util.format);
204+
expect(nodeUtil.TextEncoder).toBe(util.TextEncoder);
205+
expect(nodeUtil.TextDecoder).toBe(util.TextDecoder);
206+
});
207+
208+
it("exposes Node's encoding interfaces, identical to the globals", function () {
209+
expect(Object.keys(nodeUtil).sort()).toEqual(["TextDecoder", "TextEncoder", "format", "inspect"]);
210+
expect(nodeUtil.TextEncoder).toBe(globalThis.TextEncoder);
211+
expect(nodeUtil.TextDecoder).toBe(globalThis.TextDecoder);
212+
expect(new nodeUtil.TextDecoder("utf-8").decode(new nodeUtil.TextEncoder().encode("ok"))).toBe("ok");
161213
});
162214

163215
it("is a singleton per realm", function () {

test-app/runtime/CMakeLists.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ include_directories(
6969
set(RUNTIME_BUILTIN_JS_DIR ${PROJECT_SOURCE_DIR}/src/main/cpp/js)
7070
set(RUNTIME_BUILTIN_JS
7171
${RUNTIME_BUILTIN_JS_DIR}/abort-signal.js
72+
${RUNTIME_BUILTIN_JS_DIR}/base64.js
7273
${RUNTIME_BUILTIN_JS_DIR}/blob-url.js
7374
${RUNTIME_BUILTIN_JS_DIR}/error-events.js
7475
${RUNTIME_BUILTIN_JS_DIR}/events.js
@@ -84,6 +85,7 @@ set(RUNTIME_BUILTIN_JS
8485
${RUNTIME_BUILTIN_JS_DIR}/primordials.js
8586
${RUNTIME_BUILTIN_JS_DIR}/require-factory.js
8687
${RUNTIME_BUILTIN_JS_DIR}/structured-clone.js
88+
${RUNTIME_BUILTIN_JS_DIR}/text-encoding.js
8789
${RUNTIME_BUILTIN_JS_DIR}/weak-ref.js
8890
)
8991
set(RUNTIME_BUILTINS_GENERATED_DIR ${PROJECT_SOURCE_DIR}/src/main/cpp/generated)
@@ -168,6 +170,7 @@ add_library(
168170
src/main/cpp/ArrayElementAccessor.cpp
169171
src/main/cpp/ArrayHelper.cpp
170172
src/main/cpp/AssetExtractor.cpp
173+
src/main/cpp/Base64.cpp
171174
src/main/cpp/BuiltinLoader.cpp
172175
src/main/cpp/CallbackHandlers.cpp
173176
src/main/cpp/ConcurrentQueue.cpp
@@ -189,6 +192,7 @@ add_library(
189192
src/main/cpp/JsArgConverter.cpp
190193
src/main/cpp/JsArgToArrayConverter.cpp
191194
src/main/cpp/JSONObjectHelper.cpp
195+
src/main/cpp/LazyGlobals.cpp
192196
src/main/cpp/Logger.cpp
193197
src/main/cpp/ManualInstrumentation.cpp
194198
src/main/cpp/MetadataMethodInfo.cpp
@@ -215,6 +219,7 @@ add_library(
215219
src/main/cpp/SimpleProfiler.cpp
216220
src/main/cpp/StructuredClone.cpp
217221
src/main/cpp/StructuredSerialization.cpp
222+
src/main/cpp/TextEncoding.cpp
218223
src/main/cpp/Util.cpp
219224
src/main/cpp/V8GlobalHelpers.cpp
220225
src/main/cpp/V8StringConstants.cpp

0 commit comments

Comments
 (0)