From 47af16c9c98c69f3441246a8bb688702aef28b3b Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Tue, 18 Aug 2026 12:49:51 +0000 Subject: [PATCH] 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: Yagiz Nizipli Assisted-by: Cursor --- lib/internal/url.js | 114 +++++++++--------- src/node_url.cc | 82 ++++++++++--- .../test-whatwg-url-parse-fast-path.js | 88 ++++++++++++++ 3 files changed, 215 insertions(+), 69 deletions(-) create mode 100644 test/parallel/test-whatwg-url-parse-fast-path.js diff --git a/lib/internal/url.js b/lib/internal/url.js index e96df6148f98..881b7f315c24 100644 --- a/lib/internal/url.js +++ b/lib/internal/url.js @@ -167,41 +167,62 @@ function lazyCryptoRandom() { return cryptoRandom; } +/** + * Copy href and the latest `urlComponents` snapshot into a URLContext. + * Property assignment order matches the historical URLContext fields so + * `util.inspect(..., { showHidden: true })` stays stable. + * @param {object} ctx + * @param {string} href + */ +function setURLContextFromBinding(ctx, href) { + const c = bindingUrl.urlComponents; + ctx.href = href; + ctx.protocol_end = c[0]; + ctx.username_end = c[1]; + ctx.host_start = c[2]; + ctx.host_end = c[3]; + ctx.pathname_start = c[5]; + ctx.search_start = c[6]; + ctx.hash_start = c[7]; + ctx.port = c[4]; + ctx.scheme_type = c[8]; +} + // This class provides the internal state of a URL object. An instance of this // class is stored in every URL object and is accessed internally by setters // and getters. It roughly corresponds to the concept of a URL record in the // URL Standard, with a few differences. It is also the object transported to // the C++ binding. // Refs: https://url.spec.whatwg.org/#concept-url +// +// scheme_type refers to ada::scheme::type: +// HTTP = 0, NOT_SPECIAL = 1, HTTPS = 2, WS = 3, FTP = 4, WSS = 5, FILE = 6 class URLContext { // This is the maximum value uint32_t can get. // Ada uses uint32_t(-1) for declaring omitted values. static #omitted = 4294967295; - href = ''; - protocol_end = 0; - username_end = 0; - host_start = 0; - host_end = 0; - pathname_start = 0; - search_start = 0; - hash_start = 0; - port = 0; /** - * Refers to `ada::scheme::type` - * - * enum type : uint8_t { - * HTTP = 0, - * NOT_SPECIAL = 1, - * HTTPS = 2, - * WS = 3, - * FTP = 4, - * WSS = 5, - * FILE = 6 - * }; - * @type {number} + * @param {string} [href] Parsed href. When omitted, create an empty context + * (used by `URL.parse` on invalid input). When provided, `bindingUrl.parse` + * / `update` has just written `urlComponents`. */ - scheme_type = 1; + constructor(href) { + if (href === undefined) { + this.href = ''; + this.protocol_end = 0; + this.username_end = 0; + this.host_start = 0; + this.host_end = 0; + this.pathname_start = 0; + this.search_start = 0; + this.hash_start = 0; + this.port = 0; + this.scheme_type = 1; + return; + } + setURLContextFromBinding(this, href); + } get hasPort() { return this.port !== URLContext.#omitted; @@ -819,7 +840,7 @@ const kCreateURLFromPosixPathSymbol = Symbol('kCreateURLFromPosixPath'); const kCreateURLFromWindowsPathSymbol = Symbol('kCreateURLFromWindowsPath'); class URL { - #context = new URLContext(); + #context; #searchParams; #searchParamsModified; @@ -844,16 +865,16 @@ class URL { } constructor(input, base = undefined, parseSymbol = undefined) { - markTransferMode(this, false, false); - if (arguments.length === 0) { throw new ERR_MISSING_ARGS('url'); } // StringPrototypeToWellFormed is not needed. - input = `${input}`; + if (typeof input !== 'string') { + input = `${input}`; + } - if (base !== undefined) { + if (base !== undefined && typeof base !== 'string') { base = `${base}`; } @@ -868,9 +889,12 @@ class URL { bindingUrl.pathToFileURL(input, interpretAsWindowsPath, base) : bindingUrl.parse(input, base, raiseException); } - if (href) { - this.#updateContext(href); - } + + // Delay context allocation until parse finishes so invalid URLs that + // throw do not pay for an unused URLContext. Initialize in one shot + // from the binding snapshot instead of writing an empty context first. + this.#context = href ? new URLContext(href) : new URLContext(); + markTransferMode(this, false, false); } static parse(input, base = undefined) { @@ -939,29 +963,7 @@ class URL { const previousSearch = shouldUpdateSearchParams && this.#searchParams && (this.#searchParamsModified ? this.#getSearchFromParams() : this.#getSearchFromContext()); - this.#context.href = href; - - const { - 0: protocol_end, - 1: username_end, - 2: host_start, - 3: host_end, - 4: port, - 5: pathname_start, - 6: search_start, - 7: hash_start, - 8: scheme_type, - } = bindingUrl.urlComponents; - - this.#context.protocol_end = protocol_end; - this.#context.username_end = username_end; - this.#context.host_start = host_start; - this.#context.host_end = host_end; - this.#context.port = port; - this.#context.pathname_start = pathname_start; - this.#context.search_start = search_start; - this.#context.hash_start = hash_start; - this.#context.scheme_type = scheme_type; + setURLContextFromBinding(this.#context, href); if (this.#searchParams) { // If the search string has updated, URL becomes the source of truth, and we update URLSearchParams. @@ -1186,10 +1188,12 @@ class URL { throw new ERR_MISSING_ARGS('url'); } - url = `${url}`; + if (typeof url !== 'string') { + url = `${url}`; + } if (base !== undefined) { - return bindingUrl.canParse(url, `${base}`); + return bindingUrl.canParse(url, typeof base === 'string' ? base : `${base}`); } // It is important to differentiate the canParse call statements diff --git a/src/node_url.cc b/src/node_url.cc index 38aa7fc48eb1..bb2a5d0776e3 100644 --- a/src/node_url.cc +++ b/src/node_url.cc @@ -8,6 +8,7 @@ #include "node_metadata.h" #include "node_process-inl.h" #include "path.h" +#include "simdutf.h" #include "util-inl.h" #include "v8-fast-api-calls.h" #include "v8-local-handle.h" @@ -33,6 +34,38 @@ using v8::SnapshotCreator; using v8::String; using v8::Value; +namespace { + +// Parse a V8 string as a URL. One-byte ASCII inputs are parsed in place +// without allocating a UTF-8 copy. `reuse_input` is set when the serialized +// href is identical to that ASCII input so the caller can return the original +// V8 string. Non-ASCII inputs are never reused: UTF-8 conversion may replace +// unpaired surrogates, so the original string may not match href. +ada::result ParseUrlFromV8String( + Isolate* isolate, + Local input, + const ada::url_aggregator* base_url, + bool* reuse_input) { + { + String::ValueView view(isolate, input); + if (view.is_one_byte()) { + const char* data = reinterpret_cast(view.data8()); + const size_t length = static_cast(view.length()); + if (simdutf::validate_ascii(data, length)) [[likely]] { + const std::string_view input_view(data, length); + auto out = ada::parse(input_view, base_url); + *reuse_input = out.has_value() && out->get_href() == input_view; + return out; + } + } + } + *reuse_input = false; + Utf8Value utf8(isolate, input); + return ada::parse(utf8.ToStringView(), base_url); +} + +} // namespace + void BindingData::MemoryInfo(MemoryTracker* tracker) const { tracker->TrackField("url_components_buffer", url_components_buffer_); } @@ -392,32 +425,51 @@ void BindingData::Parse(const FunctionCallbackInfo& args) { Realm* realm = Realm::GetCurrent(args); BindingData* binding_data = realm->GetBindingData(); Isolate* isolate = realm->isolate(); - std::optional base_{}; + Local input_string = args[0].As(); - Utf8Value input(isolate, args[0]); ada::result base; ada::url_aggregator* base_pointer = nullptr; if (args[1]->IsString()) { - base_ = Utf8Value(isolate, args[1]).ToString(); - base = ada::parse(*base_); - if (!base && raise_exception) { - return ThrowInvalidURL(realm->env(), input.ToStringView(), base_); - } else if (!base) { + bool unused_reuse = false; + base = ParseUrlFromV8String( + isolate, args[1].As(), nullptr, &unused_reuse); + if (!base) { + if (raise_exception) { + Utf8Value input(isolate, input_string); + Utf8Value base_utf8(isolate, args[1]); + return ThrowInvalidURL( + realm->env(), input.ToStringView(), base_utf8.ToString()); + } return; } base_pointer = &base.value(); } - auto out = - ada::parse(input.ToStringView(), base_pointer); - if (!out && raise_exception) { - return ThrowInvalidURL(realm->env(), input.ToStringView(), base_); - } else if (!out) { + bool reuse_input = false; + auto out = + ParseUrlFromV8String(isolate, input_string, base_pointer, &reuse_input); + if (!out) { + if (raise_exception) { + Utf8Value input(isolate, input_string); + std::optional base_error; + if (args[1]->IsString()) { + base_error = Utf8Value(isolate, args[1]).ToString(); + } + return ThrowInvalidURL( + realm->env(), input.ToStringView(), std::move(base_error)); + } return; } binding_data->UpdateComponents(out->get_components(), out->type); + // Already-serialized ASCII URLs are the common case. Reuse the input + // string instead of allocating an identical V8 string from href. + if (reuse_input) { + args.GetReturnValue().Set(args[0]); + return; + } + Local ret; if (ToV8Value(realm->context(), out->get_href(), isolate).ToLocal(&ret)) [[likely]] { @@ -439,13 +491,15 @@ void BindingData::Update(const FunctionCallbackInfo& args) { return; } enum url_update_action action = static_cast(val); - Utf8Value input(isolate, args[0].As()); Utf8Value new_value(isolate, args[2].As()); std::string_view new_value_view = new_value.ToStringView(); // A serialized URL is not always reparsable: the IDNA encoder can emit a // host label that the decoder rejects. Fail the update instead of crashing. - auto out = ada::parse(input.ToStringView()); + // Existing hrefs are typically already-serialized ASCII, so parse in place. + bool unused_reuse = false; + auto out = ParseUrlFromV8String( + isolate, args[0].As(), nullptr, &unused_reuse); if (!out) { return args.GetReturnValue().Set(false); } diff --git a/test/parallel/test-whatwg-url-parse-fast-path.js b/test/parallel/test-whatwg-url-parse-fast-path.js new file mode 100644 index 000000000000..e6c295f039a3 --- /dev/null +++ b/test/parallel/test-whatwg-url-parse-fast-path.js @@ -0,0 +1,88 @@ +'use strict'; + +// Covers the URL constructor parse paths that avoid a UTF-8 copy and/or +// reuse the input string when it is already a serialized ASCII href. + +const { hasIntl } = require('../common'); +const assert = require('assert'); + +const alreadySerialized = [ + 'https://nodejs.org/en/blog/', + 'http://nodejs.org:89/docs/latest/api/foo/bar/qua/13949281/0f28b/' + + '/5d49/b3020/url.html#test?payload1=true&payload2=false&test=1' + + '&benchmark=3&foo=38.38.011.293&bar=1234834910480&test=19299&3992&' + + 'key=f5c65e1e98fe07e648249ad41e1cfdb0', + 'https://user:pass@example.com/path?search=1', + 'file:///foo/bar/test/node.js', + 'ws://localhost:9229/f46db715-70df-43ad-a359-7f9949f39868', +]; + +for (const href of alreadySerialized) { + const url = new URL(href); + assert.strictEqual(url.href, href); + assert.strictEqual(URL.parse(href).href, href); + assert.strictEqual(URL.canParse(href), true); +} + +// Special-scheme URLs with an empty path gain a trailing slash. +{ + const url = new URL('https://example.com'); + assert.strictEqual(url.href, 'https://example.com/'); + assert.strictEqual(url.pathname, '/'); +} + +// Dot-segment normalization must still rewrite the path. +{ + const url = new URL('https://example.org/./a/../b/./c'); + assert.strictEqual(url.href, 'https://example.org/b/c'); + assert.strictEqual(url.pathname, '/b/c'); +} + +// Relative resolution against a base URL. +{ + const url = new URL('/path?x=1#h', 'https://example.com:8443/base'); + assert.strictEqual(url.href, 'https://example.com:8443/path?x=1#h'); + assert.strictEqual(url.host, 'example.com:8443'); +} + +// Non-string input is still stringified. +{ + const url = new URL({ toString: () => 'https://example.com/from-object' }); + assert.strictEqual(url.href, 'https://example.com/from-object'); +} + +// Invalid input still throws from the constructor and is null from parse(). +{ + assert.throws(() => new URL('not a url'), { + code: 'ERR_INVALID_URL', + name: 'TypeError', + }); + assert.strictEqual(URL.parse('not a url'), null); + assert.strictEqual(URL.canParse('not a url'), false); +} + +// Unpaired surrogates must not be returned as-is from href. +{ + const input = 'https://example.com/\uD800'; + const url = new URL(input); + assert.notStrictEqual(url.href, input); + assert.ok(url.href.startsWith('https://example.com/')); +} + +if (hasIntl) { + const url = new URL('http://你好你好.在线'); + assert.ok(url.hostname.startsWith('xn--')); + assert.ok(url.href.startsWith('http://xn--')); +} + +// Setters re-parse the existing href; keep component updates correct. +{ + const url = new URL('https://example.com/old'); + url.pathname = '/new'; + url.search = 'q=1'; + url.hash = 'frag'; + assert.strictEqual(url.href, 'https://example.com/new?q=1#frag'); + assert.strictEqual(url.pathname, '/new'); + assert.strictEqual(url.search, '?q=1'); + assert.strictEqual(url.hash, '#frag'); +}