Skip to content

Commit 40887a2

Browse files
committed
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.
1 parent a858caf commit 40887a2

15 files changed

Lines changed: 487 additions & 132 deletions

‎NativeScript/runtime/NsBuiltinModules.cpp‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
#include "Runtime.h"
1212
#include "StructuredSerialization.h"
1313
#include "TextEncoding.h"
14+
#include "URLImpl.h"
15+
#include "URLSearchParamsImpl.h"
1416
#include "Worker.h"
1517

1618
using namespace v8;
@@ -25,6 +27,7 @@ constexpr const char* kNodePrefix = "node:";
2527
// Defined below, each next to the natives it gathers.
2628
MaybeLocal<Object> NsModuleBinding(Local<Context> context);
2729
MaybeLocal<Object> NsRuntimeBinding(Local<Context> context);
30+
MaybeLocal<Object> NsUrlBinding(Local<Context> context);
2831
MaybeLocal<Object> NsUtilBinding(Local<Context> context);
2932
MaybeLocal<Object> NsWorkerThreadsBinding(Local<Context> context);
3033

@@ -47,6 +50,7 @@ struct Registration {
4750
constexpr Registration kRegistry[] = {
4851
{"ns:module", BuiltinId::kNsModule, NsModuleBinding},
4952
{"ns:runtime", BuiltinId::kNsRuntime, NsRuntimeBinding},
53+
{"ns:url", BuiltinId::kNsUrl, NsUrlBinding},
5054
{"ns:util", BuiltinId::kNsUtil, NsUtilBinding},
5155
{"ns:worker_threads", BuiltinId::kNsWorkerThreads, NsWorkerThreadsBinding},
5256
{"node:module", BuiltinId::kNodeModule, nullptr},
@@ -200,6 +204,27 @@ MaybeLocal<Object> NsRuntimeBinding(Local<Context> context) {
200204
return binding;
201205
}
202206

207+
// URL and URLSearchParams for ns:url: the context's own, from the templates
208+
// their Init installed, rather than whatever the globals name by the time the
209+
// module is first required.
210+
MaybeLocal<Object> NsUrlBinding(Local<Context> context) {
211+
Isolate* isolate = v8::Isolate::GetCurrent();
212+
Local<Object> binding = Object::New(isolate);
213+
Local<v8::Function> url;
214+
Local<v8::Function> searchParams;
215+
if (!URLImpl::Constructor(context).ToLocal(&url) ||
216+
!URLSearchParamsImpl::Constructor(context).ToLocal(&searchParams) ||
217+
!binding->Set(context, tns::ToV8String(isolate, "URL"), url)
218+
.FromMaybe(false) ||
219+
!binding
220+
->Set(context, tns::ToV8String(isolate, "URLSearchParams"),
221+
searchParams)
222+
.FromMaybe(false)) {
223+
return MaybeLocal<Object>();
224+
}
225+
return binding;
226+
}
227+
203228
// The Worker constructor for ns:worker_threads: the context's own, from the
204229
// template Worker::Init installed, rather than whatever `globalThis.Worker`
205230
// names by the time the module is first required.

‎NativeScript/runtime/URLImpl.cpp‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,24 @@
44

55
#include "URLImpl.h"
66

7+
#include "Caches.h"
78
#include "Helpers.h"
89
#include "ModuleBinding.hpp"
910

1011
using namespace tns;
1112
using namespace ada;
1213

14+
namespace {
15+
16+
// The constructor template Init put on the global template. A template's
17+
// function is cached per context, so instantiating it again yields the very
18+
// function the global was created with.
19+
struct URLConstructorState {
20+
v8::Global<v8::FunctionTemplate> constructor;
21+
};
22+
23+
} // namespace
24+
1325
URLImpl::URLImpl(url_aggregator url) : url_(url) {}
1426

1527
void URLImpl::Init(v8::Isolate* isolate,
@@ -18,6 +30,20 @@ void URLImpl::Init(v8::Isolate* isolate,
1830

1931
v8::Local<v8::String> urlPropertyName = ToV8String(isolate, "URL");
2032
globalTemplate->Set(urlPropertyName, URLTemplate);
33+
34+
if (auto* state = Caches::StateFor<URLConstructorState>(isolate)) {
35+
state->constructor.Reset(isolate, URLTemplate);
36+
}
37+
}
38+
39+
v8::MaybeLocal<v8::Function> URLImpl::Constructor(
40+
v8::Local<v8::Context> context) {
41+
v8::Isolate* isolate = v8::Isolate::GetCurrent();
42+
auto* state = Caches::StateFor<URLConstructorState>(isolate);
43+
if (state == nullptr || state->constructor.IsEmpty()) {
44+
return v8::MaybeLocal<v8::Function>();
45+
}
46+
return state->constructor.Get(isolate)->GetFunction(context);
2147
}
2248

2349
URLImpl* URLImpl::GetPointer(v8::Local<v8::Object> object) {

‎NativeScript/runtime/URLImpl.h‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ class URLImpl : public IsolateTracked {
2121
static void Init(v8::Isolate* isolate,
2222
v8::Local<v8::ObjectTemplate> globalTemplate);
2323

24+
// The URL constructor of `context`: the function Init's template produces
25+
// for it, which is the object the global of that name was created with,
26+
// whatever `globalThis.URL` names by now. Empty before Init has run for the
27+
// isolate.
28+
static v8::MaybeLocal<v8::Function> Constructor(
29+
v8::Local<v8::Context> context);
30+
2431
static URLImpl* GetPointer(v8::Local<v8::Object> object);
2532

2633
static v8::Local<v8::FunctionTemplate> GetCtor(v8::Isolate* isolate);

‎NativeScript/runtime/URLSearchParamsImpl.cpp‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,24 @@
44

55
#include "URLSearchParamsImpl.h"
66

7+
#include "Caches.h"
78
#include "Helpers.h"
89
#include "ModuleBinding.hpp"
910

1011
using namespace ada;
1112

1213
namespace tns {
14+
15+
namespace {
16+
17+
// The constructor template Init put on the global template; see
18+
// URLConstructorState in URLImpl.cpp.
19+
struct URLSearchParamsConstructorState {
20+
v8::Global<v8::FunctionTemplate> constructor;
21+
};
22+
23+
} // namespace
24+
1325
URLSearchParamsImpl::URLSearchParamsImpl(ada::url_search_params params)
1426
: params_(params) {}
1527

@@ -20,6 +32,21 @@ void URLSearchParamsImpl::Init(v8::Isolate* isolate,
2032
v8::Local<v8::String> urlSearchParamsPropertyName =
2133
ToV8String(isolate, "URLSearchParams");
2234
globalTemplate->Set(urlSearchParamsPropertyName, URLSearchParamsTemplate);
35+
36+
if (auto* state =
37+
Caches::StateFor<URLSearchParamsConstructorState>(isolate)) {
38+
state->constructor.Reset(isolate, URLSearchParamsTemplate);
39+
}
40+
}
41+
42+
v8::MaybeLocal<v8::Function> URLSearchParamsImpl::Constructor(
43+
v8::Local<v8::Context> context) {
44+
v8::Isolate* isolate = v8::Isolate::GetCurrent();
45+
auto* state = Caches::StateFor<URLSearchParamsConstructorState>(isolate);
46+
if (state == nullptr || state->constructor.IsEmpty()) {
47+
return v8::MaybeLocal<v8::Function>();
48+
}
49+
return state->constructor.Get(isolate)->GetFunction(context);
2350
}
2451

2552
URLSearchParamsImpl* URLSearchParamsImpl::GetPointer(

‎NativeScript/runtime/URLSearchParamsImpl.h‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ class URLSearchParamsImpl : public IsolateTracked {
2222
static void Init(v8::Isolate* isolate,
2323
v8::Local<v8::ObjectTemplate> globalTemplate);
2424

25+
// The URLSearchParams constructor of `context`, as URLImpl::Constructor.
26+
static v8::MaybeLocal<v8::Function> Constructor(
27+
v8::Local<v8::Context> context);
28+
2529
static void Ctor(const v8::FunctionCallbackInfo<v8::Value>& args);
2630

2731
static void Append(const v8::FunctionCallbackInfo<v8::Value>& args);
Lines changed: 12 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,104 +1,16 @@
11
"use strict";
22

3-
// The `node:url` compatibility shim: the two path/URL converters
4-
// (docs/ns-builtin-modules.md). Parsing goes through the URL intrinsic rather
5-
// than a hand-rolled scan, so authority normalization (`file://localhost/x`
6-
// has no host, per the URL spec), percent-decoding and path canonicalization
7-
// all follow the spec instead of an approximation.
3+
// The `node:url` compatibility shim over `ns:url` (docs/ns-builtin-modules.md).
4+
// The two surfaces coincide today, so this only re-exports; any adaptation to
5+
// Node's API belongs here, never in the standard module.
86

9-
const {
10-
decodeURIComponent,
11-
ObjectFreeze,
12-
StringPrototypeCharCodeAt,
13-
StringPrototypeStartsWith,
14-
TypeError,
15-
URL,
16-
} = primordials;
17-
18-
const INVALID_ARG =
19-
'The "path" argument must be of type string or an instance of URL.';
20-
21-
function toUrl(input) {
22-
let href;
23-
if (typeof input === "string") {
24-
href = input;
25-
} else if (input !== null && typeof input === "object" &&
26-
typeof input.href === "string") {
27-
// Duck-typed so a URL from another realm still works.
28-
href = input.href;
29-
} else {
30-
throw new TypeError(INVALID_ARG);
31-
}
32-
33-
try {
34-
return new URL(href);
35-
} catch {
36-
throw new TypeError(INVALID_ARG);
37-
}
38-
}
39-
40-
function fileURLToPath(input) {
41-
const url = toUrl(input);
42-
43-
if (url.protocol !== "file:") {
44-
throw new TypeError("The URL must be of scheme file");
45-
}
46-
// The URL parser already folded a "localhost" authority to the empty host,
47-
// so anything left here is a real remote host and names no local file.
48-
if (url.hostname !== "") {
49-
throw new TypeError('File URL host must be "localhost" or empty');
50-
}
51-
52-
// `pathname` carries neither the query nor the fragment.
53-
const pathname = url.pathname;
54-
for (let i = 0; i < pathname.length; i++) {
55-
if (pathname[i] !== "%") {
56-
continue;
57-
}
58-
// %2F would decode to a separator and silently change the path's shape.
59-
const third = StringPrototypeCharCodeAt(pathname, i + 2) | 0x20;
60-
if (pathname[i + 1] === "2" && third === 102 /* 'f' */) {
61-
throw new TypeError("File URL path must not include encoded / characters");
62-
}
63-
}
7+
const { ObjectFreeze } = primordials;
8+
const { URL, URLSearchParams, fileURLToPath, pathToFileURL } =
9+
require("ns:url");
6410

65-
return decodeURIComponent(pathname);
66-
}
67-
68-
const kHexDigits = "0123456789ABCDEF";
69-
70-
// Percent-encode everything the URL parser would otherwise read as syntax (or
71-
// reject), leaving `/` as the separator it is. Non-ASCII is left alone: the
72-
// parser UTF-8 encodes it correctly on its own.
73-
function encodePathChars(filepath) {
74-
let encoded = "";
75-
for (let i = 0; i < filepath.length; i++) {
76-
const char = filepath[i];
77-
const code = StringPrototypeCharCodeAt(filepath, i);
78-
const mustEncode =
79-
code < 0x21 || code === 0x7f || char === "%" || char === "?" ||
80-
char === "#" || char === "\\" || char === '"' || char === "<" ||
81-
char === ">" || char === "`" || char === "{" || char === "}";
82-
if (mustEncode) {
83-
encoded += "%" + kHexDigits[(code >> 4) & 0xf] + kHexDigits[code & 0xf];
84-
} else {
85-
encoded += char;
86-
}
87-
}
88-
return encoded;
89-
}
90-
91-
function pathToFileURL(filepath) {
92-
if (typeof filepath !== "string") {
93-
throw new TypeError('The "path" argument must be of type string.');
94-
}
95-
// Node resolves a relative path against the process working directory; there
96-
// is no such thing here, so a relative path has no single correct answer.
97-
if (!StringPrototypeStartsWith(filepath, "/")) {
98-
throw new TypeError('The "path" argument must be an absolute path.');
99-
}
100-
101-
return new URL("file://" + encodePathChars(filepath));
102-
}
103-
104-
module.exports = ObjectFreeze({ fileURLToPath, pathToFileURL });
11+
module.exports = ObjectFreeze({
12+
URL,
13+
URLSearchParams,
14+
fileURLToPath,
15+
pathToFileURL,
16+
});

‎NativeScript/runtime/js/ns-url.js‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"use strict";
2+
3+
// The `ns:url` builtin module: the runtime's own URL and URLSearchParams, the
4+
// very functions the globals of those names were created with, and the two
5+
// path/URL converters. See docs/ns-builtin-modules.md for the contract.
6+
//
7+
// The converters parse through URL rather than a hand-rolled scan, so
8+
// authority normalization (`file://localhost/x` has no host, per the URL
9+
// spec), percent-decoding and path canonicalization all follow the spec
10+
// instead of an approximation.
11+
12+
const { URL, URLSearchParams } = binding;
13+
const {
14+
decodeURIComponent,
15+
ObjectFreeze,
16+
StringPrototypeCharCodeAt,
17+
StringPrototypeStartsWith,
18+
TypeError,
19+
} = primordials;
20+
21+
const INVALID_ARG =
22+
'The "path" argument must be of type string or an instance of URL.';
23+
24+
function toUrl(input) {
25+
let href;
26+
if (typeof input === "string") {
27+
href = input;
28+
} else if (input !== null && typeof input === "object" &&
29+
typeof input.href === "string") {
30+
// Duck-typed so a URL from another realm still works.
31+
href = input.href;
32+
} else {
33+
throw new TypeError(INVALID_ARG);
34+
}
35+
36+
try {
37+
return new URL(href);
38+
} catch {
39+
throw new TypeError(INVALID_ARG);
40+
}
41+
}
42+
43+
function fileURLToPath(input) {
44+
const url = toUrl(input);
45+
46+
if (url.protocol !== "file:") {
47+
throw new TypeError("The URL must be of scheme file");
48+
}
49+
// The URL parser already folded a "localhost" authority to the empty host,
50+
// so anything left here is a real remote host and names no local file.
51+
if (url.hostname !== "") {
52+
throw new TypeError('File URL host must be "localhost" or empty');
53+
}
54+
55+
// `pathname` carries neither the query nor the fragment.
56+
const pathname = url.pathname;
57+
for (let i = 0; i < pathname.length; i++) {
58+
if (pathname[i] !== "%") {
59+
continue;
60+
}
61+
// %2F would decode to a separator and silently change the path's shape.
62+
const third = StringPrototypeCharCodeAt(pathname, i + 2) | 0x20;
63+
if (pathname[i + 1] === "2" && third === 102 /* 'f' */) {
64+
throw new TypeError("File URL path must not include encoded / characters");
65+
}
66+
}
67+
68+
return decodeURIComponent(pathname);
69+
}
70+
71+
const kHexDigits = "0123456789ABCDEF";
72+
73+
// Percent-encode everything the URL parser would otherwise read as syntax (or
74+
// reject), leaving `/` as the separator it is. Non-ASCII is left alone: the
75+
// parser UTF-8 encodes it correctly on its own.
76+
function encodePathChars(filepath) {
77+
let encoded = "";
78+
for (let i = 0; i < filepath.length; i++) {
79+
const char = filepath[i];
80+
const code = StringPrototypeCharCodeAt(filepath, i);
81+
const mustEncode =
82+
code < 0x21 || code === 0x7f || char === "%" || char === "?" ||
83+
char === "#" || char === "\\" || char === '"' || char === "<" ||
84+
char === ">" || char === "`" || char === "{" || char === "}";
85+
if (mustEncode) {
86+
encoded += "%" + kHexDigits[(code >> 4) & 0xf] + kHexDigits[code & 0xf];
87+
} else {
88+
encoded += char;
89+
}
90+
}
91+
return encoded;
92+
}
93+
94+
function pathToFileURL(filepath) {
95+
if (typeof filepath !== "string") {
96+
throw new TypeError('The "path" argument must be of type string.');
97+
}
98+
// Node resolves a relative path against the process working directory; there
99+
// is no such thing here, so a relative path has no single correct answer.
100+
if (!StringPrototypeStartsWith(filepath, "/")) {
101+
throw new TypeError('The "path" argument must be an absolute path.');
102+
}
103+
104+
return new URL("file://" + encodePathChars(filepath));
105+
}
106+
107+
exports.URL = URL;
108+
exports.URLSearchParams = URLSearchParams;
109+
exports.fileURLToPath = fileURLToPath;
110+
exports.pathToFileURL = pathToFileURL;
111+
ObjectFreeze(exports);

0 commit comments

Comments
 (0)