Skip to content

Commit 01bc3bd

Browse files
committed
feat: expose TextEncoder/TextDecoder from ns:util and node:util
Matches the updated NativeScript/ios#448. The lazy tier's private exports cache generalizes into BuiltinLoader::GetExports, one per-isolate cache every entry point to a builtin shares — the ns:/node: module registry (whose per-specifier exports map it replaces), the lazy globals, and any binding factory. ns:util re-exports TextEncoder/TextDecoder as the very class objects the globals hold, lazily end to end (SetLazyDataProperty on the binding, getters in ns-util.js/node-util.js), and node:util forwards them as Node does.
1 parent 2e843f1 commit 01bc3bd

16 files changed

Lines changed: 329 additions & 173 deletions

‎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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ The tier is the intended home for further web globals (`Blob`, `fetch`,
2121
`test-app/runtime/src/main/cpp/js/README.md` for the rules a lazy builtin
2222
lives by.
2323

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+
2430
## TextEncoder / TextDecoder
2531

2632
Node's split: `js/text-encoding.js` owns the WebIDL surface (brand checks via
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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,44 @@ 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.postMessage(order);
50+
});
51+
});
52+
1553
it("throws for an unknown builtin", function () {
1654
expect(function () {
1755
require("ns:definitely-not-a-module");
@@ -158,6 +196,15 @@ describe("node:util", function () {
158196
expect(Object.isFrozen(nodeUtil)).toBe(true);
159197
expect(nodeUtil.inspect).toBe(util.inspect);
160198
expect(nodeUtil.format).toBe(util.format);
199+
expect(nodeUtil.TextEncoder).toBe(util.TextEncoder);
200+
expect(nodeUtil.TextDecoder).toBe(util.TextDecoder);
201+
});
202+
203+
it("exposes Node's encoding interfaces, identical to the globals", function () {
204+
expect(Object.keys(nodeUtil).sort()).toEqual(["TextDecoder", "TextEncoder", "format", "inspect"]);
205+
expect(nodeUtil.TextEncoder).toBe(globalThis.TextEncoder);
206+
expect(nodeUtil.TextDecoder).toBe(globalThis.TextDecoder);
207+
expect(new nodeUtil.TextDecoder("utf-8").decode(new nodeUtil.TextEncoder().encode("ok"))).toBe("ok");
161208
});
162209

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

‎test-app/runtime/src/main/cpp/Base64.cpp‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
#include <vector>
44

5+
#include "BuiltinLoader.h"
56
#include "Util.h"
67

78
using namespace v8;
@@ -167,14 +168,18 @@ void AtobCallback(const FunctionCallbackInfo<Value>& info) {
167168
}
168169
}
169170

170-
} // namespace
171-
172-
Local<Object> Base64::CreateBinding(Local<Context> context) {
171+
MaybeLocal<Object> CreateBinding(Local<Context> context) {
173172
Isolate* isolate = v8::Isolate::GetCurrent();
174173
Local<Object> binding = Object::New(isolate);
175174
tns::SetMethodNoSideEffect(context, binding, "btoa", BtoaCallback);
176175
tns::SetMethodNoSideEffect(context, binding, "atob", AtobCallback);
177176
return binding;
178177
}
179178

179+
} // namespace
180+
181+
MaybeLocal<Object> Base64::GetExports(Local<Context> context) {
182+
return BuiltinLoader::GetExports(context, BuiltinId::kBase64, CreateBinding);
183+
}
184+
180185
} // namespace tns

‎test-app/runtime/src/main/cpp/Base64.h‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ namespace tns {
1212
*/
1313
class Base64 {
1414
public:
15-
static v8::Local<v8::Object> CreateBinding(v8::Local<v8::Context> context);
15+
/*
16+
* The builtin's exports, `{ atob, btoa }`, from the one run it gets per
17+
* isolate.
18+
*/
19+
static v8::MaybeLocal<v8::Object> GetExports(v8::Local<v8::Context> context);
1620
};
1721

1822
} // namespace tns

‎test-app/runtime/src/main/cpp/BuiltinLoader.cpp‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ constexpr const char* kPrimordialsParamName = "primordials";
3838
constexpr const char* kInternalsParamName = "internals";
3939
constexpr size_t kParamCount = 6;
4040

41+
/*
42+
* `module.exports` of every builtin that has run in this isolate, indexed by
43+
* id. Per isolate because a builtin is a singleton per realm, so workers run
44+
* their own copy of a file and export their own objects.
45+
*/
46+
struct BuiltinExportsState {
47+
v8::Global<Object> exports[static_cast<unsigned>(BuiltinId::kCount)];
48+
};
49+
4150
/*
4251
* This runtime's intrinsics snapshot, builtin require and shared internals
4352
* object. Per-runtime state rather than an isolate-keyed shared map, so
@@ -258,4 +267,32 @@ MaybeLocal<Value> BuiltinLoader::RunBuiltin(Local<Context> context, BuiltinId id
258267
return CallBuiltin(context, id, binding, primordials, internals);
259268
}
260269

270+
MaybeLocal<Object> BuiltinLoader::GetExports(Local<Context> context, BuiltinId id,
271+
BindingFactory bindingFactory) {
272+
Isolate* isolate = v8::Isolate::GetCurrent();
273+
auto* state = RuntimeState::For<BuiltinExportsState>(isolate);
274+
if (state == nullptr) {
275+
return MaybeLocal<Object>();
276+
}
277+
278+
const unsigned index = static_cast<unsigned>(id);
279+
if (!state->exports[index].IsEmpty()) {
280+
return state->exports[index].Get(isolate);
281+
}
282+
283+
Local<Object> binding;
284+
if (bindingFactory != nullptr && !bindingFactory(context).ToLocal(&binding)) {
285+
return MaybeLocal<Object>();
286+
}
287+
288+
Local<Value> result;
289+
if (!RunBuiltin(context, id, binding).ToLocal(&result) || !result->IsObject()) {
290+
return MaybeLocal<Object>();
291+
}
292+
293+
Local<Object> exports = result.As<Object>();
294+
state->exports[index].Reset(isolate, exports);
295+
return exports;
296+
}
297+
261298
} // namespace tns

‎test-app/runtime/src/main/cpp/BuiltinLoader.h‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ namespace tns {
88

99
class BuiltinLoader {
1010
public:
11+
/*
12+
* Builds the bag of natives a builtin receives as its `binding` parameter.
13+
* GetExports calls it only when the builtin actually runs, so a call site
14+
* that hits the cache pays nothing for it.
15+
*/
16+
using BindingFactory = v8::MaybeLocal<v8::Object> (*)(v8::Local<v8::Context>);
17+
1118
/*
1219
* Compiles the builtin identified by id as a function body with the fixed
1320
* parameters `exports`, `require`, `module`, `binding` (Node's module
@@ -32,6 +39,18 @@ class BuiltinLoader {
3239
static v8::MaybeLocal<v8::Value> RunBuiltin(
3340
v8::Local<v8::Context> context, BuiltinId id,
3441
v8::Local<v8::Value> binding = v8::Local<v8::Value>());
42+
43+
/*
44+
* The builtin's `module.exports`, running it at most once per isolate.
45+
* Every entry point that reaches the same file — the `ns:`/`node:` module
46+
* registry, the lazy-global tier, another builtin's `require` — shares
47+
* that one run, so a value a file exports is the same object through all
48+
* of them. Empty when the builtin failed to run (an exception is pending)
49+
* or exported a non-object.
50+
*/
51+
static v8::MaybeLocal<v8::Object> GetExports(v8::Local<v8::Context> context,
52+
BuiltinId id,
53+
BindingFactory binding);
3554
};
3655

