diff --git a/NativeScript/runtime/NsBuiltinModules.cpp b/NativeScript/runtime/NsBuiltinModules.cpp index e3096a71..46d6f73a 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 7c3adb9c..230108b2 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) { @@ -67,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/URLImpl.h b/NativeScript/runtime/URLImpl.h index 4e4e6ec8..c2b1fd22 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 80023e3d..0f03c26e 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 87492ba4..413c8446 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/blob-url.js b/NativeScript/runtime/js/blob-url.js index 94c3fcf4..549984f0 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/NativeScript/runtime/js/node-url.js b/NativeScript/runtime/js/node-url.js index d499e1b6..7604c0e2 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 00000000..07d06ce8 --- /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 3be18f51..c751069c 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 00000000..c829208a --- /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/URL.js b/TestRunner/app/tests/URL.js index 245d3b0a..06cfef8f 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 2e8d6e4f..81ca802e 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/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index bdd14c62..1b4cd3c1 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 00000000..a97110b9 --- /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 87384363..ad839e44 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 cd46992c..af0d3589 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 b1aff29b..94256051 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 00000000..4310929d --- /dev/null +++ b/types/ns-url.d.ts @@ -0,0 +1,135 @@ +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; + /** Returns `href`, so `JSON.stringify` serializes a URL as its href. */ + toJSON(): 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;