Skip to content

Commit 128493b

Browse files
committed
feat: ns:util builtin module (inspect, format)
Runtime-provided modules get a resolution scheme. `require("ns:util")`, `import util, { inspect } from "ns:util"` and `await import("ns:util")` all resolve to the same per-realm module, and `console.*` gains Node's %-substitution, so `console.log("%d apples", 3)` prints `3 apples`. v1 exports `inspect(value[, options])` — the console formatter, now reachable from app code — and `format(fmt, ...args)`, Node's `util.format`. `docs/ns-builtin-modules.md` is the cross-runtime contract both runtimes implement against. Resolution rules: - `ns:`/`node:` specifiers are intercepted before any filesystem or npm resolution, in all three entry points: the CommonJS require path (ModuleInternal::RequireCallbackImpl), the ES module resolve callback and the dynamic-import host callback (ModuleInternalCallbacks.cpp). A builtin can never be shadowed by a file or a package. - ESM is served by v8::Module::CreateSyntheticModule, exporting every member by name plus `default`, instantiated and cached per realm. - Unknown names in either scheme fail with exactly `No such built-in module: <specifier>` — synchronously for require, as a rejection for import(). - Builtins are singletons per realm: the main context and every worker build their own exports object. Android has no Caches class, so every per-realm cache is an isolate-keyed map released from disposeIsolate; the process-global g_moduleRegistry deliberately holds none of it. - Bare specifiers are untouched: `require("util")` still resolves through npm. - Exports are frozen. The builtin function wrapper becomes `(exports, require, module, binding, primordials)`. That `require` resolves builtin specifiers only — no path, no package, no filesystem — and is how `node:util` consumes `ns:util`, keeping all Node adaptation in the shim and out of `ns-util.js`. Behavior changes: - The generic fallback for unshimmed `node:` imports (a `console.warn` plus an empty default export) is gone; those imports now fail with the contract message instead of silently succeeding and breaking at first use. - The `node:fs` stub, whose members threw when called, is gone for the same reason: the contract requires unimplemented members to be absent, never present-but-throwing. - The `node:url`, `node:module` and `node:path` polyfills provide real functionality and are kept unchanged. They predate the registry and stay import-only, so `require("node:path")` reports the builtin as missing; this is documented in the spec's non-normative Android section. Mirrors NativeScript/ios#418.
1 parent ab3b18b commit 128493b

21 files changed

Lines changed: 1220 additions & 58 deletions