3756
} // namespace tns

‎test-app/runtime/src/main/cpp/LazyGlobals.cpp‎

Lines changed: 12 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
#include "ArgConverter.h"
44
#include "Base64.h"
5-
#include "BuiltinLoader.h"
6-
#include "RuntimeState.h"
75
#include "TextEncoding.h"
86

97
using namespace v8;
@@ -12,60 +10,26 @@ namespace tns {
1210

1311
namespace {
1412

15-
using BindingFactory = Local<Object> (*)(Local<Context>);
13+
/*
14+
* The builtin's `module.exports`, from the single run it gets per isolate
15+
* (BuiltinLoader::GetExports). Two globals out of the same file therefore
16+
* cost one run, and so does a module that exports the same interfaces.
17+
*/
18+
using ExportsAccessor = MaybeLocal<Object> (*)(Local<Context>);
1619

1720
struct LazyGlobalEntry {
1821
const char* name;
19-
BuiltinId builtin;
2022
const char* exportName; // key of `name` in the builtin's module.exports
21-
BindingFactory binding; // natives the builtin needs, null if it needs none
23+
ExportsAccessor exports;
2224
};
2325

2426
constexpr LazyGlobalEntry kLazyGlobals[] = {
25-
{"TextEncoder", BuiltinId::kTextEncoding, "TextEncoder",
26-
TextEncoding::CreateBinding},
27-
{"TextDecoder", BuiltinId::kTextEncoding, "TextDecoder",
28-
TextEncoding::CreateBinding},
29-
{"atob", BuiltinId::kBase64, "atob", Base64::CreateBinding},
30-
{"btoa", BuiltinId::kBase64, "btoa", Base64::CreateBinding},
27+
{"TextEncoder", "TextEncoder", TextEncoding::GetExports},
28+
{"TextDecoder", "TextDecoder", TextEncoding::GetExports},
29+
{"atob", "atob", Base64::GetExports},
30+
{"btoa", "btoa", Base64::GetExports},
3131
};
3232

33-
// One entry per builtin this tier can run, so two globals from the same file
34-
// cost one run.
35-
struct LazyGlobalsState {
36-
v8::Global<Object> exports[static_cast<unsigned>(BuiltinId::kCount)];
37-
};
38-
39-
MaybeLocal<Object> GetExports(Local<Context> context,
40-
const LazyGlobalEntry& entry) {
41-
Isolate* isolate = v8::Isolate::GetCurrent();
42-
auto* state = RuntimeState::For<LazyGlobalsState>(isolate);
43-
if (state == nullptr) {
44-
return MaybeLocal<Object>();
45-
}
46-
47-
const unsigned index = static_cast<unsigned>(entry.builtin);
48-
if (!state->exports[index].IsEmpty()) {
49-
return state->exports[index].Get(isolate);
50-
}
51-
52-
Local<Value> binding;
53-
if (entry.binding != nullptr) {
54-
binding = entry.binding(context);
55-
}
56-
57-
Local<Value> result;
58-
if (!BuiltinLoader::RunBuiltin(context, entry.builtin, binding)
59-
.ToLocal(&result) ||
60-
!result->IsObject()) {
61-
return MaybeLocal<Object>();
62-
}
63-
64-
Local<Object> exports = result.As<Object>();
65-
state->exports[index].Reset(isolate, exports);
66-
return exports;
67-
}
68-
6933
void LazyGlobalGetter(Local<v8::Name> property,
7034
const PropertyCallbackInfo<Value>& info) {
7135
Isolate* isolate = info.GetIsolate();
@@ -74,7 +38,7 @@ void LazyGlobalGetter(Local<v8::Name> property,
7438
Local<Context> context = isolate->GetCurrentContext();
7539

7640
Local<Object> exports;
77-
if (!GetExports(context, *entry).ToLocal(&exports)) {
41+
if (!entry->exports(context).ToLocal(&exports)) {
7842
return;
7943
}
8044
Local<Value> value;

‎test-app/runtime/src/main/cpp/LazyGlobals.h‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ namespace tns {
99
* Globals whose implementation is a runtime builtin that must not run until
1010
* someone actually reaches for the name. Each entry is registered on the
1111
* global template as a lazy data property; the first read runs the builtin
12-
* once per isolate and caches its exports, so sibling names (TextEncoder and
13-
* TextDecoder) share the run, and V8 then replaces the property with a plain
14-
* data property so later reads cost nothing.
12+
* through the per-isolate exports cache (BuiltinLoader::GetExports), so
13+
* sibling names (TextEncoder and TextDecoder) share the run — as does a
14+
* module exporting the same interfaces — and V8 then replaces the property
15+
* with a plain data property so later reads cost nothing.
1516
*
1617
* A builtin behind this tier runs at an arbitrary point in the isolate's life
1718
* rather than during init, so it may only consume `internals` keys published

0 commit comments

Comments
 (0)