ns: builtin modules
August 20, 2026 · View on GitHub
Cross-runtime contract for exposing runtime-provided modules to application code. This document is the specification both the iOS and Android runtimes implement; a capability must behave identically on both platforms before it ships in a stable release.
Everything under The scheme, Module reference, Loading ES modules, The internal require and Adding a builtin module is normative. Platform specifics that a portable app must not depend on are called out as platform notes, and the closing section collects the iOS ones.
The scheme
Builtin modules live under the URL-style ns: scheme, mirroring Node's
node: prefix:
// CommonJS
const util = require("ns:util");
// ES modules — the exports object is also the default export.
import util, { inspect } from "ns:util";
const same = await import("ns:util");
console.log(same.default === util, same.inspect === inspect); // true true
Rules:
ns:andnode:specifiers are resolved by the runtime before any filesystem or npm resolution. They can never be shadowed by a file, a path mapping, or a package — and conversely, a file namedns:utilis not reachable.- Resolution of an unregistered builtin fails with an
Errorwhose message is exactlyNo such built-in module: <specifier>(matching Node's wording for familiarity) — e.g.No such built-in module: node:fs. The failure is identical throughrequire(), a staticimport, and a dynamicimport(); the first two throw, the third rejects. - A builtin module is a singleton per JS realm (main context and each
worker get their own instance).
require("ns:util")twice returns the same object; the CJS exports object and the ESM namespace expose the same underlying values (ESM additionally provides the exports object asdefault). - There is no bare-specifier fallback:
require("util")is not an alias forrequire("ns:util"). The unprefixed name continues to resolve to npm packages as it always has. - Builtin exports are frozen. Apps patch behavior by wrapping, not by mutating the runtime's module.
Module reference
ns:util
| export | description |
|---|---|
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. |
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. |
const { inspect, format } = require("ns:util");
// Depth-limited by default; pass `depth` to see further down.
const tree = { a: { b: { c: { d: 1 } } } };
inspect(tree); // "{ a: { b: { c: [Object] } } }"
inspect(tree, { depth: 4 }); // "{ a: { b: { c: { d: 1 } } } }"
// Cycles are rendered, not thrown on.
const cyclic = { name: "root" };
cyclic.self = cyclic;
inspect(cyclic); // '{ name: "root", self: [Circular] }'
format("%s took %dms", "boot", 12.5); // "boot took 12.5ms"
format("%j", { ok: true }); // '{"ok":true}'
format("100% sure", "extra"); // "100% sure extra" (no placeholder consumed)
Stability caveat (verbatim from Node's contract): the output of inspect
(and therefore format's object rendering) may change between runtime
versions for readability; it is intended for humans and must not be parsed
programmatically.
ns:runtime
Runtime-level configuration. Keys, value domains, and scope are defined and validated natively; the module surface is a thin frozen wrapper.
| export | description |
|---|---|
setConfig(key, value) | Sets a runtime config key. Throws TypeError on an unknown key, an invalid value, or (for process-wide keys) when called from a worker isolate. |
getConfig(key) | Returns the current value of a config key. Throws TypeError on an unknown key. Readable from any isolate. |
Config keys:
| key | values | scope | default |
|---|---|---|---|
releasedObjectPolicy | "report" | "throw" | process-wide (main-isolate writes only; read live by every isolate) | "report" |
debug | comma-separated category list, e.g. "esm,fetch" | process-wide (main-isolate writes only; read live by every isolate) | the NS_DEBUG environment variable, or "" |
const { setConfig, getConfig } = require("ns:runtime");
// Turn on module-resolution and transport tracing for a diagnostic run.
setConfig("debug", "esm,fetch");
getConfig("debug"); // "esm,fetch"
// The list replaces the whole set, so turning tracing off needs no knowledge
// of what was already on.
setConfig("debug", "");
// Make released-object access loud while hunting a lifetime bug.
setConfig("releasedObjectPolicy", "throw");
getConfig("releasedObjectPolicy"); // "throw"
The TypeError messages are part of the contract:
| condition | message |
|---|---|
| unknown key (either function) | Unknown runtime config key: '<key>' |
bad setConfig arity or non-string key | setConfig expects (key: string, value) |
bad getConfig arity or non-string key | getConfig expects (key: string) |
| process-wide key written from a worker | '<key>' is process-wide and can only be set from the main isolate |
invalid releasedObjectPolicy value | 'releasedObjectPolicy' must be 'report' or 'throw' |
non-string debug value | 'debug' must be a comma-separated category string (<categories>), or '' to disable tracing |
Remote-module security (security.allowRemoteModules,
security.remoteModuleAllowlist) is not part of this surface. Those
values are read once from nativescript.config / package.json the first time
the HTTP loader gates a fetch, and they cannot be inspected or changed
through getConfig / setConfig.
releasedObjectPolicy
Controls what happens when JS touches a wrapper whose native counterpart has
already been released (a state a resurrected object can expose — see the iOS
runtime's docs/knowledge/v8-resurrecting-finalizers.md):
"report"(default): the operation no-ops — reads produceundefined, writes are skipped,toStringyields a<Pointer: released>-style placeholder — and a cancelablereleasednativeaccessevent fires onglobalThis(event.errorcarries aReferenceErrorwhose stack names the touch site;event.operationnames the API surface, e.g."struct field assignment"). Reports are deduplicated per released object. In debug builds the event's default action is aconsole.warn; a listener that handles the report suppresses it withpreventDefault()."throw": the touch throws a catchableReferenceErrorsynchronously, and no event fires.
const { setConfig } = require("ns:runtime");
setConfig("releasedObjectPolicy", "report");
addEventListener("releasednativeaccess", (event) => {
// Forwarding the report to a crash reporter counts as handling it, so
// suppress the default console.warn.
myCrashReporter.record(event.error, { operation: event.operation });
event.preventDefault();
});
debug
Turns on the runtime's category-scoped trace logs. Categories:
| category | covers |
|---|---|
esm | module resolution, compilation, linking, evaluation, registry keying |
fetch | the HTTP module transport (one line per fetched URL — high volume) |
registry | registry invalidation and dynamic-import cache bookkeeping |
Each write replaces the whole set, so setConfig('debug', '') disables
tracing and no caller needs to know what was already on. getConfig('debug')
returns the canonical comma-separated list of what is enabled. Unknown names
are ignored, with one warning line naming the valid ones.
The same list can be given before boot as the NS_DEBUG environment variable
(NS_DEBUG=esm,fetch), which is the only way to trace boot itself. Traces are
compiled into release builds as well: a release build that cannot be traced is
a release build that cannot be diagnosed.
Platform note (iOS): lines are written to the unified log under subsystem
org.nativescript.runtime with the category as the os_log category, so
log stream can filter them without matching message text.
ns:module
The module-loader control surface: import-map vocabulary, registry
invalidation, and the createRequire family. It is pure mechanism — every
policy concern (boot orchestration, hot-update protocols, full reload, CSS
apply, worker teardown) belongs to whatever tooling drives it.
| export | description |
|---|---|
configureLoader(config) | Installs loader policy for the calling isolate. Sections: importMap (imports + scopes), volatilePatterns (URL substrings always re-fetched), canonicalization (registry-keying vocabulary). Each present section replaces its state wholesale, an empty array included. Throws TypeError on any malformed input, having validated the whole config first, so a rejected call installs nothing. |
invalidateModules(urls) | Evicts the given URLs (canonicalized) from the module registry and marks them bust-next-fetch, so the next network fetch bypasses every HTTP cache layer. Takes an array of strings; throws TypeError otherwise. |
getLoadedModuleUrls() | The URL-like keys currently in the module registry, as an array of strings (used to compute full-reload eviction sets). A key qualifies when it starts with blob: or contains :// — plain filesystem paths are deliberately excluded, because a reload computes its eviction set from what a server can re-serve. |
createRequire(filenameOrURL) | A require resolving against filenameOrURL's directory (a trailing slash names the directory itself). Accepts an absolute path string, a file: URL string, or a URL object; anything else throws TypeError, and an http(s) base is refused outright because require() of a dev-served module is not supported — import those. ES module graphs load under Node's require(esm) rule: a graph containing top-level await is refused before it evaluates. |
createPumpingRequire(filenameOrURL, options?) | Same argument contract and same resolution, but an ES module graph with top-level await is evaluated by driving the loop until it settles, instead of being refused. Callable only from a task context. See Pumping requires. |
ns:module (loader policy — structured, installed ahead of traffic) is
deliberately separate from ns:runtime (live key-value runtime flags via
setConfig/getConfig).
Both functions validate their arguments and throw TypeError on anything
malformed — the behavior WebIDL gives a web API and ERR_INVALID_ARG_TYPE
gives a Node one. Nothing is silently skipped or filtered: a mistyped section
or a typo'd key is a caller bug, and reporting it is what keeps it from
becoming a config that quietly does nothing.
| condition | message |
|---|---|
| missing or non-object config | configureLoader expects a config object |
| a key other than the three sections | configureLoader: unknown option '<key>' |
volatilePatterns not an array | configureLoader: volatilePatterns must be an array of strings |
a non-string in volatilePatterns | configureLoader: volatilePatterns[<index>] must be a string |
canonicalization not an object | configureLoader: canonicalization must be an object |
a canonicalization sub-key not an array | configureLoader: canonicalization.<key> must be an array of strings |
a non-string in a canonicalization sub-key | configureLoader: canonicalization.<key>[<index>] must be a string |
invalidateModules argument not an array | invalidateModules expects an array of URL strings |
| a non-string in that array | invalidateModules: urls[<index>] must be a string |
configureLoader validates the entire config — every section plus the key
names — before installing any of it. A call that throws therefore leaves all
three sections exactly as they were: the atomicity the import map alone used to
have now covers the whole call, so a config that is half-right cannot land
half-applied.
"Replaces its state wholesale" is keyed on a section being present, not on
its contents: volatilePatterns: [] clears the list, and an absent section is
left alone. undefined counts as absent, so spreading an optional section is
safe.
const { configureLoader, getLoadedModuleUrls, invalidateModules } =
require("ns:module");
configureLoader({
importMap: {
imports: {
"lodash": "http://localhost:8080/vendor/lodash.mjs",
"@scope/pkg/": "http://localhost:8080/pkg/",
},
},
volatilePatterns: ["/@ns/"],
});
// Later: drop everything the server says changed, so the next import refetches.
const stale = getLoadedModuleUrls().filter((url) => url.includes("/src/"));
invalidateModules(stale);
createRequire gives a module-relative require from anywhere, including an
ES module that has no __filename:
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const config = require("./config.json");
const helper = require("./helpers/format.js");
Neither require implements require.resolve, require.cache, or
require.main. They are absent rather than throwing, so a feature check
works; adding them is a change to this specification first.
Debug builds additionally carry canonicalizeHttpUrlKey(url), a pure test
diagnostic that takes a string and throws
canonicalizeHttpUrlKey expects a URL string otherwise; release builds omit
it. Missing members are simply absent — never
present-but-throwing — so feature checks work. The module is registered in
every build: the security boundary for remote module loading sits at the
network layer (security.allowRemoteModules in nativescript.config, enforced
by the HTTP loader), not at the module registry and not at ns:runtime.
Import maps and scopes
importMap takes the WHATWG shape:
const { configureLoader } = require("ns:module");
configureLoader({
importMap: {
imports: {
"lodash": "http://host/vendor/lodash.mjs",
"@scope/pkg/": "http://host/pkg/",
},
scopes: {
"http://host/legacy/": { "lodash": "http://host/vendor/lodash-3.mjs" },
},
},
});
Within any one section, a specifier matches exactly first, then against the
longest trailing-slash key, whose remainder is appended to the target. A key
ending in / must have a target ending in /.
A scope key is matched as a plain prefix of the importing module's canonical
registry key — an absolute http(s) URL for a served module, or a canonical
absolute path for a file on disk. That key is this runtime's analogue of the
web's resolved referrer URL, which is what scope prefixes match in a browser.
End a scope key with / to keep it on a directory boundary. Resolution
consults the most specific matching scope first (longest prefix wins), then
progressively less specific ones, then imports — so a scope can override a
global mapping for one subtree and fall through to it everywhere else. The
synchronous resolver, the graph walk, and import() all resolve through the
same cascade.
The whole map is parsed and validated before any of it is installed: a
rejected map throws a TypeError out of configureLoader and the previously
installed map keeps resolving, so a typo in an update cannot empty a live
session's vocabulary. Every message is prefixed configureLoader: .
| condition | message (after the configureLoader: prefix) |
|---|---|
importMap is neither an object nor a non-empty string | importMap must be an object or a JSON string |
| empty JSON | an import map must be a non-empty JSON object |
| unparseable JSON | an import map must be valid JSON: <detail> |
| JSON that is not an object | an import map must be a JSON object |
any import-map section other than imports/scopes | unsupported import-map section '<name>'; only "imports" and "scopes" are supported |
imports is not an object | the "imports" section must be an object |
scopes is not an object | the "scopes" section must be an object |
| non-string scope key | scopes: every scope key must be a string |
| empty scope key | scopes: a scope key must not be empty |
| a scope's value is not an object | scopes: the map for scope '<scope>' must be an object |
Inside either section — labelled imports or scope '<scope>':
| condition | message |
|---|---|
| non-string specifier key | <section>: every key must be a string |
| empty specifier key | <section>: a specifier key must not be empty |
| non-string target | <section>: the target for '<specifier>' must be a string |
| empty target | <section>: the target for '<specifier>' must not be empty |
| trailing-slash key, non-trailing-slash target | <section>: the target for '<specifier>' must end with '/' because the specifier key does |
Registry canonicalization
The registry keys modules by a canonical URL. The mechanism — fragment strip, cache-buster param drop, param sort — is the runtime's; the vocabulary is server policy, supplied here:
| key | meaning |
|---|---|
stripParams | query param names that are pure cache busters and are dropped for dev endpoints (e.g. t, v, import) |
forPathPrefixes | path prefixes (starts-with) identifying the dev endpoints whose query may be normalized (e.g. /ns/, /@id/) |
preserveQueryFor | path substrings whose query is the module identity and must be preserved verbatim (e.g. /@ng/component) |
const { configureLoader } = require("ns:module");
configureLoader({
canonicalization: {
stripParams: ["t", "v", "import"],
forPathPrefixes: ["/ns/", "/@id/"],
preserveQueryFor: ["/@ng/component"],
},
});
The three are not independent: preserveQueryFor is consulted first, and a
path matching it returns with its query intact, so forPathPrefixes and
stripParams never see it. A path that survives that check is normalized only
when it also matches forPathPrefixes; anything else keeps its query
unchanged. Ordering it the other way would let a cache-buster name listed in
stripParams erase a query that is the module's identity.
Presence of the canonicalization object marks the vocabulary as configured
and replaces the built-in fallback entirely; empty arrays are honored as
explicit policy, exactly as for volatilePatterns.
Reconfiguration and workers
The loader vocabulary is per-isolate: configureLoader writes the isolate
that calls it and nothing is shared between isolates, so no lock guards it.
A worker receives a copy of its parent's vocabulary, captured on the parent's thread while the worker spawns and installed on the worker's isolate before it loads its first module. That copy is a snapshot: reconfiguring the parent afterwards leaves running workers on the vocabulary they started with.
Normatively: tooling that reconfigures loader vocabulary must restart
workers for the change to reach them. A worker started after the
configureLoader call resolves through the new vocabulary; a live worker
never observes a later reconfiguration.
node: compatibility shims
The same registry serves the node: scheme with compatibility shims so
npm packages that require Node builtins by their prefixed names can run
unmodified where a shim exists:
- A shim implements a documented subset of the corresponding Node module's
API, backed by
ns:modules. Unimplemented members are simply absent (sotypeof util.promisify === "function"feature-checks behave correctly); they are never present-but-throwing. - One source file per specifier. A shim is its own module that consumes
the
ns:module it adapts through the internal require, and it owns all the adaptation — argument shapes, option names, aliases, anything that has to track Node. A standardns:module never contains compatibility code and never knows a shim exists. - Shims are lazy: a shim's source is only evaluated when its specifier is
first resolved, so an app that never touches the
node:scheme never pays for one. node:modules with no shim fail with the same error shape:No such built-in module: node:<name>.- Bare specifiers are untouched:
require("util")resolves through npm as it always has (many apps bundle theutilpolyfill package). Only the explicitnode:prefix reaches the shim registry, so existing apps cannot break. Bundler-level aliases (webpack/rollup) continue to work and take precedence at build time. - A shim is always a distinct module object from any
ns:module, even when every member is re-exported unchanged.ns:modules may grow runtime-specific members freely; anode:shim only ever gains members that track Node's actual API. This mirrors how Bun (bun:*), Deno (Deno.*/JSR) and Cloudflare (cloudflare:*) all keep their own surface strictly apart from theirnode:compat layer. - Shims ship on both runtimes under the same parity rule as
ns:modules.
| module | exports | notes |
|---|---|---|
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. |
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. |
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. |
node:url's parsing goes through the URL intrinsic, so file://localhost/x is
accepted (the URL spec folds a localhost authority to none) while any other
host throws, and the query and fragment are never part of the path.
fileURLToPath rejects a non-file: scheme and rejects %2F in the path
rather than decoding a separator into it. pathToFileURL returns a real URL
and requires an absolute path: Node resolves a relative one against the
process working directory, and there is no such thing here.
const { fileURLToPath, pathToFileURL } = require("node:url");
fileURLToPath("file:///app/src/main.js"); // "/app/src/main.js"
fileURLToPath("file://localhost/app/a.js"); // "/app/a.js"
fileURLToPath("file:///app/a.js?v=2#frag"); // "/app/a.js"
pathToFileURL("/app/my file.js").href; // "file:///app/my%20file.js"
Its TypeError messages are Node's:
| condition | message |
|---|---|
| argument is neither a string nor a URL-like object, or is unparseable | The "path" argument must be of type string or an instance of URL. |
non-file: scheme | The URL must be of scheme file |
a host other than localhost or empty | File URL host must be "localhost" or empty |
%2F in the path | File URL path must not include encoded / characters |
pathToFileURL given a non-string | The "path" argument must be of type string. |
pathToFileURL given a relative path | The "path" argument must be an absolute path. |
Loading ES modules
The require() specifier
Every require — the global one and any minted by createRequire /
createPumpingRequire — takes a string specifier. Anything else throws a
TypeError with Node's ERR_INVALID_ARG_TYPE wording, before any builtin,
http(s) or filesystem handling runs:
The "id" argument must be of type string. Received <what>
<what> follows Node's determineSpecificType: undefined, null,
type number (42), an instance of Object, function foo, and so on.
require() of an ES module
require() of an ES module works, under Node's require(esm) rule: the graph
is loaded and evaluated synchronously unless it contains top-level await,
in which case it is refused before evaluation with an Error reading
require() cannot load ES module '<canonical path>': the module graph contains top-level await. Use import() or createPumpingRequire from ns:module instead.
The refusal never evicts the module: the graph stays instantiated, so the very
same module still loads through import(). The text above is wrapped in
require()'s usual context block, so match it with toContain-style
substring checks rather than full-string equality.
What require() of an ES module returns
A namespace object is not a CommonJS exports object, so the runtime applies
Node's populateCJSExportsFromESM cascade, in this order:
- An own export literally named
module.exportswins outright — its value is whatrequire()returns. This is the escape hatch for a module that wants full control of its CJS shape. - Otherwise the namespace is returned unchanged when it has no own
defaultexport, or when it already declares its own__esModule. Declaring__esModuleyourself is therefore an explicit opt-out of step 3. - Otherwise (an own
default, no own__esModule)require()returns a live-binding facade: a synthetic module re-exporting everything from the target plus__esModule = true. Transpiled consumers reading_mod.__esModule ? _mod.default : _modfind the default, and because the facade re-exports rather than copies, bindings stay live.
// a.mjs — no default export: the namespace passes through.
export const x = 1;
// require("./a.mjs") → { x: 1 }
// b.mjs — a default and no __esModule: the facade is built.
export default function boot() {}
export const version = "1.0";
// require("./b.mjs") → { default: boot, version: "1.0", __esModule: true }
// c.mjs — takes over the CJS shape completely.
const handler = () => {};
export { handler as "module.exports" };
// require("./c.mjs") → handler
Pumping requires
createPumpingRequire lifts the top-level-await refusal by driving the loop —
running nestable tasks and draining microtasks — until the graph settles.
Options are validated once, when the require is minted; a require() call
itself does no option work. Unknown keys throw rather than being silently
ignored.
| option | values | default | meaning |
|---|---|---|---|
deadlineSeconds | positive finite number | 60 | how long the graph gets to settle in-pump. Governs the evaluation-settle phase only — the graph walk's fetch deadline is separate and unaffected. |
onTimeout | "throw" | "return-pending" | "throw" | what an expired deadline means. "return-pending" hands back a namespace whose evaluation is still in flight. |
pumpRunLoop | boolean | false | also give the platform runloop a slice per pump iteration, for graphs whose progress depends on native transports rather than engine tasks. |
Validation errors, all TypeError:
| condition | message |
|---|---|
options present but not an object | createPumpingRequire: options must be an object |
| unrecognized key | createPumpingRequire: unknown option '<key>' |
bad deadlineSeconds (non-number, non-finite, <= 0) | createPumpingRequire: 'deadlineSeconds' must be a positive finite number |
bad onTimeout | createPumpingRequire: 'onTimeout' must be 'throw' or 'return-pending' |
bad pumpRunLoop | createPumpingRequire: 'pumpRunLoop' must be a boolean |
options passed to createRequire | options are not supported on createRequire |
Both requires share the base-argument contract, and both reject an http(s)
base:
| condition | message |
|---|---|
not an absolute path, file: URL string, or URL object | The argument 'filename' must be a file URL object, file URL string, or absolute path string. |
an http(s) base | createRequire() cannot take an http(s) URL (<value>): require() of a dev-served module is not supported. Pass an app-root file path and use import() for remote modules. |
The microtask-reentrancy refusal. The loop cannot be pumped re-entrantly:
the engine ignores a microtask checkpoint while the isolate is already draining
the microtask queue. A top-level await resumes through a promise reaction — a
microtask — so such a graph can never settle from inside a microtask turn.
Requiring one from after an await or inside a .then callback therefore
throws immediately, before evaluation, leaving the graph instantiated so
import() can still load it:
createPumpingRequire cannot settle module graph '<canonical path>' from inside a microtask (after an await or inside a promise callback): the event loop cannot be pumped re-entrantly. Call it from a task context, or use import().
Call it from a task context instead — a native boundary, an event handler, a timer callback, or module evaluation itself. A synchronous graph needs no pumping and stays legal from anywhere, microtask turns included.
What an HTTP module response must be
A module fetched over http(s) is classified by status and MIME type before it
ever reaches the compiler, so a dev server that answers with an error page
produces a clear diagnostic instead of a syntax error. Both the synchronous
fallback and the async graph walk use the same classifier, so they cannot
disagree about what a response means. Every failure below surfaces as a plain
Error whose message is exactly the quoted text — as a throw during module
instantiation, or as the rejection of a dynamic import().
The MIME essence is the Content-Type with everything from the first ;
discarded, then trimmed of spaces and tabs and lowercased — so
Content-Type: TEXT/JavaScript; charset=utf-8 has essence text/javascript.
Loads as JavaScript — the HTML spec's JavaScript MIME type essence list, matched exactly:
application/ecmascript, application/javascript, application/x-ecmascript,
application/x-javascript, text/ecmascript, text/javascript,
text/javascript1.0, text/javascript1.1, text/javascript1.2,
text/javascript1.3, text/javascript1.4, text/javascript1.5,
text/jscript, text/livescript, text/x-ecmascript, text/x-javascript.
Loads as a JSON module: essence application/json, text/json, or any
essence ending in +json (e.g. application/vnd.api+json).
An empty 2xx body with a JavaScript MIME is a valid empty module. Type-only TypeScript modules transform to zero runtime code and dev servers serve them as empty 200s; the runtime substitutes a canonical empty module rather than failing the whole graph. An empty JSON body is a failure — there is no canonical empty JSON module.
Failures, in the order they are checked:
| condition | message |
|---|---|
| no response at all | HTTP import failed: <url> (network error) |
| status 204 or 205 | HTTP import failed: <url> (status=<status>, no content) |
| any other non-2xx status | HTTP import failed: <url> (status=<status>) |
| missing or empty Content-Type | Expected a JavaScript module but '<url>' responded with no MIME type |
| JSON MIME, empty body | Expected a JSON module but '<url>' responded with an empty body |
any other MIME (e.g. text/html) | Expected a JavaScript module but '<url>' responded with MIME type '<essence>' |
Ahead of all of these sits the security gate: when remote module loading is not
permitted, no request is made at all and the failure is
HTTP import blocked: remote module loading is not allowed for <url>.
204 and 205 are checked before the MIME type, so a "no content" response fails
as such even when it carries a JavaScript Content-Type — the web likewise
treats it as a network error for a module script rather than as an empty
module. The <essence> in the foreign-MIME message is the normalized essence,
not the raw header.
App entries and bootstraps
An app's entry can be either CommonJS or an ES module, and the choice decides when loader vocabulary can be installed.
The ordering rule is normative: configureLoader must run before any ES
module traffic it is meant to govern. The import map is consulted inside the
engine's synchronous resolver, so it cannot be produced on demand — it has to
be installed ahead of the imports that need it.
An ES module main entry is supported directly, top-level await included.
When the app's main resolves to an ES module, the entry is evaluated as a
module rather than require()d, so import/export are legal there. A local
entry is given a short, non-throwing yield: one brief in-place window in
which the graph may settle, after which evaluation simply continues on the real
event loop. Only nestable tasks can run while the entry's frames are on the
stack, so a top-level await parked on anything else could never settle in
place; returning instead of throwing is the Node shape. Should the entry's
evaluation promise still be pending when the yield ends, a boot backstop
holds the process until it settles, bounded at twice the module deadline.
Three outcomes are fatal, in every build: the entry's evaluation rejects, the backstop's bound expires with it still pending, or execution starts terminating. Termination outranks the other two — an entry that settles in the same slice a terminate lands still reports the terminating fatal, because carrying on would run app code on an isolate the engine has been told to stop. Each evicts the entry from the module registry before failing — a half-evaluated entry must not be reachable by a later import — and then throws rather than returning into an app whose entry never ran.
The trade-off: an ES module entry's own static imports resolve before its
body runs, so anything that needs configureLoader to have run must be reached
through a dynamic import() after that call. Keep the entry's static imports
to builtins only.
// main.mjs — static imports are builtins only, so nothing races the config.
import { configureLoader } from "ns:module";
configureLoader({
importMap: { imports: { "lodash": "http://localhost:8080/vendor/lodash.mjs" } },
});
// Everything that resolves through the map is reached dynamically, after.
const { start } = await import("./app.mjs");
start();
A CommonJS bootstrap avoids that constraint by being synchronous: it configures the loader and then pulls in the ES module entry, with no static imports to resolve early.
// main.js — a CommonJS bootstrap for an ESM app.
const { configureLoader, createPumpingRequire } = require("ns:module");
configureLoader({
importMap: { imports: { "lodash": "http://localhost:8080/vendor/lodash.mjs" } },
});
createPumpingRequire(__filename, {
pumpRunLoop: true,
onTimeout: "return-pending",
deadlineSeconds: 1,
})("./entry.mjs");
Two warnings on that bootstrap, both load-bearing:
pumpRunLoop: trueis sane only while boot owns the runloop. After boot the runloop belongs to the app, and slicing it from inside a require re-enters arbitrary runloop sources — including UI callbacks — underneath JS frames.- With
onTimeout: "return-pending"the returned namespace may still be evaluating. A bootstrap must discard it and never read a binding off it; reading one is a TDZ error at best.
Pick whichever fits the app: the ESM entry is simpler and needs no bootstrap file, the CommonJS bootstrap buys unconstrained ordering.
Platform note (iOS): an entry whose top-level code reaches
UIApplicationMain before its first await needs none of this — evaluation
never returns, so no deadline ever arms and the app's own runloop services
whatever is still in flight. The boot backstop only runs when the entry has
not reached UIApplicationMain.
The internal require
Builtin modules reach each other — and only each other — through an internal
require the runtime provides to every builtin source. This is the mechanism
shims are built on, so it is normative: both runtimes provide it.
- It resolves builtin specifiers only. A path, a package name or any other
specifier is not reachable from a builtin; an unregistered builtin name
throws the same
No such built-in module: <specifier>an app sees. - It materializes the target module on first use and returns the realm's singleton afterwards, which is what makes shims lazy.
- Requiring a module that is still being built throws rather than recursing, so
a dependency cycle between builtins is a loud error and not a hang. The
message is exactly
Circular require of built-in module: <specifier>.
Adding a builtin module
- The name is a short, lowercase identifier (
ns:util,ns:timers, ...). - One module, one source file, one registry entry — including shims.
- New modules and new exports require this document to be updated first and an implementation on both runtimes before a stable release; a module may ship on one platform behind a documented "experimental, iOS-only" (or Android-only) note in between.
- Internal runtime machinery must never be reachable through the scheme.
That last rule holds because public modules and internal builtins are two separate loading paths, not one registry with a per-entry flag:
- The public registry is a table mapping specifier → builtin, and it is the
only thing the
ns:/node:resolver consults. A specifier absent from it does not resolve, full stop. Today it holds six entries:ns:module,ns:runtime,ns:util,node:module,node:url,node:util. - Internal builtins (the intrinsics snapshot, the require factory, the console formatter, and so on) are invoked directly from their own native call sites. They are never named in the public registry, so there is no specifier that could reach them and nothing to mark private.
Adding an internal builtin therefore cannot accidentally expose it; exposing one is an explicit registry entry, which is also the change this document has to describe.
Source-text modules: deliberately not supported
Builtins are classic function bodies, not ES modules, on both runtimes. If
cross-builtin code sharing is ever needed, the first answer is bundling at
generation time (author as ESM, emit function bodies); runtime source-text
builtin modules (Node's kSourceTextModule) are justified only by a concrete
need for live module semantics (TLA, live bindings, cyclic imports), which no
current or planned builtin has. Revisit here before building either.
iOS implementation notes (non-normative)
Builtin modules are function-body builtins (NativeScript/runtime/js/, see the
README there) compiled via the RuntimeBuiltins table. The ns: resolver
intercepts specifiers in the CommonJS require path and in the ES module
resolve/dynamic-import callbacks; ESM consumption is served by a synthetic
module whose exports are populated from the same per-realm exports object. The
internal require is a fixed parameter of the builtin function wrapper
(exports, require, module, binding, primordials).
A local entry counts as an ES module when its path ends in .mjs. The module
deadline is a single constant (60 seconds) shared by the entry's settle window,
the pumped graph walk, and — doubled, at 120 seconds — the boot backstop in
NativeScript.mm, so the waits stay ordered: transport timeouts < module
deadline < boot backstop. The local entry's short yield is one second with
return-pending behavior and no runloop slicing; an HTTP entry instead gets the
full deadline, throws on expiry, and does slice the runloop, because the tooling
driving it needs the rejection reason synchronously.
Type declarations for the ns: modules live in types/ — one .d.ts per
module, referenced from types/index.d.ts. The node: shims are deliberately
not declared there: programs that include @types/node already have
declarations for those specifiers, and a second declare module block for the
same specifier would clash. That a shim exposes less than Node does is a
runtime concern, documented here.