diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 43d525869..e321fb27b 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -50,6 +50,7 @@ - [`node:test`](./interop/nodejs-builtins/supported-modules/test.md) - [`node:timers`](./interop/nodejs-builtins/supported-modules/timers.md) - [`node:tty`](./interop/nodejs-builtins/supported-modules/tty.md) + - [`node:url`](./interop/nodejs-builtins/supported-modules/url.md) - [Troubleshooting]() - [Common issues](./troubleshooting/common-issues.md) - [Contributor Guide]() diff --git a/docs/src/interop/nodejs-builtins.md b/docs/src/interop/nodejs-builtins.md index a918f9793..63d85a365 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -132,7 +132,6 @@ the module or upstream project. | Modules | Why they are not enabled yet | | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `node:trace_events` | The fallbacks preserve useful shapes, but tracing is synthetic or no-op without runtime integration. | -| `node:url` | There is substantial Node-derived code, but its eager `node:path` dependency adds a WASI environment requirement even for global-only URL use, and its namespace combines modern and legacy APIs that need separate policy. | ### Host-backed or broad subsystems diff --git a/docs/src/interop/nodejs-builtins/supported-modules/index.md b/docs/src/interop/nodejs-builtins/supported-modules/index.md index 0d4c55673..6bb6120d8 100644 --- a/docs/src/interop/nodejs-builtins/supported-modules/index.md +++ b/docs/src/interop/nodejs-builtins/supported-modules/index.md @@ -66,6 +66,7 @@ compatibility limits. Related submodules share their parent API page. See the | [`node:test`](./test.md), [`node:test/reporters`](./test.md) | Serial component tests, hooks, assertions, mocks, and reporters. No additional WIT imports. Runner requires engine `AbortController`; see the API page for engine limits. | | [`node:timers`](./timers.md), [`node:timers/promises`](./timers.md) | Node 24 timer handles and promise timers over engine task scheduling; see the API page for runtime limits. | | [`node:tty`](./tty.md) | Node 24.20 `isatty`, `ReadStream` and `WriteStream` over the host process's descriptors through an explicit host capability; denied by default. | +| [`node:url`](./url.md) | Node 24 URL, URLSearchParams, URLPattern, domain and file conversions; relative file paths use optional WASI environment imports. | [Globals](./globals.md) and [Errors](./errors.md) document runtime-wide Node.js APIs. They are not importable as `node:globals` or `node:errors`. diff --git a/docs/src/interop/nodejs-builtins/supported-modules/url.md b/docs/src/interop/nodejs-builtins/supported-modules/url.md new file mode 100644 index 000000000..18d65d2b0 --- /dev/null +++ b/docs/src/interop/nodejs-builtins/supported-modules/url.md @@ -0,0 +1,72 @@ +# `node:url` + +| Imports | Implementation | +| --- | --- | +| `node:url` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/url` | + +`node:url` supports URL construction and mutation, live `URLSearchParams`, +`URLPattern`, internationalized domains, file URL conversions, formatting, and +HTTP request options. The module and global `URL`/`URLSearchParams` constructors +share identity. Ordinary application imports work with `jco componentize --bundle` +on QuickJS and StarlingMonkey: + +```js +import { URL, URLPattern, pathToFileURL, fileURLToPathBuffer } from 'node:url'; + +const endpoint = new URL('../items', 'https://example.com/api/'); +endpoint.searchParams.append('tag', 'two words'); +const route = new URLPattern({ pathname: '/items/:id' }); +const file = pathToFileURL('/data/a b.txt'); +const bytes = fileURLToPathBuffer('file:///data/%FF'); +``` + +## File paths and capabilities + +URL parsing, domain conversion, formatting, HTTP options and absolute file paths +need no WIT imports. Relative `pathToFileURL()` paths use the selected world's +`wasi:cli/environment@0.2.x` interface lazily. Missing or ambiguous environment +imports produce an explicit error when cwd resolution is needed; importing the +module and using its pure operations still works. + +The default path convention is POSIX. `{ windows: true }` enables drive and UNC +paths on either backend. `fileURLToPathBuffer()` returns the same Buffer type as +`node:buffer`, preserving raw bytes and malformed percent escapes. Unlike the +string conversion, Node 24's Buffer conversion permits encoded slash bytes. + +## Compatibility target and implementation + +The target is Node **v24.20.0**, commit +`71b8b174857e25106d39b61a9e6f30d927da8b01`. The portable helpers are adapted from +Node's MIT-licensed `lib/url.js` and `lib/internal/url.js`. The WHATWG core is +`whatwg-url@14.2.0`, with `tr46@5.1.1`, `webidl-conversions@7.0.0`, and +`punycode@2.3.1`; pattern matching uses `urlpattern-polyfill@10.1.0`. + +Jco adds Node's constructor coercion, error codes, legacy object formatting, and +lazy path providers. Its UTF-8 adapter handles malformed sequences consistently +across engines; the decoder is adapted from Apache-2.0-licensed +`text-decoder@1.2.7`. StarlingMonkey's native URL host parser supplies IDNA +normalization because that engine lacks `String.normalize()`. At bundle time, +`regexpu-core@6.4.0` expands URLPattern's two Unicode identifier expressions for +StarlingMonkey. Only the exact audited dependency files receive these adapters. + +The installed `unenv@2.0.0-rc.24` URL implementation was not admitted: it lacks +`URLPattern` and `fileURLToPathBuffer`, uses Punycode without domain validation, +and differs in Windows paths, Unicode formatting, and absent HTTP option fields. +The new implementation continues to share Jco's audited Buffer and querystring +cores. Applications can mix ordinary `node:` imports with direct jco-std adapters; +the latter expose explicit factories for callers supplying their own providers. + +## Intentional differences + +Deprecated string parsing is refused immediately with +`ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API`: `parse()`, `resolve()`, +`resolveObject()`, `format(string)`, and the corresponding legacy parsing methods. +These errors occur before argument coercion or callbacks. Use `new URL(input, base)` +or `URL.parse(input, base)`. Legacy `Url` construction, object formatting, +`parseHost()`, and `Url.prototype.resolveObject(object)` remain functional. + +`URL.createObjectURL()` and `URL.revokeObjectURL()` throw +`ERR_JCO_UNSUPPORTED_NODE_API`; Jco does not provide Node's thread-local Blob URL +registry. Invalid Punycode labels can be rejected more strictly by the WHATWG +fallback than by Node's Ada parser. Engine-specific inspection and stack formatting +are not reproduced. Errors mentioning the file host platform use `posix`. diff --git a/packages/jco-std/README.md b/packages/jco-std/README.md index 818558e14..0d347467c 100644 --- a/packages/jco-std/README.md +++ b/packages/jco-std/README.md @@ -170,6 +170,9 @@ Jco can bundle the following Node.js APIs into JavaScript WebAssembly components - the `node:net` TCP client/server and address APIs over `wasi:sockets`; - `node:buffer`, with its modern core provided by Jco's audited unenv compatibility layer; +- [`node:url`](../../docs/src/interop/nodejs-builtins/supported-modules/url.md), + with portable WHATWG URL/URLSearchParams, URLPattern, domain and + file conversions, and lazy WASI cwd access for relative paths; - `node:querystring`, provided by Jco's audited unenv compatibility layer; - `node:events`, whose `EventEmitter` comes from Jco's audited unenv compatibility layer, completed by diff --git a/packages/jco-std/package.json b/packages/jco-std/package.json index df7c7a12a..d425da5b2 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -478,6 +478,21 @@ "types": "./dist/wasi/0.2.x/node/24.x.x/test/reporters.d.ts", "browser": "./dist/wasi/0.2.x/node/24.x.x/test/reporters.js", "default": "./dist/wasi/0.2.x/node/24.x.x/test/reporters.js" + }, + "./wasi/0.2.x/node/24.x.x/url": { + "types": "./dist/wasi/0.2.x/node/24.x.x/url.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/url.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/url.js" + }, + "./wasi/0.2.x/node/24.x.x/url/encoding": { + "types": "./dist/wasi/0.2.x/node/24.x.x/url/encoding.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/url/encoding.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/url/encoding.js" + }, + "./wasi/0.2.x/node/24.x.x/url/idna": { + "types": "./dist/wasi/0.2.x/node/24.x.x/url/idna.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/url/idna.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/url/idna.js" } }, "scripts": { @@ -500,7 +515,10 @@ "acorn": "8.17.0", "acorn-walk": "8.3.5", "minimatch": "10.2.6", - "readable-stream": "4.7.0" + "punycode": "2.3.1", + "readable-stream": "4.7.0", + "urlpattern-polyfill": "10.1.0", + "whatwg-url": "14.2.0" }, "devDependencies": { "@bytecodealliance/componentize-js": "^0.22.0", diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url.ts new file mode 100644 index 000000000..ea6f8f4c3 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url.ts @@ -0,0 +1,52 @@ +/** Node 24 URL module assembled over portable WHATWG cores and lazy WASI paths. */ +import { createPath, type PathProviders } from "./path.js"; +import { URL, URLPattern, URLSearchParams as CoreSearchParams } from "./url/whatwg.js"; +import { adaptSearchParams } from "./url/search-params.js"; +import { domainToASCII, domainToUnicode } from "./url/domain.js"; +import { createPathToFileURL, fileURLToPath, fileURLToPathBuffer } from "./url/file.js"; +import { format } from "./url/format.js"; +import { Url, parse, resolve, resolveObject } from "./url/legacy.js"; +import { urlToHttpOptions } from "./url/http-options.js"; + +export type * from "./url/types.js"; +export interface UrlModule { + Url: typeof Url; + parse: typeof parse; + resolve: typeof resolve; + resolveObject: typeof resolveObject; + format: typeof format; + URL: typeof URL; + URLPattern: typeof URLPattern; + URLSearchParams: typeof globalThis.URLSearchParams; + domainToASCII: typeof domainToASCII; + domainToUnicode: typeof domainToUnicode; + pathToFileURL: ReturnType; + fileURLToPath: typeof fileURLToPath; + fileURLToPathBuffer: typeof fileURLToPathBuffer; + urlToHttpOptions: typeof urlToHttpOptions; +} +const URLSearchParams = adaptSearchParams(CoreSearchParams); +Object.defineProperty(URLSearchParams.prototype, "constructor", { + value: URLSearchParams, + writable: true, + configurable: true, +}); + +export function createUrl(providers: PathProviders): UrlModule { + return { + Url, + parse, + resolve, + resolveObject, + format, + URL, + URLPattern, + URLSearchParams, + domainToASCII, + domainToUnicode, + pathToFileURL: createPathToFileURL(createPath(providers)), + fileURLToPath, + fileURLToPathBuffer, + urlToHttpOptions, + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/domain.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/domain.ts new file mode 100644 index 000000000..1b8ebd8d1 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/domain.ts @@ -0,0 +1,58 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +/** + * Node v24.20.0 lib/internal/url.js domain conversion contract, commit + * 71b8b174857e25106d39b61a9e6f30d927da8b01 (MIT, see LICENSE). + * Ada's host parser is replaced with WHATWG URL host parsing (UTS46, IPv4, + * IPv6 and forbidden characters); punycode@2.3.1 only decodes validated hosts. + */ +import punycode from "punycode/punycode.js"; +import { missingArgs } from "../errors.js"; +import { URL } from "./whatwg.js"; + +export function domainToASCII(domain: string): string { + if (arguments.length === 0) { + throw missingArgs("domain"); + } + const text = `${domain}`; + if (!text) { + return ""; + } + const url = new URL("http://jco-invalid.invalid"); + url.hostname = text; + if (url.hostname === "jco-invalid.invalid") { + // A hostname setter leaves its previous value intact on parse failure. + // A second sentinel distinguishes a valid input equal to the first one. + url.hostname = "jco-second.invalid"; + url.hostname = text; + if (url.hostname === "jco-second.invalid") { + return ""; + } + } + return url.hostname; +} +export function domainToUnicode(domain: string): string { + if (arguments.length === 0) { + throw missingArgs("domain"); + } + return punycode.toUnicode(domainToASCII(domain)); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/encoding.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/encoding.ts new file mode 100644 index 000000000..6506d24aa --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/encoding.ts @@ -0,0 +1,75 @@ +/** + * Adapter for whatwg-url@14.2.0/lib/encoding.js (jsdom, MIT). + * Jco substitutes this precise internal module when bundling so QuickJS need + * not provide TextEncoder/TextDecoder. Encoding uses the shared Buffer core; + * decoding follows the UTF-8 state machine, including maximal-subpart errors. + */ +import { Buffer } from "node:buffer"; + +export function utf8Encode(value: string): Uint8Array { + return Buffer.from(value, "utf8"); +} +/** + * Adapted from holepunchto/text-decoder@1.2.7 lib/utf8-decoder.js, Apache-2.0. + * Copyright Holepunch. See the Apache-2.0 terms in this package's LICENSE. + * The streaming state machine is made local to a single complete input. Its + * b4a fast path is removed because Feross Buffer differs on malformed UTF-8. + */ +export function utf8DecodeWithoutBOM(bytes: Uint8Array): string { + let result = ""; + let codePoint = 0; + let bytesNeeded = 0; + let bytesSeen = 0; + let lowerBoundary = 0x80; + let upperBoundary = 0xbf; + for (let i = 0; i < bytes.length; i++) { + const byte = bytes[i]; + if (bytesNeeded === 0) { + if (byte <= 0x7f) { + result += String.fromCharCode(byte); + } else if (byte >= 0xc2 && byte <= 0xdf) { + bytesNeeded = 2; + bytesSeen = 1; + codePoint = byte & 0x1f; + } else if (byte >= 0xe0 && byte <= 0xef) { + if (byte === 0xe0) { + lowerBoundary = 0xa0; + } else if (byte === 0xed) { + upperBoundary = 0x9f; + } + bytesNeeded = 3; + bytesSeen = 1; + codePoint = byte & 0xf; + } else if (byte >= 0xf0 && byte <= 0xf4) { + if (byte === 0xf0) { + lowerBoundary = 0x90; + } else if (byte === 0xf4) { + upperBoundary = 0x8f; + } + bytesNeeded = 4; + bytesSeen = 1; + codePoint = byte & 0x7; + } else { + result += "\ufffd"; + } + continue; + } + if (byte < lowerBoundary || byte > upperBoundary) { + result += "\ufffd"; + i--; + codePoint = bytesNeeded = bytesSeen = 0; + lowerBoundary = 0x80; + upperBoundary = 0xbf; + continue; + } + lowerBoundary = 0x80; + upperBoundary = 0xbf; + codePoint = (codePoint << 6) | (byte & 0x3f); + bytesSeen++; + if (bytesSeen === bytesNeeded) { + result += String.fromCodePoint(codePoint); + codePoint = bytesNeeded = bytesSeen = 0; + } + } + return bytesNeeded > 0 ? result + "\ufffd" : result; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/errors.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/errors.ts new file mode 100644 index 000000000..21ecda0e1 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/errors.ts @@ -0,0 +1,23 @@ +import { codedError } from "../errors.js"; + +export function deprecated(api: string): never { + throw codedError( + new Error(`The deprecated ${api} API is not supported; use the WHATWG URL API instead`), + "ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API", + ); +} +export function unsupported(api: string): never { + throw codedError( + new Error(`${api} is not supported by the Jco component runtime`), + "ERR_JCO_UNSUPPORTED_NODE_API", + ); +} +export function invalidURL( + input: string, + base?: string, +): TypeError & { code: string; input: string; base?: string } { + const error = Object.assign(codedError(new TypeError("Invalid URL"), "ERR_INVALID_URL"), { + input, + }); + return base === undefined ? error : Object.assign(error, { base }); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/file.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/file.ts new file mode 100644 index 000000000..b92aae41d --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/file.ts @@ -0,0 +1,221 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +/** + * Adapted from Node v24.20.0 lib/internal/url.js and lib/internal/data_url.js, + * commit 71b8b174857e25106d39b61a9e6f30d927da8b01 (MIT, see LICENSE). + * Retains path validation and byte-decoding order; uses the portable URL core, + * shared node:buffer and an injected path implementation instead of internals. + * The default platform is POSIX, as for Jco's node:path. + */ +import { Buffer } from "node:buffer"; +import { codedError, invalidArgType } from "../errors.js"; +import type { PathModule } from "../path.js"; +import type { FileUrlOptions, UrlPathBuffer } from "./types.js"; +import { inspect } from "../assert/inspect.js"; +import { invalidURL } from "./errors.js"; +import { domainToASCII, domainToUnicode } from "./domain.js"; +import { URL } from "./whatwg.js"; + +// Node intentionally accepts other WHATWG implementations (e.g. Electron). +function isURL(value: unknown): value is URL { + if (!value || (typeof value !== "object" && typeof value !== "function")) { + return false; + } + const candidate = value as URL & { auth?: unknown; path?: unknown }; + return Boolean( + candidate.href && + candidate.protocol && + candidate.auth === undefined && + candidate.path === undefined, + ); +} +function asFileURL(input: string | URL): URL { + const url = typeof input === "string" ? new URL(input) : input; + if (!isURL(url)) { + throw invalidArgType("path", ["string", "URL"], url); + } + if (url.protocol !== "file:") { + throw codedError(new TypeError("The URL must be of scheme file"), "ERR_INVALID_URL_SCHEME"); + } + return url; +} +function invalidPath(message: string, url: URL): never { + throw Object.assign( + codedError(new TypeError(`File URL path ${message}`), "ERR_INVALID_FILE_URL_PATH"), + { input: url }, + ); +} +function checkPosixHost(url: URL): void { + if (url.hostname !== "") { + throw codedError( + new TypeError('File URL host must be "localhost" or empty on posix'), + "ERR_INVALID_FILE_URL_HOST", + ); + } +} + +export function fileURLToPath(input: string | URL, options?: FileUrlOptions): string { + const windows = options?.windows; + const url = asFileURL(input); + if (!windows) { + checkPosixHost(url); + } + let pathname = url.pathname; + for (let n = 0; n < pathname.length; n++) { + if (pathname[n] !== "%") { + continue; + } + const third = pathname.charCodeAt(n + 2) | 0x20; + if ( + (pathname[n + 1] === "2" && third === 102) || + (windows && pathname[n + 1] === "5" && third === 99) + ) { + invalidPath( + windows + ? "must not include encoded \\ or / characters" + : "must not include encoded / characters", + url, + ); + } + } + if (windows) { + pathname = pathname.replace(/\//g, "\\"); + } + if (pathname.includes("%")) { + try { + pathname = decodeURIComponent(pathname); + } catch (error) { + // Match Node's public V8 message across QuickJS and SpiderMonkey. + if (error instanceof URIError) { + throw new URIError("URI malformed"); + } + throw error; + } + } + if (!windows) { + return pathname; + } + if (url.hostname !== "") { + return `\\\\${domainToUnicode(url.hostname)}${pathname}`; + } + const letter = pathname.charCodeAt(1) | 0x20; + if (letter < 97 || letter > 122 || pathname[2] !== ":") { + invalidPath("must be absolute", url); + } + return pathname.slice(1); +} + +/** Node's percentDecode: malformed escapes pass through, non-UTF8 bytes survive. */ +function percentDecode(input: Uint8Array): Uint8Array { + const output = new Uint8Array(input.length); + let index = 0; + for (let i = 0; i < input.length; i++) { + const pair = String.fromCharCode(input[i + 1], input[i + 2]); + if (input[i] === 37 && /^[\da-f]{2}$/i.test(pair)) { + output[index++] = Number.parseInt(pair, 16); + i += 2; + } else { + output[index++] = input[i]; + } + } + return output.subarray(0, index); +} +export function fileURLToPathBuffer(input: string | URL, options?: FileUrlOptions): UrlPathBuffer { + const windows = options?.windows; + const url = asFileURL(input); + if (!windows) { + checkPosixHost(url); + } + const pathname = windows ? url.pathname.replace(/\//g, "\\") : url.pathname; + const decoded = Buffer.from(percentDecode(Buffer.from(pathname, "utf8"))); + if (!windows) { + return decoded; + } + if (url.hostname !== "") { + return Buffer.concat([Buffer.from(`\\\\${domainToUnicode(url.hostname)}`, "utf8"), decoded]); + } + const letter = decoded[1] | 0x20; + if (letter < 97 || letter > 122 || decoded[2] !== 58) { + invalidPath("must be absolute", url); + } + return decoded.subarray(1); +} + +// Replaces Node24's native Ada path constructor. Percent, separators, query, +// hash and stripped ASCII whitespace must be escaped before WHATWG parsing. +function encodePath(path: string, windows: boolean): string { + return path.replace(/[%\\?#|\t\n\r]/g, (character) => { + if (character === "\\" && windows) { + return "/"; + } + return `%${character.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`; + }); +} +function invalidUncPath(path: string, reason: string): TypeError { + return codedError( + new TypeError(`The argument 'path' ${reason}. Received ${inspect(path)}`), + "ERR_INVALID_ARG_VALUE", + ); +} +export function createPathToFileURL( + path: PathModule, +): (filepath: string, options?: FileUrlOptions) => URL { + return function pathToFileURL(filepath: string, options?: FileUrlOptions): URL { + if (typeof filepath !== "string") { + throw invalidArgType("path", "string", filepath); + } + const windows = options?.windows ?? false; + const isUNC = windows && filepath.startsWith("\\\\"); + let resolved = isUNC + ? filepath + : windows + ? path.win32.resolve(filepath) + : path.posix.resolve(filepath); + if (isUNC || (windows && resolved.startsWith("\\\\"))) { + const prefixLength = resolved.startsWith("\\\\?\\UNC\\") ? 8 : 2; + const hostnameEndIndex = resolved.indexOf("\\", prefixLength); + if (hostnameEndIndex === -1) { + throw invalidUncPath(resolved, "Missing UNC resource path"); + } + if (hostnameEndIndex === 2) { + throw invalidUncPath(resolved, "Empty UNC servername"); + } + const hostname = resolved.slice(prefixLength, hostnameEndIndex); + // Node's native path constructor parses the hostname separately, so + // #, ? and / terminate the hostname without consuming the resource path. + const asciiHostname = domainToASCII(hostname); + if (!asciiHostname) { + throw invalidURL(resolved.slice(hostnameEndIndex), hostname); + } + const url = new URL(`file://${asciiHostname}/`); + url.pathname = encodePath(resolved.slice(hostnameEndIndex), true); + return url; + } + const last = filepath.charCodeAt(filepath.length - 1); + if ((last === 47 || (windows && last === 92)) && resolved.at(-1) !== path.sep) { + resolved += "/"; + } + const url = new URL("file:///"); + url.pathname = encodePath(resolved, windows); + return url; + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/format.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/format.ts new file mode 100644 index 000000000..5df646f08 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/format.ts @@ -0,0 +1,96 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +/** + * Adapted from Node v24.20.0 lib/url.js urlFormat, commit + * 71b8b174857e25106d39b61a9e6f30d927da8b01 (MIT, see notice above). + * Uses WHATWG serialization instead of Ada; deprecated string input is refused. + */ +import { invalidArgType } from "../errors.js"; +import { deprecated } from "./errors.js"; +import { domainToUnicode } from "./domain.js"; +import { Url } from "./legacy.js"; +import { URL } from "./whatwg.js"; +import type { UrlFormatOptions, UrlObject } from "./types.js"; + +export function format(urlObject: URL, options?: UrlFormatOptions): string; +export function format(urlObject: UrlObject): string; +export function format(urlObject: string): never; +export function format(urlObject: URL | UrlObject | string, options?: UrlFormatOptions): string { + if (typeof urlObject === "string") { + return deprecated("url.format(string)"); + } + if (typeof urlObject !== "object" || urlObject === null) { + throw invalidArgType("urlObject", ["Object", "string"], urlObject); + } + if ( + !(urlObject instanceof URL) && + !(typeof globalThis.URL === "function" && urlObject instanceof globalThis.URL) + ) { + return Url.prototype.format.call(urlObject); + } + let fragment = true; + let unicode = false; + let search = true; + let auth = true; + if (options) { + if (typeof options !== "object" || Array.isArray(options)) { + throw invalidArgType("options", "Object", options); + } + if (options.fragment != null) { + fragment = Boolean(options.fragment); + } + if (options.unicode != null) { + unicode = Boolean(options.unicode); + } + if (options.search != null) { + search = Boolean(options.search); + } + if (options.auth != null) { + auth = Boolean(options.auth); + } + } + const url = new URL(urlObject.href); + if (!fragment) { + url.hash = ""; + } + if (!search) { + url.search = ""; + } + if (!auth) { + url.username = ""; + url.password = ""; + } + if (!unicode || !url.hostname) { + return url.href; + } + // Only replace the serialized authority's hostname. IDNs can occur in + // credentials, paths and queries too, and must remain encoded there. + const authorityStart = url.protocol.length + 2; + const userinfo = + url.username || url.password ? `${url.username}${url.password ? `:${url.password}` : ""}@` : ""; + const hostnameStart = authorityStart + userinfo.length; + return ( + url.href.slice(0, hostnameStart) + + domainToUnicode(url.hostname) + + url.href.slice(hostnameStart + url.hostname.length) + ); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/http-options.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/http-options.ts new file mode 100644 index 000000000..4b0a865a8 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/http-options.ts @@ -0,0 +1,56 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +/** + * Adapted from Node v24.20.0 lib/internal/url.js urlToHttpOptions, commit + * 71b8b174857e25106d39b61a9e6f30d927da8b01 (MIT, see LICENSE). + * Replaces primordials/validateObject with standard operations and Jco errors. + */ +import { invalidArgType } from "../errors.js"; +import type { UrlHttpOptions } from "./types.js"; + +export function urlToHttpOptions(url: T): Omit & UrlHttpOptions; +export function urlToHttpOptions(url: URL): UrlHttpOptions { + if ((typeof url !== "object" || url === null) && typeof url !== "function") { + throw invalidArgType("url", "object", url); + } + const { hostname, pathname, port, username, password, search } = url; + const extra: object = url; + const options: UrlHttpOptions & { __proto__: null } = { + __proto__: null, + ...extra, + protocol: url.protocol, + hostname: hostname && hostname[0] === "[" ? hostname.slice(1, -1) : hostname, + hash: url.hash, + search, + pathname, + path: `${pathname || ""}${search || ""}`, + href: url.href, + }; + if (port !== "") { + options.port = Number(port); + } + if (username || password) { + options.auth = `${decodeURIComponent(username)}:${decodeURIComponent(password)}`; + } + // The own enumerable extension properties were copied above. + return options; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/idna.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/idna.ts new file mode 100644 index 000000000..f92f0147e --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/idna.ts @@ -0,0 +1,34 @@ +/** The subset of tr46@5.1.1 used by whatwg-url@14.2.0's host parser. */ +export interface IDNA { + toASCII(domain: string, options?: Record): string | null; +} + +/** + * StarlingMonkey omits String.normalize but supplies an Ada-backed native URL. + * Reuse that native host parser for UTS46/NFC instead of shipping an outdated + * normalization table. QuickJS has normalize and uses the audited tr46 core. + * This factory runs before Jco installs its public URL globals. + */ +export function createIDNA(fallback: IDNA): IDNA { + if (typeof String.prototype.normalize === "function") { + return fallback; + } + const NativeURL = globalThis.URL; + if (typeof NativeURL !== "function") { + throw new Error("node:url requires String.normalize or a native WHATWG URL host parser"); + } + return { + toASCII(domain: string): string | null { + // The caller expects a domain, not a complete URL. Delimiters must never + // become credentials, a port, path or query in the native parser. + if (/[\u0000-\u0020\u007f%#/:<>?@[\\\]^|]/.test(domain)) { + return null; + } + try { + return new NativeURL(`http://${domain}`).hostname; + } catch { + return null; + } + }, + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/legacy.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/legacy.ts new file mode 100644 index 000000000..aca9385af --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/legacy.ts @@ -0,0 +1,481 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +/** + * Adapted from Node v24.20.0 lib/url.js, commit + * 71b8b174857e25106d39b61a9e6f30d927da8b01 (MIT, see notice above). + * Preserves legacy object construction, formatting and object resolution. + * Deprecated string parsing/resolution throw before touching arguments. + * Primordials use standard methods; query encoding uses audited node:querystring. + */ +import { escape, stringify } from "node:querystring"; +import type { LegacyUrl, LegacyUrlConstructor, UrlObject } from "./types.js"; +import { deprecated } from "./errors.js"; + +const slashedProtocol = new Set([ + "http", + "http:", + "https", + "https:", + "ftp", + "ftp:", + "gopher", + "gopher:", + "file", + "file:", + "ws", + "ws:", + "wss", + "wss:", +]); +const hostlessProtocol = new Set(["javascript", "javascript:"]); +const CHAR_HASH = 35; +const CHAR_QUESTION_MARK = 63; +const CHAR_FORWARD_SLASH = 47; +const StringPrototypeCharCodeAt = (s: string, i: number): number => s.charCodeAt(i); +const StringPrototypeIndexOf = (s: string, v: string): number => s.indexOf(v); +const StringPrototypeSlice = (s: string, start: number, end?: number): string => + s.slice(start, end); +const StringPrototypeReplaceAll = (s: string, a: string, b: string): string => s.replaceAll(a, b); +const StringPrototypeAt = (s: string, i: number): string | undefined => s.at(i); +const ArrayPrototypeJoin = (a: string[], sep: string): string => a.join(sep); +const ObjectAssign = Object.assign; +const spliceOne = (a: string[], i: number): void => { + a.splice(i, 1); +}; +function isIpv6Hostname(hostname: string): boolean { + return hostname[0] === "[" && hostname.at(-1) === "]"; +} + +const LegacyUrlFunction = function Url(this: LegacyUrl): void { + this.protocol = null; + this.slashes = null; + this.auth = null; + this.host = null; + this.port = null; + this.hostname = null; + this.hash = null; + this.search = null; + this.query = null; + this.pathname = null; + this.path = null; + this.href = null; +}; + +// Preserve the public name when a bundler renames this function expression. +Object.defineProperty(LegacyUrlFunction, "name", { value: "Url", configurable: true }); +export const Url = LegacyUrlFunction as unknown as LegacyUrlConstructor; +Url.prototype.format = function format(this: UrlObject): string { + let auth = this.auth || ""; + if (auth) { + auth = escape(auth).replaceAll("%3A", ":"); + auth += "@"; + } + + let protocol = this.protocol || ""; + if (protocol && StringPrototypeCharCodeAt(protocol, protocol.length - 1) !== 58 /* : */) { + protocol += ":"; + } + + let pathname = this.pathname || ""; + let hash = this.hash || ""; + let host = ""; + let query = ""; + + if (this.host) { + host = auth + this.host; + } else if (this.hostname) { + host = + auth + + (StringPrototypeIndexOf(this.hostname, ":") !== -1 && !isIpv6Hostname(this.hostname) + ? "[" + this.hostname + "]" + : this.hostname); + if (this.port) { + host += ":" + this.port; + } + } + + if (this.query !== null && typeof this.query === "object") { + query = stringify(this.query); + } + let search = this.search || (query && "?" + query) || ""; + + if ( + StringPrototypeIndexOf(pathname, "#") !== -1 || + StringPrototypeIndexOf(pathname, "?") !== -1 + ) { + let newPathname = ""; + let lastPos = 0; + const len = pathname.length; + for (let i = 0; i < len; i++) { + const code = StringPrototypeCharCodeAt(pathname, i); + if (code === CHAR_HASH || code === CHAR_QUESTION_MARK) { + if (i > lastPos) { + newPathname += StringPrototypeSlice(pathname, lastPos, i); + } + newPathname += code === CHAR_HASH ? "%23" : "%3F"; + lastPos = i + 1; + } + } + if (lastPos < len) { + newPathname += StringPrototypeSlice(pathname, lastPos); + } + pathname = newPathname; + } + + // Only the slashedProtocols get the //. Not mailto:, xmpp:, etc. + // unless they had them to begin with. + if (this.slashes || slashedProtocol.has(protocol)) { + if (this.slashes || host) { + if (pathname && StringPrototypeCharCodeAt(pathname, 0) !== CHAR_FORWARD_SLASH) { + pathname = "/" + pathname; + } + host = "//" + host; + } else if ( + protocol.length >= 4 && + StringPrototypeCharCodeAt(protocol, 0) === 102 /* f */ && + StringPrototypeCharCodeAt(protocol, 1) === 105 /* i */ && + StringPrototypeCharCodeAt(protocol, 2) === 108 /* l */ && + StringPrototypeCharCodeAt(protocol, 3) === 101 /* e */ + ) { + host = "//"; + } + } + + // Escape '#' in search. + if (StringPrototypeIndexOf(search, "#") !== -1) { + search = StringPrototypeReplaceAll(search, "#", "%23"); + } + + if (hash && StringPrototypeCharCodeAt(hash, 0) !== CHAR_HASH) { + hash = "#" + hash; + } + if (search && StringPrototypeCharCodeAt(search, 0) !== CHAR_QUESTION_MARK) { + search = "?" + search; + } + + return protocol + host + pathname + search + hash; +}; + +Url.prototype.resolveObject = function resolveObject( + this: LegacyUrl, + relative: string | UrlObject, +): LegacyUrl { + if (typeof relative === "string") { + return deprecated("Url.prototype.resolveObject(string)"); + } + + const result = new Url(); + ObjectAssign(result, this); + + // Hash is always overridden, no matter what. + // even href="" will remove it. + result.hash = relative.hash; + + // If the relative url is empty, then there's nothing left to do here. + if (relative.href === "") { + result.href = result.format(); + return result; + } + + // Hrefs like //foo/bar always cut to the protocol. + if (relative.slashes && !relative.protocol) { + // Take everything except the protocol from relative + const relativeWithoutProtocol = { ...relative }; + delete relativeWithoutProtocol.protocol; + ObjectAssign(result, relativeWithoutProtocol); + + // urlParse appends trailing / to urls like http://www.example.com + if (slashedProtocol.has(result.protocol) && result.hostname && !result.pathname) { + result.path = result.pathname = "/"; + } + + result.href = result.format(); + return result; + } + + if (relative.protocol && relative.protocol !== result.protocol) { + // If it's a known url protocol, then changing + // the protocol does weird things + // first, if it's not file:, then we MUST have a host, + // and if there was a path + // to begin with, then we MUST have a path. + // if it is file:, then the host is dropped, + // because that's known to be hostless. + // anything else is assumed to be absolute. + if (!slashedProtocol.has(relative.protocol)) { + ObjectAssign(result, relative); + result.href = result.format(); + return result; + } + + result.protocol = relative.protocol; + if ( + !relative.host && + !/^file:?$/.test(relative.protocol) && + !hostlessProtocol.has(relative.protocol) + ) { + const relPath = (relative.pathname || "").split("/"); + while (relPath.length && !(relative.host = relPath.shift())) {} + relative.host ||= ""; + relative.hostname ||= ""; + if (relPath[0] !== "") { + relPath.unshift(""); + } + if (relPath.length < 2) { + relPath.unshift(""); + } + result.pathname = relPath.join("/"); + } else { + result.pathname = relative.pathname; + } + result.search = relative.search; + result.query = relative.query; + result.host = relative.host || ""; + result.auth = relative.auth; + result.hostname = relative.hostname || relative.host; + result.port = relative.port; + // To support http.request + if (result.pathname || result.search) { + const p = result.pathname || ""; + const s = result.search || ""; + result.path = p + s; + } + result.slashes ||= relative.slashes; + result.href = result.format(); + return result; + } + + const isSourceAbs = result.pathname && result.pathname.charAt(0) === "/"; + const isRelAbs = relative.host || (relative.pathname && relative.pathname.charAt(0) === "/"); + let mustEndAbs: boolean | string | number | null | undefined = + isRelAbs || isSourceAbs || (result.host && relative.pathname); + const removeAllDots = mustEndAbs; + let srcPath = (result.pathname && result.pathname.split("/")) || []; + const relPath = (relative.pathname && relative.pathname.split("/")) || []; + const noLeadingSlashes = result.protocol && !slashedProtocol.has(result.protocol); + + // If the url is a non-slashed url, then relative + // links like ../.. should be able + // to crawl up to the hostname, as well. This is strange. + // result.protocol has already been set by now. + // Later on, put the first path part into the host field. + if (noLeadingSlashes) { + result.hostname = ""; + result.port = null; + if (result.host) { + if (srcPath[0] === "") { + srcPath[0] = result.host; + } else { + srcPath.unshift(result.host); + } + } + result.host = ""; + if (relative.protocol) { + relative.hostname = null; + relative.port = null; + result.auth = null; + if (relative.host) { + if (relPath[0] === "") { + relPath[0] = relative.host; + } else { + relPath.unshift(relative.host); + } + } + relative.host = null; + } + mustEndAbs &&= relPath[0] === "" || srcPath[0] === ""; + } + + if (isRelAbs) { + // it's absolute. + if (relative.host || relative.host === "") { + if (result.host !== relative.host) { + result.auth = null; + } + result.host = relative.host; + result.port = relative.port; + } + if (relative.hostname || relative.hostname === "") { + if (result.hostname !== relative.hostname) { + result.auth = null; + } + result.hostname = relative.hostname; + } + result.search = relative.search; + result.query = relative.query; + srcPath = relPath; + // Fall through to the dot-handling below. + } else if (relPath.length) { + // it's relative + // throw away the existing file, and take the new path instead. + srcPath ||= []; + srcPath.pop(); + srcPath = srcPath.concat(relPath); + result.search = relative.search; + result.query = relative.query; + } else if (relative.search !== null && relative.search !== undefined) { + // Just pull out the search. + // like href='?foo'. + // Put this after the other two cases because it simplifies the booleans + if (noLeadingSlashes) { + result.hostname = result.host = srcPath.shift(); + // Occasionally the auth can get stuck only in host. + // This especially happens in cases like + // url.resolveObject('mailto:local1@domain1', 'local2@domain2') + const authInHost = result.host && result.host.indexOf("@") > 0 && result.host.split("@"); + if (authInHost) { + result.auth = authInHost.shift(); + result.host = result.hostname = authInHost.shift(); + } + } + result.search = relative.search; + result.query = relative.query; + // To support http.request + if (result.pathname !== null || result.search !== null) { + result.path = (result.pathname ? result.pathname : "") + (result.search ? result.search : ""); + } + result.href = result.format(); + return result; + } + + if (!srcPath.length) { + // No path at all. All other things were already handled above. + result.pathname = null; + // To support http.request + if (result.search) { + result.path = "/" + result.search; + } else { + result.path = null; + } + result.href = result.format(); + return result; + } + + // If a url ENDs in . or .., then it must get a trailing slash. + // however, if it ends in anything else non-slashy, + // then it must NOT get a trailing slash. + let last = srcPath[srcPath.length - 1]; + const hasTrailingSlash = + ((result.host || relative.host || srcPath.length > 1) && (last === "." || last === "..")) || + last === ""; + + // Strip single dots, resolve double dots to parent dir + // if the path tries to go above the root, `up` ends up > 0 + let up = 0; + for (let i = srcPath.length - 1; i >= 0; i--) { + last = srcPath[i]; + if (last === ".") { + spliceOne(srcPath, i); + } else if (last === "..") { + spliceOne(srcPath, i); + up++; + } else if (up) { + spliceOne(srcPath, i); + up--; + } + } + + // If the path is allowed to go above the root, restore leading ..s + if (!mustEndAbs && !removeAllDots) { + while (up--) { + srcPath.unshift(".."); + } + } + + if (mustEndAbs && srcPath[0] !== "" && (!srcPath[0] || srcPath[0].charAt(0) !== "/")) { + srcPath.unshift(""); + } + + if (hasTrailingSlash && StringPrototypeAt(ArrayPrototypeJoin(srcPath, "/"), -1) !== "/") { + srcPath.push(""); + } + + const isAbsolute = srcPath[0] === "" || (srcPath[0] && srcPath[0].charAt(0) === "/"); + + // put the host back + if (noLeadingSlashes) { + result.hostname = result.host = isAbsolute ? "" : srcPath.length ? srcPath.shift() : ""; + // Occasionally the auth can get stuck only in host. + // This especially happens in cases like + // url.resolveObject('mailto:local1@domain1', 'local2@domain2') + const authInHost = result.host && result.host.indexOf("@") > 0 ? result.host.split("@") : false; + if (authInHost) { + result.auth = authInHost.shift(); + result.host = result.hostname = authInHost.shift(); + } + } + + mustEndAbs ||= result.host && srcPath.length; + + if (mustEndAbs && !isAbsolute) { + srcPath.unshift(""); + } + + if (!srcPath.length) { + result.pathname = null; + result.path = null; + } else { + result.pathname = srcPath.join("/"); + } + + // To support request.http + if (result.pathname !== null || result.search !== null) { + result.path = (result.pathname ? result.pathname : "") + (result.search ? result.search : ""); + } + result.auth = relative.auth || result.auth; + result.slashes ||= relative.slashes; + result.href = result.format(); + return result; +}; + +Url.prototype.parseHost = function parseHost(this: LegacyUrl): void { + let host = this.host; + const match = /:[0-9]*$/.exec(`${host}`); + if (match && host) { + const port = match[0]; + if (port !== ":") { + this.port = port.slice(1); + } + host = host.slice(0, host.length - port.length); + } + if (host) { + this.hostname = host; + } +}; + +export function parse( + _url: string, + _parseQueryString?: boolean, + _slashesDenoteHost?: boolean, +): never { + return deprecated("url.parse()"); +} +export function resolve(_from: string, _to: string): never { + return deprecated("url.resolve()"); +} +export function resolveObject(_from: string | UrlObject, _to: string | UrlObject): never { + return deprecated("url.resolveObject()"); +} +Url.prototype.parse = parse; +Url.prototype.resolve = function resolve(_relative: string): never { + return deprecated("Url.prototype.resolve()"); +}; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/punycode-types.d.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/punycode-types.d.ts new file mode 100644 index 000000000..2fd33d9b6 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/punycode-types.d.ts @@ -0,0 +1,3 @@ +/** Audited decoder surface of punycode@2.3.1. */ +declare const punycode: { toUnicode(domain: string): string }; +export default punycode; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/search-params.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/search-params.ts new file mode 100644 index 000000000..807ac3175 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/search-params.ts @@ -0,0 +1,182 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +/** + * Node24 constructor/validation adaptation for whatwg-url's shared parameter + * list. Node v24.20.0 lib/internal/url.js, commit + * 71b8b174857e25106d39b61a9e6f30d927da8b01; MIT notice below. + */ +import { codedError, invalidArgType, invalidThis, missingArgs } from "../errors.js"; + +type ParametersConstructor = typeof globalThis.URLSearchParams; +function tupleError(): never { + throw codedError( + new TypeError("Each query pair must be an iterable [name, value] tuple"), + "ERR_INVALID_TUPLE", + ); +} +function wellFormed(value: unknown): string { + // Core WebIDL wrappers apply USVString conversion too. Normalize record keys + // here so two ill-formed keys that become equal retain the later value. + const text = `${value}`; + return text.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDFFF]/g, (part) => + part.length === 2 ? part : "\ufffd", + ); +} + +function normalizeInitializer(input: unknown): string | string[][] | undefined { + if (input === undefined) { + return undefined; + } + if (input === null || (typeof input !== "object" && typeof input !== "function")) { + return wellFormed(input); + } + const record = input as Record; + const method = record[Symbol.iterator]; + const pairs: string[][] = []; + if (method != null) { + if (typeof method !== "function") { + throw codedError(new TypeError("Query pairs must be iterable"), "ERR_ARG_NOT_ITERABLE"); + } + // Node's for-of reads Symbol.iterator a second time after the + // validation probe, which matters for accessors with observable behavior. + for (const pair of input as Iterable) { + if (pair == null) { + tupleError(); + } + if (Array.isArray(pair)) { + if (pair.length !== 2) { + tupleError(); + } + pairs.push([wellFormed(pair[0]), wellFormed(pair[1])]); + } else { + if ( + (typeof pair !== "object" && typeof pair !== "function") || + typeof (pair as Record)[Symbol.iterator] !== "function" + ) { + tupleError(); + } + const converted: string[] = []; + for (const element of pair as Iterable) { + converted.push(wellFormed(element)); + } + if (converted.length !== 2) { + tupleError(); + } + pairs.push(converted); + } + } + } else { + const visited = new Map(); + for (const key of Reflect.ownKeys(input)) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if (!descriptor?.enumerable) { + continue; + } + const name = wellFormed(key); + const value = wellFormed(record[key]); + const index = visited.get(name); + if (index !== undefined) { + pairs[index][1] = value; + } else { + visited.set(name, pairs.length); + pairs.push([name, value]); + } + } + } + return pairs; +} + +/** Keep URL.searchParams and standalone instances on the same core prototype. */ +export function adaptSearchParams(Core: ParametersConstructor): ParametersConstructor { + const size = Object.getOwnPropertyDescriptor(Core.prototype, "size")!; + function checkReceiver(receiver: unknown): void { + try { + size.get!.call(receiver); + } catch { + throw invalidThis("URLSearchParams"); + } + } + for (const name of [ + "append", + "delete", + "get", + "getAll", + "has", + "set", + "sort", + "toString", + "keys", + "values", + "entries", + "forEach", + ] as const) { + const descriptor = Object.getOwnPropertyDescriptor(Core.prototype, name)!; + const original: (this: unknown, ...args: unknown[]) => unknown = descriptor.value; + const method = function (this: unknown, ...args: unknown[]): unknown { + checkReceiver(this); + if (name === "append" || name === "set") { + if (args.length < 2) { + throw missingArgs("name", "value"); + } + args = [wellFormed(args[0]), wellFormed(args[1])]; + } else if (["delete", "get", "getAll", "has"].includes(name)) { + if (args.length === 0) { + throw missingArgs("name"); + } + args = [ + wellFormed(args[0]), + ...(args[1] !== undefined && (name === "delete" || name === "has") + ? [wellFormed(args[1])] + : []), + ]; + } else if (name === "forEach" && typeof args[0] !== "function") { + throw invalidArgType("callback", "function", args[0]); + } + return Reflect.apply(original, this, args); + }; + Object.defineProperties(method, { name: { value: name }, length: { value: original.length } }); + Object.defineProperty(Core.prototype, name, { ...descriptor, value: method }); + } + Object.defineProperty(Core.prototype, Symbol.iterator, { + value: Core.prototype.entries, + writable: true, + configurable: true, + }); + Object.defineProperty(Core.prototype, "size", { + ...size, + get(this: unknown): number { + checkReceiver(this); + return size.get!.call(this); + }, + }); + const Constructor = new Proxy(Core, { + construct(target, args: unknown[], newTarget) { + return Reflect.construct(target, [normalizeInitializer(args[0])], newTarget); + }, + }); + Object.defineProperty(Core.prototype, "constructor", { + value: Constructor, + writable: true, + configurable: true, + }); + return Constructor; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/types.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/types.ts new file mode 100644 index 000000000..eb1e97f2d --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/types.ts @@ -0,0 +1,105 @@ +/** Self-contained public types for the Node 24 URL adapter. */ +export interface FileUrlOptions { + windows?: boolean; +} +export interface UrlFormatOptions { + auth?: boolean; + fragment?: boolean; + search?: boolean; + unicode?: boolean; +} +export type QueryValue = string | number | bigint | boolean | null | undefined; +export type UrlQuery = Record< + string, + QueryValue | readonly Exclude[] +>; +export interface UrlObject { + auth?: string | null; + hash?: string | null; + host?: string | null; + hostname?: string | null; + href?: string | null; + pathname?: string | null; + path?: string | null; + protocol?: string | null; + search?: string | null; + slashes?: boolean | null; + port?: string | number | null; + query?: string | UrlQuery | null; +} +export interface LegacyUrl extends UrlObject { + parse(url: string, parseQueryString?: boolean, slashesDenoteHost?: boolean): never; + format(): string; + resolve(relative: string): never; + resolveObject(relative: string | UrlObject): LegacyUrl; + parseHost(): void; +} +export interface LegacyUrlConstructor { + new (): LegacyUrl; + (this: LegacyUrl): void; + prototype: LegacyUrl; +} +export interface UrlHttpOptions { + protocol: string; + hostname: string; + hash: string; + search: string; + pathname: string; + path: string; + href: string; + port?: number; + auth?: string; +} +/** A Buffer is returned at runtime, sharing node:buffer's constructor. */ +export interface UrlPathBuffer extends Uint8Array { + toString(encoding?: string, start?: number, end?: number): string; + equals(other: Uint8Array): boolean; +} +export interface UrlPatternInit { + baseURL?: string; + protocol?: string; + username?: string; + password?: string; + hostname?: string; + port?: string; + pathname?: string; + search?: string; + hash?: string; +} +export type UrlPatternInput = string | UrlPatternInit; +export interface UrlPatternOptions { + ignoreCase?: boolean; +} +export interface UrlPatternComponentResult { + input: string; + groups: Record; +} +export interface UrlPatternResult { + inputs: [UrlPatternInput, string?]; + protocol: UrlPatternComponentResult; + username: UrlPatternComponentResult; + password: UrlPatternComponentResult; + hostname: UrlPatternComponentResult; + port: UrlPatternComponentResult; + pathname: UrlPatternComponentResult; + search: UrlPatternComponentResult; + hash: UrlPatternComponentResult; +} +export interface UrlPattern { + readonly protocol: string; + readonly username: string; + readonly password: string; + readonly hostname: string; + readonly port: string; + readonly pathname: string; + readonly search: string; + readonly hash: string; + readonly hasRegExpGroups: boolean; + test(input?: UrlPatternInput, baseURL?: string): boolean; + exec(input?: UrlPatternInput, baseURL?: string): UrlPatternResult | null; +} +export interface UrlPatternConstructor { + new (input?: UrlPatternInput, options?: UrlPatternOptions): UrlPattern; + new (input: UrlPatternInput, baseURL: string, options?: UrlPatternOptions): UrlPattern; + readonly prototype: UrlPattern; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/whatwg-types.d.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/whatwg-types.d.ts new file mode 100644 index 000000000..24980f358 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/whatwg-types.d.ts @@ -0,0 +1,3 @@ +/** Audited constructor surface of whatwg-url@14.2.0. */ +export const URL: typeof globalThis.URL; +export const URLSearchParams: typeof globalThis.URLSearchParams; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/whatwg.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/whatwg.ts new file mode 100644 index 000000000..ba67fb623 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/url/whatwg.ts @@ -0,0 +1,154 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +/** + * WHATWG core: whatwg-url@14.2.0 (jsdom, MIT), with Node 24 constructor + * coercion, error fields and static methods. Native web classes can lack + * newer operations, so guests use this coherent URL/URLSearchParams pair. + * Native Blob registries have no corresponding node:buffer registry in Jco. + */ +import { URL as WhatwgURL, URLSearchParams } from "whatwg-url"; +import { URLPattern as CoreURLPattern } from "urlpattern-polyfill/urlpattern"; +import { invalidThis, missingArgs } from "../errors.js"; +import { invalidURL, unsupported } from "./errors.js"; + +import type { UrlPatternConstructor } from "./types.js"; +export { URLSearchParams }; +// 10.1.0 implements ignoreCase and hasRegExpGroups but its bundled declarations +// omit them. The guest tests cover these Node24 overloads and properties. +export const URLPattern = CoreURLPattern as unknown as UrlPatternConstructor; + +// Adapted from Node v24.20.0 lib/internal/url.js: coerce outside the parse +// failure handler, so exceptions thrown by user coercion are never swallowed. +function parse(input: string | URL, base?: string | URL): URL | null { + if (arguments.length === 0) { + throw missingArgs("url"); + } + const text = `${input}`; + const baseText = base === undefined ? undefined : `${base}`; + try { + return new URL(text, baseText); + } catch { + return null; + } +} +function canParse(input: string | URL, base?: string | URL): boolean { + if (arguments.length === 0) { + throw missingArgs("url"); + } + return parse(input, base) !== null; +} + +// A proxy preserves the WHATWG core's prototype and therefore the identity of +// URL.searchParams, iterator instances and subclasses. Only Node boundaries +// differ from the WebIDL wrappers supplied by the portable dependency. +const instances = new WeakSet(); + +export const URL: typeof globalThis.URL = new Proxy(WhatwgURL, { + construct(target, args: unknown[], newTarget) { + if (args.length === 0) { + throw missingArgs("url"); + } + const input = `${args[0]}`; + const base = args[1] === undefined ? undefined : `${args[1]}`; + try { + const instance: URL = Reflect.construct(target, [input, base], newTarget); + instances.add(instance); + return instance; + } catch (error) { + if (error instanceof TypeError && /^Invalid (base )?URL:/.test(error.message)) { + throw invalidURL(input, base); + } + throw error; + } + }, +}); +function createObjectURL(_blob: Blob): never { + return unsupported("URL.createObjectURL"); +} +function revokeObjectURL(_id: string): never { + return unsupported("URL.revokeObjectURL"); +} +Object.defineProperty(WhatwgURL.prototype, "constructor", { + value: URL, + writable: true, + configurable: true, +}); +for (const [name, value] of Object.entries({ parse, canParse, createObjectURL, revokeObjectURL })) { + Object.defineProperty(WhatwgURL, name, { + value, + writable: true, + configurable: true, + enumerable: true, + }); +} +function checkReceiver(receiver: object): void { + if (!instances.has(receiver)) { + throw invalidThis("URL"); + } +} +for (const name of [ + "href", + "origin", + "protocol", + "username", + "password", + "host", + "hostname", + "port", + "pathname", + "search", + "searchParams", + "hash", +] as const) { + const descriptor = Object.getOwnPropertyDescriptor(WhatwgURL.prototype, name)!; + const get = function (this: URL): unknown { + checkReceiver(this); + return descriptor.get!.call(this); + }; + Object.defineProperty(get, "name", { value: descriptor.get!.name }); + let set: ((this: URL, value: unknown) => void) | undefined; + if (descriptor.set) { + set = function (this: URL, value: unknown): void { + checkReceiver(this); + const input = `${value}`; + try { + descriptor.set!.call(this, input); + } catch (error) { + if (name === "href") { + throw invalidURL(input); + } + throw error; + } + }; + Object.defineProperty(set, "name", { value: descriptor.set.name }); + } + Object.defineProperty(WhatwgURL.prototype, name, { ...descriptor, get, set }); +} +for (const name of ["toString", "toJSON"] as const) { + const descriptor = Object.getOwnPropertyDescriptor(WhatwgURL.prototype, name)!; + const method = function (this: URL): string { + checkReceiver(this); + return descriptor.value.call(this); + }; + Object.defineProperty(method, "name", { value: name }); + Object.defineProperty(WhatwgURL.prototype, name, { ...descriptor, value: method }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/domain.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/domain.ts new file mode 100644 index 000000000..db0da850d --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/domain.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { url, nodeUrl, result } from "./helpers/conformance.js"; + +test("domain conversion follows UTS46, percent decoding and IP canonicalization", () => { + for (const input of [ + "BÜCHER.de", + "faß.de", + "測試", + "mañana.com", + "example.com", + "xn--bcher-kva.de", + "0xffffffff", + "0x7f.1", + "127.1", + "[2001:0db8::1]", + "[invalid]", + "%65xample.com", + "a@b", + "a b", + "a:80", + "a/b", + "a?b", + "a#b", + "a\\b", + "a\u0000b", + "\ud800", + "", + null, + undefined, + 123, + ]) { + for (const name of ["domainToASCII", "domainToUnicode"] as const) { + assert.deepEqual( + result(() => Reflect.apply(url[name], null, [input])), + result(() => Reflect.apply(nodeUrl[name], null, [input])), + String(input), + ); + } + } + for (const name of ["domainToASCII", "domainToUnicode"] as const) { + assert.deepEqual( + result(() => Reflect.apply(url[name], null, [])), + result(() => Reflect.apply(nodeUrl[name], null, [])), + ); + const sentinel = new Error("coercion"); + assert.throws( + () => + Reflect.apply(url[name], null, [ + { + toString() { + throw sentinel; + }, + }, + ]), + (error) => error === sentinel, + ); + } +}); + +test("native IDNA adapter supports engines without String.normalize", async () => { + const { createIDNA } = await import("../../../../../../src/wasi/0.2.x/node/24.x.x/url/idna.js"); + const original = Object.getOwnPropertyDescriptor(String.prototype, "normalize")!; + const fallback = { + toASCII() { + throw new Error("fallback must not run"); + }, + }; + assert.equal(createIDNA(fallback), fallback); + let native: ReturnType; + try { + Object.defineProperty(String.prototype, "normalize", { ...original, value: undefined }); + native = createIDNA(fallback); + } finally { + Object.defineProperty(String.prototype, "normalize", original); + } + for (const domain of [ + "bu\u0308cher.de", + "BÜCHER.de", + "faß.de", + "example.com", + "測試", + "127.1", + ]) { + assert.equal(native.toASCII(domain), nodeUrl.domainToASCII(domain)); + } + for (const domain of [ + "a/b", + "a@b", + "a:80", + "a?b", + "a#b", + "%65xample.com", + "a\\b", + "bad host", + "", + ]) { + assert.equal(native.toASCII(domain), null); + } +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/file.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/file.ts new file mode 100644 index 000000000..5b97156fc --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/file.ts @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { Buffer } from "node:buffer"; +import { createUrl } from "../../../../../../src/wasi/0.2.x/node/24.x.x/url.js"; +import { nodeUrl, url, result } from "./helpers/conformance.js"; + +test("fileURLToPath and raw Buffer conversion: encodings, hosts and Windows", () => { + for (const input of [ + "file:///", + "file://localhost/a", + "file://host/a", + "file:///a%2fb", + "file:///C:/%5c", + "file:///C:/%2f", + "file:///a/%FF", + "file:///a/%", + "file:///a/%FE%80", + "file://xn--bcher-kva.de/share/a", + "file:///C:/a%00b", + "file:///a/%E0%A4", + "file:///C:/x", + ]) { + for (const windows of [false, true]) { + const options = { windows }; + assert.deepEqual( + result(() => url.fileURLToPath(input, options)), + result(() => nodeUrl.fileURLToPath(input, options)), + input, + ); + assert.deepEqual( + result(() => [...url.fileURLToPathBuffer(input, options)]), + result(() => [...nodeUrl.fileURLToPathBuffer(input, options)]), + input, + ); + } + } + assert.ok(Buffer.isBuffer(url.fileURLToPathBuffer("file:///a"))); + assert.equal(url.fileURLToPath(new nodeUrl.URL("file:///native")), "/native"); +}); + +test("pathToFileURL preserves reserved characters and does not read providers for absolute paths", () => { + const pure = createUrl({ + initialCwd() { + throw new Error("cwd read"); + }, + getEnvironment() { + throw new Error("env read"); + }, + }); + for (const path of [ + "/", + "/a/../b/", + "/two words", + "/%23#?", + "/back\\slash", + "/control\u0000\u0001\n\t\r", + '/quote"<>^`{}|', + "/é/🌍", + "/unpaired\ud800", + "//server/path", + ]) { + assert.equal(pure.pathToFileURL(path).href, nodeUrl.pathToFileURL(path).href, path); + } + for (const path of [ + "C:\\", + "C:\\a\\..\\b\\", + "C:\\é #?%\\🌍", + "\\\\server\\share\\a b", + "\\\\?\\UNC\\server\\share\\a", + "\\\\server", + "\\\\\\bad\\path", + ]) { + assert.deepEqual( + result(() => pure.pathToFileURL(path, { windows: true }).href), + result(() => nodeUrl.pathToFileURL(path, { windows: true }).href), + path, + ); + } + assert.throws(() => pure.pathToFileURL("relative"), /cwd read/); + assert.equal( + url.pathToFileURL("relative/../child").href, + nodeUrl.pathToFileURL("relative/../child").href, + ); + const withCwd = createUrl({ + initialCwd: () => "/sandbox", + getEnvironment: () => [["=C:", "C:\\work"]], + }); + assert.equal(withCwd.pathToFileURL("child").href, "file:///sandbox/child"); + assert.equal(withCwd.pathToFileURL("C:child", { windows: true }).href, "file:///C:/work/child"); +}); + +test("file conversion invalid inputs preserve error codes and validation order", () => { + for (const input of [undefined, null, 42, true, {}, [], new nodeUrl.URL("https://example.com")]) { + for (const name of ["fileURLToPath", "fileURLToPathBuffer", "pathToFileURL"] as const) { + assert.deepEqual( + result(() => Reflect.apply(url[name], null, [input])), + result(() => Reflect.apply(nodeUrl[name], null, [input])), + name, + ); + } + } +}); + +// Cases derived from Node v24.20.0 test/parallel/test-url-pathtofileurl.js, +// commit 71b8b174857e25106d39b61a9e6f30d927da8b01 (Node MIT license). +test("UNC hostname terminators leave the resource path intact", () => { + for (const host of [ + "host#name", + "host?name", + "host/name", + "host\nname", + "host\rname", + "host\tname", + ]) { + const path = `\\\\${host}\\share\\file.txt`; + assert.equal( + url.pathToFileURL(path, { windows: true }).href, + nodeUrl.pathToFileURL(path, { windows: true }).href, + ); + } + for (const host of ["bad host", "host@name", "host:name", "host[name", "host]name"]) { + assert.throws(() => url.pathToFileURL(`\\\\${host}\\share\\file.txt`, { windows: true }), { + code: "ERR_INVALID_URL", + }); + } +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/format.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/format.ts new file mode 100644 index 000000000..aa3cc7aef --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/format.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { url, nodeUrl, result } from "./helpers/conformance.js"; + +test("format supports WHATWG options, opaque URLs, Unicode authorities and legacy objects", () => { + for (const text of [ + "https://u:p@xn--bcher-kva.de:8080/a?x#y", + "https://u:p@[::1]/", + "https://example.com/?#", + "file://xn--bcher-kva.de/share", + "mailto:user@example.com", + "custom://xn--bcher-kva.de/path", + ]) { + for (let bits = 0; bits < 16; bits++) { + const options = { + auth: !!(bits & 1), + search: !!(bits & 2), + fragment: !!(bits & 4), + unicode: !!(bits & 8), + }; + assert.equal( + url.format(new url.URL(text), options), + nodeUrl.format(new nodeUrl.URL(text), options), + text, + ); + } + } + for (const value of [undefined, null, false, 0, 1, "a", [], () => {}]) { + assert.deepEqual( + result(() => Reflect.apply(url.format, null, [new url.URL("https://example.com"), value])), + result(() => + Reflect.apply(nodeUrl.format, null, [new nodeUrl.URL("https://example.com"), value]), + ), + ); + } + for (const object of [ + {}, + { protocol: "file:", pathname: "/tmp/a" }, + { hostname: "::1", protocol: "https", port: 42, auth: "a:b c" }, + { query: { list: [1, 2], empty: "" }, search: "x=#y", pathname: "a?b#c" }, + ]) { + assert.equal(url.format(object), nodeUrl.format(object)); + } +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/helpers/conformance.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/helpers/conformance.ts new file mode 100644 index 000000000..fdad46b15 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/helpers/conformance.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import nodeUrl from "node:url"; +import { createUrl } from "../../../../../../../src/wasi/0.2.x/node/24.x.x/url.js"; + +assert.equal(process.versions.node.split(".")[0], "24", "URL tests require the Node 24 oracle"); +export { nodeUrl }; +export const url = createUrl({ initialCwd: () => process.cwd(), getEnvironment: () => [] }); +export function result(fn: () => unknown): unknown { + try { + return fn(); + } catch (error) { + assert.ok(error instanceof Error); + const fields = error as Error & { code?: string; input?: unknown; base?: unknown }; + // Node embeds the host OS in this one message; the guest is POSIX. + return { + name: error.name, + code: fields.code, + message: error.message.replace(`on ${process.platform}`, "on posix"), + input: + fields.input instanceof nodeUrl.URL || fields.input instanceof url.URL + ? { href: fields.input.href, isURL: true } + : fields.input, + base: fields.base, + }; + } +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/http-options.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/http-options.ts new file mode 100644 index 000000000..d6a224e2c --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/http-options.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { url, nodeUrl, result } from "./helpers/conformance.js"; + +test("HTTP options retain enumerable extensions, symbols, absent fields and validation", () => { + for (const text of [ + "http://example.com/", + "https://u%20s:p%40ss@[::1]:8443/a?x#y", + "file:///a", + "mailto:a@example.com", + ]) { + const symbol = Symbol("extra"); + const extra = { custom: 42, [symbol]: "kept" }; + const actual = url.urlToHttpOptions(Object.assign(new url.URL(text), extra)); + const expected = nodeUrl.urlToHttpOptions(Object.assign(new nodeUrl.URL(text), extra)); + assert.deepEqual(actual, expected); + assert.equal(Object.getPrototypeOf(actual), null); + } + for (const value of [undefined, null, 42, "url", true, [], {}, () => {}]) { + assert.deepEqual( + result(() => Reflect.apply(url.urlToHttpOptions, null, [value])), + result(() => Reflect.apply(nodeUrl.urlToHttpOptions, null, [value])), + ); + } +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/legacy.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/legacy.ts new file mode 100644 index 000000000..413e96276 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/legacy.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { url, nodeUrl } from "./helpers/conformance.js"; + +test("deprecated entry points and object URLs throw before observing inputs", () => { + let accessed = 0; + const input = new Proxy( + {}, + { + get() { + accessed++; + throw new Error("observed input"); + }, + }, + ); + for (const fn of [ + url.parse, + url.resolve, + url.resolveObject, + url.Url.prototype.parse, + url.Url.prototype.resolve, + ]) { + assert.throws(() => Reflect.apply(fn, input, [input, input]), { + code: "ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API", + }); + } + assert.throws(() => Reflect.apply(url.format, null, ["https://example.com", input]), { + code: "ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API", + }); + for (const fn of [url.URL.createObjectURL, url.URL.revokeObjectURL]) { + assert.throws(() => Reflect.apply(fn, null, [input]), { code: "ERR_JCO_UNSUPPORTED_NODE_API" }); + } + assert.equal(accessed, 0); +}); + +test("non-deprecated legacy Url methods retain object resolution algorithms", () => { + for (const source of [ + { + protocol: "http:", + host: "a", + hostname: "a", + pathname: "/base/file", + href: "http://a/base/file", + }, + { protocol: "mailto:", host: "domain", pathname: "local", href: "mailto:local@domain" }, + ]) { + for (const relative of [ + { href: "" }, + { pathname: "../next", href: "../next", hash: "#end" }, + { slashes: true, host: "other", hostname: "other", href: "//other" }, + { protocol: "https:", host: "b", hostname: "b", pathname: "/new", href: "https://b/new" }, + { search: "?q=1", href: "?q=1" }, + ]) { + const actual = Object.assign(new url.Url(), source).resolveObject({ ...relative }); + const expected = Object.assign(new nodeUrl.Url(), source).resolveObject({ ...relative }); + assert.deepEqual({ ...actual }, { ...expected }); + } + } +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/module.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/module.ts new file mode 100644 index 000000000..85f8ac60e --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/module.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { nodeUrl, url } from "./helpers/conformance.js"; + +test("complete Node24 module shape, aliases and constructors", () => { + assert.deepEqual(Object.keys(url), Object.keys(nodeUrl)); + const value = new url.URL("https://example.com"); + assert.ok(value.searchParams instanceof url.URLSearchParams); + assert.equal(value.searchParams.constructor, url.URLSearchParams); + assert.equal(value.constructor, url.URL); + assert.deepEqual(Object.keys(new url.Url()), Object.keys(new nodeUrl.Url())); + for (const name of ["URL", "URLSearchParams", "Url"] as const) { + const actual = url[name]; + const expected = nodeUrl[name]; + assert.equal(actual.name, expected.name); + assert.equal(actual.length, expected.length); + assert.deepEqual( + Object.getOwnPropertyNames(actual.prototype).sort(), + Object.getOwnPropertyNames(expected.prototype).sort(), + ); + } + for (const key of ["canParse", "parse", "createObjectURL", "revokeObjectURL"] as const) { + const actual = Object.getOwnPropertyDescriptor(url.URL, key)!; + const expected = Object.getOwnPropertyDescriptor(nodeUrl.URL, key)!; + assert.deepEqual({ ...actual, value: undefined }, { ...expected, value: undefined }); + } +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/pattern.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/pattern.ts new file mode 100644 index 000000000..ab1e064e3 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/pattern.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { url, nodeUrl } from "./helpers/conformance.js"; + +test("URLPattern constructors, captures, regexp groups, bases and ignoreCase", () => { + for (const pattern of [ + "/books/:id", + "/files/*", + "/users/:name?", + "/items/:id(\\d+)", + "/:café", + "/:測試", + ]) { + const actual = new url.URLPattern(pattern, "https://example.com", { ignoreCase: true }); + const expected = new nodeUrl.URLPattern(pattern, "https://example.com", { ignoreCase: true }); + assert.equal(actual.hasRegExpGroups, expected.hasRegExpGroups); + for (const input of [ + "/books/42", + "/BOOKS/ABC", + "/files/a/b", + "/users", + "/items/123", + "/items/no", + "/coffee", + ]) { + assert.equal( + actual.test(input, "https://example.com"), + expected.test(input, "https://example.com"), + ); + assert.deepEqual( + JSON.parse(JSON.stringify(actual.exec(input, "https://example.com"))), + JSON.parse(JSON.stringify(expected.exec(input, "https://example.com"))), + ); + } + } + assert.throws(() => new url.URLPattern("/relative"), TypeError); + assert.throws(() => new url.URLPattern({ pathname: "[invalid(" }), TypeError); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/search-params.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/search-params.ts new file mode 100644 index 000000000..1e61d0e53 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/search-params.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { url, nodeUrl, result } from "./helpers/conformance.js"; + +test("URLSearchParams Node overloads, malformed tuples, records and errors", () => { + const inputs = [ + undefined, + null, + 42, + true, + "?a=1&a=2", + [ + ["a", "1"], + ["a", "2"], + ], + [["a"]], + [["a", "b", "c"]], + [null], + ["ab"], + { [Symbol.iterator]: 42 }, + { "\ud800": "first", "\ud801": "last" }, + { [Symbol("key")]: 1 }, + { list: ["a", "b"] }, + Object.assign(() => {}, { key: "value" }), + ]; + for (const input of inputs) { + assert.deepEqual( + result(() => Reflect.construct(url.URLSearchParams, [input]).toString()), + result(() => Reflect.construct(nodeUrl.URLSearchParams, [input]).toString()), + ); + } + for (const name of [ + "append", + "delete", + "get", + "getAll", + "has", + "set", + "sort", + "toString", + "keys", + "values", + "entries", + "forEach", + ] as const) { + for (const args of [[], ["name"], [Symbol("name"), "value"]]) { + // Skip successful iterator/callback cases here; guest fixtures cover their behavior. + const actual = result(() => + Reflect.apply(url.URLSearchParams.prototype[name], new url.URLSearchParams(), args), + ); + const expected = result(() => + Reflect.apply(nodeUrl.URLSearchParams.prototype[name], new nodeUrl.URLSearchParams(), args), + ); + if (actual && typeof actual === "object" && "next" in actual) { + continue; + } + assert.deepEqual(actual, expected, name); + } + assert.deepEqual( + result(() => Reflect.apply(url.URLSearchParams.prototype[name], {}, [])), + result(() => Reflect.apply(nodeUrl.URLSearchParams.prototype[name], {}, [])), + name, + ); + } + assert.equal( + url.URLSearchParams.prototype[Symbol.iterator], + url.URLSearchParams.prototype.entries, + ); +}); + +test("URLSearchParams converts tuples during iteration and closes on failure", () => { + function observe(Constructor: typeof URLSearchParams): unknown { + const events: string[] = []; + function* pairs(): Generator { + try { + yield [ + { + toString() { + events.push("name"); + return "x"; + }, + }, + { + toString() { + events.push("value"); + return "1"; + }, + }, + ]; + events.push("second"); + yield ["invalid"]; + events.push("unreachable"); + } finally { + events.push("closed"); + } + } + return [result(() => Reflect.construct(Constructor, [pairs()])), events]; + } + assert.deepEqual(observe(url.URLSearchParams), observe(nodeUrl.URLSearchParams)); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/whatwg.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/whatwg.ts new file mode 100644 index 000000000..8020ad7ae --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/url/whatwg.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { url, nodeUrl, result } from "./helpers/conformance.js"; +import { + utf8Encode, + utf8DecodeWithoutBOM, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/url/encoding.js"; + +test("WHATWG URL errors, coercion, subclassing and statics", () => { + for (const args of [ + [], + [undefined], + [null], + ["relative"], + ["/x", "invalid"], + ["https://example.com"], + ["/x", "https://example.com"], + ]) { + assert.deepEqual( + result(() => Reflect.construct(url.URL, args).href), + result(() => Reflect.construct(nodeUrl.URL, args).href), + ); + for (const method of ["parse", "canParse"] as const) { + assert.deepEqual( + result(() => { + const value = Reflect.apply(url.URL[method], null, args); + return value instanceof url.URL ? value.href : value; + }), + result(() => { + const value = Reflect.apply(nodeUrl.URL[method], null, args); + return value instanceof nodeUrl.URL ? value.href : value; + }), + ); + } + } + const sentinel = new Error("coercion"); + const input = { + toString() { + throw sentinel; + }, + }; + for (const call of [ + () => Reflect.construct(url.URL, [input]), + () => Reflect.apply(url.URL.parse, null, [input]), + () => Reflect.apply(url.URL.canParse, null, [input]), + ]) { + assert.throws(call, (error) => error === sentinel); + } + class Child extends url.URL {} + const child = new Child("https://example.com"); + assert.ok(child instanceof Child); + const native = new nodeUrl.URL(child.href); + assert.deepEqual( + result(() => { + child.href = "bad"; + }), + result(() => { + native.href = "bad"; + }), + ); + assert.equal(child.href, "https://example.com/"); +}); + +test("UTF-8 adapter matches the standard decoder for truncation, invalid continuations and BOM", () => { + const decoder = new TextDecoder("utf-8", { ignoreBOM: true }); + for (const bytes of [ + [0xef, 0xbb, 0xbf], + [0xe0, 0xa4], + [0xe0, 0xa4, 0x25, 0x41], + [0xf0, 0x9f, 0x8c], + [0xf4, 0x90, 0x80, 0x80], + [0xed, 0xa0, 0x80], + [0xc0, 0x80], + [0xff, 0xfe], + [0xe0, 0x80, 0x80], + [0xf0, 0x90, 0x80, 0x80], + ]) { + assert.equal( + utf8DecodeWithoutBOM(new Uint8Array(bytes)), + decoder.decode(new Uint8Array(bytes)), + ); + } + for (let first = 0; first < 256; first++) { + for (let second = 0; second < 256; second++) { + const bytes = new Uint8Array([first, second]); + assert.equal(utf8DecodeWithoutBOM(bytes), decoder.decode(bytes), `${first},${second}`); + } + } + for (const text of ["ASCII", "🌍✓", "\ud800", "\udc00", "\ufeffhello"]) { + assert.deepEqual([...utf8Encode(text)], [...new TextEncoder().encode(text)]); + } +}); + +test("URL validates receivers before coercion and preserves writable statics", () => { + const value = new url.URL("https://example.com"); + let touched = false; + const poison = { + toString() { + touched = true; + throw new Error("coerced"); + }, + }; + for (const key of [ + "href", + "protocol", + "username", + "password", + "host", + "hostname", + "port", + "pathname", + "search", + "hash", + ]) { + const descriptor = Object.getOwnPropertyDescriptor(url.URL.prototype, key)!; + assert.throws(() => descriptor.get!.call({}), { code: "ERR_INVALID_THIS" }); + assert.throws(() => descriptor.set!.call({}, poison), { code: "ERR_INVALID_THIS" }); + assert.throws(() => descriptor.get!.call(new Proxy(value, {})), { code: "ERR_INVALID_THIS" }); + } + assert.equal(touched, false); + for (const key of ["toString", "toJSON"] as const) { + assert.throws(() => Reflect.apply(url.URL.prototype[key], {}, []), { + code: "ERR_INVALID_THIS", + }); + } + const original = url.URL.canParse; + const replacement = () => false; + try { + url.URL.canParse = replacement; + assert.equal(url.URL.canParse, replacement); + } finally { + url.URL.canParse = original; + } +}); diff --git a/packages/jco-std/tsconfig.json b/packages/jco-std/tsconfig.json index e8d2929b3..38bf5c62c 100644 --- a/packages/jco-std/tsconfig.json +++ b/packages/jco-std/tsconfig.json @@ -11,6 +11,8 @@ "declarationMap": true, "skipLibCheck": true, "paths": { + "whatwg-url": ["./src/wasi/0.2.x/node/24.x.x/url/whatwg-types.d.ts"], + "punycode/punycode.js": ["./src/wasi/0.2.x/node/24.x.x/url/punycode-types.d.ts"], "readable-stream/lib/stream.js": ["./src/wasi/0.2.x/node/24.x.x/stream/vendor-types.d.ts"], "jco:node/sqlite@0.1.0": ["./src/wasi/0.2.x/node/24.x.x/sqlite-interface.d.ts"], "wasi:sockets/instance-network@0.2.12": ["./src/wasi/0.2.x/node/24.x.x/net-interface.d.ts"], diff --git a/packages/jco/package.json b/packages/jco/package.json index 2d60b417f..db313f471 100644 --- a/packages/jco/package.json +++ b/packages/jco/package.json @@ -73,6 +73,7 @@ "commander": "^14", "componentize-qjs": "^0.4.2", "mkdirp": "^3", + "regexpu-core": "6.4.0", "rolldown": "^1.2.4", "typescript": "7.0.2", "typescript-compiler-api": "npm:typescript@6.0.3", diff --git a/packages/jco/src/node-builtins/index.ts b/packages/jco/src/node-builtins/index.ts index 311ade4cf..4b878719a 100644 --- a/packages/jco/src/node-builtins/index.ts +++ b/packages/jco/src/node-builtins/index.ts @@ -31,6 +31,7 @@ import { createHttpsBuiltin } from "./https.js"; import { createHttp2Builtin } from "./http2.js"; import { createBufferBuiltin } from "./buffer.js"; import { createQuerystringBuiltin } from "./querystring.js"; +import { createUrlBuiltin } from "./url.js"; import { createPathBuiltin } from "./path.js"; import { composeBuiltins, VIRTUAL_PREFIX } from "./shared.js"; @@ -87,6 +88,7 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui createBufferBuiltin, createQuerystringBuiltin, createPathBuiltin, + createUrlBuiltin, ]; const composed = composeBuiltins(adapters.map((create) => create(context))); return { diff --git a/packages/jco/src/node-builtins/types.ts b/packages/jco/src/node-builtins/types.ts index f932c7175..d6ef54fec 100644 --- a/packages/jco/src/node-builtins/types.ts +++ b/packages/jco/src/node-builtins/types.ts @@ -107,6 +107,10 @@ export interface NodeBuiltinOptions { /** Reports WIT imports required by builtins found while bundling. */ onWitRequirement?: (requirement: NodeWitRequirement) => void; + /** Paths to the portable URL factory and UTF-8 adapter (overridable for tests). */ + urlFactory?: string; + urlEncodingModule?: string; + /** unenv aliases to resolve audited builtins against (overridable for tests) */ unenvAliases?: Readonly>; } diff --git a/packages/jco/src/node-builtins/url-pattern.ts b/packages/jco/src/node-builtins/url-pattern.ts new file mode 100644 index 000000000..2f8eb3399 --- /dev/null +++ b/packages/jco/src/node-builtins/url-pattern.ts @@ -0,0 +1,24 @@ +import { readFileSync } from "node:fs"; +import rewritePattern from "regexpu-core"; + +const sources = new Map(); + +/** + * urlpattern-polyfill@10.1.0 uses two Unicode property escapes to lex group + * names. StarlingMonkey's reduced ICU build cannot compile those expressions. + * regexpu-core@6.4.0 expands only these expressions into equivalent ranges at + * bundle time; the guest receives no regex transpiler or additional capability. + */ +export function urlPatternSource(path: string): string { + const cached = sources.get(path); + if (cached !== undefined) { + return cached; + } + const source = readFileSync(path, "utf8").replace( + /\/\[([^\]]*\\p\{ID_(?:Start|Continue)\}[^\]]*)\]\/u/g, + (_match: string, characters: string) => + `/${rewritePattern(`[${characters}]`, "u", { unicodePropertyEscapes: "transform" })}/u`, + ); + sources.set(path, source); + return source; +} diff --git a/packages/jco/src/node-builtins/url-vendor-types.d.ts b/packages/jco/src/node-builtins/url-vendor-types.d.ts new file mode 100644 index 000000000..7a3a90927 --- /dev/null +++ b/packages/jco/src/node-builtins/url-vendor-types.d.ts @@ -0,0 +1,7 @@ +declare module "regexpu-core" { + export default function rewritePattern( + pattern: string, + flags: string, + options: { unicodePropertyEscapes: "transform" }, + ): string; +} diff --git a/packages/jco/src/node-builtins/url.ts b/packages/jco/src/node-builtins/url.ts new file mode 100644 index 000000000..1ec592ab1 --- /dev/null +++ b/packages/jco/src/node-builtins/url.ts @@ -0,0 +1,116 @@ +import { urlPatternSource } from "./url-pattern.js"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { + builtin, + composeBuiltins, + stdModule, + VIRTUAL_PREFIX, + type BuiltinAdapter, + type BuiltinContext, +} from "./shared.js"; + +/** URL operations are pure except relative path resolution, which reads WASI cwd. */ +export function createUrlBuiltin({ options, worldMetadata }: BuiltinContext): BuiltinAdapter { + let patternModule: string | undefined; + let idnaModule: string | undefined; + const idnaId = `${VIRTUAL_PREFIX}url-idna`; + const patternId = `${VIRTUAL_PREFIX}url-pattern.cjs`; + const publicAdapter = builtin("node:url", () => { + const environments = (worldMetadata?.imports ?? []).filter( + (iface) => + iface.namespace === "wasi" && + iface.package === "cli" && + iface.interface === "environment" && + iface.version?.major === 0n && + iface.version?.minor === 2n, + ); + let providerSource: string; + if (environments.length === 1) { + const { major, minor, patch, pre } = environments[0].version!; + providerSource = `import { initialCwd, getEnvironment } from "wasi:cli/environment@${major}.${minor}.${patch}${pre ? `-${pre}` : ""}";`; + } else { + const message = + environments.length === 0 + ? "node:url relative pathToFileURL requires wasi:cli/environment@0.2.x" + : "node:url relative pathToFileURL cannot select among multiple wasi:cli/environment@0.2.x versions"; + providerSource = `function initialCwd() { throw new Error(${JSON.stringify(message)}); }\nconst getEnvironment = initialCwd;`; + } + return ` +import { createUrl } from ${JSON.stringify(stdModule(options.urlFactory, "url"))}; +${providerSource} +const url = createUrl({ initialCwd, getEnvironment }); +// The Node module and global web APIs must share classes and search-param state. +globalThis.URL = url.URL; +globalThis.URLSearchParams = url.URLSearchParams; +globalThis.URLPattern = url.URLPattern; +export default url; +export const { Url, parse, resolve, resolveObject, format, URL, URLPattern, URLSearchParams, + domainToASCII, domainToUnicode, pathToFileURL, fileURLToPath, fileURLToPathBuffer, urlToHttpOptions } = url; +`; + }); + // Resolve only the audited dependency graph. Lexical prefilters avoid even + // resolving packages when an unrelated application import is encountered. + const factoryRequire = () => createRequire(stdModule(options.urlFactory, "url")); + const whatwgDirectory = () => dirname(factoryRequire().resolve("whatwg-url/lib/encoding.js")); + const tr46Path = () => createRequire(factoryRequire().resolve("whatwg-url")).resolve("tr46"); + return composeBuiltins([ + publicAdapter, + { + resolveId(id, importer) { + if ( + id === "urlpattern-polyfill/urlpattern" && + importer && + /[/\\]url[/\\]whatwg\.js$/.test(importer) && + importer === join(dirname(stdModule(options.urlFactory, "url")), "url", "whatwg.js") + ) { + patternModule = createRequire(importer).resolve(id); + return patternId; + } + if ( + id === "tr46" && + importer && + /[/\\]whatwg-url[/\\]lib[/\\]url-state-machine\.js$/.test(importer) && + dirname(importer) === whatwgDirectory() + ) { + idnaModule = tr46Path(); + return idnaId; + } + if ( + importer && + ((id === "webidl-conversions" && + /[/\\]whatwg-url[/\\]lib[/\\][^/\\]+$/.test(importer) && + dirname(importer) === whatwgDirectory()) || + (id === "punycode/" && /[/\\]tr46[/\\]index\.js$/.test(importer) && importer === tr46Path())) + ) { + return createRequire(importer).resolve(id); + } + if (!importer || !/[/\\]whatwg-url[/\\]lib[/\\][^/\\]+$/.test(importer)) { + return null; + } + if (id !== "./encoding" && id !== "./encoding.js") { + return null; + } + if (dirname(importer) !== whatwgDirectory()) { + return null; + } + return stdModule(options.urlEncodingModule, "url/encoding"); + }, + load(id) { + if (id === patternId && patternModule) { + return urlPatternSource(patternModule); + } + if (id === idnaId && idnaModule) { + return ` +import fallback from ${JSON.stringify(idnaModule)}; +import { createIDNA } from ${JSON.stringify(stdModule(undefined, "url/idna"))}; +const idna = createIDNA(fallback); +export const toASCII = idna.toASCII; +export default idna; +`; + } + return null; + }, + }, + ]); +} diff --git a/packages/jco/test/fixtures/componentize/node-url/cwd.js b/packages/jco/test/fixtures/componentize/node-url/cwd.js new file mode 100644 index 000000000..b1b1ea16a --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-url/cwd.js @@ -0,0 +1,6 @@ +import { pathToFileURL, fileURLToPath } from "node:url"; + +export function fromCwd(path) { + const url = pathToFileURL(path); + return JSON.stringify({ href: url.href, path: fileURLToPath(url) }); +} diff --git a/packages/jco/test/fixtures/componentize/node-url/peer.js b/packages/jco/test/fixtures/componentize/node-url/peer.js new file mode 100644 index 000000000..79969a689 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-url/peer.js @@ -0,0 +1,2 @@ +import url, { URL, URLSearchParams } from "node:url"; +export const peer = { url, URL, URLSearchParams }; diff --git a/packages/jco/test/fixtures/componentize/node-url/source.js b/packages/jco/test/fixtures/componentize/node-url/source.js new file mode 100644 index 000000000..942e34519 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-url/source.js @@ -0,0 +1,328 @@ +import url, { + URL, + URLSearchParams, + URLPattern, + Url, + domainToASCII, + domainToUnicode, + fileURLToPath, + fileURLToPathBuffer, + pathToFileURL, + urlToHttpOptions, + format, +} from "node:url"; +import * as namespace from "node:url"; +import { Buffer } from "node:buffer"; +import { peer } from "./peer.js"; + +function errorOf(fn) { + try { + fn(); + return null; + } catch (error) { + return { name: error.name, code: error.code, message: error.message, input: error.input, base: error.base }; + } +} + +// This same function runs in Node 24 as a differential oracle and in both +// component engines. Each named observation covers an application-visible API. +export function run() { + const report = {}; + report.identity = [ + namespace.default === url, + peer.url === url, + peer.URL === URL, + peer.URLSearchParams === URLSearchParams, + URL === globalThis.URL, + URLSearchParams === globalThis.URLSearchParams, + Object.keys(url).every((key) => namespace[key] === url[key]), + new URL("https://example.com").searchParams instanceof URLSearchParams, + new URL("https://example.com").constructor === URL, + new URLSearchParams().constructor === URLSearchParams, + ]; + report.exports = Object.keys(url).sort(); + report.legacyConstructorName = Url.name; + const value = new URL("../a b?x=1&x=2#top", "https://user:pass@BÜCHER.de:8443/base/"); + report.parts = Object.fromEntries( + [ + "href", + "origin", + "protocol", + "username", + "password", + "host", + "hostname", + "port", + "pathname", + "search", + "hash", + ].map((key) => [key, value[key]]), + ); + report.serialization = [String(value), value.toJSON(), JSON.stringify(value)]; + const savedParams = value.searchParams; + value.protocol = "http:"; + value.username = "two words"; + value.password = "p@ss"; + value.hostname = "EXAMPLE.COM"; + value.port = "80"; + value.pathname = "/c d/✓"; + value.search = "?a=1&a=2"; + value.hash = "last part"; + report.setters = [value.href, value.origin, value.port, value.searchParams === savedParams]; + savedParams.append("a", "3"); + savedParams.set("space", "two words"); + report.live = [value.href, savedParams.getAll("a")]; + value.href = "https://other.example/?z=9"; + report.reassigned = [savedParams === value.searchParams, savedParams.get("z"), savedParams.has("a")]; + report.static = [ + URL.canParse("../x", "https://example.com/a/"), + URL.canParse("invalid"), + URL.parse("invalid"), + URL.parse("/x", "https://example.com").href, + ]; + let calls = []; + const coerced = new URL( + { + toString() { + calls.push("input"); + return "/x"; + }, + }, + { + toString() { + calls.push("base"); + return "https://example.com"; + }, + }, + ); + report.coercion = [coerced.href, calls]; + class ChildURL extends URL {} + const child = new ChildURL("https://example.com"); + report.subclass = [child instanceof ChildURL, child instanceof URL]; + report.special = [ + "http://0x7f.1/", + "https://[2001:db8::1]:443/a/../b", + "file:///tmp/a%20b", + "data:text/plain,hello%20world", + "mailto:a@example.com", + "https://example.com/%2e%2e/b", + ].map((text) => { + const parsed = new URL(text); + return [parsed.href, parsed.origin]; + }); + + const params = new URLSearchParams("?b=two+words&a=1&a=2&empty=&flag&bad=%E0%A4%A&bom=%EF%BB%BF"); + report.paramsInitial = [ + params.size, + [...params], + params.get("missing"), + params.getAll("a"), + params.has("a", "2"), + params.has("a", "3"), + ]; + params.delete("a", "1"); + params.append("a", "3"); + params.set("b", "a+b & c"); + params.sort(); + const visited = []; + const context = { prefix: "ctx:" }; + params.forEach(function (v, k, self) { + visited.push([this.prefix + k, v, self === params]); + }, context); + report.paramsMutation = [ + params.toString(), + [...params.keys()], + [...params.values()], + [...params.entries()], + visited, + ]; + report.paramsConstructors = [ + new URLSearchParams({ list: [1, 2], empty: "", flag: true }).toString(), + new URLSearchParams( + new Map([ + ["x", "✓"], + ["y", "z"], + ]), + ).toString(), + new URLSearchParams([ + ["x", "1"], + ["x", "2"], + ]).toString(), + new URLSearchParams(params).toString(), + new URLSearchParams(null).toString(), + new URLSearchParams(123).toString(), + ]; + const stable = new URLSearchParams("z=1&a=2&a=1&z=0"); + stable.sort(); + report.stableSort = stable.toString(); + stable.delete("a"); + report.deleteAll = [stable.size, stable.getAll("a")]; + + report.domains = [ + "BÜCHER.de", + "mañana.com", + "測試", + "EXAMPLE.COM", + "faß.de", + "example.com", + "xn--bcher-kva.de", + "bad host", + "a@b", + "a:80", + "%65xample.com", + "0x7f.1", + "[::1]", + "", + "a/b", + ].map((d) => [domainToASCII(d), domainToUnicode(d)]); + report.posixFiles = ["/tmp/a b#c?d%é", "/tmp/back\\slash", "/tmp/a\nb\tc\rd", "/a/../b/", "/"].map((p) => { + const parsed = pathToFileURL(p); + return [ + parsed.href, + fileURLToPath(parsed), + Buffer.isBuffer(fileURLToPathBuffer(parsed)), + fileURLToPathBuffer(parsed).toString("hex"), + ]; + }); + report.windowsFiles = [ + "C:\\Program Files\\a#b?c.txt", + "C:/a/../b/", + "\\\\server\\share\\a b", + "\\\\?\\UNC\\server\\share\\a", + ].map((p) => { + const parsed = pathToFileURL(p, { windows: true }); + return [ + parsed.href, + fileURLToPath(parsed, { windows: true }), + fileURLToPathBuffer(parsed, { windows: true }).toString("hex"), + ]; + }); + report.fileBytes = ["file:///a/%FF%FE%80", "file:///a/%2F%5c", "file:///a/%ZZ%1", "file:///C:/%FF%2F%5c"].map( + (s) => [...fileURLToPathBuffer(s, { windows: s.includes("C:") })], + ); + report.uncUnicode = fileURLToPath("file://xn--bcher-kva.de/share/file", { windows: true }); + const formatted = new URL("https://u%20s:p%40ss@xn--bcher-kva.de:8443/a?b=1#c"); + report.format = [ + {}, + { auth: false }, + { fragment: false }, + { search: false }, + { unicode: true }, + { unicode: true, auth: false, search: false, fragment: false }, + ].map((options) => format(formatted, options)); + report.formatOriginal = formatted.href; + report.legacyFormat = [ + { + protocol: "https", + hostname: "example.com", + port: 8443, + pathname: "a?b#c", + query: { a: [1, 2], space: "a b" }, + hash: "end", + }, + { protocol: "http:", hostname: "::1", auth: "u s:p@ss", pathname: "/" }, + { protocol: "file:", pathname: "/tmp/a" }, + { protocol: "mailto:", pathname: "a@example.com" }, + { host: "example.com", slashes: true, search: "x=#y", hash: "z" }, + ].map((input) => format(input)); + const legacy = new Url(); + report.legacyFields = Object.entries(legacy); + Object.assign(legacy, { + protocol: "https:", + host: "example.com:443", + pathname: "/base/file", + href: "https://example.com:443/base/file", + }); + legacy.parseHost(); + report.legacyObject = [ + legacy.hostname, + legacy.port, + legacy.format(), + legacy.resolveObject({ pathname: "../next", hash: "#end", href: "../next#end" }).href, + ]; + + const http = new URL("https://u%20s:p%40ss@[::1]:8443/a?b=1#c"); + http.extra = "kept"; + const token = Symbol("token"); + http[token] = 42; + const options = urlToHttpOptions(http); + report.http = [options, Object.getPrototypeOf(options) === null, options[token]]; + const minimalHttp = urlToHttpOptions(new URL("https://example.com")); + report.httpAbsent = [ + Object.hasOwn(minimalHttp, "port"), + Object.hasOwn(minimalHttp, "auth"), + Object.keys(minimalHttp).sort(), + ]; + + const pattern = new URLPattern("https://*.example.com/books/:id"); + const match = pattern.exec("https://api.example.com/books/42"); + report.pattern = [ + pattern.test("https://api.example.com/books/42"), + pattern.test("https://api.example.com/other/42"), + match.hostname.groups, + match.pathname.groups, + pattern.protocol, + pattern.pathname, + ]; + const relativePattern = new URLPattern("/books/:id", "https://example.com"); + report.relativePattern = [ + relativePattern.test("/books/1", "https://example.com"), + relativePattern.exec("/books/1", "https://example.com").pathname.groups, + ]; + const objectPattern = new URLPattern({ pathname: "/items/:name" }); + report.objectPattern = [ + objectPattern.test({ pathname: "/items/coffee" }), + objectPattern.exec({ pathname: "/items/coffee" }).pathname.groups, + ]; + const insensitive = new URLPattern({ pathname: "/BOOKS/:id" }, { ignoreCase: true }); + const regexPattern = new URLPattern({ pathname: "/books/:id(\\d+)" }); + report.patternOptions = [ + insensitive.test({ pathname: "/books/A" }), + regexPattern.hasRegExpGroups, + regexPattern.test({ pathname: "/books/123" }), + regexPattern.test({ pathname: "/books/abc" }), + pattern.hasRegExpGroups, + ]; + report.errors = [ + () => new URL("invalid"), + () => new URL("/x", "invalid"), + () => new URL(), + () => URL.canParse(), + () => URL.parse(), + () => domainToASCII(), + () => domainToUnicode(), + () => fileURLToPath("https://example.com"), + () => fileURLToPath("file:///a%2Fb"), + () => fileURLToPath("file:///C:/a%5Cb", { windows: true }), + () => fileURLToPath("file:///relative", { windows: true }), + () => fileURLToPath("file:///a/%FF"), + () => pathToFileURL(42), + () => format(new URL("https://example.com"), []), + () => urlToHttpOptions(null), + ].map(errorOf); + return JSON.stringify(report); +} + +export function policy() { + let touched = false; + const poison = new Proxy( + {}, + { + get() { + touched = true; + throw new Error("input accessed"); + }, + }, + ); + const errors = [ + () => url.parse(poison), + () => url.resolve(poison, poison), + () => url.resolveObject(poison, poison), + () => url.format("https://example.com", poison), + () => new Url().parse(poison), + () => new Url().resolve(poison), + () => URL.createObjectURL(poison), + () => URL.revokeObjectURL(poison), + ].map(errorOf); + return JSON.stringify({ touched, errors, missingCwd: errorOf(() => pathToFileURL("relative"))?.message }); +} diff --git a/packages/jco/test/fixtures/componentize/node-url/source.wit b/packages/jco/test/fixtures/componentize/node-url/source.wit new file mode 100644 index 000000000..426fbdb63 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-url/source.wit @@ -0,0 +1,5 @@ +package jco-fixtures:node-url; +world test { + export run: func() -> string; + export policy: func() -> string; +} diff --git a/packages/jco/test/fixtures/componentize/node-url/wit/component.wit b/packages/jco/test/fixtures/componentize/node-url/wit/component.wit new file mode 100644 index 000000000..637b19845 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-url/wit/component.wit @@ -0,0 +1,5 @@ +package jco-fixtures:node-url; +world cwd { + import wasi:cli/environment@0.2.12; + export from-cwd: func(path: string) -> string; +} diff --git a/packages/jco/test/fixtures/componentize/node-url/wit/deps/wasi-cli-0.2.12/package.wit b/packages/jco/test/fixtures/componentize/node-url/wit/deps/wasi-cli-0.2.12/package.wit new file mode 100644 index 000000000..226d26ee8 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-url/wit/deps/wasi-cli-0.2.12/package.wit @@ -0,0 +1,9 @@ +// Trimmed to the single interface this fixture's world imports, the way the neighbouring +// wasi-http-detection fixtures trim theirs. +package wasi:cli@0.2.12; + +interface environment { + get-environment: func() -> list>; + get-arguments: func() -> list; + initial-cwd: func() -> option; +} diff --git a/packages/jco/test/node/url.js b/packages/jco/test/node/url.js new file mode 100644 index 000000000..23da81dd8 --- /dev/null +++ b/packages/jco/test/node/url.js @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import { pathToFileURL } from "node:url"; +import { resolve } from "node:path"; +import { createRequire } from "node:module"; +import { suite, test } from "vitest"; +import { componentizeFixture, transpileComponent } from "../helpers.js"; + +const isNode24 = process.versions.node.split(".")[0] === "24"; +let hasUrlExport = true; +try { + createRequire(import.meta.url).resolve("@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/url"); +} catch (error) { + if (error.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") { + throw error; + } + hasUrlExport = false; +} + +suite("node:url in components", () => { + // TODO(unskip): publish and depend on jco-std's URL exports. This differential + // fixture also requires the Node 24 oracle; Node 22/26 CI jobs must skip it. + test.skipIf(!hasUrlExport || !isNode24).each(["qjs", "starlingmonkey"])( + "matches Node 24 extensively through %s", + async (backend) => { + // Avoid importing Node 24-only named exports during collection on other hosts. + const { run: runInNode } = await import("../fixtures/componentize/node-url/source.js"); + const expected = JSON.parse(runInNode()); + assert.strictEqual(expected.stableSort, "a=2&a=1&z=1&z=0"); + assert.deepEqual(expected.identity, Array(10).fill(true)); + const { componentPath } = await componentizeFixture({ + fixture: "node-url", + entry: "source.js", + wit: "source.wit", + world: "test", + bundle: true, + extraArgs: ["--backend", backend], + }); + const { modulePath } = await transpileComponent({ componentPath, name: "node-url" }); + const component = await import(modulePath); + const actual = JSON.parse(component.run()); + // Compare groups separately so a failure identifies the API, not a giant report. + assert.deepEqual(Object.keys(actual), Object.keys(expected)); + for (const key of Object.keys(expected)) { + assert.deepEqual(actual[key], expected[key], key); + } + assert.deepEqual( + JSON.parse(component.run()), + actual, + "repeated calls retain module and constructor identity", + ); + const policy = JSON.parse(component.policy()); + assert.strictEqual(policy.touched, false); + assert.deepEqual( + policy.errors.map((error) => error.code), + [ + ...Array(6).fill("ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API"), + ...Array(2).fill("ERR_JCO_UNSUPPORTED_NODE_API"), + ], + ); + assert.match(policy.missingCwd, /node:url.*wasi:cli\/environment@0\.2\.x/); + }, + 180_000, + ); + // TODO(unskip): publish and depend on jco-std's URL exports; the builtin + // plugin resolves the installed package, not the workspace source directory. + test.skipIf(!hasUrlExport).each(["qjs", "starlingmonkey"])( + "uses the WIT world's WASI cwd through %s", + async (backend) => { + const { componentPath } = await componentizeFixture({ + fixture: "node-url", + entry: "cwd.js", + world: "cwd", + bundle: true, + extraArgs: ["--backend", backend], + }); + const { modulePath } = await transpileComponent({ componentPath, name: "node-url-cwd" }); + const component = await import(modulePath); + for (const path of ["relative", "a/../two words#?%", "", "/absolute"]) { + assert.deepEqual(JSON.parse(component.fromCwd(path)), { + href: pathToFileURL(path).href, + path: resolve(path), + }); + } + }, + 180_000, + ); +}); + +suite("node:url builtin integration", () => { + test("resolves without capabilities and leaves bare/unaudited imports alone", async () => { + const { nodeBuiltinPlugin } = await import("../../src/node-builtins/index.js"); + const plugin = nodeBuiltinPlugin({ imports: [], exports: [] }, { urlFactory: "/test/url.js" }); + const id = plugin.resolveId("node:url"); + assert.ok(id.startsWith("\0jco-node-builtin:")); + assert.ok(plugin.load(id)); + for (const other of ["url", "node:punycode", "node:unrelated", "node:url/unknown"]) { + assert.equal(plugin.resolveId(other), null); + } + assert.equal(plugin.resolveId("./encoding", "/application/whatwg.js"), null); + assert.equal(plugin.resolveId("webidl-conversions", "/application/whatwg.js"), null); + }); +}); + +suite("node:url optional environment selection", () => { + test("defers missing and ambiguous environment errors until cwd is used", async () => { + const { nodeBuiltinPlugin } = await import("../../src/node-builtins/index.js"); + const environment = (patch) => ({ + namespace: "wasi", + package: "cli", + interface: "environment", + version: { major: 0n, minor: 2n, patch }, + }); + // Execute the generated provider wiring with a tiny factory. Behavioral + // component tests above exercise the real implementation and WASI import. + for (const imports of [[], [environment(6n), environment(12n)]]) { + const factory = `data:text/javascript,${encodeURIComponent("export function createUrl(providers) { return { pathToFileURL: providers.initialCwd }; }")}`; + const plugin = nodeBuiltinPlugin({ imports, exports: [] }, { urlFactory: factory }); + const source = plugin.load(plugin.resolveId("node:url")); + const globals = [globalThis.URL, globalThis.URLSearchParams, globalThis.URLPattern]; + try { + const module = await import(`data:text/javascript,${encodeURIComponent(source)}`); + assert.throws( + () => module.pathToFileURL("relative"), + imports.length ? /multiple.*environment/ : /requires.*environment/, + ); + } finally { + [globalThis.URL, globalThis.URLSearchParams, globalThis.URLPattern] = globals; + } + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3202fe21..579707748 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -369,6 +369,9 @@ importers: mkdirp: specifier: ^3 version: 3.0.1 + regexpu-core: + specifier: 6.4.0 + version: 6.4.0 rolldown: specifier: ^1.2.4 version: 1.2.4 @@ -436,9 +439,18 @@ importers: minimatch: specifier: 10.2.6 version: 10.2.6 + punycode: + specifier: 2.3.1 + version: 2.3.1 readable-stream: specifier: 4.7.0 version: 4.7.0 + urlpattern-polyfill: + specifier: 10.1.0 + version: 10.1.0 + whatwg-url: + specifier: 14.2.0 + version: 14.2.0 devDependencies: '@bytecodealliance/componentize-js': specifier: ^0.22.0 @@ -3847,6 +3859,11 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -4259,6 +4276,10 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + puppeteer-core@25.4.0: resolution: {integrity: sha512-K1plkLOdeoUnGeT1OvdqF3qxl33v+Ra/uH5VyPEhXdMcpvGiEskHzxxEU3fgpccJpJLIipB/rPUsvkZRWeKqOA==} engines: {node: '>=22.12.0'} @@ -4275,6 +4296,24 @@ packages: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.2: + resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} + hasBin: true + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -4504,6 +4543,10 @@ packages: resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} engines: {node: '>= 0.4'} + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -4553,6 +4596,22 @@ packages: unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} @@ -4567,6 +4626,9 @@ packages: universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + urlpattern-polyfill@10.1.0: + resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -4701,6 +4763,14 @@ packages: webdriver-bidi-protocol@0.4.2: resolution: {integrity: sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + which-typed-array@1.1.22: resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} engines: {node: '>= 0.4'} @@ -7410,6 +7480,8 @@ snapshots: dependencies: argparse: 2.0.1 + jsesc@3.1.0: {} + json-parse-even-better-errors@2.3.1: {} json-parse-even-better-errors@5.0.0: {} @@ -7834,6 +7906,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + punycode@2.3.1: {} + puppeteer-core@25.4.0(yauzl@2.10.0): dependencies: '@puppeteer/browsers': 3.0.6(yauzl@2.10.0) @@ -7880,6 +7954,27 @@ snapshots: process: 0.11.10 string_decoder: 1.3.0 + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.2 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.2: + dependencies: + jsesc: 3.1.0 + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -8202,6 +8297,10 @@ snapshots: safe-buffer: 5.2.1 typed-array-buffer: 1.0.3 + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + tslib@2.8.1: {} tunnel@0.0.6: {} @@ -8260,6 +8359,17 @@ snapshots: dependencies: pathe: 2.0.3 + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + unicorn-magic@0.1.0: {} unicorn-magic@0.3.0: {} @@ -8268,6 +8378,8 @@ snapshots: universal-user-agent@7.0.3: {} + urlpattern-polyfill@10.1.0: {} + util-deprecate@1.0.2: {} validate-npm-package-name@7.0.2: {} @@ -8355,6 +8467,13 @@ snapshots: webdriver-bidi-protocol@0.4.2: {} + webidl-conversions@7.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + which-typed-array@1.1.22: dependencies: available-typed-arrays: 1.0.7