Skip to content

feat: ns:util builtin module (inspect, format) - #418

Open
edusperoni wants to merge 2 commits into
feat/inspectfrom
feat/ns-util
Open

feat: ns:util builtin module (inspect, format)#418
edusperoni wants to merge 2 commits into
feat/inspectfrom
feat/ns-util

Conversation

@edusperoni

@edusperoni edusperoni commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #416 (inspect). Review only the last commit; the base branch is feat/inspect.

What

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.

v1 exports:

export
inspect(value[, options]) the console formatter from #416, now reachable from app code
format(fmt, ...args) Node's util.format: %s %d %i %f %j %o %O %%, extras appended space-separated

docs/ns-builtin-modules.md (added here) is the cross-runtime contract — the same document the Android runtime implements against, so a capability behaves identically on both platforms before it ships in a stable release.

Resolution rules

  • ns:/node: specifiers are intercepted before any filesystem or npm resolution, in all three entry points: the CommonJS require path (ModuleInternal.mm:219), the ES module resolve callback (ModuleInternalCallbacks.mm:719) and the dynamic-import host callback (ModuleInternalCallbacks.mm:1878). 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 (the exports object). The module is instantiated, evaluated and cached per realm, so repeated imports return the identical namespace.
  • 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 (the caches live in Caches, not in a process-global).
  • Exports are frozen.

node: shims

The same registry serves node: (spec: "node: compatibility shims"), so npm packages requiring Node builtins by their prefixed name can run where a shim exists.

  • One source file per specifier, one registry entry. node:util is its own builtin (node-util.js) that consumes ns:util through the internal require and re-exports its members. The shim owns all the Node adaptation — argument shapes, option names, aliases — so ns-util.js contains no compatibility code and does not know a shim exists. This is the reference pattern every future ns:* module and shim copies.
  • Shims are lazy: node-util.js is only compiled the first time node:util is resolved, so an app that never touches the node: scheme never pays for it.
  • node:util is a distinct, separately frozen module object from ns:util, even though every member is re-exported unchanged (nodeUtil.inspect === nsUtil.inspect) — per the spec bullet, mirroring how Bun (bun:*), Deno and Cloudflare (cloudflare:*) keep their own surface apart from their Node compat layer. ns:util can grow runtime-specific exports without widening the Node compatibility surface.
  • Missing members are simply absent (typeof util.promisify === "undefined"), never present-but-throwing, so feature checks work.
  • Bare specifiers are completely untouched: require("util") still resolves through npm exactly as before (many apps bundle the util polyfill package). Only the explicit prefix reaches the registry. There is a test asserting require("util") does not return the builtin.

⚠️ Behavior change: unshimmed node: ES imports previously got a generic in-memory polyfill (console.warn(...); export default {}) that broke at first use; they now fail with No such built-in module: node:<name>. The pre-existing node:url polyfill (fileURLToPath/pathToFileURL) is kept as-is and is still import-only; it is noted in the doc's non-normative iOS section.

The internal require (new wrapper parameter)

The builtin function wrapper becomes (exports, require, module, binding, primordials) — Node's order. require is created once per realm by BuiltinLoader and resolves builtin specifiers only: no path, no package, no filesystem. An unregistered name throws exactly No such built-in module: <specifier>, and requiring a module that is still loading throws instead of recursing (per-realm in-progress guard). It is documented as normative in the spec, since Android needs it to build shims the same way. C++ call sites are unchanged; builtins that don't need it simply ignore the parameter.

console integration

Console::BuildStringFromArgs calls the cached format with the full argument list, so console.log("%d apples", 3) prints 3 apples while console.log("100%"), unknown specifiers and a dangling % stay verbatim. Non-format calls keep joining with spaces exactly as before. If the builtin is unavailable the old per-argument loop still runs, so logging can never be taken down by the formatter.

Implementation

  • NativeScript/runtime/js/ns-util.js — the ns:util builtin, all intrinsic access through primordials (Number, NumberParseInt, NumberParseFloat, StringPrototypeCharCodeAt added, ESLint lists kept in sync). inspect is passed in via binding, so the module and console share one formatter instance.
  • NativeScript/runtime/js/node-util.js — the shim, four lines of re-export plus the rule about where adaptation code lives.
  • NativeScript/runtime/NsBuiltinModules.{h,cpp} — the registry (one specifier → one BuiltinId), lazy per-realm materialization with a cycle guard, and the synthetic-module evaluation steps. Adding a module is one row plus one file.

Stability caveat

Verbatim from Node's contract, and repeated in the doc: inspect's output (and therefore format's object rendering) may change between runtime versions for readability. It is for humans and must not be parsed programmatically.

Tests

TestRunner/app/tests/NsUtilTests.js (+ an .mjs fixture that statically imports both specifiers, so the resolve callback is covered and not just the dynamic-import fast path): frozen exports, singleton identity, every format specifier, %%/unknown/dangling %, %j on a circular value, extras, non-string first argument, console.log("%d apples", 3), dynamic + static import identity, node:util distinctness with shared members, unknown ns:/node: messages, and the bare-specifier non-change.

Full suite on the simulator: 941 tests, 0 failures (922 baseline + 19 new). format was additionally verified against Node's own util.format on the host for every non-object case.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3f5b8ba5-ff6a-4261-b9b6-35277f170c67

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Runtime-provided modules now resolve under the URL-style `ns:` scheme,
ahead of any filesystem or npm resolution, with `node:` compatibility
shims served from the same registry. v1 ships `ns:util` (inspect, format)
and a `node:util` shim re-exporting its members from a distinct, frozen
module object.

Each specifier is one source file and one registry entry: a shim is its
own builtin that reaches the `ns:` module it adapts through a new fixed
wrapper parameter, `require`, which resolves builtin specifiers and
nothing else. Shims therefore compile only when first resolved and own
every bit of Node adaptation, keeping compatibility knowledge out of the
standard modules.

format() is Node's util.format (%s %d %i %f %j %o %O %%, extras appended
space-separated), and console.* routes its arguments through it, so
`console.log("%d apples", 3)` works while unknown and dangling percent
signs stay verbatim.

Resolution is wired into the CommonJS require path, the ES module resolve
callback and the dynamic-import callback; ESM consumption is served by a
per-realm synthetic module. An unknown name in either scheme fails with
`No such built-in module: <specifier>`, which replaces the warn-and-export-
nothing polyfill previously handed to unshimmed `node:` imports. Bare
specifiers are untouched.

docs/ns-builtin-modules.md is the cross-runtime contract both runtimes
implement.
…s inputs

Builtins stay classic function bodies on both runtimes; cross-builtin
sharing, if ever needed, starts with generation-time bundling. The js2c
guard turns an accidental .mjs into a build error pointing at the spec.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants