From 5ad2f4cd8da90a2c679b0e27a90b1fa00bacb64a Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 8 Oct 2026 15:56:25 -0300 Subject: [PATCH 1/2] feat(runtime): ns:url builtin module mirroring node:url, with URL types Adds `ns:url`, exporting the runtime's own URL and URLSearchParams plus the `fileURLToPath`/`pathToFileURL` converters, so typed code can name the URL classes by specifier instead of through whatever `globalThis.URL` resolves to in the program's type environment. Its surface is exactly `node:url`'s: the standard module now owns the converters, and `node:url` becomes a re-export shim over it on a distinct, separately frozen module object, the way `node:util` sits over `ns:util`. `require("node:url")` behaves as before and additionally exports URL and URLSearchParams, as Node's does. URLPattern stays a global only; Node's `node:url` has no counterpart and nothing here needs it. The binding takes both constructors from the templates URLImpl::Init and URLSearchParamsImpl::Init installed, held per isolate the way Worker's is. V8 instantiates a template once per context, so the module hands out the very functions the globals were created with even after an app reassigns them. types/ns-url.d.ts declares the module and, like @types/node does for its own globals, merges the module's interfaces into the global URL and URLSearchParams and declares the global variables through the `onmessage` probe, so a program with a DOM lib keeps the lib's declarations and one without gets the module's constructors. Verified with tsc --strict against a DOM and a non-DOM program: both are clean, and the non-DOM one no longer reports the two missing-`URL` errors ns-module.d.ts used to produce there. Experimental, iOS-only until the Android runtime ships the module. --- NativeScript/runtime/NsBuiltinModules.cpp | 25 ++++ NativeScript/runtime/URLImpl.cpp | 26 ++++ NativeScript/runtime/URLImpl.h | 7 + NativeScript/runtime/URLSearchParamsImpl.cpp | 27 ++++ NativeScript/runtime/URLSearchParamsImpl.h | 4 + NativeScript/runtime/js/node-url.js | 112 ++------------- NativeScript/runtime/js/ns-url.js | 111 +++++++++++++++ .../NodeBuiltinsAndOptionalModulesTests.mjs | 7 +- TestRunner/app/tests/NsUrlTests.js | 67 +++++++++ TestRunner/app/tests/index.js | 1 + TestRunner/app/tests/nsUrlIdentityWorker.js | 10 ++ docs/ns-builtin-modules.md | 87 ++++++++---- tools/js2c-inputs.xcfilelist | 1 + types/index.d.ts | 1 + types/ns-url.d.ts | 133 ++++++++++++++++++ 15 files changed, 487 insertions(+), 132 deletions(-) create mode 100644 NativeScript/runtime/js/ns-url.js create mode 100644 TestRunner/app/tests/NsUrlTests.js create mode 100644 TestRunner/app/tests/nsUrlIdentityWorker.js create mode 100644 types/ns-url.d.ts diff --git a/NativeScript/runtime/NsBuiltinModules.cpp b/NativeScript/runtime/NsBuiltinModules.cpp index e3096a71b..46d6f73a8 100644 --- a/NativeScript/runtime/NsBuiltinModules.cpp +++ b/NativeScript/runtime/NsBuiltinModules.cpp @@ -11,6 +11,8 @@ #include "Runtime.h" #include "StructuredSerialization.h" #include "TextEncoding.h" +#include "URLImpl.h" +#include "URLSearchParamsImpl.h" #include "Worker.h" using namespace v8; @@ -25,6 +27,7 @@ constexpr const char* kNodePrefix = "node:"; // Defined below, each next to the natives it gathers. MaybeLocal NsModuleBinding(Local context); MaybeLocal NsRuntimeBinding(Local context); +MaybeLocal NsUrlBinding(Local context); MaybeLocal NsUtilBinding(Local context); MaybeLocal NsWorkerThreadsBinding(Local context); @@ -47,6 +50,7 @@ struct Registration { constexpr Registration kRegistry[] = { {"ns:module", BuiltinId::kNsModule, NsModuleBinding}, {"ns:runtime", BuiltinId::kNsRuntime, NsRuntimeBinding}, + {"ns:url", BuiltinId::kNsUrl, NsUrlBinding}, {"ns:util", BuiltinId::kNsUtil, NsUtilBinding}, {"ns:worker_threads", BuiltinId::kNsWorkerThreads, NsWorkerThreadsBinding}, {"node:module", BuiltinId::kNodeModule, nullptr}, @@ -200,6 +204,27 @@ MaybeLocal NsRuntimeBinding(Local context) { return binding; } +// URL and URLSearchParams for ns:url: the context's own, from the templates +// their Init installed, rather than whatever the globals name by the time the +// module is first required. +MaybeLocal NsUrlBinding(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + Local binding = Object::New(isolate); + Local url; + Local searchParams; + if (!URLImpl::Constructor(context).ToLocal(&url) || + !URLSearchParamsImpl::Constructor(context).ToLocal(&searchParams) || + !binding->Set(context, tns::ToV8String(isolate, "URL"), url) + .FromMaybe(false) || + !binding + ->Set(context, tns::ToV8String(isolate, "URLSearchParams"), + searchParams) + .FromMaybe(false)) { + return MaybeLocal(); + } + return binding; +} + // The Worker constructor for ns:worker_threads: the context's own, from the // template Worker::Init installed, rather than whatever `globalThis.Worker` // names by the time the module is first required. diff --git a/NativeScript/runtime/URLImpl.cpp b/NativeScript/runtime/URLImpl.cpp index 7c3adb9c2..29f5a2c4b 100644 --- a/NativeScript/runtime/URLImpl.cpp +++ b/NativeScript/runtime/URLImpl.cpp @@ -4,12 +4,24 @@ #include "URLImpl.h" +#include "Caches.h" #include "Helpers.h" #include "ModuleBinding.hpp" using namespace tns; using namespace ada; +namespace { + +// The constructor template Init put on the global template. A template's +// function is cached per context, so instantiating it again yields the very +// function the global was created with. +struct URLConstructorState { + v8::Global constructor; +}; + +} // namespace + URLImpl::URLImpl(url_aggregator url) : url_(url) {} void URLImpl::Init(v8::Isolate* isolate, @@ -18,6 +30,20 @@ void URLImpl::Init(v8::Isolate* isolate, v8::Local urlPropertyName = ToV8String(isolate, "URL"); globalTemplate->Set(urlPropertyName, URLTemplate); + + if (auto* state = Caches::StateFor(isolate)) { + state->constructor.Reset(isolate, URLTemplate); + } +} + +v8::MaybeLocal URLImpl::Constructor( + v8::Local context) { + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + auto* state = Caches::StateFor(isolate); + if (state == nullptr || state->constructor.IsEmpty()) { + return v8::MaybeLocal(); + } + return state->constructor.Get(isolate)->GetFunction(context); } URLImpl* URLImpl::GetPointer(v8::Local object) { diff --git a/NativeScript/runtime/URLImpl.h b/NativeScript/runtime/URLImpl.h index 4e4e6ec80..c2b1fd225 100644 --- a/NativeScript/runtime/URLImpl.h +++ b/NativeScript/runtime/URLImpl.h @@ -21,6 +21,13 @@ class URLImpl : public IsolateTracked { static void Init(v8::Isolate* isolate, v8::Local globalTemplate); + // The URL constructor of `context`: the function Init's template produces + // for it, which is the object the global of that name was created with, + // whatever `globalThis.URL` names by now. Empty before Init has run for the + // isolate. + static v8::MaybeLocal Constructor( + v8::Local context); + static URLImpl* GetPointer(v8::Local object); static v8::Local GetCtor(v8::Isolate* isolate); diff --git a/NativeScript/runtime/URLSearchParamsImpl.cpp b/NativeScript/runtime/URLSearchParamsImpl.cpp index 80023e3d7..0f03c26eb 100644 --- a/NativeScript/runtime/URLSearchParamsImpl.cpp +++ b/NativeScript/runtime/URLSearchParamsImpl.cpp @@ -4,12 +4,24 @@ #include "URLSearchParamsImpl.h" +#include "Caches.h" #include "Helpers.h" #include "ModuleBinding.hpp" using namespace ada; namespace tns { + +namespace { + +// The constructor template Init put on the global template; see +// URLConstructorState in URLImpl.cpp. +struct URLSearchParamsConstructorState { + v8::Global constructor; +}; + +} // namespace + URLSearchParamsImpl::URLSearchParamsImpl(ada::url_search_params params) : params_(params) {} @@ -20,6 +32,21 @@ void URLSearchParamsImpl::Init(v8::Isolate* isolate, v8::Local urlSearchParamsPropertyName = ToV8String(isolate, "URLSearchParams"); globalTemplate->Set(urlSearchParamsPropertyName, URLSearchParamsTemplate); + + if (auto* state = + Caches::StateFor(isolate)) { + state->constructor.Reset(isolate, URLSearchParamsTemplate); + } +} + +v8::MaybeLocal URLSearchParamsImpl::Constructor( + v8::Local context) { + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + auto* state = Caches::StateFor(isolate); + if (state == nullptr || state->constructor.IsEmpty()) { + return v8::MaybeLocal(); + } + return state->constructor.Get(isolate)->GetFunction(context); } URLSearchParamsImpl* URLSearchParamsImpl::GetPointer( diff --git a/NativeScript/runtime/URLSearchParamsImpl.h b/NativeScript/runtime/URLSearchParamsImpl.h index 87492ba47..413c84469 100644 --- a/NativeScript/runtime/URLSearchParamsImpl.h +++ b/NativeScript/runtime/URLSearchParamsImpl.h @@ -22,6 +22,10 @@ class URLSearchParamsImpl : public IsolateTracked { static void Init(v8::Isolate* isolate, v8::Local globalTemplate); + // The URLSearchParams constructor of `context`, as URLImpl::Constructor. + static v8::MaybeLocal Constructor( + v8::Local context); + static void Ctor(const v8::FunctionCallbackInfo& args); static void Append(const v8::FunctionCallbackInfo& args); diff --git a/NativeScript/runtime/js/node-url.js b/NativeScript/runtime/js/node-url.js index d499e1b64..7604c0e23 100644 --- a/NativeScript/runtime/js/node-url.js +++ b/NativeScript/runtime/js/node-url.js @@ -1,104 +1,16 @@ "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. +// The `node:url` compatibility shim over `ns:url` (docs/ns-builtin-modules.md). +// The two surfaces coincide today, so this only re-exports; any adaptation to +// Node's API belongs here, never in the standard module. -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"); - } - } +const { ObjectFreeze } = primordials; +const { URL, URLSearchParams, fileURLToPath, pathToFileURL } = + require("ns:url"); - 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 }); +module.exports = ObjectFreeze({ + URL, + URLSearchParams, + fileURLToPath, + pathToFileURL, +}); diff --git a/NativeScript/runtime/js/ns-url.js b/NativeScript/runtime/js/ns-url.js new file mode 100644 index 000000000..07d06ce8f --- /dev/null +++ b/NativeScript/runtime/js/ns-url.js @@ -0,0 +1,111 @@ +"use strict"; + +// The `ns:url` builtin module: the runtime's own URL and URLSearchParams, the +// very functions the globals of those names were created with, and the two +// path/URL converters. See docs/ns-builtin-modules.md for the contract. +// +// The converters parse through URL 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 { URL, URLSearchParams } = binding; +const { + decodeURIComponent, + ObjectFreeze, + StringPrototypeCharCodeAt, + StringPrototypeStartsWith, + TypeError, +} = 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)); +} + +exports.URL = URL; +exports.URLSearchParams = URLSearchParams; +exports.fileURLToPath = fileURLToPath; +exports.pathToFileURL = pathToFileURL; +ObjectFreeze(exports); diff --git a/TestRunner/app/tests/NodeBuiltinsAndOptionalModulesTests.mjs b/TestRunner/app/tests/NodeBuiltinsAndOptionalModulesTests.mjs index 3be18f51a..c751069c9 100644 --- a/TestRunner/app/tests/NodeBuiltinsAndOptionalModulesTests.mjs +++ b/TestRunner/app/tests/NodeBuiltinsAndOptionalModulesTests.mjs @@ -10,7 +10,12 @@ describe("Node built-in and optional module resolution", function () { expect(ns.default).toBe(required); expect(ns.fileURLToPath).toBe(required.fileURLToPath); expect(Object.isFrozen(required)).toBe(true); - expect(Object.keys(required).sort()).toEqual(["fileURLToPath", "pathToFileURL"]); + expect(Object.keys(required).sort()).toEqual([ + "URL", + "URLSearchParams", + "fileURLToPath", + "pathToFileURL", + ]); }); it("converts file URLs to paths the way Node does", function () { diff --git a/TestRunner/app/tests/NsUrlTests.js b/TestRunner/app/tests/NsUrlTests.js new file mode 100644 index 000000000..c829208a6 --- /dev/null +++ b/TestRunner/app/tests/NsUrlTests.js @@ -0,0 +1,67 @@ +describe("ns:url", function () { + var nsUrl = require("ns:url"); + + it("exposes frozen exports", function () { + expect(Object.isFrozen(nsUrl)).toBe(true); + expect(typeof nsUrl.URL).toBe("function"); + expect(typeof nsUrl.URLSearchParams).toBe("function"); + expect(typeof nsUrl.fileURLToPath).toBe("function"); + expect(typeof nsUrl.pathToFileURL).toBe("function"); + }); + + // The export set is public API, declared in types/ns-url.d.ts and + // docs/ns-builtin-modules.md — all three must change together. + it("exposes exactly the declared surface", function () { + expect(Object.keys(nsUrl).sort()).toEqual([ + "URL", + "URLSearchParams", + "fileURLToPath", + "pathToFileURL", + ]); + }); + + it("exports the URL and URLSearchParams the globals hold", function () { + expect(nsUrl.URL).toBe(globalThis.URL); + expect(nsUrl.URLSearchParams).toBe(globalThis.URLSearchParams); + }); + + it("leaves URLPattern a global only", function () { + expect(typeof globalThis.URLPattern).toBe("function"); + expect("URLPattern" in nsUrl).toBe(false); + }); + + it("is a singleton per realm", function () { + expect(require("ns:url")).toBe(nsUrl); + }); + + it("converts between paths and file URLs with its own URL", function () { + var url = nsUrl.pathToFileURL("/foo/bar baz.txt"); + expect(url instanceof nsUrl.URL).toBe(true); + expect(url.href).toBe("file:///foo/bar%20baz.txt"); + expect(nsUrl.fileURLToPath(url)).toBe("/foo/bar baz.txt"); + }); + + it("is re-exported member for member by a distinct, frozen node:url", function () { + var shim = require("node:url"); + expect(shim).not.toBe(nsUrl); + expect(Object.isFrozen(shim)).toBe(true); + expect(Object.keys(shim).sort()).toEqual(Object.keys(nsUrl).sort()); + Object.keys(nsUrl).forEach(function (name) { + expect(shim[name]).toBe(nsUrl[name]); + }); + }); + + it("hands a worker realm its own URL, not whatever the global names", function (done) { + var worker = new Worker("./nsUrlIdentityWorker.js"); + worker.onmessage = function (event) { + expect(event.data).toEqual({ isOriginal: true, isImpostor: false, frozen: true }); + worker.terminate(); + done(); + }; + worker.onerror = function (event) { + worker.terminate(); + fail("worker error: " + event.message); + done(); + }; + }); +}); diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index bdd14c628..1b4cd3c17 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -130,6 +130,7 @@ require("./MetadataTests"); require("./ApiTests"); require("./NsRuntimeTests"); require("./NsWorkerThreadsTests"); +require("./NsUrlTests"); require("./GCFinalizerTests"); require("./WorkerConcurrentStartupTests"); require("./WorkerOptionsTests"); diff --git a/TestRunner/app/tests/nsUrlIdentityWorker.js b/TestRunner/app/tests/nsUrlIdentityWorker.js new file mode 100644 index 000000000..a97110b9f --- /dev/null +++ b/TestRunner/app/tests/nsUrlIdentityWorker.js @@ -0,0 +1,10 @@ +// Reassigns the global before the module is first required in this realm, so +// a module that merely read `globalThis.URL` would report the impostor. +var original = globalThis.URL; +globalThis.URL = function Impostor() {}; +var nsUrl = require("ns:url"); +postMessage({ + isOriginal: nsUrl.URL === original, + isImpostor: nsUrl.URL === globalThis.URL, + frozen: Object.isFrozen(nsUrl), +}); diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md index 873843630..ad839e444 100644 --- a/docs/ns-builtin-modules.md +++ b/docs/ns-builtin-modules.md @@ -426,6 +426,60 @@ a program with a DOM lib keeps the lib's declaration, so `new Worker(path, { ios })` type-checks either way without a conflicting redeclaration. +### `ns:url` + +The runtime's own `URL` and `URLSearchParams`, reachable by specifier, and the +converters between `file:` URLs and paths. The surface is the same as +`node:url`'s, which is a re-export shim over this module. **Experimental, +iOS-only** until the Android runtime ships it. + +| export | description | +|---|---| +| `URL` | The runtime's `URL` constructor: the very function the global of that name was created with, so `require("ns:url").URL === globalThis.URL` unless the app has reassigned the global. | +| `URLSearchParams` | The runtime's `URLSearchParams` constructor, with the same identity guarantee. | +| `fileURLToPath(url)` | Converts a `file:` URL — a string or a URL-like object with a string `href` — to a path. | +| `pathToFileURL(path)` | Converts an absolute path to a `file:` URL, returned as an instance of this module's `URL`. | + +`URLPattern` remains a global only for now; it has no `node:url` counterpart, +and nothing in the module needs it. + +The converters parse through `URL`, 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` requires an **absolute** path: +Node resolves a relative one against the process working directory, and there +is no such thing here. + +```js +import { URL, fileURLToPath, pathToFileURL } from "ns: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" +new URL("./b.js", pathToFileURL("/app/a.js")).pathname; // "/app/b.js" +``` + +The converters' `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.` | + +The module exists so that typed code can name the runtime's URL classes +without depending on what `globalThis.URL` resolves to in the program's type +environment. `types/ns-url.d.ts` declares the module and, at script level, +declares the global `URL` and `URLSearchParams` the way `@types/node` declares +its own globals: a program with a DOM lib keeps the lib's declarations, and a +program without one gets the module's constructors as the globals. + ### `node:` compatibility shims The same registry serves the `node:` scheme with **compatibility shims** so @@ -465,39 +519,10 @@ unmodified where a shim exists: | module | exports | notes | |---|---|---| | `node:util` | `inspect`, `format`, `TextEncoder`, `TextDecoder` | Re-exports `ns:util`'s members unchanged (`nodeUtil.inspect === nsUtil.inspect`) from a **distinct, separately frozen module object**. `TextEncoder`/`TextDecoder` are the globals of those names, as they are in Node. Documented as partial. | -| `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:url` | `URL`, `URLSearchParams`, `fileURLToPath`, `pathToFileURL` | Re-exports all four of [`ns:url`](#nsurl)'s members unchanged (`nodeUrl.fileURLToPath === nsUrl.fileURLToPath`) from a **distinct, separately frozen module object**; the converters' behavior and error messages are specified there. Documented as partial — no legacy `url.parse`/`format`/`resolve`, and no `URLPattern` (Node's `node:url` has none either). | | `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:worker_threads` | the messaging and thread surface — see [worker-threads.md](worker-threads.md) | The channel half (`MessagePort`, `MessageChannel`, `BroadcastChannel`, `receiveMessageOnPort`) is the real implementation, the same objects the globals of those names hold; the thread half is a bridge over the `Worker` that `ns:worker_threads` exports. The channel half has no `ns:` counterpart — its surface tracks Node's, so there is nothing for a standard module to own. The one place it breaks the absent-not-throwing rule below is deliberate: `postMessageToThread` and `moveMessagePortToContext` are present and throw an `Error` naming themselves, because silently missing thread-addressed messaging reads as a delivery bug rather than as an unsupported call. 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 @@ -782,7 +807,7 @@ resolvers read the same table differently: - The **`ns:`/`node:` resolver** — the app-facing one, behind `require()`, `import` and `import()` — serves only rows *not* marked internal-only. An internal-only specifier fails exactly as a name absent from the table does. - Eight rows are public today: `ns:module`, `ns:runtime`, `ns:util`, + Nine rows are public today: `ns:module`, `ns:runtime`, `ns:url`, `ns:util`, `ns:worker_threads`, `node:module`, `node:url`, `node:util`, `node:worker_threads`. - The **internal require** builtins receive (previous section) is the only diff --git a/tools/js2c-inputs.xcfilelist b/tools/js2c-inputs.xcfilelist index cd46992ca..af0d35894 100644 --- a/tools/js2c-inputs.xcfilelist +++ b/tools/js2c-inputs.xcfilelist @@ -18,6 +18,7 @@ $(SRCROOT)/NativeScript/runtime/js/node-util.js $(SRCROOT)/NativeScript/runtime/js/node-worker-threads.js $(SRCROOT)/NativeScript/runtime/js/ns-module.js $(SRCROOT)/NativeScript/runtime/js/ns-runtime.js +$(SRCROOT)/NativeScript/runtime/js/ns-url.js $(SRCROOT)/NativeScript/runtime/js/ns-util.js $(SRCROOT)/NativeScript/runtime/js/ns-worker-threads.js $(SRCROOT)/NativeScript/runtime/js/performance.js diff --git a/types/index.d.ts b/types/index.d.ts index b1aff29b0..94256051a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -27,5 +27,6 @@ /// /// +/// /// /// diff --git a/types/ns-url.d.ts b/types/ns-url.d.ts new file mode 100644 index 000000000..a4405bdcf --- /dev/null +++ b/types/ns-url.d.ts @@ -0,0 +1,133 @@ +declare module "ns:url" { + /** + * The WHATWG `URL` surface the runtime implements. Every component setter + * stringifies its value. + */ + export interface URL { + hash: string; + host: string; + hostname: string; + href: string; + readonly origin: string; + password: string; + pathname: string; + port: string; + protocol: string; + search: string; + /** + * The same object on every read, kept in step with `search` in both + * directions. + */ + readonly searchParams: URLSearchParams; + username: string; + /** Returns `href`. */ + toString(): string; + } + + /** The WHATWG `URLSearchParams` surface the runtime implements. */ + export interface URLSearchParams { + readonly size: number; + append(name: string, value: string): void; + /** With `value`, removes only the pairs matching both name and value. */ + delete(name: string, value?: string): void; + get(name: string): string | null; + getAll(name: string): string[]; + /** With `value`, matches only a pair with both that name and value. */ + has(name: string, value?: string): boolean; + set(name: string, value: string): void; + sort(): void; + forEach( + callback: ( + this: This, + value: string, + name: string, + searchParams: URLSearchParams + ) => void, + thisArg?: This + ): void; + /** The iterators are live: pairs added while iterating are visited. */ + entries(): IterableIterator<[string, string]>; + keys(): IterableIterator; + values(): IterableIterator; + [Symbol.iterator](): IterableIterator<[string, string]>; + toString(): string; + } + + /** + * The runtime's `URL` constructor: the very function the global of that + * name was created with, however `globalThis.URL` has been reassigned + * since. A `url` or `base` that does not parse throws a `TypeError`. + */ + export const URL: { + readonly prototype: URL; + new (url: string, base?: string | URL): URL; + canParse(url: string, base?: string): boolean; + /** + * Registers a `Blob` or `File` under a fresh `blob:nativescript/` URL; + * anything else yields `null`. `ext` records a file extension for the + * consumers that read the blob back by URL. + */ + createObjectURL( + object: object, + options?: { ext?: string } | null + ): string | null; + revokeObjectURL(url: string): void; + }; + + /** + * The runtime's `URLSearchParams` constructor, the very function the global + * of that name was created with. `init` is a query string (one leading `?` + * is ignored), an iterable of name/value pairs (an element that is not + * exactly a pair throws a `TypeError`), or a record of names to values. + */ + export const URLSearchParams: { + readonly prototype: URLSearchParams; + new ( + init?: string | Iterable | Record + ): URLSearchParams; + }; + + /** + * Converts a `file:` URL to an absolute path, percent-decoding it. Accepts + * a string or any object with a string `href` (so a `URL` from another + * realm works too). Throws a `TypeError` for a value that does not parse as + * a URL, a scheme other than `file:`, a host other than empty or + * `localhost`, or an encoded `/` in the path. + */ + export function fileURLToPath(url: string | { readonly href: string }): string; + + /** + * Converts an absolute path to a `file:` URL, percent-encoding the + * characters the URL parser would otherwise read as syntax. There is no + * working directory to resolve against, so a relative path throws a + * `TypeError`. + */ + export function pathToFileURL(path: string): URL; + + // An interface cannot extend an `import()` type, and inside `global` the + // bare names mean the globals, hence the self-import. + import { + URL as NsURL, + URLSearchParams as NsURLSearchParams, + } from "ns:url"; + global { + // Merged into the DOM's interfaces when a DOM lib is present. + interface URL extends NsURL {} + interface URLSearchParams extends NsURLSearchParams {} + } +} + +// Script-level declarations are global. The variables follow @types/node's +// rule for its own globals: when a DOM lib declares the global (detected +// through `onmessage`, which only the DOM libs put on `globalThis`), that +// declaration is reused verbatim so the two `declare var`s agree, and +// otherwise the module's constructor becomes the global. +declare var URL: typeof globalThis extends { onmessage: any; URL: infer T } + ? T + : typeof import("ns:url").URL; +declare var URLSearchParams: typeof globalThis extends { + onmessage: any; + URLSearchParams: infer T; +} + ? T + : typeof import("ns:url").URLSearchParams; From aae08b4019770e8a806c3ce3d01ef4a6b48b168b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 8 Oct 2026 16:01:29 -0300 Subject: [PATCH 2/2] fix(runtime): URL toJSON and value-aware searchParams.delete WHATWG URL defines toJSON as the href serialization, so JSON.stringify(url) yields the href string. The runtime's URL lacked it, which also kept a module URL from being assignable to lib.dom's URL in typed code. It is registered next to toString and shares its callback, so both return the same serialization. types/ns-url.d.ts declares it; tsc against the DOM and the non-DOM program stays clean, and the DOM program now assigns pathToFileURL()'s result to the global URL type. The searchParams wrapper that keeps a URL's query in sync forwarded only the name to the native delete, so url.searchParams.delete(name, value) removed every pair with that name. It now forwards the value whenever the caller passed one, by arity, so an explicit undefined still reaches the native coercion. --- NativeScript/runtime/URLImpl.cpp | 4 ++++ NativeScript/runtime/js/blob-url.js | 10 ++++++++-- TestRunner/app/tests/URL.js | 7 +++++++ TestRunner/app/tests/UrlSearchParamsTests.js | 14 ++++++++++++++ types/ns-url.d.ts | 2 ++ 5 files changed, 35 insertions(+), 2 deletions(-) diff --git a/NativeScript/runtime/URLImpl.cpp b/NativeScript/runtime/URLImpl.cpp index 29f5a2c4b..230108b28 100644 --- a/NativeScript/runtime/URLImpl.cpp +++ b/NativeScript/runtime/URLImpl.cpp @@ -93,6 +93,10 @@ v8::Local URLImpl::GetCtor(v8::Isolate* isolate) { tmpl->Set(ToV8String(isolate, "toString"), v8::FunctionTemplate::New(isolate, &ToString)); + // WHATWG defines toJSON as the href serialization, the same as toString. + tmpl->Set(ToV8String(isolate, "toJSON"), + v8::FunctionTemplate::New(isolate, &ToString)); + ctorTmpl->Set(ToV8String(isolate, "canParse"), v8::FunctionTemplate::New(isolate, &CanParse)); diff --git a/NativeScript/runtime/js/blob-url.js b/NativeScript/runtime/js/blob-url.js index 94c3fcf48..549984f0c 100644 --- a/NativeScript/runtime/js/blob-url.js +++ b/NativeScript/runtime/js/blob-url.js @@ -105,9 +105,15 @@ ObjectDefineProperty(URL.prototype, 'searchParams', { writeBack(this); }; params._delete = params.delete; - params.delete = function (name) { + params.delete = function (name, value) { resync(this); - this._delete(name); + // An explicitly passed undefined must still reach the native + // coercion, so forward by arity rather than by value. + if (arguments.length > 1) { + this._delete(name, value); + } else { + this._delete(name); + } writeBack(this); }; params._set = params.set; diff --git a/TestRunner/app/tests/URL.js b/TestRunner/app/tests/URL.js index 245d3b0a8..06cfef8fd 100644 --- a/TestRunner/app/tests/URL.js +++ b/TestRunner/app/tests/URL.js @@ -58,4 +58,11 @@ describe("URL", function () { expect(url.searchParams.get("q")).toBe("hello"); expect(url.pathname).toBe("/some/path"); }); + + it("serializes to JSON as its href", function () { + const url = new URL("https://x.test/a?b#c"); + expect(url.toJSON()).toBe(url.href); + expect(JSON.stringify(url)).toBe(JSON.stringify(url.href)); + expect(JSON.stringify({ u: url })).toBe(JSON.stringify({ u: url.href })); + }); }); diff --git a/TestRunner/app/tests/UrlSearchParamsTests.js b/TestRunner/app/tests/UrlSearchParamsTests.js index 2e8d6e4fa..81ca802e4 100644 --- a/TestRunner/app/tests/UrlSearchParamsTests.js +++ b/TestRunner/app/tests/UrlSearchParamsTests.js @@ -146,4 +146,18 @@ describe("URL.searchParams caching", function () { expect(key).not.toBe("_searchParamsSource"); } }); + + it("deletes only the matching pair when given a value", function () { + const url = new URL("https://x.test/?a=1&a=2"); + url.searchParams.delete("a", "1"); + expect(url.search).toBe("?a=2"); + expect(url.searchParams.getAll("a")).toEqual(["2"]); + }); + + it("deletes every pair of the name when given no value", function () { + const url = new URL("https://x.test/?a=1&a=2"); + url.searchParams.delete("a"); + expect(url.search).toBe(""); + expect(url.searchParams.getAll("a")).toEqual([]); + }); }); diff --git a/types/ns-url.d.ts b/types/ns-url.d.ts index a4405bdcf..4310929d9 100644 --- a/types/ns-url.d.ts +++ b/types/ns-url.d.ts @@ -22,6 +22,8 @@ declare module "ns:url" { username: string; /** Returns `href`. */ toString(): string; + /** Returns `href`, so `JSON.stringify` serializes a URL as its href. */ + toJSON(): string; } /** The WHATWG `URLSearchParams` surface the runtime implements. */