diff --git a/build.gradle b/build.gradle index 221c47de6..cffa741df 100644 --- a/build.gradle +++ b/build.gradle @@ -193,6 +193,9 @@ def getAssembleReleaseBuildArguments = { -> if (onlyX86) { arguments.add("-PonlyX86") } + if (project.hasProperty("abis")) { + arguments.add("-Pabis=${project.property('abis')}") + } if (useCCache) { arguments.add("-PuseCCache") } @@ -462,6 +465,9 @@ def getRunTestsBuildArguments = { taskName -> if (onlyX86) { arguments.add("-PonlyX86") } + if (project.hasProperty("abis")) { + arguments.add("-Pabis=${project.property('abis')}") + } if (useCCache) { arguments.add("-PuseCCache") } diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md index 589b42acc..c7e48c10e 100644 --- a/docs/ns-builtin-modules.md +++ b/docs/ns-builtin-modules.md @@ -5,6 +5,12 @@ 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](#the-scheme), [Module reference](#module-reference), +[Loading ES modules](#loading-es-modules), [The internal require](#the-internal-require) +and [Adding a builtin module](#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 Android ones. + ## The scheme Builtin modules live under the URL-style `ns:` scheme, mirroring Node's @@ -13,20 +19,27 @@ Builtin modules live under the URL-style `ns:` scheme, mirroring Node's ```js // CommonJS const util = require("ns:util"); +``` -// ES modules +```js +// ES modules — the exports object is also the default export. import util, { inspect } from "ns:util"; -const util2 = await import("ns:util"); + +const same = await import("ns:util"); +console.log(same.default === util, same.inspect === inspect); // true true ``` Rules: -- `ns:` 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 named `ns:util` is not reachable. -- Resolution of an unknown builtin fails synchronously with an `Error` whose - message is exactly `No such built-in module: ns:` (matching Node's - wording for familiarity). +- `ns:` and `node:` 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 named `ns:util` is not + reachable. +- Resolution of an unregistered builtin fails with an `Error` whose message is + exactly `No such built-in module: ` (matching Node's wording for + familiarity) — e.g. `No such built-in module: node:fs`. The failure is + identical through `require()`, a static `import`, and a dynamic `import()`; + 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 @@ -38,21 +51,321 @@ Rules: - Builtin exports are frozen. Apps patch behavior by wrapping, not by mutating the runtime's module. -## Modules +## Module reference -### `ns:util` (v1) +### `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. | +```js +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. +programmatically. String quoting is one such detail: Android renders strings +through `JSON.stringify`, so they come back double-quoted where iOS uses +Node's single-quoted style. + +### `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 | +|---|---|---|---| +| `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 `""` | + +iOS additionally registers `releasedObjectPolicy`, which governs access to a +wrapper whose native counterpart has already been released. Android does not +register it: it has no released-native-counterpart machinery for that key to +govern, so the key is unknown here and both functions reject it like any other +unknown key. + +```js +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", ""); +``` + +The `TypeError` messages are part of the contract: + +| condition | message | +|---|---| +| unknown key (either function) | `Unknown runtime config 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 | `'' is process-wide and can only be set from the main isolate` | +| non-string `debug` value | `'debug' must be a comma-separated category string (), or '' to disable tracing` | + +`` is the runtime's own list of valid category names, which on +Android expands to `esm,fetch,registry`. + +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`. + +#### `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 (Android):* each category writes under its own logcat tag — +`TNS.esm`, `TNS.fetch`, `TNS.registry` — so `adb logcat -s TNS.esm` 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). | +| `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](#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 ''` | +| `volatilePatterns` not an array | `configureLoader: volatilePatterns must be an array of strings` | +| a non-string in `volatilePatterns` | `configureLoader: volatilePatterns[] must be a string` | +| `canonicalization` not an object | `configureLoader: canonicalization must be an object` | +| a `canonicalization` sub-key not an array | `configureLoader: canonicalization. must be an array of strings` | +| a non-string in a `canonicalization` sub-key | `configureLoader: canonicalization.[] must be a string` | +| `invalidateModules` argument not an array | `invalidateModules expects an array of URL strings` | +| a non-string in that array | `invalidateModules: urls[] 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. + +```js +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); +``` + +`getLoadedModuleUrls()` reports the **URL-like** keys only: registry entries +that are `blob:`-prefixed or contain `://`. A module keyed by a plain +filesystem path is not in the result, so the eviction set a dev client computes +from it covers served modules rather than the app's own bundled files. + +`createRequire` gives a module-relative `require` from anywhere, including an +ES module that has no `__filename`: + +```js +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: + +```js +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: ` | +| 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 ''; 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 '' must be an object` | + +Inside either section — labelled `imports` or `scope ''`: + +| condition | message | +|---|---| +| non-string specifier key | `
: every key must be a string` | +| empty specifier key | `
: a specifier key must not be empty` | +| non-string target | `
: the target for '' must be a string` | +| empty target | `
: the target for '' must not be empty` | +| trailing-slash key, non-trailing-slash target | `
: the target for '' 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`) | + +```js +const { configureLoader } = require("ns:module"); + +configureLoader({ + canonicalization: { + stripParams: ["t", "v", "import"], + forPathPrefixes: ["/ns/", "/@id/"], + preserveQueryFor: ["/@ng/component"], + }, +}); +``` + +Presence of the `canonicalization` object marks the vocabulary as configured +and replaces the built-in fallback entirely; empty arrays are honored as +explicit policy. `preserveQueryFor` is checked before the dev-endpoint prefix +test, so a path that matches both keeps its query. + +#### 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. -## `node:` compatibility shims +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 @@ -78,38 +391,315 @@ unmodified where a shim exists: 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; a `node:` 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 - their `node:` compat layer. + when every member is re-exported unchanged. `ns:` modules may grow + runtime-specific members freely; a `node:` 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 their `node:` compat layer. - Shims ship on both runtimes under the same parity rule as `ns:` modules. -### v1 shims - | 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. + +```js +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 +``` + +`` follows Node's `determineSpecificType`: `undefined`, `null`, +`type number (42)`, `an instance of Object`, `function foo`, and so on. -Candidates for future shims, in rough order of ecosystem demand: -`node:events` (EventEmitter), `node:path` (pure JS), `node:buffer`, -`node:process` (subset). Each requires a spec update here first. +### `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 '': 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()`. Match the text with +`toContain`-style substring checks rather than full-string equality — how much +surrounding context a platform adds to a `require()` failure is not part of +this contract. + +### 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: + +1. **An own export literally named `module.exports` wins outright** — its value + is what `require()` returns. This is the escape hatch for a module that + wants full control of its CJS shape. +2. **Otherwise the namespace is returned unchanged** when it has **no own + `default` export**, *or* when it **already declares its own `__esModule`**. + Declaring `__esModule` yourself is therefore an explicit opt-out of step 3. +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 : _mod` find the default, and because the + facade re-exports rather than copies, bindings stay live. + +```js +// 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. That +default slice services engine work only: a graph whose settling depends on a +JS timer, a worker reply, or anything else riding the platform loop needs +`pumpRunLoop: true` (see below), on both platforms. + +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 run the platform loop's own due work each pump iteration — a run-loop slice on iOS, the looper-equivalent drain on Android (JS timers, worker messages, plain loop posts) — for graphs whose progress depends on more than engine tasks and microtasks. | + +Validation errors, all `TypeError`: + +| condition | message | +|---|---| +| `options` present but not an object | `createPumpingRequire: options must be an object` | +| unrecognized key | `createPumpingRequire: unknown option ''` | +| 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 (): 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 '' 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: (network error)` | +| status 204 or 205 | `HTTP import failed: (status=, no content)` | +| any other non-2xx status | `HTTP import failed: (status=)` | +| missing or empty Content-Type | `Expected a JavaScript module but '' responded with no MIME type` | +| JSON MIME, empty body | `Expected a JSON module but '' responded with an empty body` | +| any other MIME (e.g. `text/html`) | `Expected a JavaScript module but '' responded with MIME type ''` | + +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 `. + +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 `` 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** — +and the backstop, unlike the yield, drains the loop's own due work (JS timers +and worker messages included), so an entry parked on those settles there. + +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. + +```js +// 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. + +```js +// 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: true` is sane **only while boot owns the looper**. After boot + the looper belongs to the app, and slicing it from inside a require re-enters + arbitrary looper 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 (Android):* there is no never-returning entry point here — the +entry is evaluated from `Runtime::RunModule`, which returns to Java when boot +finishes — so the boot backstop is on the path of every app, not just the ones +that park. Its two failures are fatal and reported in every build: +`Fatal: the main entry module's evaluation rejected during boot: ` and +`Fatal: the main entry module '' never settled within 120s`. A +**CommonJS** main that throws is fatal the same way: the failure is rethrown +through the boot boundary (prefixed `require() failed for module `) +rather than left pending under the backstop, in every build. ## The internal require Builtin modules reach each other — and only each other — through an internal -`require` the runtime provides to every builtin source: +`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: ` 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. - -This is the mechanism shims are built on, so it is normative: both runtimes -provide it. +- 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: `. ## Adding a builtin module @@ -119,9 +709,23 @@ provide it. 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: - the registry distinguishes public modules from internal builtins, and only - public ones resolve (Node's `canBeRequiredByUsers` split). +- 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 @@ -132,50 +736,97 @@ 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`). - -iOS also keeps a pre-registry `node:url` polyfill (`fileURLToPath`, -`pathToFileURL`) that predates this document. It is compiled from module -source inside the ES module resolver and is therefore reachable through -`import` only, not through `require()`. - ## Android implementation notes (non-normative) Builtin modules are function-body builtins -(`test-app/runtime/src/main/cpp/js/`, see the README there) compiled via the -RuntimeBuiltins table. The registry lives in -`test-app/runtime/src/main/cpp/NsBuiltinModules.{h,cpp}` and intercepts -specifiers in the CommonJS require path (`ModuleInternal::RequireCallbackImpl`) -and in the ES module resolve and dynamic-import callbacks -(`ModuleInternalCallbacks.cpp`); ESM consumption is served by a synthetic -module whose exports are populated from the same per-realm exports object. The +(`test-app/runtime/src/main/cpp/js/`, see the README there). A CMake custom +command runs `tools/js2c.mjs` to embed them into `generated/RuntimeBuiltins.cpp`, +and `BuiltinLoader::RunBuiltin` compiles them with an `internal/.js` +script origin and a process-wide bytecode cache: the first compile in the +process runs eagerly and produces a code cache that every later realm — +including every worker — consumes instead of recompiling. The public registry +lives in `NsBuiltinModules.{h,cpp}` and intercepts specifiers in the CommonJS +require path (`ModuleInternal::RequireCallbackImpl`) and in the ES module +resolve and dynamic-import callbacks (`ModuleInternalCallbacks.cpp`); all three +paths share one `NsBuiltinModules::NotFoundMessage`, which is why the failure +text is identical across them. 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`). -Android has no `Caches` class, so every per-realm cache — exports objects, -synthetic modules, the in-progress set, the cached `format` and the builtin -`require` — lives in an isolate-keyed map released from `disposeIsolate`. -The ES module registry app modules land in (`g_moduleRegistry`) is -process-global and shared by every isolate; the builtin caches deliberately do -not use it, so workers get their own instances as the spec requires. - -Android also keeps three pre-registry `node:` polyfills that predate this -document: `node:url` (`fileURLToPath`, `pathToFileURL`), `node:module` -(`createRequire`) and `node:path` (`sep`, `delimiter`, `basename`, `dirname`, -`extname`, `join`, `resolve`, `isAbsolute`). They are compiled from module -source inside the ES module resolver and are therefore reachable through -`import` only; `require("node:path")` reaches the registry and fails with the -not-found message. - -There is deliberately no `node:fs` polyfill and no catch-all for unshimmed -`node:` names: a stub whose members throw on use violates the -absent-not-present-but-throwing rule above, and an empty-default fallback lets -an import that cannot work succeed. +The files under `js/` that are *not* in the public registry — `primordials.js`, +`require-factory.js`, `inspect.js`, `json-helper.js`, `events.js`, +`error-events.js`, `structured-clone.js`, `blob-url.js`, `performance.js`, +`weak-ref.js` — are the internal builtins: each is run from its own native call +site and none is named in the registry table. + +Per-realm builtin state — the exports objects, the synthetic modules, the +in-progress set that produces the circular-require error, the cached `format`, +the builtin `require` — and the loader's `ModuleLoaderState` (module registry, +loader vocabulary, in-flight graph loads) live in `RuntimeState` slots rather +than in isolate-keyed shared maps. A slot is reached with an isolate data-slot +read and a vector index, needs no lock, and is destroyed with its isolate, so a +worker gets its own instances as the spec requires and teardown cannot leave a +stale entry behind. + +The ES module pipeline is a three-phase module map: a graph walk starting from +the entry discovers the transitive closure and compiles + registers every +module in it, so that by `InstantiateModule` time V8's synchronous +`ResolveModuleCallback` is a pure registry lookup — compile-and-register only, +never a fetch. Discovery is scheme-agnostic and every edge goes through the +same `ResolveSpecifierToPath` the resolver uses, so both agree on a module's +registry key; only the fetch differs per scheme. `http(s)` edges are fetched +concurrently off-thread and their completions hop back to the isolate's home +thread as **nestable** V8 foreground tasks on that isolate's event loop, so +`RunNestableV8Tasks` can drain them with JS frames already on the stack. + +A local entry counts as an ES module when its path ends in `.mjs`. The module +deadline is a single constant, `kModuleEvaluateDeadlineSeconds` = 60 seconds +(`ModuleInternal.h`), shared by the HTTP entry's settle window, the pumped +graph walk, and — doubled, at 120 seconds — the boot backstop. The waits are +*designed* to nest — transport timeouts within the module deadline within the +boot backstop — but the transport numbers are per-attempt bounds (connect) and +per-read inactivity bounds (read), not totals: a retried fetch or a slowly +dripping response can legally spend longer than one connect+read sum, and the +deadline above it is what actually cuts the wait off. The local entry's short +yield is deliberately *not* derived from that constant: it is an independent +one-second literal in `BootEntryEvaluationOptions`, with `return-pending` +behavior. An HTTP entry instead gets the full deadline and throws on expiry, +because the tooling driving it needs the rejection reason synchronously. The +backstop itself is `HoldBootBackstop` in `Runtime.cpp`, called from both +`Runtime::RunModule` overloads; it pumps the isolate's event loop in place +(`EventLoop::PumpUntil`) until the entry and all async graph work settle. + +Every pump on Android runs the same `EventLoop::PumpUntil` primitive, in one +of two modes that mirror the iOS pump exactly: + +- **The default slice** — nestable V8 tasks plus a microtask checkpoint — + is all a pumping require gets unless it opts in, matching iOS's default + pump body. Work outside that lane (JS timers, worker messages, `Handler` + posts) does not run: it rides Java `Handler` messages or its own fds, which + cannot dispatch while the pump's JS frames hold the thread. +- **`pumpRunLoop: true` adds the looper-equivalent drain**, standing exactly + where iOS slices its run loop: due ordered-lane work (JS timers) and plain + internal-lane posts (worker messages, Node-API completions) run directly + from the pump, which is what lets a graph parked on `setTimeout` or a + worker reply settle in-pump. The boot backstop and the pumped graph walk + always drain, just as iOS's boot path always pumps its run loop. The pump + still never re-enters the platform looper itself, and non-nestable V8 + tasks stay queued in both modes. + +The drain runs the loop's own work *early*, while the looper is blocked: a +due timer callback can execute in the middle of a `require()` — before +`Handler.post` runnables queued ahead of it — and can observe the require in +progress. That is inherent to pumping (iOS's run-loop slice does the same) +and is the reason the default mode stays conservative. + +Workers copy the loader vocabulary from the parent at spawn +(`CaptureLoaderVocabulary` on the parent's thread, `InstallLoaderVocabulary` +before the worker's first module load) and, for WHATWG parity, keep the +implicit port's message queue disabled until the worker entry finishes +evaluating — including after a pending top-level await settles. Messages sent +before that stay buffered. + +Unlike iOS, Android ships no `.d.ts` declarations for the `ns:` modules; the +`.d.ts` files in this repo describe the Android platform classes, not this +surface. diff --git a/test-app/app/src/main/AndroidManifest.xml b/test-app/app/src/main/AndroidManifest.xml index b0a7d50b3..f6896f5df 100644 --- a/test-app/app/src/main/AndroidManifest.xml +++ b/test-app/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ "; + } + + describe("surface", function () { + it("ns:module exposes both require factories", function () { + expect(typeof nsModule.createRequire).toBe("function"); + expect(typeof nsModule.createPumpingRequire).toBe("function"); + }); + + it("node:module re-exports createRequire and nothing else", function () { + var nodeModule = require("node:module"); + expect(Object.isFrozen(nodeModule)).toBe(true); + expect(Object.keys(nodeModule)).toEqual(["createRequire"]); + // The pumping flavor is a NativeScript extension with no Node + // counterpart, so it stays off the node: surface. + expect(nodeModule.createPumpingRequire).toBeUndefined(); + }); + + it("node:module is a distinct module object from ns:module", function () { + expect(require("node:module")).not.toBe(require("ns:module")); + }); + + it("exposes createRequire through a static import of node:module", function (done) { + import("~/esm/createrequire/node-module-import.mjs").then(function (ns) { + expect(ns.createRequireType).toBe("function"); + var target = ns.requireFrom(fixtureDir + "/anything.js", "./target.js"); + expect(target.tag).toBe("createrequire-target"); + done(); + }).catch(function (e) { + expect("rejected: " + String((e && e.message) || e)).toBe("resolved"); + done(); + }); + }); + + // Absent rather than present-but-throwing, so a feature check that + // guards on them takes the fallback path. + it("mints a require without resolve, cache or main", function () { + var req = nsModule.createRequire(fixtureDir + "/anything.js"); + expect(req.resolve).toBeUndefined(); + expect(req.cache).toBeUndefined(); + expect(req.main).toBeUndefined(); + }); + }); + + describe("base resolution", function () { + it("resolves ./ against the directory of the given file", function () { + var req = nsModule.createRequire(fixtureDir + "/anything.js"); + expect(req("./target.js").tag).toBe("createrequire-target"); + }); + + it("treats a trailing slash as the directory itself", function () { + var req = nsModule.createRequire(fixtureDir + "/"); + expect(req("./target.js").tag).toBe("createrequire-target"); + }); + + it("accepts a file URL string", function () { + var req = nsModule.createRequire("file://" + fixtureDir + "/anything.js"); + expect(req("./target.js").tag).toBe("createrequire-target"); + }); + + it("accepts a URL object", function () { + var req = nsModule.createRequire(new URL("file://" + fixtureDir + "/anything.js")); + expect(req("./target.js").tag).toBe("createrequire-target"); + }); + + it("still resolves ~ specifiers against the app root", function () { + var req = nsModule.createRequire(fixtureDir + "/anything.js"); + expect(req("~/esm/createrequire/target.js").tag).toBe("createrequire-target"); + }); + }); + + describe("argument validation", function () { + it("rejects a non-string, non-URL argument", function () { + expect(messageOf(function () { nsModule.createRequire(42); })).toBe(ARGUMENT_ERROR); + }); + + it("rejects a relative path string", function () { + expect(messageOf(function () { nsModule.createRequire("./tests/index.js"); })) + .toBe(ARGUMENT_ERROR); + }); + + it("rejects a non-file URL scheme", function () { + expect(messageOf(function () { nsModule.createRequire("ftp://example.com/a.js"); })) + .toBe(ARGUMENT_ERROR); + }); + + it("refuses an http base with a dev-server specific message", function () { + expect(messageOf(function () { + nsModule.createRequire("http://localhost:8080/main.js"); + })).toBe("createRequire() cannot take an http(s) URL (http://localhost:8080/main.js): " + + "require() of a dev-served module is not supported. Pass an app-root file " + + "path and use import() for remote modules."); + }); + + it("applies the same validation to createPumpingRequire", function () { + expect(messageOf(function () { nsModule.createPumpingRequire(42); })) + .toBe(ARGUMENT_ERROR); + }); + }); + + // The specifier itself is validated with Node's ERR_INVALID_ARG_TYPE + // wording, so a message copied out of a stack trace matches what the + // ecosystem documents. Rejected before any builtin, http or filesystem + // handling — none of which can run without a string. + describe("specifier validation", function () { + var minted = nsModule.createRequire(fixtureDir + "/"); + + it("rejects a non-string specifier with Node's wording", function () { + expect(function () { globalThis.require(42); }) + .toThrowError(TypeError, + /^The "id" argument must be of type string\. Received type number \(42\)$/); + }); + + it("rejects a missing specifier with Node's wording", function () { + expect(function () { globalThis.require(); }) + .toThrowError(TypeError, + /^The "id" argument must be of type string\. Received undefined$/); + }); + + it("names null and object arguments the way Node does", function () { + expect(messageOf(function () { globalThis.require(null); })) + .toBe('The "id" argument must be of type string. Received null'); + expect(messageOf(function () { globalThis.require({}); })) + .toBe('The "id" argument must be of type string. Received an instance of Object'); + }); + + it("applies the same validation to a minted require", function () { + expect(function () { minted(42); }) + .toThrowError(TypeError, /^The "id" argument must be of type string\./); + expect(function () { minted(); }) + .toThrowError(TypeError, /Received undefined$/); + }); + }); + + describe("evaluation policy", function () { + it("refuses a top-level-await graph strictly", function () { + var strictRequire = nsModule.createRequire(fixtureDir + "/anything.js"); + + var refusal = messageOf(function () { strictRequire("./microtask-tla.mjs"); }); + expect(refusal).toContain("require() cannot load ES module '"); + expect(refusal).toContain("': the module graph contains top-level await. " + + "Use import() or createPumpingRequire from ns:module instead."); + }); + + it("evaluates the same graph when pumping", function (done) { + onFreshTask(function () { + var pumpingRequire = nsModule.createPumpingRequire(fixtureDir + "/anything.js"); + var result = ""; + try { + result = String(pumpingRequire("./microtask-tla.mjs").value); + } catch (e) { + result = "threw: " + ((e && e.message) || e); + } + expect(result).toBe("ok"); + done(); + }); + }); + + // A timer's Handler token cannot dispatch while the pumping require + // holds the thread, so this settles only through the pump's direct + // ordered-lane drain — which pumpRunLoop opts into. + it("settles a top-level await parked on a JS timer when pumpRunLoop is set", function (done) { + onFreshTask(function () { + var pumpingRequire = nsModule.createPumpingRequire(fixtureDir + "/anything.js", + { pumpRunLoop: true }); + var result = ""; + try { + result = String(pumpingRequire("./timer-tla.mjs").value); + } catch (e) { + result = "threw: " + ((e && e.message) || e); + } + expect(result).toBe("timer-ok"); + done(); + }); + }); + + // Contract: the default pump runs engine tasks and microtasks only + // (the iOS default), so without pumpRunLoop the same timer-parked + // graph must hit its deadline instead of settling. + it("does not run JS timers under the default pump options", function (done) { + onFreshTask(function () { + var pumpingRequire = nsModule.createPumpingRequire(fixtureDir + "/anything.js", + { deadlineSeconds: 0.5 }); + var refusal = messageOf(function () { pumpingRequire("./timer-tla-gated.mjs"); }); + expect(refusal).toContain("Top-level await timed out for ES module"); + done(); + }); + }); + + it("refuses to pump a top-level-await graph from inside a microtask", function (done) { + var pumpingRequire = nsModule.createPumpingRequire(fixtureDir + "/anything.js"); + Promise.resolve().then(function () { + var refusal = messageOf(function () { + pumpingRequire("./microtask-tla-guarded.mjs"); + }); + expect(refusal).toContain("createPumpingRequire cannot settle module graph '"); + expect(refusal).toContain("' 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()."); + done(); + }); + }); + + it("still loads a synchronous graph from inside a microtask", function (done) { + var pumpingRequire = nsModule.createPumpingRequire(fixtureDir + "/anything.js"); + Promise.resolve().then(function () { + expect(pumpingRequire("./target.js").tag).toBe("createrequire-target"); + done(); + }); + }); + + // The refusal is decided before evaluation, so the graph stays loadable. + it("still imports a graph a strict require refused", function (done) { + import("~/esm/createrequire/microtask-tla.mjs").then(function (ns) { + expect(ns.value).toBe("ok"); + done(); + }).catch(function (e) { + expect("rejected: " + String((e && e.message) || e)).toBe("resolved"); + done(); + }); + }); + + describe("pumping options", function () { + it("rejects a non-object options bag", function () { + expect(function () { + nsModule.createPumpingRequire(fixtureDir + "/x.js", 42); + }).toThrowError(TypeError, "createPumpingRequire: options must be an object"); + }); + + it("rejects an unknown option key by name", function () { + expect(function () { + nsModule.createPumpingRequire(fixtureDir + "/x.js", { deadline: 1 }); + }).toThrowError(TypeError, "createPumpingRequire: unknown option 'deadline'"); + }); + + it("rejects bad option values", function () { + var badDeadline = + "createPumpingRequire: 'deadlineSeconds' must be a positive finite number"; + expect(function () { + nsModule.createPumpingRequire(fixtureDir + "/x.js", { deadlineSeconds: 0 }); + }).toThrowError(TypeError, badDeadline); + expect(function () { + nsModule.createPumpingRequire(fixtureDir + "/x.js", { deadlineSeconds: Infinity }); + }).toThrowError(TypeError, badDeadline); + expect(function () { + nsModule.createPumpingRequire(fixtureDir + "/x.js", { onTimeout: "wait" }); + }).toThrowError(TypeError, + "createPumpingRequire: 'onTimeout' must be 'throw' or 'return-pending'"); + expect(function () { + nsModule.createPumpingRequire(fixtureDir + "/x.js", { pumpRunLoop: "yes" }); + }).toThrowError(TypeError, + "createPumpingRequire: 'pumpRunLoop' must be a boolean"); + }); + + it("refuses options on the strict createRequire", function () { + expect(function () { + nsModule.createRequire(fixtureDir + "/x.js", { deadlineSeconds: 1 }); + }).toThrowError(TypeError, "options are not supported on createRequire"); + }); + + // These reach the deadline, so they must run from a task context — + // from a microtask the guard would refuse before evaluating. + it("returns without throwing at the deadline under onTimeout return-pending", + function (done) { + onFreshTask(function () { + // The graph parks on a promise nothing settles, so the + // deadline is always what ends the wait. + var req = nsModule.createPumpingRequire(fixtureDir + "/anything.js", { + deadlineSeconds: 0.25, + onTimeout: "return-pending", + }); + var outcome = ""; + try { + var mod = req("./tla-return-pending.mjs"); + outcome = typeof mod === "object" ? "returned a namespace" + : "returned " + typeof mod; + } catch (e) { + outcome = "threw: " + ((e && e.message) || e); + } + expect(outcome).toBe("returned a namespace"); + done(); + }); + }); + + it("honors a short deadlineSeconds with the default onTimeout throw", function (done) { + onFreshTask(function () { + var req = nsModule.createPumpingRequire(fixtureDir + "/anything.js", { + deadlineSeconds: 0.25, + }); + var started = Date.now(); + expect(messageOf(function () { req("./tla-deadline.mjs"); })) + .toContain("Top-level await timed out for ES module "); + // The configured deadline governed, not the 60s default. + expect(Date.now() - started < 5000 ? "within the short deadline" + : "took too long") + .toBe("within the short deadline"); + done(); + }); + }); + + it("keeps the microtask guard unconditional even with pumpRunLoop", function (done) { + var req = nsModule.createPumpingRequire(fixtureDir + "/anything.js", { + pumpRunLoop: true, + }); + Promise.resolve().then(function () { + expect(messageOf(function () { req("./microtask-tla-guarded.mjs"); })) + .toContain("cannot be pumped re-entrantly"); + done(); + }); + }); + }); + + it("refuses a foreground-task top-level await through createRequire", function () { + var req = nsModule.createRequire(fixtureDir + "/anything.js"); + expect(messageOf(function () { req("./tla-foreground-task.mjs"); })) + .toContain("the module graph contains top-level await"); + }); + }); +}); + +// `~` marks the app root; the separator after it is optional. +describe("app-root specifiers", function () { + it("resolves ~/path", function () { + expect(require("~/esm/createrequire/target.js").tag).toBe("createrequire-target"); + }); + + it("resolves ~path without a separator", function () { + expect(require("~esm/createrequire/target.js").tag).toBe("createrequire-target"); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js b/test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js new file mode 100644 index 000000000..c020089ae --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js @@ -0,0 +1,890 @@ +// The loopback fixture server byte-mirrors the routes of the iOS TestRunner's +// ModuleTestServer, so these specs pin the same loader contract on both +// platforms. +var appRoot = __dirname.replace(/\/tests$/, ""); +var origin = "http://127.0.0.1:" + com.tns.tests.ModuleTestServer.ensureStarted(); + +describe("HTTP ESM Loader", function () { + + function formatError(e) { + try { + if (!e) return "(no error)"; + if (e instanceof Error) return e.message; + if (typeof e === "string") return e; + if (e && typeof e.message === "string") return e.message; + return JSON.stringify(e); + } catch (_) { + return String(e); + } + } + + // This Jasmine's fail() throws, which inside a promise reaction surfaces as + // an opaque spec timeout instead of a diff. Every rejection handler below + // reports through a non-throwing expect for that reason. + function reportRejection(error, done) { + expect("rejected: " + formatError(error)).toBe("resolved"); + done(); + } + + function withTimeout(promise, ms, label) { + return new Promise(function (resolve, reject) { + var timer = setTimeout(function () { + reject(new Error("Timeout after " + ms + "ms" + (label ? ": " + label : ""))); + }, ms); + + promise.then(function (value) { + clearTimeout(timer); + resolve(value); + }).catch(function (err) { + clearTimeout(timer); + reject(err); + }); + }); + } + + // Loopback fetches can outrun jasmine 2.0.1's 5s default on a cold + // emulator. 2.0.1 has no beforeAll, so the pair is installed per describe. + function useHttpTimeout() { + var originalTimeout; + beforeEach(function () { + originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 15000; + }); + afterEach(function () { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + }); + } + + function onBackgroundThread(body) { + new java.lang.Thread(new java.lang.Runnable({ + run: body + })).start(); + } + + describe("URL Resolution", function () { + it("should handle relative imports", function (done) { + import("~/esm/relative/entry.mjs").then(function (module) { + expect(module.viaDefault).toBe("relative-import-success"); + expect(module.viaNamed).toBe("relative-import-success"); + expect(module.readDependencyPayload()).toBe(true); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + + it("should surface helpful errors for unresolved bare specifiers", function (done) { + import("bare-spec-example").then(function (mod) { + // A placeholder module default-exports a Proxy whose get trap + // throws; touching a property is what surfaces the diagnostic. + var threw = false; + try { + void (mod && mod.default && mod.default.__touch__); + } catch (useErr) { + threw = true; + expect(formatError(useErr)).toContain("bare-spec-example"); + } + expect(threw).toBe(true); + done(); + }).catch(function (error) { + expect(formatError(error)).toContain("bare-spec-example"); + done(); + }); + }); + }); + + describe("HTTP Fetch Integration", function () { + + it("settles a local dynamic import issued from a background thread", function (done) { + onBackgroundThread(function () { + import("~/esm/graph/bg-solo.mjs").then(function (module) { + expect(module.name).toBe("bg-solo"); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + + describe("from a background thread over HTTP", function () { + useHttpTimeout(); + + it("settles an HTTP dynamic import issued from a background thread", function (done) { + // Completion delivery must not depend on the calling thread + // owning a looper, so nothing here schedules a timer. + onBackgroundThread(function () { + import(origin + "/esm/query.mjs?v=bg").then(function (module) { + expect(module).toBeDefined(); + expect(module.query).toContain("v=bg"); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + }); + + it("evaluates a disk diamond graph in spec order, each module once", function (done) { + import("~/esm/graph/diamond-entry.mjs").then(function (module) { + expect(module.order).toEqual(["shared", "left", "right", "entry"]); + expect(module.names).toEqual(["left", "right"]); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + + // A local root whose graph reaches an HTTP leaf. Discovery is + // scheme-agnostic, so the walk compiles the whole closure up front and + // the resolver never takes a blocking synchronous fetch. + describe("mixed local/http graphs", function () { + useHttpTimeout(); + + function configureLeaves() { + require("ns:module").configureLoader({ + importMap: { + imports: { + "ns-test-leaf-a": origin + "/esm/graph-leaf.mjs?k=a", + "ns-test-leaf-b": origin + "/esm/graph-leaf.mjs?k=b", + }, + }, + }); + } + + afterEach(function () { + require("ns:module").configureLoader({ importMap: { imports: {} } }); + }); + + it("resolves a local->local->http graph through require()", function () { + configureLeaves(); + + var req = require("ns:module").createRequire(appRoot + "/anything.js"); + var mod = req("./esm/mixed/a-entry.mjs"); + expect(mod.leaf).toBe("a"); + // Spec evaluation order, deepest first — the walk changes only + // when modules are compiled, never when they run. + expect(mod.order).toEqual(["leaf", "mid", "entry"]); + }); + + it("resolves a local->local->http graph through import()", function (done) { + configureLeaves(); + + import("~/esm/mixed/b-entry.mjs").then(function (mod) { + expect(mod.leaf).toBe("b"); + expect(mod.order).toEqual(["leaf", "mid", "entry"]); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + + // Each present configureLoader section replaces its state wholesale, so + // an empty array is explicit policy — "nothing is volatile any more" — + // not a no-op. + describe("volatile patterns", function () { + useHttpTimeout(); + + var nsModule = require("ns:module"); + + afterEach(function () { + nsModule.configureLoader({ volatilePatterns: [] }); + }); + + it("stops treating a URL as volatile once the list is emptied", function (done) { + // The fixture pushes one entry per evaluation, so the bucket + // length counts how many times the module actually ran. + var url = origin + "/esm/graph-leaf.mjs?k=vol"; + function evaluations() { + return (globalThis.__nsMixedOrdervol || []).length; + } + + nsModule.configureLoader({ volatilePatterns: ["k=vol"] }); + + import(url).then(function () { + return import(url); + }).then(function () { + // Volatile: the cached module is dropped, so it re-evaluates. + expect(evaluations()).toBe(2); + + nsModule.configureLoader({ volatilePatterns: [] }); + return import(url); + }).then(function () { + // Cleared: the registry entry is reused, nothing re-runs. + expect(evaluations()).toBe(2); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + + // The import map is process-wide, so every spec here installs its own + // and restores the empty map afterwards. + describe("import map", function () { + useHttpTimeout(); + + var nsModule = require("ns:module"); + + function setMap(map) { + nsModule.configureLoader({ importMap: map }); + } + + afterEach(function () { + setMap({ imports: {} }); + }); + + it("rejects an unknown top-level section by name", function () { + expect(function () { + setMap({ imports: {}, integrity: {} }); + }).toThrowError(TypeError, /unsupported import-map section 'integrity'/); + }); + + it("rejects a trailing-slash key whose target does not end in '/'", function () { + expect(function () { + setMap({ imports: { "pkg/": "http://example.com/pkg" } }); + }).toThrowError(TypeError, /must end with '\/'/); + }); + + it("rejects a trailing-slash key inside a scope map too", function () { + expect(function () { + setMap({ scopes: { "/a/": { "pkg/": "http://example.com/pkg" } } }); + }).toThrowError(TypeError, /must end with '\/'/); + }); + + it("rejects a null or non-string target", function () { + expect(function () { + setMap({ imports: { "pkg": null } }); + }).toThrowError(TypeError, /must be a string/); + expect(function () { + setMap({ imports: { "pkg": 42 } }); + }).toThrowError(TypeError, /must be a string/); + }); + + it("rejects a non-object scope map", function () { + expect(function () { + setMap({ scopes: { "/a/": "not-an-object" } }); + }).toThrowError(TypeError, /must be an object/); + }); + + it("prefixes every validation failure with 'configureLoader: '", function () { + expect(function () { + setMap({ imports: {}, integrity: {} }); + }).toThrowError(TypeError, /^configureLoader: /); + }); + + it("keeps the previous map when an update is rejected", function (done) { + setMap({ imports: { "ns-survivor": origin + "/esm/graph-leaf.mjs?k=surv" } }); + + expect(function () { + nsModule.configureLoader({ importMap: "{ this is not json" }); + }).toThrowError(TypeError, /valid JSON/); + + // The rejected update changed nothing, so the module installed + // by the previous map still resolves. + import("~/esm/scoped/survivor.mjs").then(function (mod) { + expect(mod.leaf).toBe("surv"); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + + // The vocabulary is per-isolate; a worker gets a copy taken on the + // parent's thread as it spawns. + it("gives a worker spawned after configureLoader the parent's map", function (done) { + setMap({ imports: { "ns-worker-leaf": origin + "/esm/graph-leaf.mjs?k=wa" } }); + + var worker = new Worker("./importMapWorker.js"); + worker.onmessage = function (msg) { + expect(msg.data.ok ? "resolved" : "failed: " + msg.data.error).toBe("resolved"); + expect(msg.data.name).toBe("wa"); + worker.terminate(); + done(); + }; + worker.postMessage("ns-worker-leaf"); + }); + + it("leaves a running worker on the map it was spawned with", function (done) { + setMap({ imports: { "ns-worker-leaf": origin + "/esm/graph-leaf.mjs?k=wb" } }); + + var worker = new Worker("./importMapWorker.js"); + worker.onmessage = function (msg) { + expect(msg.data.ok ? "resolved" : "failed: " + msg.data.error).toBe("resolved"); + expect(msg.data.name).toBe("wb"); + worker.terminate(); + done(); + }; + + // Reconfigure the parent only after the worker exists, then ask + // it to resolve. The parent's own isolate does see the update. + setMap({ imports: { "ns-worker-leaf": origin + "/esm/graph-leaf.mjs?k=wc" } }); + worker.postMessage("ns-worker-leaf"); + }); + + it("resolves through the scope cascade for every referrer", function (done) { + // A scope key is prefix-matched against the referrer's canonical + // registry key, which for a disk module is a bare absolute path. + var insideScope = appRoot + "/esm/scoped/inside/"; + var deepScope = appRoot + "/esm/scoped/inside/deep/"; + var scopes = {}; + scopes[insideScope] = { "ns-scoped-leaf": origin + "/esm/graph-leaf.mjs?k=in" }; + scopes[deepScope] = { "ns-scoped-leaf": origin + "/esm/graph-leaf.mjs?k=deep" }; + setMap({ + imports: { + "ns-scoped-leaf": origin + "/esm/graph-leaf.mjs?k=top", + "ns-scoped-fallthrough": origin + "/esm/graph-leaf.mjs?k=fall", + }, + scopes: scopes, + }); + + Promise.all([ + import("~/esm/scoped/inside/mid.mjs"), + import("~/esm/scoped/inside/deep/mid.mjs"), + import("~/esm/scoped/outside/mid.mjs"), + ]).then(function (mods) { + // A scope wins over the top-level entry for a referrer inside it. + expect(mods[0].leaf).toBe("in"); + // ...and a specifier the scope does not define falls through. + expect(mods[0].fallthrough).toBe("fall"); + // Two scopes match; the more specific one wins. + expect(mods[1].leaf).toBe("deep"); + // No scope matches this referrer. + expect(mods[2].leaf).toBe("top"); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + + // An imperative API rejects bad input loudly, the way WebIDL does on the + // web and ERR_INVALID_ARG_TYPE does in Node. Silently skipping a + // mistyped section or a typo'd key turns a caller's bug into a config + // that quietly does nothing. + describe("loader surface argument validation", function () { + useHttpTimeout(); + + var nsModule = require("ns:module"); + + it("rejects a missing or non-object config", function () { + expect(function () { + nsModule.configureLoader(); + }).toThrowError(TypeError, /configureLoader expects a config object/); + expect(function () { + nsModule.configureLoader(42); + }).toThrowError(TypeError, /configureLoader expects a config object/); + }); + + it("rejects an unknown top-level config key by name", function () { + expect(function () { + nsModule.configureLoader({ typoKey: [] }); + }).toThrowError(TypeError, /unknown option 'typoKey'/); + }); + + it("rejects a non-array volatilePatterns", function () { + expect(function () { + nsModule.configureLoader({ volatilePatterns: "x" }); + }).toThrowError(TypeError, /volatilePatterns must be an array of strings/); + }); + + it("rejects a non-string volatilePatterns element by index", function () { + expect(function () { + nsModule.configureLoader({ volatilePatterns: [1] }); + }).toThrowError(TypeError, /volatilePatterns\[0\] must be a string/); + expect(function () { + nsModule.configureLoader({ volatilePatterns: ["ok", null] }); + }).toThrowError(TypeError, /volatilePatterns\[1\] must be a string/); + }); + + it("rejects a non-object canonicalization", function () { + expect(function () { + nsModule.configureLoader({ canonicalization: "x" }); + }).toThrowError(TypeError, /canonicalization must be an object/); + }); + + it("rejects a non-array canonicalization sub-key by name", function () { + expect(function () { + nsModule.configureLoader({ canonicalization: { stripParams: "t" } }); + }).toThrowError(TypeError, + /canonicalization\.stripParams must be an array of strings/); + expect(function () { + nsModule.configureLoader({ canonicalization: { forPathPrefixes: [7] } }); + }).toThrowError(TypeError, /canonicalization\.forPathPrefixes\[0\] must be a string/); + }); + + // A function is an object to the engine, and JSON.stringify answers + // the literal text "undefined" for one rather than failing, so it + // has to be turned away before the map parser ever sees it. + it("rejects a function importMap and leaves the installed map in place", function (done) { + nsModule.configureLoader({ + importMap: { imports: { "ns-fnmap-leaf": "~/esm/vocab/leafA.mjs" } }, + }); + + expect(function () { + nsModule.configureLoader({ importMap: function () {} }); + }).toThrowError(TypeError, /importMap must be an object or a JSON string/); + + function restore() { + nsModule.configureLoader({ importMap: { imports: {} } }); + } + + // A bare specifier resolves only through the map, so it still + // importing proves the rejected call replaced nothing. + import("ns-fnmap-leaf").then(function (mod) { + expect(mod.name).toBe("vocab-a"); + restore(); + done(); + }).catch(function (error) { + restore(); + reportRejection(error, done); + }); + }); + + it("rejects a non-array invalidateModules argument", function () { + expect(function () { + nsModule.invalidateModules("x"); + }).toThrowError(TypeError, /invalidateModules expects an array of URL strings/); + }); + + it("rejects a non-string invalidateModules element by index", function () { + expect(function () { + nsModule.invalidateModules([1]); + }).toThrowError(TypeError, /urls\[0\] must be a string/); + }); + + it("rejects a non-string canonicalizeHttpUrlKey argument", function () { + // Debug-only diagnostic; release builds omit the member entirely. + if (typeof nsModule.canonicalizeHttpUrlKey !== "function") { + pending("canonicalizeHttpUrlKey is debug-only; absent in this build"); + return; + } + expect(function () { + nsModule.canonicalizeHttpUrlKey(42); + }).toThrowError(TypeError, /canonicalizeHttpUrlKey expects a URL string/); + }); + + // Validate-before-apply: the whole config is checked before any of + // it is installed, so a call that throws leaves every section on the + // state it already had. + it("applies no section when any part of the config is invalid", function () { + if (typeof nsModule.canonicalizeHttpUrlKey !== "function") { + pending("canonicalizeHttpUrlKey is debug-only; absent in this build"); + return; + } + var url = "http://h/dev/core?p=x&t=123"; + var before = nsModule.canonicalizeHttpUrlKey(url); + + // A well-formed canonicalization section paired with a typo'd key. + expect(function () { + nsModule.configureLoader({ + canonicalization: { stripParams: ["t"], forPathPrefixes: ["/dev/"] }, + typoKey: 1, + }); + }).toThrowError(TypeError, /unknown option 'typoKey'/); + + // Had the canonicalization section been applied, `t` would now + // be stripped and the key would differ. + expect(nsModule.canonicalizeHttpUrlKey(url)).toBe(before); + }); + + it("leaves volatilePatterns untouched when the same call throws", function (done) { + var url = origin + "/esm/graph-leaf.mjs?k=vpre"; + function evaluations() { + return (globalThis.__nsMixedOrdervpre || []).length; + } + + // Nothing is volatile yet, so a second import reuses the entry. + import(url).then(function () { + return import(url); + }).then(function () { + expect(evaluations()).toBe(1); + + // A valid volatilePatterns alongside an unknown key: the + // call throws and the patterns must NOT be installed. + expect(function () { + nsModule.configureLoader({ + volatilePatterns: ["k=vpre"], + typoKey: 1, + }); + }).toThrowError(TypeError, /unknown option 'typoKey'/); + + return import(url); + }).then(function () { + // Still not volatile: the rejected call installed nothing. + expect(evaluations()).toBe(1); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + + // Module scripts are strict about MIME on the web, and so is the + // loader: the response policy lives in one classifier shared by the + // synchronous fallback and the graph walk. + describe("module MIME gate", function () { + useHttpTimeout(); + + function rejectionOf(url, callback) { + import(url).then(function () { + callback(""); + }).catch(function (error) { + callback(String((error && error.message) || error)); + }); + } + + it("rejects an SPA fallback that answers with text/html", function (done) { + var url = origin + "/esm/html-fallback.mjs"; + rejectionOf(url, function (message) { + // The DX win: the cause is the MIME type, not a syntax + // error from HTML reaching the JS parser. + expect(message.indexOf("text/html") >= 0 ? "names the MIME" : message) + .toBe("names the MIME"); + expect(message.indexOf(url) >= 0 ? "names the URL" : message) + .toBe("names the URL"); + expect(message.indexOf("Unexpected token") >= 0 ? message : "no parse error") + .toBe("no parse error"); + done(); + }); + }); + + it("rejects a response that carries no MIME type", function (done) { + var url = origin + "/esm/no-mime.mjs"; + rejectionOf(url, function (message) { + expect(message.indexOf("no MIME type") >= 0 ? "names the missing MIME" : message) + .toBe("names the missing MIME"); + expect(message.indexOf(url) >= 0 ? "names the URL" : message) + .toBe("names the URL"); + done(); + }); + }); + + it("names the status for a non-2xx response", function (done) { + var url = origin + "/esm/nonexistent-module-404.mjs"; + rejectionOf(url, function (message) { + expect(message).toBe("HTTP import failed: " + url + " (status=404)"); + done(); + }); + }); + + it("still serves an empty 200 with a JS MIME as the empty module", function (done) { + // Type-only modules transform to zero runtime code; dev servers + // serve them as empty 200s and they must stay valid. + import(origin + "/esm/empty.mjs").then(function (mod) { + expect(typeof mod).toBe("object"); + expect(Object.keys(mod)).toEqual([]); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + + it("routes a served JSON module through the JSON path, with stable identity", + function (done) { + var url = origin + "/esm/data.json"; + import(url).then(function (first) { + expect(first.default.kind).toBe("json-module"); + expect(first.default.n).toBe(41); + return import(url).then(function (second) { + expect(second).toBe(first); + expect(second.default).toBe(first.default); + done(); + }); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + + // Re-importing from inside the first import's own resolution is + // the case that exposed stale waiter routing: the reaction runs + // while the first settle is still unwinding, so the loader must + // already have cleared the state that would park this import on a + // waiter list nothing will flush. + it("settles a re-entrant re-import issued from the first import's handler", + function (done) { + var url = origin + "/esm/data.json?reentrant=1"; + var settled = "never settled"; + import(url).then(function (first) { + import(url).then(function (second) { + settled = second === first ? "same namespace" : "different namespace"; + }, function (error) { + settled = "re-import rejected: " + ((error && error.message) || error); + }); + }, function (error) { + settled = "first import rejected: " + ((error && error.message) || error); + }); + __ns__setTimeout(function () { + expect(settled).toBe("same namespace"); + done(); + }, 1500); + }); + }); + + it("links and evaluates cyclic disk imports", function (done) { + import("~/esm/graph/cycle-a.mjs").then(function (module) { + expect(module.aValue).toBe("a"); + expect(module.roundTrip).toBe("b-saw-a"); + expect(module.describeB()).toBe("a-saw-b"); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + + it("gives nested disk modules a correct import.meta", function (done) { + import("~/esm/relative/meta.mjs").then(function (module) { + expect(typeof module.metaUrl).toBe("string"); + expect(module.metaUrl.indexOf("file://")).toBe(0); + expect(module.metaUrl).toContain("esm/relative/meta.mjs"); + expect(typeof module.metaDirname).toBe("string"); + expect(module.metaDirname).toContain("esm/relative"); + expect(module.metaDirname).not.toContain("meta.mjs"); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + + it("returns one module identity for repeated JSON imports", function (done) { + var spec = "~/esm/identity.json"; + Promise.all([import(spec), import(spec)]).then(function (results) { + expect(results[0]).toBe(results[1]); + expect(results[0].default.name).toBe("esm-identity-fixture"); + expect(results[0].default.value).toBe(42); + return import(spec).then(function (third) { + expect(third).toBe(results[0]); + expect(third.default).toBe(results[0].default); + done(); + }); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + + it("should fall back to filesystem when HTTP fetch fails", function (done) { + import("~/esm/fs-fallback.mjs").then(function (module) { + expect(module).toBeDefined(); + expect(module.ok || (module.default && module.default.ok)).toBe(true); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + + describe("Module Compilation", function () { + + it("should compile filesystem-backed ES modules successfully", function (done) { + import("~/esm/hmr/test-esm-module.mjs").then(function (module) { + expect(module).toBeDefined(); + expect(module.testValue).toBe("http-esm-loaded"); + expect(typeof module.default).toBe("function"); + expect(module.default()).toContain("HTTP ESM loader working"); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + + it("should reuse compiled modules across multiple dynamic imports", function (done) { + var spec = "~/esm/hmr/test-esm-module.mjs"; + Promise.all([import(spec), import(spec)]).then(function (results) { + expect(results[0]).toBeDefined(); + expect(results[1]).toBeDefined(); + expect(results[0].timestamp).toBe(results[1].timestamp); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + + describe("Error Handling", function () { + useHttpTimeout(); + + it("surfaces the real compile error for a served module with a syntax error", function (done) { + var url = origin + "/esm/syntax-error.mjs"; + withTimeout(import(url), 10000, "import " + url) + .then(function () { + expect("resolved").toBe("rejected"); + done(); + }) + .catch(function (error) { + // The parse error itself, not a generic "compile failed" / + // instantiation failure that names no cause. + var message = String((error && error.message) || error); + expect(message.indexOf("Unexpected token") >= 0 ? "names the parse error" : message) + .toBe("names the parse error"); + expect(message.indexOf("syntax-error.mjs") >= 0 ? "names the module" : message) + .toBe("names the module"); + done(); + }); + }); + + describe("unreachable and slow endpoints", function () { + it("rejects an unreachable host as a network error", function (done) { + // A closed loopback port refuses instantly, so this pins the + // network-error wording without waiting out a real timeout. + var url = "http://127.0.0.1:59999/unreachable.mjs"; + import(url).then(function () { + expect("resolved").toBe("rejected"); + done(); + }).catch(function (error) { + expect(String((error && error.message) || error)) + .toBe("HTTP import failed: " + url + " (network error)"); + done(); + }); + }); + + it("waits out a slow response instead of aborting it early", function (done) { + var url = origin + "/esm/timeout.mjs?delayMs=1500"; + import(url).then(function (mod) { + expect(typeof mod.evaluatedAt).toBe("number"); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + + it("should handle malformed URLs gracefully", function () { + // The rejection is swallowed deliberately: the contract under test + // is only that a malformed http specifier throws nothing inline. + expect(function () { + import("http://").catch(function () { }); + }).not.toThrow(); + }); + }); + + describe("Integration with HMR", function () { + + it("should NOT attach a native import.meta.hot (hot contexts are injected by the dev server)", function (done) { + // The runtime owns no HMR policy: `import.meta.hot` is only present + // when the @nativescript/vite dev server injects a JS hot context + // into the served module source. + import("~/esm/hmr/test-esm-module.mjs").then(function (module) { + expect(module.getHotContext()).toBeUndefined(); + expect(module.callInvalidateSafe()).toBe(false); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + + describe("URL Key Canonicalization", function () { + useHttpTimeout(); + + it("preserves query for non-dev/public URLs", function (done) { + var u1 = origin + "/esm/query.mjs?v=1"; + var u2 = origin + "/esm/query.mjs?v=2"; + + withTimeout(import(u1), 10000, "import " + u1) + .then(function (m1) { + return withTimeout(import(u2), 10000, "import " + u2).then(function (m2) { + expect(m1.query).toContain("v=1"); + expect(m2.query).toContain("v=2"); + expect(m1.query).not.toBe(m2.query); + done(); + }); + }) + .catch(function (error) { + reportRejection(error, done); + }); + }); + + // Collapsing cache-busters onto one registry key needs a vocabulary; + // the runtime ships none, so these specs install one. Canonicalization + // config is process-wide, hence the restore. (Jasmine 2.0.1 has no + // beforeAll/afterAll.) + describe("with a dev-endpoint vocabulary configured", function () { + beforeEach(function () { + require("ns:module").configureLoader({ + canonicalization: { + stripParams: ["t", "v", "import"], + forPathPrefixes: ["/ns/"], + preserveQueryFor: [], + }, + }); + }); + + afterEach(function () { + require("ns:module").configureLoader({ + canonicalization: { stripParams: [], forPathPrefixes: [], preserveQueryFor: [] }, + }); + }); + + it("drops the configured cache-busters for dev endpoints", function (done) { + var u1 = origin + "/ns/m/query.mjs?v=1"; + var u2 = origin + "/ns/m/query.mjs?v=2"; + + withTimeout(import(u1), 10000, "import " + u1) + .then(function (m1) { + return withTimeout(import(u2), 10000, "import " + u2).then(function (m2) { + // Both URLs map to one cache key, so the second + // import reuses the first evaluated module. + expect(m2.evaluatedAt).toBe(m1.evaluatedAt); + expect(m2.query).toBe(m1.query); + done(); + }); + }) + .catch(function (error) { + reportRejection(error, done); + }); + }); + + it("sorts query params for dev endpoints", function (done) { + var u1 = origin + "/ns/m/query.mjs?b=2&a=1"; + var u2 = origin + "/ns/m/query.mjs?a=1&b=2"; + + withTimeout(import(u1), 10000, "import " + u1) + .then(function (m1) { + return withTimeout(import(u2), 10000, "import " + u2).then(function (m2) { + expect(m2.evaluatedAt).toBe(m1.evaluatedAt); + expect(m2.query).toBe(m1.query); + done(); + }); + }) + .catch(function (error) { + reportRejection(error, done); + }); + }); + }); + + it("ignores URL fragments for cache identity", function (done) { + var u1 = origin + "/esm/query.mjs#one"; + var u2 = origin + "/esm/query.mjs#two"; + + withTimeout(import(u1), 10000, "import " + u1) + .then(function (m1) { + return withTimeout(import(u2), 10000, "import " + u2).then(function (m2) { + expect(m2.evaluatedAt).toBe(m1.evaluatedAt); + done(); + }); + }) + .catch(function (error) { + reportRejection(error, done); + }); + }); + }); +}); + +// A bare `@` is not a specifier the runtime knows: it resolves through the +// normal path and fails, naming itself, instead of being swallowed into a +// fabricated empty module. +describe("invalid module specifiers", function () { + it("rejects a dynamic import of '@' with an error naming the specifier", function (done) { + import("@").then(function () { + expect("resolved").toBe("rejected"); + done(); + }).catch(function (e) { + var message = String((e && e.message) || e); + expect(message.indexOf("Cannot find module '@'") >= 0 ? "names the specifier" : message) + .toBe("names the specifier"); + expect(message.indexOf("tried " + appRoot + "/@") >= 0 ? "names the path tried" : message) + .toBe("names the path tried"); + done(); + }); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testEsmInterop.js b/test-app/app/src/main/assets/app/tests/testEsmInterop.js new file mode 100644 index 000000000..a834fe92d --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testEsmInterop.js @@ -0,0 +1,74 @@ +// require() of an ES module follows Node's populateCJSExportsFromESM cascade: +// an explicit 'module.exports' export wins, a namespace without a default or +// with its own __esModule passes through, and everything else is wrapped in a +// facade that adds __esModule while keeping the target's live bindings. +describe("require(esm) exports interop", function () { + it("wraps a default-exporting module in an __esModule facade", function () { + var mod = require("~/esm/interop/default-live.mjs"); + expect(mod.__esModule).toBe(true); + expect(mod.default).toBe(1); + }); + + it("keeps the facade's default binding live", function () { + var mod = require("~/esm/interop/default-live.mjs"); + var before = mod.default; + mod.bump(); + expect(mod.default).toBe(before + 1); + }); + + it("returns the 'module.exports' export verbatim", function () { + var mod = require("~/esm/interop/module-exports.mjs"); + expect(typeof mod).toBe("function"); + expect(mod.marker).toBe("module.exports fixture"); + expect(mod(2, 3)).toBe(5); + }); + + it("passes the namespace through when the module declares __esModule", function () { + var mod = require("~/esm/interop/own-esmodule.mjs"); + expect(mod.__esModule).toBe("mine"); + expect(mod.default.tag).toBe("own-esmodule"); + }); + + it("passes the namespace through when there is no default export", function () { + var mod = require("~/esm/interop/named-only.mjs"); + expect(mod.alpha).toBe("a"); + expect(mod.beta()).toBe("b"); + expect(mod.default).toBeUndefined(); + expect(mod.__esModule).toBeUndefined(); + }); + + it("returns the same exports object for repeated requires", function () { + var first = require("~/esm/interop/identity.mjs"); + var second = require("~/esm/interop/identity.mjs"); + expect(first).toBe(second); + expect(first.__esModule).toBe(true); + }); + + it("re-exports the very same default the namespace holds", function (done) { + var required = require("~/esm/interop/agreement.mjs"); + import("~/esm/interop/agreement.mjs").then(function (ns) { + expect(required.default).toBe(ns.default); + expect(required.named).toBe(ns.named); + // The facade is a distinct namespace: only it carries __esModule. + expect(required).not.toBe(ns); + expect(ns.__esModule).toBeUndefined(); + done(); + }).catch(function (e) { + expect("rejected: " + String((e && e.message) || e)).toBe("resolved"); + done(); + }); + }); + + // import() keeps observing the raw namespace whichever side loaded first. + it("still gives import() the raw namespace after a require()", function (done) { + import("~/esm/interop/default-live.mjs").then(function (ns) { + expect(ns.__esModule).toBeUndefined(); + expect(typeof ns.bump).toBe("function"); + expect(require("~/esm/interop/default-live.mjs").default).toBe(ns.default); + done(); + }).catch(function (e) { + expect("rejected: " + String((e && e.message) || e)).toBe("resolved"); + done(); + }); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testImportMetaResolution.js b/test-app/app/src/main/assets/app/tests/testImportMetaResolution.js new file mode 100644 index 000000000..4d9e6850f --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testImportMetaResolution.js @@ -0,0 +1,38 @@ +// `import.meta` is populated by identifying the module in the loader registry, +// so each module in a graph must get its own — not the entry's, and not the +// importer's. +describe("import.meta resolution", function () { + function loadGraph() { + return import("~/esm/meta/parent.mjs"); + } + + function rejected(done) { + return function (error) { + expect("rejected: " + String((error && error.message) || error)).toBe("resolved"); + done(); + }; + } + + it("gives every module in a graph its own url and dirname", function (done) { + loadGraph().then(function (graph) { + var parent = graph.parentMeta; + var child = graph.childMeta; + + expect(child).not.toBe(parent); + // `dirname` is a filesystem path, `url` is a file: URL over it. + expect(parent.url).toBe("file://" + parent.dirname + "/parent.mjs"); + expect(child.url).toBe("file://" + child.dirname + "/child.mjs"); + expect(child.dirname).toBe(parent.dirname + "/nested"); + done(); + }, rejected(done)); + }); + + it("returns the identical import.meta object on a repeated import", function (done) { + Promise.all([loadGraph(), loadGraph()]).then(function (results) { + expect(results[1]).toBe(results[0]); + expect(results[1].parentMeta).toBe(results[0].parentMeta); + expect(results[1].childMeta).toBe(results[0].childMeta); + done(); + }, rejected(done)); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testNodeUrlModule.js b/test-app/app/src/main/assets/app/tests/testNodeUrlModule.js new file mode 100644 index 000000000..b41081c0d --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testNodeUrlModule.js @@ -0,0 +1,172 @@ +describe("node:url", function () { + function capture(fn) { + try { + fn(); + } catch (e) { + return e; + } + return null; + } + + it("resolves to one frozen object through both require and import", function (done) { + var required = require("node:url"); + + expect(Object.isFrozen(required)).toBe(true); + expect(Object.keys(required).sort()).toEqual(["fileURLToPath", "pathToFileURL"]); + // The shim converts between paths and file URLs; it is not a place to + // reach the URL intrinsic from. + expect(required.URL).toBeUndefined(); + expect(require("node:url")).toBe(required); + + Promise.all([import("node:url"), import("node:url")]).then(function (results) { + expect(results[1]).toBe(results[0]); + expect(results[0].default).toBe(required); + expect(results[0].fileURLToPath).toBe(required.fileURLToPath); + expect(results[0].pathToFileURL).toBe(required.pathToFileURL); + done(); + }).catch(function (error) { + expect("rejected: " + String((error && error.message) || error)).toBe("resolved"); + done(); + }); + }); + + it("converts file URLs to paths the way Node does", function () { + var fileURLToPath = require("node:url").fileURLToPath; + + expect(fileURLToPath("file:///foo/bar.txt")).toBe("/foo/bar.txt"); + expect(fileURLToPath(new URL("file:///foo/bar.txt"))).toBe("/foo/bar.txt"); + // The URL spec folds a "localhost" authority to no host at all. + expect(fileURLToPath("file://localhost/foo/bar.txt")).toBe("/foo/bar.txt"); + // Query and fragment are URL syntax, never part of the path. + expect(fileURLToPath("file:///foo/bar.txt?x=1#frag")).toBe("/foo/bar.txt"); + expect(fileURLToPath("file:///foo/a%20b.txt")).toBe("/foo/a b.txt"); + expect(fileURLToPath("file:///foo/100%25.txt")).toBe("/foo/100%.txt"); + }); + + it("rejects file URLs it cannot honestly convert", function () { + var fileURLToPath = require("node:url").fileURLToPath; + + var wrongScheme = capture(function () { fileURLToPath("http://example.com/x.js"); }); + expect(wrongScheme instanceof TypeError).toBe(true); + expect(wrongScheme.message).toBe("The URL must be of scheme file"); + + var remoteHost = capture(function () { fileURLToPath("file://otherhost/foo.txt"); }); + expect(remoteHost instanceof TypeError).toBe(true); + expect(remoteHost.message).toBe('File URL host must be "localhost" or empty'); + + // %2F would decode into a separator and change the path's shape. + var encodedSlash = capture(function () { fileURLToPath("file:///foo%2Fbar.txt"); }); + expect(encodedSlash instanceof TypeError).toBe(true); + expect(encodedSlash.message).toBe("File URL path must not include encoded / characters"); + + var expectedArgMessage = + 'The "path" argument must be of type string or an instance of URL.'; + var notAString = capture(function () { fileURLToPath(42); }); + expect(notAString instanceof TypeError).toBe(true); + expect(notAString.message).toBe(expectedArgMessage); + + var notAUrl = capture(function () { fileURLToPath("not a url"); }); + expect(notAUrl instanceof TypeError).toBe(true); + expect(notAUrl.message).toBe(expectedArgMessage); + }); + + it("converts paths to file URLs and round-trips them", function () { + var nodeUrl = require("node:url"); + var url = nodeUrl.pathToFileURL("/foo/bar.txt"); + + expect(url instanceof URL).toBe(true); + expect(url.protocol).toBe("file:"); + expect(url.pathname).toBe("/foo/bar.txt"); + + // The characters that would otherwise be read as URL syntax. + var paths = ["/foo/bar.txt", "/foo/a b.txt", "/foo/100%.txt", + "/foo/q?x.txt", "/foo/h#x.txt", "/foo/dir/"]; + for (var i = 0; i < paths.length; i++) { + expect(nodeUrl.fileURLToPath(nodeUrl.pathToFileURL(paths[i]))).toBe(paths[i]); + } + + var notAString = capture(function () { nodeUrl.pathToFileURL(42); }); + expect(notAString instanceof TypeError).toBe(true); + expect(notAString.message).toBe('The "path" argument must be of type string.'); + + // No process working directory here, so a relative path has no answer. + var relative = capture(function () { nodeUrl.pathToFileURL("foo/bar.txt"); }); + expect(relative instanceof TypeError).toBe(true); + expect(relative.message).toBe('The "path" argument must be an absolute path.'); + }); +}); + +describe("node:module", function () { + it("exposes exactly createRequire, frozen", function () { + var nodeModule = require("node:module"); + + expect(Object.isFrozen(nodeModule)).toBe(true); + expect(Object.keys(nodeModule)).toEqual(["createRequire"]); + expect(typeof nodeModule.createRequire).toBe("function"); + expect(require("node:module")).toBe(nodeModule); + }); + + it("is a distinct module object from ns:module sharing one createRequire", function () { + var nodeModule = require("node:module"); + var nsModule = require("ns:module"); + + expect(nodeModule).not.toBe(nsModule); + expect(nodeModule.createRequire).toBe(nsModule.createRequire); + }); + + it("omits createPumpingRequire, which has no Node counterpart", function () { + expect(require("node:module").createPumpingRequire).toBeUndefined(); + expect(typeof require("ns:module").createPumpingRequire).toBe("function"); + }); + + it("resolves to the same object through dynamic import", function (done) { + var nodeModule = require("node:module"); + import("node:module").then(function (ns) { + expect(ns.default).toBe(nodeModule); + expect(ns.createRequire).toBe(nodeModule.createRequire); + done(); + }).catch(function (error) { + expect("rejected: " + String((error && error.message) || error)).toBe("resolved"); + done(); + }); + }); +}); + +// node:path had an in-resolver polyfill once; it is not a registered builtin, +// so it must now fail exactly like any other unshimmed node: specifier. +describe("unregistered node: specifiers", function () { + var NOT_FOUND = "No such built-in module: node:path"; + + it("fails on require", function () { + var error = null; + try { + require("node:path"); + } catch (e) { + error = e; + } + expect(error instanceof Error).toBe(true); + expect(error.message).toBe(NOT_FOUND); + }); + + it("fails on dynamic import", function (done) { + import("node:path").then(function () { + expect("resolved").toBe("rejected with " + NOT_FOUND); + done(); + }, function (error) { + expect(error instanceof Error).toBe(true); + expect(error.message).toBe(NOT_FOUND); + done(); + }); + }); + + it("fails on a static import from a module", function (done) { + import("~/esm/nodebuiltins/importsNodePath.mjs").then(function () { + expect("resolved").toBe("rejected with " + NOT_FOUND); + done(); + }, function (error) { + // The instantiation failure wraps the resolver's message. + expect(String((error && error.message) || error)).toContain(NOT_FOUND); + done(); + }); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testNsModule.js b/test-app/app/src/main/assets/app/tests/testNsModule.js new file mode 100644 index 000000000..7d4d75866 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testNsModule.js @@ -0,0 +1,137 @@ +describe("ns:module", function () { + it("should expose the dev-loader primitives via the ns:module builtin", function () { + var nsModule = require("ns:module"); + expect(Object.isFrozen(nsModule)).toBe(true); + expect(typeof nsModule.configureLoader).toBe("function"); + expect(typeof nsModule.invalidateModules).toBe("function"); + expect(typeof nsModule.getLoadedModuleUrls).toBe("function"); + expect(typeof nsModule.createRequire).toBe("function"); + expect(typeof nsModule.createPumpingRequire).toBe("function"); + expect(nsModule.terminateAllWorkers).toBeUndefined(); + expect(global.__NS_DEV__).toBeUndefined(); + }); + + it("exposes exactly the declared surface", function () { + var nsModule = require("ns:module"); + var expected = ["configureLoader", "createPumpingRequire", "createRequire", + "getLoadedModuleUrls", "invalidateModules"]; + if (typeof nsModule.canonicalizeHttpUrlKey === "function") { + expected.push("canonicalizeHttpUrlKey"); + } + expect(Object.keys(nsModule).sort()).toEqual(expected.sort()); + }); + + it("resolves ns:module to the same members for require and import()", function (done) { + var nsModule = require("ns:module"); + import("ns:module").then(function (ns) { + expect(ns.default).toBe(nsModule); + expect(ns.invalidateModules).toBe(nsModule.invalidateModules); + expect(ns.configureLoader).toBe(nsModule.configureLoader); + done(); + }).catch(function (error) { + // fail() throws in this Jasmine, which inside a promise chain + // surfaces as an opaque timeout instead of the real reason. + expect("rejected: " + String((error && error.message) || error)).toBe("resolved"); + done(); + }); + }); + + // Boot state is derived natively from the entry-evaluation window; there is + // no client signal and no JS-visible mirror. + it("exposes no boot-complete signal", function () { + var nsModule = require("ns:module"); + expect(nsModule.setDevBootComplete).toBeUndefined(); + expect(global.__NS_HMR_BOOT_COMPLETE__).toBeUndefined(); + }); +}); + +describe("HTTP canonical key (ns:module canonicalizeHttpUrlKey)", function () { + function getCanon() { + return require("ns:module").canonicalizeHttpUrlKey; + } + + function checkKey(input, expected) { + var canon = getCanon(); + if (typeof canon !== "function") { + pending("ns:module.canonicalizeHttpUrlKey not exposed (release build)"); + return; + } + expect(canon(input)).toBe(expected); + } + + it("is exposed as a function in debug builds", function () { + var canon = getCanon(); + if (typeof canon !== "function") { + pending("ns:module.canonicalizeHttpUrlKey not exposed (release build)"); + return; + } + expect(typeof canon).toBe("function"); + }); + + // Unconfigured, the runtime knows no client vocabulary: it strips the + // fragment and nothing else. Which params are cache-busters and which + // paths are dev endpoints arrives through configureLoader. + describe("unconfigured (mechanical only)", function () { + it("keeps every query param, cache-buster-looking or not", function () { + checkKey("http://h/app/core?p=x&t=123&v=9&import=1", + "http://h/app/core?p=x&t=123&v=9&import=1"); + }); + + it("leaves public URLs untouched", function () { + checkKey("https://cdn.example.com/lib.js?token=abc", + "https://cdn.example.com/lib.js?token=abc"); + }); + + it("treats module identity as literally the URL — no path-tag collapses", function () { + checkKey("http://h/app/m/foo.js", "http://h/app/m/foo.js"); + checkKey("http://h/app/rt", "http://h/app/rt"); + }); + + it("still drops the fragment", function () { + checkKey("http://h/app/m/foo.js#frag", "http://h/app/m/foo.js"); + checkKey("https://cdn.example.com/lib.js?token=abc#frag", + "https://cdn.example.com/lib.js?token=abc"); + }); + }); + + // The canonicalization vocabulary is per-isolate loader state, so each spec + // installs it and restores the unconfigured shape afterwards. (Jasmine + // 2.0.1 has no beforeAll/afterAll.) + describe("with a client-supplied vocabulary", function () { + beforeEach(function () { + if (typeof getCanon() !== "function") { + return; + } + require("ns:module").configureLoader({ + canonicalization: { + stripParams: ["t", "v", "import"], + forPathPrefixes: ["/dev/"], + preserveQueryFor: ["/dev/metadata"], + }, + }); + }); + + afterEach(function () { + if (typeof getCanon() !== "function") { + return; + } + require("ns:module").configureLoader({ + canonicalization: { stripParams: [], forPathPrefixes: [], preserveQueryFor: [] }, + }); + }); + + it("strips the configured cache-busters under a configured prefix", function () { + checkKey("http://h/dev/core?p=x&t=123&v=9&import=1", "http://h/dev/core?p=x"); + }); + + it("lets preserveQueryFor win under a configured prefix", function () { + checkKey("http://h/dev/metadata?c=a&t=42", "http://h/dev/metadata?c=a&t=42"); + }); + + it("leaves paths outside the configured prefixes alone", function () { + checkKey("http://h/app/core?p=x&t=123", "http://h/app/core?p=x&t=123"); + checkKey("https://cdn.example.com/lib.js?token=abc", + "https://cdn.example.com/lib.js?token=abc"); + }); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testNsRuntime.js b/test-app/app/src/main/assets/app/tests/testNsRuntime.js new file mode 100644 index 000000000..f57d1c5bd --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testNsRuntime.js @@ -0,0 +1,105 @@ +describe("ns:runtime", function () { + var runtime = require("ns:runtime"); + + it("exposes frozen exports", function () { + expect(Object.isFrozen(runtime)).toBe(true); + expect(typeof runtime.setConfig).toBe("function"); + expect(typeof runtime.getConfig).toBe("function"); + }); + + // The export set is public API, declared alongside docs/ns-builtin-modules.md + // — all of them change together. + it("exposes exactly the declared surface", function () { + expect(Object.keys(runtime).sort()).toEqual(["getConfig", "setConfig"]); + }); + + it("rejects unknown keys", function () { + expect(function () { + runtime.setConfig("noSuchKey", 1); + }).toThrowError(TypeError, /Unknown runtime config key/); + expect(function () { + runtime.getConfig("noSuchKey"); + }).toThrowError(TypeError, /Unknown runtime config key/); + }); + + it("is a singleton across require calls", function () { + expect(require("ns:runtime")).toBe(runtime); + }); + + describe("debug categories", function () { + afterEach(function () { + runtime.setConfig("debug", ""); + }); + + it("starts disabled", function () { + expect(runtime.getConfig("debug")).toBe(""); + }); + + it("round-trips a category list canonically", function () { + runtime.setConfig("debug", "esm,fetch"); + expect(runtime.getConfig("debug")).toBe("esm,fetch"); + }); + + it("canonicalizes order and whitespace", function () { + runtime.setConfig("debug", " fetch , esm "); + expect(runtime.getConfig("debug")).toBe("esm,fetch"); + }); + + it("replaces the whole set rather than adding to it", function () { + runtime.setConfig("debug", "esm,fetch"); + runtime.setConfig("debug", "registry"); + expect(runtime.getConfig("debug")).toBe("registry"); + }); + + it("ignores unknown categories but keeps the known ones", function () { + runtime.setConfig("debug", "esm,nosuchcategory"); + expect(runtime.getConfig("debug")).toBe("esm"); + }); + + it("accepts every declared category", function () { + runtime.setConfig("debug", "esm,fetch,registry"); + expect(runtime.getConfig("debug")).toBe("esm,fetch,registry"); + }); + + it("disables everything on an empty string", function () { + runtime.setConfig("debug", "esm,fetch,registry"); + runtime.setConfig("debug", ""); + expect(runtime.getConfig("debug")).toBe(""); + }); + + it("rejects a non-string value and keeps the current set", function () { + runtime.setConfig("debug", "esm"); + expect(function () { + runtime.setConfig("debug", true); + }).toThrowError(TypeError, /comma-separated category string/); + expect(runtime.getConfig("debug")).toBe("esm"); + }); + }); + + it("no longer registers the removed log flags", function () { + ["logScriptLoading", "httpFetchUrlLog"].forEach(function (key) { + expect(function () { + runtime.getConfig(key); + }).toThrowError(TypeError, /Unknown runtime config key/); + }); + }); + + it("does not expose remote-module security through getConfig or setConfig", function () { + ["security", "allowRemoteModules", "remoteModuleAllowlist"].forEach(function (key) { + expect(function () { + runtime.getConfig(key); + }).toThrowError(TypeError, /Unknown runtime config key/); + expect(function () { + runtime.setConfig(key, true); + }).toThrowError(TypeError, /Unknown runtime config key/); + }); + }); + + // releasedObjectPolicy is an iOS-only key; the GC teardown policy it names + // has no Android counterpart. + it("does not expose releasedObjectPolicy", function () { + expect(function () { + runtime.getConfig("releasedObjectPolicy"); + }).toThrowError(TypeError, /Unknown runtime config key/); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js b/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js index 0398634b3..a53474d9b 100644 --- a/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js +++ b/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js @@ -38,8 +38,11 @@ describe("Remote Module Security", function() { }); it("should allow HTTPS module imports in debug mode", function(done) { - // Test HTTPS URL - should be allowed in debug mode - import("https://192.0.2.1:5173/test-module.js").then(function(module) { + // A closed loopback port, not an unroutable host: HTTPS bypasses the + // cleartext policy that makes the plain-HTTP cases fail instantly, so + // an unroutable address would burn the transport's 15s connect + // timeout twice (once per retry) and blow the spec timeout. + import("https://127.0.0.1:1/test-module.js").then(function(module) { expect(module).toBeDefined(); done(); }).catch(function(error) { @@ -55,30 +58,13 @@ describe("Remote Module Security", function() { describe("Security Configuration", function() { it("should have security configuration in package.json", function() { - var context = com.tns.Runtime.getCurrentRuntime().getContext(); - var assetManager = context.getAssets(); - - try { - var inputStream = assetManager.open("app/package.json"); - var reader = new java.io.BufferedReader(new java.io.InputStreamReader(inputStream)); - var sb = new java.lang.StringBuilder(); - var line; - - while ((line = reader.readLine()) !== null) { - sb.append(line); - } - reader.close(); - - var jsonString = sb.toString(); - var config = JSON.parse(jsonString); - - // Verify security config structure - expect(config.security).toBeDefined(); - expect(typeof config.security.allowRemoteModules).toBe("boolean"); - expect(Array.isArray(config.security.remoteModuleAllowlist)).toBe(true); - } catch (e) { - fail("Failed to read package.json: " + e.message); - } + // require() of a .json goes through the loader's own JSON route, so + // this reads the same file the native security gate was seeded from. + var config = require("~/package.json"); + + expect(config.security).toBeDefined(); + expect(typeof config.security.allowRemoteModules).toBe("boolean"); + expect(Array.isArray(config.security.remoteModuleAllowlist)).toBe(true); }); it("should parse security allowRemoteModules from package.json", function() { @@ -88,9 +74,11 @@ describe("Remote Module Security", function() { }); it("should parse security remoteModuleAllowlist from package.json", function() { + // A Java String[], not a JS Array — it indexes and reports a length + // but fails Array.isArray. var allowlist = com.tns.Runtime.getSecurityRemoteModuleAllowlist(); expect(allowlist).not.toBeNull(); - expect(Array.isArray(allowlist)).toBe(true); + expect(typeof allowlist.length).toBe("number"); expect(allowlist.length).toBeGreaterThan(0); // Verify our test allowlist entries are present @@ -142,6 +130,15 @@ describe("Remote Module Security", function() { // In debug mode, this returns true because debug bypasses allowlist expect(isAllowed).toBe(true); }); + + it("should refuse lookalike-host prefixes at a URL-component boundary (Java helper)", function() { + // The Java helper is the production-path twin of the native gate. + // Debug still short-circuits to true, so this only asserts the + // helper exists and debug bypass still holds; production matching + // is covered by the native RemoteUrlMatchesAllowlistEntry logic. + expect(typeof com.tns.Runtime.isRemoteUrlAllowed).toBe("function"); + expect(com.tns.Runtime.isRemoteUrlAllowed("https://cdn.example.com.attacker.com/x.js")).toBe(true); + }); }); describe("Static Import HTTP Loading", function() { @@ -168,7 +165,8 @@ describe("Remote Module Security", function() { // Test dynamic imports (ImportModuleDynamicallyCallback path) it("should attempt to load HTTPS module dynamically in debug mode", function(done) { - var url = "https://10.255.255.1:5173/dynamic-module.js"; + // Closed loopback port — see the HTTPS note above. + var url = "https://127.0.0.1:1/dynamic-module.js"; import(url).then(function(module) { expect(module).toBeDefined(); diff --git a/test-app/app/src/main/assets/app/tests/testWorkerEsmEntry.js b/test-app/app/src/main/assets/app/tests/testWorkerEsmEntry.js new file mode 100644 index 000000000..9c66a4fad --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testWorkerEsmEntry.js @@ -0,0 +1,165 @@ +// An ES module worker entry takes the same RunModule branch — and the same +// boot evaluation options — the app's main entry takes, so these pin that +// destination even though the suite cannot re-drive the app's own boot. +// A worker specifier is resolved through Java resolvePath whatever route the +// entry ends up taking, so app-root-absolute, relative and extension-less +// paths all reach an `.mjs` entry. +describe("worker ES module entries", function () { + var originalTimeout; + + beforeEach(function () { + originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 15000; + }); + + afterEach(function () { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + }); + + it("runs a synchronous ES module worker entry, statics and all", function (done) { + var worker = new Worker("~/tests/esmEntrySyncWorker.mjs"); + worker.onmessage = function (msg) { + expect(msg.data).toBe("esm-entry:ping"); + worker.terminate(); + done(); + }; + worker.postMessage("ping"); + }); + + it("runs an ES module worker entry whose top-level await parks past the yield window", + function (done) { + // The park is non-nestable, so the in-place window cannot settle it: + // the entry finishes from the real event loop afterwards, and the + // message queue enables on settle rather than being lost. + var worker = new Worker("~/tests/esmEntryTlaWorker.mjs"); + worker.onmessage = function (msg) { + expect(msg.data).toBe("tla-entry:ok:ping"); + worker.terminate(); + done(); + }; + worker.postMessage("ping"); + }); + + it("runs an ES module worker entry whose top-level await parks on a JS timer", + function (done) { + var worker = new Worker("~/tests/esmEntryTimerWorker.mjs"); + worker.onmessage = function (msg) { + expect(msg.data).toBe("timer-entry:ok:ping"); + worker.terminate(); + done(); + }; + worker.postMessage("ping"); + }); + + it("runs an ES module worker entry spawned through a relative path", function (done) { + var worker = new Worker("./esmEntryRelativeWorker.mjs"); + worker.onmessage = function (msg) { + expect(msg.data).toBe("relative-entry:ping"); + worker.terminate(); + done(); + }; + worker.postMessage("ping"); + }); + + // Extension resolution tries `.js` before `.mjs`, and no `.js` sibling + // exists, so the ES module entry is what answers. Its top-level await also + // parks past the yield window, so the message posted here proves the + // settle-gated queue engages on a resolved specifier too. + it("runs an extension-less ES module worker entry past its top-level await", + function (done) { + var worker = new Worker("./esmEntryResolvedWorker"); + worker.onmessage = function (msg) { + expect(msg.data).toBe("resolved-entry:ok:ping"); + worker.terminate(); + done(); + }; + worker.postMessage("ping"); + }); + + // WHATWG parity: the worker's message queue is enabled when its entry + // script finishes evaluating, and from then on messages dispatch whether + // or not a handler exists. A handler registered later (from a timer) + // misses messages delivered in between — exactly as on the web. + it("drops messages dispatched before a late-registered onmessage, like the web", function (done) { + var worker = new Worker("./lateHandlerWorker.js"); + var received = []; + worker.onmessage = function (msg) { + received.push(msg.data); + if (msg.data === "ready") { + worker.postMessage("second"); + } else { + expect(received).toEqual(["ready", "late:second"]); + worker.terminate(); + done(); + } + }; + // Posted before the entry finishes evaluating: buffered, then + // dispatched into a global with no handler yet — dropped. + worker.postMessage("early"); + }); +}); + +// A worker inherits a copy of its parent's loader vocabulary, taken on the +// parent's thread as the worker is constructed and installed before the worker +// loads any module. +describe("worker loader-vocabulary inheritance", function () { + var originalTimeout; + + function setLeaf(target) { + require("ns:module").configureLoader({ + importMap: { imports: { "ns-worker-leaf": target } }, + }); + } + + beforeEach(function () { + originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 15000; + }); + + afterEach(function () { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + // The map is isolate-wide state, so it must not outlive this describe. + require("ns:module").configureLoader({ importMap: { imports: {} } }); + }); + + it("gives a worker spawned after configureLoader the parent's map", function (done) { + setLeaf("~/esm/vocab/leafA.mjs"); + + var worker = new Worker("./importMapWorker.js"); + worker.onmessage = function (msg) { + expect(msg.data.ok ? "resolved" : "failed: " + msg.data.error).toBe("resolved"); + expect(msg.data.name).toBe("vocab-a"); + worker.terminate(); + done(); + }; + worker.postMessage("ns-worker-leaf"); + }); + + it("leaves a running worker on the map it was spawned with", function (done) { + setLeaf("~/esm/vocab/leafB.mjs"); + + var worker = new Worker("./importMapWorker.js"); + worker.onmessage = function (msg) { + expect(msg.data.ok ? "resolved" : "failed: " + msg.data.error).toBe("resolved"); + expect(msg.data.name).toBe("vocab-b"); + worker.terminate(); + done(); + }; + + // Reconfigure the parent only after the worker exists, then ask it to + // resolve: the worker answers from the copy taken at its spawn. + setLeaf("~/esm/vocab/leafC.mjs"); + worker.postMessage("ns-worker-leaf"); + }); + + it("still applies a later configureLoader on the parent's own isolate", function (done) { + setLeaf("~/esm/vocab/leafC.mjs"); + import("ns-worker-leaf").then(function (mod) { + expect(mod.name).toBe("vocab-c"); + done(); + }).catch(function (error) { + expect("rejected: " + String((error && error.message) || error)).toBe("resolved"); + done(); + }); + }); +}); diff --git a/test-app/app/src/main/java/com/tns/tests/ModuleTestServer.java b/test-app/app/src/main/java/com/tns/tests/ModuleTestServer.java new file mode 100644 index 000000000..0d90eec58 --- /dev/null +++ b/test-app/app/src/main/java/com/tns/tests/ModuleTestServer.java @@ -0,0 +1,295 @@ +package com.tns.tests; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.ByteArrayOutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; + +/** + * Loopback HTTP/1.1 fixture server for the in-app Jasmine suite. Mirrors the + * module-serving routes of the iOS TestRunnerTests ModuleTestServer so the + * HTTP ESM loader specs can run identically on both platforms. + */ +public final class ModuleTestServer { + private static final String JS_MIME = "application/javascript; charset=utf-8"; + private static final Charset UTF8 = Charset.forName("UTF-8"); + + private static ServerSocket serverSocket; + private static Thread acceptThread; + private static int boundPort = -1; + private static final List workers = new ArrayList(); + + private ModuleTestServer() { + } + + /** + * Starts the server if it is not already running and returns the port it is + * bound to on 127.0.0.1. Safe to call from any thread, any number of times. + */ + public static synchronized int ensureStarted() { + if (serverSocket != null && !serverSocket.isClosed()) { + return boundPort; + } + try { + serverSocket = new ServerSocket(0, 64, InetAddress.getByName("127.0.0.1")); + } catch (IOException e) { + throw new RuntimeException("ModuleTestServer failed to bind", e); + } + boundPort = serverSocket.getLocalPort(); + + final ServerSocket listener = serverSocket; + acceptThread = new Thread(new Runnable() { + @Override + public void run() { + acceptLoop(listener); + } + }, "ModuleTestServer"); + acceptThread.setDaemon(true); + acceptThread.start(); + return boundPort; + } + + public static synchronized void stop() { + if (serverSocket != null) { + try { + serverSocket.close(); + } catch (IOException ignored) { + } + serverSocket = null; + } + synchronized (workers) { + for (Thread t : workers) { + t.interrupt(); + } + workers.clear(); + } + acceptThread = null; + boundPort = -1; + } + + public static synchronized int getPort() { + return boundPort; + } + + private static void acceptLoop(ServerSocket listener) { + while (!listener.isClosed()) { + final Socket socket; + try { + socket = listener.accept(); + } catch (IOException e) { + return; + } + // A thread per connection: the /esm/timeout.mjs route parks its own + // thread for delayMs, and the module-graph walk fetches concurrently. + Thread worker = new Thread(new Runnable() { + @Override + public void run() { + try { + handle(socket); + } catch (Throwable ignored) { + } finally { + try { + socket.close(); + } catch (IOException ignored) { + } + synchronized (workers) { + workers.remove(Thread.currentThread()); + } + } + } + }, "ModuleTestServer-conn"); + worker.setDaemon(true); + synchronized (workers) { + workers.add(worker); + } + worker.start(); + } + } + + private static void handle(Socket socket) throws IOException { + socket.setSoTimeout(30000); + socket.setTcpNoDelay(true); + + String head = readHead(socket.getInputStream()); + if (head == null) { + return; + } + int lineEnd = head.indexOf("\r\n"); + String requestLine = lineEnd < 0 ? head : head.substring(0, lineEnd); + String[] parts = requestLine.split(" "); + if (parts.length < 2) { + respond(socket, "400 Bad Request", null, new byte[0]); + return; + } + String method = parts[0]; + String target = parts[1]; + String path = target; + String query = ""; + int q = target.indexOf('?'); + if (q >= 0) { + path = target.substring(0, q); + query = target.substring(q + 1); + } + + if (!"GET".equals(method)) { + respondNotFound(socket); + return; + } + route(socket, path, query); + } + + private static void route(Socket socket, String path, String query) throws IOException { + if ("/esm/query.mjs".equals(path) || "/ns/m/query.mjs".equals(path)) { + String body = "export const path = \"" + jsStringLiteral(path) + "\";\n" + + "export const query = \"" + jsStringLiteral(query) + "\";\n" + + "export const evaluatedAt = " + System.currentTimeMillis() + ";\n" + + "export default { path, query, evaluatedAt };"; + respond(socket, "200 OK", JS_MIME, body.getBytes(UTF8)); + return; + } + + if ("/esm/html-fallback.mjs".equals(path)) { + // The SPA-fallback shape: an unknown path answered with the index + // document, 200 OK. The module loader must reject it on MIME rather + // than hand HTML to the JS parser. + String body = "\nindex\n"; + respond(socket, "200 OK", "text/html; charset=utf-8", body.getBytes(UTF8)); + return; + } + + if ("/esm/data.json".equals(path)) { + String body = "{\"kind\":\"json-module\",\"n\":41}"; + respond(socket, "200 OK", "application/json; charset=utf-8", body.getBytes(UTF8)); + return; + } + + if ("/esm/empty.mjs".equals(path)) { + respond(socket, "200 OK", JS_MIME, new byte[0]); + return; + } + + if ("/esm/no-mime.mjs".equals(path)) { + // Content-Type is omitted deliberately: this route exercises the + // loader's missing-MIME branch. + respond(socket, "200 OK", null, "export const ok = true;\n".getBytes(UTF8)); + return; + } + + if ("/esm/graph-leaf.mjs".equals(path)) { + // `k` gives each importer its own module identity (the query is part + // of the key when no canonicalization vocabulary is configured), so + // several specs share this one route. + String key = param(query, "k="); + if (key == null) { + key = "x"; + } + String body = "const bucket = \"__nsMixedOrder\" + \"" + key + "\";\n" + + "(globalThis[bucket] = globalThis[bucket] || []).push(\"leaf\");\n" + + "export const name = \"" + key + "\";"; + respond(socket, "200 OK", JS_MIME, body.getBytes(UTF8)); + return; + } + + if ("/esm/syntax-error.mjs".equals(path)) { + // Deliberately unparseable: pins that the loader surfaces V8's real + // compile error instead of a generic failure. + respond(socket, "200 OK", JS_MIME, "export const ok = ;\n".getBytes(UTF8)); + return; + } + + if ("/esm/timeout.mjs".equals(path)) { + int delayMs = 12000; + String raw = param(query, "delayMs="); + if (raw != null) { + try { + delayMs = Integer.parseInt(raw); + } catch (NumberFormatException ignored) { + } + } + try { + Thread.sleep(delayMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + String body = "export const evaluatedAt = " + System.currentTimeMillis() + + "; export default { evaluatedAt };"; + respond(socket, "200 OK", JS_MIME, body.getBytes(UTF8)); + return; + } + + respondNotFound(socket); + } + + private static void respondNotFound(Socket socket) throws IOException { + respond(socket, "404 Not Found", "text/plain; charset=utf-8", "Not Found".getBytes(UTF8)); + } + + /** Reads bytes up to and including the CRLFCRLF header terminator. */ + private static String readHead(InputStream in) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + int matched = 0; + while (matched < 4) { + int b = in.read(); + if (b < 0) { + return null; + } + buffer.write(b); + char expected = (matched == 0 || matched == 2) ? '\r' : '\n'; + matched = (b == expected) ? matched + 1 : (b == '\r' ? 1 : 0); + if (buffer.size() > 64 * 1024) { + return null; + } + } + byte[] bytes = buffer.toByteArray(); + return new String(bytes, 0, bytes.length - 4, UTF8); + } + + /** + * Value of the first `&`-separated query component starting with `prefix`, + * undecoded. Unknown components (the loader appends cache-bust nonces) are + * ignored. + */ + private static String param(String query, String prefix) { + if (query == null || query.length() == 0) { + return null; + } + for (String pair : query.split("&")) { + if (pair.startsWith(prefix)) { + return pair.substring(prefix.length()); + } + } + return null; + } + + private static String jsStringLiteral(String s) { + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r"); + } + + private static void respond(Socket socket, String status, String contentType, byte[] body) + throws IOException { + StringBuilder headers = new StringBuilder(); + headers.append("HTTP/1.1 ").append(status).append("\r\n"); + if (contentType != null) { + headers.append("Content-Type: ").append(contentType).append("\r\n"); + } + headers.append("Content-Length: ").append(body.length).append("\r\n"); + // One request per connection: the client is HttpURLConnection, which + // would otherwise pool a socket this server never services again. + headers.append("Connection: close\r\n\r\n"); + + OutputStream out = socket.getOutputStream(); + out.write(headers.toString().getBytes(UTF8)); + out.write(body); + out.flush(); + } +} diff --git a/test-app/app/src/main/res/xml/network_security_config.xml b/test-app/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 000000000..ad81d9e18 --- /dev/null +++ b/test-app/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,13 @@ + + + + + 127.0.0.1 + localhost + + diff --git a/test-app/runtests.gradle b/test-app/runtests.gradle index 9cc19e6ff..aeb6f4951 100644 --- a/test-app/runtests.gradle +++ b/test-app/runtests.gradle @@ -35,6 +35,9 @@ def getBuildArguments = { -> if (onlyX86) { arguments.add("-PonlyX86") } + if (project.hasProperty("abis")) { + arguments.add("-Pabis=${project.property('abis')}") + } if (useCCache) { arguments.add("-PuseCCache") } @@ -68,13 +71,14 @@ task runAdbAsRoot(type: Exec) { } task deletePreviousResultXml(type: Exec) { + ignoreExitValue = true doFirst { println "Removing previous android_unit_test_results.xml" if (isWinOs) { - commandLine "cmd", "/c", "adb", runOnDeviceOrEmulator, "-e", "shell", "rm", "-rf", "/data/data/com.tns.testapplication/android_unit_test_results.xml" + commandLine "cmd", "/c", "adb", runOnDeviceOrEmulator, "-e", "shell", "run-as", "com.tns.testapplication", "rm", "-f", "android_unit_test_results.xml" } else { - commandLine "adb", runOnDeviceOrEmulator, "-e", "shell", "rm", "-rf", "/data/data/com.tns.testapplication/android_unit_test_results.xml" + commandLine "adb", runOnDeviceOrEmulator, "-e", "shell", "run-as", "com.tns.testapplication", "rm", "-f", "android_unit_test_results.xml" } } } diff --git a/test-app/runtime-binding-generator/src/main/java/com/tns/bindings/ProxyGenerator.java b/test-app/runtime-binding-generator/src/main/java/com/tns/bindings/ProxyGenerator.java index 52506fbd5..1cbfc8b0f 100644 --- a/test-app/runtime-binding-generator/src/main/java/com/tns/bindings/ProxyGenerator.java +++ b/test-app/runtime-binding-generator/src/main/java/com/tns/bindings/ProxyGenerator.java @@ -26,6 +26,17 @@ public void setProxyThumb(String proxyThumb) { } public String generateProxy(String proxyName, ClassDescriptor classToProxy, HashSet methodOverrides, HashSet implementedInterfaces, boolean isInterface, AnnotationDescriptor[] annotations) throws IOException { + return generateProxy(proxyName, null, classToProxy, methodOverrides, implementedInterfaces, isInterface, annotations); + } + + /** + * cacheDigest, when present, becomes part of the proxy's file name. The + * thumb only changes on reinstall, so name + thumb alone cannot see an + * edit to the proxy's contents (method overrides, interfaces) - the + * digest is what makes such an edit miss the cache instead of silently + * loading the previous dex. + */ + public String generateProxy(String proxyName, String cacheDigest, ClassDescriptor classToProxy, HashSet methodOverrides, HashSet implementedInterfaces, boolean isInterface, AnnotationDescriptor[] annotations) throws IOException { ApplicationWriter aw = new ApplicationWriter(); aw.visit(); @@ -37,7 +48,14 @@ public String generateProxy(String proxyName, ClassDescriptor classToProxy, Hash String proxyFileName; if (proxyName.contains(".")) { + // Thumb-suffix dotted names like the anonymous ones: DexFactory's + // cache probe (getDexFile) and purge (purgeDexesByThumb) both key + // on the thumb, so an unsuffixed file regenerates every launch and + // its stale .jar survives — and gets reused — across app versions. proxyFileName = proxyName; + if (proxyThumb != null) { + proxyFileName += "-" + proxyThumb; + } } else { proxyFileName = classToProxy.getName().replace('$', '_'); if (!isInterface) { @@ -47,6 +65,10 @@ public String generateProxy(String proxyName, ClassDescriptor classToProxy, Hash proxyFileName += "-" + proxyThumb; } } + // After the thumb, so purgeDexesByThumb keeps matching old generations. + if (cacheDigest != null) { + proxyFileName += "-" + cacheDigest; + } if (IsLogEnabled) { System.out.println("Generator: Saving proxy with file name: " + proxyFileName); diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index ef8a0d782..7d91ae760 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -73,7 +73,11 @@ set(RUNTIME_BUILTIN_JS ${RUNTIME_BUILTIN_JS_DIR}/events.js ${RUNTIME_BUILTIN_JS_DIR}/inspect.js ${RUNTIME_BUILTIN_JS_DIR}/json-helper.js + ${RUNTIME_BUILTIN_JS_DIR}/node-module.js + ${RUNTIME_BUILTIN_JS_DIR}/node-url.js ${RUNTIME_BUILTIN_JS_DIR}/node-util.js + ${RUNTIME_BUILTIN_JS_DIR}/ns-module.js + ${RUNTIME_BUILTIN_JS_DIR}/ns-runtime.js ${RUNTIME_BUILTIN_JS_DIR}/ns-util.js ${RUNTIME_BUILTIN_JS_DIR}/performance.js ${RUNTIME_BUILTIN_JS_DIR}/primordials.js @@ -227,8 +231,8 @@ add_library( src/main/cpp/URLImpl.cpp src/main/cpp/URLSearchParamsImpl.cpp src/main/cpp/URLPatternImpl.cpp - src/main/cpp/HMRSupport.cpp - src/main/cpp/DevFlags.cpp + src/main/cpp/HttpLoader.cpp + src/main/cpp/TraceLog.cpp # Node-API: vendored upstream implementation plus the embedder half # (env lifecycle, module registry, async work, threadsafe functions) diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index cf8b5188d..3762de682 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -798,7 +798,14 @@ void CallbackHandlers::QueueMacrotaskCallback(const v8::FunctionCallbackInfoPostOrdered([isolate, callback]() { - auto runtime = Runtime::GetRuntime(isolate); + // Java-dispatched callback with no live runtime: log-and-drop, + // never throw across the boundary + auto runtime = Runtime::TryGetRuntime(isolate); + if (runtime == nullptr) { + DEBUG_WRITE("__ns__queueMacrotask: dropping macrotask, its runtime is gone"); + callback->Reset(); + return; + } auto context = runtime->GetContext(); Context::Scope context_scope(context); TryCatch tc(isolate); @@ -807,7 +814,17 @@ void CallbackHandlers::QueueMacrotaskCallback(const v8::FunctionCallbackInfoReset(); if (tc.HasCaught() && !NativeScriptException::ContainUncaughtCallbackException(isolate, tc)) { - NativeScriptException(tc).ReThrowToJava(); + if (EventLoop::IsPumping()) { + // A pump drained this entry and keeps making JNI calls + // after we return; the loop reports the exception from + // its next token dispatch instead. + auto loop = runtime->GetEventLoop(); + if (loop != nullptr) { + loop->DeferJavaThrow(std::make_shared(tc)); + } + } else { + NativeScriptException(tc).ReThrowToJava(); + } } }); } catch (NativeScriptException &e) { @@ -1231,16 +1248,21 @@ void CallbackHandlers::NewThreadCallback(const v8::FunctionCallbackInfo(isolate, workerId, resolvedPath, + auto wrapper = std::make_shared(isolate, workerId, entryPath, currentDir, priority, thiz); WorkerWrapper::Insert(workerId, wrapper); diff --git a/test-app/runtime/src/main/cpp/ConcurrentQueue.cpp b/test-app/runtime/src/main/cpp/ConcurrentQueue.cpp index cc43b238c..0a5fcd52b 100644 --- a/test-app/runtime/src/main/cpp/ConcurrentQueue.cpp +++ b/test-app/runtime/src/main/cpp/ConcurrentQueue.cpp @@ -58,6 +58,20 @@ void ConcurrentQueue::Push(std::shared_ptr message) { } } +void ConcurrentQueue::Signal() { + std::unique_lock lock(initializationMutex_); + if (terminated_ || this->fd_ == -1) { + return; + } + uint64_t value = 1; + write(this->fd_, &value, sizeof(value)); +} + +bool ConcurrentQueue::IsEmpty() { + std::unique_lock mlock(this->mutex_); + return this->messagesQueue_.empty(); +} + std::vector> ConcurrentQueue::PopAll() { std::unique_lock mlock(this->mutex_); std::vector> messages; diff --git a/test-app/runtime/src/main/cpp/ConcurrentQueue.h b/test-app/runtime/src/main/cpp/ConcurrentQueue.h index 33526f443..bbcbbd688 100644 --- a/test-app/runtime/src/main/cpp/ConcurrentQueue.h +++ b/test-app/runtime/src/main/cpp/ConcurrentQueue.h @@ -21,6 +21,8 @@ struct ConcurrentQueue { public: void Initialize(ALooper* looper, ALooper_callbackFunc performWork, void* data); void Push(std::shared_ptr message); + void Signal(); + bool IsEmpty(); std::vector> PopAll(); void Terminate(); diff --git a/test-app/runtime/src/main/cpp/DevFlags.cpp b/test-app/runtime/src/main/cpp/DevFlags.cpp deleted file mode 100644 index 224601b10..000000000 --- a/test-app/runtime/src/main/cpp/DevFlags.cpp +++ /dev/null @@ -1,141 +0,0 @@ -// DevFlags.cpp -#include "DevFlags.h" -#include "JEnv.h" -#include -#include -#include -#include - -namespace tns { - -bool IsScriptLoadingLogEnabled() { - static std::atomic cached{-1}; // -1 unknown, 0 false, 1 true - int v = cached.load(std::memory_order_acquire); - if (v != -1) { - return v == 1; - } - - static std::once_flag initFlag; - std::call_once(initFlag, []() { - bool enabled = false; - try { - JEnv env; - jclass runtimeClass = env.FindClass("com/tns/Runtime"); - if (runtimeClass != nullptr) { - jmethodID mid = env.GetStaticMethodID(runtimeClass, "getLogScriptLoadingEnabled", "()Z"); - if (mid != nullptr) { - jboolean res = env.CallStaticBooleanMethod(runtimeClass, mid); - enabled = (res == JNI_TRUE); - } - } - } catch (...) { - // keep default false - } - cached.store(enabled ? 1 : 0, std::memory_order_release); - }); - - return cached.load(std::memory_order_acquire) == 1; -} - -// Security config - -static std::once_flag s_securityConfigInitFlag; -static bool s_allowRemoteModules = false; -static std::vector s_remoteModuleAllowlist; -static bool s_isDebuggable = false; - -// Helper to check if a URL starts with a given prefix -static bool UrlStartsWith(const std::string& url, const std::string& prefix) { - if (prefix.size() > url.size()) return false; - return url.compare(0, prefix.size(), prefix) == 0; -} - -void InitializeSecurityConfig() { - std::call_once(s_securityConfigInitFlag, []() { - try { - JEnv env; - jclass runtimeClass = env.FindClass("com/tns/Runtime"); - if (runtimeClass == nullptr) { - return; - } - - // Check isDebuggable first - jmethodID isDebuggableMid = env.GetStaticMethodID(runtimeClass, "isDebuggable", "()Z"); - if (isDebuggableMid != nullptr) { - jboolean res = env.CallStaticBooleanMethod(runtimeClass, isDebuggableMid); - s_isDebuggable = (res == JNI_TRUE); - } - - // If debuggable, we don't need to check further - always allow - if (s_isDebuggable) { - s_allowRemoteModules = true; - return; - } - - // Check isRemoteModulesAllowed - jmethodID allowRemoteMid = env.GetStaticMethodID(runtimeClass, "isRemoteModulesAllowed", "()Z"); - if (allowRemoteMid != nullptr) { - jboolean res = env.CallStaticBooleanMethod(runtimeClass, allowRemoteMid); - s_allowRemoteModules = (res == JNI_TRUE); - } - - // Get the allowlist - jmethodID getAllowlistMid = env.GetStaticMethodID(runtimeClass, "getRemoteModuleAllowlist", "()[Ljava/lang/String;"); - if (getAllowlistMid != nullptr) { - jobjectArray allowlistArray = (jobjectArray)env.CallStaticObjectMethod(runtimeClass, getAllowlistMid); - if (allowlistArray != nullptr) { - jsize len = env.GetArrayLength(allowlistArray); - for (jsize i = 0; i < len; i++) { - jstring jstr = (jstring)env.GetObjectArrayElement(allowlistArray, i); - if (jstr != nullptr) { - const char* str = env.GetStringUTFChars(jstr, nullptr); - if (str != nullptr) { - s_remoteModuleAllowlist.push_back(std::string(str)); - env.ReleaseStringUTFChars(jstr, str); - } - env.DeleteLocalRef(jstr); - } - } - env.DeleteLocalRef(allowlistArray); - } - } - } catch (...) { - // Keep defaults (remote modules disabled) - } - }); -} - -bool IsRemoteModulesAllowed() { - InitializeSecurityConfig(); - return s_allowRemoteModules || s_isDebuggable; -} - -bool IsRemoteUrlAllowed(const std::string& url) { - InitializeSecurityConfig(); - - // Debug mode always allows all URLs - if (s_isDebuggable) { - return true; - } - - // Production: first check if remote modules are allowed at all - if (!s_allowRemoteModules) { - return false; - } - - // If no allowlist is configured, allow all URLs (user explicitly enabled remote modules) - if (s_remoteModuleAllowlist.empty()) { - return true; - } - - // Check if URL matches any allowlist prefix - for (const std::string& prefix : s_remoteModuleAllowlist) { - if (UrlStartsWith(url, prefix)) { - return true; - } - } - - return false; -} - -} // namespace tns diff --git a/test-app/runtime/src/main/cpp/DevFlags.h b/test-app/runtime/src/main/cpp/DevFlags.h deleted file mode 100644 index db571d49f..000000000 --- a/test-app/runtime/src/main/cpp/DevFlags.h +++ /dev/null @@ -1,24 +0,0 @@ -// DevFlags.h -#pragma once - -#include - -namespace tns { - -// Fast cached flag: whether to log script loading diagnostics. -// First call queries Java once; subsequent calls are atomic loads only. -bool IsScriptLoadingLogEnabled(); - -// Security config - -// "security.allowRemoteModules" from nativescript.config -bool IsRemoteModulesAllowed(); - -// "security.remoteModuleAllowlist" array from nativescript.config -// If no allowlist is configured but allowRemoteModules is true, all URLs are allowed. -bool IsRemoteUrlAllowed(const std::string& url); - -// Init security configuration -void InitializeSecurityConfig(); - -} diff --git a/test-app/runtime/src/main/cpp/EventLoop.cpp b/test-app/runtime/src/main/cpp/EventLoop.cpp index 13d1df306..2a439dc88 100644 --- a/test-app/runtime/src/main/cpp/EventLoop.cpp +++ b/test-app/runtime/src/main/cpp/EventLoop.cpp @@ -1,12 +1,14 @@ #include "EventLoop.h" #include +#include #include #include #include #include #include +#include #include #include #include @@ -27,6 +29,15 @@ double now_ms() { return 1000.0 * res.tv_sec + (double) res.tv_nsec / 1e6; } +// Depth, not a flag: a pumped callback can start a nested pump (a drained +// timer calling a pumping require). +thread_local int t_pumpDepth = 0; + +struct PumpScope { + PumpScope() { ++t_pumpDepth; } + ~PumpScope() { --t_pumpDepth; } +}; + // runs one unit of work without letting a C++ exception escape into an // ALooper callback frame template @@ -132,9 +143,10 @@ void EventLoop::BindToCurrentThread() { // flush work buffered before the home thread was known auto now = now_ms(); - for (size_t i = 0; i < internal_.immediate.size(); i++) { + for (auto& entry : internal_.immediate) { uint64_t value = 1; write(eventFd_, &value, sizeof(value)); + entry.unitIssued = true; } ArmTimerLocked(now); for (auto& entry : ordered_.immediate) { @@ -162,6 +174,8 @@ void EventLoop::Shutdown() { internal_.delayed.clear(); ordered_.immediate.clear(); ordered_.delayed.clear(); + deferredJavaThrows_.clear(); + pumpDrainHook_ = nullptr; if (eventFd_ != -1) { ALooper_removeFd(looper_, eventFd_); close(eventFd_); @@ -203,6 +217,7 @@ void EventLoop::PostInternalLocked(Entry entry, double delayMs) { auto now = now_ms(); if (delayMs <= 0) { entry.time = now; + entry.unitIssued = eventFd_ != -1; internal_.immediate.push_back(std::move(entry)); if (eventFd_ != -1) { uint64_t value = 1; @@ -408,13 +423,22 @@ bool EventLoop::IsStopped() { return stopped_; } -std::unique_ptr EventLoop::TakeDueLocked(Lane& lane, bool nestableOnly, - bool v8Only, +bool EventLoop::MatchesFilter(const Entry& e, DrainFilter filter) { + switch (filter) { + case DrainFilter::kAny: + return true; + case DrainFilter::kNestableV8: + return e.nestable && e.task != nullptr; + case DrainFilter::kPumpDeliverable: + return e.nestable && !e.bare; + } + return false; +} + +std::unique_ptr EventLoop::TakeDueLocked(Lane& lane, DrainFilter filter, bool requireSignaledDelayed, double now) { - auto matches = [&](const Entry& e) { - return (!nestableOnly || e.nestable) && (!v8Only || e.task != nullptr); - }; + auto matches = [&](const Entry& e) { return MatchesFilter(e, filter); }; auto imIt = lane.immediate.begin(); while (imIt != lane.immediate.end() && !matches(*imIt)) { ++imIt; @@ -450,6 +474,26 @@ double EventLoop::PeekDueLocked(Lane& lane, double now) { return due; } +double EventLoop::PeekDueFilteredLocked(Lane& lane, DrainFilter filter, double now) { + auto matches = [&](const Entry& e) { return MatchesFilter(e, filter); }; + double due = -1; + for (const auto& e : lane.immediate) { + if (matches(e)) { + due = e.time; + break; + } + } + for (const auto& pair : lane.delayed) { + if (pair.first > now) { + break; + } + if (matches(pair.second) && (due < 0 || pair.first < due)) { + due = pair.first; + } + } + return due; +} + void EventLoop::ArmTimerLocked(double now) { if (timerFd_ == -1) { return; @@ -501,17 +545,78 @@ void EventLoop::RunOneInternal() { if (stopped_) { return; } - entry = TakeDueLocked(internal_, false, false, true, now_ms()); + entry = TakeDueLocked(internal_, DrainFilter::kAny, true, now_ms()); + if (entry == nullptr) { + // leftover unit: the work it represented ran early from a direct + // drain - this dispatch just consumed it, so it is no longer + // WaitForInternalWork's to swallow + if (leftoverUnits_ > 0) { + leftoverUnits_--; + } + return; + } } - if (entry == nullptr) { - // leftover unit: the work it represented ran early from a nested loop - // drain + RunEntry(*entry); +} + +bool EventLoop::IsPumping() { return t_pumpDepth > 0; } + +void EventLoop::DeferJavaThrow(std::shared_ptr ex) { + std::lock_guard lock(mutex_); + if (stopped_) { return; } - RunEntry(*entry); + deferredJavaThrows_.push_back(std::move(ex)); + // the wakeup: an empty ordered entry whose token forces a nativeRunTask + // visit once the looper resumes, even if a drain consumes the entry first + PostOrderedLocked(Entry{nullptr, []() {}, true, false, 0}, 0); +} + +void EventLoop::ReportDeferredJavaError() { + std::shared_ptr ex; + { + std::lock_guard lock(mutex_); + if (deferredJavaThrows_.empty()) { + return; + } + ex = std::move(deferredJavaThrows_.front()); + deferredJavaThrows_.pop_front(); + } + ex->ReThrowToJava(); +} + +void EventLoop::SetPumpDrainHook(std::function hook) { + // home thread only, like every consumer of pumpDrainHook_ + pumpDrainHook_ = std::move(hook); +} + +// Runs `body` without letting a C++ exception escape, deferring the Java-side +// report while a pump is on the stack (RunGuarded's direct ReThrowToJava arms +// a pending JNI exception, which is only legal when returning to Java is the +// next act). +void EventLoop::GuardEntryRun(const std::function& body) { + if (!IsPumping()) { + RunGuarded(body); + return; + } + try { + body(); + } catch (NativeScriptException& ex) { + // what() may read a JNI local ref that cannot outlive this dispatch, + // so only its text is carried + DeferJavaThrow(std::make_shared(std::string(ex.what()))); + } catch (std::exception& ex) { + DEBUG_WRITE_FORCE("Error: c++ exception in event loop task: %s", ex.what()); + } catch (...) { + DEBUG_WRITE_FORCE("Error: unknown c++ exception in event loop task!"); + } } void EventLoop::RunNestableV8Tasks() { + RunDueInternalWork(DrainFilter::kNestableV8); +} + +void EventLoop::RunDueInternalWork(DrainFilter filter) { // bounded to the entries present at call time so a task that reposts // can't wedge the inspector pause loop that called us size_t budget; @@ -526,53 +631,213 @@ void EventLoop::RunNestableV8Tasks() { if (stopped_) { return; } - entry = TakeDueLocked(internal_, true, true, false, now_ms()); - } - if (entry == nullptr) { - return; + const size_t delayedBefore = internal_.delayed.size(); + entry = TakeDueLocked(internal_, filter, false, now_ms()); + if (entry == nullptr) { + return; + } + if (entry->unitIssued) { + leftoverUnits_++; + } + if (internal_.delayed.size() != delayedBefore) { + // a drained delayed entry may leave the timerfd armed (or + // expired unread) for it; rearming to the queue's new + // earliest also discards the stale expiration + ArmTimerLocked(now_ms()); + } } // the pause loops call this from inside v8 inspector frames - a C++ // exception must not unwind through them - RunGuarded([&] { RunEntry(*entry); }); + GuardEntryRun([&] { RunEntry(*entry); }); } } -void EventLoop::RunOrderedTask() { - // one anonymous token = one due slot across the whole ordered domain: - // pick the earliest due item among the ordered entries and the timer - // source, whichever it is. Timers and entries only ever run on this - // thread, so the peeked winner can't be taken by anyone else before we - // re-lock (a concurrent post can only add later work). +bool EventLoop::RunOneOrderedDue() { + // one due slot across the whole ordered domain: pick the earliest due + // item among the ordered entries and the timer source, whichever it is. + // Timers and entries only ever run on this thread, so the peeked winner + // can't be taken by anyone else before we re-lock (a concurrent post can + // only add later work). auto now = now_ms(); double entryDue; { std::lock_guard lock(mutex_); if (stopped_) { - return; + return false; } entryDue = PeekDueLocked(ordered_, now); } if (timerSource_ != nullptr && timerSource_->RunIfEarliest(now, entryDue)) { - return; + // fn entries get their checkpoint in RunEntry; a timer callback runs + // under kAuto, which skips the depth-0 drain whenever a pump's JS + // frames are on the stack, so drain here or a microtask enqueued by + // one timer runs after the next timer instead of before it + v8::Locker locker(isolate_); + v8::Isolate::Scope isolateScope(isolate_); + v8::HandleScope handleScope(isolate_); + isolate_->PerformMicrotaskCheckpoint(); + return true; } if (entryDue < 0) { - // leftover token: nothing in the domain is due yet - return; + // leftover token, or an idle drain: nothing in the domain is due yet + return false; } std::unique_ptr entry; { std::lock_guard lock(mutex_); if (stopped_) { - return; + return false; } - entry = TakeDueLocked(ordered_, false, false, false, now_ms()); + entry = TakeDueLocked(ordered_, DrainFilter::kAny, false, now_ms()); } - if (entry != nullptr) { + if (entry == nullptr) { + return false; + } + if (IsPumping()) { + // an ordered entry's failure is its own report, never the pumping + // require's; on the token path the throw belongs to nativeRunTask + GuardEntryRun([&] { RunEntry(*entry); }); + } else { RunEntry(*entry); } + return true; +} + +void EventLoop::RunOrderedTask() { + // one anonymous token = one due slot; a token whose item a pump drained + // early finds nothing due and dies here + RunOneOrderedDue(); +} + +int EventLoop::RunDueOrderedEntries() { + // Bounded slice: a callback that keeps minting due-now work (a + // setTimeout(0) chain) must not pin the calling pump past its own + // deadline checks, so the drain yields after a few milliseconds and the + // pump comes back for the rest on its next iteration. + constexpr double kSliceMs = 8.0; + const double start = now_ms(); + int ran = 0; + while (!isolate_->IsExecutionTerminating() && RunOneOrderedDue()) { + ran++; + if (now_ms() - start >= kSliceMs) { + break; + } + } + return ran; +} + +EventLoop::PumpResult EventLoop::PumpUntil(double deadlineSeconds, + const std::function& settled, + bool drainLooperWork) { + // home thread only: the drains below take ordered/timer slots and eventfd + // units that the looper's own dispatch owns on that thread + NS_DCHECK(looper_ == nullptr || ALooper_forThread() == looper_); + PumpScope pumpScope; + const auto deadline = std::chrono::steady_clock::now() + + std::chrono::duration(deadlineSeconds); + for (;;) { + if (settled()) { + return PumpResult::kSettled; + } + if (isolate_->IsExecutionTerminating()) { + return PumpResult::kTerminated; + } + if (IsStopped()) { + // a stopped loop drops every post, so nothing can settle anymore + return PumpResult::kTerminated; + } + if (std::chrono::steady_clock::now() >= deadline) { + return PumpResult::kDeadline; + } + RunDueInternalWork(drainLooperWork ? DrainFilter::kPumpDeliverable + : DrainFilter::kNestableV8); + { + // work may enqueue microtasks without entering JS; scopes are + // re-entrant, so callers already holding them pay nothing + v8::Locker locker(isolate_); + v8::Isolate::Scope isolateScope(isolate_); + v8::HandleScope handleScope(isolate_); + isolate_->PerformMicrotaskCheckpoint(); + } + int ranLooperWork = 0; + if (drainLooperWork) { + ranLooperWork = RunDueOrderedEntries(); + if (pumpDrainHook_ != nullptr) { + ranLooperWork += pumpDrainHook_(); + } + } + if (settled()) { + return PumpResult::kSettled; + } + if (ranLooperWork == 0) { + WaitForInternalWork(10, /*pumpDeliverable=*/drainLooperWork); + } + } +} + +namespace { +// Depth, not a flag: an fd callback can dispatch JS that lands back in +// another callback through a nested drain. +thread_local int t_looperCallbackDepth = 0; + +struct LooperCallbackScope { + LooperCallbackScope() { ++t_looperCallbackDepth; } + ~LooperCallbackScope() { --t_looperCallbackDepth; } +}; +} // namespace + +bool EventLoop::IsInLooperCallback() { return t_looperCallbackDepth > 0; } + +void EventLoop::WaitForInternalWork(int timeoutMs, bool pumpDeliverable) { + const DrainFilter filter = + pumpDeliverable ? DrainFilter::kPumpDeliverable : DrainFilter::kNestableV8; + struct pollfd fds[2]; + nfds_t count = 0; + bool sleepOnly = false; + { + std::lock_guard lock(mutex_); + if (stopped_) { + sleepOnly = true; + } else { + const double now = now_ms(); + // Drainable work already due: the caller's drain runs it, waiting + // would only add latency. The filter must match the drain mode of + // the pump idling here — a due entry the drain cannot take must + // not turn the wait into a no-op. + if (PeekDueFilteredLocked(internal_, filter, now) >= 0) { + return; + } + // units whose entries a direct drain already consumed keep the + // eventfd readable; swallow them or the poll below returns + // immediately on every call + while (leftoverUnits_ > 0 && eventFd_ != -1) { + uint64_t value; + if (read(eventFd_, &value, sizeof(value)) != sizeof(value)) { + break; + } + leftoverUnits_--; + } + // A due entry the drain cannot take (non-nestable task, plain fn + // post) pins its unread unit in the eventfd, so the fds cannot go + // quiet — polling them would spin. Plain sleep is the only honest + // wait until the looper resumes and runs it. + if (PeekDueLocked(internal_, now) >= 0) { + sleepOnly = true; + } else { + if (eventFd_ != -1) fds[count++] = {eventFd_, POLLIN, 0}; + if (timerFd_ != -1) fds[count++] = {timerFd_, POLLIN, 0}; + } + } + } + if (sleepOnly || count == 0) { + usleep(static_cast(timeoutMs) * 1000); + return; + } + poll(fds, count, timeoutMs); } int EventLoop::EventFdCallback(int fd, int events, void* data) { + LooperCallbackScope callbackScope; uint64_t value; // EFD_SEMAPHORE: consumes exactly one unit; while more remain the fd stays // readable and ALooper calls back next poll, interleaving with Java @@ -586,6 +851,7 @@ int EventLoop::EventFdCallback(int fd, int events, void* data) { } int EventLoop::TimerFdCallback(int fd, int events, void* data) { + LooperCallbackScope callbackScope; uint64_t expirations; if (read(fd, &expirations, sizeof(expirations)) != sizeof(expirations)) { return 1; @@ -604,6 +870,7 @@ int EventLoop::TimerFdCallback(int fd, int events, void* data) { } if (!pair.second.signaled) { pair.second.signaled = true; + pair.second.unitIssued = true; due++; } } @@ -621,7 +888,11 @@ int EventLoop::TimerFdCallback(int fd, int events, void* data) { extern "C" JNIEXPORT void JNICALL Java_com_tns_EventLoopHandler_nativeRunTask( JNIEnv* env, jclass clazz, jlong nativeLoopPtr) { try { - reinterpret_cast(nativeLoopPtr)->RunOrderedTask(); + auto* loop = reinterpret_cast(nativeLoopPtr); + loop->RunOrderedTask(); + // returning to Java is the next act, so a report a pump had to defer + // is safe to arm here + loop->ReportDeferredJavaError(); } catch (tns::NativeScriptException& e) { e.ReThrowToJava(); } catch (std::exception& e) { diff --git a/test-app/runtime/src/main/cpp/EventLoop.h b/test-app/runtime/src/main/cpp/EventLoop.h index 0fa1e823f..9a185ec18 100644 --- a/test-app/runtime/src/main/cpp/EventLoop.h +++ b/test-app/runtime/src/main/cpp/EventLoop.h @@ -16,6 +16,8 @@ namespace tns { +class NativeScriptException; + /** * A producer of ordered-lane work that keeps its own bookkeeping (Timers). * The EventLoop's token drain consults it so timers and ordered entries form @@ -183,6 +185,94 @@ class EventLoop { */ void RunNestableV8Tasks(); + /** + * True while the calling thread is inside one of this process's ALooper + * fd callbacks. Android's Looper::pollInner holds a Response& into its + * response vector across each callback; a nested ALooper_pollOnce on the + * same looper clears and reallocates that vector, so the outer poll + * resumes over freed memory. Any code that pumps the looper (module + * evaluation, the boot backstop, the fetch yield) must consult this and + * drain queues directly instead of polling when it is set. + */ + static bool IsInLooperCallback(); + + /** + * Blocks the calling thread until this loop's internal lane has work (the + * eventfd or timerfd is readable) or `timeoutMs` elapses, whichever comes + * first, without entering the looper - so it is safe where + * IsInLooperCallback forbids polling. Returns immediately when work the + * caller's drain can take is already due; `pumpDeliverable` selects which + * filter that is, and must match the drain mode of the pump that idles + * here - a due entry the drain cannot take must not no-op the wait. + * Eventfd units left over from entries a direct drain consumed are + * swallowed first, so the wait only wakes for new work instead of + * spinning on stale readability. + */ + void WaitForInternalWork(int timeoutMs, bool pumpDeliverable = false); + + /** + * Runs every ordered-lane item that is due NOW - Java-token entries and + * timer-source items alike, in due order - directly from the calling + * (home) thread, without waiting for their Handler messages. The messages + * still arrive later and die as leftover tokens: a token whose item was + * drained early finds nothing due and no-ops, and its claim cell is + * retired by the dispatch gate as usual. Bounded to a short slice so a + * callback minting due-now work (a setTimeout(0) chain) cannot pin the + * caller past its own deadline checks. Returns the number of items run. + */ + int RunDueOrderedEntries(); + + enum class PumpResult { kSettled, kDeadline, kTerminated }; + + /** + * Drives this loop in place on the home thread until `settled()` returns + * true or `deadlineSeconds` elapses, idling in WaitForInternalWork + * between slices. The one pump primitive behind module evaluation, the + * graph walk, and the boot backstop; `settled` may throw and the + * exception propagates. Returns kTerminated when the isolate is + * terminating or the loop has been shut down. + * + * `drainLooperWork` picks what a pump iteration runs, mirroring the iOS + * pump's pumpRunLoop split: + * - false: nestable v8 tasks and a microtask checkpoint only, exactly + * like the inspector pause loops (iOS's default pump body). + * - true (the looper-equivalent drain, standing in for iOS's runloop + * slice): additionally runs due ordered-lane work (JS timers included) + * and plain internal-lane posts (worker->parent messages, Node-API + * completions), plus the registered pump drain hook. Non-nestable v8 + * tasks stay queued in both modes (the v8 nestability contract: JS + * frames are on the stack throughout), and so do bare posts - their + * fns lock a DIFFERENT isolate, and running one under a caller + * holding this isolate's Locker nests Lockers across isolates. + */ + PumpResult PumpUntil(double deadlineSeconds, const std::function& settled, + bool drainLooperWork); + + /** + * True while the calling thread is inside PumpUntil. Callback code that + * would arm a pending Java exception (env.Throw) must defer it through + * DeferJavaThrow instead while this holds: the pump keeps making JNI + * calls after the callback returns. + */ + static bool IsPumping(); + + /** + * Queues an exception to be raised on the Java side from the next + * ordered-token dispatch - the point where returning to Java is the next + * act - and posts the wakeup that guarantees such a dispatch happens. + * The exception must not hold JNI local refs (capture the message text + * instead when it might). + */ + void DeferJavaThrow(std::shared_ptr ex); + + /** + * Registers extra home-thread work for looper-equivalent pump drains + * (the worker inbox, which rides its own fd the pump never polls). + * Returns the number of items it ran. Home thread only; cleared by + * Shutdown; pass nullptr to unregister early. + */ + void SetPumpDrainHook(std::function hook); + /** * Runs at most one due ordered-lane entry, then performs a microtask * checkpoint. Invoked by Java EventLoopHandler.handleMessage once per @@ -190,7 +280,24 @@ class EventLoop { */ void RunOrderedTask(); + /** + * Raises one deferred exception (DeferJavaThrow) as a pending Java + * exception. Called by nativeRunTask after each token dispatch, where + * returning to Java is the next act; one exception per dispatch, each + * defer having posted its own wakeup token. + */ + void ReportDeferredJavaError(); + private: + // which internal-lane entries a drain may take + enum class DrainFilter { + kAny, // the looper's own dispatch (RunOneInternal) + kNestableV8, // inspector pause loops and drain-off pumps + // looper-equivalent pumps: nestable v8 tasks and plain fn posts; + // never bare posts (they lock a different isolate) and never + // non-nestable tasks + kPumpDeliverable, + }; struct Entry { // exactly one of task/fn is set; fn entries are never drained by // RunNestableV8Tasks (plain posts didn't run during debugger pauses @@ -208,12 +315,18 @@ class EventLoop { // this entry (written when its timerfd deadline fired), so a later // timer fire must not issue a second one bool signaled = false; + // internal entries only: an eventfd unit backs this entry, so a + // direct (unit-free) drain that consumes it must count the unit as + // leftover for WaitForInternalWork to swallow + bool unitIssued = false; }; struct Lane { std::deque immediate; std::multimap delayed; }; + static bool MatchesFilter(const Entry& e, DrainFilter filter); + // all *Locked members require mutex_ to be held. // requireSignaledDelayed must be true on the eventfd unit-consuming path: // a due delayed entry whose timerfd unit hasn't been issued yet is not @@ -224,13 +337,25 @@ class EventLoop { // drains consume no units at all. void PostInternalLocked(Entry entry, double delayMs); void PostOrderedLocked(Entry entry, double delayMs); - static std::unique_ptr TakeDueLocked(Lane& lane, bool nestableOnly, bool v8Only, + static std::unique_ptr TakeDueLocked(Lane& lane, DrainFilter filter, bool requireSignaledDelayed, double now); // earliest due entry time in the lane, or a negative value if none is due static double PeekDueLocked(Lane& lane, double now); + // same, but only over entries matching TakeDueLocked's filter + static double PeekDueFilteredLocked(Lane& lane, DrainFilter filter, double now); void ArmTimerLocked(double now); void RunEntry(Entry& entry); + // RunGuarded, but pump-aware: defers the Java-side report while a pump is + // on the stack instead of arming a pending JNI exception mid-pump + void GuardEntryRun(const std::function& body); + // one bounded pass over the internal lane's due entries under `filter`; + // the body behind RunNestableV8Tasks and the pumps' internal drains + void RunDueInternalWork(DrainFilter filter); void RunOneInternal(); + // one due slot across the ordered domain (entries + timer source); true + // when a slot was consumed. The body behind both RunOrderedTask (one call + // per Java token) and RunDueOrderedEntries (looped by the pumps). + bool RunOneOrderedDue(); static int EventFdCallback(int fd, int events, void* data); static int TimerFdCallback(int fd, int events, void* data); @@ -287,6 +412,15 @@ class EventLoop { int eventFd_ = -1; int timerFd_ = -1; bool stopped_ = false; + // units written to eventFd_ whose entries a direct drain already ran; + // consumed by WaitForInternalWork (or by an EventFdCallback that finds + // nothing due). Guarded by mutex_. + uint64_t leftoverUnits_ = 0; + // exceptions deferred by pump drains, raised one per token dispatch by + // ReportDeferredJavaError. Guarded by mutex_. + std::deque> deferredJavaThrows_; + // extra looper-equivalent pump work (the worker inbox); home-thread only + std::function pumpDrainHook_; // process-wide JNI cache, written once under the first bind's lock (the // main runtime binds before any worker thread exists) diff --git a/test-app/runtime/src/main/cpp/File.cpp b/test-app/runtime/src/main/cpp/File.cpp index 21365e7d5..83aa17b0a 100644 --- a/test-app/runtime/src/main/cpp/File.cpp +++ b/test-app/runtime/src/main/cpp/File.cpp @@ -6,6 +6,7 @@ */ #include "File.h" +#include "NativeScriptAssert.h" #include #include #include @@ -15,10 +16,21 @@ using namespace std; namespace tns { string File::ReadText(const string& filePath) { + bool ok; + return ReadText(filePath, ok); +} + +string File::ReadText(const string& filePath, bool& ok) { int len; bool isNew; const char* content = ReadText(filePath, len, isNew); + ok = content != nullptr; + + if (content == nullptr) { + return string(); + } + string s(content, len); if (isNew) { @@ -60,7 +72,17 @@ bool File::WriteBinary(const string& filePath, const void* data, int length) { } const char* File::ReadText(const string& filePath, int& charLength, bool& isNew) { + charLength = 0; + isNew = false; + FILE* file = fopen(filePath.c_str(), "rb"); + if (file == nullptr) { + // A path that never existed, or one deleted between a caller's stat and + // this open. Callers surface their own error; reading on regardless + // aborts the process on a null FILE*. + DEBUG_WRITE_FORCE("File::ReadText: cannot open %s", filePath.c_str()); + return nullptr; + } fseek(file, 0, SEEK_END); charLength = ftell(file); diff --git a/test-app/runtime/src/main/cpp/File.h b/test-app/runtime/src/main/cpp/File.h index 258e75988..0691a3090 100644 --- a/test-app/runtime/src/main/cpp/File.h +++ b/test-app/runtime/src/main/cpp/File.h @@ -15,6 +15,11 @@ class File { public: static const char* ReadText(const std::string& filePath, int& length, bool& isNew); static std::string ReadText(const std::string& filePath); + /* + * `ok` distinguishes a file that could not be opened from one that is + * genuinely empty — the plain overload renders both as "". + */ + static std::string ReadText(const std::string& filePath, bool& ok); static bool WriteBinary(const std::string& filePath, const void* inData, int length); static void* ReadBinary(const std::string& filePath, int& length); private: diff --git a/test-app/runtime/src/main/cpp/HMRSupport.cpp b/test-app/runtime/src/main/cpp/HMRSupport.cpp deleted file mode 100644 index 16cac04d8..000000000 --- a/test-app/runtime/src/main/cpp/HMRSupport.cpp +++ /dev/null @@ -1,353 +0,0 @@ -// HMRSupport.cpp -#include "HMRSupport.h" -#include "ArgConverter.h" -#include "JEnv.h" -#include "DevFlags.h" -#include "NativeScriptAssert.h" -#include -#include -#include -#include -#include -#include - -namespace tns { - -static inline bool StartsWith(const std::string& s, const char* prefix) { - size_t n = strlen(prefix); - return s.size() >= n && s.compare(0, n, prefix) == 0; -} - -// Per-module hot data and callbacks. Keyed by canonical module path (file path or URL). -static std::unordered_map> g_hotData; -static std::unordered_map>> g_hotAccept; -static std::unordered_map>> g_hotDispose; - -v8::Local GetOrCreateHotData(v8::Isolate* isolate, const std::string& key) { - auto it = g_hotData.find(key); - if (it != g_hotData.end() && !it->second.IsEmpty()) { - return it->second.Get(isolate); - } - v8::Local obj = v8::Object::New(isolate); - g_hotData[key].Reset(isolate, obj); - return obj; -} - -void RegisterHotAccept(v8::Isolate* isolate, const std::string& key, v8::Local cb) { - if (cb.IsEmpty()) return; - g_hotAccept[key].emplace_back(v8::Global(isolate, cb)); -} - -void RegisterHotDispose(v8::Isolate* isolate, const std::string& key, v8::Local cb) { - if (cb.IsEmpty()) return; - g_hotDispose[key].emplace_back(v8::Global(isolate, cb)); -} - -std::vector> GetHotAcceptCallbacks(v8::Isolate* isolate, const std::string& key) { - std::vector> out; - auto it = g_hotAccept.find(key); - if (it != g_hotAccept.end()) { - for (auto& gfn : it->second) { - if (!gfn.IsEmpty()) out.push_back(gfn.Get(isolate)); - } - } - return out; -} - -std::vector> GetHotDisposeCallbacks(v8::Isolate* isolate, const std::string& key) { - std::vector> out; - auto it = g_hotDispose.find(key); - if (it != g_hotDispose.end()) { - for (auto& gfn : it->second) { - if (!gfn.IsEmpty()) out.push_back(gfn.Get(isolate)); - } - } - return out; -} - -void InitializeImportMetaHot(v8::Isolate* isolate, - v8::Local context, - v8::Local importMeta, - const std::string& modulePath) { - using v8::Function; - using v8::FunctionCallbackInfo; - using v8::Local; - using v8::Object; - using v8::String; - using v8::Value; - - v8::HandleScope scope(isolate); - - auto makeKeyData = [&](const std::string& key) -> Local { - return ArgConverter::ConvertToV8String(isolate, key); - }; - - auto acceptCb = [](const FunctionCallbackInfo& info) { - v8::Isolate* iso = info.GetIsolate(); - Local data = info.Data(); - std::string key; - if (!data.IsEmpty()) { - v8::String::Utf8Value s(iso, data); - key = *s ? *s : ""; - } - v8::Local cb; - if (info.Length() >= 1 && info[0]->IsFunction()) { - cb = info[0].As(); - } else if (info.Length() >= 2 && info[1]->IsFunction()) { - cb = info[1].As(); - } - if (!cb.IsEmpty()) { - RegisterHotAccept(iso, key, cb); - } - info.GetReturnValue().Set(v8::Undefined(iso)); - }; - - auto disposeCb = [](const FunctionCallbackInfo& info) { - v8::Isolate* iso = info.GetIsolate(); - Local data = info.Data(); - std::string key; - if (!data.IsEmpty()) { v8::String::Utf8Value s(iso, data); key = *s ? *s : ""; } - if (info.Length() >= 1 && info[0]->IsFunction()) { - RegisterHotDispose(iso, key, info[0].As()); - } - info.GetReturnValue().Set(v8::Undefined(iso)); - }; - - auto declineCb = [](const FunctionCallbackInfo& info) { - info.GetReturnValue().Set(v8::Undefined(info.GetIsolate())); - }; - - auto invalidateCb = [](const FunctionCallbackInfo& info) { - info.GetReturnValue().Set(v8::Undefined(info.GetIsolate())); - }; - - Local hot = Object::New(isolate); - hot->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "data"), - GetOrCreateHotData(isolate, modulePath)).Check(); - hot->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "prune"), - v8::Boolean::New(isolate, false)).Check(); - hot->CreateDataProperty( - context, ArgConverter::ConvertToV8String(isolate, "accept"), - v8::Function::New(context, acceptCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); - hot->CreateDataProperty( - context, ArgConverter::ConvertToV8String(isolate, "dispose"), - v8::Function::New(context, disposeCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); - hot->CreateDataProperty( - context, ArgConverter::ConvertToV8String(isolate, "decline"), - v8::Function::New(context, declineCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); - hot->CreateDataProperty( - context, ArgConverter::ConvertToV8String(isolate, "invalidate"), - v8::Function::New(context, invalidateCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); - - importMeta->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "hot"), hot).Check(); -} - -// Drop fragments and normalize parameters for consistent registry keys. -std::string CanonicalizeHttpUrlKey(const std::string& url) { - if (!(StartsWith(url, "http://") || StartsWith(url, "https://"))) { - return url; - } - // Remove fragment - size_t hashPos = url.find('#'); - std::string noHash = (hashPos == std::string::npos) ? url : url.substr(0, hashPos); - - // Split into origin+path and query - size_t qPos = noHash.find('?'); - std::string originAndPath = (qPos == std::string::npos) ? noHash : noHash.substr(0, qPos); - std::string query = (qPos == std::string::npos) ? std::string() : noHash.substr(qPos + 1); - - // Normalize bridge endpoints to keep a single realm across HMR updates: - // - /ns/rt/ -> /ns/rt - // - /ns/core/ -> /ns/core - size_t schemePos = originAndPath.find("://"); - if (schemePos != std::string::npos) { - size_t pathStart = originAndPath.find('/', schemePos + 3); - if (pathStart != std::string::npos) { - std::string pathOnly = originAndPath.substr(pathStart); - auto normalizeBridge = [&](const char* needle) { - size_t nlen = strlen(needle); - if (pathOnly.size() <= nlen) return false; - if (pathOnly.compare(0, nlen, needle) != 0) return false; - if (pathOnly.size() == nlen) return true; - if (pathOnly[nlen] != '/') return false; - size_t i = nlen + 1; - size_t j = i; - while (j < pathOnly.size() && isdigit((unsigned char)pathOnly[j])) j++; - // Only normalize exact version segment: /ns/*/ (no further segments) - if (j == i) return false; - if (j != pathOnly.size()) return false; - originAndPath = originAndPath.substr(0, pathStart) + std::string(needle); - return true; - }; - if (!normalizeBridge("/ns/rt")) { - normalizeBridge("/ns/core"); - } - } - } - - if (query.empty()) return originAndPath; - - // Strip ?import markers and sort remaining query params for stability - std::vector kept; - size_t start = 0; - while (start <= query.size()) { - size_t amp = query.find('&', start); - std::string pair = (amp == std::string::npos) ? query.substr(start) : query.substr(start, amp - start); - if (!pair.empty()) { - size_t eq = pair.find('='); - std::string name = (eq == std::string::npos) ? pair : pair.substr(0, eq); - if (!(name == "import")) kept.push_back(pair); - } - if (amp == std::string::npos) break; - start = amp + 1; - } - if (kept.empty()) return originAndPath; - std::sort(kept.begin(), kept.end()); - std::string rebuilt = originAndPath + "?"; - for (size_t i = 0; i < kept.size(); i++) { - if (i > 0) rebuilt += "&"; - rebuilt += kept[i]; - } - return rebuilt; -} - -// Minimal HTTP fetch using java.net.* via JNI. Returns true on success (2xx) and non-empty body. -// Security: This is the single point of enforcement for remote module loading. -// In debug mode, all URLs are allowed. In production, checks security.allowRemoteModules -// and security.remoteModuleAllowlist from the app config. -bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status) { - out.clear(); - contentType.clear(); - status = 0; - - // Security gate: check if remote module loading is allowed before any HTTP fetch. - if (!IsRemoteUrlAllowed(url)) { - status = 403; // Forbidden - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][security][blocked] %s", url.c_str()); - } - return false; - } - - try { - JEnv env; - - // Allow network operations on the current thread (dev-only HMR path) - // Some Android environments enforce StrictMode which throws NetworkOnMainThreadException - // when performing network I/O on the main thread. Since this fetch runs on the JS/V8 thread - // during development, explicitly relax the policy here. - { - jclass clsStrict = env.FindClass("android/os/StrictMode"); - jclass clsPolicyBuilder = env.FindClass("android/os/StrictMode$ThreadPolicy$Builder"); - if (clsStrict && clsPolicyBuilder) { - jmethodID builderCtor = env.GetMethodID(clsPolicyBuilder, "", "()V"); - jobject builder = env.NewObject(clsPolicyBuilder, builderCtor); - if (builder) { - jmethodID permitAll = env.GetMethodID(clsPolicyBuilder, "permitAll", "()Landroid/os/StrictMode$ThreadPolicy$Builder;"); - jobject builder2 = permitAll ? env.CallObjectMethod(builder, permitAll) : builder; - jmethodID build = env.GetMethodID(clsPolicyBuilder, "build", "()Landroid/os/StrictMode$ThreadPolicy;"); - jobject policy = build ? env.CallObjectMethod(builder2 ? builder2 : builder, build) : nullptr; - if (policy) { - jmethodID setThreadPolicy = env.GetStaticMethodID(clsStrict, "setThreadPolicy", "(Landroid/os/StrictMode$ThreadPolicy;)V"); - if (setThreadPolicy) { - env.CallStaticVoidMethod(clsStrict, setThreadPolicy, policy); - } - } - } - } - } - - jclass clsURL = env.FindClass("java/net/URL"); - if (!clsURL) return false; - jmethodID urlCtor = env.GetMethodID(clsURL, "", "(Ljava/lang/String;)V"); - jmethodID openConnection = env.GetMethodID(clsURL, "openConnection", "()Ljava/net/URLConnection;"); - jstring jUrlStr = env.NewStringUTF(url.c_str()); - jobject urlObj = env.NewObject(clsURL, urlCtor, jUrlStr); - - jobject conn = env.CallObjectMethod(urlObj, openConnection); - if (!conn) return false; - - jclass clsConn = env.GetObjectClass(conn); - jmethodID setConnectTimeout = env.GetMethodID(clsConn, "setConnectTimeout", "(I)V"); - jmethodID setReadTimeout = env.GetMethodID(clsConn, "setReadTimeout", "(I)V"); - jmethodID setDoInput = env.GetMethodID(clsConn, "setDoInput", "(Z)V"); - jmethodID setUseCaches = env.GetMethodID(clsConn, "setUseCaches", "(Z)V"); - jmethodID setReqProp = env.GetMethodID(clsConn, "setRequestProperty", "(Ljava/lang/String;Ljava/lang/String;)V"); - env.CallVoidMethod(conn, setConnectTimeout, 15000); - env.CallVoidMethod(conn, setReadTimeout, 15000); - if (setDoInput) { env.CallVoidMethod(conn, setDoInput, JNI_TRUE); } - if (setUseCaches) { env.CallVoidMethod(conn, setUseCaches, JNI_FALSE); } - env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Accept"), env.NewStringUTF("application/javascript, text/javascript, */*;q=0.1")); - env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Accept-Encoding"), env.NewStringUTF("identity")); - env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Cache-Control"), env.NewStringUTF("no-cache")); - env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Connection"), env.NewStringUTF("close")); - env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("User-Agent"), env.NewStringUTF("NativeScript-HTTP-ESM")); - - // Try to get status via HttpURLConnection if possible - jclass clsHttp = env.FindClass("java/net/HttpURLConnection"); - bool isHttp = clsHttp && env.IsInstanceOf(conn, clsHttp); - jmethodID getResponseCode = isHttp ? env.GetMethodID(clsHttp, "getResponseCode", "()I") : nullptr; - jmethodID getErrorStream = isHttp ? env.GetMethodID(clsHttp, "getErrorStream", "()Ljava/io/InputStream;") : nullptr; - if (isHttp && getResponseCode) { - status = env.CallIntMethod(conn, getResponseCode); - } - - // Read InputStream (prefer error stream on HTTP error codes) - jmethodID getInputStream = env.GetMethodID(clsConn, "getInputStream", "()Ljava/io/InputStream;"); - jobject inStream = nullptr; - if (isHttp && status >= 400 && getErrorStream) { - inStream = env.CallObjectMethod(conn, getErrorStream); - } - if (!inStream) { - inStream = env.CallObjectMethod(conn, getInputStream); - } - if (!inStream) return false; - - jclass clsIS = env.GetObjectClass(inStream); - jmethodID readMethod = env.GetMethodID(clsIS, "read", "([B)I"); - jmethodID closeIS = env.GetMethodID(clsIS, "close", "()V"); - - jclass clsBAOS = env.FindClass("java/io/ByteArrayOutputStream"); - jmethodID baosCtor = env.GetMethodID(clsBAOS, "", "()V"); - jmethodID baosWrite = env.GetMethodID(clsBAOS, "write", "([BII)V"); - jmethodID baosToByteArray = env.GetMethodID(clsBAOS, "toByteArray", "()[B"); - jmethodID baosClose = env.GetMethodID(clsBAOS, "close", "()V"); - jobject baos = env.NewObject(clsBAOS, baosCtor); - - jbyteArray buffer = env.NewByteArray(8192); - while (true) { - jint n = env.CallIntMethod(inStream, readMethod, buffer); - if (n < 0) break; // -1 indicates EOF - if (n == 0) { - // Defensive: continue reading if zero bytes returned - continue; - } - env.CallVoidMethod(baos, baosWrite, buffer, 0, n); - } - - env.CallVoidMethod(inStream, closeIS); - jbyteArray bytes = (jbyteArray) env.CallObjectMethod(baos, baosToByteArray); - env.CallVoidMethod(baos, baosClose); - - if (!bytes) return false; - jsize len = env.GetArrayLength(bytes); - out.resize(static_cast(len)); - if (len > 0) { - env.GetByteArrayRegion(bytes, 0, len, reinterpret_cast(&out[0])); - } - - // Content-Type if available - jmethodID getContentType = env.GetMethodID(clsConn, "getContentType", "()Ljava/lang/String;"); - jstring jct = (jstring) env.CallObjectMethod(conn, getContentType); - if (jct) { - contentType = ArgConverter::jstringToString(jct); - } - - if (status == 0) status = 200; // assume OK if not HTTP - return status >= 200 && status < 300 && !out.empty(); - } catch (...) { - return false; - } -} - -} // namespace tns diff --git a/test-app/runtime/src/main/cpp/HMRSupport.h b/test-app/runtime/src/main/cpp/HMRSupport.h deleted file mode 100644 index f08e7fa09..000000000 --- a/test-app/runtime/src/main/cpp/HMRSupport.h +++ /dev/null @@ -1,25 +0,0 @@ -// HMRSupport.h -#pragma once - -#include -#include -#include - -namespace tns { - -// import.meta.hot support -v8::Local GetOrCreateHotData(v8::Isolate* isolate, const std::string& key); -void RegisterHotAccept(v8::Isolate* isolate, const std::string& key, v8::Local cb); -void RegisterHotDispose(v8::Isolate* isolate, const std::string& key, v8::Local cb); -std::vector> GetHotAcceptCallbacks(v8::Isolate* isolate, const std::string& key); -std::vector> GetHotDisposeCallbacks(v8::Isolate* isolate, const std::string& key); -void InitializeImportMetaHot(v8::Isolate* isolate, - v8::Local context, - v8::Local importMeta, - const std::string& modulePath); - -// Dev HTTP loader helpers -std::string CanonicalizeHttpUrlKey(const std::string& url); -bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status); - -} // namespace tns diff --git a/test-app/runtime/src/main/cpp/HttpLoader.cpp b/test-app/runtime/src/main/cpp/HttpLoader.cpp new file mode 100644 index 000000000..34ba45206 --- /dev/null +++ b/test-app/runtime/src/main/cpp/HttpLoader.cpp @@ -0,0 +1,1320 @@ +#include "HttpLoader.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ArgConverter.h" +#include "JEnv.h" +#include "ModuleInternal.h" +#include "ModuleInternalCallbacks.h" +#include "NativeScriptException.h" +#include "Runtime.h" +#include "TraceLog.h" +#include "robin_hood.h" +#include "v8-json.h" + +namespace tns { + +static inline bool StartsWith(const std::string& s, const char* prefix) { + size_t n = strlen(prefix); + return s.size() >= n && s.compare(0, n, prefix) == 0; +} + +static inline v8::Local ToV8String(v8::Isolate* isolate, const char* str) { + return ArgConverter::ConvertToV8String(isolate, str ? std::string(str) : std::string()); +} + +static inline v8::Local ToV8String(v8::Isolate* isolate, const std::string& str) { + return ArgConverter::ConvertToV8String(isolate, str); +} + +// ───────────────────────────────────────────────────────────── +// Remote-module security gate + +static std::once_flag s_securityConfigInitFlag; +static bool s_allowRemoteModules = false; +static std::vector s_remoteModuleAllowlist; +static bool s_isDebuggable = false; + +static bool RemoteUrlMatchesAllowlistEntry(const std::string& url, const std::string& entry) { + if (entry.empty()) return false; + if (url.size() < entry.size()) return false; + if (url.compare(0, entry.size(), entry) != 0) return false; + if (url.size() == entry.size()) return true; + if (entry.back() == '/') return true; + const char next = url[entry.size()]; + return next == '/' || next == '?' || next == '#'; +} + +static void InitializeSecurityConfig() { + std::call_once(s_securityConfigInitFlag, []() { + try { + JEnv env; + jclass runtimeClass = env.FindClass("com/tns/Runtime"); + if (runtimeClass == nullptr) { + return; + } + + jmethodID isDebuggableMid = env.GetStaticMethodID(runtimeClass, "isDebuggable", "()Z"); + if (isDebuggableMid != nullptr) { + s_isDebuggable = env.CallStaticBooleanMethod(runtimeClass, isDebuggableMid) == + JNI_TRUE; + } + + if (s_isDebuggable) { + s_allowRemoteModules = true; + return; + } + + jmethodID allowRemoteMid = + env.GetStaticMethodID(runtimeClass, "isRemoteModulesAllowed", "()Z"); + if (allowRemoteMid != nullptr) { + s_allowRemoteModules = + env.CallStaticBooleanMethod(runtimeClass, allowRemoteMid) == JNI_TRUE; + } + + jmethodID getAllowlistMid = env.GetStaticMethodID( + runtimeClass, "getRemoteModuleAllowlist", "()[Ljava/lang/String;"); + if (getAllowlistMid != nullptr) { + jobjectArray allowlistArray = static_cast( + env.CallStaticObjectMethod(runtimeClass, getAllowlistMid)); + if (allowlistArray != nullptr) { + jsize len = env.GetArrayLength(allowlistArray); + for (jsize i = 0; i < len; i++) { + jstring jstr = + static_cast(env.GetObjectArrayElement(allowlistArray, i)); + if (jstr != nullptr) { + const char* str = env.GetStringUTFChars(jstr, nullptr); + if (str != nullptr) { + s_remoteModuleAllowlist.emplace_back(str); + env.ReleaseStringUTFChars(jstr, str); + } + env.DeleteLocalRef(jstr); + } + } + env.DeleteLocalRef(allowlistArray); + } + } + } catch (...) { + // Keep defaults (remote modules disabled) + } + }); +} + +bool IsDebuggable() { + InitializeSecurityConfig(); + return s_isDebuggable; +} + +bool IsRemoteModulesAllowed() { + if (IsDebuggable()) { + return true; + } + InitializeSecurityConfig(); + return s_allowRemoteModules; +} + +bool IsRemoteUrlAllowed(const std::string& url) { + if (IsDebuggable()) { + return true; + } + + InitializeSecurityConfig(); + if (!s_allowRemoteModules) { + return false; + } + + if (s_remoteModuleAllowlist.empty()) { + return true; + } + + for (const std::string& entry : s_remoteModuleAllowlist) { + if (RemoteUrlMatchesAllowlistEntry(url, entry)) { + return true; + } + } + + return false; +} + +// ───────────────────────────────────────────────────────────── +// Canonical module keys + +std::string NormalizeHttpModuleUrl(const std::string& path) { + if (path.empty()) { + return path; + } + + std::string normalized = path; + if (StartsWith(normalized, "file://http://") || StartsWith(normalized, "file://https://")) { + normalized = normalized.substr(strlen("file://")); + } + + // A path normalizer that collapses `//` into `/` (Java's, or a URL that + // travelled through one) leaves the scheme separator one slash short. + if (normalized.rfind("http:/", 0) == 0 && normalized.rfind("http://", 0) != 0) { + normalized.insert(5, "/"); + } else if (normalized.rfind("https:/", 0) == 0 && normalized.rfind("https://", 0) != 0) { + normalized.insert(6, "/"); + } + + return normalized; +} + +std::string CanonicalizeHttpUrlKey(const std::string& url) { + std::string normalizedUrl = url; + if (StartsWith(normalizedUrl, "file://http://") || StartsWith(normalizedUrl, "file://https://")) { + normalizedUrl = normalizedUrl.substr(strlen("file://")); + } + if (!(StartsWith(normalizedUrl, "http://") || StartsWith(normalizedUrl, "https://"))) { + return normalizedUrl; + } + size_t hashPos = normalizedUrl.find('#'); + std::string noHash = + (hashPos == std::string::npos) ? normalizedUrl : normalizedUrl.substr(0, hashPos); + + size_t schemePos = noHash.find("://"); + if (schemePos == std::string::npos) { + size_t q = noHash.find('?'); + return (q == std::string::npos) ? noHash : noHash.substr(0, q); + } + size_t pathStart = noHash.find('/', schemePos + 3); + if (pathStart == std::string::npos) { + return noHash; + } + size_t qPos = noHash.find('?', pathStart); + std::string originAndPath = (qPos == std::string::npos) ? noHash : noHash.substr(0, qPos); + std::string query = (qPos == std::string::npos) ? std::string() : noHash.substr(qPos + 1); + + // This key is the module registry/cache key. For general-purpose HTTP + // module loading the query can be part of a module's identity (auth, + // content versioning, routing), so query normalization applies only to the + // endpoints the client names, through the vocabulary it supplies. + // + // `preserveQueryFor` is checked BEFORE the dev-endpoint prefix test, so it + // covers endpoints nested under a dev prefix: for some endpoints the query + // IS the identity, and stripping it would collapse every refetch onto the + // boot-time key. + // + // Until a client supplies that vocabulary, canonicalization is purely + // mechanical: the fragment is gone and the query stays. Which params are + // cache-busters and which paths are dev endpoints is knowledge only the + // client has; guessing would silently collapse two distinct modules onto + // one registry key. + const CanonicalizationConfig* canon = CanonicalizationConfigForCurrentIsolate(); + if (canon == nullptr) { + return noHash; + } + { + std::string pathOnly = originAndPath.substr(pathStart); + for (const auto& p : canon->preserveQueryPrefixes) { + if (!p.empty() && pathOnly.find(p) != std::string::npos) { + return noHash; + } + } + bool isDevEndpoint = false; + for (const auto& p : canon->devPathPrefixes) { + if (!p.empty() && StartsWith(pathOnly, p.c_str())) { + isDevEndpoint = true; + break; + } + } + if (!isDevEndpoint) { + return noHash; + } + } + + if (query.empty()) return originAndPath; + + std::vector kept; + size_t start = 0; + while (start <= query.size()) { + size_t amp = query.find('&', start); + std::string pair = + (amp == std::string::npos) ? query.substr(start) : query.substr(start, amp - start); + if (!pair.empty()) { + size_t eq = pair.find('='); + std::string name = (eq == std::string::npos) ? pair : pair.substr(0, eq); + const bool drop = std::find(canon->stripParams.begin(), canon->stripParams.end(), + name) != canon->stripParams.end(); + if (!drop) kept.push_back(pair); + } + if (amp == std::string::npos) break; + start = amp + 1; + } + if (kept.empty()) return originAndPath; + std::sort(kept.begin(), kept.end()); + std::string rebuilt = originAndPath + "?"; + for (size_t i = 0; i < kept.size(); i++) { + if (i > 0) rebuilt += "&"; + rebuilt += kept[i]; + } + return rebuilt; +} + +// ───────────────────────────────────────────────────────────── +// Eviction-driven fetch cache-bust +// +// Process-global because it belongs to the transport, not to any isolate: the +// HTTP cache layers it defeats are shared by the whole process. The set is +// keyed by canonical keys the callers compute on their own isolate's thread +// and pass in by value, so nothing here canonicalizes. + +static std::mutex g_bustNextFetchMutex; +static robin_hood::unordered_set g_bustNextFetchKeys; + +void MarkKeysForCacheBust(const std::vector& canonicalKeys) { + if (canonicalKeys.empty()) return; + std::lock_guard lock(g_bustNextFetchMutex); + for (const auto& key : canonicalKeys) { + if (key.empty()) continue; + if (!(StartsWith(key, "http://") || StartsWith(key, "https://"))) continue; + g_bustNextFetchKeys.insert(key); + } +} + +static bool IsUrlMarkedForCacheBust(const std::string& canonicalKey) { + std::lock_guard lock(g_bustNextFetchMutex); + if (g_bustNextFetchKeys.empty()) return false; + return g_bustNextFetchKeys.find(canonicalKey) != g_bustNextFetchKeys.end(); +} + +static void ClearCacheBustForUrl(const std::string& canonicalKey) { + std::lock_guard lock(g_bustNextFetchMutex); + if (g_bustNextFetchKeys.empty()) return; + g_bustNextFetchKeys.erase(canonicalKey); +} + +static void ClearAllCacheBustMarks() { + std::lock_guard lock(g_bustNextFetchMutex); + g_bustNextFetchKeys.clear(); +} + +// ───────────────────────────────────────────────────────────── +// JNI fetch diagnostics + request builder + +// Describes and clears a pending Java exception. The introspection calls go +// through the raw JNIEnv: JEnv's wrappers turn a pending Java exception into a +// thrown NativeScriptException, which here would replace the exception being +// described with the failure to describe it. +static bool DrainPendingJniException(JEnv& env, std::string& outClassName, std::string& outMessage) { + outClassName.clear(); + outMessage.clear(); + JNIEnv* raw = env; + jthrowable th = raw->ExceptionOccurred(); + if (!th) return false; + raw->ExceptionClear(); + + jclass clsThrowable = env.GetObjectClass(th); + if (clsThrowable) { + jclass clsClass = env.FindClass("java/lang/Class"); + if (clsClass) { + jmethodID getName = env.GetMethodID(clsClass, "getName", "()Ljava/lang/String;"); + if (getName) { + jstring jName = static_cast(raw->CallObjectMethod(clsThrowable, getName)); + raw->ExceptionClear(); + if (jName) { + outClassName = ArgConverter::jstringToString(jName); + } + } + } + jmethodID toString = env.GetMethodID(clsThrowable, "toString", "()Ljava/lang/String;"); + if (toString) { + jstring jMsg = static_cast(raw->CallObjectMethod(th, toString)); + raw->ExceptionClear(); + if (jMsg) { + outMessage = ArgConverter::jstringToString(jMsg); + } + } + } + raw->ExceptionClear(); + return true; +} + +static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& canonicalKey, + std::string& out, std::string& contentType, int& status, + bool& bustApplied); + +static std::string ApplyCacheBustNonce(const std::string& url, const std::string& canonicalKey, + bool* outBustRequested) { + std::string fetchUrl = url; + const bool bustRequested = IsUrlMarkedForCacheBust(canonicalKey); + if (outBustRequested) *outBustRequested = bustRequested; + if (bustRequested) { + static std::atomic s_fetchSeq{0}; + const uint64_t seq = s_fetchSeq.fetch_add(1, std::memory_order_relaxed); + const uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count()); + fetchUrl += (url.find('?') == std::string::npos) ? '?' : '&'; + fetchUrl += "__ns_dev_nonce="; + fetchUrl += std::to_string(nowMs); + fetchUrl += "-"; + fetchUrl += std::to_string(seq); + } + return fetchUrl; +} + +// A blocking network call on the JS thread is exactly what StrictMode is meant +// to flag, so the fetch relaxes the thread policy — but the policy belongs to +// the app, not to this request: whatever runs next on this thread must get its +// own policy back, including when the fetch leaves through an exception. +// Restoring uses the raw JNIEnv (JEnv's wrappers throw, and a destructor may +// run mid-unwind). +struct StrictModeScope { + JNIEnv* jni = nullptr; + jclass clsStrict = nullptr; + jmethodID setThreadPolicy = nullptr; + jobject savedPolicy = nullptr; + + explicit StrictModeScope(JEnv& env) { + // Every bail must clear: FindClass/GetMethodID return null WITH an + // exception pending, and leaving one armed makes the caller's next + // JNI call illegal. + JNIEnv* raw = env; + clsStrict = env.FindClass("android/os/StrictMode"); + jclass clsPolicyBuilder = env.FindClass("android/os/StrictMode$ThreadPolicy$Builder"); + if (!clsStrict || !clsPolicyBuilder) { + raw->ExceptionClear(); + return; + } + jmethodID getThreadPolicy = env.GetStaticMethodID( + clsStrict, "getThreadPolicy", "()Landroid/os/StrictMode$ThreadPolicy;"); + jmethodID setter = env.GetStaticMethodID( + clsStrict, "setThreadPolicy", "(Landroid/os/StrictMode$ThreadPolicy;)V"); + if (!getThreadPolicy || !setter) { + raw->ExceptionClear(); + return; + } + // No captured policy means no way back, so leave the thread alone + // rather than relaxing it permanently. + jobject captured = env.CallStaticObjectMethod(clsStrict, getThreadPolicy); + if (!captured) { + raw->ExceptionClear(); + return; + } + + jmethodID builderCtor = env.GetMethodID(clsPolicyBuilder, "", "()V"); + jobject builder = builderCtor ? env.NewObject(clsPolicyBuilder, builderCtor) : nullptr; + if (!builder) { + raw->ExceptionClear(); + return; + } + jmethodID permitAll = env.GetMethodID(clsPolicyBuilder, "permitAll", + "()Landroid/os/StrictMode$ThreadPolicy$Builder;"); + jobject builder2 = permitAll ? env.CallObjectMethod(builder, permitAll) : builder; + jmethodID build = env.GetMethodID(clsPolicyBuilder, "build", + "()Landroid/os/StrictMode$ThreadPolicy;"); + jobject policy = build ? env.CallObjectMethod(builder2 ? builder2 : builder, build) + : nullptr; + if (!policy) { + raw->ExceptionClear(); + return; + } + env.CallStaticVoidMethod(clsStrict, setter, policy); + // Armed only once the permissive policy is actually in force. The + // saved policy is a global ref so the restore does not depend on any + // JNI local frame the caller pushed around this scope. + setThreadPolicy = setter; + savedPolicy = env.NewGlobalRef(captured); + jni = env; + } + + StrictModeScope(const StrictModeScope&) = delete; + StrictModeScope& operator=(const StrictModeScope&) = delete; + + ~StrictModeScope() { + if (jni == nullptr) return; + jni->CallStaticVoidMethod(clsStrict, setThreadPolicy, savedPolicy); + jni->DeleteGlobalRef(savedPolicy); + jni->ExceptionClear(); + } +}; + +// ── The module response policy ─────────────────────────────── +// +// Module scripts are strict about MIME: the HTML spec's "fetch a single module +// script" fails the fetch outright for anything that is not a JavaScript or +// JSON MIME type, where a classic script would sniff and run it anyway. That +// strictness is the whole point — an SPA dev server answering an unknown path +// with `200 text/html` should say so, not hand HTML to the parser and produce +// `Unexpected token '<'` from somewhere deep in the graph. +// +// Both transports classify here, so the synchronous fallback and the async +// walk cannot disagree about what a response means. + +// "text/javascript; charset=utf-8" → "text/javascript": parameters stripped, +// trimmed, lowercased. +static std::string MimeEssence(const std::string& contentType) { + size_t semi = contentType.find(';'); + std::string essence = + semi == std::string::npos ? contentType : contentType.substr(0, semi); + size_t begin = essence.find_first_not_of(" \t"); + if (begin == std::string::npos) { + return ""; + } + size_t end = essence.find_last_not_of(" \t"); + essence = essence.substr(begin, end - begin + 1); + for (char& c : essence) { + c = (char)tolower((unsigned char)c); + } + return essence; +} + +// The HTML spec's JavaScript MIME type essence list, verbatim. +static bool IsJavaScriptMimeEssence(const std::string& essence) { + static const char* const kJavaScriptEssences[] = {"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"}; + for (const char* candidate : kJavaScriptEssences) { + if (essence == candidate) { + return true; + } + } + return false; +} + +// A JSON MIME type is application/json, text/json, or any `+json` subtype. +static bool IsJsonMimeEssence(const std::string& essence) { + if (essence == "application/json" || essence == "text/json") { + return true; + } + const std::string suffix = "+json"; + return essence.size() > suffix.size() && + essence.compare(essence.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +// `transportOk` means a response arrived at all; everything else about it — +// status, MIME, emptiness — is policy decided here. `body` is moved into the +// result on success. +static void ClassifyModuleResponse(const std::string& url, bool transportOk, int status, + const std::string& contentType, std::string& body, + ModuleFetchResult& result) { + result.status = status; + result.contentType = contentType; + + if (!transportOk) { + result.failureReason = "HTTP import failed: " + url + " (network error)"; + return; + } + if (status == 204 || status == 205) { + // "No content" carries no module, which the web treats as a network + // error for a module script rather than as an empty module. + result.failureReason = + "HTTP import failed: " + url + " (status=" + std::to_string(status) + + ", no content)"; + return; + } + if (status < 200 || status >= 300) { + result.failureReason = + "HTTP import failed: " + url + " (status=" + std::to_string(status) + ")"; + return; + } + + const std::string essence = MimeEssence(contentType); + if (essence.empty()) { + result.failureReason = + "Expected a JavaScript module but '" + url + "' responded with no MIME type"; + return; + } + + if (IsJsonMimeEssence(essence)) { + if (body.empty()) { + result.failureReason = + "Expected a JSON module but '" + url + "' responded with an empty body"; + return; + } + result.kind = ModuleResponseKind::kJson; + } else if (IsJavaScriptMimeEssence(essence)) { + result.kind = ModuleResponseKind::kJavaScript; + // An empty 2xx JavaScript body is a valid module: type-only TypeScript + // modules transform to zero runtime code and dev servers serve them as + // empty 200s. Failing here would kill the whole graph with a misleading + // "status=200". + if (body.empty()) { + body = "export {};\n"; + TNS_DEBUG(Esm, "[http-loader] empty 2xx body for %s — serving canonical empty module", + url.c_str()); + } + } else { + result.failureReason = "Expected a JavaScript module but '" + url + + "' responded with MIME type '" + essence + "'"; + return; + } + + result.ok = true; + result.body = std::move(body); +} + +bool HttpFetchModule(const std::string& url, ModuleFetchResult& result) { + result = ModuleFetchResult(); + + // Security gate: the single point of enforcement for all HTTP module + // loading, checked before any network turn. + if (!IsRemoteUrlAllowed(url)) { + result.status = 403; + result.failureReason = + "HTTP import blocked: remote module loading is not allowed for " + url; + TNS_DEBUG(Esm, "[http-esm][security][blocked] %s", url.c_str()); + return false; + } + + const bool urlLogEnabled = LogCategoryEnabled(LogCategory::Fetch); + const auto netStart = urlLogEnabled ? std::chrono::steady_clock::now() + : std::chrono::steady_clock::time_point{}; + + // Canonicalize here, on the caller's isolate thread: the vocabulary the + // key depends on belongs to that isolate, and the transport below must + // never reach for it. + const std::string canonicalKey = CanonicalizeHttpUrlKey(url); + + std::string body; + std::string contentType; + int status = 0; + bool bustApplied = false; + bool transportOk = + PerformHttpFetchOnceSync(url, canonicalKey, body, contentType, status, bustApplied); + if (!transportOk) { + // One retry, and only for a transport error: an HTTP status is an + // answer, not a failure to communicate, so asking again would just + // repeat it. + TNS_DEBUG(Esm, "[http-loader] retrying %s after initial fetch error", url.c_str()); + usleep(120 * 1000); + transportOk = + PerformHttpFetchOnceSync(url, canonicalKey, body, contentType, status, bustApplied); + } + + ClassifyModuleResponse(url, transportOk, status, contentType, body, result); + + // A cache-bust mark is only satisfied by a response the loader can actually + // use: a 404, or a 200 that classified as something other than a module, + // leaves it armed for the next attempt. + if (result.ok && bustApplied) { + ClearCacheBustForUrl(canonicalKey); + } + + if (!result.ok) { + TNS_DEBUG(Esm, "[http-loader][fetch-sync][reject] %s", result.failureReason.c_str()); + return false; + } + + TNS_DEBUG(Esm, "[http-loader] fetched status=%d content-type=%s bytes=%llu", result.status, + result.contentType.empty() ? "" : result.contentType.c_str(), + (unsigned long long)result.body.size()); + if (urlLogEnabled) { + const auto netMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - netStart) + .count(); + TNS_DEBUG(Fetch, "[http-loader][fetch][network] %s bytes=%lu ms=%lld", url.c_str(), + (unsigned long)result.body.size(), (long long)netMs); + } + + return true; +} + +// Runs on whichever thread drives the fetch — the JS thread for the sync path, +// a detached background thread for the async one. `canonicalKey` is computed +// by the caller on its isolate's thread; nothing here may canonicalize. +// +// The network calls below go through the raw JNIEnv rather than JEnv's +// wrappers: a wrapper converts a pending Java exception into a thrown +// NativeScriptException, which would unwind past the per-stage handling that +// tells a status-bearing answer (an empty 404) apart from a transport failure, +// and past the InputStream close. The wrappers stay on the setup calls, whose +// failures have no per-stage verdict and are caught below. +static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& canonicalKey, + std::string& out, std::string& contentType, int& status, + bool& bustApplied) { + out.clear(); + contentType.clear(); + status = 0; + TNS_DEBUG(Esm, "[http-esm][fetch][enter] url=%s", url.c_str()); + + const std::string fetchUrl = ApplyCacheBustNonce(url, canonicalKey, &bustApplied); + + auto recordStageFailure = [&url](const char* stage, const std::string& excClass, + const std::string& excMsg) { + TNS_DEBUG(Esm, "[http-esm][fetch][exception] stage=%s url=%s class=%s msg=%s", stage, + url.c_str(), excClass.c_str(), excMsg.c_str()); + }; + + JEnv env; + JNIEnv* raw = env; + + // Request setup alone burns a couple of dozen local refs (a jstring per + // header, one per drained exception), and the sync path runs inside a + // caller's frame — V8's resolve walk — that must not be left holding + // them. Declared OUTSIDE the try: a caught NativeScriptException may hold + // the Java throwable as a local ref in this frame, which the handlers + // below read — the pop must come after them, not during the unwind. + const bool framePushed = raw->PushLocalFrame(64) == JNI_OK; + if (!framePushed) { + raw->ExceptionClear(); + } + struct LocalFrame { + JNIEnv* jni; + bool pushed; + ~LocalFrame() { + if (pushed) jni->PopLocalFrame(nullptr); + } + } localFrame{raw, framePushed}; + + try { + StrictModeScope strictMode(env); + + jclass clsURL = env.FindClass("java/net/URL"); + if (!clsURL) return false; + jmethodID urlCtor = env.GetMethodID(clsURL, "", "(Ljava/lang/String;)V"); + jmethodID openConnection = + env.GetMethodID(clsURL, "openConnection", "()Ljava/net/URLConnection;"); + jstring jUrlStr = env.NewStringUTF(fetchUrl.c_str()); + jobject urlObj = raw->NewObject(clsURL, urlCtor, jUrlStr); + + { + std::string excClass, excMsg; + if (DrainPendingJniException(env, excClass, excMsg)) { + recordStageFailure("url-ctor", excClass, excMsg); + return false; + } + } + + jobject conn = raw->CallObjectMethod(urlObj, openConnection); + { + std::string excClass, excMsg; + if (DrainPendingJniException(env, excClass, excMsg)) { + recordStageFailure("open-connection", excClass, excMsg); + return false; + } + } + if (!conn) return false; + + jclass clsConn = env.GetObjectClass(conn); + jmethodID setConnectTimeout = env.GetMethodID(clsConn, "setConnectTimeout", "(I)V"); + jmethodID setReadTimeout = env.GetMethodID(clsConn, "setReadTimeout", "(I)V"); + jmethodID setDoInput = env.GetMethodID(clsConn, "setDoInput", "(Z)V"); + jmethodID setUseCaches = env.GetMethodID(clsConn, "setUseCaches", "(Z)V"); + jmethodID setReqProp = + env.GetMethodID(clsConn, "setRequestProperty", "(Ljava/lang/String;Ljava/lang/String;)V"); + env.CallVoidMethod(conn, setConnectTimeout, 15000); + env.CallVoidMethod(conn, setReadTimeout, 15000); + if (setDoInput) { + env.CallVoidMethod(conn, setDoInput, JNI_TRUE); + } + if (setUseCaches) { + env.CallVoidMethod(conn, setUseCaches, JNI_FALSE); + } + env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Accept"), + env.NewStringUTF("application/javascript, text/javascript, */*;q=0.1")); + env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Accept-Encoding"), + env.NewStringUTF("identity")); + env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Cache-Control"), + env.NewStringUTF("no-cache, no-store, max-age=0")); + env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Pragma"), + env.NewStringUTF("no-cache")); + env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Connection"), + env.NewStringUTF("close")); + env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("User-Agent"), + env.NewStringUTF("NativeScript-HTTP-ESM")); + + jclass clsHttp = env.FindClass("java/net/HttpURLConnection"); + bool isHttp = clsHttp && env.IsInstanceOf(conn, clsHttp); + jmethodID getResponseCode = + isHttp ? env.GetMethodID(clsHttp, "getResponseCode", "()I") : nullptr; + jmethodID getErrorStream = + isHttp ? env.GetMethodID(clsHttp, "getErrorStream", "()Ljava/io/InputStream;") + : nullptr; + // Once a status line has been read the server has answered, and every + // body-side failure below stops being a transport error: an empty 404 + // is an answer, and reporting it as "network error" would both hide + // the status and earn a pointless retry. + bool haveStatus = false; + if (isHttp && getResponseCode) { + status = raw->CallIntMethod(conn, getResponseCode); + std::string excClass, excMsg; + if (DrainPendingJniException(env, excClass, excMsg)) { + // The return value of a JNI call that threw is undefined. + status = 0; + recordStageFailure("get-response-code", excClass, excMsg); + return false; + } + haveStatus = status > 0; + } + + jmethodID getInputStream = + env.GetMethodID(clsConn, "getInputStream", "()Ljava/io/InputStream;"); + jobject inStream = nullptr; + if (isHttp && status >= 400 && getErrorStream) { + inStream = raw->CallObjectMethod(conn, getErrorStream); + raw->ExceptionClear(); + } + if (!inStream) { + // On an error status with no error body, getInputStream throws + // FileNotFoundException rather than returning null. + inStream = raw->CallObjectMethod(conn, getInputStream); + std::string excClass, excMsg; + if (DrainPendingJniException(env, excClass, excMsg)) { + if (!haveStatus) { + recordStageFailure("get-input-stream", excClass, excMsg); + return false; + } + inStream = nullptr; + } + } + if (!inStream && !haveStatus) return false; + + bool readFailed = false; + if (inStream) { + jclass clsIS = env.GetObjectClass(inStream); + jmethodID readMethod = env.GetMethodID(clsIS, "read", "([B)I"); + jmethodID closeIS = env.GetMethodID(clsIS, "close", "()V"); + + // The stream holds a socket fd, so nothing between here and the + // end of this scope — a failed read, or a Java exception escaping + // one of the checked wrappers — may leave it open. + struct StreamCloser { + JNIEnv* jni; + jobject stream; + jmethodID closeMethod; + ~StreamCloser() { + if (closeMethod == nullptr) return; + jni->CallVoidMethod(stream, closeMethod); + jni->ExceptionClear(); + } + } streamCloser{raw, inStream, closeIS}; + + jclass clsBAOS = env.FindClass("java/io/ByteArrayOutputStream"); + jmethodID baosCtor = env.GetMethodID(clsBAOS, "", "()V"); + jmethodID baosWrite = env.GetMethodID(clsBAOS, "write", "([BII)V"); + jmethodID baosToByteArray = env.GetMethodID(clsBAOS, "toByteArray", "()[B"); + jmethodID baosClose = env.GetMethodID(clsBAOS, "close", "()V"); + jobject baos = env.NewObject(clsBAOS, baosCtor); + + jbyteArray buffer = env.NewByteArray(8192); + std::string excClass, excMsg; + while (true) { + jint n = raw->CallIntMethod(inStream, readMethod, buffer); + if (DrainPendingJniException(env, excClass, excMsg)) { + recordStageFailure("read-body", excClass, excMsg); + readFailed = true; + break; + } + if (n < 0) break; + if (n == 0) continue; + raw->CallVoidMethod(baos, baosWrite, buffer, 0, n); + if (DrainPendingJniException(env, excClass, excMsg)) { + recordStageFailure("read-body", excClass, excMsg); + readFailed = true; + break; + } + } + + if (!readFailed) { + jbyteArray bytes = + static_cast(env.CallObjectMethod(baos, baosToByteArray)); + env.CallVoidMethod(baos, baosClose); + if (bytes) { + jsize len = env.GetArrayLength(bytes); + out.resize(static_cast(len)); + if (len > 0) { + env.GetByteArrayRegion(bytes, 0, len, reinterpret_cast(&out[0])); + } + } else { + readFailed = true; + } + } + } + // A truncated read only matters when the body is what the caller + // needs: on a non-2xx the status alone decides the outcome, so keep + // the answer rather than turning it into a retryable network error. + if (readFailed && (!haveStatus || (status >= 200 && status < 300))) { + return false; + } + + jmethodID getContentType = + env.GetMethodID(clsConn, "getContentType", "()Ljava/lang/String;"); + jstring jct = static_cast(env.CallObjectMethod(conn, getContentType)); + if (jct) { + contentType = ArgConverter::jstringToString(jct); + } + + if (status == 0) status = 200; + // Pure transport: true means a response arrived. Whether that response + // is a usable module — status, MIME, emptiness — is + // ClassifyModuleResponse's call, so both fetch paths answer it the + // same way. + return true; + } catch (NativeScriptException& nse) { + std::string what = nse.what() ? nse.what() : ""; + if (what.empty()) { + what = nse.GetErrorMessage(); + } + TNS_DEBUG(Esm, "[http-esm][fetch][exception] stage=native-script-exception url=%s msg=%s", + url.c_str(), what.c_str()); + return false; + } catch (std::exception& ex) { + std::string what = ex.what() ? ex.what() : ""; + TNS_DEBUG(Esm, "[http-esm][fetch][exception] stage=std-exception url=%s msg=%s", + url.c_str(), what.c_str()); + return false; + } catch (...) { + TNS_DEBUG(Esm, "[http-esm][fetch][exception] stage=unknown-cpp-exception url=%s", + url.c_str()); + return false; + } +} + +void FetchModuleBodyAsync(const std::string& url, + std::function completion) { + // Security gate: single point of enforcement, same as HttpFetchModule. + if (!IsRemoteUrlAllowed(url)) { + TNS_DEBUG(Esm, "[http-esm][security][blocked] %s", url.c_str()); + ModuleFetchResult blocked; + blocked.status = 403; + blocked.failureReason = + "HTTP import blocked: remote module loading is not allowed for " + url; + completion(std::move(blocked)); + return; + } + + // Canonicalize before the hop: the vocabulary belongs to the calling + // isolate, and the fetch thread below has no isolate to read it from. + const std::string canonicalKey = CanonicalizeHttpUrlKey(url); + + std::thread([url, canonicalKey, completion = std::move(completion)]() mutable { + JavaVM* jvm = Runtime::GetJVM(); + bool attachedHere = false; + if (jvm != nullptr) { + JNIEnv* raw = nullptr; + if (jvm->GetEnv(reinterpret_cast(&raw), JNI_VERSION_1_6) != JNI_OK) { + if (jvm->AttachCurrentThread(&raw, nullptr) == JNI_OK) { + attachedHere = true; + } + } + } + struct DetachIfAttached { + JavaVM* jvm; + bool attached; + ~DetachIfAttached() { + if (attached && jvm != nullptr) { + jvm->DetachCurrentThread(); + } + } + } detachGuard{jvm, attachedHere}; + + std::string body; + std::string contentType; + int status = 0; + const auto start = std::chrono::steady_clock::now(); + bool bustApplied = false; + bool transportOk = + PerformHttpFetchOnceSync(url, canonicalKey, body, contentType, status, bustApplied); + if (!transportOk) { + // Transport error → one retry, the same single-retry policy the + // sync path applies. + TNS_DEBUG(Esm, "[http-loader][fetch-async] retrying %s after transport error", + url.c_str()); + usleep(120 * 1000); + transportOk = PerformHttpFetchOnceSync(url, canonicalKey, body, contentType, status, + bustApplied); + } + + ModuleFetchResult result; + ClassifyModuleResponse(url, transportOk, status, contentType, body, result); + + if (result.ok && bustApplied) { + ClearCacheBustForUrl(canonicalKey); + } + + if (!result.ok) { + TNS_DEBUG(Esm, "[http-loader][fetch-async][reject] %s", result.failureReason.c_str()); + } else if (LogCategoryEnabled(LogCategory::Fetch)) { + const auto ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + TNS_DEBUG(Fetch, "[http-loader][fetch][async] %s bytes=%lu ms=%lld", url.c_str(), + (unsigned long)result.body.size(), (long long)ms); + } + completion(std::move(result)); + }).detach(); +} + +void CleanupHttpLoaderGlobals() { + ClearAllCacheBustMarks(); +} + +// ───────────────────────────────────────────────────────────── +// ns:module binding + +namespace { + +void InstallDevFunction(v8::Isolate* isolate, v8::Local context, + v8::Local target, const char* name, + v8::FunctionCallback callback) { + v8::Local fnTpl = v8::FunctionTemplate::New(isolate, callback); + v8::Local fn = fnTpl->GetFunction(context).ToLocalChecked(); + fn->SetName(ToV8String(isolate, name)); + target->CreateDataProperty(context, ToV8String(isolate, name), fn).Check(); +} + +// The only sections configureLoader understands. An unlisted key is a typo the +// caller hears about rather than a setting that silently does nothing. +constexpr const char* kLoaderConfigKeys[] = {"importMap", "volatilePatterns", "canonicalization"}; + +void ConfigureLoaderCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + + try { + auto throwTypeError = [&](const std::string& message) { + isolate->ThrowException(v8::Exception::TypeError(ToV8String(isolate, message))); + }; + + if (info.Length() < 1 || !info[0]->IsObject()) { + throwTypeError("configureLoader expects a config object"); + return; + } + + v8::Local config = info[0].As(); + + // ── Validation phase ───────────────────────────────────────────── + // Nothing below mutates the vocabulary. The whole config is checked first + // so a rejected call leaves every section exactly as it was — the + // atomicity the import map alone used to have, now covering the entire + // call. + + // Unknown top-level keys. + v8::Local configKeys; + if (!config->GetOwnPropertyNames(ctx, v8::PropertyFilter::ONLY_ENUMERABLE, + v8::KeyConversionMode::kConvertToString) + .ToLocal(&configKeys)) { + return; // pending exception + } + for (uint32_t i = 0; i < configKeys->Length(); i++) { + v8::Local keyVal; + if (!configKeys->Get(ctx, i).ToLocal(&keyVal)) { + return; + } + std::string key = ArgConverter::ToString(isolate, keyVal); + bool known = false; + for (const char* candidate : kLoaderConfigKeys) { + if (key == candidate) { + known = true; + break; + } + } + if (!known) { + throwTypeError("configureLoader: unknown option '" + key + "'"); + return; + } + } + + // Reads `obj[key]` as an array of strings into `out`. `label` names the + // section in any error. Returns false with an exception pending on a type + // failure; `present` distinguishes "absent" from "present and valid". + auto readStringArray = [&](v8::Local obj, const char* key, + const std::string& label, std::vector& out, + bool* present) -> bool { + *present = false; + v8::Local val; + if (!obj->Get(ctx, ToV8String(isolate, key)).ToLocal(&val)) { + return false; + } + if (val->IsUndefined()) { + return true; + } + if (!val->IsArray()) { + throwTypeError("configureLoader: " + label + " must be an array of strings"); + return false; + } + v8::Local arr = val.As(); + for (uint32_t i = 0; i < arr->Length(); i++) { + v8::Local elem; + if (!arr->Get(ctx, i).ToLocal(&elem)) { + return false; + } + if (!elem->IsString()) { + throwTypeError("configureLoader: " + label + "[" + std::to_string(i) + + "] must be a string"); + return false; + } + out.push_back(ArgConverter::ToString(isolate, elem)); + } + *present = true; + return true; + }; + + // importMap: an object or a JSON string. Validated here, installed below. + std::string importMapJson; + bool haveImportMap = false; + v8::Local importMapVal; + if (!config->Get(ctx, ToV8String(isolate, "importMap")).ToLocal(&importMapVal)) { + return; + } + if (!importMapVal->IsUndefined()) { + std::string jsonStr; + if (importMapVal->IsString()) { + jsonStr = ArgConverter::ToString(isolate, importMapVal); + } else if (importMapVal->IsObject() && !importMapVal->IsFunction()) { + // A function is an object to V8, and JSON::Stringify hands one back + // as the literal text "undefined" rather than failing — so it is + // excluded here and falls through to the TypeError below. + v8::Local stringified; + if (!v8::JSON::Stringify(ctx, importMapVal).ToLocal(&stringified)) { + return; // a throwing toJSON / getter propagates unchanged + } + std::string text = ArgConverter::ToString(isolate, stringified); + // The same "undefined" answer reaches a plain object whose toJSON + // returns undefined; leaving jsonStr empty routes it to the same + // TypeError. + if (text != "undefined") { + jsonStr = std::move(text); + } + } + if (jsonStr.empty()) { + throwTypeError("configureLoader: importMap must be an object or a JSON string"); + return; + } + std::string importMapError; + if (!ValidateImportMapJson(jsonStr, &importMapError)) { + // The previous map is still installed: a rejected update changes + // nothing, so a typo cannot empty a live session's vocabulary. + throwTypeError("configureLoader: " + importMapError); + return; + } + importMapJson = std::move(jsonStr); + haveImportMap = true; + } + + // volatilePatterns: array of strings. Presence of the array decides, not + // its contents — an empty one is explicit policy meaning "nothing is + // volatile any more", the same rule canonicalization follows, and the only + // reading under which a present section replaces its state wholesale. + std::vector patterns; + bool havePatterns = false; + if (!readStringArray(config, "volatilePatterns", "volatilePatterns", patterns, + &havePatterns)) { + return; + } + + // canonicalization: { stripParams, forPathPrefixes, preserveQueryFor } — + // the URL vocabulary CanonicalizeHttpUrlKey applies (see its doc block). + // Presence of the object marks the vocabulary as configured, replacing the + // built-in fallback entirely (empty arrays are honored as explicit policy). + CanonicalizationConfig canon; + bool haveCanon = false; + v8::Local canonVal; + if (!config->Get(ctx, ToV8String(isolate, "canonicalization")).ToLocal(&canonVal)) { + return; + } + if (!canonVal->IsUndefined()) { + if (!canonVal->IsObject()) { + throwTypeError("configureLoader: canonicalization must be an object"); + return; + } + v8::Local canonObj = canonVal.As(); + bool ignored = false; + if (!readStringArray(canonObj, "stripParams", "canonicalization.stripParams", + canon.stripParams, &ignored) || + !readStringArray(canonObj, "forPathPrefixes", "canonicalization.forPathPrefixes", + canon.devPathPrefixes, &ignored) || + !readStringArray(canonObj, "preserveQueryFor", "canonicalization.preserveQueryFor", + canon.preserveQueryPrefixes, &ignored)) { + return; + } + haveCanon = true; + } + + // ── Apply phase ────────────────────────────────────────────────── + // Everything validated; from here nothing can fail on the caller's input. + + if (haveImportMap) { + // The re-parse inside SetImportMap is deterministic and already + // succeeded above, so the only failure left is the isolate shutting + // down. + std::string installError; + if (!SetImportMap(importMapJson, &installError)) { + throwTypeError("configureLoader: " + installError); + return; + } + TNS_DEBUG(Esm, "[ns:module configureLoader] import map set (%zu bytes)", + importMapJson.size()); + } + + if (havePatterns) { + SetVolatilePatterns(patterns); + TNS_DEBUG(Esm, "[ns:module configureLoader] %zu volatile patterns set", + patterns.size()); + } + + if (haveCanon) { + SetCanonicalizationConfig(std::move(canon)); + } + } catch (NativeScriptException& e) { + e.ReThrowToV8(); + } catch (std::exception& e) { + NativeScriptException nsEx(std::string("Error: c++ exception: ") + e.what() + "\n"); + nsEx.ReThrowToV8(); + } catch (...) { + NativeScriptException nsEx(std::string("Error: c++ exception!")); + nsEx.ReThrowToV8(); + } +} + +void InvalidateModulesCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + + try { + if (info.Length() < 1 || !info[0]->IsArray()) { + isolate->ThrowException(v8::Exception::TypeError( + ToV8String(isolate, "invalidateModules expects an array of URL strings"))); + return; + } + + v8::Local urlsArray = info[0].As(); + std::vector urls; + urls.reserve(urlsArray->Length()); + for (uint32_t index = 0; index < urlsArray->Length(); index++) { + v8::Local value; + if (!urlsArray->Get(ctx, index).ToLocal(&value)) { + return; + } + if (!value->IsString()) { + isolate->ThrowException(v8::Exception::TypeError(ToV8String( + isolate, + "invalidateModules: urls[" + std::to_string(index) + + "] must be a string"))); + return; + } + urls.push_back(ArgConverter::ToString(isolate, value)); + } + + if (tns::LogCategoryEnabled(tns::LogCategory::Registry)) { + TNS_DEBUG(Registry, "invalidate called urls.count=%zu", urls.size()); + size_t shown = 0; + for (const auto& u : urls) { + if (shown >= 32) break; + TNS_DEBUG(Registry, "invalidate url[%zu]=%s", shown, u.c_str()); + shown++; + } + if (urls.size() > shown) { + TNS_DEBUG(Registry, "invalidate (hidden %zu more URL(s))", urls.size() - shown); + } + } + + tns::InvalidateModules(isolate, ctx, urls); + } catch (NativeScriptException& e) { + e.ReThrowToV8(); + } catch (std::exception& e) { + NativeScriptException nsEx(std::string("Error: c++ exception: ") + e.what() + "\n"); + nsEx.ReThrowToV8(); + } catch (...) { + NativeScriptException nsEx(std::string("Error: c++ exception!")); + nsEx.ReThrowToV8(); + } +} + +void GetLoadedModuleUrlsCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + + try { + std::vector urls = tns::GetLoadedModuleUrls(); + v8::Local result = v8::Array::New(isolate, static_cast(urls.size())); + + for (uint32_t index = 0; index < urls.size(); index++) { + result->Set(ctx, index, ToV8String(isolate, urls[index])).FromMaybe(false); + } + + info.GetReturnValue().Set(result); + } catch (NativeScriptException& e) { + e.ReThrowToV8(); + } catch (std::exception& e) { + NativeScriptException nsEx(std::string("Error: c++ exception: ") + e.what() + "\n"); + nsEx.ReThrowToV8(); + } catch (...) { + NativeScriptException nsEx(std::string("Error: c++ exception!")); + nsEx.ReThrowToV8(); + } +} + +} // namespace + +bool BuildNsModuleBinding(v8::Local context, v8::Local binding) { + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + + InstallDevFunction(isolate, context, binding, "configureLoader", ConfigureLoaderCallback); + InstallDevFunction(isolate, context, binding, "invalidateModules", InvalidateModulesCallback); + InstallDevFunction(isolate, context, binding, "getLoadedModuleUrls", + GetLoadedModuleUrlsCallback); + + if (!ModuleInternal::InstallCreateRequireBinding(context, binding)) { + return false; + } + + if (IsDebuggable()) { + auto canonicalizeCb = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + try { + if (info.Length() < 1 || !info[0]->IsString()) { + iso->ThrowException(v8::Exception::TypeError( + ToV8String(iso, "canonicalizeHttpUrlKey expects a URL string"))); + return; + } + std::string key = CanonicalizeHttpUrlKey(ArgConverter::ToString(iso, info[0])); + info.GetReturnValue().Set(ToV8String(iso, key)); + } catch (NativeScriptException& e) { + e.ReThrowToV8(); + } catch (std::exception& e) { + NativeScriptException nsEx(std::string("Error: c++ exception: ") + e.what() + + "\n"); + nsEx.ReThrowToV8(); + } catch (...) { + NativeScriptException nsEx(std::string("Error: c++ exception!")); + nsEx.ReThrowToV8(); + } + }; + v8::Local fn; + if (v8::Function::New(context, canonicalizeCb).ToLocal(&fn)) { + fn->SetName(ToV8String(isolate, "canonicalizeHttpUrlKey")); + if (!binding + ->CreateDataProperty(context, ToV8String(isolate, "canonicalizeHttpUrlKey"), + fn) + .FromMaybe(false)) { + return false; + } + } + } + + return true; +} + +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/HttpLoader.h b/test-app/runtime/src/main/cpp/HttpLoader.h new file mode 100644 index 000000000..eccfc5497 --- /dev/null +++ b/test-app/runtime/src/main/cpp/HttpLoader.h @@ -0,0 +1,182 @@ +#pragma once + +#include +#include +#include + +// Forward declare v8 types to keep this header lightweight and avoid +// requiring V8 headers at include sites. +namespace v8 { +class Isolate; +template +class Local; +class Object; +class Function; +class Context; +class Value; +} // namespace v8 + +namespace tns { + +// HttpLoader: the native half of the NativeScript HTTP module-loader +// contract. +// +// The runtime deliberately exposes *mechanism* only: +// - the synchronous HTTP text fetch backing the HTTP ESM loader's +// fallback path (V8's ResolveModuleCallback is synchronous — still +// true as of 14.9.207.39 — so the fallback must be native), +// - the async background-thread fetch behind the phase-1 module-graph +// walk (StartModuleGraphLoad), which is how module bodies +// normally arrive, +// - eviction plumbing (an eviction-driven fetch nonce that defeats +// any HTTP cache layer between the runtime and the origin), +// - the remote-module security gate, seeded once from nativescript.config +// at boot and never exposed on ns:runtime getConfig/setConfig. + +// ───────────────────────────────────────────────────────────── +// HTTP loader helpers (used by dev/HMR and general-purpose HTTP module loading) +// +// The canonical-key *mechanism* (fragment strip, cache-buster param drop, +// param sort) must be native because it keys the module registry inside V8's +// synchronous resolve walk. The *vocabulary* — which query params are pure +// cache busters, which path prefixes identify dev endpoints whose queries may +// be normalized, and which paths must keep their query verbatim because the +// query IS the identity — is server/framework policy, supplied by the dev +// client via ns:module `configureLoader({ canonicalization: {...} })`. It is +// per-isolate loader vocabulary — installed through SetCanonicalizationConfig +// in ModuleInternalCallbacks.h — so CanonicalizeHttpUrlKey runs on the +// isolate's own thread only. The transport canonicalizes at its JS-thread +// entry points (HttpFetchModule, FetchModuleBodyAsync) and nowhere else; +// background fetch threads only ever carry keys computed for them. +// +// When unconfigured, canonicalization is purely mechanical (fragment strip). +struct CanonicalizationConfig { + std::vector stripParams; // query param names to drop + std::vector devPathPrefixes; // StartsWith → normalize query + std::vector preserveQueryPrefixes; // contains → keep query +}; + +// Normalize an HTTP(S) URL into a stable module registry/cache key. +// - Anything that is not HTTP(S) comes back unchanged, after unwrapping a +// `file://` prefix the resolver may have put in front of an http(s) URL. +// - The fragment is always stripped. +// - The query survives unless the isolate's canonicalization vocabulary says +// otherwise: a path matching `preserveQueryFor` keeps its query verbatim; +// a path under a `forPathPrefixes` prefix drops every `stripParams` name and +// sorts what remains, for stability. Unconfigured, every query is kept. +// Module identity IS the (canonical) URL — the dev server serves every +// module under exactly one URL and never varies it for freshness. +std::string CanonicalizeHttpUrlKey(const std::string& url); + +// Undoes what a path normalizer did to an http(s) URL — unwraps a `file://` +// prefix the resolver may have put in front, and re-doubles a scheme +// separator collapsed to a single slash (`http:/host/...`). Every consumer +// that classifies or keys a module URL must run this first, or the same +// module string routes/keys differently at different sites. +std::string NormalizeHttpModuleUrl(const std::string& path); + +// What a module response turned out to be. Decided once, by the shared +// classifier, for whichever transport produced the response. +enum class ModuleResponseKind { + kJavaScript, + kJson, +}; + +// The outcome of fetching one module over HTTP. Both transports produce this +// same verdict, so the synchronous fallback and the async graph walk cannot +// drift apart on what counts as a usable module. +struct ModuleFetchResult { + bool ok = false; + int status = 0; + ModuleResponseKind kind = ModuleResponseKind::kJavaScript; + // Normalized: an empty 2xx JavaScript body becomes the canonical empty + // module. Meaningful only when `ok`. + std::string body; + std::string contentType; // as received, parameters included + // Reader-facing explanation, non-empty exactly when `!ok`. This is the text + // that reaches the importer's rejection, so it names the URL and the cause. + std::string failureReason; +}; + +// Synchronous module fetch with one retry on transport error — the fallback +// path for anything the module-graph walk missed. Blocks the calling thread. +// Returns `result.ok`. +bool HttpFetchModule(const std::string& url, ModuleFetchResult& result); + +// Asynchronous single-URL module fetch — the I/O primitive behind the +// module-graph walk (see StartModuleGraphLoad in ModuleInternalCallbacks.h). +// Same response policy as HttpFetchModule, minus the JS-thread block: +// - security gate (IsRemoteUrlAllowed) checked up front, +// - a JNI HttpURLConnection GET on a background thread with the same +// request shape as the sync path (cache-bust nonce, zero-cache headers) +// and one retry on transport error. +// `completion(result)` is invoked exactly once, on an arbitrary thread — +// callers must hop to their JS thread before touching V8. +void FetchModuleBodyAsync( + const std::string& url, + std::function completion); + +// Mark a set of canonical registry keys so that the NEXT network fetch of +// each carries a unique `__ns_dev_nonce` query parameter, guaranteeing no +// HTTP cache layer between the runtime and the origin can satisfy the +// request. Called by `InvalidateModules` for the eviction set; marks are +// consumed when a fresh body arrives. +// The keys are inserted verbatim: canonicalization belongs to the caller's +// isolate thread (see CanonicalizeHttpUrlKey), and the transport's own +// background threads have no isolate to read the vocabulary from. +// The nonce is transport-only and never affects module identity. +void MarkKeysForCacheBust(const std::vector& canonicalKeys); + +// Clear the transport's process-wide state (cache-bust marks). MUST be +// called inside Runtime::DestroyRuntime() before isolate +// disposal — and only for the MAIN isolate (worker teardown must not wipe +// shared state the main isolate still uses). +void CleanupHttpLoaderGlobals(); + +// ───────────────────────────────────────────────────────────── +// Remote-module security gate +// +// Seeded once from nativescript.config / package.json (`security.allowRemoteModules`, +// `security.remoteModuleAllowlist`) the first time a fetch is gated. Debug +// apps always allow. These values are not readable or writable through +// ns:runtime getConfig/setConfig — only nativescript.config at boot. + +// In debug mode (Runtime.isDebuggable()): always returns true. +// Otherwise returns the boot-time `security.allowRemoteModules` value. +bool IsRemoteModulesAllowed(); + +// Whether `url` may be fetched as a remote ES module. Debug apps always +// allow. Production requires allowRemoteModules, then an allowlist match +// (or all URLs if the allowlist is empty). +bool IsRemoteUrlAllowed(const std::string& url); + +// Mirrors com.tns.Runtime.isDebuggable(), cached once via the security +// config init. Fail-safe false until initialized. +bool IsDebuggable(); + +// ───────────────────────────────────────────────────────────── +// The `ns:module` builtin binding +// +// Populates the native half of the `ns:module` builtin module — the one +// namespace carrying every JS-callable dev primitive that any tooling can +// depend on. Called from NsBuiltinModules::BuildBinding the first time a +// realm resolves `ns:module` (via require, static import, or import()); +// ns-module.js shapes and freezes the exports. +// +// `ns:module` members: +// - configureLoader(config) (import map + volatile patterns + +// canonicalization vocabulary) +// - invalidateModules(urls) (registry + cache eviction) +// - getLoadedModuleUrls() (registry introspection) +// - canonicalizeHttpUrlKey(url) (debug builds only; test diagnostic) +// +// Worker teardown across HMR cycles is userland: the dev client intercepts +// the global `Worker` constructor and terminates tracked instances +// (worker.terminate() cascades to nested workers via Runtime::DestroyRuntime). +// +// Returns false (with an exception pending or a failed Set) when the +// binding could not be populated. +bool BuildNsModuleBinding(v8::Local context, + v8::Local binding); + +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index 7b4ce5d1b..f2e85a70c 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -1853,8 +1853,6 @@ bool MetadataNode::GetExtendLocation(v8::Isolate* isolate, string& extendLocatio } string srcFileName = ArgConverter::ConvertToString(scriptName); - // trim 'file://' to normalize path to always begin with "/data/" - srcFileName = Util::ReplaceAll(srcFileName, "file://", ""); string fullPathToFile; if (srcFileName == "") { @@ -1866,22 +1864,80 @@ bool MetadataNode::GetExtendLocation(v8::Isolate* isolate, string& extendLocatio // preceding the underscore (_) fullPathToFile = "script"; } else { - string hardcodedPathToSkip = Constants::APP_ROOT_FOLDER_PATH; + // srcFileName is not always `file:///.js`: + // HTTP ESM loading (HMR dev workflow) passes a full URL like + // `http:///` with no `.js` suffix and no + // app-root prefix, so naive scheme/app-root/`.js` stripping + // can yield an empty `fullPathToFile` and crash downstream on + // an empty token list. + string normalized = srcFileName; + + auto stripPrefix = [](string& s, const string& prefix) { + if (s.size() >= prefix.size() && + s.compare(0, prefix.size(), prefix) == 0) { + s.erase(0, prefix.size()); + } + }; + + stripPrefix(normalized, "file://"); + if (normalized.rfind("http://", 0) == 0 || + normalized.rfind("https://", 0) == 0) { + size_t schemeEnd = normalized.find("://"); + size_t pathStart = normalized.find('/', schemeEnd + 3); + if (pathStart == string::npos) { + normalized.clear(); + } else { + normalized.erase(0, pathStart + 1); + } + } - int startIndex = hardcodedPathToSkip.length(); - int strToTakeLen = (srcFileName.length() - startIndex - 3); // 3 refers to .js at the end of file name - fullPathToFile = srcFileName.substr(startIndex, strToTakeLen); + size_t queryOrFragment = normalized.find_first_of("?#"); + if (queryOrFragment != string::npos) { + normalized.resize(queryOrFragment); + } - std::replace(fullPathToFile.begin(), fullPathToFile.end(), '/', '_'); - std::replace(fullPathToFile.begin(), fullPathToFile.end(), '.', '_'); - std::replace(fullPathToFile.begin(), fullPathToFile.end(), '-', '_'); - std::replace(fullPathToFile.begin(), fullPathToFile.end(), ' ', '_'); + const string& appRoot = Constants::APP_ROOT_FOLDER_PATH; + if (!appRoot.empty()) { + stripPrefix(normalized, appRoot); + } - std::vector pathParts; + auto endsWith = [](const string& s, const string& suffix) { + return s.size() >= suffix.size() && + s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; + }; + if (endsWith(normalized, ".mjs")) { + normalized.resize(normalized.size() - 4); + } else if (endsWith(normalized, ".js")) { + normalized.resize(normalized.size() - 3); + } + fullPathToFile = normalized; + + for (char& ch : fullPathToFile) { + const unsigned char c = static_cast(ch); + const bool isIdentifierChar = + (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + ch == '_'; + if (!isIdentifierChar) { + ch = '_'; + } + } + + std::vector pathParts; Util::SplitString(fullPathToFile, "_", pathParts); - std::string lastPathPart = pathParts.back(); + std::string lastPathPart; + for (auto it = pathParts.rbegin(); it != pathParts.rend(); ++it) { + if (!it->empty()) { + lastPathPart = *it; + break; + } + } + if (lastPathPart.empty()) { + lastPathPart = "script"; + } fullPathToFile = lastPathPart; } diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index 79f7aa0f6..3c1fe5da5 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -8,45 +8,110 @@ #include "ModuleInternalCallbacks.h" #include "BuiltinLoader.h" #include "File.h" +#include "HttpLoader.h" #include "JniLocalRef.h" #include "ArgConverter.h" -#include "V8GlobalHelpers.h" #include "NativeScriptAssert.h" #include "Constants.h" #include "CrashBreadcrumbs.h" +#include "EventLoop.h" #include "NativeScriptException.h" #include "NsBuiltinModules.h" #include "napi/NapiModules.h" #include "Util.h" #include "SimpleProfiler.h" -#include "include/v8.h" #include "CallbackHandlers.h" #include "ManualInstrumentation.h" #include "Runtime.h" +#include "TraceLog.h" #include -#include #include #include #include #include #include #include +#include +#include +#include using namespace v8; using namespace std; using namespace tns; -// Global module registry for ES modules: maps absolute file paths → compiled Module handles -std::unordered_map> g_moduleRegistry; +// Classifies the NORMALIZED form: a scheme separator collapsed by a path +// normalizer (`http:/host/...`) must still route to the HTTP loader, or the +// same string classifies as a filesystem path and repairs itself only after +// taking the wrong branch. +static bool IsHttpModulePath(const std::string& path) { + const std::string normalized = NormalizeHttpModuleUrl(path); + return normalized.rfind("http://", 0) == 0 || normalized.rfind("https://", 0) == 0; +} -// Helper function to check if a module name looks like an optional external module -bool ModuleInternal::IsLikelyOptionalModule(const std::string& moduleName) { - // Check if it's a bare module name (no path separators) that could be an npm package - if (moduleName.find('/') == std::string::npos && moduleName.find('\\') == std::string::npos && - moduleName[0] != '.' && moduleName[0] != '~' && moduleName[0] != '/') { - return true; +// What a rejected evaluation promise says about itself. +struct RejectionDetail { + // The reason's own text: an Error's `message`, or the reason stringified. + std::string message; + // A bounded rendering of the reason's `stack`, filled only when asked for. + std::string stackPreview; +}; + +// `detail`, when non-null, receives the reason's parts unjoined; reading the +// stack costs a property get plus a copy, so callers pass null unless a trace +// is actually going to be emitted. +static std::string PromiseRejectionMessage(Isolate* isolate, Local promise, + const std::string& path, + RejectionDetail* detail = nullptr) { + std::string errorMessage = "Module evaluation promise rejected: " + path; + TryCatch tc(isolate); + Local reason = promise->Result(); + if (reason.IsEmpty()) { + return errorMessage; + } + std::string reasonText; + Local context = isolate->GetCurrentContext(); + if (reason->IsObject()) { + Local errorObj = reason.As(); + Local messageVal; + if (errorObj->Get(context, ArgConverter::ConvertToV8String(isolate, "message")) + .ToLocal(&messageVal) && + messageVal->IsString()) { + v8::String::Utf8Value messageUtf8(isolate, messageVal); + if (*messageUtf8) { + reasonText.assign(*messageUtf8); + } + } + Local stackVal; + if (detail != nullptr && + errorObj->Get(context, ArgConverter::ConvertToV8String(isolate, "stack")) + .ToLocal(&stackVal) && + stackVal->IsString()) { + v8::String::Utf8Value stackUtf8(isolate, stackVal); + if (*stackUtf8) { + std::string stack(*stackUtf8); + detail->stackPreview = stack.size() > 240 ? stack.substr(0, 240) + "…" : stack; + } + } + } else { + auto maybeReasonStr = reason->ToString(context); + if (!maybeReasonStr.IsEmpty()) { + v8::String::Utf8Value reasonUtf8(isolate, maybeReasonStr.ToLocalChecked()); + if (*reasonUtf8) { + reasonText.assign(*reasonUtf8); + } + } + } + if (!reasonText.empty()) { + errorMessage.append(" — "); + errorMessage.append(reasonText); } - return false; + if (detail != nullptr) { + detail->message = std::move(reasonText); + } + if (tc.HasCaught()) { + tc.Reset(); + } + return errorMessage; } // A package-style specifier: neither a path nor a scheme, so it may be claimed @@ -60,6 +125,9 @@ static bool IsBareSpecifier(const std::string& specifier) { return specifier.find(':') == std::string::npos; } +static ModuleEvaluationOptions BootEntryEvaluationOptions(bool isHttpModule); +static ModuleEvaluationOptions RequireEvaluationOptions(ModuleEvaluationPolicy policy); + // Helper function to check if a file path is an ES module (.mjs) but not a source map (.mjs.map) bool ModuleInternal::IsESModule(const std::string& path) { return path.size() >= 4 && path.compare(path.size() - 4, 4, ".mjs") == 0 && @@ -124,26 +192,94 @@ void ModuleInternal::Init(Isolate* isolate, const string& baseDir) { m_requireFactoryFunction = new Persistent(isolate, requireFactoryFunction); - auto requireFuncTemplate = FunctionTemplate::New(isolate, RequireCallback, External::New(isolate, this, v8::kExternalPointerTypeTagDefault)); + auto external = External::New(isolate, this, v8::kExternalPointerTypeTagDefault); + + // Only the require factory receives this one, so the evaluation options it + // forwards were validated at mint time. + auto requireFuncTemplate = FunctionTemplate::New(isolate, RequireCallback, external); auto requireFunc = requireFuncTemplate->GetFunction(context).ToLocalChecked(); - global->Set(context, ArgConverter::ConvertToV8String(isolate, "__nativeRequire"), requireFunc); m_requireFunction = new Persistent(isolate, requireFunc); + // App code can reach this one, so it reads nothing but the specifier and the + // calling directory: a caller must not be able to hand itself a pumping + // policy, an unbounded deadline or a looper-slicing require. + auto publicRequireTemplate = FunctionTemplate::New(isolate, RequirePublicCallback, external); + global->Set(context, ArgConverter::ConvertToV8String(isolate, "__nativeRequire"), + publicRequireTemplate->GetFunction(context).ToLocalChecked()); + Local globalRequire; if (!baseDir.empty()) { - globalRequire = GetRequireFunction(isolate, baseDir); + globalRequire = GetRequireFunction(isolate, baseDir, RequireEvaluationOptions( + ModuleEvaluationPolicy::kSyncStrict)); } else { - globalRequire = GetRequireFunction(isolate, Constants::APP_ROOT_FOLDER_PATH); + globalRequire = GetRequireFunction(isolate, Constants::APP_ROOT_FOLDER_PATH, + RequireEvaluationOptions( + ModuleEvaluationPolicy::kSyncStrict)); } global->Set(context, ArgConverter::ConvertToV8String(isolate, "require"), globalRequire); } -Local ModuleInternal::GetRequireFunction(Isolate* isolate, const string& dirName) { +// How an entry module's graph settles. For local modules the bound is a yield, +// not a timeout: the default pump runs only nestable V8 tasks while these JS +// frames are on the stack, so a TLA parked on anything else can never settle +// in-place — give it one short window, then return and let the real event +// loop (or the draining boot backstop) finish it after the turn. HTTP entries +// must settle in-pump — the dev client needs the rejection reason +// synchronously — so they get the full deadline and the looper-equivalent +// drain, the same split iOS makes with its runloop slice. +static ModuleEvaluationOptions BootEntryEvaluationOptions(bool isHttpModule) { + ModuleEvaluationOptions options; + options.policy = ModuleEvaluationPolicy::kSyncPumping; + options.deadlineSeconds = isHttpModule ? kModuleEvaluateDeadlineSeconds : 1.0; + options.timeoutBehavior = isHttpModule + ? ModuleEvaluationOptions::TimeoutBehavior::kThrow + : ModuleEvaluationOptions::TimeoutBehavior::kReturnPending; + options.pumpRunLoop = isHttpModule; + return options; +} + +// How a graph reached through require() settles. A pumping require must settle +// or throw — handing back a half-initialized namespace is what the strict +// policy exists to prevent — so it gets the full deadline. By default the +// pump runs nestable v8 tasks and microtasks only, matching iOS: running JS +// timers or loop posts in the middle of an arbitrary require is opt-in +// (pumpRunLoop), because those callbacks execute underneath the require's JS +// frames. +static ModuleEvaluationOptions RequireEvaluationOptions(ModuleEvaluationPolicy policy) { + ModuleEvaluationOptions options; + options.policy = policy; + if (policy == ModuleEvaluationPolicy::kSyncPumping) { + options.deadlineSeconds = kModuleEvaluateDeadlineSeconds; + options.timeoutBehavior = ModuleEvaluationOptions::TimeoutBehavior::kThrow; + options.pumpRunLoop = false; + } + return options; +} + +// The require cache is keyed by directory AND by the options the require was +// minted with: a pumping require for a directory must never be served from a +// strict require cached for the same directory, in either direction. +static std::string RequireCacheKey(const std::string& dirName, + const ModuleEvaluationOptions& options) { + std::string key = dirName; + key += '\x1f'; + key += std::to_string(static_cast(options.policy)); + key += '\x1f'; + key += std::to_string(options.deadlineSeconds); + key += '\x1f'; + key += (options.timeoutBehavior == ModuleEvaluationOptions::TimeoutBehavior::kThrow) ? '1' : '0'; + key += options.pumpRunLoop ? '1' : '0'; + return key; +} + +Local ModuleInternal::GetRequireFunction(Isolate* isolate, const string& dirName, + const ModuleEvaluationOptions& options) { TNSPERF(); Local requireFunc; - auto itFound = m_requireCache.find(dirName); + const std::string cacheKey = RequireCacheKey(dirName, options); + auto itFound = m_requireCache.find(cacheKey); if (itFound != m_requireCache.end()) { requireFunc = Local::New(isolate, *itFound->second); @@ -154,12 +290,18 @@ Local ModuleInternal::GetRequireFunction(Isolate* isolate, const strin auto requireInternalFunc = Local::New(isolate, *m_requireFunction); - Local args[2] { - requireInternalFunc, ArgConverter::ConvertToV8String(isolate, dirName) + Local args[6] { + requireInternalFunc, + ArgConverter::ConvertToV8String(isolate, dirName), + Integer::New(isolate, static_cast(options.policy)), + Number::New(isolate, options.deadlineSeconds), + v8::Boolean::New(isolate, options.timeoutBehavior == + ModuleEvaluationOptions::TimeoutBehavior::kThrow), + v8::Boolean::New(isolate, options.pumpRunLoop) }; Local result; auto thiz = Object::New(isolate); - auto success = requireFuncFactory->Call(context, thiz, 2, args).ToLocal(&result); + auto success = requireFuncFactory->Call(context, thiz, 6, args).ToLocal(&result); NS_CHECK(success && !result.IsEmpty() && result->IsFunction()); @@ -167,16 +309,141 @@ Local ModuleInternal::GetRequireFunction(Isolate* isolate, const strin auto poFunc = new Persistent(isolate, requireFunc); - m_requireCache.emplace(dirName, poFunc); + m_requireCache.emplace(cacheKey, poFunc); } return requireFunc; } -void ModuleInternal::RequireCallback(const v8::FunctionCallbackInfo& args) { +void ModuleInternal::CreateRequireCallback(const v8::FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + if (args.Length() < 1 || !args[0]->IsString()) { + isolate->ThrowException(Exception::TypeError(ArgConverter::ConvertToV8String( + isolate, "createRequire expects a base directory string"))); + return; + } + + Runtime* runtime = Runtime::TryGetRuntime(isolate); + ModuleInternal* moduleInternal = runtime != nullptr ? runtime->GetModuleInternal() : nullptr; + if (moduleInternal == nullptr) { + isolate->ThrowException(Exception::Error(ArgConverter::ConvertToV8String( + isolate, "createRequire is unavailable: this isolate has no module loader"))); + return; + } + + string dirName = ArgConverter::ConvertToString(args[0].As()); + const bool pumping = args.Length() > 1 && args[1]->BooleanValue(isolate); + ModuleEvaluationOptions options = RequireEvaluationOptions( + pumping ? ModuleEvaluationPolicy::kSyncPumping : ModuleEvaluationPolicy::kSyncStrict); + + // ns-module.js has already validated these and passes undefined for anything + // the caller left out, so each present value simply overrides its default. + if (args.Length() > 2 && args[2]->IsNumber()) { + double deadlineSeconds = args[2].As()->Value(); + // A NaN or infinite window makes the pump's deadline arithmetic + // undefined, and a non-positive one is no window at all. + if (std::isfinite(deadlineSeconds) && deadlineSeconds > 0.0) { + options.deadlineSeconds = deadlineSeconds; + } + } + if (args.Length() > 3 && args[3]->IsBoolean()) { + options.timeoutBehavior = args[3]->BooleanValue(isolate) + ? ModuleEvaluationOptions::TimeoutBehavior::kThrow + : ModuleEvaluationOptions::TimeoutBehavior::kReturnPending; + } + if (args.Length() > 4 && args[4]->IsBoolean()) { + options.pumpRunLoop = args[4]->BooleanValue(isolate); + } + + args.GetReturnValue().Set(moduleInternal->GetRequireFunction(isolate, dirName, options)); +} + +bool ModuleInternal::InstallCreateRequireBinding(Local context, Local binding) { + Isolate* isolate = v8::Isolate::GetCurrent(); + Local fn; + if (!Function::New(context, ModuleInternal::CreateRequireCallback).ToLocal(&fn)) { + return false; + } + fn->SetName(ArgConverter::ConvertToV8String(isolate, "createRequire")); + return binding->CreateDataProperty(context, + ArgConverter::ConvertToV8String(isolate, "createRequire"), + fn) + .FromMaybe(false); +} + +// Node's `determineSpecificType` (lib/internal/errors.js), so an +// ERR_INVALID_ARG_TYPE-shaped message reads the same here as it does there. +// Deliberately side-effect free: no getter, no user `toString`, no `inspect`. +static std::string DescribeValueForTypeError(Isolate* isolate, Local value) { + if (value.IsEmpty() || value->IsUndefined()) { + return "undefined"; + } + if (value->IsNull()) { + return "null"; + } + + if (value->IsFunction()) { + std::string name = ArgConverter::ToString(isolate, value.As()->GetName()); + return name.empty() ? "an instance of Function" : "function " + name; + } + + if (value->IsObject()) { + std::string ctorName = ArgConverter::ToString(isolate, + value.As()->GetConstructorName()); + return ctorName.empty() ? "an object" : "an instance of " + ctorName; + } + + // A primitive: `type ()`. + const char* typeName = "object"; + std::string rendered; + if (value->IsBoolean()) { + typeName = "boolean"; + rendered = value->IsTrue() ? "true" : "false"; + } else if (value->IsNumber()) { + typeName = "number"; + double number = value.As()->Value(); + // String(-0) is "0", but Node renders the sign, and losing it here would + // hide exactly the distinction the message is meant to surface. + rendered = (number == 0 && std::signbit(number)) ? "-0" + : ArgConverter::ToString(isolate, value); + } else if (value->IsBigInt()) { + typeName = "bigint"; + rendered = ArgConverter::ToString(isolate, value) + "n"; + } else if (value->IsSymbol()) { + typeName = "symbol"; + Local description = value.As()->Description(isolate); + rendered = "Symbol(" + (description->IsUndefined() + ? std::string() + : ArgConverter::ToString(isolate, description)) + + ")"; + } else { + rendered = ArgConverter::ToString(isolate, value); + } + + if (rendered.size() > 28) { + rendered = rendered.substr(0, 25) + "..."; + } + return "type " + std::string(typeName) + " (" + rendered + ")"; +} + +void ModuleInternal::DispatchRequire(const v8::FunctionCallbackInfo& args, + bool honorEvaluationOptions) { + auto isolate = args.GetIsolate(); + + // Every path below assumes a string specifier — the builtin probe, the + // http(s) guard and the filesystem resolution all read it — so reject a + // non-string before any of them rather than casting one unchecked. + if (args.Length() < 1 || !args[0]->IsString()) { + Local received = args.Length() < 1 ? Local() : args[0]; + isolate->ThrowException(Exception::TypeError(ArgConverter::ConvertToV8String( + isolate, "The \"id\" argument must be of type string. Received " + + DescribeValueForTypeError(isolate, received)))); + return; + } + try { auto thiz = static_cast(args.Data().As()->Value(v8::kExternalPointerTypeTagDefault)); - thiz->RequireCallbackImpl(args); + thiz->RequireCallbackImpl(args, honorEvaluationOptions); } catch (NativeScriptException& e) { e.ReThrowToV8(); } catch (std::exception e) { @@ -190,11 +457,20 @@ void ModuleInternal::RequireCallback(const v8::FunctionCallbackInfo& } } -void ModuleInternal::RequireCallbackImpl(const v8::FunctionCallbackInfo& args) { +void ModuleInternal::RequireCallback(const v8::FunctionCallbackInfo& args) { + DispatchRequire(args, true /* honorEvaluationOptions */); +} + +void ModuleInternal::RequirePublicCallback(const v8::FunctionCallbackInfo& args) { + DispatchRequire(args, false /* honorEvaluationOptions */); +} + +void ModuleInternal::RequireCallbackImpl(const v8::FunctionCallbackInfo& args, + bool honorEvaluationOptions) { auto isolate = args.GetIsolate(); - if (args.Length() != 2) { - throw NativeScriptException(string("require should be called with two parameters")); + if (args.Length() < 2) { + throw NativeScriptException(string("require should be called with at least two parameters")); } if (!args[0]->IsString()) { throw NativeScriptException(string("require's first parameter should be string")); @@ -235,11 +511,48 @@ void ModuleInternal::RequireCallbackImpl(const v8::FunctionCallbackInfo()); auto isData = false; - auto moduleObj = LoadImpl(isolate, moduleName, callingModuleDirName, isData); + ModuleEvaluationOptions evaluationOptions = + RequireEvaluationOptions(ModuleEvaluationPolicy::kSyncStrict); + if (honorEvaluationOptions) { + // The require factory forwards the options its require was minted with; + // an absent policy is the strict default every ordinary require uses. + ModuleEvaluationPolicy policy = ModuleEvaluationPolicy::kSyncStrict; + if (args.Length() > 2 && args[2]->IsInt32() && + args[2].As()->Value() == + static_cast(ModuleEvaluationPolicy::kSyncPumping)) { + policy = ModuleEvaluationPolicy::kSyncPumping; + } + evaluationOptions = RequireEvaluationOptions(policy); + if (args.Length() > 3 && args[3]->IsNumber()) { + double deadlineSeconds = args[3].As()->Value(); + // A NaN or infinite window makes the pump's deadline arithmetic + // undefined, and a non-positive one is no window at all. + if (std::isfinite(deadlineSeconds) && deadlineSeconds > 0.0) { + evaluationOptions.deadlineSeconds = deadlineSeconds; + } + } + if (args.Length() > 4 && args[4]->IsBoolean()) { + evaluationOptions.timeoutBehavior = + args[4]->BooleanValue(isolate) + ? ModuleEvaluationOptions::TimeoutBehavior::kThrow + : ModuleEvaluationOptions::TimeoutBehavior::kReturnPending; + } + if (args.Length() > 5 && args[5]->IsBoolean()) { + evaluationOptions.pumpRunLoop = args[5]->BooleanValue(isolate); + } + } + + auto moduleObj = LoadImpl(isolate, moduleName, callingModuleDirName, isData, evaluationOptions); if (isData) { NS_DCHECK(!moduleObj.IsEmpty()); @@ -267,10 +580,37 @@ void ModuleInternal::RequireNativeCallback(const v8::FunctionCallbackInfo context, const string& path) { TNSPERF(); auto isolate = m_isolate; + // The ES module branch compiles and links against + // isolate->GetCurrentContext(); a caller that enters the isolate through a + // fresh Isolate::Scope has no current context, and CompileModule would + // dereference a null native context. The require branch never needed this + // because Function::Call enters the context it is handed. + Context::Scope context_scope(context); + const bool isHttpModule = IsHttpModulePath(path); + if (isHttpModule || IsESModule(path)) { + if (isHttpModule) { + TNS_DEBUG(Esm, "run-module http-esm begin %s", NormalizeHttpModuleUrl(path).c_str()); + } + // The entry runs before this thread's event loop does, so its graph can + // only make progress from the pump inside LoadESModule. + LoadESModule(isolate, path, BootEntryEvaluationOptions(isHttpModule)); + if (isHttpModule) { + TNS_DEBUG(Esm, "run-module http-esm ok %s", NormalizeHttpModuleUrl(path).c_str()); + } + return; + } auto globalObject = context->Global(); auto require = globalObject->Get(context, ArgConverter::ConvertToV8String(isolate, "require")).ToLocalChecked().As(); Local args[] = { ArgConverter::ConvertToV8String(isolate, path) }; - require->Call(context, globalObject, 1, args); + // A failed entry must throw through this boundary in every build — the + // caller (boot, or a worker's onerror routing) owns the report, and the + // boot backstop must never pump with an exception pending on the isolate. + TryCatch tc(isolate); + Local result; + const bool ok = require->Call(context, globalObject, 1, args).ToLocal(&result); + if (!ok || tc.HasCaught()) { + throw NativeScriptException(tc, "require() failed for module " + path); + } } void ModuleInternal::LoadWorker(Local context, const string& path) { @@ -278,7 +618,11 @@ void ModuleInternal::LoadWorker(Local context, const string& path) { auto isolate = m_isolate; TryCatch tc(isolate); - Load(context, path); + try { + Load(context, path); + } catch (NativeScriptException& e) { + e.ReThrowToV8(); + } if (tc.HasCaught()) { // This will handle any errors that occur when first loading a script (new worker) @@ -289,15 +633,37 @@ void ModuleInternal::LoadWorker(Local context, const string& path) { } } -void ModuleInternal::CheckFileExists(Isolate* isolate, const std::string& path, const std::string& baseDir) { +std::string ModuleInternal::CheckFileExists(Isolate* isolate, const std::string& path, const std::string& baseDir) { JEnv env; JniLocalRef jsModulename(env.NewStringUTF(path.c_str())); JniLocalRef jsBaseDir(env.NewStringUTF(baseDir.c_str())); - env.CallStaticObjectMethod(MODULE_CLASS, RESOLVE_PATH_METHOD_ID, (jstring) jsModulename, (jstring) jsBaseDir); + // Throws a NativeScriptException (through JEnv's pending-exception check) + // when nothing resolves, so the conversion below only ever sees a hit. + JniLocalRef jsModulePath(env.CallStaticObjectMethod(MODULE_CLASS, RESOLVE_PATH_METHOD_ID, + (jstring) jsModulename, + (jstring) jsBaseDir)); + + return ArgConverter::jstringToString((jstring) jsModulePath); +} + +// The trailing extension without its dot, or an empty string when the last +// segment has none. A leading dot names a hidden file, not an extension. +static std::string PathExtension(const std::string& path) { + size_t dot = path.find_last_of('.'); + if (dot == std::string::npos || dot + 1 >= path.size()) { + return std::string(); + } + size_t slash = path.find_last_of('/'); + if (slash != std::string::npos && (dot < slash || dot == slash + 1)) { + return std::string(); + } + return path.substr(dot + 1); } -Local ModuleInternal::LoadImpl(Isolate* isolate, const string& moduleName, const string& baseDir, bool& isData) { +Local ModuleInternal::LoadImpl(Isolate* isolate, const string& moduleName, + const string& baseDir, bool& isData, + const ModuleEvaluationOptions& options) { auto pathKind = GetModulePathKind(moduleName); auto cachePathKey = (pathKind == ModulePathKind::Global) ? moduleName : (baseDir + "*" + moduleName); @@ -355,12 +721,12 @@ Local ModuleInternal::LoadImpl(Isolate* isolate, const string& moduleNam if (it2 == m_loadedModules.end()) { if (Util::EndsWith(path, ".js") || Util::EndsWith(path, ".mjs") || Util::EndsWith(path, ".so")) { isData = false; - result = LoadModule(isolate, path, cachePathKey); + result = LoadModule(isolate, path, cachePathKey, options); } else if (Util::EndsWith(path, ".json")) { isData = true; result = LoadData(isolate, path); } else { - string errMsg = "Unsupported file extension: " + path; + string errMsg = "Unsupported file extension: " + PathExtension(path); throw NativeScriptException(errMsg); } } else { @@ -377,7 +743,74 @@ Local ModuleInternal::LoadImpl(Isolate* isolate, const string& moduleNam return result; } -Local ModuleInternal::LoadModule(Isolate* isolate, const string& modulePath, const string& moduleCacheKey) { +static bool NamespaceHasOwn(Isolate* isolate, Local context, Local ns, + const char* name) { + return ns->HasOwnProperty(context, ArgConverter::ConvertToV8String(isolate, name)) + .FromMaybe(false); +} + +// The live compiled module behind a registry key, or empty. +static Local RegisteredModuleForPath(Isolate* isolate, const std::string& canonicalPath) { + auto* registryPtr = ModuleRegistryFor(isolate); + if (registryPtr == nullptr) { + return Local(); + } + auto it = registryPtr->find(canonicalPath); + if (it == registryPtr->end()) { + return Local(); + } + return it->second.Get(isolate); +} + +// What `require()` of an ES module hands back, per Node's +// populateCJSExportsFromESM: an explicit `module.exports` export wins outright; +// a namespace with no default export, or one that already declares +// __esModule, passes through untouched; everything else gets the facade so +// transpiled consumers reading `_mod.__esModule ? _mod.default : _mod` find the +// default. Export names are arbitrary strings, hence the own-property probes. +static Local RequireExportsForNamespace(Isolate* isolate, Local context, + Local ns, + const std::string& canonicalPath) { + TryCatch tc(isolate); + + if (NamespaceHasOwn(isolate, context, ns, "module.exports")) { + Local moduleExports; + if (!ns->Get(context, ArgConverter::ConvertToV8String(isolate, "module.exports")) + .ToLocal(&moduleExports)) { + throw NativeScriptException( + tc, "Cannot read the 'module.exports' export of " + canonicalPath); + } + return moduleExports; + } + + bool hasDefault = NamespaceHasOwn(isolate, context, ns, "default"); + bool hasEsModuleMarker = NamespaceHasOwn(isolate, context, ns, "__esModule"); + if (!hasDefault || hasEsModuleMarker) { + return ns; + } + + Local target = RegisteredModuleForPath(isolate, canonicalPath); + if (target.IsEmpty()) { + // The load that produced this namespace registered the module under this + // very key, so a miss means the registry and the namespace disagree — + // returning the bare namespace would drop __esModule and misroute every + // transpiled consumer downstream. + throw NativeScriptException( + "require() cannot build the exports facade for " + canonicalPath + + ": the module evaluated but is absent from the registry under its canonical key"); + } + + Local facade; + if (!GetOrCreateRequireFacade(isolate, context, target, canonicalPath).ToLocal(&facade)) { + throw NativeScriptException("Cannot build the require() exports facade for " + + canonicalPath); + } + return facade->GetModuleNamespace(); +} + +Local ModuleInternal::LoadModule(Isolate* isolate, const string& modulePath, + const string& moduleCacheKey, + const ModuleEvaluationOptions& options) { string frameName("LoadModule " + modulePath); tns::instrumentation::Frame frame(frameName); CrashBreadcrumbs::ModuleScope moduleBreadcrumb(modulePath.c_str()); @@ -398,13 +831,27 @@ Local ModuleInternal::LoadModule(Isolate* isolate, const string& moduleP // Check if this is an ES module (.mjs) if (Util::EndsWith(modulePath, ".mjs")) { - // For ES modules, load using the ES module system - Local moduleNamespace = LoadESModule(isolate, modulePath); - - // Create a wrapper object that behaves like a CommonJS module - // but exports the ES module namespace - moduleObj->Set(context, ArgConverter::ConvertToV8String(isolate, "exports"), moduleNamespace); - + // require()'s route into the ES module system, which cannot wait: an + // async graph is refused rather than pumped. + Local moduleNamespace = LoadESModule(isolate, modulePath, options); + + // A load that produced no namespace produced no module either — an + // isolate torn down mid-load, or a graph that never settled. Caching the + // empty exports object would intern that failure for the process. + if (moduleNamespace.IsEmpty()) { + throw NativeScriptException("ES module load returned empty value " + modulePath); + } + if (!moduleNamespace->IsObject()) { + throw NativeScriptException("Failed to load ES module " + modulePath); + } + + // `module.exports` is what Node's populateCJSExportsFromESM produces for + // this namespace, not the namespace itself. + Local esmExports = RequireExportsForNamespace(isolate, context, + moduleNamespace.As(), + CanonicalizeRegistryKey(modulePath)); + moduleObj->Set(context, ArgConverter::ConvertToV8String(isolate, "exports"), esmExports); + tempModule.SaveToCache(); result = moduleObj; return result; @@ -472,18 +919,23 @@ Local ModuleInternal::LoadModule(Isolate* isolate, const string& moduleP } moduleFunc = maybeFunc.ToLocalChecked(); } else { - string errMsg = "Unsupported file extension: " + modulePath; + string errMsg = "Unsupported file extension: " + PathExtension(modulePath); throw NativeScriptException(errMsg); } SET_PROFILER_FRAME(); auto fileName = ArgConverter::ConvertToV8String(isolate, modulePath); - char pathcopy[1024]; - strcpy(pathcopy, modulePath.c_str()); - string strDirName(dirname(pathcopy)); + // dirname() semantics without its fixed-size copy: module paths can + // exceed any stack buffer (PATH_MAX is 4096 and node_modules nests). + const size_t lastSlash = modulePath.find_last_of('/'); + string strDirName = lastSlash == string::npos ? "." + : lastSlash == 0 ? "/" + : modulePath.substr(0, lastSlash); auto dirName = ArgConverter::ConvertToV8String(isolate, strDirName); - auto require = GetRequireFunction(isolate, strDirName); + // A module's own require inherits the options it was loaded under, so a + // pumping require's whole dependency tree keeps pumping. + auto require = GetRequireFunction(isolate, strDirName, options); Local requireArgs[5] { moduleObj, exportsObj, require, fileName, dirName }; @@ -562,7 +1014,12 @@ Local ModuleInternal::LoadData(Isolate* isolate, const string& path) { tns::instrumentation::Frame frame(frameName); Local json; - auto jsonData = Runtime::GetRuntime(m_isolate)->ReadFileText(path); + Runtime* runtime = Runtime::TryGetRuntime(m_isolate); + if (runtime == nullptr) { + throw NativeScriptException("Cannot read JSON module " + path + + ": the isolate has no runtime"); + } + auto jsonData = runtime->ReadFileText(path); TryCatch tc(isolate); @@ -592,15 +1049,32 @@ Local ModuleInternal::LoadData(Isolate* isolate, const string& path) { return json; } -Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& path) { - auto context = isolate->GetCurrentContext(); +MaybeLocal ModuleInternal::CompileFileEsModule(Isolate* isolate, const std::string& path) { + // The resolver only ever hands over a path it already probed, but the ENTRY + // routes (app main, worker main) reach here straight from the caller's + // specifier — so the existence check has to live here, or a missing entry + // reads a null FILE* instead of failing with a name. + struct stat st; + if (stat(path.c_str(), &st) != 0 || !S_ISREG(st.st_mode)) { + throw NativeScriptException("Cannot find module " + path); + } - // 1) Prepare URL & source string url = "file://" + path; - string content = Runtime::GetRuntime(isolate)->ReadFileText(path); - + // An exists-but-unreadable file, or one deleted between the stat above and + // the open, reads as "" — which compiles into a perfectly valid empty + // module unless the failure is told apart from an empty file here. + bool readOk = false; + Runtime* runtime = Runtime::TryGetRuntime(isolate); + if (runtime == nullptr) { + throw NativeScriptException("Cannot read module " + path + + ": the isolate has no runtime"); + } + string content = runtime->ReadFileText(path, readOk); + if (!readOk) { + throw NativeScriptException("Cannot read module " + path); + } + Local sourceText = ArgConverter::ConvertToV8String(isolate, content); - ScriptCompiler::CachedData* cacheData = nullptr; // TODO: Implement cache support for ES modules Local urlString; if (!String::NewFromUtf8(isolate, url.c_str(), NewStringType::kNormal).ToLocal(&urlString)) { @@ -610,97 +1084,514 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p ScriptOrigin origin(urlString, 0, 0, false, -1, Local(), false, false, true // ← is_module ); - ScriptCompiler::Source source(sourceText, origin, cacheData); + ScriptCompiler::Source source(sourceText, origin); - // 2) Compile with its own TryCatch - Local module; - { - TryCatch tcCompile(isolate); - MaybeLocal maybeMod = ScriptCompiler::CompileModule( - isolate, &source, - cacheData ? ScriptCompiler::kConsumeCodeCache : ScriptCompiler::kNoCompileOptions); + return ScriptCompiler::CompileModule(isolate, &source); +} - if (!maybeMod.ToLocal(&module)) { - if (tcCompile.HasCaught()) { - throw NativeScriptException(tcCompile, "Cannot compile ES module " + path); - } else { - throw NativeScriptException(string("Cannot compile ES module ") + path); - } +// Phase diagnostics for one module's trip through the loader. +static void LogEsmPhase(const std::string& canonicalPath, const char* phase, const char* status, + const char* classification = "", const char* extra = "") { + if (classification && classification[0] != '\0') { + if (extra && extra[0] != '\0') { + TNS_DEBUG(Esm, "[%s][%s][%s] %s %s", phase, status, classification, + canonicalPath.c_str(), extra); + } else { + TNS_DEBUG(Esm, "[%s][%s][%s] %s", phase, status, classification, canonicalPath.c_str()); + } + } else { + if (extra && extra[0] != '\0') { + TNS_DEBUG(Esm, "[%s][%s] %s %s", phase, status, canonicalPath.c_str(), extra); + } else { + TNS_DEBUG(Esm, "[%s][%s] %s", phase, status, canonicalPath.c_str()); } } +} - // 3) Register for resolution callback - // Safe Global handle management: Clear any existing entry first - auto it = g_moduleRegistry.find(path); - if (it != g_moduleRegistry.end()) { - // Clear the existing Global handle before replacing it - it->second.Reset(); +// A V8 module status as it appears in a trace line. +static const char* DescribeModuleStatus(Module::Status status) { + switch (status) { + case Module::kUninstantiated: + return "uninstantiated"; + case Module::kInstantiating: + return "instantiating"; + case Module::kInstantiated: + return "instantiated"; + case Module::kEvaluating: + return "evaluating"; + case Module::kEvaluated: + return "evaluated"; + case Module::kErrored: + return "errored"; } - // Now safely set the new module handle - g_moduleRegistry[path].Reset(isolate, module); + return "unknown"; +} - // 4) Instantiate (link) with ResolveModuleCallback - { - TryCatch tcLink(isolate); - bool linked = module->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false); +struct V8FailureRule { + const char* needle; + const char* label; +}; + +// What the message of a failed compile/link/evaluate says the failure was, for +// the trace line. Heuristic on purpose: V8 reports these as plain messages, so +// the rules are matched in order and the first hit wins. +static const char* ClassifyV8Failure(Isolate* isolate, TryCatch& tc, + std::initializer_list rules) { + if (!tc.HasCaught()) { + return "unknown"; + } + Local msg = tc.Message(); + if (msg.IsEmpty()) { + return "unknown"; + } + v8::String::Utf8Value text(isolate, msg->Get()); + if (*text == nullptr) { + return "unknown"; + } + std::string m(*text); + for (const V8FailureRule& rule : rules) { + if (m.find(rule.needle) != std::string::npos) { + return rule.label; + } + } + return "unknown"; +} - if (!linked) { - if (tcLink.HasCaught()) { - throw NativeScriptException(tcLink, "Cannot instantiate module " + path); - } else { - throw NativeScriptException(string("Cannot instantiate module ") + path); - } +namespace { + +// `require()` cannot wait, so an async graph is refused rather than evaluated. +// Never evicts: the module is perfectly loadable through import(). +[[noreturn]] void ThrowAsyncGraphRefusal(const std::string& canonicalPath) { + LogEsmPhase(canonicalPath, "evaluate", "refused", "async-graph"); + throw NativeScriptException("require() cannot load ES module '" + canonicalPath + + "': the module graph contains top-level await. Use import() or " + "createPumpingRequire from ns:module instead."); +} + +// The pump advances the loop with nestable tasks and microtask checkpoints, and +// V8 ignores a checkpoint while the isolate is already draining the microtask +// queue — so a graph whose top-level await resumes through a promise reaction +// could never settle from here. Refused up front, before evaluation, so the +// graph stays instantiated and import() can still load it. +[[noreturn]] void ThrowMicrotaskPumpRefusal(const std::string& canonicalPath) { + LogEsmPhase(canonicalPath, "evaluate", "refused", "microtask-context"); + throw NativeScriptException( + "createPumpingRequire cannot settle module graph '" + canonicalPath + + "' 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()."); +} + +// Evicts the module and surfaces the rejection reason. Always throws, in every +// build — the reason has to reach the boundary handler that reports it. +[[noreturn]] void ThrowModuleEvaluationRejection(Isolate* isolate, Local promise, + TryCatch& tc, + const std::string& canonicalPath) { + RemoveModuleFromRegistry(isolate, canonicalPath); + LogEsmPhase(canonicalPath, "evaluate", "promise-rejected"); + const bool traceEsm = LogCategoryEnabled(LogCategory::Esm); + RejectionDetail rejection; + std::string detail = PromiseRejectionMessage(isolate, promise, canonicalPath, + traceEsm ? &rejection : nullptr); + if (traceEsm) { + TNS_DEBUG(Esm, "[evaluate][promise-rejected:detail] path=%s message=%s stack=%s", + canonicalPath.c_str(), rejection.message.c_str(), + rejection.stackPreview.c_str()); + } + if (!tc.HasCaught()) { + Local reason = promise->Result(); + if (!reason.IsEmpty()) { + isolate->ThrowException(reason); + } + } + if (tc.HasCaught()) { + throw NativeScriptException(tc, detail); + } + throw NativeScriptException(detail); +} + +} // namespace + +MaybeLocal tns::EvaluateModuleGraph(Isolate* isolate, Local context, + Local module, + const std::string& canonicalPath, + const ModuleEvaluationOptions& options) { + if (options.policy == ModuleEvaluationPolicy::kSyncStrict) { + if (module->IsGraphAsync()) { + // Refusing before evaluation leaves the graph at kInstantiated, so a + // later import() can still evaluate it, and keeps this diagnosis ahead + // of whatever runtime error the graph would have produced first. + ThrowAsyncGraphRefusal(canonicalPath); + } + if (module->GetStatus() == Module::kEvaluating) { + // Re-entered through a cycle while the graph is still on the stack; its + // namespace holds whatever has been initialized so far. + return MaybeLocal(); + } + } + + if (options.policy == ModuleEvaluationPolicy::kSyncPumping && module->IsGraphAsync() && + v8::MicrotasksScope::IsRunningMicrotasks(isolate)) { + // Only an async graph needs the pump; a synchronous one settles on its own + // and stays legal from anywhere. Entry modules also arrive here, but from + // native at task level, so they never trip this. + ThrowMicrotaskPumpRefusal(canonicalPath); + } + + LogEsmPhase(canonicalPath, "evaluate", "begin"); + TryCatch tcEval(isolate); + Local result; + if (!module->Evaluate(context).ToLocal(&result)) { + RemoveModuleFromRegistry(isolate, canonicalPath); + LogEsmPhase(canonicalPath, "evaluate", "fail", + ClassifyV8Failure(isolate, tcEval, + {{"is not defined", "reference"}, + {"TypeError", "type"}, + {"Cannot read properties", "type-nullish"}})); + if (tcEval.HasCaught()) { + throw NativeScriptException(tcEval, "Cannot evaluate module " + canonicalPath); + } + throw NativeScriptException(string("Cannot evaluate module ") + canonicalPath); + } + LogEsmPhase(canonicalPath, "evaluate", "ok"); + + if (!result->IsPromise()) { + return MaybeLocal(); + } + LogEsmPhase(canonicalPath, "evaluate", "promise"); + Local promise = result.As(); + + if (options.policy == ModuleEvaluationPolicy::kAsync) { + return promise; + } + + TryCatch promiseTc(isolate); + + if (options.policy == ModuleEvaluationPolicy::kSyncStrict) { + Promise::PromiseState state = promise->State(); + if (state == Promise::kRejected) { + ThrowModuleEvaluationRejection(isolate, promise, promiseTc, canonicalPath); + } + if (state == Promise::kPending) { + // V8 guarantees a settled capability for a graph that reported + // !IsGraphAsync, so reaching here means the graph classification and the + // evaluation disagree — never paper over it with a half-initialized + // namespace. + throw NativeScriptException("ES module " + canonicalPath + + " left its evaluation promise pending on a graph reported " + "as synchronous"); + } + LogEsmPhase(canonicalPath, "evaluate", "promise-resolved"); + return MaybeLocal(); + } + + // Top-level await can depend on native async work: fetch completions and + // TLA continuations arrive as nestable v8 tasks, which every pump runs. + // JS timers and worker messages live outside that lane, and Java Handler + // messages cannot dispatch while these JS frames hold the thread — only a + // pumpRunLoop pump drains them directly (the iOS split: its default pump + // never slices the runloop either). Non-nestable v8 tasks stay queued in + // both modes, like the inspector pause loops. + Runtime* runtime = Runtime::TryGetRuntime(isolate); + std::shared_ptr eventLoop = runtime != nullptr ? runtime->GetEventLoop() : nullptr; + + bool settled = false; + // Probed before the first pump iteration: a synchronous graph's evaluation + // promise is already settled when Evaluate() returns, so it never pays for + // a pump slice. + const auto probe = [&]() { + if (promiseTc.HasCaught()) { + return true; + } + Promise::PromiseState state = promise->State(); + if (state == Promise::kPending) { + return false; + } + settled = true; + if (state == Promise::kRejected) { + ThrowModuleEvaluationRejection(isolate, promise, promiseTc, canonicalPath); + } + LogEsmPhase(canonicalPath, "evaluate", "promise-resolved"); + return true; + }; + + if (!probe() && eventLoop != nullptr) { + if (eventLoop->PumpUntil(options.deadlineSeconds, probe, options.pumpRunLoop) == + EventLoop::PumpResult::kTerminated) { + // terminating isolate (worker.terminate) or a stopped loop: no + // outcome to report, and no timeout to mislabel it with + return MaybeLocal(); } } - // 5) Evaluate with its own TryCatch + if (!settled && promise->State() == Promise::kPending) { + LogEsmPhase(canonicalPath, "evaluate", "promise-timeout"); + if (options.timeoutBehavior == ModuleEvaluationOptions::TimeoutBehavior::kThrow) { + RemoveModuleFromRegistry(isolate, canonicalPath); + throw NativeScriptException("Top-level await timed out for ES module " + canonicalPath); + } + } + + return MaybeLocal(); +} + +// The shared probe behind both entry-evaluation queries: a registry hit plus +// Evaluate(), which hands back the SAME capability promise rather than +// re-running anything, so it is cheap enough to call from a pump loop. +static MaybeLocal EntryEvaluationPromise(Isolate* isolate, const std::string& path) { + if (!ModuleInternal::IsESModule(path) && !IsHttpModulePath(path)) { + return MaybeLocal(); + } + auto* registryPtr = ModuleRegistryFor(isolate); + if (registryPtr == nullptr) { + return MaybeLocal(); + } + auto it = registryPtr->find(CanonicalizeRegistryKey(path)); + if (it == registryPtr->end()) { + return MaybeLocal(); + } + Local mod = it->second.Get(isolate); + // A TLA-parked module reports kEvaluated while its promise is still + // pending, so the status is the gate to *having* a promise, never to its + // state. + if (mod.IsEmpty() || mod->GetStatus() != Module::kEvaluated) { + return MaybeLocal(); + } + TryCatch tc(isolate); + Local context = isolate->GetCurrentContext(); Local result; - { - TryCatch tcEval(isolate); - if (!module->Evaluate(context).ToLocal(&result)) { - if (tcEval.HasCaught()) { - throw NativeScriptException(tcEval, "Cannot evaluate module " + path); + if (!mod->Evaluate(context).ToLocal(&result) || !result->IsPromise()) { + return MaybeLocal(); + } + return MaybeLocal(result.As()); +} + +MaybeLocal ModuleInternal::PendingEntryEvaluation(Isolate* isolate, + const std::string& path) { + Local promise; + if (!EntryEvaluationPromise(isolate, path).ToLocal(&promise)) { + return MaybeLocal(); + } + if (promise->State() != Promise::kPending) { + return MaybeLocal(); + } + return MaybeLocal(promise); +} + +EntryEvaluationState ModuleInternal::PollEntryEvaluation(Isolate* isolate, const std::string& path, + std::string* rejectionReason) { + Local promise; + if (!EntryEvaluationPromise(isolate, path).ToLocal(&promise)) { + return EntryEvaluationState::kNone; + } + switch (promise->State()) { + case Promise::kPending: + return EntryEvaluationState::kPending; + case Promise::kFulfilled: + return EntryEvaluationState::kFulfilled; + case Promise::kRejected: + break; + } + if (rejectionReason != nullptr) { + Local reason = promise->Result(); + if (reason.IsEmpty()) { + *rejectionReason = ""; + } else { + // A reason whose `toString` throws — or a Symbol, which cannot be + // stringified at all — must not leave the isolate poisoned: this runs + // from the boot pump, where the caller has no exception to observe. + TryCatch tc(isolate); + *rejectionReason = ArgConverter::ToString(isolate, reason); + if (tc.HasCaught()) { + *rejectionReason = ""; + tc.Reset(); + } + } + } + return EntryEvaluationState::kRejected; +} + +// The root entry point for an ES module graph: compile + register the root, +// then instantiate and evaluate it once. Dependencies are compiled and +// registered by ResolveModuleCallback while V8 walks the graph from here; +// nothing below the root evaluates on its own. +Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& path, + const ModuleEvaluationOptions& options) { + auto context = isolate->GetCurrentContext(); + const bool isHttpModule = IsHttpModulePath(path); + // The key the resolver would derive for this same module as someone's + // dependency. Keying the root by anything else mints a second identity for + // one file, so a cycle back to the root would not terminate on its entry. + const std::string canonicalPath = CanonicalizeRegistryKey(path); + const std::string requestPath = isHttpModule ? NormalizeHttpModuleUrl(path) : canonicalPath; + + auto logPhase = [&canonicalPath](const char* phase, const char* status, + const char* classification = "", const char* extra = "") { + LogEsmPhase(canonicalPath, phase, status, classification, extra); + }; + + Local module; + + if (isHttpModule) { + logPhase("compile", "delegate-http"); + RunModuleGraphLoadPumped(isolate, context, requestPath, kModuleEvaluateDeadlineSeconds); + if (isolate->IsExecutionTerminating()) { + // no outcome to report, and the sync loader below must not start + // a blocking fetch on a terminating isolate + return Local(); + } + // The loader throws the classifier's reason (status, MIME or + // transport); catch it so it lands in the message instead of staying + // pending on the isolate behind a C++ throw. + TryCatch tcLoad(isolate); + MaybeLocal maybeMod = LoadHttpModuleForUrl(isolate, context, requestPath); + if (!maybeMod.ToLocal(&module)) { + logPhase("compile", "fail", "http-loader"); + std::string message = "Cannot load ES module " + canonicalPath; + if (tcLoad.HasCaught()) { + throw NativeScriptException(tcLoad, message); + } + throw NativeScriptException(message); + } + logPhase("compile", "ok", "http-loader"); + if (module->GetStatus() == Module::kEvaluated) { + // A top-level-await graph reports kEvaluated while its capability + // promise is still pending, so the namespace here may be in its TDZ; + // require() refuses the graph whatever the load order, matching Node. + if (options.policy == ModuleEvaluationPolicy::kSyncStrict && module->IsGraphAsync()) { + ThrowAsyncGraphRefusal(canonicalPath); + } + return module->GetModuleNamespace(); + } + } else { + auto* registryPtr = ModuleRegistryFor(isolate); + if (registryPtr == nullptr) { + return Local(); + } + auto& registry = *registryPtr; + + auto existingIt = registry.find(canonicalPath); + if (existingIt != registry.end()) { + Local existing = existingIt->second.Get(isolate); + Module::Status status = existing.IsEmpty() ? Module::kErrored : existing->GetStatus(); + if (existing.IsEmpty()) { + TNS_DEBUG(Esm, "[cache] dropping empty registry entry %s", canonicalPath.c_str()); } else { - throw NativeScriptException(string("Cannot evaluate module ") + path); + TNS_DEBUG(Esm, "[cache] hit %s status=%s", canonicalPath.c_str(), + DescribeModuleStatus(status)); + } + if (status == Module::kErrored) { + RemoveModuleFromRegistry(isolate, canonicalPath); + } else if (status == Module::kEvaluated) { + // A top-level-await graph reports kEvaluated while its capability + // promise is still pending, so the namespace here may be in its TDZ; + // require() refuses the graph whatever the load order, matching Node. + if (options.policy == ModuleEvaluationPolicy::kSyncStrict && + existing->IsGraphAsync()) { + ThrowAsyncGraphRefusal(canonicalPath); + } + return existing->GetModuleNamespace(); + } else if (status == Module::kUninstantiated || status == Module::kInstantiated) { + // Recompiling would mint a second module identity while importers still + // hold this one; reuse it and let InstantiateModule below no-op + // (kInstantiated) or link it (kUninstantiated). + logPhase("compile", "reuse-registry"); + module = existing; } } - // Handle the case where evaluation returns a Promise (for top-level await) - if (result->IsPromise()) { - Local promise = result.As(); - - // Process microtasks to allow Promise resolution - int maxAttempts = 100; - int attempts = 0; - - while (attempts < maxAttempts) { - isolate->PerformMicrotaskCheckpoint(); - Promise::PromiseState state = promise->State(); - - if (state != Promise::kPending) { - if (state == Promise::kRejected) { - Local reason = promise->Result(); - isolate->ThrowException(reason); - throw NativeScriptException(string("Module evaluation promise rejected: ") + path); - } - break; + const bool reusedFromRegistry = !module.IsEmpty(); + + if (module.IsEmpty()) { + logPhase("compile", "begin"); + // Discovery pre-pass for local roots too: a local graph can reach HTTP + // edges, and without this they hit the resolver cold and fetch serially, + // one blocking request at a time. The walk compiles and registers the + // whole closure up front — including this root — so instantiation + // resolves as pure lookup. A graph with no HTTP edges settles inside the + // call and pays no wait. + RunModuleGraphLoadPumped(isolate, context, canonicalPath, + kModuleEvaluateDeadlineSeconds); + auto walkedIt = registry.find(canonicalPath); + if (walkedIt != registry.end()) { + Local walked = walkedIt->second.Get(isolate); + if (!walked.IsEmpty() && walked->GetStatus() != Module::kErrored) { + module = walked; + } + } + } + + if (module.IsEmpty()) { + TryCatch tcCompile(isolate); + if (!CompileFileEsModule(isolate, canonicalPath).ToLocal(&module)) { + logPhase("compile", "fail", + ClassifyV8Failure( + isolate, tcCompile, + {{"Unexpected token", "syntax"}, + {"SyntaxError", "syntax"}, + {"Cannot use import statement outside a module", "not-a-module"}})); + if (tcCompile.HasCaught()) { + throw NativeScriptException(tcCompile, "Cannot compile ES module " + canonicalPath); + } else { + throw NativeScriptException(string("Cannot compile ES module ") + canonicalPath); } + } - attempts++; - usleep(100); // 0.1ms delay + UnindexModuleForIsolate(isolate, canonicalPath); + auto it = registry.find(canonicalPath); + if (requestPath != canonicalPath || path != canonicalPath) { + TNS_DEBUG(Esm, "[register] raw=%s request=%s canonical=%s existing=%s", path.c_str(), + requestPath.c_str(), canonicalPath.c_str(), + it != registry.end() ? "yes" : "no"); + } + if (it != registry.end()) { + it->second.Reset(); } + registry[canonicalPath].Reset(isolate, module); + IndexModuleForIsolate(isolate, canonicalPath, module); + } + if (!reusedFromRegistry) { + logPhase("compile", "ok"); } } - // 6) Return the namespace + // Instantiate (link) with ResolveModuleCallback + if (module->GetStatus() < Module::kInstantiated) { + logPhase("instantiate", "begin"); + TryCatch tcLink(isolate); + bool linked = module->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false); + + if (!linked) { + logPhase("instantiate", "fail", + ClassifyV8Failure( + isolate, tcLink, + {{"Cannot find module", "resolve"}, + {"failed to resolve module specifier", "resolve"}, + {"does not provide an export named", "link-export"}})); + if (tcLink.HasCaught()) { + throw NativeScriptException(tcLink, "Cannot instantiate module " + canonicalPath); + } else { + throw NativeScriptException(string("Cannot instantiate module ") + canonicalPath); + } + } + logPhase("instantiate", "ok"); + } + + // Evaluate the graph under the caller's options. + EvaluateModuleGraph(isolate, context, module, canonicalPath, options); + return module->GetModuleNamespace(); } Local ModuleInternal::WrapModuleContent(const string& path) { TNSPERF(); - string content = Runtime::GetRuntime(m_isolate)->ReadFileText(path); + Runtime* runtime = Runtime::TryGetRuntime(m_isolate); + if (runtime == nullptr) { + throw NativeScriptException("Cannot read module " + path + + ": the isolate has no runtime"); + } + string content = runtime->ReadFileText(path); // TODO: Use statically allocated buffer for better performance string result(MODULE_PROLOGUE); diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.h b/test-app/runtime/src/main/cpp/ModuleInternal.h index def3e5d9b..5916d76b7 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.h +++ b/test-app/runtime/src/main/cpp/ModuleInternal.h @@ -15,6 +15,57 @@ #include namespace tns { + +// The single deadline for every module-graph settle wait: the entry +// top-level-await pump in LoadESModule, the pumped module-graph walk, and +// (doubled, as the outermost backstop) the app-boot handoff in Runtime.cpp. +// One knob, so the waits stay ordered: transport timeouts < this < the boot +// backstop. +inline constexpr double kModuleEvaluateDeadlineSeconds = 60.0; + +// How a module graph's evaluation promise is settled. +// kSyncStrict - Node's `require(esm)`: an async graph is refused before it +// ever evaluates, and the capability promise must already be +// settled when Evaluate() returns. +// kSyncPumping - drive this thread in place until the promise settles or the +// window closes: nestable V8 tasks, microtask checkpoints, +// and due ordered-lane work (JS timers). Non-nestable tasks +// stay queued, as in the inspector pause loops. +// kAsync - evaluate and hand the caller the capability promise. +enum class ModuleEvaluationPolicy { kSyncStrict, kSyncPumping, kAsync }; + +// The state of an entry module's evaluation promise. kNone covers everything +// that has no promise to report on: a path naming no registered ES module (a +// classic script settles synchronously and never has one, so it needs no boot +// backstop), a torn-down isolate with no registry left, a module that has not +// reached kEvaluated, and an Evaluate() that failed or returned a non-promise. +enum class EntryEvaluationState { kNone, kPending, kFulfilled, kRejected }; + +struct ModuleEvaluationOptions { + enum class TimeoutBehavior { kReturnPending, kThrow }; + + ModuleEvaluationPolicy policy = ModuleEvaluationPolicy::kSyncStrict; + // kSyncPumping only: how long the graph gets to settle in-pump. + double deadlineSeconds = 0.0; + // kSyncPumping only: what an expired window means. + TimeoutBehavior timeoutBehavior = TimeoutBehavior::kReturnPending; + // kSyncPumping only. Contract surface (createPumpingRequire validates and + // carries it); on Android the pump always drains this loop's own lanes — + // internal v8 tasks and due JS timers — and never re-enters the platform + // looper, so the option adds nothing here. + bool pumpRunLoop = false; +}; + +// Evaluates an instantiated graph under `options`. Returns the capability +// promise for kAsync and an empty handle otherwise; the namespace always comes +// from the module itself. Throws NativeScriptException on failure, in every +// build. `canonicalPath` names the registry entry to evict on failure. +v8::MaybeLocal EvaluateModuleGraph(v8::Isolate* isolate, + v8::Local context, + v8::Local module, + const std::string& canonicalPath, + const ModuleEvaluationOptions& options); + class ModuleInternal { public: ModuleInternal(); @@ -32,15 +83,65 @@ class ModuleInternal { void LoadWorker(v8::Local context, const std::string& path); /* - * Checks if target script exists, will throw if negative - * Used before initializing workers, to ensure a thread will not be created, when the file doesn't exist + * Resolves `path` against `baseDir` through the Java module resolver and returns the + * canonical resolved path - extension (.js then .mjs) and directory/index resolution + * included. Throws when nothing resolves. + * Used before initializing workers, both to ensure a thread will not be created when the + * file doesn't exist and to hand the worker the very file that was validated here. */ - static void CheckFileExists(v8::Isolate* isolate, const std::string& path, const std::string& baseDir); + static std::string CheckFileExists(v8::Isolate* isolate, const std::string& path, const std::string& baseDir); // Helper functions for ES module support - static bool IsLikelyOptionalModule(const std::string& moduleName); static bool IsESModule(const std::string& path); - static v8::Local LoadESModule(v8::Isolate* isolate, const std::string& path); + + /* + * Compile/link/evaluate an ES module; returns its namespace object. `options` + * decide how the graph's evaluation promise is settled — see + * ModuleEvaluationPolicy. + */ + static v8::Local LoadESModule(v8::Isolate* isolate, const std::string& path, + const ModuleEvaluationOptions& options); + + /* + * Installs `createRequire` on the `ns:module` binding object. Kept here rather + * than with the dev-loader members because it hands out the very require the + * CommonJS loader builds for every module. + */ + static bool InstallCreateRequireBinding(v8::Local context, + v8::Local binding); + + /* + * Read + compile `path` as an ES module WITHOUT registering, instantiating or + * evaluating it. On compile failure the exception is left pending on the isolate + * and the result is empty; a NativeScriptException is thrown instead when the + * file does not exist, cannot be read, or the compile could not be set up. + * This is the resolver's file loader: the resolver must only ever hand V8 a + * compiled module — evaluation order belongs to V8. + */ + static v8::MaybeLocal CompileFileEsModule(v8::Isolate* isolate, const std::string& path); + + /* + * The entry module's still-pending evaluation promise, or empty when + * evaluation has settled (classic scripts settle synchronously and always + * return empty). Callers use this after the entry load to observe a + * top-level await that outlived the settle window. Note a TLA-parked module + * reports kEvaluated while its capability promise is still pending, so this + * probes the promise (Evaluate() hands back the same capability), not the + * status enum. + */ + static v8::MaybeLocal PendingEntryEvaluation(v8::Isolate* isolate, + const std::string& path); + + /* + * The same probe, but reporting the promise's state rather than only + * "pending or not" — the boot backstop must tell a rejection from a + * successful settle. Cheap enough to call once per pump slice: a registry + * hit plus Evaluate(), which returns the existing capability promise. + * `rejectionReason` (when non-null) receives the reason's text on kRejected. + */ + static EntryEvaluationState PollEntryEvaluation(v8::Isolate* isolate, + const std::string& path, + std::string* rejectionReason); static int MODULE_PROLOGUE_LENGTH; private: @@ -63,23 +164,53 @@ class ModuleInternal { v8::Persistent* obj; }; + /* + * The require the require factory is handed. It honours the evaluation + * options the factory forwards as trailing arguments, so it must never be + * reachable from app code — see RequirePublicCallback. + */ static void RequireCallback(const v8::FunctionCallbackInfo& args); + /* + * The require installed on the global. Reads the specifier and the calling + * directory only, and always evaluates under the strict defaults. + */ + static void RequirePublicCallback(const v8::FunctionCallbackInfo& args); + + // Argument validation and the C++/V8 exception boundary shared by both. + static void DispatchRequire(const v8::FunctionCallbackInfo& args, + bool honorEvaluationOptions); + static void RequireNativeCallback(const v8::FunctionCallbackInfo& args); - void RequireCallbackImpl(const v8::FunctionCallbackInfo& args); + static void CreateRequireCallback(const v8::FunctionCallbackInfo& args); + + void RequireCallbackImpl(const v8::FunctionCallbackInfo& args, + bool honorEvaluationOptions); v8::Local WrapModuleContent(const std::string& path); - v8::Local LoadImpl(v8::Isolate* isolate, const std::string& moduleName, const std::string& baseDir, bool& isData); + v8::Local LoadImpl(v8::Isolate* isolate, const std::string& moduleName, + const std::string& baseDir, bool& isData, + const ModuleEvaluationOptions& options); - v8::Local LoadModule(v8::Isolate* isolate, const std::string& path, const std::string& moduleCacheKey); + v8::Local LoadModule(v8::Isolate* isolate, const std::string& path, + const std::string& moduleCacheKey, + const ModuleEvaluationOptions& options); v8::Local LoadData(v8::Isolate* isolate, const std::string& path); v8::Local LoadScript(v8::Isolate* isolate, const std::string& modulePath, const v8::Local& fullRequiredModulePath); - v8::Local GetRequireFunction(v8::Isolate* isolate, const std::string& dirName); + /* + * A require bound to `dirName`, whose ES module loads evaluate under `options`. + * The options ride along as trailing arguments to the require factory, so + * nothing about them is ambient — and they are resolved once at mint time, + * never per require() call. + */ + v8::Local GetRequireFunction(v8::Isolate* isolate, + const std::string& dirName, + const ModuleEvaluationOptions& options); v8::ScriptCompiler::CachedData* TryLoadScriptCache(const std::string& path); diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 828fc0c9a..63bfd51b5 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -1,1069 +1,3415 @@ -#include "ModuleInternal.h" -#include "ArgConverter.h" -#include "NativeScriptException.h" -#include "NativeScriptAssert.h" -#include "NsBuiltinModules.h" -#include "Runtime.h" -#include "Util.h" +// ModuleInternalCallbacks.cpp +#include "ModuleInternalCallbacks.h" + +#include #include -#include -#include +#include + #include #include +#include +#include #include -#include "HMRSupport.h" -#include "DevFlags.h" +#include +#include +#include +#include +#include + +#include "ArgConverter.h" +#include "EventLoop.h" +#include "HttpLoader.h" #include "JEnv.h" +#include "ModuleInternal.h" +#include "NativeScriptAssert.h" +#include "NativeScriptException.h" +#include "NativeScriptPlatform.h" +#include "NsBuiltinModules.h" +#include "Runtime.h" +#include "RuntimeState.h" +#include "TraceLog.h" +#include "robin_hood.h" using namespace v8; using namespace std; using namespace tns; -// External global module registry declared in ModuleInternal.cpp -extern std::unordered_map> g_moduleRegistry; +namespace tns { -// Forward declaration used by logging helper -std::string GetApplicationPath(); +// ───────────────────────────────────────────────────────────── +// Small string helpers (kept file-local — used everywhere below). +static inline bool StartsWith(const std::string& s, const char* prefix) { + size_t n = strlen(prefix); + return s.size() >= n && s.compare(0, n, prefix) == 0; +} -// Diagnostic helper: emit detailed V8 compile error info for HTTP ESM sources. -static void LogHttpCompileDiagnostics(v8::Isolate* isolate, - v8::Local context, - const std::string& url, - const std::string& code, - v8::TryCatch& tc) { - if (!IsScriptLoadingLogEnabled()) { - return; - } - using namespace v8; - - const char* classification = "unknown"; - std::string msgStr; - std::string srcLineStr; - int lineNum = 0; - int startCol = 0; - int endCol = 0; - - Local message = tc.Message(); - if (!message.IsEmpty()) { - String::Utf8Value m8(isolate, message->Get()); - if (*m8) msgStr = *m8; - lineNum = message->GetLineNumber(context).FromMaybe(0); - startCol = message->GetStartColumn(); - endCol = message->GetEndColumn(); - MaybeLocal maybeLine = message->GetSourceLine(context); - if (!maybeLine.IsEmpty()) { - String::Utf8Value l8(isolate, maybeLine.ToLocalChecked()); - if (*l8) srcLineStr = *l8; - } - // Heuristics similar to iOS for quick triage - if (msgStr.find("Unexpected identifier") != std::string::npos || - msgStr.find("Unexpected token") != std::string::npos) { - if (msgStr.find("export") != std::string::npos && - code.find("export default") == std::string::npos && - code.find("__sfc__") != std::string::npos) { - classification = "missing-export-default"; - } else { - classification = "syntax"; - } - } else if (msgStr.find("Cannot use import statement") != std::string::npos) { - classification = "wrap-error"; - } - } - if (strcmp(classification, "unknown") == 0) { - if (code.find("export default") == std::string::npos && code.find("__sfc__") != std::string::npos) classification = "missing-export-default"; - else if (code.find("__sfc__") != std::string::npos && code.find("export {") == std::string::npos && code.find("export ") == std::string::npos) classification = "no-exports"; - else if (code.find("import ") == std::string::npos && code.find("export ") == std::string::npos) classification = "not-module"; - else if (code.find("_openBlock") != std::string::npos && code.find("openBlock") == std::string::npos) classification = "underscore-helper-unmapped"; - } +static inline bool EndsWith(const std::string& value, const std::string& suffix) { + if (suffix.size() > value.size()) return false; + return std::equal(suffix.rbegin(), suffix.rend(), value.rbegin()); +} - // FNV-1a 64-bit hash of source for correlation - unsigned long long h = 1469598103934665603ull; // offset basis - for (unsigned char c : code) { h ^= c; h *= 1099511628211ull; } +// Filesystem: `path` names an existing regular file. +static bool IsFile(const std::string& path) { + struct stat st; + if (stat(path.c_str(), &st) != 0) { + return false; + } + return (st.st_mode & S_IFMT) == S_IFREG; +} - // Trim the snippet for readability - std::string snippet = code.substr(0, 600); - for (char& ch : snippet) { if (ch == '\n' || ch == '\r') ch = ' '; } - if (srcLineStr.size() > 240) srcLineStr = srcLineStr.substr(0, 240); +// Append `ext` if `path` doesn't already carry it. +static std::string WithExtension(const std::string& path, const std::string& ext) { + if (path.size() >= ext.size() && + path.compare(path.size() - ext.size(), ext.size(), ext) == 0) { + return path; + } + return path + ext; +} - DEBUG_WRITE("[http-esm][compile][v8-error][%s] %s line=%d col=%d..%d hash=%llx bytes=%lu msg=%s srcLine=%s snippet=%s", - classification, - url.c_str(), - lineNum, - startCol, - endCol, - (unsigned long long)h, - (unsigned long)code.size(), - msgStr.c_str(), - srcLineStr.c_str(), - snippet.c_str()); +// Application filesystem root for on-disk .mjs/.js resolution. +// Mirrors Module.java's getApplicationFilesPath + "/app". Cached after first +// JNI call — the value is process-stable, and re-entering JNI on every +// resolver hit would add avoidable overhead to hot module-graph walks. +static std::string GetApplicationPath() { + static std::string cached; + static std::once_flag flag; + std::call_once(flag, []() { + JEnv env; + jstring applicationFilesPath = (jstring)env.CallStaticObjectMethod( + ModuleInternal::MODULE_CLASS, + ModuleInternal::GET_APPLICATION_FILES_PATH_METHOD_ID); + if (applicationFilesPath != nullptr) { + cached = ArgConverter::jstringToString(applicationFilesPath) + "/app"; + } + }); + return cached; } -// Helper: collapse "." and ".." path segments, preserving a leading "/". +// Collapse "." and ".." segments, preserving a leading "/". static std::string NormalizeDotSegments(const std::string& path) { - std::vector stack; - bool absolute = !path.empty() && path[0] == '/'; - size_t i = 0; - while (i <= path.size()) { - size_t j = path.find('/', i); - std::string seg = (j == std::string::npos) ? path.substr(i) : path.substr(i, j - i); - if (seg.empty() || seg == ".") { - // skip - } else if (seg == "..") { - if (!stack.empty()) stack.pop_back(); - } else { - stack.push_back(seg); - } - if (j == std::string::npos) break; - i = j + 1; - } - std::string norm = absolute ? "/" : std::string(); - for (size_t k = 0; k < stack.size(); k++) { - if (k > 0) norm += "/"; - norm += stack[k]; - } - return norm; -} - -// Helper: resolve relative or root-absolute spec against an HTTP(S) referrer URL. -// Returns empty string if resolution is not possible. -static std::string ResolveHttpRelative(const std::string& referrerUrl, const std::string& spec) { - if (referrerUrl.empty()) { - return std::string(); - } - auto startsWith = [](const std::string& s, const char* pre) -> bool { - size_t n = strlen(pre); - return s.size() >= n && s.compare(0, n, pre) == 0; - }; - if (!(startsWith(referrerUrl, "http://") || startsWith(referrerUrl, "https://"))) { - return std::string(); - } - // Normalize referrer: drop fragment and query - std::string base = referrerUrl; - size_t hashPos = base.find('#'); - if (hashPos != std::string::npos) base = base.substr(0, hashPos); - size_t qPos = base.find('?'); - if (qPos != std::string::npos) base = base.substr(0, qPos); - - // Extract origin and path - size_t schemePos = base.find("://"); - if (schemePos == std::string::npos) { - return std::string(); - } - size_t pathStart = base.find('/', schemePos + 3); - std::string origin = (pathStart == std::string::npos) ? base : base.substr(0, pathStart); - std::string path = (pathStart == std::string::npos) ? std::string("/") : base.substr(pathStart); - - // Separate query/fragment from spec - std::string specPath = spec; - std::string specSuffix; - size_t specQ = specPath.find('?'); - size_t specH = specPath.find('#'); - size_t cut = std::string::npos; - if (specQ != std::string::npos && specH != std::string::npos) { - cut = std::min(specQ, specH); - } else if (specQ != std::string::npos) { - cut = specQ; - } else if (specH != std::string::npos) { - cut = specH; - } - if (cut != std::string::npos) { - specSuffix = specPath.substr(cut); - specPath = specPath.substr(0, cut); - } - - // Build new path - std::string newPath; - if (!specPath.empty() && specPath[0] == '/') { - // Root-absolute relative to origin - newPath = specPath; - } else { - // Relative to directory of referrer path - size_t lastSlash = path.find_last_of('/'); - std::string baseDir = (lastSlash == std::string::npos) ? std::string("/") : path.substr(0, lastSlash + 1); - newPath = baseDir + specPath; - } - - // Normalize "." and ".." segments - std::string normPath = NormalizeDotSegments(newPath); - return origin + normPath + specSuffix; -} - -// Helper: resolve a relative "./" or "../" specifier against a file:// referrer -// URL, returning an absolute file:// URL. Returns empty if not applicable. -static std::string ResolveFileRelative(const std::string& referrerUrl, const std::string& spec) { - const std::string filePrefix = "file://"; - if (referrerUrl.rfind(filePrefix, 0) != 0) { - return std::string(); - } - if (spec.empty() || spec[0] != '.') { - return std::string(); - } - // Referrer path: strip scheme, drop query and fragment - std::string refPath = referrerUrl.substr(filePrefix.size()); - size_t hashPos = refPath.find('#'); - if (hashPos != std::string::npos) refPath = refPath.substr(0, hashPos); - size_t qPos = refPath.find('?'); - if (qPos != std::string::npos) refPath = refPath.substr(0, qPos); - - size_t lastSlash = refPath.find_last_of('/'); - std::string baseDir = (lastSlash == std::string::npos) ? std::string("/") : refPath.substr(0, lastSlash + 1); - return filePrefix + NormalizeDotSegments(baseDir + spec); -} - -// Import meta callback to support import.meta.url and import.meta.dirname -void InitializeImportMetaObject(Local context, Local module, Local meta) { - Isolate* isolate = v8::Isolate::GetCurrent(); - - // Look up the module path in the global module registry (with safety checks) - std::string modulePath; - - try { - for (auto& kv : g_moduleRegistry) { - // Check if Global handle is empty before accessing - if (kv.second.IsEmpty()) { - continue; - } - - Local registered = kv.second.Get(isolate); - if (!registered.IsEmpty() && registered == module) { - modulePath = kv.first; - break; - } - } - } catch (...) { - DEBUG_WRITE("InitializeImportMetaObject: Exception during module registry lookup, using fallback"); - modulePath = ""; // Will use fallback path - } - - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("InitializeImportMetaObject: Module lookup: found path = %s", - modulePath.empty() ? "(empty)" : modulePath.c_str()); - DEBUG_WRITE("InitializeImportMetaObject: Registry size: %zu", g_moduleRegistry.size()); - } - - // Convert to URL for import.meta.url; keep http(s) untouched, file paths with file:// - std::string moduleUrl; - if (!modulePath.empty()) { - if (modulePath.rfind("http://", 0) == 0 || modulePath.rfind("https://", 0) == 0) { - moduleUrl = modulePath; - } else { - moduleUrl = "file://" + modulePath; - } + std::vector stack; + bool absolute = !path.empty() && path[0] == '/'; + size_t i = 0; + while (i <= path.size()) { + size_t j = path.find('/', i); + std::string seg = (j == std::string::npos) ? path.substr(i) : path.substr(i, j - i); + if (seg.empty() || seg == ".") { + // skip + } else if (seg == "..") { + if (!stack.empty()) stack.pop_back(); } else { - // Fallback URL if module not found in registry - moduleUrl = "file:///android_asset/app/"; - } - - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("InitializeImportMetaObject: Final URL: %s", moduleUrl.c_str()); - } - - Local url = ArgConverter::ConvertToV8String(isolate, moduleUrl); - - // Set import.meta.url property - meta->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "url"), url).Check(); - - // Add import.meta.dirname support (extract directory) - std::string dirname; - if (!modulePath.empty()) { - if (modulePath.rfind("http://", 0) == 0 || modulePath.rfind("https://", 0) == 0) { - // For URLs, compute dirname by trimming after last '/' - size_t q = modulePath.find('?'); - std::string noQuery = (q == std::string::npos) ? modulePath : modulePath.substr(0, q); - size_t lastSlash = noQuery.find_last_of('/'); - dirname = (lastSlash == std::string::npos) ? modulePath : noQuery.substr(0, lastSlash); - } else { - size_t lastSlash = modulePath.find_last_of("/\\"); - if (lastSlash != std::string::npos) { - dirname = modulePath.substr(0, lastSlash); - } else { - dirname = "/android_asset/app"; // fallback - } - } + stack.push_back(std::move(seg)); + } + if (j == std::string::npos) break; + i = j + 1; + } + std::string norm = absolute ? "/" : std::string(); + for (size_t k = 0; k < stack.size(); k++) { + if (k > 0) norm += "/"; + norm += stack[k]; + } + return norm; +} + +// Normalize a filesystem path: collapse duplicate slashes, "./" and "../" +// segments. Same intent as iOS's `stringByStandardizingPath`, minus the +// Foundation dependency (no HOME expansion, which we never used anyway). +static std::string NormalizePath(const std::string& path) { + if (path.empty()) return path; + return NormalizeDotSegments(path); +} + +// Convert a file:// URL to a filesystem path. Handles both file:///a/b and +// file:/a/b variants. Percent-decoding is deliberately omitted — the runtime +// only emits ASCII file:// URLs internally. +static std::string FileURLToPath(const std::string& url) { + if (url.empty()) return url; + if (!StartsWith(url, "file://")) return url; + std::string tail = url.substr(7); + // Strip host component when present (file://host/path → /path). NS never + // emits a host, but be tolerant. + if (!tail.empty() && tail[0] != '/') { + size_t slash = tail.find('/'); + tail = (slash == std::string::npos) ? std::string() : tail.substr(slash); + } + // Drop query and fragment — these have no meaning for filesystem paths. + size_t cut = tail.find_first_of("?#"); + if (cut != std::string::npos) tail = tail.substr(0, cut); + return NormalizePath(tail); +} + +// Resolve a relative or root-absolute spec against an HTTP(S) referrer URL. +// Returns empty string if resolution is not applicable. +static std::string ResolveHttpRelative(const std::string& referrerUrl, + const std::string& spec) { + if (referrerUrl.empty()) return std::string(); + if (!(StartsWith(referrerUrl, "http://") || StartsWith(referrerUrl, "https://"))) { + return std::string(); + } + // Normalize referrer: drop fragment and query. + std::string base = referrerUrl; + size_t hashPos = base.find('#'); + if (hashPos != std::string::npos) base = base.substr(0, hashPos); + size_t qPos = base.find('?'); + if (qPos != std::string::npos) base = base.substr(0, qPos); + + size_t schemePos = base.find("://"); + if (schemePos == std::string::npos) return std::string(); + size_t pathStart = base.find('/', schemePos + 3); + std::string origin = (pathStart == std::string::npos) ? base : base.substr(0, pathStart); + std::string path = (pathStart == std::string::npos) ? std::string("/") + : base.substr(pathStart); + + std::string specPath = spec; + std::string specSuffix; + size_t specQ = specPath.find('?'); + size_t specH = specPath.find('#'); + size_t cut = std::string::npos; + if (specQ != std::string::npos && specH != std::string::npos) { + cut = std::min(specQ, specH); + } else if (specQ != std::string::npos) { + cut = specQ; + } else if (specH != std::string::npos) { + cut = specH; + } + if (cut != std::string::npos) { + specSuffix = specPath.substr(cut); + specPath = specPath.substr(0, cut); + } + + std::string newPath; + if (!specPath.empty() && specPath[0] == '/') { + newPath = specPath; + } else { + size_t lastSlash = path.find_last_of('/'); + std::string baseDir = (lastSlash == std::string::npos) + ? std::string("/") + : path.substr(0, lastSlash + 1); + newPath = baseDir + specPath; + } + return origin + NormalizeDotSegments(newPath) + specSuffix; +} + +// Forward declarations for helpers referenced before their definitions. +static const char* ModuleStatusToString(v8::Module::Status status); +static void KillAsyncGraphLoadsForIsolate(v8::Isolate* isolate); +static v8::MaybeLocal CompileJsonTextAsEsModule( + v8::Isolate* isolate, v8::Local context, + const std::string& jsonText, const std::string& registryAbsPath, + const std::string& displayUrl); +static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey); + +namespace { +struct AsyncGraphLoad; + +// One require(esm) exports facade and the module it wraps. Held as a pair +// because identity hashes collide: lookups compare the target handle. +struct RequireFacadeEntry { + v8::Global target; + v8::Global facade; +}; + +// ───────────────────────────────────────────────────────────── +// Per-isolate module-loader state +// +// Why per-isolate (not process-global, not thread_local): v8::Global +// handles are bound to the isolate that created them; reading their internal +// state from a different isolate is undefined behaviour. NS Workers each run +// a separate v8::Isolate on their own thread and, under HMR, may fetch the +// same URLs the main thread already loaded — a shared map would hand the +// worker isolate a Module the main isolate compiled, and V8's linker would +// read the cross-isolate export table and emit bogus errors like: +// SyntaxError: The requested module 'X' does not provide an export named 'Y' +// +// Lifetime: the state lives in a RuntimeState slot, so it is destroyed with +// the runtime (Runtime::DestroyRuntime → RuntimeState::Clear), on the +// runtime's own thread while the isolate is still alive — which lets the +// v8::Global members Reset safely in their own destructors and leaves nothing +// to static/thread destructors, where a post-disposal Reset would crash. +// Access from the isolate's own thread only, per the slot contract. +struct ModuleLoaderState { + ModuleHandleMap registry; // canonical key -> compiled module + + // What the dev client taught THIS isolate's loader: import map, + // canonicalization vocabulary, volatile patterns. + LoaderVocabulary vocabulary; + + // In-flight async graph walks; entries are weak so a finished load frees + // itself. A pending background fetch completion can hold a load's + // shared_ptr past teardown, so QuiesceModuleLoadsForIsolate must flag these + // dead and Reset their context Globals while the isolate is still alive — + // the slot destructor alone is not enough for them. + std::vector> asyncGraphLoads; + + // HTTP dynamic imports currently fetching/evaluating, for coalescing. + robin_hood::unordered_set modulesInFlight; + + // Dynamic HTTP import waiters: resolve to the module namespace. + robin_hood::unordered_map>> + httpDynamicWaiters; + + // Reverse index: v8::Module::GetIdentityHash() -> registry keys, so + // module→key lookups (resolver referrer discovery, import.meta) are O(1) + // instead of a scan of the whole registry. Hashes collide, so a bucket holds + // candidates; FindKeyForModule confirms each against the registry and prunes + // the ones it no longer backs, so a stale candidate can never answer a + // lookup. + robin_hood::unordered_map> keysByModuleHash; + + // require(esm) facades, keyed by the TARGET module's identity hash — same + // bucket-plus-handle-compare shape as keysByModuleHash. Repeated require() of + // one ES module must hand back the identical exports object, and a facade + // must never outlive the module it re-exports (UnindexRegistryKey drops the + // entry as the target stops being the registry's answer for its key). + robin_hood::unordered_map> + requireFacadesByTargetHash; + + // Holds the facade target across that facade's InstantiateModule and nothing + // else — the facade's resolve callback is the only reader. + v8::Global pendingFacadeTarget; +}; + +// This isolate's loader state, or null once teardown has begun — callers must +// bail, not recreate state. +ModuleLoaderState* ModuleLoaderStateFor(v8::Isolate* isolate) { + if (isolate == nullptr) return nullptr; + return RuntimeState::For(isolate); +} + +// Record `key` as a candidate for `mod`'s identity hash. Call alongside every +// registry insert. +void IndexRegisteredModule(ModuleLoaderState& state, const std::string& key, + v8::Local mod) { + if (mod.IsEmpty()) return; + auto& keys = state.keysByModuleHash[mod->GetIdentityHash()]; + if (std::find(keys.begin(), keys.end(), key) == keys.end()) { + keys.push_back(key); + } +} + +// Drop any facade wrapping `target`. Called as the target stops being the +// registry's answer for its key: a facade whose re-export source is gone would +// serve a dead namespace. +void DropRequireFacadesForTarget(ModuleLoaderState& state, v8::Isolate* isolate, + v8::Local target) { + if (target.IsEmpty()) return; + auto bucketIt = state.requireFacadesByTargetHash.find(target->GetIdentityHash()); + if (bucketIt == state.requireFacadesByTargetHash.end()) return; + auto& entries = bucketIt->second; + for (auto it = entries.begin(); it != entries.end();) { + if (it->target.Get(isolate) == target) { + it = entries.erase(it); } else { - dirname = "/android_asset/app"; // fallback + ++it; } - - Local dirnameStr = ArgConverter::ConvertToV8String(isolate, dirname); - - // Set import.meta.dirname property - meta->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "dirname"), dirnameStr).Check(); + } + if (entries.empty()) { + state.requireFacadesByTargetHash.erase(bucketIt); + } +} - // Attach import.meta.hot for HMR - tns::InitializeImportMetaHot(isolate, context, meta, modulePath); +// Drop `key` from the bucket of whatever module the registry holds under it +// right now. Call before replacing or erasing that entry, while the outgoing +// handle is still reachable — afterwards its hash is unrecoverable. +void UnindexRegistryKey(ModuleLoaderState& state, v8::Isolate* isolate, + const std::string& key) { + auto regIt = state.registry.find(key); + if (regIt == state.registry.end() || regIt->second.IsEmpty()) return; + v8::Local outgoing = regIt->second.Get(isolate); + if (outgoing.IsEmpty()) return; + DropRequireFacadesForTarget(state, isolate, outgoing); + auto bucketIt = state.keysByModuleHash.find(outgoing->GetIdentityHash()); + if (bucketIt == state.keysByModuleHash.end()) return; + auto& keys = bucketIt->second; + keys.erase(std::remove(keys.begin(), keys.end(), key), keys.end()); + if (keys.empty()) { + state.keysByModuleHash.erase(bucketIt); + } } -// Helper function to check if a file exists and is a regular file -bool IsFile(const std::string& path) { - struct stat st; - if (stat(path.c_str(), &st) != 0) { - return false; +// The registry key whose live entry is `mod`, or empty. Prunes candidates the +// registry no longer confirms. +std::string FindKeyForModule(ModuleLoaderState& state, v8::Isolate* isolate, + v8::Local mod) { + if (mod.IsEmpty()) return std::string(); + auto bucketIt = state.keysByModuleHash.find(mod->GetIdentityHash()); + if (bucketIt == state.keysByModuleHash.end()) return std::string(); + auto& keys = bucketIt->second; + for (auto it = keys.begin(); it != keys.end();) { + auto regIt = state.registry.find(*it); + if (regIt == state.registry.end() || regIt->second.IsEmpty()) { + it = keys.erase(it); + continue; + } + if (regIt->second.Get(isolate) == mod) { + return *it; } - return (st.st_mode & S_IFMT) == S_IFREG; + ++it; + } + if (keys.empty()) { + state.keysByModuleHash.erase(bucketIt); + } + return std::string(); } +} // namespace -// Helper function to add extension if missing -std::string WithExtension(const std::string& path, const std::string& ext) { - if (path.size() >= ext.size() && path.compare(path.size() - ext.size(), ext.size(), ext) == 0) { - return path; +std::string LookupModuleKeyForModule(v8::Isolate* isolate, + v8::Local mod) { + auto* state = ModuleLoaderStateFor(isolate); + if (state == nullptr) return std::string(); + return FindKeyForModule(*state, isolate, mod); +} + +namespace { +// The single module request in the facade source, and the source itself. Both +// match Node's required_module_facade_source_string so the semantics (live +// bindings, enumerable re-exports, overridable __esModule) stay identical. +constexpr const char* kRequireFacadeSpecifier = "original"; +constexpr const char* kRequireFacadeSource = + "export * from 'original'; export { default } from 'original'; " + "export const __esModule = true;"; + +// Resolves the facade's one request. Passed only to a facade's +// InstantiateModule, so the general resolver never sees 'original' and user +// code can never reach this slot. +v8::MaybeLocal ResolveRequireFacadeTarget( + v8::Local context, v8::Local specifier, + v8::Local /*import_assertions*/, + v8::Local /*referrer*/) { + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + auto* state = ModuleLoaderStateFor(isolate); + v8::String::Utf8Value specUtf8(isolate, specifier); + const std::string spec = *specUtf8 ? *specUtf8 : ""; + if (state == nullptr || state->pendingFacadeTarget.IsEmpty() || + spec != kRequireFacadeSpecifier) { + DEBUG_WRITE_FORCE("FATAL: require(esm) facade resolve for '%s' with no pending target", + spec.c_str()); + isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "require(esm) facade could not be linked to its target module"))); + return v8::MaybeLocal(); + } + return v8::MaybeLocal(state->pendingFacadeTarget.Get(isolate)); +} +} // namespace + +v8::MaybeLocal GetOrCreateRequireFacade( + v8::Isolate* isolate, v8::Local context, + v8::Local target, const std::string& targetCanonicalPath) { + if (target.IsEmpty()) return v8::MaybeLocal(); + auto* state = ModuleLoaderStateFor(isolate); + if (state == nullptr) return v8::MaybeLocal(); + + auto bucketIt = state->requireFacadesByTargetHash.find(target->GetIdentityHash()); + if (bucketIt != state->requireFacadesByTargetHash.end()) { + for (auto& entry : bucketIt->second) { + if (entry.target.Get(isolate) == target) { + return v8::MaybeLocal(entry.facade.Get(isolate)); + } } - return path + ext; + } + + v8::EscapableHandleScope hs(isolate); + const std::string facadeUrl = "ns:require-facade:" + targetCanonicalPath; + + v8::Local urlV8; + if (!v8::String::NewFromUtf8(isolate, facadeUrl.c_str(), v8::NewStringType::kNormal) + .ToLocal(&urlV8)) { + return v8::MaybeLocal(); + } + v8::ScriptOrigin origin(urlV8, 0, 0, false, -1, v8::Local(), false, + false, true /* is_module */); + v8::ScriptCompiler::Source source( + ArgConverter::ConvertToV8String(isolate, kRequireFacadeSource), origin); + + v8::TryCatch tc(isolate); + v8::Local facade; + if (!v8::ScriptCompiler::CompileModule(isolate, &source).ToLocal(&facade)) { + throw NativeScriptException( + tc, "Cannot compile the require() facade for " + targetCanonicalPath); + } + + bool linked = false; + { + // The slot must be clear again whichever way instantiation ends. + struct PendingTargetScope { + ModuleLoaderState* state; + ~PendingTargetScope() { state->pendingFacadeTarget.Reset(); } + } pendingScope{state}; + state->pendingFacadeTarget.Reset(isolate, target); + linked = facade->InstantiateModule(context, &ResolveRequireFacadeTarget) + .FromMaybe(false); + } + if (!linked) { + throw NativeScriptException( + tc, "Cannot link the require() facade for " + targetCanonicalPath); + } + + // Three re-export statements over an already-evaluated module: trivially + // synchronous, so the strict policy's settled-promise requirement holds. + ModuleEvaluationOptions evalOptions; + evalOptions.policy = ModuleEvaluationPolicy::kSyncStrict; + EvaluateModuleGraph(isolate, context, facade, facadeUrl, evalOptions); + + // The facade is deliberately absent from the registry and the identity-hash + // index: nothing resolves to it by name, and its source has no import.meta or + // dynamic import, so no host callback ever needs to find it. + RequireFacadeEntry entry; + entry.target.Reset(isolate, target); + entry.facade.Reset(isolate, facade); + state->requireFacadesByTargetHash[target->GetIdentityHash()].push_back( + std::move(entry)); + + return hs.Escape(facade); } -// Helper function to check if a module is a Node.js built-in (e.g., node:url) -bool IsNodeBuiltinModule(const std::string& spec) { - return spec.size() > 5 && spec.substr(0, 5) == "node:"; +void IndexModuleForIsolate(v8::Isolate* isolate, const std::string& canonicalKey, + v8::Local mod) { + auto* state = ModuleLoaderStateFor(isolate); + if (state == nullptr) return; + IndexRegisteredModule(*state, canonicalKey, mod); } -// Helper function to get application path (for Android, we'll use a simple approach) -std::string GetApplicationPath() { - // For Android, use the actual file system path instead of asset path - // This should match the ApplicationFilesPath + "/app" from Module.java - JEnv env; - jstring applicationFilesPath = (jstring) env.CallStaticObjectMethod(ModuleInternal::MODULE_CLASS, ModuleInternal::GET_APPLICATION_FILES_PATH_METHOD_ID); - std::string path = ArgConverter::jstringToString(applicationFilesPath); - return path + "/app"; +void UnindexModuleForIsolate(v8::Isolate* isolate, + const std::string& canonicalKey) { + auto* state = ModuleLoaderStateFor(isolate); + if (state == nullptr) return; + UnindexRegistryKey(*state, isolate, canonicalKey); +} + +static bool IsVolatileUrl(const LoaderVocabulary& vocabulary, + const std::string& url); + +// ───────────────────────────────────────────────────────────── +// AdoptThenable +// +// Turn any thenable value into a real v8::Promise. Promises returned by +// V8 itself (Module::Evaluate) are genuine and take the fast path; +// user-space thenables (e.g. Proxy'd Promises) fail v8::Value::IsPromise +// but adopting them via Promise::Resolver::New + Resolve preserves their +// state. +static v8::MaybeLocal AdoptThenable(v8::Isolate* isolate, + v8::Local context, + v8::Local value) { + if (value.IsEmpty()) return v8::MaybeLocal(); + if (value->IsPromise()) return value.As(); + if (!value->IsObject()) return v8::MaybeLocal(); + + v8::Local thenVal; + if (!value.As() + ->Get(context, ArgConverter::ConvertToV8String(isolate, "then")) + .ToLocal(&thenVal) || + !thenVal->IsFunction()) { + return v8::MaybeLocal(); + } + + v8::Local adopter; + if (!v8::Promise::Resolver::New(context).ToLocal(&adopter) || + adopter->Resolve(context, value).IsNothing()) { + return v8::MaybeLocal(); + } + return adopter->GetPromise(); } -// ResolveModuleCallback - Main callback invoked by V8 to resolve import statements -v8::MaybeLocal ResolveModuleCallback(v8::Local context, - v8::Local specifier, - v8::Local import_assertions, - v8::Local referrer) { - v8::Isolate* isolate = v8::Isolate::GetCurrent(); +// ───────────────────────────────────────────────────────────── +// Compile helpers + +// "message (line L:C)" for a caught exception, or empty. The line/column are +// the part no caller can reconstruct from a failure code. +static std::string DescribeCaughtError(v8::Isolate* isolate, + v8::Local context, + const v8::TryCatch& tc) { + if (!tc.HasCaught()) return std::string(); + v8::Local message = tc.Message(); + if (message.IsEmpty()) return std::string(); + v8::String::Utf8Value text(isolate, message->Get()); + std::string described = *text ? *text : ""; + int line = message->GetLineNumber(context).FromMaybe(0); + if (line > 0) { + described += " (line " + std::to_string(line) + ":" + + std::to_string(message->GetStartColumn()) + ")"; + } + return described; +} - // 1) Convert specifier to std::string - v8::String::Utf8Value specUtf8(isolate, specifier); - std::string spec = *specUtf8 ? *specUtf8 : ""; - if (spec.empty()) { - return v8::MaybeLocal(); +// Compile-only variant used inside ResolveModuleCallback. Compiles a +// v8::Module and registers it under urlStr but does NOT instantiate or +// evaluate. V8 is currently instantiating the importer and will handle +// instantiation of this dependency. +// +// On compile failure the exception is left PENDING, the same contract as +// ModuleInternal::CompileFileEsModule: it names the file, line and column, +// which nothing downstream can reconstruct. A caller that cannot let it +// propagate must consume it through its own TryCatch and route the text into +// its own failure channel — never drop it. +static v8::MaybeLocal CompileModuleForResolveRegisterOnly( + v8::Isolate* isolate, v8::Local context, + const std::string& code, const std::string& urlStr) { + v8::EscapableHandleScope hs(isolate); + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) { + return v8::MaybeLocal(); + } + auto& registry = moduleState->registry; + const std::string registryKey = CanonicalizeRegistryKey(urlStr); + + // Checked before compiling: recompiling a key that is already registered + // would mint a second module identity while importers hold the first. + auto itExisting = registry.find(registryKey); + if (itExisting != registry.end()) { + v8::Local existing = itExisting->second.Get(isolate); + if (!existing.IsEmpty()) { + return hs.Escape(existing); + } + } + + v8::Local sourceText = + ArgConverter::ConvertToV8String(isolate, code); + v8::Local urlV8; + if (!v8::String::NewFromUtf8(isolate, urlStr.c_str(), + v8::NewStringType::kNormal) + .ToLocal(&urlV8)) { + return v8::MaybeLocal(); + } + v8::ScriptOrigin origin(urlV8, 0, 0, false, -1, v8::Local(), + false, false, true /* is_module */); + v8::ScriptCompiler::Source src(sourceText, origin); + v8::Local mod; + { + v8::TryCatch tcCompile(isolate); + if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&mod)) { + TNS_DEBUG(Esm, "[http-esm][compile][fail] %s %s", urlStr.c_str(), + DescribeCaughtError(isolate, context, tcCompile).c_str()); + tcCompile.ReThrow(); + return v8::MaybeLocal(); } + } + UnindexRegistryKey(*moduleState, isolate, registryKey); + registry[registryKey].Reset(isolate, mod); + IndexRegisteredModule(*moduleState, registryKey, mod); + return hs.Escape(mod); +} - // Debug logging - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: Resolving '%s'", spec.c_str()); +// Returns null once teardown has begun. +ModuleHandleMap* ModuleRegistryFor(v8::Isolate* isolate) { + auto* state = ModuleLoaderStateFor(isolate); + return state == nullptr ? nullptr : &state->registry; +} + +// Neutralize any in-flight async graph loads for `isolate`: their fetch +// completions check the dead flag before touching V8, and their context +// Globals are Reset here, while the isolate is still alive. The rest of the +// loader state is destroyed with the isolate's RuntimeState. +void QuiesceModuleLoadsForIsolate(v8::Isolate* isolate) { + KillAsyncGraphLoadsForIsolate(isolate); +} + +// The calling isolate's vocabulary, or null once teardown has begun. +static LoaderVocabulary* VocabularyForCurrentIsolate() { + auto* state = ModuleLoaderStateFor(v8::Isolate::TryGetCurrent()); + return state != nullptr ? &state->vocabulary : nullptr; +} + +std::string CanonicalizeRegistryKey(const std::string& key) { + if (key.empty()) return key; + + std::string registryKey; + const char* classification = "path"; + bool traceEvenWithoutChange = false; + + // Repair-first, exactly like IsHttpModulePath: a collapsed scheme + // separator (`http:/host/...`) must key as the URL it routes as, or the + // loader registers a module under a key the probes and evictions of the + // raw string can never find. + const std::string repaired = NormalizeHttpModuleUrl(key); + if (StartsWith(repaired, "http://") || StartsWith(repaired, "https://")) { + registryKey = CanonicalizeHttpUrlKey(repaired); + classification = "http"; + } else if (StartsWith(key, "file://")) { + registryKey = NormalizePath(FileURLToPath(key)); + classification = "file-url"; + } else if (StartsWith(key, "blob:")) { + registryKey = key; + classification = "blob"; + traceEvenWithoutChange = true; + } else { + // Preserve non-filesystem module namespaces such as node: + // so synthetic/in-memory modules keep their exact registry identity. + size_t schemePos = key.find(':'); + size_t slashPos = key.find('/'); + if (schemePos != std::string::npos && + (slashPos == std::string::npos || schemePos < slashPos)) { + registryKey = key; + classification = "custom-scheme"; + traceEvenWithoutChange = true; + } else { + registryKey = NormalizePath(key); } + } - // Builtin modules resolve before any path handling. Unshimmed "node:" - // names fall through to the legacy polyfills below. - if (NsBuiltinModules::IsRegistered(spec) || NsBuiltinModules::IsNsScheme(spec)) { - v8::Local builtin; - if (NsBuiltinModules::GetModule(context, spec).ToLocal(&builtin)) { - return v8::MaybeLocal(builtin); - } - if (!NsBuiltinModules::IsRegistered(spec)) { - isolate->ThrowException(v8::Exception::Error( - ArgConverter::ConvertToV8String(isolate, NsBuiltinModules::NotFoundMessage(spec)))); - } - return v8::MaybeLocal(); + if (traceEvenWithoutChange || registryKey != key) { + TNS_DEBUG(Esm, "[resolver][registry-key][%s] raw=%s key=%s", classification, + key.c_str(), registryKey.c_str()); + } + return registryKey; +} + +v8::MaybeLocal LoadHttpModuleForUrl(v8::Isolate* isolate, + v8::Local context, + const std::string& requestedUrl) { + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) { + return v8::MaybeLocal(); + } + auto& registry = moduleState->registry; + const std::string registryKey = CanonicalizeHttpUrlKey(requestedUrl); + + TNS_DEBUG(Esm, "[http-esm][load][begin] request=%s key=%s", + requestedUrl.c_str(), registryKey.c_str()); + + auto itExisting = registry.find(registryKey); + if (itExisting != registry.end()) { + v8::Local existing = itExisting->second.Get(isolate); + if (!existing.IsEmpty() && existing->GetStatus() != v8::Module::kErrored) { + TNS_DEBUG(Esm, "[http-esm][load][cache-hit] key=%s", registryKey.c_str()); + return v8::MaybeLocal(existing); + } + TNS_DEBUG(Esm, "[http-esm][load][drop-errored] key=%s", registryKey.c_str()); + RemoveModuleFromRegistry(isolate, registryKey); + } + + // Reaching this point means the graph walk did not discover this URL, so the + // module is about to be fetched synchronously, blocking the JS thread for a + // whole round trip. That is an invariant violation, not a mode — always + // visible, in every build, so it cannot hide behind a disabled trace + // category. The fallback itself stays: correctness first, diagnosis loud. + DEBUG_WRITE_FORCE( + "NativeScript: module graph walk missed %s — falling back to a blocking " + "synchronous fetch. This should not happen; please report it.", + requestedUrl.c_str()); + + ModuleFetchResult fetched; + if (!HttpFetchModule(requestedUrl, fetched)) { + TNS_DEBUG(Esm, "[http-esm][load][fetch-fail] request=%s key=%s status=%d", + requestedUrl.c_str(), registryKey.c_str(), fetched.status); + // The classifier's reason names the URL and the cause (status, MIME or + // transport); a generic message here would lose all of it. V8 requires an + // exception whenever a resolve callback returns empty, so this is thrown + // in every build. + isolate->ThrowException(v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, fetched.failureReason))); + return v8::MaybeLocal(); + } + + if (fetched.kind == ModuleResponseKind::kJson) { + return CompileJsonTextAsEsModule(isolate, context, fetched.body, registryKey, + requestedUrl); + } + + v8::Local loaded; + { + v8::TryCatch tcCompile(isolate); + if (!CompileModuleForResolveRegisterOnly(isolate, context, fetched.body, + registryKey) + .ToLocal(&loaded)) { + TNS_DEBUG(Esm, "[http-esm][load][compile-fail] request=%s key=%s bytes=%zu", + requestedUrl.c_str(), registryKey.c_str(), + fetched.body.size()); + if (tcCompile.HasCaught()) { + // The compile error names the module, line and column; replacing it + // with a generic "compile failed" would strictly lose information. + tcCompile.ReThrow(); + } else { + std::string msg = "HTTP import compile failed: " + requestedUrl; + isolate->ThrowException(v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, msg))); + } + return v8::MaybeLocal(); } + } - // Normalize malformed http:/ and https:/ prefixes - if (spec.rfind("http:/", 0) == 0 && spec.rfind("http://", 0) != 0) { - spec.insert(5, "/"); - } else if (spec.rfind("https:/", 0) == 0 && spec.rfind("https://", 0) != 0) { - spec.insert(6, "/"); + TNS_DEBUG(Esm, "[http-esm][load][ok] request=%s key=%s type=%s bytes=%zu", + requestedUrl.c_str(), registryKey.c_str(), + fetched.contentType.c_str(), fetched.body.size()); + return loaded; +} + +// ───────────────────────────────────────────────────────────── +// Import map helpers + +// Read one imports-shaped section. Every rejection names the offending key so +// a bad map is fixable from the message alone. +static bool ParseImportMapEntries(v8::Isolate* isolate, v8::Local context, + v8::Local source, + const std::string& sectionLabel, + ImportMapEntries* out, std::string* error) { + v8::Local keys; + if (!source->GetOwnPropertyNames(context).ToLocal(&keys)) { + *error = sectionLabel + ": could not be read"; + return false; + } + for (uint32_t i = 0; i < keys->Length(); i++) { + v8::Local keyVal; + if (!keys->Get(context, i).ToLocal(&keyVal) || !keyVal->IsString()) { + *error = sectionLabel + ": every key must be a string"; + return false; + } + v8::String::Utf8Value keyUtf8(isolate, keyVal); + if (!*keyUtf8) { + *error = sectionLabel + ": every key must be a string"; + return false; + } + const std::string specifier(*keyUtf8); + if (specifier.empty()) { + *error = sectionLabel + ": a specifier key must not be empty"; + return false; } - // Attempt to resolve relative or root-absolute specifiers against an HTTP referrer URL - std::string referrerPath; - for (auto& kv : g_moduleRegistry) { - v8::Local registered = kv.second.Get(isolate); - if (!registered.IsEmpty() && registered == referrer) { - referrerPath = kv.first; - break; - } + v8::Local value; + if (!source->Get(context, keyVal).ToLocal(&value) || !value->IsString()) { + *error = sectionLabel + ": the target for '" + specifier + "' must be a string"; + return false; } - bool specIsRelative = !spec.empty() && spec[0] == '.'; - bool specIsRootAbs = !spec.empty() && spec[0] == '/'; - auto startsWithHttp = [](const std::string& s) -> bool { - return s.rfind("http://", 0) == 0 || s.rfind("https://", 0) == 0; - }; - if (!startsWithHttp(spec) && (specIsRelative || specIsRootAbs)) { - if (!referrerPath.empty() && startsWithHttp(referrerPath)) { - std::string resolved = ResolveHttpRelative(referrerPath, spec); - if (!resolved.empty()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: HTTP-relative resolved '%s' + '%s' -> '%s'", - referrerPath.c_str(), spec.c_str(), resolved.c_str()); - } - spec = resolved; - } - } else if (specIsRootAbs) { - // Fallback: use global __NS_HTTP_ORIGIN__ if present to anchor root-absolute specs - v8::Local key = ArgConverter::ConvertToV8String(isolate, "__NS_HTTP_ORIGIN__"); - v8::Local global = context->Global(); - v8::MaybeLocal maybeOriginVal = global->Get(context, key); - v8::Local originVal; - if (!maybeOriginVal.IsEmpty() && maybeOriginVal.ToLocal(&originVal) && originVal->IsString()) { - v8::String::Utf8Value o8(isolate, originVal); - std::string origin = *o8 ? *o8 : ""; - if (!origin.empty() && (origin.rfind("http://", 0) == 0 || origin.rfind("https://", 0) == 0)) { - std::string refBase = origin; - if (refBase.back() != '/') refBase += '/'; - std::string resolved = ResolveHttpRelative(refBase, spec); - if (!resolved.empty()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][http-origin][fallback] origin=%s spec=%s -> %s", refBase.c_str(), spec.c_str(), resolved.c_str()); - } - spec = resolved; - } - } - } - } + v8::String::Utf8Value valueUtf8(isolate, value); + if (!*valueUtf8) { + *error = sectionLabel + ": the target for '" + specifier + "' must be a string"; + return false; + } + const std::string target(*valueUtf8); + if (target.empty()) { + *error = sectionLabel + ": the target for '" + specifier + "' must not be empty"; + return false; } - // HTTP(S) ESM support: resolve, fetch and compile from dev server - // Security: HttpFetchText gates remote module access centrally. - if (spec.rfind("http://", 0) == 0 || spec.rfind("https://", 0) == 0) { - std::string canonical = tns::CanonicalizeHttpUrlKey(spec); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][resolve] spec=%s canonical=%s", spec.c_str(), canonical.c_str()); - } - auto it = g_moduleRegistry.find(canonical); - if (it != g_moduleRegistry.end()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][cache] hit %s", canonical.c_str()); - } - return v8::MaybeLocal(it->second.Get(isolate)); - } + // A trailing-slash key maps a whole subtree, so its target must name one + // too — otherwise the remainder would be pasted onto a file path. + if (specifier.back() == '/' && target.back() != '/') { + *error = sectionLabel + ": the target for '" + specifier + + "' must end with '/' because the specifier key does"; + return false; + } - std::string body, ct; - int status = 0; - if (!tns::HttpFetchText(spec, body, ct, status)) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][fetch][fail] url=%s status=%d", spec.c_str(), status); - } - std::string msg = std::string("Failed to fetch ") + spec + ", status=" + std::to_string(status); - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); - } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][fetch][ok] url=%s status=%d bytes=%lu ct=%s", spec.c_str(), status, (unsigned long)body.size(), ct.c_str()); - } + (*out)[specifier] = target; + } + return true; +} - v8::Local sourceText = ArgConverter::ConvertToV8String(isolate, body); - v8::Local urlString = ArgConverter::ConvertToV8String(isolate, canonical); - v8::ScriptOrigin origin(urlString, 0, 0, false, -1, v8::Local(), false, false, true); - v8::ScriptCompiler::Source src(sourceText, origin); - v8::Local mod; - { - v8::TryCatch tc(isolate); - if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&mod)) { - LogHttpCompileDiagnostics(isolate, context, canonical, body, tc); - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "HTTP module compile failed"))); - return v8::MaybeLocal(); - } - } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][compile][ok] %s bytes=%lu", canonical.c_str(), (unsigned long)body.size()); - } - // Register before instantiation to allow cyclic imports to resolve to same instance - g_moduleRegistry[canonical].Reset(isolate, mod); - // Do not evaluate here; allow V8 to handle instantiation/evaluation in importer context. - // Instantiate proactively if desired (safe), but not required. - // if (mod->GetStatus() == v8::Module::kUninstantiated) { - // if (!mod->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { - // g_moduleRegistry.erase(canonical); - // return v8::MaybeLocal(); - // } - // } - // Let V8 evaluate during importer evaluation. Returning compiled module is fine. - return v8::MaybeLocal(mod); - } - - // 2) Find which filepath the referrer was compiled under (local filesystem case) - // referrerPath may already be set above; leave as-is if found. - if (referrerPath.empty()) { - for (auto& kv : g_moduleRegistry) { - v8::Local registered = kv.second.Get(isolate); - if (registered == referrer) { - referrerPath = kv.first; - break; - } - } +// Parse without touching the live map. On any failure `error` explains what is +// wrong and `out` is meaningless — the caller keeps whatever it already had. +// V8's JSON parser stands in for iOS's NSJSONSerialization: escapes, nesting +// and malformed input are handled by the engine rather than a hand-rolled +// scanner, and this always runs on the isolate's own thread. +static bool ParseImportMap(v8::Isolate* isolate, const std::string& json, + ParsedImportMap* out, std::string* error) { + if (json.empty()) { + *error = "an import map must be a non-empty JSON object"; + return false; + } + + v8::Local context = isolate->GetCurrentContext(); + v8::TryCatch tc(isolate); + v8::Local parsed; + if (!v8::JSON::Parse(context, ArgConverter::ConvertToV8String(isolate, json)) + .ToLocal(&parsed)) { + std::string detail = DescribeCaughtError(isolate, context, tc); + *error = "an import map must be valid JSON" + (detail.empty() ? "" : ": " + detail); + return false; + } + if (!parsed->IsObject() || parsed->IsArray()) { + *error = "an import map must be a JSON object"; + return false; + } + v8::Local top = parsed.As(); + + // Only the map's OWN keys are sections; reading through the prototype would + // let a polluted Object.prototype smuggle one in. + v8::Local sections; + if (!top->GetOwnPropertyNames(context).ToLocal(§ions)) { + *error = "an import map must be a JSON object"; + return false; + } + bool hasImports = false; + bool hasScopes = false; + for (uint32_t i = 0; i < sections->Length(); i++) { + v8::Local sectionVal; + std::string name; + if (sections->Get(context, i).ToLocal(§ionVal) && sectionVal->IsString()) { + v8::String::Utf8Value utf8(isolate, sectionVal); + if (*utf8) name = *utf8; } - - // If we couldn't identify the referrer and the specifier is relative, - // assume the base directory is the application root - bool specIsRelativeFs = !spec.empty() && spec[0] == '.'; - if (referrerPath.empty() && specIsRelativeFs) { - referrerPath = GetApplicationPath() + "/index.mjs"; // Default referrer + if (name == "imports") { + hasImports = true; + } else if (name == "scopes") { + hasScopes = true; + } else { + *error = "unsupported import-map section '" + name + + "'; only \"imports\" and \"scopes\" are supported"; + return false; + } + } + + v8::Local imports; + if (hasImports && + top->Get(context, ArgConverter::ConvertToV8String(isolate, "imports")).ToLocal(&imports) && + !imports->IsUndefined()) { + if (!imports->IsObject() || imports->IsArray()) { + *error = "the \"imports\" section must be an object"; + return false; + } + if (!ParseImportMapEntries(isolate, context, imports.As(), "imports", + &out->imports, error)) { + return false; } + } + + v8::Local scopes; + if (hasScopes && + top->Get(context, ArgConverter::ConvertToV8String(isolate, "scopes")).ToLocal(&scopes) && + !scopes->IsUndefined()) { + if (!scopes->IsObject() || scopes->IsArray()) { + *error = "the \"scopes\" section must be an object"; + return false; + } + v8::Local scopesObj = scopes.As(); + v8::Local scopeKeys; + if (!scopesObj->GetOwnPropertyNames(context).ToLocal(&scopeKeys)) { + *error = "the \"scopes\" section must be an object"; + return false; + } + for (uint32_t i = 0; i < scopeKeys->Length(); i++) { + v8::Local scopeKeyVal; + if (!scopeKeys->Get(context, i).ToLocal(&scopeKeyVal) || !scopeKeyVal->IsString()) { + *error = "scopes: every scope key must be a string"; + return false; + } + v8::String::Utf8Value scopeUtf8(isolate, scopeKeyVal); + const std::string scopePrefix(*scopeUtf8 ? *scopeUtf8 : ""); + if (scopePrefix.empty()) { + *error = "scopes: a scope key must not be empty"; + return false; + } + v8::Local scopeMap; + if (!scopesObj->Get(context, scopeKeyVal).ToLocal(&scopeMap) || !scopeMap->IsObject() || + scopeMap->IsArray()) { + *error = "scopes: the map for scope '" + scopePrefix + "' must be an object"; + return false; + } + ImportMapEntries entries; + if (!ParseImportMapEntries(isolate, context, scopeMap.As(), + "scope '" + scopePrefix + "'", &entries, error)) { + return false; + } + out->scopes.emplace_back(scopePrefix, std::move(entries)); + } + } + + // Most specific first: a longer prefix is the more specific scope, and the + // key comparison keeps the order deterministic for equal-length prefixes. + std::sort(out->scopes.begin(), out->scopes.end(), + [](const std::pair& a, + const std::pair& b) { + if (a.first.size() != b.first.size()) { + return a.first.size() > b.first.size(); + } + return a.first > b.first; + }); + return true; +} - // 3) Compute base directory from referrer path - size_t slash = referrerPath.find_last_of("/\\"); - std::string baseDir = slash == std::string::npos ? "" : referrerPath.substr(0, slash + 1); +bool SetImportMap(const std::string& json, std::string* error) { + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + LoaderVocabulary* vocabulary = VocabularyForCurrentIsolate(); + std::string localError; + std::string& err = error != nullptr ? *error : localError; + if (vocabulary == nullptr) { + err = "the isolate is shutting down"; + return false; + } + + // Parse-validate-swap: the live vocabulary is replaced only once a complete + // map has been built, so a rejected update leaves resolution exactly as it + // was rather than silently emptying it. + ParsedImportMap parsedMap; + if (!ParseImportMap(isolate, json, &parsedMap, &err)) { + return false; + } + vocabulary->importMap = std::move(parsedMap); + TNS_DEBUG(Esm, "[import-map] loaded %lu entries, %lu scopes", + (unsigned long)vocabulary->importMap.imports.size(), + (unsigned long)vocabulary->importMap.scopes.size()); + return true; +} - // 4) Build candidate paths for resolution - std::vector candidateBases; - std::string appPath = GetApplicationPath(); +bool ValidateImportMapJson(const std::string& json, std::string* error) { + std::string localError; + ParsedImportMap parsedMap; + return ParseImportMap(v8::Isolate::GetCurrent(), json, &parsedMap, + error != nullptr ? error : &localError); +} - if (!spec.empty() && spec[0] == '.') { - // Relative import (./ or ../) - std::string cleanSpec = spec.substr(0, 2) == "./" ? spec.substr(2) : spec; - std::string candidate = baseDir + cleanSpec; - candidateBases.push_back(candidate); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: Relative import: '%s' + '%s' -> '%s'", - baseDir.c_str(), cleanSpec.c_str(), candidate.c_str()); - } - } else if (spec.size() > 7 && spec.substr(0, 7) == "file://") { - // Absolute file URL - std::string tail = spec.substr(7); // strip file:// - if (tail.empty() || tail[0] != '/') { - tail = "/" + tail; - } +void SetVolatilePatterns(const std::vector& patterns) { + LoaderVocabulary* vocabulary = VocabularyForCurrentIsolate(); + if (vocabulary == nullptr) return; + vocabulary->volatilePatterns = patterns; + TNS_DEBUG(Esm, "[import-map] volatile patterns: %lu", + (unsigned long)vocabulary->volatilePatterns.size()); +} - // Map common virtual roots to the real appPath - const std::string appVirtualRoot = "/app/"; // e.g. file:///app/foo.mjs - const std::string androidAssetAppRoot = "/android_asset/app/"; // e.g. file:///android_asset/app/foo.mjs +LoaderVocabulary CaptureLoaderVocabulary(v8::Isolate* isolate) { + auto* state = ModuleLoaderStateFor(isolate); + return state != nullptr ? state->vocabulary : LoaderVocabulary(); +} - std::string candidate; - if (tail.rfind(appVirtualRoot, 0) == 0) { - // Drop the leading "/app/" and prepend real appPath - candidate = appPath + "/" + tail.substr(appVirtualRoot.size()); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: file:// to appPath mapping: '%s' -> '%s'", tail.c_str(), candidate.c_str()); - } - } else if (tail.rfind(androidAssetAppRoot, 0) == 0) { - // Replace "/android_asset/app/" with the real appPath - candidate = appPath + "/" + tail.substr(androidAssetAppRoot.size()); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: file:// android_asset mapping: '%s' -> '%s'", tail.c_str(), candidate.c_str()); - } - } else if (tail.rfind(appPath, 0) == 0) { - // Already an absolute on-disk path to the app folder - candidate = tail; - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: file:// absolute path preserved: '%s'", candidate.c_str()); - } - } else { - // Fallback: treat as absolute on-disk path - candidate = tail; - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: file:// generic absolute: '%s'", candidate.c_str()); - } - } +void InstallLoaderVocabulary(v8::Isolate* isolate, LoaderVocabulary vocabulary) { + auto* state = ModuleLoaderStateFor(isolate); + if (state == nullptr) return; + state->vocabulary = std::move(vocabulary); + TNS_DEBUG(Esm, + "[import-map] inherited %lu entries, %lu scopes, %lu volatile patterns", + (unsigned long)state->vocabulary.importMap.imports.size(), + (unsigned long)state->vocabulary.importMap.scopes.size(), + (unsigned long)state->vocabulary.volatilePatterns.size()); +} + +const CanonicalizationConfig* CanonicalizationConfigForCurrentIsolate() { + const LoaderVocabulary* vocabulary = VocabularyForCurrentIsolate(); + if (vocabulary == nullptr || !vocabulary->canonicalizationConfigured) { + return nullptr; + } + return &vocabulary->canonicalization; +} + +void SetCanonicalizationConfig(CanonicalizationConfig config) { + LoaderVocabulary* vocabulary = VocabularyForCurrentIsolate(); + if (vocabulary == nullptr) return; + vocabulary->canonicalization = std::move(config); + vocabulary->canonicalizationConfigured = true; + TNS_DEBUG(Esm, "[ns:module configureLoader] canonicalization set (strip=%lu " + "devPrefixes=%lu preserve=%lu)", + (unsigned long)vocabulary->canonicalization.stripParams.size(), + (unsigned long)vocabulary->canonicalization.devPathPrefixes.size(), + (unsigned long)vocabulary->canonicalization.preserveQueryPrefixes.size()); +} + +static bool IsVolatileUrl(const LoaderVocabulary& vocabulary, + const std::string& url) { + for (const auto& pat : vocabulary.volatilePatterns) { + if (url.find(pat) != std::string::npos) return true; + } + return false; +} + +// Look up a specifier in ONE import-map section: exact match first, then the +// longest trailing-slash prefix entry, whose remainder is appended to the +// target. Returns empty when the section has no answer. +static std::string LookupInEntries(const ImportMapEntries& entries, + const std::string& specifier) { + auto it = entries.find(specifier); + if (it != entries.end()) { + TNS_DEBUG(Esm, "[import-map] exact: %s -> %s", specifier.c_str(), + it->second.c_str()); + return it->second; + } + + std::string bestKey; + std::string bestValue; + for (const auto& kv : entries) { + const std::string& key = kv.first; + if (key.back() != '/') continue; // only trailing-slash entries map subtrees + if (specifier.size() > key.size() && + specifier.compare(0, key.size(), key) == 0) { + if (key.size() > bestKey.size()) { + bestKey = key; + bestValue = kv.second; + } + } + } + if (bestKey.empty()) return ""; + std::string resolved = bestValue + specifier.substr(bestKey.size()); + TNS_DEBUG(Esm, "[import-map] prefix: %s -> %s (via %s)", specifier.c_str(), + resolved.c_str(), bestKey.c_str()); + return resolved; +} + +// The import-map resolution cascade: the most specific applicable scope first, +// then progressively less specific ones, then the top-level imports — each +// consulted with the same per-section lookup. +// +// A scope key matches as a plain prefix of `referrerKey`, the importing +// module's canonical registry key: an absolute http(s) URL for a served +// module, or a canonical absolute path for a file. That key is this runtime's +// analogue of the web's resolved referrer URL, which is what scope prefixes +// match there. Ending a scope key with '/' keeps it on a directory boundary, +// exactly as on the web. +static std::string LookupImportMap(const LoaderVocabulary& vocabulary, + const std::string& specifier, + const std::string& referrerKey) { + for (const auto& scope : vocabulary.importMap.scopes) { + const std::string& prefix = scope.first; + if (referrerKey.size() < prefix.size() || + referrerKey.compare(0, prefix.size(), prefix) != 0) { + continue; + } + std::string mapped = LookupInEntries(scope.second, specifier); + if (!mapped.empty()) { + TNS_DEBUG(Esm, "[import-map] scope '%s' matched referrer %s", prefix.c_str(), + referrerKey.c_str()); + return mapped; + } + } + return LookupInEntries(vocabulary.importMap.imports, specifier); +} + +// ───────────────────────────────────────────────────────────── +// The shared resolution seam +// +// One module specifier resolved to something the loader can act on. Both +// ResolveModuleCallback and the graph walk go through this, so a module gets +// the same registry key whichever of them reaches it first — a divergence here +// mints two identities for one file. +// +// Pure computation: it consults the import map and the filesystem, but never +// compiles, registers, fetches, or throws. +struct ModuleResolution { + enum class Kind { + kUnresolved, // nothing locatable; the caller decides how to report it + kBuiltin, // ns:/node: — served from the builtin registry + kHttp, // absolute http(s) URL + kFile, // absolute filesystem path, confirmed to be a regular file + }; + + Kind kind = Kind::kUnresolved; + std::string url; // kHttp + std::string path; // kFile + std::string specifier; // the specifier after import-map rewriting + std::string attempted; // kUnresolved: the last candidate tried +}; + +// Rebuild an HTTP URL a path join swallowed ('/app/http:/host/x' → +// 'http://host/x'), or empty when the path embeds none. +static std::string HttpUrlEmbeddedInPath(const std::string& p) { + size_t pos1 = p.find("/http:/"); + size_t pos2 = p.find("/https:/"); + size_t pos = std::min(pos1 == std::string::npos ? SIZE_MAX : pos1, + pos2 == std::string::npos ? SIZE_MAX : pos2); + if (pos == SIZE_MAX) return ""; + std::string tail = p.substr(pos + 1); + if (StartsWith(tail, "http:/") && !StartsWith(tail, "http://")) { + tail.insert(5, "/"); + } else if (StartsWith(tail, "https:/") && !StartsWith(tail, "https://")) { + tail.insert(6, "/"); + } + if (!(StartsWith(tail, "http://") || StartsWith(tail, "https://"))) return ""; + return tail; +} - candidateBases.push_back(candidate); - } else if (!spec.empty() && spec[0] == '~') { - // Alias to application root using ~/path - std::string tail = spec.size() >= 2 && spec[1] == '/' ? spec.substr(2) : spec.substr(1); - std::string candidate = appPath + "/" + tail; - candidateBases.push_back(candidate); - } else if (!spec.empty() && spec[0] == '/') { - // Absolute path within the bundle - candidateBases.push_back(appPath + spec); +// `referrerKey` is the registry key of the importing module — empty when the +// importer is unknown (a dynamic import with no compiled referrer). +static ModuleResolution ResolveSpecifierToPath(const std::string& rawSpec, + const std::string& referrerKey) { + ModuleResolution result; + if (rawSpec.empty()) return result; + + // Builtins resolve before any path handling, so a file can never shadow one. + // The whole scheme is claimed, registered or not, so an unknown `node:` name + // fails as a missing builtin instead of falling through to the filesystem. + if (NsBuiltinModules::IsBuiltinScheme(rawSpec)) { + result.kind = ModuleResolution::Kind::kBuiltin; + result.specifier = rawSpec; + return result; + } + + // blob: names a registry key, never a filesystem path — the callers' blob + // branches own it, so it must not burn stat() probes under the app root. + if (StartsWith(rawSpec, "blob:")) { + result.specifier = rawSpec; + return result; + } + + std::string spec = rawSpec; + // Repair 'http:/host' (single slash) left by upstream path joins, so the URL + // takes the HTTP path instead of becoming '/app/http:/host'. + if (spec.rfind("http:/", 0) == 0 && spec.rfind("http://", 0) != 0) { + spec.insert(5, "/"); + } else if (spec.rfind("https:/", 0) == 0 && spec.rfind("https://", 0) != 0) { + spec.insert(6, "/"); + } + + // Query and fragment only mean something to a server, so a non-http + // specifier drops them before anything looks it up. Applied here, in the one + // seam both import forms go through, so `./x.js?v=1` names the same module + // whether it arrives as a static import or an import(). + if (!(StartsWith(spec, "http://") || StartsWith(spec, "https://"))) { + size_t cut = spec.find_first_of("?#"); + if (cut != std::string::npos) spec = spec.substr(0, cut); + if (spec.empty()) return result; + } + + TNS_DEBUG(Esm, "[resolver][spec] %s", spec.c_str()); + + // The import map is consulted before any other resolution: bare specifiers + // resolve through it to vendor or HTTP URLs. A client that rewrites + // specifiers must map every form it emits — keys are matched literally. + const LoaderVocabulary* vocabularyPtr = VocabularyForCurrentIsolate(); + if (vocabularyPtr != nullptr && !vocabularyPtr->importMap.empty()) { + const LoaderVocabulary& vocabulary = *vocabularyPtr; + std::string mapped = LookupImportMap(vocabulary, spec, referrerKey); + if (!mapped.empty()) { + TNS_DEBUG(Esm, "[resolver][import-map] rewrite: %s -> %s", spec.c_str(), + mapped.c_str()); + spec = mapped; } else { - // Bare specifier – resolve relative to the application root - std::string candidate = appPath + "/" + spec; - candidateBases.push_back(candidate); - - // Try converting underscores to slashes (bundler heuristic) - std::string withSlashes = spec; - std::replace(withSlashes.begin(), withSlashes.end(), '_', '/'); - std::string candidateSlashes = appPath + "/" + withSlashes; - if (candidateSlashes != candidate) { - candidateBases.push_back(candidateSlashes); - } + // A bare-looking specifier the map didn't match is about to fall back to + // filesystem resolution and almost certainly fail; surface the missing + // entry before the more cryptic `Cannot find module` follow-on. + bool looksBare = spec[0] != '/' && spec[0] != '.' && + spec.find("://") == std::string::npos && + spec.find('\\') == std::string::npos; + if (looksBare) { + TNS_DEBUG(Esm, "[resolver][import-map][miss] bare='%s' importMap.size=%lu", + spec.c_str(), + (unsigned long)vocabulary.importMap.imports.size()); + } + } + } + + result.specifier = spec; + + if (StartsWith(spec, "http://") || StartsWith(spec, "https://")) { + result.kind = ModuleResolution::Kind::kHttp; + result.url = spec; + return result; + } + + TNS_DEBUG(Esm, "[resolver] resolving '%s'", spec.c_str()); + + const bool specIsRelative = spec[0] == '.'; + const bool specIsRootAbs = spec[0] == '/'; + std::string referrer = referrerKey; + if (referrer.empty() && specIsRelative) { + TNS_DEBUG(Esm, "[resolver] No referrer for relative '%s' - assuming app root", + spec.c_str()); + referrer = GetApplicationPath() + "/index.mjs"; + } + size_t slash = referrer.find_last_of("/\\"); + const std::string baseDir = + slash == std::string::npos ? "" : referrer.substr(0, slash + 1); + + // A referrer fetched over HTTP makes its relative and root-absolute imports + // HTTP too, the way a browser resolves them. + const bool referrerIsHttp = StartsWith(referrer, "http://") || + StartsWith(referrer, "https://"); + if (referrerIsHttp && (specIsRelative || specIsRootAbs)) { + std::string resolvedHttp = ResolveHttpRelative(referrer, spec); + if (StartsWith(resolvedHttp, "http://") || + StartsWith(resolvedHttp, "https://")) { + TNS_DEBUG(Esm, "[resolver][http-rel] base=%s spec=%s -> %s", + referrer.c_str(), spec.c_str(), resolvedHttp.c_str()); + result.kind = ModuleResolution::Kind::kHttp; + result.url = resolvedHttp; + return result; + } + } + + // Build the filesystem candidates for this specifier shape. The specifier may + // omit its extension or name a directory, so each candidate is probed with + // Node-style extension and index fallbacks below. + const std::string appPath = GetApplicationPath(); + std::vector candidateBases; + + if (specIsRelative) { + std::string cleanSpec = spec.rfind("./", 0) == 0 ? spec.substr(2) : spec; + std::string candidate = NormalizePath(baseDir + cleanSpec); + candidateBases.push_back(candidate); + TNS_DEBUG(Esm, "[resolver][normalize-rel] %s + %s -> %s", baseDir.c_str(), + cleanSpec.c_str(), candidate.c_str()); + } else if (StartsWith(spec, "file://")) { + // Absolute file URL. Handle the two virtual roots the runtime emits. + std::string tail = spec.substr(7); + if (tail.empty() || tail[0] != '/') tail = "/" + tail; + + const std::string appVirtualRoot = "/app/"; + const std::string androidAssetAppRoot = "/android_asset/app/"; + std::string candidate; + if (tail.rfind(appVirtualRoot, 0) == 0) { + candidate = appPath + "/" + tail.substr(appVirtualRoot.size()); + } else if (tail.rfind(androidAssetAppRoot, 0) == 0) { + candidate = appPath + "/" + tail.substr(androidAssetAppRoot.size()); + } else { + candidate = tail; + } + candidateBases.push_back(NormalizePath(candidate)); + TNS_DEBUG(Esm, "[resolver][file-url] tail=%s -> %s", tail.c_str(), + candidateBases.back().c_str()); + } else if (spec[0] == '~') { + std::string tail = spec.size() >= 2 && spec[1] == '/' ? spec.substr(2) + : spec.substr(1); + std::string base = NormalizePath(appPath + "/" + tail); + candidateBases.push_back(base); + // Also try appPath/app for projects that bundle JS under an app folder. + std::string baseApp = NormalizePath(appPath + "/app/" + tail); + if (baseApp != base) candidateBases.push_back(baseApp); + TNS_DEBUG(Esm, "[resolver][tilde] spec=%s base=%s appBase=%s", spec.c_str(), + base.c_str(), baseApp.c_str()); + } else if (specIsRootAbs) { + // Dynamic import may already have resolved a relative specifier to a real + // filesystem path under the application root; use that as-is so we don't + // prefix ApplicationPath twice. Bundle-relative paths like /app/... or + // /src/... still resolve against appPath. + if (!appPath.empty() && spec.rfind(appPath, 0) == 0) { + candidateBases.push_back(NormalizePath(spec)); + TNS_DEBUG(Esm, "[resolver][abs-fs] spec=%s", spec.c_str()); + } else { + std::string base = NormalizePath(appPath + spec); + candidateBases.push_back(base); + const std::string appPrefix = "/app/"; + if (spec.rfind(appPrefix, 0) == 0) { + std::string tailNoApp = spec.substr(appPrefix.size() - 1); + std::string baseNoApp = NormalizePath(appPath + tailNoApp); + if (baseNoApp != base) candidateBases.push_back(baseNoApp); + } + TNS_DEBUG(Esm, "[resolver][abs] spec=%s base=%s", spec.c_str(), + base.c_str()); + } + } else { + // Bare specifier — resolve relative to the application root. + std::string base = NormalizePath(appPath + "/" + spec); + candidateBases.push_back(base); + } + + std::string absPath; + bool found = false; + for (const std::string& baseCandidate : candidateBases) { + absPath = baseCandidate; + + std::string embedded = HttpUrlEmbeddedInPath(absPath); + if (!embedded.empty()) { + TNS_DEBUG(Esm, "[resolver][http-embedded] %s -> %s", absPath.c_str(), + embedded.c_str()); + result.kind = ModuleResolution::Kind::kHttp; + result.url = embedded; + return result; } - // 5) Attempt to resolve to an actual file - std::string absPath; - bool found = false; + if (IsFile(absPath)) { + found = true; + break; + } + for (const char* e : {".mjs", ".js"}) { + std::string cand = NormalizePath(WithExtension(absPath, e)); + if (IsFile(cand)) { + absPath = cand; + found = true; + break; + } + } + if (found) break; + for (const char* idx : {"/index.mjs", "/index.js"}) { + std::string cand = NormalizePath(absPath + idx); + if (IsFile(cand)) { + absPath = cand; + found = true; + break; + } + } + if (found) break; + } - for (const std::string& baseCandidate : candidateBases) { - absPath = baseCandidate; + if (found) { + result.kind = ModuleResolution::Kind::kFile; + result.path = NormalizePath(absPath); + return result; + } - // Check if file exists as-is - if (IsFile(absPath)) { - found = true; - break; - } + result.attempted = absPath; + return result; +} - // Try adding extensions - const char* exts[] = {".mjs", ".js"}; - for (const char* ext : exts) { - std::string candidate = WithExtension(absPath, ext); - if (IsFile(candidate)) { - absPath = candidate; - found = true; - break; - } +// Monotonic microseconds since some fixed epoch — matches iOS's +// CFAbsoluteTimeGetCurrent() semantic (used for internal timing only, never +// exposed to JS). +static uint64_t MonotonicUs() { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000ull + (uint64_t)(ts.tv_nsec / 1000); +} + +// ───────────────────────────────────────────────────────────── +// The module-graph walk +// +// See the contract comment in ModuleInternalCallbacks.h. Mechanically, per +// edge — local edges never leave the JS thread: +// +// Enqueue(root) +// → local: CompileFileEsModule + register under the canonical key +// → http: FetchModuleBodyAsync (background thread — see HttpLoader.cpp) +// → back to the isolate's own event loop as a nestable v8 task +// → CompileModuleForResolveRegisterOnly (registers under the +// canonical URL key — the exact entry the resolver looks up) +// → GetModuleRequests() → ResolveSpecifierToPath → Enqueue(…) +// → when pendingFetches drains, onComplete fires on the JS thread. +// +// Thread discipline: `visited`, `pendingFetches`, `failed`, `completed` are +// touched ONLY on the isolate's JS thread (every fetch completion hops there +// first). Only raw I/O runs off-thread. The one crossing signal is `dead`, +// an atomic set by isolate teardown so in-flight completions become no-ops +// instead of touching a disposed isolate. + +namespace { +struct AsyncGraphLoad { + v8::Isolate* isolate = nullptr; + v8::Global context; + std::string rootKey; // canonical registry key of the root + robin_hood::unordered_set visited; // canonical keys (JS thread only) + int pendingFetches = 0; // JS thread only + bool failed = false; // JS thread only (root failure) + bool completed = false; // JS thread only + std::string failureMessage; + size_t fetchedCount = 0; + size_t compiledCount = 0; + uint64_t startUs = 0; + std::atomic dead{false}; // set by isolate teardown (any thread) + std::function context)> + onComplete; + + ~AsyncGraphLoad() { + g_asyncGraphLoadsInFlightCounter().fetch_sub(1, std::memory_order_acq_rel); + } + + static std::atomic& g_asyncGraphLoadsInFlightCounter() { + static std::atomic counter{0}; + return counter; + } +}; + +// Adapter so fetch completions ride the isolate's foreground task queue +// (EventLoop::PostV8Task) like any other v8 platform task. +class FetchCompletionTask : public v8::Task { + public: + explicit FetchCompletionTask(std::function fn) : fn_(std::move(fn)) {} + void Run() override { fn_(); } + + private: + std::function fn_; +}; + +// Registration and quiesce both run on the isolate's thread (the slot +// contract); background fetch completions only ever touch the AsyncGraphLoad +// they retain, never this list, so no lock is needed. +void RegisterAsyncGraphLoad(v8::Isolate* isolate, + const std::shared_ptr& load) { + auto* state = ModuleLoaderStateFor(isolate); + if (state == nullptr) return; + auto& loads = state->asyncGraphLoads; + // Prune expired entries opportunistically so the vector stays small. + loads.erase(std::remove_if(loads.begin(), loads.end(), + [](const std::weak_ptr& w) { + return w.expired(); + }), + loads.end()); + loads.push_back(load); +} +} // namespace + +bool HasPendingAsyncModuleGraphWork() { + return AsyncGraphLoad::g_asyncGraphLoadsInFlightCounter().load( + std::memory_order_acquire) > 0; +} + +// Isolate-teardown hook: mark every in-flight load owned by `isolate` dead +// (pending fetch completions become no-ops) and Reset their context Globals +// NOW, while the isolate is still alive — nothing may destroy a v8::Global +// after isolate disposal, and a pending background fetch completion can hold a +// load's shared_ptr past teardown, so the slot destructor alone cannot cover +// these. Called from QuiesceModuleLoadsForIsolate. +static void KillAsyncGraphLoadsForIsolate(v8::Isolate* isolate) { + auto* state = ModuleLoaderStateFor(isolate); + if (state == nullptr) return; + for (auto& weak : state->asyncGraphLoads) { + if (auto load = weak.lock()) { + load->dead.store(true, std::memory_order_release); + load->context.Reset(); + } + } + state->asyncGraphLoads.clear(); +} + +static void AsyncGraphEnqueue(const std::shared_ptr& load, + const ModuleResolution& resolution); + +// Walk `mod`'s static module requests and enqueue every edge the walk can +// resolve. JS thread only; `moduleKey` is the registry key the module was +// registered under, which is also the referrer for relative resolution. +static void AsyncGraphWalkModuleRequests( + const std::shared_ptr& load, v8::Local context, + v8::Local mod, const std::string& moduleKey) { + v8::Isolate* isolate = load->isolate; + v8::Local requests = mod->GetModuleRequests(); + const int length = requests->Length(); + for (int i = 0; i < length; i++) { + v8::Local request = + requests->Get(i).As(); + if (request.IsEmpty()) continue; + v8::Local specV8 = request->GetSpecifier(); + v8::String::Utf8Value specUtf8(isolate, specV8); + if (!*specUtf8) continue; + // Builtins are served by the resolver from the builtin registry, and an + // unresolved specifier (typically a bare name with no import-map entry) + // stays on the resolver's lazy path — where it either resolves later or + // fails with the resolver's own message. An unmapped bare specifier's + // subtree is therefore not discovered here; any HTTP edge inside it is + // pathological and lands on the synchronous anomaly guard. + const ModuleResolution resolution = + ResolveSpecifierToPath(*specUtf8, moduleKey); + if (resolution.kind != ModuleResolution::Kind::kHttp && + resolution.kind != ModuleResolution::Kind::kFile) { + continue; + } + AsyncGraphEnqueue(load, resolution); + } +} + +// Fire onComplete exactly once, when the frontier has drained. JS thread only. +static void AsyncGraphMaybeComplete(const std::shared_ptr& load, + v8::Local context) { + if (load->completed || load->pendingFetches > 0) return; + load->completed = true; + if (LogCategoryEnabled(LogCategory::Esm)) { + const uint64_t endUs = MonotonicUs(); + const uint64_t ms = endUs > load->startUs ? (endUs - load->startUs) / 1000ull : 0ull; + TNS_DEBUG( + Esm, + "[graph][done] root=%s urls=%lu fetched=%lu compiled=%lu ms=%llu ok=%d", + load->rootKey.c_str(), (unsigned long)load->visited.size(), + (unsigned long)load->fetchedCount, (unsigned long)load->compiledCount, + (unsigned long long)ms, load->failed ? 0 : 1); + } + auto onComplete = std::move(load->onComplete); + load->onComplete = nullptr; + if (onComplete) { + v8::TryCatch tc(load->isolate); + onComplete(!load->failed, load->failureMessage, context); + (void)tc; // swallow any pending exception; failures already surface as rejections + } +} + +// A fetch verdict arrived on the isolate's JS thread: compile + register the +// module, then walk its requests. Runs outside any V8 scope, so it enters the +// isolate the same way other cross-thread callbacks do. +static void AsyncGraphOnFetchCompleted( + const std::shared_ptr& load, const std::string& url, + const std::shared_ptr& fetched) { + if (load->dead.load(std::memory_order_acquire)) return; + v8::Isolate* isolate = load->isolate; + if (Runtime::TryGetRuntime(isolate) == nullptr) return; + + v8::Locker locker(isolate); + v8::Isolate::Scope isolate_scope(isolate); + v8::HandleScope handle_scope(isolate); + v8::Local context = load->context.Get(isolate); + if (context.IsEmpty()) return; + v8::Context::Scope context_scope(context); + + load->pendingFetches--; + + const std::string key = CanonicalizeRegistryKey(url); + const bool isRoot = (key == load->rootKey); + + if (!load->failed) { + if (!fetched->ok) { + if (isRoot) { + load->failed = true; + load->failureMessage = fetched->failureReason; + } else { + TNS_DEBUG(Esm, "[graph][dep-fetch-fail] %s (left to sync resolver)", + fetched->failureReason.c_str()); + } + } else if (fetched->kind == ModuleResponseKind::kJson) { + // JSON compiles, instantiates and evaluates in one step and carries no + // module requests, so there is nothing further to walk from here. + load->fetchedCount++; + v8::TryCatch tcJson(isolate); + if (CompileJsonTextAsEsModule(isolate, context, fetched->body, key, url) + .IsEmpty()) { + std::string reason = DescribeCaughtError(isolate, context, tcJson); + if (isRoot) { + load->failed = true; + load->failureMessage = "JSON module failed to compile: " + url; + if (!reason.empty()) { + load->failureMessage += " — " + reason; + } + } else { + TNS_DEBUG(Esm, "[graph][dep-json-fail] %s %s (left to sync resolver)", + url.c_str(), reason.c_str()); } - if (found) break; - - // Try index files if path is a directory - const char* indexExts[] = {"/index.mjs", "/index.js"}; - for (const char* idx : indexExts) { - std::string candidate = absPath + idx; - if (IsFile(candidate)) { - absPath = candidate; - found = true; - break; - } + } else { + load->compiledCount++; + } + } else { + load->fetchedCount++; + v8::Local mod; + bool compiled = false; + std::string compileError; + { + // This callback runs on to completion and a microtask checkpoint, so a + // compile exception must be consumed here rather than left pending; its + // text goes into the load's own failure channel instead. + v8::TryCatch tcCompile(isolate); + compiled = CompileModuleForResolveRegisterOnly(isolate, context, + fetched->body, key) + .ToLocal(&mod); + if (!compiled) { + compileError = DescribeCaughtError(isolate, context, tcCompile); } - if (found) break; - } - - // Canonicalize "." / ".." segments so a file reached through different - // spellings (e.g. "./x" from /a/b and "../x" from /a/b/c both name /a/b/x) - // maps to one registry key and is compiled once. The HTTP branch - // canonicalizes via CanonicalizeHttpUrlKey. - if (found) { - absPath = NormalizeDotSegments(absPath); - } - - // 6) Handle special cases if file not found - if (!found) { - // Check for Node.js built-in modules - if (IsNodeBuiltinModule(spec)) { - std::string builtinName = spec.substr(5); // Remove "node:" prefix - - // Create polyfill content for Node.js built-in modules - std::string polyfillContent; - - if (builtinName == "url") { - // Create a polyfill for node:url with fileURLToPath - polyfillContent = "// Polyfill for node:url\n" - "export function fileURLToPath(url) {\n" - " if (typeof url === 'string') {\n" - " if (url.startsWith('file://')) {\n" - " return decodeURIComponent(url.slice(7));\n" - " }\n" - " return url;\n" - " }\n" - " if (url && typeof url.href === 'string') {\n" - " return fileURLToPath(url.href);\n" - " }\n" - " throw new Error('Invalid URL');\n" - "}\n" - "\n" - "export function pathToFileURL(path) {\n" - " const encoded = encodeURIComponent(path).replace(/%2F/g, '/');\n" - " return new URL('file://' + encoded);\n" - "}\n"; - } else if (builtinName == "module") { - // Create a polyfill for node:module with createRequire - polyfillContent = "// Polyfill for node:module\n" - "export function createRequire(filename) {\n" - " // Return the global require function\n" - " // In NativeScript, require is globally available\n" - " if (typeof require === 'function') {\n" - " return require;\n" - " }\n" - " \n" - " // Fallback: create a basic require function\n" - " return function(id) {\n" - " throw new Error('Module ' + id + ' not found. NativeScript require() not available.');\n" - " };\n" - "}\n" - "\n" - "// Export as default as well for compatibility\n" - "export default { createRequire };\n"; - } else if (builtinName == "path") { - // Create a polyfill for node:path - polyfillContent = "// Polyfill for node:path\n" - "export const sep = '/';\n" - "export const delimiter = ':';\n" - "\n" - "export function basename(path, ext) {\n" - " const name = path.split('/').pop() || '';\n" - " return ext && name.endsWith(ext) ? name.slice(0, -ext.length) : name;\n" - "}\n" - "\n" - "export function dirname(path) {\n" - " const parts = path.split('/');\n" - " return parts.slice(0, -1).join('/') || '/';\n" - "}\n" - "\n" - "export function extname(path) {\n" - " const name = basename(path);\n" - " const dot = name.lastIndexOf('.');\n" - " return dot > 0 ? name.slice(dot) : '';\n" - "}\n" - "\n" - "export function join(...paths) {\n" - " return paths.filter(Boolean).join('/').replace(/\\/+/g, '/');\n" - "}\n" - "\n" - "export function resolve(...paths) {\n" - " let resolved = '';\n" - " for (let path of paths) {\n" - " if (path.startsWith('/')) {\n" - " resolved = path;\n" - " } else {\n" - " resolved = join(resolved, path);\n" - " }\n" - " }\n" - " return resolved || '/';\n" - "}\n" - "\n" - "export function isAbsolute(path) {\n" - " return path.startsWith('/');\n" - "}\n" - "\n" - "export default { basename, dirname, extname, join, resolve, isAbsolute, sep, delimiter };\n"; - } else { - isolate->ThrowException(v8::Exception::Error( - ArgConverter::ConvertToV8String(isolate, NsBuiltinModules::NotFoundMessage(spec)))); - return v8::MaybeLocal(); - } - - // Create module source and compile it in-memory - v8::Local sourceText = ArgConverter::ConvertToV8String(isolate, polyfillContent); - - // Build URL for stack traces - std::string moduleUrl = "node:" + builtinName; - v8::Local urlString = ArgConverter::ConvertToV8String(isolate, moduleUrl); - - v8::ScriptOrigin origin(urlString, 0, 0, false, -1, v8::Local(), false, false, true /* is_module */); - v8::ScriptCompiler::Source src(sourceText, origin); - - v8::Local polyfillModule; - if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&polyfillModule)) { - std::string msg = "Failed to compile polyfill for: " + spec; - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); - } - - // Store in registry before instantiation - g_moduleRegistry[spec].Reset(isolate, polyfillModule); - - // Instantiate the module - if (!polyfillModule->InstantiateModule(context, ResolveModuleCallback).FromMaybe(false)) { - g_moduleRegistry.erase(spec); - std::string msg = "Failed to instantiate polyfill for: " + spec; - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); - } - - // Evaluate the module - v8::MaybeLocal evalResult = polyfillModule->Evaluate(context); - if (evalResult.IsEmpty()) { - g_moduleRegistry.erase(spec); - std::string msg = "Failed to evaluate polyfill for: " + spec; - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); - } - - return v8::MaybeLocal(polyfillModule); - - } else if (tns::ModuleInternal::IsLikelyOptionalModule(spec)) { - // For optional modules, create a placeholder - std::string msg = "Optional module not found: " + spec; - DEBUG_WRITE("ResolveModuleCallback: %s", msg.c_str()); - // Return empty to indicate module not found gracefully - return v8::MaybeLocal(); + } + if (!compiled) { + if (isRoot) { + load->failed = true; + load->failureMessage = "HTTP import compile failed: " + url; + if (!compileError.empty()) { + load->failureMessage += " — " + compileError; + } } else { - // Regular module not found - std::string msg = "Cannot find module " + spec + " (tried " + absPath + ")"; - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); + TNS_DEBUG(Esm, "[graph][dep-compile-fail] %s %s (left to sync resolver)", + url.c_str(), compileError.c_str()); } + } else { + load->compiledCount++; + AsyncGraphWalkModuleRequests(load, context, mod, key); + } } + } + + AsyncGraphMaybeComplete(load, context); + isolate->PerformMicrotaskCheckpoint(); +} + +// A local edge: read + compile + register it inline, then keep walking. No +// thread hop — the bytes are already on disk, and a hop would only reorder +// discovery. A compile failure is deliberately swallowed here: the walk is a +// discovery optimization, and the resolver (or LoadESModule, for the root) +// owns the error message for a module that will not compile. Leaving it +// unregistered is exactly what makes those paths run and report. +static void AsyncGraphCompileLocalModule( + const std::shared_ptr& load, v8::Local context, + const std::string& path, const std::string& key) { + v8::Isolate* isolate = load->isolate; + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return; + + v8::Local mod; + { + v8::TryCatch tcCompile(isolate); + bool compiled = false; + try { + compiled = + tns::ModuleInternal::CompileFileEsModule(isolate, path).ToLocal(&mod); + } catch (NativeScriptException& ex) { + TNS_DEBUG(Esm, "[graph][local-compile-fail] %s %s (left to the resolver)", + path.c_str(), ex.GetErrorMessage().c_str()); + return; + } + if (!compiled) { + TNS_DEBUG(Esm, "[graph][local-compile-fail] %s (left to the resolver)", + path.c_str()); + return; + } + } + + UnindexRegistryKey(*moduleState, isolate, key); + moduleState->registry[key].Reset(isolate, mod); + IndexRegisteredModule(*moduleState, key, mod); + load->compiledCount++; + AsyncGraphWalkModuleRequests(load, context, mod, key); +} - // 7) Handle JSON modules - if (absPath.size() >= 5 && absPath.compare(absPath.size() - 5, 5, ".json") == 0) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: Handling JSON module '%s'", absPath.c_str()); +// Enqueue one resolved edge into the walk frontier. JS thread only. +static void AsyncGraphEnqueue(const std::shared_ptr& load, + const ModuleResolution& resolution) { + const bool isHttp = resolution.kind == ModuleResolution::Kind::kHttp; + const std::string& target = isHttp ? resolution.url : resolution.path; + // One keying function for both schemes: it dispatches to the HTTP canonical + // key for URLs and to the normalized path otherwise, so the walk registers + // every module under the exact key the resolver will look up. + const std::string key = CanonicalizeRegistryKey(target); + if (!load->visited.insert(key).second) return; + + v8::Isolate* isolate = load->isolate; + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return; + auto& registry = moduleState->registry; + auto it = registry.find(key); + if (it != registry.end()) { + v8::Local existing = it->second.Get(isolate); + if (!existing.IsEmpty() && existing->GetStatus() != v8::Module::kErrored) { + if (existing->GetStatus() == v8::Module::kUninstantiated) { + v8::Local context = load->context.Get(isolate); + if (!context.IsEmpty()) { + AsyncGraphWalkModuleRequests(load, context, existing, key); } + } + return; // instantiated/evaluated → its closure is already resolved + } + // Errored entry: drop and reload, mirroring LoadHttpModuleForUrl. + RemoveModuleFromRegistry(isolate, key); + } + + if (!isHttp) { + // JSON carries no module requests, and it compiles through a different + // path; there is nothing for the walk to discover in it. + if (EndsWith(target, ".json")) return; + v8::Local context = load->context.Get(isolate); + if (!context.IsEmpty()) { + AsyncGraphCompileLocalModule(load, context, target, key); + } + return; + } + + load->pendingFetches++; + std::shared_ptr loadRef = load; + const std::string url = target; + FetchModuleBodyAsync(url, [loadRef, url](ModuleFetchResult result) { + // Arbitrary thread. Hop to the isolate's home thread as a nestable v8 + // foreground task — delivery is a property of the isolate, not of the + // thread that started the fetch, and the pumped walk's + // RunNestableV8Tasks can drain it with JS frames on the stack. A null + // lookup means the isolate is gone; drop everything, and since teardown + // quiesces the loads before shutting the loop down, a dropped post holds + // only already-Reset state. + if (loadRef->dead.load(std::memory_order_acquire)) return; + auto* platform = NativeScriptPlatform::Instance(); + std::shared_ptr loop = + platform != nullptr ? platform->LookupEventLoop(loadRef->isolate) + : nullptr; + if (loop == nullptr) return; + auto resultPtr = std::make_shared(std::move(result)); + loop->PostV8Task( + std::make_unique([loadRef, url, resultPtr]() { + AsyncGraphOnFetchCompleted(loadRef, url, resultPtr); + }), + /*nestable=*/true, /*delaySeconds=*/0); + }); +} - // Read JSON file content - std::string jsonText = Runtime::GetRuntime(isolate)->ReadFileText(absPath); +// Classify a walk root. The root arrives already resolved — an absolute URL +// from the HTTP loader, or a canonical path from LoadESModule — so it needs +// only scheme dispatch, not the full specifier resolution. +static ModuleResolution ResolutionForRoot(const std::string& root) { + ModuleResolution resolution; + resolution.specifier = root; + if (StartsWith(root, "http://") || StartsWith(root, "https://")) { + resolution.kind = ModuleResolution::Kind::kHttp; + resolution.url = root; + } else if (IsFile(root)) { + resolution.kind = ModuleResolution::Kind::kFile; + resolution.path = root; + } + // Anything else stays kUnresolved: there is nothing to walk, and the + // caller's own load path reports why. + return resolution; +} - // Create ES module that exports the JSON as default - std::string moduleSource = "export default " + jsonText + ";"; +void StartModuleGraphLoad( + v8::Isolate* isolate, v8::Local context, + const std::string& root, + std::function context)> + onComplete) { + auto load = std::make_shared(); + load->isolate = isolate; + load->context.Reset(isolate, context); + load->rootKey = CanonicalizeRegistryKey(root); + load->startUs = MonotonicUs(); + load->onComplete = std::move(onComplete); + + AsyncGraphLoad::g_asyncGraphLoadsInFlightCounter().fetch_add( + 1, std::memory_order_acq_rel); + RegisterAsyncGraphLoad(isolate, load); + + TNS_DEBUG(Esm, "[graph][start] root=%s key=%s", root.c_str(), + load->rootKey.c_str()); + + const ModuleResolution rootResolution = ResolutionForRoot(root); + if (rootResolution.kind != ModuleResolution::Kind::kUnresolved) { + AsyncGraphEnqueue(load, rootResolution); + } + // Nothing left pending (a disk-only graph finishes entirely here): complete + // inline, so the pumped runner below never enters its wait loop. + AsyncGraphMaybeComplete(load, context); +} - v8::Local sourceText = ArgConverter::ConvertToV8String(isolate, moduleSource); - std::string url = "file://" + absPath; +bool RunModuleGraphLoadPumped(v8::Isolate* isolate, + v8::Local context, + const std::string& root, double timeoutSeconds) { + if (timeoutSeconds <= 0.0) timeoutSeconds = kModuleEvaluateDeadlineSeconds; + auto done = std::make_shared(false); + StartModuleGraphLoad(isolate, context, root, + [done](bool /*ok*/, const std::string& /*errorMessage*/, + v8::Local) { *done = true; }); + + // Fetch completions are nestable v8 foreground tasks on the isolate's event + // loop, which the pump drains directly. A graph with no HTTP edges is + // already done here, so the pump never runs. The walk always takes the + // looper-equivalent drain — it stands where iOS pumps its runloop, so a + // fetch that needs a timer or a worker reply to complete still progresses. + Runtime* runtime = Runtime::TryGetRuntime(isolate); + std::shared_ptr eventLoop = + runtime != nullptr ? runtime->GetEventLoop() : nullptr; + if (!*done && eventLoop != nullptr) { + if (eventLoop->PumpUntil(timeoutSeconds, [&]() { return *done; }, + /*drainLooperWork=*/true) == + EventLoop::PumpResult::kTerminated) { + // terminating isolate or stopped loop: nothing can complete the walk, + // and the sync takeover below must not start a blocking fetch + TNS_DEBUG(Esm, "[graph][pumped][terminated] root=%s", root.c_str()); + return *done; + } + } + if (!*done) { + TNS_DEBUG( + Esm, + "[graph][pumped][timeout] root=%s after %.1fs (sync loader takes over)", + root.c_str(), timeoutSeconds); + } + return *done; +} - v8::Local urlString; - if (!v8::String::NewFromUtf8(isolate, url.c_str(), v8::NewStringType::kNormal).ToLocal(&urlString)) { - isolate->ThrowException(v8::Exception::Error( - ArgConverter::ConvertToV8String(isolate, "Failed to create URL string for JSON module"))); - return v8::MaybeLocal(); - } +// ───────────────────────────────────────────────────────────── +// Registry mutation + diagnostics + +static const char* ModuleStatusToString(v8::Module::Status status) { + switch (status) { + case v8::Module::kUninstantiated: + return "Uninstantiated"; + case v8::Module::kInstantiating: + return "Instantiating"; + case v8::Module::kInstantiated: + return "Instantiated"; + case v8::Module::kEvaluating: + return "Evaluating"; + case v8::Module::kEvaluated: + return "Evaluated"; + case v8::Module::kErrored: + return "Errored"; + } + return "Unknown"; +} - v8::ScriptOrigin origin(urlString, 0, 0, false, -1, v8::Local(), false, - false, true /* is_module */); +void RemoveModuleFromRegistry(v8::Isolate* isolate, + const std::string& canonicalPath) { + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return; + auto& registry = moduleState->registry; + const std::string registryKey = CanonicalizeRegistryKey(canonicalPath); + + const LoaderVocabulary& vocabulary = moduleState->vocabulary; + auto classify = [&vocabulary](const std::string& s) -> const char* { + bool http = StartsWith(s, "http://") || StartsWith(s, "https://"); + if (http) { + // `http:volatile` is client-configured, via volatilePatterns; every other + // arm is derived from the URL's own shape, never from a client's + // conventions. + if (IsVolatileUrl(vocabulary, s)) return "http:volatile"; + return "http:other"; + } + if (StartsWith(s, "file://")) return "file-url"; + return "path"; + }; + + if (registryKey != canonicalPath) { + TNS_DEBUG(Esm, "[resolver][remove:pre] raw=%s key=%s class=%s", + canonicalPath.c_str(), registryKey.c_str(), + classify(registryKey)); + } else { + TNS_DEBUG(Esm, "[resolver][remove:pre] key=%s class=%s", registryKey.c_str(), + classify(registryKey)); + } + + size_t regPre = registry.size(); + + auto it = registry.find(registryKey); + if (it != registry.end()) { + bool isHttpKey = + StartsWith(registryKey, "http://") || StartsWith(registryKey, "https://"); + if (!isHttpKey) { + TNS_DEBUG(Esm, "[resolver] removing stale module %s", registryKey.c_str()); + } + UnindexRegistryKey(*moduleState, isolate, registryKey); + it->second.Reset(); + registry.erase(it); + } else { + TNS_DEBUG(Esm, "[resolver][remove:miss] key not found (%s)", + registryKey.c_str()); + } + + TNS_DEBUG(Esm, "[resolver][remove:post] reg %lu->%lu", (unsigned long)regPre, + (unsigned long)registry.size()); +} - v8::ScriptCompiler::Source src(sourceText, origin); +std::vector GetLoadedModuleUrls() { + std::vector urls; + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return urls; + auto& registry = moduleState->registry; + urls.reserve(registry.size()); + + for (const auto& entry : registry) { + const std::string& key = entry.first; + if (key.empty()) continue; + if (StartsWith(key, "blob:") || key.find("://") != std::string::npos) { + urls.push_back(key); + } + } + std::sort(urls.begin(), urls.end()); + urls.erase(std::unique(urls.begin(), urls.end()), urls.end()); + return urls; +} - v8::Local jsonModule; - if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&jsonModule)) { - isolate->ThrowException(v8::Exception::SyntaxError( - ArgConverter::ConvertToV8String(isolate, "Failed to compile JSON module"))); - return v8::MaybeLocal(); - } +void InvalidateModules(v8::Isolate* isolate, v8::Local context, + const std::vector& urls) { + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return; + auto& registry = moduleState->registry; + if (urls.empty()) return; + + robin_hood::unordered_set seen; + std::vector uniqueUrls; + uniqueUrls.reserve(urls.size()); + + for (const auto& url : urls) { + if (url.empty()) continue; + std::string registryKey = CanonicalizeRegistryKey(url); + if (registryKey.empty()) continue; + if (!seen.insert(registryKey).second) continue; + uniqueUrls.push_back(registryKey); + } + + size_t hits = 0, misses = 0; + for (const auto& url : uniqueUrls) { + bool present = registry.find(url) != registry.end(); + if (present) hits++; + else misses++; + TNS_DEBUG(Registry, "invalidate %s key=%s", present ? "HIT " : "MISS", + url.c_str()); + RejectAndClearInvalidatedModuleState(isolate, context, url); + RemoveModuleFromRegistry(isolate, url); + } + + // Second layer: the OS HTTP cache is outside our control and may serve + // a previous save's body even with no-store headers. Mark every + // invalidated key so the NEXT network fetch carries a unique + // `__ns_dev_nonce` query param — the network sees a URL it has never + // cached and must go to origin. The nonce is transport-only; module + // identity stays the canonical URL. + MarkKeysForCacheBust(uniqueUrls); + + TNS_DEBUG(Registry, "invalidate summary unique=%lu hits=%lu misses=%lu " + "(registry now=%lu)", + (unsigned long)uniqueUrls.size(), (unsigned long)hits, + (unsigned long)misses, (unsigned long)registry.size()); +} - // Instantiate and evaluate the JSON module - if (!jsonModule->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { - return v8::MaybeLocal(); - } +// ───────────────────────────────────────────────────────────── +// Resolver state +// +// The dynamic-import in-flight set and waiter lists live in ModuleLoaderState +// (per isolate, in a RuntimeState slot). - v8::MaybeLocal evalResult = jsonModule->Evaluate(context); - if (evalResult.IsEmpty()) { - return v8::MaybeLocal(); - } +static bool IsModuleEvaluationInProgress(v8::Module::Status status) { + return status == v8::Module::kInstantiating || + status == v8::Module::kEvaluating; +} - // Store in registry with safe handle management - auto it = g_moduleRegistry.find(absPath); - if (it != g_moduleRegistry.end()) { - it->second.Reset(); - } - g_moduleRegistry[absPath].Reset(isolate, jsonModule); - return v8::MaybeLocal(jsonModule); +static void ResolveResolversWithModuleNamespace( + v8::Isolate* isolate, v8::Local context, + std::vector>& resolvers, + v8::Local module, const std::string& registryKey) { + if (resolvers.empty()) return; + if (module.IsEmpty() || module->GetStatus() != v8::Module::kEvaluated) { + std::string msg = "Module did not finish evaluation: " + registryKey; + v8::Local errObj = + v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg)); + for (auto& resGlobal : resolvers) { + v8::Local resolver = resGlobal.Get(isolate); + if (!resolver.IsEmpty()) { + resolver->Reject(context, errObj).FromMaybe(false); + } + resGlobal.Reset(); + } + return; + } + v8::Local moduleNamespace = module->GetModuleNamespace(); + for (auto& resGlobal : resolvers) { + v8::Local resolver = resGlobal.Get(isolate); + if (!resolver.IsEmpty()) { + resolver->Resolve(context, moduleNamespace).FromMaybe(false); } + resGlobal.Reset(); + } +} + +static void RejectResolversWithReason( + v8::Isolate* isolate, v8::Local context, + std::vector>& resolvers, + v8::Local reason) { + if (resolvers.empty()) return; + for (auto& resGlobal : resolvers) { + v8::Local resolver = resGlobal.Get(isolate); + if (!resolver.IsEmpty()) { + resolver->Reject(context, reason).FromMaybe(false); + } + resGlobal.Reset(); + } +} + +// Park `resolver` on `registryKey`'s waiter list when that key is mid-flight. +// +// A queued waiter is settled by exactly one thing: the top-level-await +// continuation the pass that began evaluating this key attached to the +// module's capability promise. Every site that queues a waiter and returns +// while the module is already kEvaluating therefore depends on such a pass +// existing for that key. +static bool QueueHttpDynamicWaiterIfInFlight( + v8::Isolate* isolate, const std::string& registryKey, + v8::Local module, v8::Local resolver) { + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return false; + auto& modulesInFlight = moduleState->modulesInFlight; + if (registryKey.empty() || module.IsEmpty() || + !IsModuleEvaluationInProgress(module->GetStatus()) || + modulesInFlight.find(registryKey) == modulesInFlight.end()) { + return false; + } + moduleState->httpDynamicWaiters[registryKey].emplace_back(isolate, resolver); + TNS_DEBUG(Esm, "[dyn-import][http-await] queued waiter for %s status=%s", + registryKey.c_str(), + ModuleStatusToString(module->GetStatus())); + return true; +} - // 8) Check if we've already compiled this module - auto it = g_moduleRegistry.find(absPath); - if (it != g_moduleRegistry.end()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: Found cached module '%s'", absPath.c_str()); +// Build a rejection reason that PRESERVES the underlying V8 exception text. +static v8::Local BuildModuleFailureReason(v8::Isolate* isolate, + v8::TryCatch& tc, + const char* stage, + const std::string& urlOrKey) { + std::string message = std::string(stage) + ": " + urlOrKey; + if (tc.HasCaught()) { + v8::Local excMessage = tc.Message(); + if (!excMessage.IsEmpty()) { + v8::String::Utf8Value text(isolate, excMessage->Get()); + if (*text != nullptr && strlen(*text) > 0) { + message += std::string(" — ") + *text; + } + } else { + v8::Local exception = tc.Exception(); + if (!exception.IsEmpty()) { + v8::String::Utf8Value text(isolate, exception); + if (*text != nullptr && strlen(*text) > 0) { + message += std::string(" — ") + *text; } - return v8::MaybeLocal(it->second.Get(isolate)); + } } + } + TNS_DEBUG(Esm, "[dyn-import][failure] %s", message.c_str()); + return v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, message)); +} + +static void ResolveHttpDynamicWaiters(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey, + v8::Local module) { + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return; + // Settling a promise can run its reactions immediately: with the default + // microtask policy, a Resolve/Reject issued from a plain platform task (no + // JS on the stack) drains the queue as the API call unwinds. A reaction that + // re-imports this URL would then see stale routing state and park on a + // waiter list that was just flushed — a promise nothing would ever settle. + // So every piece of state that can route a new import onto the waiter list + // is cleared FIRST; a re-entrant import then takes the registry-hit path. + std::vector> resolvers; + auto& httpDynamicWaiters = moduleState->httpDynamicWaiters; + auto waitIt = httpDynamicWaiters.find(registryKey); + if (waitIt != httpDynamicWaiters.end()) { + resolvers.swap(waitIt->second); + httpDynamicWaiters.erase(waitIt); + } + moduleState->modulesInFlight.erase(registryKey); + + ResolveResolversWithModuleNamespace(isolate, context, resolvers, module, + registryKey); +} - // 9) Compile and register the new module - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: Compiling new module '%s'", absPath.c_str()); +static void RejectHttpDynamicWaiters(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey, + v8::Local reason) { + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return; + // Cleared before rejecting, for the same reason as the resolve path: a + // rejection handler that retries this URL must not join a flushed waiter + // list. + std::vector> resolvers; + auto& httpDynamicWaiters = moduleState->httpDynamicWaiters; + auto waitIt = httpDynamicWaiters.find(registryKey); + if (waitIt != httpDynamicWaiters.end()) { + resolvers.swap(waitIt->second); + httpDynamicWaiters.erase(waitIt); + } + moduleState->modulesInFlight.erase(registryKey); + + RejectResolversWithReason(isolate, context, resolvers, reason); +} + +static void RejectResolversForInvalidation( + v8::Isolate* isolate, v8::Local context, + std::vector>& resolvers, + const std::string& registryKey) { + if (resolvers.empty()) return; + std::string message = "Module invalidated during dev reload: " + registryKey; + v8::Local error = + v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, message)); + for (auto& resolverGlobal : resolvers) { + v8::Local resolver = resolverGlobal.Get(isolate); + if (!resolver.IsEmpty()) { + resolver->Reject(context, error).FromMaybe(false); } - try { - // Use our existing LoadESModule function to compile the module - tns::ModuleInternal::LoadESModule(isolate, absPath); - } catch (NativeScriptException& ex) { - DEBUG_WRITE("ResolveModuleCallback: Failed to compile module '%s'", absPath.c_str()); - ex.ReThrowToV8(); - return v8::MaybeLocal(); + resolverGlobal.Reset(); + } +} + +static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey) { + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return; + auto& httpDynamicWaiters = moduleState->httpDynamicWaiters; + moduleState->modulesInFlight.erase(registryKey); + + auto dynamicWaitIt = httpDynamicWaiters.find(registryKey); + if (dynamicWaitIt != httpDynamicWaiters.end()) { + std::vector> resolvers; + resolvers.swap(dynamicWaitIt->second); + httpDynamicWaiters.erase(dynamicWaitIt); + RejectResolversForInvalidation(isolate, context, resolvers, registryKey); + } + TNS_DEBUG(Esm, "[resolver][invalidate-state] cleared in-flight state for %s", + registryKey.c_str()); +} + +// ───────────────────────────────────────────────────────────── +// JSON module → synthetic ES module + +// Wrap JSON source as an ES module with the parsed value as its default +// export. Shared by the filesystem path and the HTTP path so a served JSON +// module and an imported .json file behave identically; `displayUrl` only +// names the module in stack traces. Handles registry insertion and eager +// evaluation. +static v8::MaybeLocal CompileJsonTextAsEsModule( + v8::Isolate* isolate, v8::Local context, + const std::string& jsonText, const std::string& registryAbsPath, + const std::string& displayUrl) { + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) { + return v8::MaybeLocal(); + } + auto& registry = moduleState->registry; + + // JSON modules are compiled eagerly to kEvaluated, so a registered entry is + // complete and must be reused — recompiling would mint a second module + // identity (and namespace) for the same source on every resolve. + auto existingIt = registry.find(registryAbsPath); + if (existingIt != registry.end()) { + v8::Local existing = existingIt->second.Get(isolate); + if (!existing.IsEmpty() && existing->GetStatus() == v8::Module::kEvaluated) { + return v8::MaybeLocal(existing); + } + UnindexRegistryKey(*moduleState, isolate, registryAbsPath); + existingIt->second.Reset(); + registry.erase(existingIt); + } + + TNS_DEBUG(Esm, "[json] wrapping %s", displayUrl.c_str()); + + std::string moduleSource = "export default " + jsonText + ";"; + v8::Local sourceText = + ArgConverter::ConvertToV8String(isolate, moduleSource); + const std::string& url = displayUrl; + + v8::Local urlString; + if (!v8::String::NewFromUtf8(isolate, url.c_str(), + v8::NewStringType::kNormal) + .ToLocal(&urlString)) { + isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "Failed to create URL string for JSON module"))); + return v8::MaybeLocal(); + } + + v8::ScriptOrigin origin(urlString, 0, 0, false, -1, v8::Local(), + false, false, true /* is_module */); + v8::ScriptCompiler::Source src(sourceText, origin); + + v8::Local jsonModule; + if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&jsonModule)) { + isolate->ThrowException(v8::Exception::SyntaxError( + ArgConverter::ConvertToV8String(isolate, "Failed to compile JSON module"))); + return v8::MaybeLocal(); + } + + if (!jsonModule->InstantiateModule(context, &ResolveModuleCallback) + .FromMaybe(false)) { + return v8::MaybeLocal(); + } + v8::MaybeLocal evalResult = jsonModule->Evaluate(context); + if (evalResult.IsEmpty()) return v8::MaybeLocal(); + + UnindexRegistryKey(*moduleState, isolate, registryAbsPath); + auto it = registry.find(registryAbsPath); + if (it != registry.end()) it->second.Reset(); + registry[registryAbsPath].Reset(isolate, jsonModule); + IndexRegisteredModule(*moduleState, registryAbsPath, jsonModule); + return v8::MaybeLocal(jsonModule); +} + +// The filesystem entry point: read the file, then share the wrap. +static v8::MaybeLocal CompileJsonAsEsModule( + v8::Isolate* isolate, v8::Local context, + const std::string& absPath, const std::string& registryAbsPath) { + Runtime* runtime = Runtime::TryGetRuntime(isolate); + if (runtime == nullptr) { + // Resolve-callback contract: an empty return needs an exception scheduled, + // and a C++ throw here would unwind through InstantiateModule. + isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "Cannot read JSON module " + absPath + ": the isolate has no runtime"))); + return v8::MaybeLocal(); + } + const std::string jsonText = runtime->ReadFileText(absPath); + return CompileJsonTextAsEsModule(isolate, context, jsonText, registryAbsPath, + "file://" + absPath); +} + +// ───────────────────────────────────────────────────────────── +// ResolveModuleCallback — invoked by V8 to resolve `import X from ''`. +// +// Every resolution decision belongs to ResolveSpecifierToPath, shared with the +// graph walk; what stays here is the V8-facing half — serving builtins, +// delegating HTTP, and compiling + registering a file. + +static v8::MaybeLocal LoadResolvedModule( + v8::Isolate* isolate, v8::Local context, + const ModuleResolution& resolution); + +v8::MaybeLocal ResolveModuleCallback( + v8::Local context, v8::Local specifier, + v8::Local /*import_assertions*/, + v8::Local referrer) { + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) { + return v8::MaybeLocal(); + } + + v8::String::Utf8Value specUtf8(isolate, specifier); + const std::string rawSpec = *specUtf8 ? *specUtf8 : ""; + if (rawSpec.empty()) return v8::MaybeLocal(); + + const std::string referrerPath = + FindKeyForModule(*moduleState, isolate, referrer); + return LoadResolvedModule(isolate, context, + ResolveSpecifierToPath(rawSpec, referrerPath)); +} + +// The loading half of resolution: everything that happens once a specifier has +// become a ModuleResolution. Split out so dynamic import() can route an +// already-resolved specifier here instead of resolving the string a second +// time — resolving twice would apply the import map twice, and the second pass +// has no referrer, so the two passes need not even agree. +static v8::MaybeLocal LoadResolvedModule( + v8::Isolate* isolate, v8::Local context, + const ModuleResolution& resolution) { + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) { + return v8::MaybeLocal(); + } + auto& registry = moduleState->registry; + + switch (resolution.kind) { + case ModuleResolution::Kind::kBuiltin: { + v8::Local builtin; + if (NsBuiltinModules::GetModule(context, resolution.specifier) + .ToLocal(&builtin)) { + return v8::MaybeLocal(builtin); + } + if (!NsBuiltinModules::IsRegistered(resolution.specifier)) { + isolate->ThrowException( + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, + NsBuiltinModules::NotFoundMessage(resolution.specifier)))); + } + return v8::MaybeLocal(); + } + case ModuleResolution::Kind::kHttp: + // Security: HttpFetchModule gates remote module access centrally. + return LoadHttpModuleForUrl(isolate, context, resolution.url); + case ModuleResolution::Kind::kUnresolved: { + // Surfaced as an exception rather than left to ReadFileText, which would + // abort trying to open a directory. + std::string msg = "Cannot find module '" + resolution.specifier + + "' (tried " + resolution.attempted + ")"; + isolate->ThrowException( + v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); + return v8::MaybeLocal(); } + case ModuleResolution::Kind::kFile: + break; + } + + const std::string& absPath = resolution.path; + const std::string registryAbsPath = CanonicalizeRegistryKey(absPath); + + // JSON module: compile a synthetic ESM. + if (EndsWith(absPath, ".json")) { + return CompileJsonAsEsModule(isolate, context, absPath, registryAbsPath); + } + + // Reuse any live, non-errored registry entry. The resolver never evaluates, + // so an unfinished entry (kUninstantiated / kInstantiating / kEvaluating) + // simply rejoins the graph V8 is currently linking — that is how import + // cycles terminate, the same way Node/Blink break them with the module-map + // self-insert. + auto it = registry.find(registryAbsPath); + if (it != registry.end()) { + v8::Local existing = it->second.Get(isolate); + if (!existing.IsEmpty() && existing->GetStatus() != v8::Module::kErrored) { + TNS_DEBUG(Esm, "[resolver] cache hit %s (status=%s)", absPath.c_str(), + ModuleStatusToString(existing->GetStatus())); + return v8::MaybeLocal(existing); + } + RemoveModuleFromRegistry(isolate, absPath); + } + + // Compile + register only — never instantiate or evaluate here. V8 is + // instantiating the importer and continues the graph walk by resolving this + // module's own requests next; evaluating inside the resolver would run + // dependencies in resolver order instead of the spec's evaluation order. + TNS_DEBUG(Esm, "[resolver] -> compile-register %s", absPath.c_str()); + try { + v8::Local mod; + if (!tns::ModuleInternal::CompileFileEsModule(isolate, absPath) + .ToLocal(&mod)) { + // The compile exception is pending on the isolate; V8 fails the + // importer's instantiation with it. + return v8::MaybeLocal(); + } + UnindexRegistryKey(*moduleState, isolate, registryAbsPath); + registry[registryAbsPath].Reset(isolate, mod); + IndexRegisteredModule(*moduleState, registryAbsPath, mod); + return v8::MaybeLocal(mod); + } catch (NativeScriptException& ex) { + TNS_DEBUG(Esm, "[resolver] failed to compile '%s' -> '%s'", + resolution.specifier.c_str(), absPath.c_str()); + ex.ReThrowToV8(); + return v8::MaybeLocal(); + } +} - // LoadESModule should have added it to g_moduleRegistry - auto it2 = g_moduleRegistry.find(absPath); - if (it2 == g_moduleRegistry.end()) { - // Something went wrong - std::string msg = "Failed to register compiled module: " + absPath; - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); +// ───────────────────────────────────────────────────────────── +// FinishHttpDynamicImport +// +// Called on the JS thread once the async graph walk has fetched (and +// registered as uninstantiated) the transitive closure for an HTTP dynamic +// import. Instantiates + evaluates the root and settles all queued +// dynamic-import waiters. Top-level await is fanned out to a Then handler so +// waiters only settle after the returned promise settles. +static void FinishHttpDynamicImport(v8::Isolate* isolate, + v8::Local context, + const std::string& key, + const std::string& requestUrl) { + if (LogCategoryEnabled(LogCategory::Esm)) { + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState != nullptr && + moduleState->registry.find(key) == moduleState->registry.end()) { + TNS_DEBUG(Esm, "[graph][fallback-sync-load] root missed walk: %s", + key.c_str()); } + } + // The loader throws the classifier's reason (status, MIME or transport) on + // failure; catch it here so it becomes the waiters' rejection instead of a + // generic message left beside a pending exception. + v8::TryCatch tcLoad(isolate); + v8::MaybeLocal modMaybe = + LoadHttpModuleForUrl(isolate, context, requestUrl); + if (!modMaybe.IsEmpty()) { + v8::Local mod; + if (modMaybe.ToLocal(&mod)) { + if (mod->GetStatus() == v8::Module::kUninstantiated) { + v8::TryCatch tcInstantiate(isolate); + if (!mod->InstantiateModule(context, &ResolveModuleCallback) + .FromMaybe(false)) { + RemoveModuleFromRegistry(isolate, key); + v8::Local reason = + BuildModuleFailureReason(isolate, tcInstantiate, + "Instantiation failed (http-loader)", + requestUrl); + tcInstantiate.Reset(); + RejectHttpDynamicWaiters(isolate, context, key, reason); + return; + } + } - return v8::MaybeLocal(it2->second.Get(isolate)); + if (IsModuleEvaluationInProgress(mod->GetStatus())) { + TNS_DEBUG(Esm, + "[dyn-import][http-loader] waiting on existing evaluation for %s status=%s", + key.c_str(), ModuleStatusToString(mod->GetStatus())); + return; + } + + if (mod->GetStatus() != v8::Module::kEvaluated) { + v8::Local evalResult; + { + v8::TryCatch tcEvaluate(isolate); + if (!mod->Evaluate(context).ToLocal(&evalResult)) { + RemoveModuleFromRegistry(isolate, key); + v8::Local reason = + BuildModuleFailureReason(isolate, tcEvaluate, + "Evaluation failed (http-loader)", + requestUrl); + tcEvaluate.Reset(); + RejectHttpDynamicWaiters(isolate, context, key, reason); + return; + } + } + if (!evalResult.IsEmpty() && evalResult->IsPromise()) { + v8::Local p = evalResult.As(); + struct EvalWaitData2 { + std::string key; + v8::Global ctx; + v8::Global mod; + }; + auto* data2 = new EvalWaitData2{ + key, v8::Global(isolate, context), + v8::Global(isolate, mod)}; + auto onFulfilled2 = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + std::string keyLocal = d->key; + v8::Local modLocal = d->mod.Get(iso); + ResolveHttpDynamicWaiters(iso, ctx, keyLocal, modLocal); + delete d; + }; + auto onRejected2 = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + std::string keyLocal = d->key; + v8::Local reason = + (info.Length() > 0) + ? info[0] + : v8::Exception::Error(ArgConverter::ConvertToV8String( + iso, "Evaluation failed (http-loader TLA)")); + if (LogCategoryEnabled(LogCategory::Esm)) { + v8::String::Utf8Value r(iso, reason); + if (*r) { + TNS_DEBUG(Esm, "[dyn-import][http-loader][tla] rejected: %s", *r); + } + } + RejectHttpDynamicWaiters(iso, ctx, keyLocal, reason); + delete d; + }; + v8::Local thenFulfillTpl2 = + v8::FunctionTemplate::New( + isolate, onFulfilled2, + v8::External::New(isolate, data2, + v8::kExternalPointerTypeTagDefault)); + v8::Local thenFulfill2 = + thenFulfillTpl2->GetFunction(context).ToLocalChecked(); + v8::Local thenRejectTpl2 = + v8::FunctionTemplate::New( + isolate, onRejected2, + v8::External::New(isolate, data2, + v8::kExternalPointerTypeTagDefault)); + v8::Local thenReject2 = + thenRejectTpl2->GetFunction(context).ToLocalChecked(); + p->Then(context, thenFulfill2, thenReject2) + .FromMaybe(v8::Local()); + return; + } + } + ResolveHttpDynamicWaiters(isolate, context, key, mod); + return; + } + } + v8::Local reason = + tcLoad.HasCaught() + ? tcLoad.Exception() + : v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "HTTP fetch/compile failed: " + requestUrl)); + tcLoad.Reset(); + RejectHttpDynamicWaiters(isolate, context, key, reason); } -// Dynamic import() host callback +// ───────────────────────────────────────────────────────────── +// ImportModuleDynamicallyCallback — host callback for `import()` expressions. +// +// Structure: builtins → one pass through the shared resolution seam → blob URL +// path → HTTP fast path (with coalescing + cache) → local module load → +// instantiate/evaluate/TLA settle. v8::MaybeLocal ImportModuleDynamicallyCallback( - v8::Local context, v8::Local host_defined_options, + v8::Local context, v8::Local /*host_defined_options*/, v8::Local resource_name, v8::Local specifier, - v8::Local import_assertions) { - v8::Isolate* isolate = v8::Isolate::GetCurrent(); - - // Convert specifier to std::string for logging - v8::String::Utf8Value specUtf8(isolate, specifier); - std::string spec = *specUtf8 ? *specUtf8 : ""; - - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ImportModuleDynamicallyCallback: Dynamic import for '%s'", spec.c_str()); - } - - v8::EscapableHandleScope scope(isolate); - - // Create a Promise resolver we'll resolve/reject synchronously for now. - v8::Local resolver; - if (!v8::Promise::Resolver::New(context).ToLocal(&resolver)) { - // Failed to create resolver, return empty promise - return v8::MaybeLocal(); - } - - // Builtin modules never reach the loader below; the namespace comes - // straight from the realm's synthetic module. - if (NsBuiltinModules::IsRegistered(spec) || NsBuiltinModules::IsNsScheme(spec)) { - v8::TryCatch tc(isolate); - v8::Local builtin; - if (NsBuiltinModules::GetModule(context, spec).ToLocal(&builtin)) { - resolver->Resolve(context, builtin->GetModuleNamespace()).FromMaybe(false); + v8::Local /*import_assertions*/) { + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) { + return v8::MaybeLocal(); + } + auto& registry = moduleState->registry; + auto& modulesInFlight = moduleState->modulesInFlight; + auto& httpDynamicWaiters = moduleState->httpDynamicWaiters; + + v8::String::Utf8Value specUtf8(isolate, specifier); + const char* cSpec = (*specUtf8) ? *specUtf8 : ""; + TNS_DEBUG(Esm, "[dyn-import] -> %s", cSpec); + if (LogCategoryEnabled(LogCategory::Esm)) { + v8::Local resName = resource_name; + if (!resName.IsEmpty() && resName->IsString()) { + v8::String::Utf8Value rn(isolate, resName); + if (*rn) { + TNS_DEBUG(Esm, "[dyn-import][referrer] %s", *rn); + } + } + } + + std::string rawSpec = cSpec ? std::string(cSpec) : std::string(); + + // Builtin modules never touch the loader below; the namespace comes straight + // from the realm's synthetic module. + if (NsBuiltinModules::IsBuiltinScheme(rawSpec)) { + v8::EscapableHandleScope builtinScope(isolate); + v8::Local builtinResolver; + if (!v8::Promise::Resolver::New(context).ToLocal(&builtinResolver)) { + return v8::MaybeLocal(); + } + v8::TryCatch tc(isolate); + v8::Local builtin; + if (NsBuiltinModules::GetModule(context, rawSpec).ToLocal(&builtin)) { + builtinResolver->Resolve(context, builtin->GetModuleNamespace()) + .FromMaybe(false); + } else { + v8::Local error = + tc.HasCaught() + ? tc.Exception() + : v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, NsBuiltinModules::NotFoundMessage(rawSpec))); + // Reject must not run with a pending exception on the isolate. + tc.Reset(); + builtinResolver->Reject(context, error).FromMaybe(false); + } + return builtinScope.Escape(builtinResolver->GetPromise()); + } + + std::string normalizedSpec = rawSpec; + // remove query/hash ONLY for non-HTTP specs + bool isHttpLike = + (!normalizedSpec.empty() && (StartsWith(normalizedSpec, "http://") || + StartsWith(normalizedSpec, "https://"))); + if (!isHttpLike) { + size_t qpos = normalizedSpec.find_first_of("?#"); + if (qpos != std::string::npos) { + normalizedSpec = normalizedSpec.substr(0, qpos); + } + } + if (normalizedSpec != rawSpec) { + TNS_DEBUG(Esm, "[dyn-import][normalize] %s -> %s", rawSpec.c_str(), + normalizedSpec.c_str()); + } + + v8::EscapableHandleScope scope(isolate); + + v8::Local resolver; + if (!v8::Promise::Resolver::New(context).ToLocal(&resolver)) { + return v8::MaybeLocal(); + } + + // ── Resolution for dynamic import() ── + // The specifier goes through the shared seam exactly once, with the referrer + // key taken from the host-supplied resource name and canonicalized the way + // the registry keys it: a scope matches an import() exactly as it matches a + // static import from the same module. Everything below routes THIS + // resolution — re-resolving the string further down would run the import map + // a second time, and that pass would have no referrer. + const LoaderVocabulary& vocabulary = moduleState->vocabulary; + std::string dynamicReferrerKey; + if (!resource_name.IsEmpty() && resource_name->IsString()) { + v8::String::Utf8Value resourceUtf8(isolate, resource_name); + if (*resourceUtf8) { + dynamicReferrerKey = CanonicalizeRegistryKey(*resourceUtf8); + } + } + const ModuleResolution dynamicResolution = + ResolveSpecifierToPath(normalizedSpec, dynamicReferrerKey); + if (!dynamicResolution.specifier.empty() && + dynamicResolution.specifier != normalizedSpec) { + normalizedSpec = dynamicResolution.specifier; + TNS_DEBUG(Esm, "[dyn-import][import-map] rewrite: %s -> %s", + rawSpec.c_str(), normalizedSpec.c_str()); + } + + try { + // ── Blob URL support (e.g. blob:nativescript/) ── + // Retrieve the blob content from the global BLOB_STORE via + // URL.InternalAccessor.getData() (installed by Android's blob-url.js) and + // compile it as an ES module. + if (!normalizedSpec.empty() && + StartsWith(normalizedSpec, "blob:nativescript/")) { + const std::string blobRegistryKey = CanonicalizeRegistryKey(normalizedSpec); + TNS_DEBUG(Esm, "[dyn-import][blob] trying blob URL %s key=%s", + normalizedSpec.c_str(), blobRegistryKey.c_str()); + + auto existingIt = registry.find(blobRegistryKey); + if (existingIt != registry.end()) { + v8::Local existing = existingIt->second.Get(isolate); + if (!existing.IsEmpty()) { + v8::Module::Status existingStatus = existing->GetStatus(); + TNS_DEBUG(Esm, "[dyn-import][blob-cache] hit %s status=%s", + blobRegistryKey.c_str(), + ModuleStatusToString(existingStatus)); + if (existingStatus == v8::Module::kErrored) { + RemoveModuleFromRegistry(isolate, blobRegistryKey); + } else if (IsModuleEvaluationInProgress(existingStatus)) { + modulesInFlight.insert(blobRegistryKey); + httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); + TNS_DEBUG(Esm, + "[dyn-import][blob-await] queued waiter for %s status=%s", + blobRegistryKey.c_str(), ModuleStatusToString(existingStatus)); + return scope.Escape(resolver->GetPromise()); + } else { + resolver->Resolve(context, existing->GetModuleNamespace()) + .FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } } else { - v8::Local error = - tc.HasCaught() ? tc.Exception() - : v8::Exception::Error(ArgConverter::ConvertToV8String( - isolate, NsBuiltinModules::NotFoundMessage(spec))); - // Reject must not run with the exception still pending on the isolate. - tc.Reset(); - resolver->Reject(context, error).FromMaybe(false); + RemoveModuleFromRegistry(isolate, blobRegistryKey); } - return scope.Escape(resolver->GetPromise()); - } + } - // Resolve relative or root-absolute dynamic imports against the referrer's URL when provided - auto isHttpLike = [](const std::string& s) -> bool { - return s.rfind("http://", 0) == 0 || s.rfind("https://", 0) == 0; - }; - bool specIsRelative = !spec.empty() && spec[0] == '.'; - bool specIsRootAbs = !spec.empty() && spec[0] == '/'; - std::string referrerUrl; - if (!resource_name.IsEmpty() && resource_name->IsString()) { - v8::String::Utf8Value r8(isolate, resource_name); - referrerUrl = *r8 ? *r8 : ""; - } - if ((specIsRelative || specIsRootAbs) && isHttpLike(referrerUrl)) { - std::string resolved = ResolveHttpRelative(referrerUrl, spec); - if (!resolved.empty()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][http-rel] base=%s spec=%s -> %s", referrerUrl.c_str(), spec.c_str(), resolved.c_str()); + if (modulesInFlight.find(blobRegistryKey) != modulesInFlight.end()) { + TNS_DEBUG(Esm, "[dyn-import][blob] coalesce in-flight %s", + blobRegistryKey.c_str()); + httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); + return scope.Escape(resolver->GetPromise()); + } + + modulesInFlight.insert(blobRegistryKey); + httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); + + v8::TryCatch tc(isolate); + v8::Local globalObj = context->Global(); + + v8::Local urlCtorVal; + if (!globalObj + ->Get(context, ArgConverter::ConvertToV8String(isolate, "URL")) + .ToLocal(&urlCtorVal) || + !urlCtorVal->IsFunction()) { + TNS_DEBUG(Esm, "[dyn-import][blob] URL constructor not found"); + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "URL constructor not available"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local urlCtor = urlCtorVal.As(); + + v8::Local internalAccessorVal; + if (!urlCtor + ->Get(context, ArgConverter::ConvertToV8String(isolate, + "InternalAccessor")) + .ToLocal(&internalAccessorVal) || + !internalAccessorVal->IsObject()) { + TNS_DEBUG(Esm, "[dyn-import][blob] URL.InternalAccessor not found"); + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "URL.InternalAccessor not available"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local internalAccessor = + internalAccessorVal.As(); + + v8::Local getDataVal; + if (!internalAccessor + ->Get(context, + ArgConverter::ConvertToV8String(isolate, "getData")) + .ToLocal(&getDataVal) || + !getDataVal->IsFunction()) { + TNS_DEBUG(Esm, "[dyn-import][blob] URL.InternalAccessor.getData not found"); + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "URL.InternalAccessor.getData not available"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local getDataFn = getDataVal.As(); + + v8::Local urlArg = + ArgConverter::ConvertToV8String(isolate, normalizedSpec); + v8::Local blobDataVal; + if (!getDataFn->Call(context, internalAccessor, 1, &urlArg) + .ToLocal(&blobDataVal) || + blobDataVal->IsNullOrUndefined()) { + TNS_DEBUG(Esm, "[dyn-import][blob] blob not found in BLOB_STORE: %s", + normalizedSpec.c_str()); + std::string msg = "Blob not found: " + normalizedSpec; + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); + return scope.Escape(resolver->GetPromise()); + } + + if (!blobDataVal->IsObject()) { + TNS_DEBUG(Esm, "[dyn-import][blob] blob data is not an object"); + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, "Invalid blob data"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local blobData = blobDataVal.As(); + + v8::Local blobVal; + if (!blobData + ->Get(context, ArgConverter::ConvertToV8String(isolate, "blob")) + .ToLocal(&blobVal) || + !blobVal->IsObject()) { + TNS_DEBUG(Esm, "[dyn-import][blob] blob property not found"); + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "Blob object not found"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local blobObj = blobVal.As(); + + v8::Local textFnVal; + if (!blobObj + ->Get(context, ArgConverter::ConvertToV8String(isolate, "text")) + .ToLocal(&textFnVal) || + !textFnVal->IsFunction()) { + TNS_DEBUG(Esm, "[dyn-import][blob] Blob.text() not available"); + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "Blob.text() not available"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local textFn = textFnVal.As(); + + // Keep the two failure modes distinct — a throw out of text() and a + // non-thenable return — and carry the thrown value's text into the + // rejection to preserve diagnostics. + v8::Local textResultVal; + std::string textFailure; + { + v8::TryCatch textTc(isolate); + if (!textFn->Call(context, blobObj, 0, nullptr) + .ToLocal(&textResultVal)) { + textFailure = "Blob.text() threw"; + if (textTc.HasCaught()) { + v8::String::Utf8Value thrown(isolate, textTc.Exception()); + if (*thrown) { + textFailure += std::string(": ") + *thrown; } - spec = resolved; - } else if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][http-rel][skip] base=%s spec=%s", referrerUrl.c_str(), spec.c_str()); + } } - } - - // Handle HTTP(S) dynamic import directly - // Security: HttpFetchText gates remote module access centrally. - if (!spec.empty() && isHttpLike(spec)) { - std::string canonical = tns::CanonicalizeHttpUrlKey(spec); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][resolve] spec=%s canonical=%s", spec.c_str(), canonical.c_str()); + } + + v8::Local textPromise; + if (textFailure.empty() && + !AdoptThenable(isolate, context, textResultVal).ToLocal(&textPromise)) { + textFailure = "Blob.text() did not return a thenable"; + } + if (!textFailure.empty()) { + TNS_DEBUG(Esm, "[dyn-import][blob] %s", textFailure.c_str()); + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, textFailure))); + return scope.Escape(resolver->GetPromise()); + } + + struct BlobImportData { + v8::Global ctx; + std::string blobUrl; + std::string registryKey; + }; + auto* data = new BlobImportData{v8::Global(isolate, context), + normalizedSpec, blobRegistryKey}; + + auto onFulfilled = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + + if (info.Length() < 1 || !info[0]->IsString()) { + RejectHttpDynamicWaiters(iso, ctx, d->registryKey, + v8::Exception::Error( + ArgConverter::ConvertToV8String( + iso, "Blob text is not a string"))); + delete d; + return; } + + v8::String::Utf8Value codeUtf8(iso, info[0]); + std::string code = *codeUtf8 ? *codeUtf8 : ""; + + TNS_DEBUG(Esm, "[dyn-import][blob] compiling blob module, code length=%zu", + code.size()); + v8::Local mod; - auto it = g_moduleRegistry.find(canonical); - if (it != g_moduleRegistry.end()) { - mod = it->second.Get(isolate); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][cache] hit %s", canonical.c_str()); - } - } else { - std::string body, ct; int status = 0; - if (!tns::HttpFetchText(spec, body, ct, status)) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][fetch][fail] url=%s status=%d", spec.c_str(), status); - } - resolver->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, std::string("Failed to fetch ")+spec))).Check(); - return scope.Escape(resolver->GetPromise()); - } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][fetch][ok] url=%s status=%d bytes=%lu ct=%s", spec.c_str(), status, (unsigned long)body.size(), ct.c_str()); - } - v8::Local sourceText = ArgConverter::ConvertToV8String(isolate, body); - v8::Local urlString = ArgConverter::ConvertToV8String(isolate, canonical); - v8::ScriptOrigin origin(urlString, 0, 0, false, -1, v8::Local(), false, false, true); - v8::ScriptCompiler::Source src(sourceText, origin); - { - v8::TryCatch tc(isolate); - if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&mod)) { - LogHttpCompileDiagnostics(isolate, context, canonical, body, tc); - resolver->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "HTTP module compile failed"))).Check(); - return scope.Escape(resolver->GetPromise()); - } - } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][compile][ok] %s bytes=%lu", canonical.c_str(), (unsigned long)body.size()); - } - g_moduleRegistry[canonical].Reset(isolate, mod); + bool compiled = false; + std::string compileError; + { + // A pending exception would escape this callback into V8's promise + // machinery; the waiters are this path's failure channel. + v8::TryCatch tcCompile(iso); + compiled = CompileModuleForResolveRegisterOnly(iso, ctx, code, d->blobUrl) + .ToLocal(&mod); + if (!compiled) { + compileError = DescribeCaughtError(iso, ctx, tcCompile); + } } + if (!compiled) { + std::string msg = "Failed to compile blob module"; + if (!compileError.empty()) { + msg += ": " + compileError; + } + RejectHttpDynamicWaiters( + iso, ctx, d->registryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String(iso, msg))); + delete d; + return; + } + if (mod->GetStatus() == v8::Module::kUninstantiated) { - if (!mod->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { - resolver->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "Instantiate failed"))).Check(); - return scope.Escape(resolver->GetPromise()); - } + v8::TryCatch tcInstantiate(iso); + if (!mod->InstantiateModule(ctx, &ResolveModuleCallback) + .FromMaybe(false)) { + RemoveModuleFromRegistry(iso, d->registryKey); + v8::Local reason = BuildModuleFailureReason( + iso, tcInstantiate, "Failed to instantiate blob module", + d->registryKey); + tcInstantiate.Reset(); + RejectHttpDynamicWaiters(iso, ctx, d->registryKey, reason); + delete d; + return; + } } - if (mod->GetStatus() != v8::Module::kEvaluated) { - if (mod->Evaluate(context).IsEmpty()) { - resolver->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "Evaluation failed"))).Check(); - return scope.Escape(resolver->GetPromise()); - } + + if (IsModuleEvaluationInProgress(mod->GetStatus())) { + TNS_DEBUG(Esm, + "[dyn-import][blob] waiting on existing evaluation for %s status=%s", + d->registryKey.c_str(), ModuleStatusToString(mod->GetStatus())); + delete d; + return; } - resolver->Resolve(context, mod->GetModuleNamespace()).Check(); - return scope.Escape(resolver->GetPromise()); - } - // Re-use the static resolver to locate / compile the module for non-HTTP cases. - try { - // V8 exposes only the referrer's URL here (resource_name), not its Module, - // so anchor a relative specifier at the referrer's directory and hand the - // resolver an absolute file:// URL. Other specifiers pass through unchanged - // (the resolver applies its own ~/, bare and absolute heuristics). - v8::Local resolvedSpecifier = specifier; - if (specIsRelative) { - std::string fileResolved = ResolveFileRelative(referrerUrl, spec); - if (!fileResolved.empty()) { - resolvedSpecifier = ArgConverter::ConvertToV8String(isolate, fileResolved); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[esm][dyn][file-rel] base=%s spec=%s -> %s", - referrerUrl.c_str(), spec.c_str(), fileResolved.c_str()); - } + if (mod->GetStatus() != v8::Module::kEvaluated) { + v8::Local evalResult; + { + v8::TryCatch tcEvaluate(iso); + if (!mod->Evaluate(ctx).ToLocal(&evalResult)) { + RemoveModuleFromRegistry(iso, d->registryKey); + v8::Local reason = BuildModuleFailureReason( + iso, tcEvaluate, "Failed to evaluate blob module", + d->registryKey); + tcEvaluate.Reset(); + RejectHttpDynamicWaiters(iso, ctx, d->registryKey, reason); + delete d; + return; } + } + + if (!evalResult.IsEmpty() && evalResult->IsPromise()) { + struct BlobEvalData { + std::string registryKey; + v8::Global ctx; + v8::Global mod; + }; + auto* evalData = new BlobEvalData{ + d->registryKey, v8::Global(iso, ctx), + v8::Global(iso, mod)}; + + auto onEvalFulfilled = + [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + v8::Local mod = d->mod.Get(iso); + ResolveHttpDynamicWaiters(iso, ctx, d->registryKey, mod); + delete d; + }; + + auto onEvalRejected = + [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + v8::Local reason = + info.Length() > 0 + ? info[0] + : v8::Exception::Error( + ArgConverter::ConvertToV8String( + iso, "Blob module evaluation failed")); + RemoveModuleFromRegistry(iso, d->registryKey); + RejectHttpDynamicWaiters(iso, ctx, d->registryKey, reason); + delete d; + }; + + v8::Local evalPromise = evalResult.As(); + v8::Local onEvalFulfilledFn = + v8::Function::New( + ctx, onEvalFulfilled, + v8::External::New(iso, evalData, + v8::kExternalPointerTypeTagDefault)) + .ToLocalChecked(); + v8::Local onEvalRejectedFn = + v8::Function::New( + ctx, onEvalRejected, + v8::External::New(iso, evalData, + v8::kExternalPointerTypeTagDefault)) + .ToLocalChecked(); + evalPromise->Then(ctx, onEvalFulfilledFn, onEvalRejectedFn) + .FromMaybe(v8::Local()); + delete d; + return; + } } - // Pass empty referrer: this V8 version does not expose GetModule() on - // ScriptOrModule, and the specifier above is already absolute when needed. - v8::Local refMod; + ResolveHttpDynamicWaiters(iso, ctx, d->registryKey, mod); + delete d; + }; + + auto onRejected = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + v8::Local reason = + info.Length() > 0 + ? info[0] + : v8::Exception::Error(ArgConverter::ConvertToV8String( + iso, "Blob text() failed")); + RejectHttpDynamicWaiters(iso, ctx, d->registryKey, reason); + delete d; + }; + + v8::Local onFulfilledFn = + v8::Function::New( + context, onFulfilled, + v8::External::New(isolate, data, + v8::kExternalPointerTypeTagDefault)) + .ToLocalChecked(); + v8::Local onRejectedFn = + v8::Function::New( + context, onRejected, + v8::External::New(isolate, data, + v8::kExternalPointerTypeTagDefault)) + .ToLocalChecked(); + + textPromise->Then(context, onFulfilledFn, onRejectedFn) + .FromMaybe(v8::Local()); + + return scope.Escape(resolver->GetPromise()); + } - v8::Local module; - { - v8::TryCatch resolveTc(isolate); - v8::MaybeLocal maybeModule = - ResolveModuleCallback(context, resolvedSpecifier, import_assertions, refMod); - - if (!maybeModule.ToLocal(&module)) { - // Resolution failed; reject to avoid leaving a pending Promise (white screen) - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ImportModuleDynamicallyCallback: Resolution failed for '%s'", spec.c_str()); - } - // The resolver's own error carries the reason (a missing - // builtin names the exact contract message); only invent one - // when resolution failed without throwing. - v8::Local ex = - resolveTc.HasCaught() - ? resolveTc.Exception() - : v8::Exception::Error(ArgConverter::ConvertToV8String( - isolate, std::string("Failed to resolve module: ") + spec)); - resolveTc.Reset(); - resolver->Reject(context, ex).Check(); - return scope.Escape(resolver->GetPromise()); - } + // ── HTTP(S) fast path ── + // Security: HttpFetchModule gates remote module access centrally. + if (!normalizedSpec.empty() && + (StartsWith(normalizedSpec, "http://") || + StartsWith(normalizedSpec, "https://"))) { + TNS_DEBUG(Esm, "[dyn-import][http-loader] trying URL %s", + normalizedSpec.c_str()); + std::string key = CanonicalizeHttpUrlKey(normalizedSpec); + + // Volatile-pattern eviction: if the URL matches any configured volatile + // pattern, evict the cached module so we always re-fetch. Policy is + // supplied exclusively by JS via ns:module `configureLoader({ + // volatilePatterns })` — the runtime carries no framework or server URL + // vocabulary of its own. + bool isVolatile = IsVolatileUrl(vocabulary, normalizedSpec); + if (isVolatile) { + auto ex = registry.find(key); + if (ex != registry.end()) { + TNS_DEBUG(Esm, "[dyn-import][http-cache] drop volatile %s", key.c_str()); + RemoveModuleFromRegistry(isolate, key); } - - // If not yet instantiated/evaluated, do it now - if (module->GetStatus() == v8::Module::kUninstantiated) { - if (!module->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ImportModuleDynamicallyCallback: Instantiate failed for '%s'", spec.c_str()); - } - resolver - ->Reject(context, - v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "Failed to instantiate module"))) - .Check(); - return scope.Escape(resolver->GetPromise()); + } + // Coalesce concurrent dynamic imports for the same HTTP key. + auto inflight = modulesInFlight.find(key) != modulesInFlight.end(); + if (inflight) { + TNS_DEBUG(Esm, "[dyn-import][http] coalesce in-flight %s", key.c_str()); + httpDynamicWaiters[key].emplace_back(isolate, resolver); + return scope.Escape(resolver->GetPromise()); + } + // If module was already compiled, resolve immediately. + auto itExisting = registry.find(key); + if (itExisting != registry.end()) { + v8::Local existing = itExisting->second.Get(isolate); + if (!existing.IsEmpty()) { + TNS_DEBUG(Esm, "[dyn-import][http-cache] hit %s status=%s", key.c_str(), + ModuleStatusToString(existing->GetStatus())); + v8::Module::Status st = existing->GetStatus(); + if (st == v8::Module::kErrored) { + TNS_DEBUG(Esm, "[dyn-import][http-cache] dropping errored module for %s", + key.c_str()); + RemoveModuleFromRegistry(isolate, key); + } else if (IsModuleEvaluationInProgress(st)) { + if (QueueHttpDynamicWaiterIfInFlight(isolate, key, existing, + resolver)) { + return scope.Escape(resolver->GetPromise()); } - } + TNS_DEBUG(Esm, + "[dyn-import][http-cache] avoiding re-entrant Evaluate for %s status=%s", + key.c_str(), ModuleStatusToString(st)); + resolver->Resolve(context, existing->GetModuleNamespace()) + .FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } else { + if (st != v8::Module::kEvaluated) { + modulesInFlight.insert(key); + TNS_DEBUG(Esm, "[dyn-import][http-cache] awaiting evaluation %s", + key.c_str()); + httpDynamicWaiters[key].emplace_back(isolate, resolver); + if (st == v8::Module::kUninstantiated) { + v8::TryCatch tcInstantiate(isolate); + if (!existing->InstantiateModule(context, &ResolveModuleCallback) + .FromMaybe(false)) { + RemoveModuleFromRegistry(isolate, key); + v8::Local reason = BuildModuleFailureReason( + isolate, tcInstantiate, + "Instantiation failed (http-cache hit)", key); + tcInstantiate.Reset(); + RejectHttpDynamicWaiters(isolate, context, key, reason); + return scope.Escape(resolver->GetPromise()); + } + } - if (module->GetStatus() != v8::Module::kEvaluated) { - if (module->Evaluate(context).IsEmpty()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ImportModuleDynamicallyCallback: Evaluation failed for '%s'", spec.c_str()); + if (IsModuleEvaluationInProgress(existing->GetStatus())) { + return scope.Escape(resolver->GetPromise()); + } + + v8::Local evalResult; + { + v8::TryCatch tcEvaluate(isolate); + if (!existing->Evaluate(context).ToLocal(&evalResult)) { + RemoveModuleFromRegistry(isolate, key); + v8::Local reason = BuildModuleFailureReason( + isolate, tcEvaluate, "Evaluation failed (http-cache hit)", + key); + tcEvaluate.Reset(); + RejectHttpDynamicWaiters(isolate, context, key, reason); + return scope.Escape(resolver->GetPromise()); } - v8::Local ex = - v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "Evaluation failed")); - resolver->Reject(context, ex).Check(); + } + if (!evalResult.IsEmpty() && evalResult->IsPromise()) { + v8::Local p = evalResult.As(); + struct EvalWaitData { + std::string key; + v8::Global ctx; + v8::Global mod; + }; + auto* data = new EvalWaitData{ + key, v8::Global(isolate, context), + v8::Global(isolate, existing)}; + auto onFulfilled = + [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + std::string keyLocal = d->key; + v8::Local modLocal = d->mod.Get(iso); + ResolveHttpDynamicWaiters(iso, ctx, keyLocal, modLocal); + delete d; + }; + auto onRejected = + [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + std::string keyLocal = d->key; + v8::Local reason = + (info.Length() > 0) + ? info[0] + : v8::Exception::Error( + ArgConverter::ConvertToV8String( + iso, "Evaluation failed (http-cache TLA)")); + if (LogCategoryEnabled(LogCategory::Esm)) { + v8::String::Utf8Value r(iso, reason); + if (*r) { + TNS_DEBUG(Esm, + "[dyn-import][http-cache][tla] rejected: %s", + *r); + } + } + RejectHttpDynamicWaiters(iso, ctx, keyLocal, reason); + delete d; + }; + v8::Local thenFulfillTpl = + v8::FunctionTemplate::New( + isolate, onFulfilled, + v8::External::New(isolate, data, + v8::kExternalPointerTypeTagDefault)); + v8::Local thenFulfill = + thenFulfillTpl->GetFunction(context).ToLocalChecked(); + v8::Local thenRejectTpl = + v8::FunctionTemplate::New( + isolate, onRejected, + v8::External::New(isolate, data, + v8::kExternalPointerTypeTagDefault)); + v8::Local thenReject = + thenRejectTpl->GetFunction(context).ToLocalChecked(); + p->Then(context, thenFulfill, thenReject) + .FromMaybe(v8::Local()); return scope.Escape(resolver->GetPromise()); + } + ResolveHttpDynamicWaiters(isolate, context, key, existing); + return scope.Escape(resolver->GetPromise()); } + resolver->Resolve(context, existing->GetModuleNamespace()) + .FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } } + } + // Mark in-flight and start the async graph load. + modulesInFlight.insert(key); + httpDynamicWaiters[key].emplace_back(isolate, resolver); + const std::string requestUrl = normalizedSpec; + StartModuleGraphLoad( + isolate, context, requestUrl, + [key, requestUrl, isolate](bool ok, const std::string& errorMessage, + v8::Local completionContext) { + v8::Isolate* iso = isolate; + if (!ok) { + RejectHttpDynamicWaiters( + iso, completionContext, key, + v8::Exception::Error( + ArgConverter::ConvertToV8String(iso, errorMessage))); + return; + } + FinishHttpDynamicImport(iso, completionContext, key, requestUrl); + }); + return scope.Escape(resolver->GetPromise()); + } - resolver->Resolve(context, module->GetModuleNamespace()).Check(); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ImportModuleDynamicallyCallback: Successfully resolved '%s'", spec.c_str()); - } - } catch (NativeScriptException& ex) { - ex.ReThrowToV8(); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ImportModuleDynamicallyCallback: Native exception for '%s'", spec.c_str()); + // ── Local path ── + // Discovery pre-pass, the same one the static path runs: a local graph can + // reach HTTP edges, and without the walk those meet the resolver cold and + // fetch serially, one blocking round trip each. A graph with no HTTP edges + // settles inside the call, so a local-only dynamic import is unchanged — + // it neither waits nor touches the looper. + if (dynamicResolution.kind == ModuleResolution::Kind::kFile) { + RunModuleGraphLoadPumped(isolate, context, dynamicResolution.path, + kModuleEvaluateDeadlineSeconds); + } + + v8::TryCatch resolveTc(isolate); + v8::MaybeLocal maybeModule = + LoadResolvedModule(isolate, context, dynamicResolution); + TNS_DEBUG(Esm, "[dyn-import][resolver-call] raw=%s resolved=%s", + rawSpec.c_str(), normalizedSpec.c_str()); + if (maybeModule.IsEmpty()) { + if (resolveTc.HasCaught()) { + v8::Local resolveError = resolveTc.Exception(); + resolveTc.Reset(); + resolver->Reject(context, resolveError).FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } else { + std::string msg = "Module resolution failed for dynamic import: "; + msg += normalizedSpec.empty() ? "" : normalizedSpec; + resolver + ->Reject(context, v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, msg))) + .FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } + } + + v8::Local module = maybeModule.ToLocalChecked(); + + if (module->GetStatus() == v8::Module::kUninstantiated) { + v8::TryCatch ictc(isolate); + if (!module->InstantiateModule(context, &ResolveModuleCallback) + .FromMaybe(false)) { + TNS_DEBUG(Esm, "[dyn-import] instantiate failed %s", + normalizedSpec.c_str()); + std::string msg = + std::string("Failed to instantiate module: ") + normalizedSpec; + if (ictc.HasCaught()) { + std::string exStr = ArgConverter::ToString(isolate, ictc.Exception()); + if (!exStr.empty()) { + msg.append(" — "); + msg.append(exStr); + } } + ictc.Reset(); resolver ->Reject(context, v8::Exception::Error( - ArgConverter::ConvertToV8String(isolate, "Native error during dynamic import"))) - .Check(); + ArgConverter::ConvertToV8String(isolate, msg))) + .FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } + } + + // A kEvaluating module (TLA in flight, or a cycle re-entry) falls through + // deliberately: Evaluate() on an already-evaluating module returns its + // existing top-level capability promise, so the TLA chain below coalesces + // this import with the in-flight evaluation. + if (module->GetStatus() != v8::Module::kEvaluated) { + v8::Local evalResult; + if (!module->Evaluate(context).ToLocal(&evalResult)) { + TNS_DEBUG(Esm, "[dyn-import] evaluation failed %s", + normalizedSpec.c_str()); + v8::Local ex = BuildModuleFailureReason( + isolate, resolveTc, "Evaluation failed for module", normalizedSpec); + resolveTc.Reset(); + resolver->Reject(context, ex).FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } + if (!evalResult.IsEmpty() && evalResult->IsPromise()) { + v8::Local p = evalResult.As(); + struct DynEvalData { + v8::Global ctx; + v8::Global mod; + v8::Global res; + }; + auto* d = new DynEvalData{ + v8::Global(isolate, context), + v8::Global(isolate, module), + v8::Global(isolate, resolver)}; + auto onFulfilled = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + v8::Local modLocal = d->mod.Get(iso); + v8::Local res = d->res.Get(iso); + TNS_DEBUG(Esm, "[dyn-import][tla] fulfilled, resolving namespace"); + if (!res.IsEmpty()) + res->Resolve(ctx, modLocal->GetModuleNamespace()).FromMaybe(false); + delete d; + }; + auto onRejected = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + v8::Local res = d->res.Get(iso); + v8::Local reason = + (info.Length() > 0) + ? info[0] + : v8::Exception::Error(ArgConverter::ConvertToV8String( + iso, "Evaluation failed (TLA)")); + if (LogCategoryEnabled(LogCategory::Esm)) { + v8::String::Utf8Value r(iso, reason); + if (*r) { + TNS_DEBUG(Esm, "[dyn-import][tla] rejected: %s", *r); + } + } + if (!res.IsEmpty()) res->Reject(ctx, reason).FromMaybe(false); + delete d; + }; + v8::Local fulfillTpl = v8::FunctionTemplate::New( + isolate, onFulfilled, + v8::External::New(isolate, d, v8::kExternalPointerTypeTagDefault)); + v8::Local fulfill = + fulfillTpl->GetFunction(context).ToLocalChecked(); + v8::Local rejectTpl = v8::FunctionTemplate::New( + isolate, onRejected, + v8::External::New(isolate, d, v8::kExternalPointerTypeTagDefault)); + v8::Local reject = + rejectTpl->GetFunction(context).ToLocalChecked(); + p->Then(context, fulfill, reject).FromMaybe(v8::Local()); + return scope.Escape(resolver->GetPromise()); + } } - return scope.Escape(resolver->GetPromise()); + // Final verify before resolving for non-HTTP paths. + v8::Local nsFinal = module->GetModuleNamespace(); + if (nsFinal->IsObject()) { + v8::Local o = nsFinal.As(); + v8::TryCatch tc3(isolate); + v8::Local defVal; + if (!o->Get(context, ArgConverter::ConvertToV8String(isolate, "default")) + .ToLocal(&defVal)) { + TNS_DEBUG(Esm, + "[dyn-import][verify] ns.default threw after eval (generic) %s", + normalizedSpec.c_str()); + v8::Local tdzError = + tc3.HasCaught() + ? tc3.Exception() + : v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "TDZ on default after eval (generic)")); + tc3.Reset(); + resolver->Reject(context, tdzError).FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } + } + { + // Resolving reads `then` off the namespace; a module exporting `then` + // can make that read throw (TDZ in a cycle) — that is the importer's + // rejection, never a CHECK. + v8::TryCatch tcResolve(isolate); + if (resolver->Resolve(context, module->GetModuleNamespace()).IsNothing()) { + v8::Local reason = + tcResolve.HasCaught() + ? tcResolve.Exception() + : v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, + "Cannot resolve the namespace of " + normalizedSpec)); + tcResolve.Reset(); + resolver->Reject(context, reason).FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } + } + TNS_DEBUG(Esm, "[dyn-import] resolved %s", normalizedSpec.c_str()); + } catch (NativeScriptException& ex) { + TNS_DEBUG(Esm, "[dyn-import] native failed %s", normalizedSpec.c_str()); + // v8-callbacks.h: this callback must reject the promise it returns and + // leave nothing scheduled on the isolate, so the exception is caught back + // out of ReThrowToV8 and becomes the rejection reason instead. + v8::TryCatch nativeTc(isolate); + ex.ReThrowToV8(); + v8::Local error = + nativeTc.HasCaught() + ? nativeTc.Exception() + : v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "Native error during dynamic import")); + nativeTc.Reset(); + resolver->Reject(context, error).FromMaybe(false); + } + + return scope.Escape(resolver->GetPromise()); +} + +// ───────────────────────────────────────────────────────────── +// InitializeImportMetaObject — populates `import.meta.url` and +// `import.meta.dirname`. `import.meta.hot` is JS policy and is deliberately +// NOT set here (matches the port spec). +void InitializeImportMetaObject(v8::Local context, + v8::Local module, + v8::Local meta) { + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return; + + const std::string modulePath = FindKeyForModule(*moduleState, isolate, module); + + // A registry key either carries a URL scheme — http(s):, blob:, node:, and + // whatever else a synthetic module is keyed under — or it is a filesystem + // path. A scheme'd key IS the module's identity and passes through untouched; + // only a path becomes a file:// URL. + auto hasUrlScheme = [](const std::string& s) -> bool { + if (s.empty()) return false; + size_t colonPos = s.find(':'); + if (colonPos == 0 || colonPos == std::string::npos) return false; + size_t slashPos = s.find('/'); + if (slashPos != std::string::npos && slashPos < colonPos) return false; + for (size_t i = 0; i < colonPos; i++) { + char c = s[i]; + const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '+' || c == '-' || + c == '.'; + if (!ok) return false; + } + return true; + }; + + std::string moduleUrl; + if (modulePath.empty()) { + moduleUrl = "file:///app/"; + } else if (hasUrlScheme(modulePath)) { + moduleUrl = modulePath; + } else { + moduleUrl = "file://" + modulePath; + } + + // `dirname` is a filesystem notion; a URL-backed module has no directory, so + // it gets the URL with its last path segment stripped — stable and useful in + // a log line. A key with no path beyond the host or scheme body has nothing + // to strip and keeps its identity. + std::string moduleDirname; + if (modulePath.empty()) { + moduleDirname = "/app"; + } else if (hasUrlScheme(modulePath)) { + size_t schemeEnd = modulePath.find("://"); + size_t pathStart = (schemeEnd == std::string::npos) + ? std::string::npos + : modulePath.find('/', schemeEnd + 3); + size_t lastSlash = modulePath.find_last_of('/'); + if (pathStart != std::string::npos && lastSlash != std::string::npos && + lastSlash > pathStart) { + moduleDirname = modulePath.substr(0, lastSlash); + } else { + moduleDirname = modulePath; + } + } else { + size_t lastSlash = modulePath.find_last_of("/\\"); + moduleDirname = + lastSlash == std::string::npos ? "/app" : modulePath.substr(0, lastSlash); + } + + meta->CreateDataProperty( + context, ArgConverter::ConvertToV8String(isolate, "url"), + ArgConverter::ConvertToV8String(isolate, moduleUrl)) + .FromMaybe(false); + meta->CreateDataProperty( + context, ArgConverter::ConvertToV8String(isolate, "dirname"), + ArgConverter::ConvertToV8String(isolate, moduleDirname)) + .FromMaybe(false); } + +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h index 908c30ba7..0e06eff37 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h @@ -1,29 +1,255 @@ -#ifndef MODULE_INTERNAL_CALLBACKS_H -#define MODULE_INTERNAL_CALLBACKS_H +// ModuleInternalCallbacks.h +#pragma once +#include -#include "v8.h" +#include +#include +#include -// Module resolution callback for ES modules -v8::MaybeLocal ResolveModuleCallback(v8::Local context, - v8::Local specifier, - v8::Local import_assertions, - v8::Local referrer); +#include "HttpLoader.h" +#include "robin_hood.h" -// InitializeImportMetaObject - Callback invoked by V8 to initialize import.meta object -void InitializeImportMetaObject(v8::Local context, - v8::Local module, - v8::Local meta); +namespace tns { + +// ── The loader vocabulary ──────────────────────────────────── +// +// Everything the dev client teaches one isolate's module loader: which bare +// specifiers resolve where, how URLs are keyed, and which URLs are never +// cached. Per-isolate, not process-wide — it lives in the isolate's loader +// state and dies with the isolate. +// +// A worker inherits a COPY taken on the parent's thread at spawn (see +// CaptureLoaderVocabulary / InstallLoaderVocabulary), so no synchronization is +// needed anywhere: each isolate only ever reads and writes its own. A live +// worker therefore does not observe a later reconfiguration — the dev client +// restarts workers on updates. All of it must be set from the isolate's own +// thread (see SetImportMap and friends at the bottom of this header). + +// One import-map section: specifier key → target. Lookup within a section is +// exact-then-trailing-slash-prefix with longest match, per the import-maps +// spec. +using ImportMapEntries = robin_hood::unordered_map; + +// A parsed import map. `scopes` is kept ordered most-specific-first so the +// resolution cascade walks it without re-sorting on every lookup. +struct ParsedImportMap { + ImportMapEntries imports; + std::vector> scopes; + + bool empty() const { return imports.empty() && scopes.empty(); } +}; + +struct LoaderVocabulary { + // Bare specifier → resolved URL, plus the per-referrer `scopes` overrides. + // Instead of rewriting import statements on the bundler side, the runtime + // resolves bare specifiers through this map to HTTP module URLs; source + // code is served as-is. + ParsedImportMap importMap; + + // URLs matching any of these substrings are always re-fetched (the cache is + // evicted before loading). The vocabulary is server/framework policy, so + // the runtime carries no framework-specific URL strings of its own. + std::vector volatilePatterns; + + CanonicalizationConfig canonicalization; + // Distinguishes "no vocabulary supplied" (mechanical canonicalization only) + // from "supplied, and empty" — an empty vocabulary is explicit policy. + bool canonicalizationConfigured = false; +}; + +// Copy `isolate`'s vocabulary. Call on that isolate's own thread. +LoaderVocabulary CaptureLoaderVocabulary(v8::Isolate* isolate); + +// Replace `isolate`'s vocabulary wholesale. Call on that isolate's own thread, +// before it loads any module. +void InstallLoaderVocabulary(v8::Isolate* isolate, LoaderVocabulary vocabulary); + +// Canonical module key → compiled-module handle map used by the per-isolate +// registries below. +using ModuleHandleMap = + robin_hood::unordered_map>; + +// The registry key for `key`, whatever form it arrives in (filesystem path, +// file:// URL, http(s) URL, blob:, or a custom scheme such as node:). Every +// registry read and write goes through this, so a module reached as a root and +// the same module reached as someone's dependency land on one entry — and one +// identity for import.meta. +std::string CanonicalizeRegistryKey(const std::string& key); + +// Per-isolate module registry accessor: map canonical keys → compiled +// v8::Module handles for `isolate`. Keyed by v8::Isolate* (not thread) because +// v8::Global handles are isolate-bound; see the long-form comment +// above the definition in ModuleInternalCallbacks.cpp for the +// cross-isolate-handle bug this prevents. The map lives in a RuntimeState +// slot, so this returns null once the isolate's teardown has begun — callers +// must bail. +ModuleHandleMap* ModuleRegistryFor(v8::Isolate* isolate); + +// Mark every in-flight async graph load owned by `isolate` dead and Reset +// their context Globals. Must be called while the isolate is still alive (the +// Runtime destructor calls this before disposal); the rest of the loader state +// is destroyed with the isolate's RuntimeState. +void QuiesceModuleLoadsForIsolate(v8::Isolate* isolate); + +// Utility to drop modules from the registry when compilation/instantiation +// fails. Call on `isolate`'s own JS thread. +void RemoveModuleFromRegistry(v8::Isolate* isolate, + const std::string& canonicalPath); + +// The canonical registry key whose live entry is `mod`, or empty when the +// module is not registered for `isolate`. O(1) via the loader state's +// identity-hash index. +std::string LookupModuleKeyForModule(v8::Isolate* isolate, + v8::Local mod); -// Dynamic import() host callback +// Keep the identity-hash index in step with a registry write performed outside +// ModuleInternalCallbacks.cpp. Unindex first, while the key's outgoing module +// is still reachable — once its handle is Reset its hash is unrecoverable and +// the bucket entry would leak — then index the incoming one after the write. +void UnindexModuleForIsolate(v8::Isolate* isolate, + const std::string& canonicalKey); +void IndexModuleForIsolate(v8::Isolate* isolate, const std::string& canonicalKey, + v8::Local mod); + +// The require(esm) exports facade: a synthetic source-text module that +// re-exports everything from `target` and adds `__esModule = true`, so +// transpiled CJS consumers (`_mod.__esModule ? _mod.default : _mod`) pick up a +// real ESM default export through require(). Re-exports keep the target's live +// bindings and enumerability, which a copied object would not. Returns the +// facade instantiated and evaluated; the caller takes GetModuleNamespace(). +// One facade per target module, cached until the target leaves the registry. +v8::MaybeLocal GetOrCreateRequireFacade( + v8::Isolate* isolate, v8::Local context, + v8::Local target, const std::string& targetCanonicalPath); + +// Authoritative HTTP URL loader for dev-served ESM. This compiles and +// registers the module under its canonical URL key without evaluating it. +// Returns empty with a V8 exception scheduled on `isolate` — the fetch +// classifier's reason, or the compile error — so a caller that is not a V8 +// resolve callback must consume it through its own TryCatch and route the text +// into its own failure channel. +v8::MaybeLocal LoadHttpModuleForUrl( + v8::Isolate* isolate, v8::Local context, + const std::string& requestedUrl); + +// ── The module-graph walk ──────────────────────── +// +// Standard three-phase module-map pipeline (the Node/Blink shape) under V8's +// synchronous ResolveModuleCallback: the sync constraint applies to +// *resolution*, not *fetching*. Starting from `root` (an absolute http(s) URL +// or a canonical filesystem path), the walk discovers the transitive closure +// and compiles + registers every module in it, so that by InstantiateModule +// time the resolver is a pure registry lookup. +// +// Discovery is scheme-agnostic; only the fetch is per-scheme. Every edge goes +// through the same resolution the resolver uses (ResolveSpecifierToPath), so +// both agree on a module's registry key: +// - http(s) edges are fetched concurrently off-thread +// (FetchModuleBodyAsync) and compiled on the isolate's JS thread; +// - local edges are read and compiled inline during the walk — the bytes +// are already on disk, and a thread hop would only reorder discovery; +// - builtins are left to the resolver, which serves them from the builtin +// registry; +// - specifiers the walk cannot resolve (typically a bare name with no +// import-map entry) stay on the resolver's lazy path. +// +// Compilation runs no user code, so pre-compiling the closure cannot change +// evaluation order: V8 still evaluates in spec order from the root. +// +// `onComplete(ok, errorMessage, context)` runs exactly once on the isolate's +// JS thread with the isolate entered and `context` (the context captured at +// start) already scoped. `ok` is false only when an HTTP ROOT fetch/compile +// failed. Every other failure — a dependency, or anything local including the +// root — is left unregistered for the resolver (or the caller's own load +// path) to report with its own message, so the walk introduces no new failure +// modes and steals no error text. +// +// `onComplete` must capture only trivially destructible state. A background +// fetch completion can drop the last reference to a load whose isolate is +// already quiesced — QuiesceModuleLoadsForIsolate Resets the load's context +// Global but nothing else — so the closure is destroyed on whichever thread +// gets there last, and a captured v8 handle would be destroyed off-thread. +void StartModuleGraphLoad( + v8::Isolate* isolate, v8::Local context, + const std::string& root, + std::function context)> + onComplete); + +// Synchronous wrapper for callers that need the graph ready before +// continuing: starts the walk, then pumps the isolate's event loop in place +// (EventLoop::PumpUntil) until it settles or `timeoutSeconds` elapses. A +// graph with no http(s) edges completes entirely inside StartModuleGraphLoad, +// so this returns without entering the pump at all — a disk-only load pays +// no pump slice. +// Returns true when the walk completed (regardless of root success — the +// caller's own load path reports root failures). +bool RunModuleGraphLoadPumped(v8::Isolate* isolate, + v8::Local context, + const std::string& root, double timeoutSeconds); + +// True while any async graph load (any isolate) has fetches or compiles +// outstanding. +bool HasPendingAsyncModuleGraphWork(); + +// Drop exact URL-keyed modules from the registry and clear any in-flight +// invalidation bookkeeping tied to those canonical keys. +void InvalidateModules(v8::Isolate* isolate, v8::Local context, + const std::vector& urls); + +// Diagnostics helper: returns URL-like keys currently loaded in the module +// registry. +std::vector GetLoadedModuleUrls(); + +// Resolve callback signature (with import‑assertions slot) +v8::MaybeLocal ResolveModuleCallback( + v8::Local context, v8::Local specifier, + v8::Local import_assertions, + v8::Local referrer); + +// Host callback for dynamic import() expressions v8::MaybeLocal ImportModuleDynamicallyCallback( v8::Local context, v8::Local host_defined_options, v8::Local resource_name, v8::Local specifier, v8::Local import_assertions); -// Helper functions -bool IsFile(const std::string& path); -std::string WithExtension(const std::string& path, const std::string& ext); -bool IsNodeBuiltinModule(const std::string& spec); -std::string GetApplicationPath(); +// Host callback for import.meta initialization — Android-specific. Populates +// `import.meta.url` and `import.meta.dirname`. Kept here (not on iOS) because +// Runtime.cpp installs it via SetHostInitializeImportMetaObjectCallback. No +// `import.meta.hot` — that surface is JS policy, not native. +void InitializeImportMetaObject(v8::Local context, + v8::Local module, + v8::Local meta); + +// Import map support. +// +// Shape: {"imports": {"specifier": "target", ...}, +// "scopes": {"": {imports-shaped map}, ...}} +// +// Parsed and validated in full before anything is installed: on any invalid +// input this returns false with `error` explaining which key or section is +// wrong, and the calling isolate's currently installed map is left untouched. +// Per-isolate like the rest of the loader vocabulary — a worker resolves +// through the copy taken at spawn. +bool SetImportMap(const std::string& json, std::string* error); + +// Run the same parse `SetImportMap` runs and throw the result away. Lets +// `configureLoader` validate the whole call before installing any section, +// without the parsed representation leaving the loader implementation. +bool ValidateImportMapJson(const std::string& json, std::string* error); + +// Set URL patterns that should bypass module cache (e.g. "?v=", "/hot/") +// on the calling isolate. +void SetVolatilePatterns(const std::vector& patterns); + +// The calling isolate's canonicalization vocabulary, or null when it has none +// (the mechanical canonicalization applies). Isolate thread only — the +// transport never calls this, it carries canonical keys instead. +const CanonicalizationConfig* CanonicalizationConfigForCurrentIsolate(); + +// Install the client-supplied canonicalization vocabulary on the calling +// isolate. Its presence replaces the mechanical default entirely — empty +// vectors are honored as explicit policy. +void SetCanonicalizationConfig(CanonicalizationConfig config); -#endif // MODULE_INTERNAL_CALLBACKS_H +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/NativeScriptException.cpp b/test-app/runtime/src/main/cpp/NativeScriptException.cpp index cb6b4e763..b7471415c 100644 --- a/test-app/runtime/src/main/cpp/NativeScriptException.cpp +++ b/test-app/runtime/src/main/cpp/NativeScriptException.cpp @@ -183,6 +183,9 @@ std::shared_ptr> MakeOwnedPersistent(Isolate* isolate, } // namespace +// The throwable is held as a JNI LOCAL ref, and the message is rendered +// lazily from it (what()/ToString) - so the exception must be consumed +// before any JNI local frame enclosing this construction is popped. NativeScriptException::NativeScriptException(JEnv& env) : m_javascriptException(nullptr) { m_javaException = JniLocalRef(env.ExceptionOccurred()); diff --git a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp index b6a3d6f16..b3d9e670e 100644 --- a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp +++ b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp @@ -6,7 +6,11 @@ #include "ArgConverter.h" #include "BuiltinLoader.h" +#include "HttpLoader.h" +#include "NativeScriptAssert.h" +#include "Runtime.h" #include "RuntimeState.h" +#include "TraceLog.h" #include "console/Console.h" #include "robin_hood.h" @@ -31,10 +35,80 @@ struct Registration { * never carries compatibility code. */ constexpr Registration kRegistry[] = { + {"ns:module", BuiltinId::kNsModule}, + {"ns:runtime", BuiltinId::kNsRuntime}, {"ns:util", BuiltinId::kNsUtil}, + {"node:module", BuiltinId::kNodeModule}, + {"node:url", BuiltinId::kNodeUrl}, {"node:util", BuiltinId::kNodeUtil}, }; +constexpr const char* kDebugKey = "debug"; + +void ThrowTypeError(Isolate* isolate, const std::string& message) { + isolate->ThrowException(Exception::TypeError(ArgConverter::ConvertToV8String(isolate, message))); +} + +bool EnsureMainIsolateWrite(Isolate* isolate, const std::string& key) { + Runtime* runtime = Runtime::GetRuntime(isolate); + if (runtime == nullptr || !runtime->IsMainThread()) { + ThrowTypeError(isolate, "'" + key + + "' is process-wide and can only be set from the main " + "isolate"); + return false; + } + return true; +} + +void SetConfigCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() < 2 || !info[0]->IsString()) { + ThrowTypeError(isolate, "setConfig expects (key: string, value)"); + return; + } + std::string key = ArgConverter::ConvertToString(info[0].As()); + if (key == kDebugKey) { + if (!EnsureMainIsolateWrite(isolate, key)) { + return; + } + if (!info[1]->IsString()) { + ThrowTypeError(isolate, "'" + key + "' must be a comma-separated category string (" + + tns::AllLogCategoryNames() + + "), or '' to disable tracing"); + return; + } + // The list replaces the whole mask, so a caller never has to know what + // was already on to turn something off. + std::string value = ArgConverter::ConvertToString(info[1].As()); + bool hadUnknown = false; + uint32_t mask = tns::ParseLogCategories(value, &hadUnknown); + tns::SetEnabledLogCategories(mask); + if (hadUnknown) { + DEBUG_WRITE_FORCE( + "ns:runtime setConfig('debug', '%s'): ignoring unknown categories; valid " + "categories are %s", + value.c_str(), tns::AllLogCategoryNames().c_str()); + } + return; + } + ThrowTypeError(isolate, "Unknown runtime config key: '" + key + "'"); +} + +void GetConfigCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() < 1 || !info[0]->IsString()) { + ThrowTypeError(isolate, "getConfig expects (key: string)"); + return; + } + std::string key = ArgConverter::ConvertToString(info[0].As()); + if (key == kDebugKey) { + info.GetReturnValue().Set( + ArgConverter::ConvertToV8String(isolate, tns::EnabledLogCategoryNames())); + return; + } + ThrowTypeError(isolate, "Unknown runtime config key: '" + key + "'"); +} + const Registration* Find(const std::string& specifier) { for (const Registration& registration : kRegistry) { if (specifier == registration.specifier) { @@ -51,8 +125,8 @@ bool HasPrefix(const std::string& specifier, const char* prefix) { /* * A builtin module is a singleton per realm, so every cache here is per * runtime: workers get their own exports objects and their own synthetic - * modules. The process-global g_moduleRegistry deliberately holds none of - * this. Touched only from its own runtime's thread. + * modules. The ES module registry deliberately holds none of this. Touched + * only from its own runtime's thread. */ struct RealmState { robin_hood::unordered_map*> exports; @@ -88,6 +162,26 @@ MaybeLocal BuildBinding(Local context, BuiltinId builtin) { Local binding = Object::New(isolate); switch (builtin) { + case BuiltinId::kNsModule: { + if (!BuildNsModuleBinding(context, binding)) { + return MaybeLocal(); + } + break; + } + case BuiltinId::kNsRuntime: { + Local setConfig, getConfig; + if (!v8::Function::New(context, SetConfigCallback).ToLocal(&setConfig) || + !v8::Function::New(context, GetConfigCallback).ToLocal(&getConfig) || + !binding->Set(context, ArgConverter::ConvertToV8String(isolate, "setConfig"), + setConfig) + .FromMaybe(false) || + !binding->Set(context, ArgConverter::ConvertToV8String(isolate, "getConfig"), + getConfig) + .FromMaybe(false)) { + return MaybeLocal(); + } + break; + } case BuiltinId::kNsUtil: { // The console formatter is built once per realm; ns:util // re-exports that instance instead of creating a second one. diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index 2057695cd..3795bb08b 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -1,10 +1,11 @@ #include "Runtime.h" #include -#include + #include #include +#include #include #include #include @@ -16,13 +17,14 @@ #include "Constants.h" #include "CrashBreadcrumbs.h" #include "ErrorEvents.h" +#include "EventLoop.h" #include "Events.h" #include "File.h" #include "FrameCallbacks.h" +#include "HttpLoader.h" #include "Interop.h" #include "IsolateTracked.h" #include "JType.h" -#include "JsArgConverter.h" #include "JsArgToArrayConverter.h" #include "ManualInstrumentation.h" #include "MetadataNode.h" @@ -38,17 +40,15 @@ #include "SimpleAllocator.h" #include "SimpleProfiler.h" #include "StructuredClone.h" +#include "TraceLog.h" #include "URLImpl.h" #include "URLPatternImpl.h" #include "URLSearchParamsImpl.h" #include "Util.h" -#include "V8GlobalHelpers.h" #include "V8StringConstants.h" #include "Version.h" #include "WeakRef.h" #include "include/libplatform/libplatform.h" -#include "include/zipconf.h" -#include "libplatform/libplatform.h" #include "sys/system_properties.h" #ifdef APPLICATION_IN_DEBUG @@ -83,6 +83,9 @@ void LogAndAbortUncaught() { } void Runtime::Init(JavaVM* vm, void* reserved) { + // Before anything worth tracing runs, so NS_DEBUG covers boot itself. + tns::InitializeLogCategoriesFromEnvironment(); + __android_log_print(ANDROID_LOG_INFO, "TNS.Runtime", "NativeScript Runtime Version %s, commit %s", NATIVE_SCRIPT_RUNTIME_VERSION, @@ -260,7 +263,7 @@ void Runtime::Init(JNIEnv* env, jstring filesPath, jstring nativeLibDir, } JniLocalRef uncaughtErrorPolicy(env->GetObjectArrayElement( - args, (jsize)15 /* KnownKeys.UncaughtErrorPolicy */)); + args, (jsize)14 /* KnownKeys.UncaughtErrorPolicy */)); if (!uncaughtErrorPolicy.IsNull()) { auto policy = ArgConverter::jstringToString(uncaughtErrorPolicy); if (policy == "throw") { @@ -338,6 +341,13 @@ std::string Runtime::ReadFileText(const std::string& filePath) { return File::ReadText(filePath); } +std::string Runtime::ReadFileText(const std::string& filePath, bool& ok) { +#ifdef APPLICATION_IN_DEBUG + std::lock_guard lock(m_fileWriteMutex); +#endif + return File::ReadText(filePath, ok); +} + void Runtime::Lock() { #ifdef APPLICATION_IN_DEBUG m_fileWriteMutex.lock(); @@ -350,17 +360,95 @@ void Runtime::Unlock() { #endif } +// The boot backstop: hold the launching thread until boot has actually +// finished. Two independent things can leave it unfinished, and BOTH must hold +// the pump — an in-flight module-graph load, and an entry whose own evaluation +// promise is still pending (a top-level await parked on anything at all: a +// nested import() doing its own async work, a native init that completes +// later). Gating on graph work alone let the second case return to Java with +// the entry half-evaluated. +// +// A settled entry simply exits the loop — a script-style app finishing +// normally, Node-like. Only the two failures below are fatal, and both are +// reported in every build. +static void HoldBootBackstop(v8::Isolate* isolate, const std::string& entryPath) { + // The entry can already be rejected on the first poll: LoadESModule takes the + // registry-hit path for an already-evaluated module without re-entering + // EvaluateModuleGraph, so a re-run of a previously failed entry arrives here + // carrying its rejection. + std::string entryRejectionReason; + EntryEvaluationState entryState = + ModuleInternal::PollEntryEvaluation(isolate, entryPath, &entryRejectionReason); + bool entryPending = entryState == EntryEvaluationState::kPending; + bool entryRejected = entryState == EntryEvaluationState::kRejected; + + if (!entryPending && !entryRejected && !tns::HasPendingAsyncModuleGraphWork()) { + return; + } + + const double deadlineSeconds = 2 * kModuleEvaluateDeadlineSeconds; + Runtime* runtime = Runtime::TryGetRuntime(isolate); + std::shared_ptr eventLoop = + runtime != nullptr ? runtime->GetEventLoop() : nullptr; + + if (!entryRejected && eventLoop != nullptr) { + // The backstop always takes the looper-equivalent drain — it stands where + // iOS pumps its runloop, and Java Handler messages cannot dispatch while + // this frame holds the launching thread — so an entry parked on a JS + // timer or a worker reply settles here. + const EventLoop::PumpResult result = eventLoop->PumpUntil( + deadlineSeconds, + [&]() { + if (entryPending) { + EntryEvaluationState state = ModuleInternal::PollEntryEvaluation( + isolate, entryPath, &entryRejectionReason); + // Once it settles, stop probing for good. + entryPending = state == EntryEvaluationState::kPending; + entryRejected = state == EntryEvaluationState::kRejected; + } + return entryRejected || + (!entryPending && !tns::HasPendingAsyncModuleGraphWork()); + }, + /*drainLooperWork=*/true); + if (result == EventLoop::PumpResult::kTerminated && !entryRejected) { + // terminating isolate or stopped loop: no outcome to report, and no + // timeout to mislabel it with + return; + } + } + + // Evict before throwing: the entry would otherwise stay registered at + // kEvaluated with a failed capability, and the registry-hit path of a later + // RunModule on this isolate would never surface the failure again. + if (entryRejected) { + tns::RemoveModuleFromRegistry(isolate, tns::CanonicalizeRegistryKey(entryPath)); + throw NativeScriptException( + "Fatal: the main entry module's evaluation rejected during boot: " + + entryRejectionReason); + } + if (entryPending) { + tns::RemoveModuleFromRegistry(isolate, tns::CanonicalizeRegistryKey(entryPath)); + throw NativeScriptException("Fatal: the main entry module '" + entryPath + + "' never settled within " + + std::to_string(static_cast(deadlineSeconds)) + "s"); + } +} + void Runtime::RunModule(JNIEnv* _env, jobject obj, jstring scriptFile) { JEnv env(_env); string filePath = ArgConverter::jstringToString(scriptFile); auto context = this->GetContext(); m_module.Load(context, filePath); + // Java resolves package.json's `main` before handing the path over, so the + // entry the backstop probes is the very one that was just evaluated. + HoldBootBackstop(m_isolate, filePath); } void Runtime::RunModule(const char* moduleName) { auto context = this->GetContext(); m_module.Load(context, moduleName); + HoldBootBackstop(m_isolate, moduleName); } void Runtime::RunWorker(const std::string& filePath) { @@ -1014,6 +1102,22 @@ void Runtime::DestroyRuntime() { { std::lock_guard lock(s_runtimeCacheMutex); s_id2RuntimeCache.erase(m_id); + } + // Flag this isolate's in-flight async graph loads dead and Reset their + // context Globals while the isolate is still alive, so fetch completions + // still queued on background threads become no-ops. This MUST precede the + // event-loop Shutdown: a post the stopped loop rejects is destroyed on the + // POSTING (background) thread, and quiescing first guarantees such a task + // holds only already-Reset Globals by then. The rest of the loader state + // (registries, waiters, loader vocabulary) lives in a RuntimeState slot and + // is destroyed with it below. Worker isolates quiesce the same way. + tns::QuiesceModuleLoadsForIsolate(m_isolate); + // The isolate->runtime mapping must outlive the quiesce: a fetch completion + // that finds GetRuntime(isolate) == nullptr bails without decrementing its + // load's accounting, so erasing first would leave a not-yet-dead load + // permanently un-completable. + { + std::lock_guard lock(s_runtimeCacheMutex); s_isolate2RuntimesCache.erase(m_isolate); } if (m_eventLoop != nullptr) { @@ -1042,7 +1146,6 @@ void Runtime::DestroyRuntime() { m_dispatchUnhandledRejectionFunc.Reset(); m_dispatchRejectionHandledFunc.Reset(); m_dispatchNativeUncaughtErrorFunc.Reset(); - // Both hold v8::Global handles to JS callbacks, so their entries must be // dropped here rather than in ~Runtime, which runs after Isolate::Dispose -- // resetting a Global then writes into a freed handle table. Doing it here @@ -1051,6 +1154,13 @@ void Runtime::DestroyRuntime() { CallbackHandlers::RemoveIsolateEntries(m_isolate); FrameCallbacks::RemoveIsolateEntries(m_isolate); + // The transport's process-wide state (the cache-bust marks) is shared + // across isolates; only the main isolate may clear it (worker teardown must + // not wipe the main isolate's session). + if (m_isMainThread) { + tns::CleanupHttpLoaderGlobals(); + } + // V8 does not run weak callbacks when an isolate is disposed, so anything // still bound to one has to be deleted explicitly, here, while the isolate // is alive and its destructors can still touch v8::Global handles. diff --git a/test-app/runtime/src/main/cpp/Runtime.h b/test-app/runtime/src/main/cpp/Runtime.h index 1a139bd17..f60f69e9f 100644 --- a/test-app/runtime/src/main/cpp/Runtime.h +++ b/test-app/runtime/src/main/cpp/Runtime.h @@ -117,6 +117,10 @@ class Runtime { return m_state.get(); } + bool IsMainThread() const { + return m_isMainThread; + } + jobject GetJavaRuntime() const; ObjectManager* GetObjectManager() const; @@ -151,6 +155,11 @@ class Runtime { static v8::Platform* platform; std::string ReadFileText(const std::string& filePath); + /* + * `ok` distinguishes an unreadable file from an empty one — callers that + * compile what they read must not treat the first as valid empty source. + */ + std::string ReadFileText(const std::string& filePath, bool& ok); /* * The main runtime's event loop, set once when the main runtime @@ -173,6 +182,15 @@ class Runtime { return m_eventLoop; } + /* + * This runtime's CommonJS loader. `ns:module`'s createRequire mints its + * requires through it, so the require it hands out is the very one the + * loader builds for every module. + */ + ModuleInternal* GetModuleInternal() { + return &m_module; + } + /* * Milliseconds since this runtime's time origin, on the monotonic * clock. Not inline: v8::Platform is only forward-declared through diff --git a/test-app/runtime/src/main/cpp/Timers.cpp b/test-app/runtime/src/main/cpp/Timers.cpp index 232db81c0..6476269a4 100644 --- a/test-app/runtime/src/main/cpp/Timers.cpp +++ b/test-app/runtime/src/main/cpp/Timers.cpp @@ -330,7 +330,19 @@ bool Timers::RunIfEarliest(double now, double otherDue) { auto task = it->second; // task is no longer in queue to be executed task->queued_ = false; + // Java-dispatched callback with no live runtime: log-and-drop, never + // throw across the boundary + Runtime* runtime = Runtime::TryGetRuntime(isolate); + if (runtime == nullptr) { + DEBUG_WRITE("Timers: dropping timer %d, its runtime is gone", ref.id); + removeTask(task); + return true; + } #ifdef NS_TIMERS_NESTING_CLAMP + // save/restore, not reset: the event-loop pump dispatches timers + // nested inside an outer timer's callback, and the outer callback's + // remaining setTimeout calls must keep the outer nesting level + const int enclosingNesting = nesting; nesting = task->nestingLevel_; #endif if (task->repeats_) { @@ -342,7 +354,6 @@ bool Timers::RunIfEarliest(double now, double otherDue) { addTask(task); } v8::Local cb = task->callback_.Get(isolate); - Runtime* runtime = Runtime::GetRuntime(isolate); v8::Local context = runtime->GetContext(); Context::Scope context_scope(context); TryCatch tc(isolate); @@ -363,12 +374,22 @@ bool Timers::RunIfEarliest(double now, double otherDue) { } #ifdef NS_TIMERS_NESTING_CLAMP - nesting = 0; + nesting = enclosingNesting; #endif if (tc.HasCaught() && !NativeScriptException::ContainUncaughtCallbackException(isolate, tc)) { - NativeScriptException(tc).ReThrowToJava(); + if (EventLoop::IsPumping()) { + // A pump drained this slot and keeps making JNI calls after we + // return, so arming a pending Java exception here is illegal; + // the loop reports it from its next token dispatch instead. + auto eventLoop = runtime->GetEventLoop(); + if (eventLoop != nullptr) { + eventLoop->DeferJavaThrow(std::make_shared(tc)); + } + } else { + NativeScriptException(tc).ReThrowToJava(); + } } diff --git a/test-app/runtime/src/main/cpp/TraceLog.cpp b/test-app/runtime/src/main/cpp/TraceLog.cpp new file mode 100644 index 000000000..7f6d6d404 --- /dev/null +++ b/test-app/runtime/src/main/cpp/TraceLog.cpp @@ -0,0 +1,153 @@ +#include "TraceLog.h" + +#include + +#include +#include +#include +#include + +#include "NativeScriptAssert.h" + +namespace tns { + +namespace { + +// Index-aligned with tns::LogCategory; the only place a category name lives. +constexpr const char* kLogCategoryNames[] = {"esm", "fetch", "registry"}; +// One logcat tag per category, so `adb logcat -s TNS.esm` filters without +// matching message text. +constexpr const char* kLogCategoryTags[] = {"TNS.esm", "TNS.fetch", "TNS.registry"}; +constexpr size_t kLogCategoryCount = static_cast(LogCategory::kCount); +static_assert(sizeof(kLogCategoryNames) / sizeof(kLogCategoryNames[0]) == kLogCategoryCount, + "every LogCategory needs exactly one name"); +static_assert(sizeof(kLogCategoryTags) / sizeof(kLogCategoryTags[0]) == kLogCategoryCount, + "every LogCategory needs exactly one logcat tag"); + +void WriteDebugLine(LogCategory category, const char* message) { + size_t index = static_cast(category); + const char* tag = index < kLogCategoryCount ? kLogCategoryTags[index] : "TNS.Native"; + __android_log_print(ANDROID_LOG_DEBUG, tag, "%s", message); +} + +std::string TrimAsciiSpace(const std::string& value) { + size_t begin = value.find_first_not_of(" \t"); + if (begin == std::string::npos) { + return std::string(); + } + size_t end = value.find_last_not_of(" \t"); + return value.substr(begin, end - begin + 1); +} + +} // namespace + +const char* LogCategoryName(LogCategory category) { + size_t index = static_cast(category); + return index < kLogCategoryCount ? kLogCategoryNames[index] : "unknown"; +} + +std::string AllLogCategoryNames() { + std::string names; + for (size_t i = 0; i < kLogCategoryCount; ++i) { + if (!names.empty()) { + names += ","; + } + names += kLogCategoryNames[i]; + } + return names; +} + +uint32_t ParseLogCategories(const std::string& list, bool* hadUnknown) { + if (hadUnknown != nullptr) { + *hadUnknown = false; + } + + uint32_t mask = 0; + size_t start = 0; + while (start <= list.size()) { + size_t comma = list.find(',', start); + size_t length = comma == std::string::npos ? std::string::npos : comma - start; + std::string name = TrimAsciiSpace(list.substr(start, length)); + + if (!name.empty()) { + bool matched = false; + for (size_t i = 0; i < kLogCategoryCount; ++i) { + if (name == kLogCategoryNames[i]) { + mask |= 1u << i; + matched = true; + break; + } + } + if (!matched && hadUnknown != nullptr) { + *hadUnknown = true; + } + } + + if (comma == std::string::npos) { + break; + } + start = comma + 1; + } + return mask; +} + +std::string EnabledLogCategoryNames() { + uint32_t mask = g_enabledLogCategories.load(std::memory_order_relaxed); + std::string names; + for (size_t i = 0; i < kLogCategoryCount; ++i) { + if ((mask & (1u << i)) == 0) { + continue; + } + if (!names.empty()) { + names += ","; + } + names += kLogCategoryNames[i]; + } + return names; +} + +void SetEnabledLogCategories(uint32_t mask) { + g_enabledLogCategories.store(mask, std::memory_order_relaxed); +} + +void InitializeLogCategoriesFromEnvironment() { + const char* value = getenv("NS_DEBUG"); + if (value == nullptr || *value == '\0') { + return; + } + + bool hadUnknown = false; + SetEnabledLogCategories(ParseLogCategories(value, &hadUnknown)); + if (hadUnknown) { + DEBUG_WRITE_FORCE("NS_DEBUG: ignoring unknown categories in '%s'; valid categories are %s", + value, AllLogCategoryNames().c_str()); + } +} + +void EmitDebugLog(LogCategory category, const char* format, ...) { + va_list ap; + va_start(ap, format); + + char stackBuffer[1024]; + va_list apCopy; + va_copy(apCopy, ap); + int needed = vsnprintf(stackBuffer, sizeof(stackBuffer), format, apCopy); + va_end(apCopy); + + if (needed < 0) { + va_end(ap); + return; + } + + if (static_cast(needed) < sizeof(stackBuffer)) { + WriteDebugLine(category, stackBuffer); + } else { + std::vector heapBuffer(static_cast(needed) + 1); + vsnprintf(heapBuffer.data(), heapBuffer.size(), format, ap); + WriteDebugLine(category, heapBuffer.data()); + } + + va_end(ap); +} + +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/TraceLog.h b/test-app/runtime/src/main/cpp/TraceLog.h new file mode 100644 index 000000000..cad96d3c7 --- /dev/null +++ b/test-app/runtime/src/main/cpp/TraceLog.h @@ -0,0 +1,75 @@ +#ifndef TEST_APP_TRACELOG_H +#define TEST_APP_TRACELOG_H + +#include +#include +#include + +namespace tns { + +/* + * Category-scoped debug tracing. + * + * A process-wide bitmask of enabled categories, tested inline at every call + * site, so a disabled category costs one relaxed load and a well-predicted + * branch. Present in every build: these are traces, and a release build that + * cannot be traced is a release build that cannot be diagnosed. Error and + * lifecycle logs are unconditional and do not belong here. + * + * Turned on by the NS_DEBUG environment variable (read once at process init) + * or by ns:runtime's `debug` config key. + */ +enum class LogCategory : uint8_t { + Esm, // module resolution, compilation, linking, evaluation + Fetch, // the HTTP module transport + Registry, // registry invalidation and dynamic-import cache bookkeeping + kCount +}; + +/* + * One bit per LogCategory. Written from process init and from main-isolate + * setConfig; read from every thread. Relaxed suffices -- a trace line racing a + * toggle changes nothing but that line. + */ +inline std::atomic g_enabledLogCategories{0}; + +inline bool LogCategoryEnabled(LogCategory category) { + return (g_enabledLogCategories.load(std::memory_order_relaxed) & + (1u << static_cast(category))) != 0; +} + +/* + * Writes one trace line under `category`, to that category's own logcat tag. + * Out of line so nothing but the enabled test lands at the call site. + */ +void EmitDebugLog(LogCategory category, const char* format, ...) + __attribute__((format(printf, 2, 3))); + +const char* LogCategoryName(LogCategory category); +// Every category name, comma separated -- for the "valid categories are ..." +// diagnostic. +std::string AllLogCategoryNames(); +// A comma-separated category list to a mask. Unknown names are skipped and +// reported through `hadUnknown` rather than failing the whole list. +uint32_t ParseLogCategories(const std::string& list, bool* hadUnknown); +// The canonical comma-separated list of the categories currently enabled. +std::string EnabledLogCategoryNames(); +void SetEnabledLogCategories(uint32_t mask); +// Applies NS_DEBUG. Call once, before anything worth tracing runs. +void InitializeLogCategoriesFromEnvironment(); + +/* + * A MACRO rather than a function or template on purpose: the arguments must + * not be evaluated unless the category is on, and call sites routinely build + * strings that cost far more than the line they would print. + */ +#define TNS_DEBUG(category, ...) \ + do { \ + if (tns::LogCategoryEnabled(tns::LogCategory::category)) [[unlikely]] { \ + tns::EmitDebugLog(tns::LogCategory::category, __VA_ARGS__); \ + } \ + } while (0) + +} // namespace tns + +#endif // TEST_APP_TRACELOG_H diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp index 503da71c5..58a26c788 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp @@ -26,6 +26,57 @@ using namespace v8; namespace tns { +namespace { + +/* + * Reports a worker entry that failed to evaluate, with the web's order: the + * worker scope's own `onerror` gets first refusal (a truthy return consumes the + * failure) and only an unconsumed one reaches the parent's Worker object. + * Mirrors the worker branch of the unhandled-rejection path in + * NativeScriptException.cpp, which this rejection no longer travels: attaching + * a rejection handler to the entry's evaluation promise marks it handled. + */ +void ReportEntryRejection(Isolate* isolate, Local reason, + const std::shared_ptr& wrapper) { + auto context = isolate->GetCurrentContext(); + + std::string message = "Unhandled promise rejection: "; + Local detail; + if (!reason.IsEmpty() && reason->ToDetailString(context).ToLocal(&detail)) { + message += ArgConverter::ConvertToString(detail); + } + + std::string stackTrace; + if (!reason.IsEmpty()) { + auto stack = Exception::GetStackTrace(reason); + if (!stack.IsEmpty()) { + stackTrace = NativeScriptException::GetErrorStackTrace(stack); + } + } + + Local onError; + if (context->Global() + ->Get(context, ArgConverter::ConvertToV8String(isolate, "onerror")) + .ToLocal(&onError) && + onError->IsFunction()) { + Local args[] = {ArgConverter::ConvertToV8String(isolate, message)}; + Local result; + // A handler that throws has not consumed anything - the failure falls + // through to the parent, as if no handler had been installed. + TryCatch tc(isolate); + if (onError.As() + ->Call(context, Undefined(isolate), 1, args) + .ToLocal(&result) && + !result.IsEmpty() && result->BooleanValue(isolate)) { + return; + } + } + + wrapper->PassUncaughtExceptionFromWorkerToParent(message, "", stackTrace, 0); +} + +} // namespace + WorkerWrapper::WorkerWrapper(Isolate* parentIsolate, int workerId, std::string workerPath, std::string callingDir, int priority, Local workerObject) @@ -40,10 +91,13 @@ WorkerWrapper::WorkerWrapper(Isolate* parentIsolate, int workerId, std::string w // workerPath_ (not workerPath) - the parameter was just moved from threadName_("W" + std::to_string(workerId) + ": " + workerPath_), priority_(priority), + // Runs on the parent's thread, so this is the parent's live vocabulary. + inheritedVocabulary_(CaptureLoaderVocabulary(parentIsolate)), poWorker_(new Persistent(parentIsolate, workerObject)), isClosing_(false), isTerminating_(false), isDisposed_(false), + messagesEnabled_(false), javaLooperRef_(nullptr) {} void WorkerWrapper::Start() { @@ -139,15 +193,10 @@ int WorkerWrapper::DrainCallback(int fd, int events, void* data) { return 1; } -void WorkerWrapper::DrainPendingTasks() { +int WorkerWrapper::DrainPendingTasks() { Isolate* isolate = workerIsolate_.load(); if (isolate == nullptr || isTerminating_) { - return; - } - - auto messages = queue_.PopAll(); - if (messages.empty()) { - return; + return 0; } v8::Locker locker(isolate); @@ -157,10 +206,26 @@ void WorkerWrapper::DrainPendingTasks() { Context::Scope context_scope(context); auto globalObject = context->Global(); + // WHATWG parity: the implicit port's message queue starts disabled and is + // enabled once the entry script has finished evaluating (including after a + // pending top-level await settles). Until then messages stay buffered here; + // afterwards every message dispatches whether or not a handler exists — a + // handler installed later misses earlier messages, exactly as on the web. + if (!messagesEnabled_.load(std::memory_order_acquire)) { + return 0; + } + + auto messages = queue_.PopAll(); + if (messages.empty()) { + return 0; + } + + int dispatched = 0; for (auto& message : messages) { if (isTerminating_ || isClosing_) { break; } + dispatched++; TryCatch tc(isolate); @@ -186,6 +251,12 @@ void WorkerWrapper::DrainPendingTasks() { CallbackHandlers::CallWorkerScopeOnErrorHandle(isolate, tc); } } + return dispatched; +} + +void WorkerWrapper::EnableMessageQueue() { + messagesEnabled_.store(true, std::memory_order_release); + queue_.Signal(); } void WorkerWrapper::FireMessageOnParentWorkerObject(int workerId, @@ -366,6 +437,13 @@ void WorkerWrapper::BackgroundLooper(std::shared_ptr self) { // native looper backing the Java one - fds added here are pumped // by Looper.loop(). queue_.Initialize(ALooper_forThread(), WorkerWrapper::DrainCallback, this); + // The inbox rides its own fd, which a pump never polls; the hook + // lets a looper-equivalent pump drain it (the loop's Shutdown, on + // this thread, unregisters it before `this` can die). + auto pumpLoop = runtime_->GetEventLoop(); + if (pumpLoop != nullptr) { + pumpLoop->SetPumpDrainHook([this]() { return DrainPendingTasks(); }); + } Isolate* isolate = runtime_->GetIsolate(); @@ -379,6 +457,9 @@ void WorkerWrapper::BackgroundLooper(std::shared_ptr self) { auto context = runtime_->GetContext(); Context::Scope context_scope(context); + // Before any module load runs in this isolate. + InstallLoaderVocabulary(isolate, inheritedVocabulary_); + #ifdef APPLICATION_IN_DEBUG // Expose this worker to an attached Chrome DevTools frontend // as a child target, mirroring the iOS runtime. Created before @@ -389,6 +470,63 @@ void WorkerWrapper::BackgroundLooper(std::shared_ptr self) { if (!isTerminating_) { runtime_->RunWorker(workerPath_); + + // WHATWG parity: enable the implicit port's message queue + // once the entry has finished evaluating. RunWorker returns + // settled for classic scripts and pumped HTTP entries; a + // local top-level-await entry that outlived its settle + // window enables when its evaluation promise settles — + // rejected included, since a broken worker still drains its + // inbox into a listenerless global, as on the web. + Local pendingEntry; + if (!ModuleInternal::PendingEntryEvaluation(isolate, workerPath_) + .ToLocal(&pendingEntry)) { + EnableMessageQueue(); + } else { + // Neither handler may capture anything: they resolve the + // wrapper by id because the worker may be gone by the + // time the entry settles. Both run on this thread, in + // this isolate. + auto onFulfilled = [](const v8::FunctionCallbackInfo& info) { + auto wrapper = WorkerWrapper::GetById( + info.Data().As()->Value()); + if (wrapper != nullptr) { + wrapper->EnableMessageQueue(); + } + }; + // A rejection needs its own handler: sharing the fulfill + // one would mark the entry's evaluation promise handled + // and drop the failure on the floor. + auto onRejected = [](const v8::FunctionCallbackInfo& info) { + auto wrapper = WorkerWrapper::GetById( + info.Data().As()->Value()); + if (wrapper == nullptr) { + return; + } + wrapper->EnableMessageQueue(); + if (wrapper->IsTerminating() || wrapper->IsDisposed()) { + return; + } + auto isolate = info.GetIsolate(); + ReportEntryRejection(isolate, + info.Length() > 0 + ? info[0] + : Undefined(isolate).As(), + wrapper); + }; + auto workerIdData = v8::Integer::New(isolate, workerId_); + Local enableFn; + Local reportFn; + if (Function::New(context, onFulfilled, workerIdData) + .ToLocal(&enableFn) && + Function::New(context, onRejected, workerIdData) + .ToLocal(&reportFn)) { + pendingEntry->Then(context, enableFn, reportFn) + .FromMaybe(Local()); + } else { + EnableMessageQueue(); + } + } } } @@ -602,10 +740,7 @@ void WorkerWrapper::CreateInspector(Isolate* isolate) { } // Same url scheme the module loader reports in Debugger.scriptParsed. - // workerPath_ may still be relative to the caller's dir at this point - // (resolution happens in require); callingDir_ ends with '/'. - std::string url = - "file://" + (workerPath_[0] == '/' ? workerPath_ : callingDir_ + workerPath_); + std::string url = "file://" + workerPath_; auto* client = new WorkerInspectorClient(workerId_, isolate, ALooper_forThread(), url); { diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.h b/test-app/runtime/src/main/cpp/WorkerWrapper.h index fd12bbcb2..3b7be441a 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.h +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.h @@ -16,6 +16,7 @@ #endif #include "ConcurrentQueue.h" +#include "ModuleInternalCallbacks.h" #include "WorkerMessage.h" #include "v8.h" @@ -60,8 +61,9 @@ class WorkerWrapper : public std::enable_shared_from_this { /* * parent -> worker. Queues a serialized message and wakes the worker - * looper. Messages posted before the worker finishes bootstrapping are - * drained right after the worker script runs. + * looper. Messages posted before the worker finishes bootstrapping stay + * buffered until the entry has finished evaluating - for a module entry + * that is when its evaluation promise settles, not when the script returns. */ void PostMessage(std::shared_ptr message); @@ -93,6 +95,16 @@ class WorkerWrapper : public std::enable_shared_from_this { const std::string& stackTrace, int lineno); + /* + * WHATWG parity: the worker's implicit port message queue starts disabled; + * the worker thread calls this once the entry script has finished + * evaluating (including after a pending top-level await settles). From then + * on every buffered and future message dispatches whether or not a handler + * exists — a handler installed later (e.g. from a timer) misses earlier + * messages, exactly as on the web. + */ + void EnableMessageQueue(); + /* * Registry of live workers, keyed by workerId. Replaces the old * CallbackHandlers::id2WorkerMap. Guarded by a mutex because the worker @@ -141,7 +153,9 @@ class WorkerWrapper : public std::enable_shared_from_this { private: void BackgroundLooper(std::shared_ptr self); - void DrainPendingTasks(); + // returns the number of inbox messages dispatched, for the event loop's + // pump drain hook to count as progress + int DrainPendingTasks(); void QuitLooper(); static int DrainCallback(int fd, int events, void* data); static void FireMessageOnParentWorkerObject(int workerId, @@ -159,16 +173,31 @@ class WorkerWrapper : public std::enable_shared_from_this { Runtime* runtime_; const int workerId_; + // The entry's canonical resolved path, produced by the module resolver on + // the parent's thread: the worker has its own module registry and working + // directory, so nothing on this side can redo a relative resolution. const std::string workerPath_; const std::string callingDir_; const std::string threadName_; const int priority_; + // The parent's loader vocabulary, copied on the parent's thread when this + // wrapper is constructed and installed on the worker's own isolate before + // it loads anything. Nothing is shared, so nothing needs synchronizing — + // and a live worker deliberately does not observe a later configureLoader + // on the parent (the dev client restarts workers on vocabulary updates). + const LoaderVocabulary inheritedVocabulary_; + v8::Persistent* poWorker_; std::atomic_bool isClosing_; std::atomic_bool isTerminating_; std::atomic_bool isDisposed_; + // False until the entry script has finished evaluating + // (EnableMessageQueue); DrainPendingTasks leaves the queue untouched while + // disabled. Written and read on the worker thread only - the atomic is + // belt-and-braces, not a cross-thread channel. + std::atomic_bool messagesEnabled_; ConcurrentQueue queue_; diff --git a/test-app/runtime/src/main/cpp/js/README.md b/test-app/runtime/src/main/cpp/js/README.md index 65beb22a9..16a7fea53 100644 --- a/test-app/runtime/src/main/cpp/js/README.md +++ b/test-app/runtime/src/main/cpp/js/README.md @@ -46,6 +46,11 @@ module.exports = somethingTheCallSiteNeeds; `node:util` shim: one source file per specifier, the shim owning every bit of Node compatibility. See `docs/ns-builtin-modules.md` for the cross-runtime contract. +- `ns-module.js` is the `ns:module` loader-control surface and `ns-runtime.js` + is the `ns:runtime` live config surface (`setConfig`/`getConfig`). +- `node-module.js` re-exports `ns:module`'s `createRequire` as the `node:module` + shim, and `node-url.js` is the `node:url` shim (`fileURLToPath` / + `pathToFileURL`), the one shim with no `ns:` counterpart to adapt. - Destructure `binding` and `primordials` once, at the top of the file, so the file's dependencies are visible and greppable. diff --git a/test-app/runtime/src/main/cpp/js/node-module.js b/test-app/runtime/src/main/cpp/js/node-module.js new file mode 100644 index 000000000..5cd3202b1 --- /dev/null +++ b/test-app/runtime/src/main/cpp/js/node-module.js @@ -0,0 +1,15 @@ +"use strict"; + +// The `node:module` compatibility shim: the documented subset of Node's +// module API, backed by `ns:module` (docs/ns-builtin-modules.md). Compiled +// the first time `node:module` is resolved, so an app that never touches the +// `node:` scheme never pays for it. +// +// Only `createRequire` is re-exported. `createPumpingRequire` is a +// NativeScript extension with no Node counterpart and stays on `ns:module`, +// so code written against this shim keeps running on Node unchanged. + +const { ObjectFreeze } = primordials; +const { createRequire } = require("ns:module"); + +module.exports = ObjectFreeze({ createRequire }); diff --git a/test-app/runtime/src/main/cpp/js/node-url.js b/test-app/runtime/src/main/cpp/js/node-url.js new file mode 100644 index 000000000..d499e1b64 --- /dev/null +++ b/test-app/runtime/src/main/cpp/js/node-url.js @@ -0,0 +1,104 @@ +"use strict"; + +// The `node:url` compatibility shim: the two path/URL converters +// (docs/ns-builtin-modules.md). Parsing goes through the URL intrinsic rather +// than a hand-rolled scan, so authority normalization (`file://localhost/x` +// has no host, per the URL spec), percent-decoding and path canonicalization +// all follow the spec instead of an approximation. + +const { + decodeURIComponent, + ObjectFreeze, + StringPrototypeCharCodeAt, + StringPrototypeStartsWith, + TypeError, + URL, +} = primordials; + +const INVALID_ARG = + 'The "path" argument must be of type string or an instance of URL.'; + +function toUrl(input) { + let href; + if (typeof input === "string") { + href = input; + } else if (input !== null && typeof input === "object" && + typeof input.href === "string") { + // Duck-typed so a URL from another realm still works. + href = input.href; + } else { + throw new TypeError(INVALID_ARG); + } + + try { + return new URL(href); + } catch { + throw new TypeError(INVALID_ARG); + } +} + +function fileURLToPath(input) { + const url = toUrl(input); + + if (url.protocol !== "file:") { + throw new TypeError("The URL must be of scheme file"); + } + // The URL parser already folded a "localhost" authority to the empty host, + // so anything left here is a real remote host and names no local file. + if (url.hostname !== "") { + throw new TypeError('File URL host must be "localhost" or empty'); + } + + // `pathname` carries neither the query nor the fragment. + const pathname = url.pathname; + for (let i = 0; i < pathname.length; i++) { + if (pathname[i] !== "%") { + continue; + } + // %2F would decode to a separator and silently change the path's shape. + const third = StringPrototypeCharCodeAt(pathname, i + 2) | 0x20; + if (pathname[i + 1] === "2" && third === 102 /* 'f' */) { + throw new TypeError("File URL path must not include encoded / characters"); + } + } + + return decodeURIComponent(pathname); +} + +const kHexDigits = "0123456789ABCDEF"; + +// Percent-encode everything the URL parser would otherwise read as syntax (or +// reject), leaving `/` as the separator it is. Non-ASCII is left alone: the +// parser UTF-8 encodes it correctly on its own. +function encodePathChars(filepath) { + let encoded = ""; + for (let i = 0; i < filepath.length; i++) { + const char = filepath[i]; + const code = StringPrototypeCharCodeAt(filepath, i); + const mustEncode = + code < 0x21 || code === 0x7f || char === "%" || char === "?" || + char === "#" || char === "\\" || char === '"' || char === "<" || + char === ">" || char === "`" || char === "{" || char === "}"; + if (mustEncode) { + encoded += "%" + kHexDigits[(code >> 4) & 0xf] + kHexDigits[code & 0xf]; + } else { + encoded += char; + } + } + return encoded; +} + +function pathToFileURL(filepath) { + if (typeof filepath !== "string") { + throw new TypeError('The "path" argument must be of type string.'); + } + // Node resolves a relative path against the process working directory; there + // is no such thing here, so a relative path has no single correct answer. + if (!StringPrototypeStartsWith(filepath, "/")) { + throw new TypeError('The "path" argument must be an absolute path.'); + } + + return new URL("file://" + encodePathChars(filepath)); +} + +module.exports = ObjectFreeze({ fileURLToPath, pathToFileURL }); diff --git a/test-app/runtime/src/main/cpp/js/ns-module.js b/test-app/runtime/src/main/cpp/js/ns-module.js new file mode 100644 index 000000000..4059a7c10 --- /dev/null +++ b/test-app/runtime/src/main/cpp/js/ns-module.js @@ -0,0 +1,183 @@ +"use strict"; + +// The `ns:module` builtin: the dev-loader control surface the runtime +// exposes to development tooling (docs/ns-builtin-modules.md). Every member +// is a native function handed in through `binding`; this file only shapes +// and freezes the exports. +// +// Membership varies by build: +// - `canonicalizeHttpUrlKey` exists only in debug builds (test diagnostic). +// Missing members are simply absent — never present-but-throwing — so +// feature checks work. + +const { + ArrayPrototypeIndexOf, + decodeURIComponent, + NumberIsFinite, + ObjectFreeze, + ObjectKeys, + StringPrototypeEndsWith, + StringPrototypeIndexOf, + StringPrototypeLastIndexOf, + StringPrototypeSlice, + StringPrototypeStartsWith, + TypeError, +} = primordials; + +// Node's wording (lib/internal/modules/cjs/loader.js), so a message copied out +// of a NativeScript stack trace still matches what the ecosystem documents. +const CREATE_REQUIRE_ERROR = + "The argument 'filename' must be a file URL object, file URL string, or absolute path string."; + +// A `file:` URL string down to the path it names. Deliberately string-based +// rather than routed through the global URL: this runs before app code and +// must not depend on an intrinsic the app may have replaced. +function fileUrlToPath(href) { + let rest = StringPrototypeSlice(href, "file://".length); + + // Only an empty or localhost authority names a local file. + const authorityEnd = StringPrototypeIndexOf(rest, "/"); + if (authorityEnd < 0) { + throw new TypeError(CREATE_REQUIRE_ERROR); + } + const authority = StringPrototypeSlice(rest, 0, authorityEnd); + if (authority !== "" && authority !== "localhost") { + throw new TypeError(CREATE_REQUIRE_ERROR); + } + rest = StringPrototypeSlice(rest, authorityEnd); + + // The query and fragment are URL syntax, never part of the path. + const queryAt = StringPrototypeIndexOf(rest, "?"); + if (queryAt >= 0) { + rest = StringPrototypeSlice(rest, 0, queryAt); + } + const hashAt = StringPrototypeIndexOf(rest, "#"); + if (hashAt >= 0) { + rest = StringPrototypeSlice(rest, 0, hashAt); + } + + try { + return decodeURIComponent(rest); + } catch { + throw new TypeError(CREATE_REQUIRE_ERROR); + } +} + +// The directory a require created for `filenameOrURL` resolves against. +function requireBaseDir(filenameOrURL) { + let filepath; + + if (typeof filenameOrURL === "object" && filenameOrURL !== null) { + // A URL object, identified by its href rather than by instanceof so a + // URL from another realm still works. + const href = filenameOrURL.href; + if (typeof href !== "string") { + throw new TypeError(CREATE_REQUIRE_ERROR); + } + filepath = urlStringToPath(href); + } else if (typeof filenameOrURL !== "string") { + throw new TypeError(CREATE_REQUIRE_ERROR); + } else if (StringPrototypeStartsWith(filenameOrURL, "/")) { + filepath = filenameOrURL; + } else { + filepath = urlStringToPath(filenameOrURL); + } + + // Node treats a trailing slash as "this directory is the base"; otherwise + // the base is the directory holding the named file. + if (StringPrototypeEndsWith(filepath, "/")) { + const trimmed = StringPrototypeSlice(filepath, 0, filepath.length - 1); + return trimmed === "" ? "/" : trimmed; + } + const lastSlash = StringPrototypeLastIndexOf(filepath, "/"); + return lastSlash <= 0 ? "/" : StringPrototypeSlice(filepath, 0, lastSlash); +} + +function urlStringToPath(value) { + if (StringPrototypeStartsWith(value, "file://")) { + return fileUrlToPath(value); + } + if (StringPrototypeStartsWith(value, "http://") || + StringPrototypeStartsWith(value, "https://")) { + // require() over HTTP is blocked runtime-wide; a dev-served module is + // reachable through import(), and a require base must name a real file. + throw new TypeError( + "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."); + } + throw new TypeError(CREATE_REQUIRE_ERROR); +} + +// Every option a pumping require accepts, so an unknown key is a typo the +// caller hears about rather than a setting that silently does nothing. +const kPumpingOptionKeys = ["deadlineSeconds", "onTimeout", "pumpRunLoop"]; + +// Validated once, when the require is minted — a require() call itself does no +// option work at all. Returns the three values the native mint expects, with +// `undefined` standing for "leave the default alone". +function validatePumpingOptions(options) { + if (options === undefined) { + return { deadlineSeconds: undefined, throwOnTimeout: undefined, pumpRunLoop: undefined }; + } + if (typeof options !== "object" || options === null) { + throw new TypeError("createPumpingRequire: options must be an object"); + } + + const keys = ObjectKeys(options); + for (let i = 0; i < keys.length; i++) { + if (ArrayPrototypeIndexOf(kPumpingOptionKeys, keys[i]) < 0) { + throw new TypeError("createPumpingRequire: unknown option '" + keys[i] + "'"); + } + } + + const deadlineSeconds = options.deadlineSeconds; + if (deadlineSeconds !== undefined && + (typeof deadlineSeconds !== "number" || !NumberIsFinite(deadlineSeconds) || + deadlineSeconds <= 0)) { + throw new TypeError( + "createPumpingRequire: 'deadlineSeconds' must be a positive finite number"); + } + + const onTimeout = options.onTimeout; + if (onTimeout !== undefined && onTimeout !== "throw" && onTimeout !== "return-pending") { + throw new TypeError("createPumpingRequire: 'onTimeout' must be 'throw' or 'return-pending'"); + } + + const pumpRunLoop = options.pumpRunLoop; + if (pumpRunLoop !== undefined && typeof pumpRunLoop !== "boolean") { + throw new TypeError("createPumpingRequire: 'pumpRunLoop' must be a boolean"); + } + + return { + deadlineSeconds, + throwOnTimeout: onTimeout === undefined ? undefined : onTimeout === "throw", + pumpRunLoop, + }; +} + +function createRequire(filenameOrURL, options) { + if (options !== undefined) { + throw new TypeError("options are not supported on createRequire"); + } + return binding.createRequire(requireBaseDir(filenameOrURL), false); +} + +function createPumpingRequire(filenameOrURL, options) { + const resolved = validatePumpingOptions(options); + return binding.createRequire(requireBaseDir(filenameOrURL), true, resolved.deadlineSeconds, + resolved.throwOnTimeout, resolved.pumpRunLoop); +} + +const surface = { + configureLoader: binding.configureLoader, + invalidateModules: binding.invalidateModules, + getLoadedModuleUrls: binding.getLoadedModuleUrls, + createRequire, + createPumpingRequire, +}; +if (binding.canonicalizeHttpUrlKey !== undefined) { + surface.canonicalizeHttpUrlKey = binding.canonicalizeHttpUrlKey; +} + +module.exports = ObjectFreeze(surface); diff --git a/test-app/runtime/src/main/cpp/js/ns-runtime.js b/test-app/runtime/src/main/cpp/js/ns-runtime.js new file mode 100644 index 000000000..fc026722e --- /dev/null +++ b/test-app/runtime/src/main/cpp/js/ns-runtime.js @@ -0,0 +1,14 @@ +"use strict"; + +// The `ns:runtime` builtin module: runtime-level configuration and (future) +// runtime introspection. See docs/ns-builtin-modules.md for the contract and +// the key registry — keys, their value domains, and their scope (process-wide +// vs per-isolate) are defined and validated on the native side, so this file +// stays a thin, frozen surface. + +const { setConfig, getConfig } = binding; +const { ObjectFreeze } = primordials; + +exports.setConfig = setConfig; +exports.getConfig = getConfig; +ObjectFreeze(exports); diff --git a/test-app/runtime/src/main/cpp/js/primordials.js b/test-app/runtime/src/main/cpp/js/primordials.js index 8970295e9..559c2e37c 100644 --- a/test-app/runtime/src/main/cpp/js/primordials.js +++ b/test-app/runtime/src/main/cpp/js/primordials.js @@ -2,7 +2,7 @@ // Snapshot of the intrinsics the other builtins depend on, taken before any // user code can reach the globals. Runs first and is handed to every other -// builtin as the fourth fixed parameter. +// builtin as the fifth fixed parameter. // // Instance methods are exposed "uncurried" (Node's idiom): the receiver // becomes the first argument, so `ArrayPrototypeSlice(list, 0)` reads the @@ -11,7 +11,6 @@ const FunctionPrototypeCall = Function.prototype.call; const FunctionPrototypeBind = Function.prototype.bind; -const FunctionPrototypeApply = Function.prototype.apply; // bind() with `this` pinned to call(): uncurryThis(fn) === fn.call.bind(fn), // but without reading `fn.call`. @@ -25,10 +24,10 @@ const intrinsics = { Error, Map, Number, - Proxy, Set, String, TypeError, + URL, // Well-known symbols. SymbolIterator: Symbol.iterator, @@ -40,6 +39,7 @@ const intrinsics = { // Statics. ArrayBufferIsView: ArrayBuffer.isView, ArrayIsArray: Array.isArray, + decodeURIComponent, JSONStringify: JSON.stringify, NumberIsFinite: Number.isFinite, NumberIsNaN: Number.isNaN, @@ -64,7 +64,6 @@ const intrinsics = { DatePrototypeGetTime: uncurryThis(Date.prototype.getTime), DatePrototypeToISOString: uncurryThis(Date.prototype.toISOString), DatePrototypeToJSON: uncurryThis(Date.prototype.toJSON), - FunctionPrototypeApply: uncurryThis(FunctionPrototypeApply), FunctionPrototypeCall: uncurryThis(FunctionPrototypeCall), FunctionPrototypeToString: uncurryThis(Function.prototype.toString), MapPrototypeDelete: uncurryThis(Map.prototype.delete), @@ -73,8 +72,6 @@ const intrinsics = { MapPrototypeSet: uncurryThis(Map.prototype.set), ObjectPrototypePropertyIsEnumerable: uncurryThis(Object.prototype.propertyIsEnumerable), ObjectPrototypeToString: uncurryThis(Object.prototype.toString), - PromisePrototypeCatch: uncurryThis(Promise.prototype.catch), - PromisePrototypeThen: uncurryThis(Promise.prototype.then), RegExpPrototypeTest: uncurryThis(RegExp.prototype.test), RegExpPrototypeToString: uncurryThis(RegExp.prototype.toString), SetPrototypeAdd: uncurryThis(Set.prototype.add), @@ -82,8 +79,11 @@ const intrinsics = { SetPrototypeHas: uncurryThis(Set.prototype.has), SetPrototypeValues: uncurryThis(Set.prototype.values), StringPrototypeCharCodeAt: uncurryThis(String.prototype.charCodeAt), + StringPrototypeEndsWith: uncurryThis(String.prototype.endsWith), StringPrototypeIndexOf: uncurryThis(String.prototype.indexOf), + StringPrototypeLastIndexOf: uncurryThis(String.prototype.lastIndexOf), StringPrototypeSlice: uncurryThis(String.prototype.slice), + StringPrototypeStartsWith: uncurryThis(String.prototype.startsWith), SymbolPrototypeToString: uncurryThis(Symbol.prototype.toString), // Iterator-protocol escape hatches: the captured `next` of the live map/set diff --git a/test-app/runtime/src/main/cpp/js/require-factory.js b/test-app/runtime/src/main/cpp/js/require-factory.js index dc477af0a..58c5645b2 100644 --- a/test-app/runtime/src/main/cpp/js/require-factory.js +++ b/test-app/runtime/src/main/cpp/js/require-factory.js @@ -1,4 +1,5 @@ -function require_factory(requireInternal, dirName) { +function require_factory(requireInternal, dirName, policy, deadlineSeconds, throwOnTimeout, + pumpRunLoop) { return function require(modulePath) { if (global.__requireOverride) { var result = global.__requireOverride(modulePath, dirName); @@ -6,7 +7,11 @@ function require_factory(requireInternal, dirName) { return result; } } - return requireInternal(modulePath, dirName); + // `policy` and the three evaluate options are opaque native tokens, + // resolved once when this require was minted; undefined means the + // strict default. + return requireInternal(modulePath, dirName, policy, deadlineSeconds, throwOnTimeout, + pumpRunLoop); } } module.exports = require_factory; diff --git a/test-app/runtime/src/main/java/com/tns/AppConfig.java b/test-app/runtime/src/main/java/com/tns/AppConfig.java index d1379a440..d56f32c0a 100644 --- a/test-app/runtime/src/main/java/com/tns/AppConfig.java +++ b/test-app/runtime/src/main/java/com/tns/AppConfig.java @@ -24,7 +24,6 @@ protected enum KnownKeys { DiscardUncaughtJsExceptions("discardUncaughtJsExceptions", false), EnableLineBreakpoins("enableLineBreakpoints", false), EnableMultithreadedJavascript("enableMultithreadedJavascript", false), - LogScriptLoading("logScriptLoading", false), // Appended last: native code reads this array by ordinal. UncaughtErrorPolicy("uncaughtErrorPolicy", "report"); @@ -85,9 +84,6 @@ public AppConfig(File appDir) { String profiling = rootObject.getString(KnownKeys.Profiling.getName()); values[KnownKeys.Profiling.ordinal()] = profiling; } - if (rootObject.has(KnownKeys.LogScriptLoading.getName())) { - values[KnownKeys.LogScriptLoading.ordinal()] = rootObject.getBoolean(KnownKeys.LogScriptLoading.getName()); - } if (rootObject.has(KnownKeys.DiscardUncaughtJsExceptions.getName())) { boolean discard = rootObject.getBoolean(KnownKeys.DiscardUncaughtJsExceptions.getName()); if (discard) { @@ -225,11 +221,6 @@ public boolean getEnableMultithreadedJavascript() { return (boolean)values[KnownKeys.EnableMultithreadedJavascript.ordinal()]; } - public boolean getLogScriptLoading() { - Object v = values[KnownKeys.LogScriptLoading.ordinal()]; - return (v instanceof Boolean) ? ((Boolean)v).booleanValue() : false; - } - // Security conf /** diff --git a/test-app/runtime/src/main/java/com/tns/ClassResolver.java b/test-app/runtime/src/main/java/com/tns/ClassResolver.java index 0dba4c64a..538a69739 100644 --- a/test-app/runtime/src/main/java/com/tns/ClassResolver.java +++ b/test-app/runtime/src/main/java/com/tns/ClassResolver.java @@ -1,6 +1,9 @@ package com.tns; +import android.util.Log; + import com.tns.system.classes.loading.ClassStorageService; +import com.tns.system.classes.loading.LookedUpClassNotFound; import java.io.IOException; @@ -26,7 +29,28 @@ Class resolveClass(String baseClassName, String fullClassName, DexFactory dex } if (clazz == null) { - clazz = classStorageService.retrieveClass(className); + try { + clazz = classStorageService.retrieveClass(className); + } catch (LookedUpClassNotFound notFound) { + // A named proxy (`Base.extend('a.b.C', {...})` / @JavaProxy) + // whose class the static binding generator never compiled — it + // only scans assets/app, and dev servers keep most source off + // disk. The proxy generator accepts dotted names for classes + // and interfaces alike, so supply the class the same way + // anonymous extends are supplied. + Log.w("JS", "Class " + className + " not precompiled; generating at runtime. Framework references resolve only if dex injection into the app class loader succeeds."); + try { + clazz = dexFactory.resolveClass(canonicalBaseClassName, name, className, methodOverrides, implementedInterfaces, isInterface); + } catch (Throwable generationFailure) { + // The precise not-found is the actionable error; a failed + // generation attempt is its detail, not its replacement. + notFound.addSuppressed(generationFailure); + throw notFound; + } + if (clazz == null) { + throw notFound; + } + } } return clazz; diff --git a/test-app/runtime/src/main/java/com/tns/DexFactory.java b/test-app/runtime/src/main/java/com/tns/DexFactory.java index 56b37462e..1bf7ee7ed 100644 --- a/test-app/runtime/src/main/java/com/tns/DexFactory.java +++ b/test-app/runtime/src/main/java/com/tns/DexFactory.java @@ -120,13 +120,24 @@ public Class resolveClass(String baseClassName, String name, String className // strip the `com.tns.gen` off the base extended class name String desiredDexClassName = this.getClassToProxyName(fullClassName); + // A named proxy (`Base.extend('a.b.C', {...})` / @JavaProxy) asks for + // exactly that Java class name; the substitutions below are for the + // anonymous form only, where the name is derived from the base. + boolean isNamedProxy = !fullClassName.startsWith(COM_TNS_GEN_PREFIX) && fullClassName.contains("."); + // when interfaces are extended as classes, we still want to preserve // just the interface name without the extra file, line, column information - if (!baseClassName.isEmpty() && isInterface) { + if (!baseClassName.isEmpty() && isInterface && !isNamedProxy) { fullClassName = COM_TNS_GEN_PREFIX + classToProxy; } - File dexFile = this.getDexFile(desiredDexClassName); + // The thumb only changes on reinstall, so a cache key of name + thumb + // cannot see an edit to the proxy's contents: under HMR a named + // proxy's new method overrides would silently load the previous dex. + // The digest carries the contents into the file name. + String contentDigest = computeContentDigest(classToProxy, methodOverrides, implementedInterfaces, isInterface); + + File dexFile = this.getDexFile(desiredDexClassName, contentDigest); // generate dex file if (dexFile == null) { @@ -136,10 +147,10 @@ public Class resolveClass(String baseClassName, String name, String className } String dexFilePath; - if (isInterface) { - dexFilePath = this.generateDex(name, classToProxy, methodOverrides, implementedInterfaces, isInterface); + if (isInterface && !isNamedProxy) { + dexFilePath = this.generateDex(name, contentDigest, classToProxy, methodOverrides, implementedInterfaces, isInterface); } else { - dexFilePath = this.generateDex(desiredDexClassName, classToProxy, methodOverrides, implementedInterfaces, isInterface); + dexFilePath = this.generateDex(desiredDexClassName, contentDigest, classToProxy, methodOverrides, implementedInterfaces, isInterface); } dexFile = new File(dexFilePath); long stopGenTime = System.nanoTime(); @@ -204,7 +215,22 @@ public Class findClass(String className) throws ClassNotFoundException { return existingClass; } - return classLoader.loadClass(canonicalName); + String underscored = canonicalName.replace('$', '_'); + if (!underscored.equals(canonicalName)) { + existingClass = this.injectedDexClasses.get(underscored); + if (existingClass != null) { + return existingClass; + } + } + + try { + return classLoader.loadClass(canonicalName); + } catch (ClassNotFoundException e) { + if (!underscored.equals(canonicalName)) { + return classLoader.loadClass(underscored); + } + throw e; + } } public static String strJoin(String[] array, String separator) { @@ -236,12 +262,51 @@ private String getClassToProxyName(String className) throws InvalidClassExceptio return classToProxy; } - private File getDexFile(String className) throws InvalidClassException { + /** + * Digest of everything that shapes the generated proxy besides its name, + * so the dex cache key changes when the proxy's contents do. Sorted, so + * JS property-enumeration order cannot produce a spurious miss. + */ + private static String computeContentDigest(String classToProxy, String[] methodOverrides, String[] implementedInterfaces, boolean isInterface) { + StringBuilder canonical = new StringBuilder(classToProxy).append('\n').append(isInterface); + if (methodOverrides != null) { + String[] sortedOverrides = methodOverrides.clone(); + java.util.Arrays.sort(sortedOverrides); + for (String override : sortedOverrides) { + canonical.append('\n').append(override); + } + } + if (implementedInterfaces != null) { + String[] sortedInterfaces = implementedInterfaces.clone(); + java.util.Arrays.sort(sortedInterfaces); + for (String iface : sortedInterfaces) { + canonical.append('').append(iface); + } + } + try { + byte[] hash = java.security.MessageDigest.getInstance("SHA-256") + .digest(canonical.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(10); + for (int i = 0; i < 5; i++) { + hex.append(String.format("%02x", hash[i])); + } + return hex.toString(); + } catch (java.security.NoSuchAlgorithmException e) { + // SHA-256 is mandatory on Android; a digestless key only loses + // cache freshness, never correctness of a fresh generation. + return null; + } + } + + private File getDexFile(String className, String contentDigest) throws InvalidClassException { String classToProxyFile = className.replace("$", "_"); if (this.dexThumb != null) { classToProxyFile += "-" + this.dexThumb; } + if (contentDigest != null) { + classToProxyFile += "-" + contentDigest; + } String dexFilePath = dexDir + "/" + classToProxyFile + ".dex"; File dexFile = new File(dexFilePath); @@ -259,7 +324,7 @@ private File getDexFile(String className) throws InvalidClassException { return null; } - private String generateDex(String proxyName, String className, String[] methodOverrides, String[] implementedInterfaces, boolean isInterface) throws ClassNotFoundException, IOException { + private String generateDex(String proxyName, String contentDigest, String className, String[] methodOverrides, String[] implementedInterfaces, boolean isInterface) throws ClassNotFoundException, IOException { Class classToProxy = Class.forName(className); HashSet methodOverridesSet = null; diff --git a/test-app/runtime/src/main/java/com/tns/Module.java b/test-app/runtime/src/main/java/com/tns/Module.java index b74020762..29317d3bd 100644 --- a/test-app/runtime/src/main/java/com/tns/Module.java +++ b/test-app/runtime/src/main/java/com/tns/Module.java @@ -47,6 +47,24 @@ static String getApplicationFilesPath() { return ApplicationFilesPath; } + /** + * Resolves an entry-module path for runModule. A non-absolute, scheme-less + * name is an app-root-relative module name (the @JavaScriptImplementation + * convention) and goes through the same resolution require uses - extension + * and directory probing included - so the native side only ever receives a + * loadable identity: an absolute path or a URL. Missing entries throw here, + * with require's wording, instead of failing against the process cwd. + */ + static String resolveEntryPath(String path) { + if (path.isEmpty() || path.startsWith("/") || path.contains("://")) { + return path; + } + String relative = (path.startsWith("./") || path.startsWith("../") || path.startsWith("~/")) + ? path + : "./" + path; + return resolvePath(relative, ApplicationFilesPath + ModulesFilesPath); + } + @RuntimeCallable private static String resolvePath(String path, String baseDir) { // The baseDir is the directory path of the calling module. diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index 4a02c22c4..b21263154 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -293,18 +293,6 @@ public static boolean isDebuggable() { } } - // Expose logScriptLoading flag for native code without re-reading package.json - public static boolean getLogScriptLoadingEnabled() { - Runtime runtime = com.tns.Runtime.getCurrentRuntime(); - if (runtime != null && runtime.config != null && runtime.config.appConfig != null) { - return runtime.config.appConfig.getLogScriptLoading(); - } - if (staticConfiguration != null && staticConfiguration.appConfig != null) { - return staticConfiguration.appConfig.getLogScriptLoading(); - } - return false; - } - // Security config /** @@ -349,15 +337,48 @@ public static boolean isRemoteUrlAllowed(String url) { return true; } - // Check if URL matches any allowlist prefix + // Check if URL matches any allowlist prefix at a URL-component boundary + // (exact match, entry ends in '/', or next char is '/', '?', or '#'). + // This refuses lookalike-host and lookalike-port bypasses. for (String prefix : allowlist) { - if (url != null && prefix != null && url.startsWith(prefix)) { + if (url != null && prefix != null && remoteUrlMatchesAllowlistEntry(url, prefix)) { return true; } } return false; } + + private static boolean remoteUrlMatchesAllowlistEntry(String url, String entry) { + if (entry.isEmpty() || url.length() < entry.length()) { + return false; + } + if (!url.startsWith(entry)) { + return false; + } + if (url.length() == entry.length()) { + return true; + } + if (entry.charAt(entry.length() - 1) == '/') { + return true; + } + char next = url.charAt(entry.length()); + return next == '/' || next == '?' || next == '#'; + } + + /** + * Test/JNI helper: boot-time security.allowRemoteModules (debug always true). + */ + public static boolean getSecurityAllowRemoteModules() { + return isRemoteModulesAllowed(); + } + + /** + * Test/JNI helper: boot-time security.remoteModuleAllowlist. + */ + public static String[] getSecurityRemoteModuleAllowlist() { + return getRemoteModuleAllowlist(); + } /** * Returns the remote module allowlist as a String array for JNI. @@ -675,8 +696,7 @@ public void run() throws NativeScriptException { } public void runModule(File jsFile) throws NativeScriptException { - String filePath = jsFile.getPath(); - runModule(getRuntimeId(), filePath); + runModule(getRuntimeId(), Module.resolveEntryPath(jsFile.getPath())); } public Object runScript(File jsFile) throws NativeScriptException { diff --git a/test-app/tools/package-lock.json b/test-app/tools/package-lock.json index dec6bc4c5..1f8c782e0 100644 --- a/test-app/tools/package-lock.json +++ b/test-app/tools/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "static_analysis", "version": "1.0.0", "license": "ISC", "dependencies": { diff --git a/test-app/tools/try_to_find_test_result_file.js b/test-app/tools/try_to_find_test_result_file.js index b9bebb19a..d12cfc7d8 100644 --- a/test-app/tools/try_to_find_test_result_file.js +++ b/test-app/tools/try_to_find_test_result_file.js @@ -131,11 +131,40 @@ async function checkForErrorActivity() { } } +function isCompleteJunitXml(text) { + if (!text || typeof text !== "string") { + return false; + } + const trimmed = text.trim(); + return /]/.test(trimmed) && trimmed.includes(""); +} + async function tryPullResultsFile() { const { error } = await execAndStream(`${adbPrefix} pull ${resultsPath}`); if (!error) { - console.log("Tests results file found!"); + const fs = require("fs"); + try { + const text = fs.readFileSync("android_unit_test_results.xml", "utf8"); + if (isCompleteJunitXml(text)) { + console.log("Tests results file found!"); + process.exit(0); + } + } catch (e) { + // Missing or unreadable; keep polling. + } + } + + // Play Store / userdebug-less images reject `adb root` and cannot pull + // /data/data directly. Debug apps can still read their own files via run-as. + const localPath = "android_unit_test_results.xml"; + const { error: runAsError, stdout } = await execAndStream( + `${adbPrefix} exec-out run-as ${appId} cat android_unit_test_results.xml` + ); + if (!runAsError && isCompleteJunitXml(stdout)) { + const fs = require("fs"); + fs.writeFileSync(localPath, stdout); + console.log("Tests results file found via run-as!"); process.exit(0); } }