Skip to content

Commit b9884e1

Browse files
cursoragentanonrig
andcommitted
url: speed up WHATWG URL parsing
Parse one-byte ASCII inputs in place instead of copying them into a UTF-8 buffer, and reuse the original V8 string when the serialized href is unchanged. Delay URLContext allocation until parse finishes and skip ToString when the input is already a string. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Yagiz Nizipli <anonrig@users.noreply.github.com>
1 parent 13fcd6f commit b9884e1

3 files changed

Lines changed: 215 additions & 69 deletions

File tree

lib/internal/url.js

Lines changed: 59 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -167,41 +167,62 @@ function lazyCryptoRandom() {
167167
return cryptoRandom;
168168
}
169169

170+
/**
171+
* Copy href and the latest `urlComponents` snapshot into a URLContext.
172+
* Property assignment order matches the historical URLContext fields so
173+
* `util.inspect(..., { showHidden: true })` stays stable.
174+
* @param {object} ctx
175+
* @param {string} href
176+
*/
177+
function setURLContextFromBinding(ctx, href) {
178+
const c = bindingUrl.urlComponents;
179+
ctx.href = href;
180+
ctx.protocol_end = c[0];
181+
ctx.username_end = c[1];
182+
ctx.host_start = c[2];
183+
ctx.host_end = c[3];
184+
ctx.pathname_start = c[5];
185+
ctx.search_start = c[6];
186+
ctx.hash_start = c[7];
187+
ctx.port = c[4];
188+
ctx.scheme_type = c[8];
189+
}
190+
170191
// This class provides the internal state of a URL object. An instance of this
171192
// class is stored in every URL object and is accessed internally by setters
172193
// and getters. It roughly corresponds to the concept of a URL record in the
173194
// URL Standard, with a few differences. It is also the object transported to
174195
// the C++ binding.
175196
// Refs: https://url.spec.whatwg.org/#concept-url
197+
//
198+
// scheme_type refers to ada::scheme::type:
199+
// HTTP = 0, NOT_SPECIAL = 1, HTTPS = 2, WS = 3, FTP = 4, WSS = 5, FILE = 6
176200
class URLContext {
177201
// This is the maximum value uint32_t can get.
178202
// Ada uses uint32_t(-1) for declaring omitted values.
179203
static #omitted = 4294967295;
180204

181-
href = '';
182-
protocol_end = 0;
183-
username_end = 0;
184-
host_start = 0;
185-
host_end = 0;
186-
pathname_start = 0;
187-
search_start = 0;
188-
hash_start = 0;
189-
port = 0;
190205
/**
191-
* Refers to `ada::scheme::type`
192-
*
193-
* enum type : uint8_t {
194-
* HTTP = 0,
195-
* NOT_SPECIAL = 1,
196-
* HTTPS = 2,
197-
* WS = 3,
198-
* FTP = 4,
199-
* WSS = 5,
200-
* FILE = 6
201-
* };
202-
* @type {number}
206+
* @param {string} [href] Parsed href. When omitted, create an empty context
207+
* (used by `URL.parse` on invalid input). When provided, `bindingUrl.parse`
208+
* / `update` has just written `urlComponents`.
203209
*/
204-
scheme_type = 1;
210+
constructor(href) {
211+
if (href === undefined) {
212+
this.href = '';
213+
this.protocol_end = 0;
214+
this.username_end = 0;
215+
this.host_start = 0;
216+
this.host_end = 0;
217+
this.pathname_start = 0;
218+
this.search_start = 0;
219+
this.hash_start = 0;
220+
this.port = 0;
221+
this.scheme_type = 1;
222+
return;
223+
}
224+
setURLContextFromBinding(this, href);
225+
}
205226

206227
get hasPort() {
207228
return this.port !== URLContext.#omitted;
@@ -819,7 +840,7 @@ const kCreateURLFromPosixPathSymbol = Symbol('kCreateURLFromPosixPath');
819840
const kCreateURLFromWindowsPathSymbol = Symbol('kCreateURLFromWindowsPath');
820841

821842
class URL {
822-
#context = new URLContext();
843+
#context;
823844
#searchParams;
824845
#searchParamsModified;
825846

@@ -844,16 +865,16 @@ class URL {
844865
}
845866

846867
constructor(input, base = undefined, parseSymbol = undefined) {
847-
markTransferMode(this, false, false);
848-
849868
if (arguments.length === 0) {
850869
throw new ERR_MISSING_ARGS('url');
851870
}
852871

853872
// StringPrototypeToWellFormed is not needed.
854-
input = `${input}`;
873+
if (typeof input !== 'string') {
874+
input = `${input}`;
875+
}
855876

856-
if (base !== undefined) {
877+
if (base !== undefined && typeof base !== 'string') {
857878
base = `${base}`;
858879
}
859880

@@ -868,9 +889,12 @@ class URL {
868889
bindingUrl.pathToFileURL(input, interpretAsWindowsPath, base) :
869890
bindingUrl.parse(input, base, raiseException);
870891
}
871-
if (href) {
872-
this.#updateContext(href);
873-
}
892+
893+
// Delay context allocation until parse finishes so invalid URLs that
894+
// throw do not pay for an unused URLContext. Initialize in one shot
895+
// from the binding snapshot instead of writing an empty context first.
896+
this.#context = href ? new URLContext(href) : new URLContext();
897+
markTransferMode(this, false, false);
874898
}
875899

876900
static parse(input, base = undefined) {
@@ -939,29 +963,7 @@ class URL {
939963
const previousSearch = shouldUpdateSearchParams && this.#searchParams &&
940964
(this.#searchParamsModified ? this.#getSearchFromParams() : this.#getSearchFromContext());
941965

942-
this.#context.href = href;
943-
944-
const {
945-
0: protocol_end,
946-
1: username_end,
947-
2: host_start,
948-
3: host_end,
949-
4: port,
950-
5: pathname_start,
951-
6: search_start,
952-
7: hash_start,
953-
8: scheme_type,
954-
} = bindingUrl.urlComponents;
955-
956-
this.#context.protocol_end = protocol_end;
957-
this.#context.username_end = username_end;
958-
this.#context.host_start = host_start;
959-
this.#context.host_end = host_end;
960-
this.#context.port = port;
961-
this.#context.pathname_start = pathname_start;
962-
this.#context.search_start = search_start;
963-
this.#context.hash_start = hash_start;
964-
this.#context.scheme_type = scheme_type;
966+
setURLContextFromBinding(this.#context, href);
965967

966968
if (this.#searchParams) {
967969
// If the search string has updated, URL becomes the source of truth, and we update URLSearchParams.
@@ -1186,10 +1188,12 @@ class URL {
11861188
throw new ERR_MISSING_ARGS('url');
11871189
}
11881190

1189-
url = `${url}`;
1191+
if (typeof url !== 'string') {
1192+
url = `${url}`;
1193+
}
11901194

11911195
if (base !== undefined) {
1192-
return bindingUrl.canParse(url, `${base}`);
1196+
return bindingUrl.canParse(url, typeof base === 'string' ? base : `${base}`);
11931197
}
11941198

11951199
// It is important to differentiate the canParse call statements

src/node_url.cc

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
#include "node_metadata.h"
99
#include "node_process-inl.h"
1010
#include "path.h"
11+
#include "simdutf.h"
1112
#include "util-inl.h"
1213
#include "v8-fast-api-calls.h"
1314
#include "v8-local-handle.h"
@@ -33,6 +34,38 @@ using v8::SnapshotCreator;
3334
using v8::String;
3435
using v8::Value;
3536

37+
namespace {
38+
39+
// Parse a V8 string as a URL. One-byte ASCII inputs are parsed in place
40+
// without allocating a UTF-8 copy. `reuse_input` is set when the serialized
41+
// href is identical to that ASCII input so the caller can return the original
42+
// V8 string. Non-ASCII inputs are never reused: UTF-8 conversion may replace
43+
// unpaired surrogates, so the original string may not match href.
44+
ada::result<ada::url_aggregator> ParseUrlFromV8String(
45+
Isolate* isolate,
46+
Local<String> input,
47+
const ada::url_aggregator* base_url,
48+
bool* reuse_input) {
49+
{
50+
String::ValueView view(isolate, input);
51+
if (view.is_one_byte()) {
52+
const char* data = reinterpret_cast<const char*>(view.data8());
53+
const size_t length = static_cast<size_t>(view.length());
54+
if (simdutf::validate_ascii(data, length)) [[likely]] {
55+
const std::string_view input_view(data, length);
56+
auto out = ada::parse<ada::url_aggregator>(input_view, base_url);
57+
*reuse_input = out.has_value() && out->get_href() == input_view;
58+
return out;
59+
}
60+
}
61+
}
62+
*reuse_input = false;
63+
Utf8Value utf8(isolate, input);
64+
return ada::parse<ada::url_aggregator>(utf8.ToStringView(), base_url);
65+
}
66+
67+
} // namespace
68+
3669
void BindingData::MemoryInfo(MemoryTracker* tracker) const {
3770
tracker->TrackField("url_components_buffer", url_components_buffer_);
3871
}
@@ -392,32 +425,51 @@ void BindingData::Parse(const FunctionCallbackInfo<Value>& args) {
392425
Realm* realm = Realm::GetCurrent(args);
393426
BindingData* binding_data = realm->GetBindingData<BindingData>();
394427
Isolate* isolate = realm->isolate();
395-
std::optional<std::string> base_{};
428+
Local<String> input_string = args[0].As<String>();
396429

397-
Utf8Value input(isolate, args[0]);
398430
ada::result<ada::url_aggregator> base;
399431
ada::url_aggregator* base_pointer = nullptr;
400432
if (args[1]->IsString()) {
401-
base_ = Utf8Value(isolate, args[1]).ToString();
402-
base = ada::parse<ada::url_aggregator>(*base_);
403-
if (!base && raise_exception) {
404-
return ThrowInvalidURL(realm->env(), input.ToStringView(), base_);
405-
} else if (!base) {
433+
bool unused_reuse = false;
434+
base = ParseUrlFromV8String(
435+
isolate, args[1].As<String>(), nullptr, &unused_reuse);
436+
if (!base) {
437+
if (raise_exception) {
438+
Utf8Value input(isolate, input_string);
439+
Utf8Value base_utf8(isolate, args[1]);
440+
return ThrowInvalidURL(
441+
realm->env(), input.ToStringView(), base_utf8.ToString());
442+
}
406443
return;
407444
}
408445
base_pointer = &base.value();
409446
}
410-
auto out =
411-
ada::parse<ada::url_aggregator>(input.ToStringView(), base_pointer);
412447

413-
if (!out && raise_exception) {
414-
return ThrowInvalidURL(realm->env(), input.ToStringView(), base_);
415-
} else if (!out) {
448+
bool reuse_input = false;
449+
auto out =
450+
ParseUrlFromV8String(isolate, input_string, base_pointer, &reuse_input);
451+
if (!out) {
452+
if (raise_exception) {
453+
Utf8Value input(isolate, input_string);
454+
std::optional<std::string> base_error;
455+
if (args[1]->IsString()) {
456+
base_error = Utf8Value(isolate, args[1]).ToString();
457+
}
458+
return ThrowInvalidURL(
459+
realm->env(), input.ToStringView(), std::move(base_error));
460+
}
416461
return;
417462
}
418463

419464
binding_data->UpdateComponents(out->get_components(), out->type);
420465

466+
// Already-serialized ASCII URLs are the common case. Reuse the input
467+
// string instead of allocating an identical V8 string from href.
468+
if (reuse_input) {
469+
args.GetReturnValue().Set(args[0]);
470+
return;
471+
}
472+
421473
Local<Value> ret;
422474
if (ToV8Value(realm->context(), out->get_href(), isolate).ToLocal(&ret))
423475
[[likely]] {
@@ -439,13 +491,15 @@ void BindingData::Update(const FunctionCallbackInfo<Value>& args) {
439491
return;
440492
}
441493
enum url_update_action action = static_cast<enum url_update_action>(val);
442-
Utf8Value input(isolate, args[0].As<String>());
443494
Utf8Value new_value(isolate, args[2].As<String>());
444495

445496
std::string_view new_value_view = new_value.ToStringView();
446497
// A serialized URL is not always reparsable: the IDNA encoder can emit a
447498
// host label that the decoder rejects. Fail the update instead of crashing.
448-
auto out = ada::parse<ada::url_aggregator>(input.ToStringView());
499+
// Existing hrefs are typically already-serialized ASCII, so parse in place.
500+
bool unused_reuse = false;
501+
auto out = ParseUrlFromV8String(
502+
isolate, args[0].As<String>(), nullptr, &unused_reuse);
449503
if (!out) {
450504
return args.GetReturnValue().Set(false);
451505
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
'use strict';
2+
3+
// Covers the URL constructor parse paths that avoid a UTF-8 copy and/or
4+
// reuse the input string when it is already a serialized ASCII href.
5+
6+
const { hasIntl } = require('../common');
7+
const assert = require('assert');
8+
9+
const alreadySerialized = [
10+
'https://nodejs.org/en/blog/',
11+
'http://nodejs.org:89/docs/latest/api/foo/bar/qua/13949281/0f28b/' +
12+
'/5d49/b3020/url.html#test?payload1=true&payload2=false&test=1' +
13+
'&benchmark=3&foo=38.38.011.293&bar=1234834910480&test=19299&3992&' +
14+
'key=f5c65e1e98fe07e648249ad41e1cfdb0',
15+
'https://user:pass@example.com/path?search=1',
16+
'file:///foo/bar/test/node.js',
17+
'ws://localhost:9229/f46db715-70df-43ad-a359-7f9949f39868',
18+
];
19+
20+
for (const href of alreadySerialized) {
21+
const url = new URL(href);
22+
assert.strictEqual(url.href, href);
23+
assert.strictEqual(URL.parse(href).href, href);
24+
assert.strictEqual(URL.canParse(href), true);
25+
}
26+
27+
// Special-scheme URLs with an empty path gain a trailing slash.
28+
{
29+
const url = new URL('https://example.com');
30+
assert.strictEqual(url.href, 'https://example.com/');
31+
assert.strictEqual(url.pathname, '/');
32+
}
33+
34+
// Dot-segment normalization must still rewrite the path.
35+
{
36+
const url = new URL('https://example.org/./a/../b/./c');
37+
assert.strictEqual(url.href, 'https://example.org/b/c');
38+
assert.strictEqual(url.pathname, '/b/c');
39+
}
40+
41+
// Relative resolution against a base URL.
42+
{
43+
const url = new URL('/path?x=1#h', 'https://example.com:8443/base');
44+
assert.strictEqual(url.href, 'https://example.com:8443/path?x=1#h');
45+
assert.strictEqual(url.host, 'example.com:8443');
46+
}
47+
48+
// Non-string input is still stringified.
49+
{
50+
const url = new URL({ toString: () => 'https://example.com/from-object' });
51+
assert.strictEqual(url.href, 'https://example.com/from-object');
52+
}
53+
54+
// Invalid input still throws from the constructor and is null from parse().
55+
{
56+
assert.throws(() => new URL('not a url'), {
57+
code: 'ERR_INVALID_URL',
58+
name: 'TypeError',
59+
});
60+
assert.strictEqual(URL.parse('not a url'), null);
61+
assert.strictEqual(URL.canParse('not a url'), false);
62+
}
63+
64+
// Unpaired surrogates must not be returned as-is from href.
65+
{
66+
const input = 'https://example.com/\uD800';
67+
const url = new URL(input);
68+
assert.notStrictEqual(url.href, input);
69+
assert.ok(url.href.startsWith('https://example.com/'));
70+
}
71+
72+
if (hasIntl) {
73+
const url = new URL('http://你好你好.在线');
74+
assert.ok(url.hostname.startsWith('xn--'));
75+
assert.ok(url.href.startsWith('http://xn--'));
76+
}
77+
78+
// Setters re-parse the existing href; keep component updates correct.
79+
{
80+
const url = new URL('https://example.com/old');
81+
url.pathname = '/new';
82+
url.search = 'q=1';
83+
url.hash = 'frag';
84+
assert.strictEqual(url.href, 'https://example.com/new?q=1#frag');
85+
assert.strictEqual(url.pathname, '/new');
86+
assert.strictEqual(url.search, '?q=1');
87+
assert.strictEqual(url.hash, '#frag');
88+
}

0 commit comments

Comments
 (0)