docs/ns-builtin-modules.md

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# `ns:` builtin modules
2+
3+
Cross-runtime contract for exposing runtime-provided modules to application
4+
code. This document is the specification both the iOS and Android runtimes
5+
implement; a capability must behave identically on both platforms before it
6+
ships in a stable release.
7+
8+
## The scheme
9+
10+
Builtin modules live under the URL-style `ns:` scheme, mirroring Node's
11+
`node:` prefix:
12+
13+
```js
14+
// CommonJS
15+
const util = require("ns:util");
16+
17+
// ES modules
18+
import util, { inspect } from "ns:util";
19+
const util2 = await import("ns:util");
20+
```
21+
22+
Rules:
23+
24+
- `ns:` specifiers are resolved by the runtime **before any filesystem or
25+
npm resolution**. They can never be shadowed by a file, a path mapping, or
26+
a package — and conversely, a file named `ns:util` is not reachable.
27+
- Resolution of an unknown builtin fails synchronously with an `Error` whose
28+
message is exactly `No such built-in module: ns:<name>` (matching Node's
29+
wording for familiarity).
30+
- A builtin module is a **singleton per JS realm** (main context and each
31+
worker get their own instance). `require("ns:util")` twice returns the same
32+
object; the CJS exports object and the ESM namespace expose the same
33+
underlying values (ESM additionally provides the exports object as
34+
`default`).
35+
- There is no bare-specifier fallback: `require("util")` is **not** an alias
36+
for `require("ns:util")`. The unprefixed name continues to resolve to npm
37+
packages as it always has.
38+
- Builtin exports are frozen. Apps patch behavior by wrapping, not by
39+
mutating the runtime's module.
40+
41+
## Modules
42+
43+
### `ns:util` (v1)
44+
45+
| export | description |
46+
|---|---|
47+
| `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. |
48+
| `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. |
49+
50+
**Stability caveat (verbatim from Node's contract):** the output of `inspect`
51+
(and therefore `format`'s object rendering) may change between runtime
52+
versions for readability; it is intended for humans and must not be parsed
53+
programmatically.
54+
55+
## `node:` compatibility shims
56+
57+
The same registry serves the `node:` scheme with **compatibility shims** so
58+
npm packages that require Node builtins by their prefixed names can run
59+
unmodified where a shim exists:
60+
61+
- A shim implements a documented **subset** of the corresponding Node module's
62+
API, backed by `ns:` modules. Unimplemented members are simply absent
63+
(so `typeof util.promisify === "function"` feature-checks behave
64+
correctly); they are never present-but-throwing.
65+
- **One source file per specifier.** A shim is its own module that consumes
66+
the `ns:` module it adapts through the internal require, and it owns *all*
67+
the adaptation — argument shapes, option names, aliases, anything that has
68+
to track Node. A standard `ns:` module never contains compatibility code
69+
and never knows a shim exists.
70+
- Shims are **lazy**: a shim's source is only evaluated when its specifier is
71+
first resolved, so an app that never touches the `node:` scheme never pays
72+
for one.
73+
- `node:` modules with no shim fail with the same error shape:
74+
`No such built-in module: node:<name>`.
75+
- **Bare specifiers are untouched**: `require("util")` resolves through npm
76+
as it always has (many apps bundle the `util` polyfill package). Only the
77+
explicit `node:` prefix reaches the shim registry, so existing apps cannot
78+
break. Bundler-level aliases (webpack/rollup) continue to work and take
79+
precedence at build time.
80+
- A shim is always a **distinct module object** from any `ns:` module, even
81+
when every member is re-exported unchanged. `ns:` modules may grow runtime-specific
82+
members freely; a `node:` shim only ever gains members that track Node's
83+
actual API. This mirrors how Bun (`bun:*`), Deno (`Deno.*`/JSR) and
84+
Cloudflare (`cloudflare:*`) all keep their own surface strictly apart from
85+
their `node:` compat layer.
86+
- Shims ship on both runtimes under the same parity rule as `ns:` modules.
87+
88+
### v1 shims
89+
90+
| module | exports | notes |
91+
|---|---|---|
92+
| `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. |
93+
94+
Candidates for future shims, in rough order of ecosystem demand:
95+
`node:events` (EventEmitter), `node:path` (pure JS), `node:buffer`,
96+
`node:process` (subset). Each requires a spec update here first.
97+
98+
## The internal require
99+
100+
Builtin modules reach each other — and only each other — through an internal
101+
`require` the runtime provides to every builtin source:
102+
103+
- It resolves **builtin specifiers only**. A path, a package name or any other
104+
specifier is not reachable from a builtin; an unregistered builtin name
105+
throws the same `No such built-in module: <specifier>` an app sees.
106+
- It materializes the target module on first use and returns the realm's
107+
singleton afterwards, which is what makes shims lazy.
108+
- Requiring a module that is still being built throws rather than recursing,
109+
so a dependency cycle between builtins is a loud error and not a hang.
110+
111+
This is the mechanism shims are built on, so it is normative: both runtimes
112+
provide it.
113+
114+
## Adding a builtin module
115+
116+
- The name is a short, lowercase identifier (`ns:util`, `ns:timers`, ...).
117+
- One module, one source file, one registry entry — including shims.
118+
- New modules and new exports require this document to be updated first and
119+
an implementation on both runtimes before a stable release; a module may
120+
ship on one platform behind a documented "experimental, iOS-only" (or
121+
Android-only) note in between.
122+
- Internal runtime machinery must never be reachable through the scheme:
123+
the registry distinguishes public modules from internal builtins, and only
124+
public ones resolve (Node's `canBeRequiredByUsers` split).
125+
126+
## Source-text modules: deliberately not supported
127+
128+
Builtins are classic function bodies, not ES modules, on both runtimes. If
129+
cross-builtin code sharing is ever needed, the first answer is bundling at
130+
generation time (author as ESM, emit function bodies); runtime source-text
131+
builtin modules (Node's `kSourceTextModule`) are justified only by a concrete
132+
need for live module semantics (TLA, live bindings, cyclic imports), which no
133+
current or planned builtin has. Revisit here before building either.
134+
135+
## iOS implementation notes (non-normative)
136+
137+
Builtin modules are function-body builtins (`NativeScript/runtime/js/`,
138+
see the README there) compiled via the RuntimeBuiltins table. The `ns:`
139+
resolver intercepts specifiers in the CommonJS require path and in the ES
140+
module resolve/dynamic-import callbacks; ESM consumption is served by a
141+
synthetic module whose exports are populated from the same per-realm exports
142+
object. The internal require is a fixed parameter of the builtin function
143+
wrapper (`exports`, `require`, `module`, `binding`, `primordials`).
144+
145+
iOS also keeps a pre-registry `node:url` polyfill (`fileURLToPath`,
146+
`pathToFileURL`) that predates this document. It is compiled from module
147+
source inside the ES module resolver and is therefore reachable through
148+
`import` only, not through `require()`.
149+
150+
## Android implementation notes (non-normative)
151+
152+
Builtin modules are function-body builtins
153+
(`test-app/runtime/src/main/cpp/js/`, see the README there) compiled via the
154+
RuntimeBuiltins table. The registry lives in
155+
`test-app/runtime/src/main/cpp/NsBuiltinModules.{h,cpp}` and intercepts
156+
specifiers in the CommonJS require path (`ModuleInternal::RequireCallbackImpl`)
157+
and in the ES module resolve and dynamic-import callbacks
158+
(`ModuleInternalCallbacks.cpp`); ESM consumption is served by a synthetic
159+
module whose exports are populated from the same per-realm exports object. The
160+
internal require is a fixed parameter of the builtin function wrapper
161+
(`exports`, `require`, `module`, `binding`, `primordials`).
162+
163+
Android has no `Caches` class, so every per-realm cache — exports objects,
164+
synthetic modules, the in-progress set, the cached `format` and the builtin
165+
`require` — lives in an isolate-keyed map released from `disposeIsolate`.
166+
The ES module registry app modules land in (`g_moduleRegistry`) is
167+
process-global and shared by every isolate; the builtin caches deliberately do
168+
not use it, so workers get their own instances as the spec requires.
169+
170+
Android also keeps three pre-registry `node:` polyfills that predate this
171+
document: `node:url` (`fileURLToPath`, `pathToFileURL`), `node:module`
172+
(`createRequire`) and `node:path` (`sep`, `delimiter`, `basename`, `dirname`,
173+
`extname`, `join`, `resolve`, `isAbsolute`). They are compiled from module
174+
source inside the ES module resolver and are therefore reachable through
175+
`import` only; `require("node:path")` reaches the registry and fails with the
176+
not-found message.
177+
178+
There is deliberately no `node:fs` polyfill and no catch-all for unshimmed
179+
`node:` names: a stub whose members throw on use violates the
180+
absent-not-present-but-throwing rule above, and an empty-default fallback lets
181+
an import that cannot work succeed.

eslint.config.mjs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Lint setup for the runtime's builtin JavaScript
22
// (test-app/runtime/src/main/cpp/js). Each file is compiled by BuiltinLoader
3-
// as a FUNCTION BODY with the fixed parameters `exports`, `module`, `binding`
4-
// and `primordials` (see that directory's README.md), which are declared as
5-
// globals here. no-undef is the typo net for binding-bag destructures and
3+
// as a FUNCTION BODY with the fixed parameters `exports`, `require`, `module`,
4+
// `binding` and `primordials` (see that directory's README.md), which are
5+
// declared as globals here. no-undef is the typo net for binding-bag destructures and
66
// native-global usage alike; no-restricted-properties keeps the captured
77
// intrinsics from being read off the live globals again.
88
import globals from 'globals';
@@ -15,8 +15,11 @@ const capturedStatics = [
1515
['Array', 'isArray', 'ArrayIsArray'],
1616
['ArrayBuffer', 'isView', 'ArrayBufferIsView'],
1717
['JSON', 'stringify', 'JSONStringify'],
18+
['Number', 'parseFloat', 'NumberParseFloat'],
19+
['Number', 'parseInt', 'NumberParseInt'],
1820
['Object', 'create', 'ObjectCreate'],
1921
['Object', 'defineProperty', 'ObjectDefineProperty'],
22+
['Object', 'freeze', 'ObjectFreeze'],
2023
['Object', 'getOwnPropertyDescriptor', 'ObjectGetOwnPropertyDescriptor'],
2124
['Object', 'getOwnPropertySymbols', 'ObjectGetOwnPropertySymbols'],
2225
['Object', 'getPrototypeOf', 'ObjectGetPrototypeOf'],
@@ -26,7 +29,7 @@ const capturedStatics = [
2629

2730
// Captured constructors. A destructure from `primordials` shadows the global,
2831
// so these only fire on the unguarded reference.
29-
const restrictedGlobals = ['Date', 'Map', 'Proxy', 'Set', 'String', 'TypeError'].map((name) => ({
32+
const restrictedGlobals = ['Date', 'Map', 'Number', 'Proxy', 'Set', 'String', 'TypeError'].map((name) => ({
3033
name,
3134
message: `Destructure ${name} from primordials — builtins must not read intrinsics off globals user code can replace.`,
3235
}));
@@ -46,6 +49,7 @@ export default [
4649
globals: {
4750
...globals.es2021,
4851
exports: 'readonly',
52+
require: 'readonly',
4953
module: 'readonly',
5054
binding: 'readonly',
5155
primordials: 'readonly',

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ require('./tests/testUncaughtErrorPolicy');
8181
// Runtime builtins keep working when app code replaces the intrinsics they use
8282
require('./tests/testPrimordials');
8383
require('./tests/testInspect');
84+
// The ns:/node: builtin modules
85+
require('./tests/testNsUtil');
8486
require("./tests/testConcurrentAccess");
8587

8688
require("./tests/testESModules.mjs");
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Static `import` of the builtins, so the ES module resolve callback is
2+
// exercised too (dynamic import() has its own fast path).
3+
import nsDefault, { inspect, format } from "ns:util";
4+
import nodeDefault, { inspect as nodeInspect } from "node:util";
5+
6+
export { nsDefault, inspect, format, nodeDefault, nodeInspect };
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// A builtin module is a singleton per realm, so the worker builds its own
2+
// ns:util rather than reaching the main context's instance.
3+
onmessage = function () {
4+
var util = require("ns:util");
5+
postMessage({
6+
formatted: util.format("%d apples", 3),
7+
singleton: require("ns:util") === util,
8+
frozen: Object.isFrozen(util),
9+
});
10+
};

0 commit comments

Comments
 (0)