From 6453cb75670b6aefbfc712c7d05b1135bee6226c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 17 Sep 2020 19:13:20 +0200 Subject: refactor: Move URL to op_crates/web (#7544) --- Cargo.lock | 3 +- cli/Cargo.toml | 2 +- cli/ops/idna.rs | 40 --- cli/ops/mod.rs | 1 - cli/rt/01_web_util.js | 14 - cli/rt/11_url.js | 877 --------------------------------------------- cli/rt/11_workers.js | 19 - cli/web_worker.rs | 6 +- cli/worker.rs | 6 +- op_crates/web/11_url.js | 897 +++++++++++++++++++++++++++++++++++++++++++++++ op_crates/web/Cargo.toml | 2 + op_crates/web/lib.rs | 35 ++ 12 files changed, 947 insertions(+), 955 deletions(-) delete mode 100644 cli/ops/idna.rs delete mode 100644 cli/rt/11_url.js create mode 100644 op_crates/web/11_url.js diff --git a/Cargo.lock b/Cargo.lock index 4bfdc3e2f..5ffc17e79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -414,7 +414,6 @@ dependencies = [ "futures", "fwdansi", "http", - "idna", "indexmap", "jsonc-parser", "lazy_static", @@ -506,6 +505,8 @@ version = "0.8.0" dependencies = [ "deno_core", "futures", + "idna", + "serde", ] [[package]] diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 26f452ad4..1c19dabdb 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -31,6 +31,7 @@ winapi = "0.3.9" deno_core = { path = "../core", version = "0.57.0" } deno_doc = "0.1.9" deno_lint = { version = "0.2.0", features = ["json"] } +deno_web = { path = "../op_crates/web", version = "0.8.0" } atty = "0.2.14" base64 = "0.12.3" @@ -44,7 +45,6 @@ dprint-plugin-typescript = "0.31.3" futures = "0.3.5" # TODO(ry) Remove and use deno_core::futures filetime = "0.2.12" http = "0.2.1" -idna = "0.2.0" indexmap = "1.6.0" jsonc-parser = "0.14.0" lazy_static = "1.4.0" diff --git a/cli/ops/idna.rs b/cli/ops/idna.rs deleted file mode 100644 index c81d9c314..000000000 --- a/cli/ops/idna.rs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license. - -//! https://url.spec.whatwg.org/#idna - -use deno_core::error::uri_error; -use deno_core::error::AnyError; -use deno_core::ZeroCopyBuf; -use idna::domain_to_ascii; -use idna::domain_to_ascii_strict; -use serde::Deserialize; -use serde_json::Value; - -pub fn init(rt: &mut deno_core::JsRuntime) { - super::reg_json_sync(rt, "op_domain_to_ascii", op_domain_to_ascii); -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct DomainToAscii { - domain: String, - be_strict: bool, -} - -fn op_domain_to_ascii( - _state: &mut deno_core::OpState, - args: Value, - _zero_copy: &mut [ZeroCopyBuf], -) -> Result { - let args: DomainToAscii = serde_json::from_value(args)?; - if args.be_strict { - domain_to_ascii_strict(args.domain.as_str()) - } else { - domain_to_ascii(args.domain.as_str()) - } - .map_err(|err| { - let message = format!("Invalid IDNA encoded domain name: {:?}", err); - uri_error(message) - }) - .map(|domain| json!(domain)) -} diff --git a/cli/ops/mod.rs b/cli/ops/mod.rs index 288e3d0c0..2c8e3472f 100644 --- a/cli/ops/mod.rs +++ b/cli/ops/mod.rs @@ -8,7 +8,6 @@ pub mod errors; pub mod fetch; pub mod fs; pub mod fs_events; -pub mod idna; pub mod io; pub mod net; #[cfg(unix)] diff --git a/cli/rt/01_web_util.js b/cli/rt/01_web_util.js index 596dcbfcd..d64ef28c3 100644 --- a/cli/rt/01_web_util.js +++ b/cli/rt/01_web_util.js @@ -41,19 +41,6 @@ return Object.prototype.hasOwnProperty.call(obj, v); } - /** Returns whether o is iterable. */ - function isIterable( - o, - ) { - // checks for null and undefined - if (o == null) { - return false; - } - return ( - typeof (o)[Symbol.iterator] === "function" - ); - } - const objectCloneMemo = new WeakMap(); function cloneArrayBuffer( @@ -192,7 +179,6 @@ requiredArguments, immutableDefine, hasOwnProperty, - isIterable, cloneValue, defineEnumerableProps, getHeaderValueParams, diff --git a/cli/rt/11_url.js b/cli/rt/11_url.js deleted file mode 100644 index 99f4ba0e1..000000000 --- a/cli/rt/11_url.js +++ /dev/null @@ -1,877 +0,0 @@ -// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license. - -((window) => { - const core = window.Deno.core; - const { isIterable, requiredArguments } = window.__bootstrap.webUtil; - - /** https://url.spec.whatwg.org/#idna */ - function domainToAscii( - domain, - { beStrict = false } = {}, - ) { - return core.jsonOpSync("op_domain_to_ascii", { domain, beStrict }); - } - - function decodeSearchParam(p) { - return decodeURIComponent(p.replace(/\+/g, " ")); - } - - const urls = new WeakMap(); - - class URLSearchParams { - #params = []; - - constructor(init = "") { - if (typeof init === "string") { - this.#handleStringInitialization(init); - return; - } - - if (Array.isArray(init) || isIterable(init)) { - this.#handleArrayInitialization(init); - return; - } - - if (Object(init) !== init) { - return; - } - - if (init instanceof URLSearchParams) { - this.#params = [...init.#params]; - return; - } - - // Overload: record - for (const key of Object.keys(init)) { - this.#append(key, init[key]); - } - - urls.set(this, null); - } - - #handleStringInitialization = (init) => { - // Overload: USVString - // If init is a string and starts with U+003F (?), - // remove the first code point from init. - if (init.charCodeAt(0) === 0x003f) { - init = init.slice(1); - } - - for (const pair of init.split("&")) { - // Empty params are ignored - if (pair.length === 0) { - continue; - } - const position = pair.indexOf("="); - const name = pair.slice(0, position === -1 ? pair.length : position); - const value = pair.slice(name.length + 1); - this.#append(decodeSearchParam(name), decodeSearchParam(value)); - } - }; - - #handleArrayInitialization = ( - init, - ) => { - // Overload: sequence> - for (const tuple of init) { - // If pair does not contain exactly two items, then throw a TypeError. - if (tuple.length !== 2) { - throw new TypeError( - "URLSearchParams.constructor tuple array argument must only contain pair elements", - ); - } - this.#append(tuple[0], tuple[1]); - } - }; - - #updateSteps = () => { - const url = urls.get(this); - if (url == null) { - return; - } - parts.get(url).query = this.toString(); - }; - - #append = (name, value) => { - this.#params.push([String(name), String(value)]); - }; - - append(name, value) { - requiredArguments("URLSearchParams.append", arguments.length, 2); - this.#append(name, value); - this.#updateSteps(); - } - - delete(name) { - requiredArguments("URLSearchParams.delete", arguments.length, 1); - name = String(name); - let i = 0; - while (i < this.#params.length) { - if (this.#params[i][0] === name) { - this.#params.splice(i, 1); - } else { - i++; - } - } - this.#updateSteps(); - } - - getAll(name) { - requiredArguments("URLSearchParams.getAll", arguments.length, 1); - name = String(name); - const values = []; - for (const entry of this.#params) { - if (entry[0] === name) { - values.push(entry[1]); - } - } - - return values; - } - - get(name) { - requiredArguments("URLSearchParams.get", arguments.length, 1); - name = String(name); - for (const entry of this.#params) { - if (entry[0] === name) { - return entry[1]; - } - } - - return null; - } - - has(name) { - requiredArguments("URLSearchParams.has", arguments.length, 1); - name = String(name); - return this.#params.some((entry) => entry[0] === name); - } - - set(name, value) { - requiredArguments("URLSearchParams.set", arguments.length, 2); - - // If there are any name-value pairs whose name is name, in list, - // set the value of the first such name-value pair to value - // and remove the others. - name = String(name); - value = String(value); - let found = false; - let i = 0; - while (i < this.#params.length) { - if (this.#params[i][0] === name) { - if (!found) { - this.#params[i][1] = value; - found = true; - i++; - } else { - this.#params.splice(i, 1); - } - } else { - i++; - } - } - - // Otherwise, append a new name-value pair whose name is name - // and value is value, to list. - if (!found) { - this.#append(name, value); - } - - this.#updateSteps(); - } - - sort() { - this.#params.sort((a, b) => (a[0] === b[0] ? 0 : a[0] > b[0] ? 1 : -1)); - this.#updateSteps(); - } - - forEach( - callbackfn, - thisArg, - ) { - requiredArguments("URLSearchParams.forEach", arguments.length, 1); - - if (typeof thisArg !== "undefined") { - callbackfn = callbackfn.bind(thisArg); - } - - for (const [key, value] of this.#params) { - callbackfn(value, key, this); - } - } - - *keys() { - for (const [key] of this.#params) { - yield key; - } - } - - *values() { - for (const [, value] of this.#params) { - yield value; - } - } - - *entries() { - yield* this.#params; - } - - *[Symbol.iterator]() { - yield* this.#params; - } - - toString() { - return this.#params - .map( - (tuple) => - `${encodeSearchParam(tuple[0])}=${encodeSearchParam(tuple[1])}`, - ) - .join("&"); - } - } - - const searchParamsMethods = [ - "append", - "delete", - "set", - ]; - - const specialSchemes = ["ftp", "file", "http", "https", "ws", "wss"]; - - // https://url.spec.whatwg.org/#special-scheme - const schemePorts = { - ftp: "21", - file: "", - http: "80", - https: "443", - ws: "80", - wss: "443", - }; - const MAX_PORT = 2 ** 16 - 1; - - // Remove the part of the string that matches the pattern and return the - // remainder (RHS) as well as the first captured group of the matched substring - // (LHS). e.g. - // takePattern("https://deno.land:80", /^([a-z]+):[/]{2}/) - // = ["http", "deno.land:80"] - // takePattern("deno.land:80", /^(\[[0-9a-fA-F.:]{2,}\]|[^:]+)/) - // = ["deno.land", "80"] - function takePattern(string, pattern) { - let capture = ""; - const rest = string.replace(pattern, (_, capture_) => { - capture = capture_; - return ""; - }); - return [capture, rest]; - } - - function parse(url, baseParts = null) { - const parts = {}; - let restUrl; - let usedNonBase = false; - [parts.protocol, restUrl] = takePattern( - url.trim(), - /^([A-Za-z][+-.0-9A-Za-z]*):/, - ); - parts.protocol = parts.protocol.toLowerCase(); - if (parts.protocol == "") { - if (baseParts == null) { - return null; - } - parts.protocol = baseParts.protocol; - } else if ( - parts.protocol != baseParts?.protocol || - !specialSchemes.includes(parts.protocol) - ) { - usedNonBase = true; - } - const isSpecial = specialSchemes.includes(parts.protocol); - if (parts.protocol == "file") { - parts.slashes = "//"; - parts.username = ""; - parts.password = ""; - if (usedNonBase || restUrl.match(/^[/\\]{2}/)) { - [parts.hostname, restUrl] = takePattern( - restUrl, - /^[/\\]{2}([^/\\?#]*)/, - ); - usedNonBase = true; - } else { - parts.hostname = baseParts.hostname; - } - parts.port = ""; - } else { - if (usedNonBase || restUrl.match(/^[/\\]{2}/)) { - let restAuthority; - if (isSpecial) { - parts.slashes = "//"; - [restAuthority, restUrl] = takePattern( - restUrl, - /^[/\\]*([^/\\?#]*)/, - ); - } else { - parts.slashes = restUrl.match(/^[/\\]{2}/) ? "//" : ""; - [restAuthority, restUrl] = takePattern( - restUrl, - /^[/\\]{2}([^/\\?#]*)/, - ); - } - let restAuthentication; - [restAuthentication, restAuthority] = takePattern( - restAuthority, - /^(.*)@/, - ); - [parts.username, restAuthentication] = takePattern( - restAuthentication, - /^([^:]*)/, - ); - parts.username = encodeUserinfo(parts.username); - [parts.password] = takePattern(restAuthentication, /^:(.*)/); - parts.password = encodeUserinfo(parts.password); - [parts.hostname, restAuthority] = takePattern( - restAuthority, - /^(\[[0-9a-fA-F.:]{2,}\]|[^:]+)/, - ); - [parts.port] = takePattern(restAuthority, /^:(.*)/); - if (!isValidPort(parts.port)) { - return null; - } - if (parts.hostname == "" && isSpecial) { - return null; - } - usedNonBase = true; - } else { - parts.slashes = baseParts.slashes; - parts.username = baseParts.username; - parts.password = baseParts.password; - parts.hostname = baseParts.hostname; - parts.port = baseParts.port; - } - } - try { - parts.hostname = encodeHostname(parts.hostname, isSpecial); - } catch { - return null; - } - [parts.path, restUrl] = takePattern(restUrl, /^([^?#]*)/); - parts.path = encodePathname(parts.path.replace(/\\/g, "/")); - if (usedNonBase) { - parts.path = normalizePath(parts.path, parts.protocol == "file"); - } else { - if (parts.path != "") { - usedNonBase = true; - } - parts.path = resolvePathFromBase( - parts.path, - baseParts.path || "/", - baseParts.protocol == "file", - ); - } - // Drop the hostname if a drive letter is parsed. - if (parts.protocol == "file" && parts.path.match(/^\/+[A-Za-z]:(\/|$)/)) { - parts.hostname = ""; - } - if (usedNonBase || restUrl.startsWith("?")) { - [parts.query, restUrl] = takePattern(restUrl, /^(\?[^#]*)/); - parts.query = encodeSearch(parts.query, isSpecial); - usedNonBase = true; - } else { - parts.query = baseParts.query; - } - [parts.hash] = takePattern(restUrl, /^(#.*)/); - parts.hash = encodeHash(parts.hash); - return parts; - } - - // Keep it outside of URL to avoid any attempts of access. - const blobURLMap = new Map(); - - // Resolves `.`s and `..`s where possible. - // Preserves repeating and trailing `/`s by design. - // Assumes drive letter file paths will have a leading slash. - function normalizePath(path, isFilePath) { - const isAbsolute = path.startsWith("/"); - path = path.replace(/^\//, ""); - const pathSegments = path.split("/"); - - let driveLetter = null; - if (isFilePath && pathSegments[0].match(/^[A-Za-z]:$/)) { - driveLetter = pathSegments.shift(); - } - - if (isFilePath && isAbsolute) { - while (pathSegments.length > 1 && pathSegments[0] == "") { - pathSegments.shift(); - } - } - - let ensureTrailingSlash = false; - const newPathSegments = []; - for (let i = 0; i < pathSegments.length; i++) { - const previous = newPathSegments[newPathSegments.length - 1]; - if ( - pathSegments[i] == ".." && - previous != ".." && - (previous != undefined || isAbsolute) - ) { - newPathSegments.pop(); - ensureTrailingSlash = true; - } else if (pathSegments[i] == ".") { - ensureTrailingSlash = true; - } else { - newPathSegments.push(pathSegments[i]); - ensureTrailingSlash = false; - } - } - if (driveLetter != null) { - newPathSegments.unshift(driveLetter); - } - if (newPathSegments.length == 0 && !isAbsolute) { - newPathSegments.push("."); - ensureTrailingSlash = false; - } - - let newPath = newPathSegments.join("/"); - if (isAbsolute) { - newPath = `/${newPath}`; - } - if (ensureTrailingSlash) { - newPath = newPath.replace(/\/*$/, "/"); - } - return newPath; - } - - // Standard URL basing logic, applied to paths. - function resolvePathFromBase(path, basePath, isFilePath) { - let basePrefix; - let suffix; - const baseDriveLetter = basePath.match(/^\/+[A-Za-z]:(?=\/|$)/)?.[0]; - if (isFilePath && path.match(/^\/+[A-Za-z]:(\/|$)/)) { - basePrefix = ""; - suffix = path; - } else if (path.startsWith("/")) { - if (isFilePath && baseDriveLetter) { - basePrefix = baseDriveLetter; - suffix = path; - } else { - basePrefix = ""; - suffix = path; - } - } else if (path != "") { - basePath = normalizePath(basePath, isFilePath); - path = normalizePath(path, isFilePath); - // Remove everything after the last `/` in `basePath`. - if (baseDriveLetter && isFilePath) { - basePrefix = `${baseDriveLetter}${ - basePath.slice(baseDriveLetter.length).replace(/[^\/]*$/, "") - }`; - } else { - basePrefix = basePath.replace(/[^\/]*$/, ""); - } - basePrefix = basePrefix.replace(/\/*$/, "/"); - // If `normalizedPath` ends with `.` or `..`, add a trailing slash. - suffix = path.replace(/(?<=(^|\/)(\.|\.\.))$/, "/"); - } else { - basePrefix = basePath; - suffix = ""; - } - return normalizePath(basePrefix + suffix, isFilePath); - } - - function isValidPort(value) { - // https://url.spec.whatwg.org/#port-state - if (value === "") return true; - - const port = Number(value); - return Number.isInteger(port) && port >= 0 && port <= MAX_PORT; - } - - const parts = new WeakMap(); - - class URL { - #searchParams = null; - - [Symbol.for("Deno.customInspect")]() { - const keys = [ - "href", - "origin", - "protocol", - "username", - "password", - "host", - "hostname", - "port", - "pathname", - "hash", - "search", - ]; - const objectString = keys - .map((key) => `${key}: "${this[key] || ""}"`) - .join(", "); - return `URL { ${objectString} }`; - } - - #updateSearchParams = () => { - const searchParams = new URLSearchParams(this.search); - - for (const methodName of searchParamsMethods) { - const method = searchParams[methodName]; - searchParams[methodName] = (...args) => { - method.apply(searchParams, args); - this.search = searchParams.toString(); - }; - } - this.#searchParams = searchParams; - - urls.set(searchParams, this); - }; - - get hash() { - return parts.get(this).hash; - } - - set hash(value) { - value = unescape(String(value)); - if (!value) { - parts.get(this).hash = ""; - } else { - if (value.charAt(0) !== "#") { - value = `#${value}`; - } - // hashes can contain % and # unescaped - parts.get(this).hash = encodeHash(value); - } - } - - get host() { - return `${this.hostname}${this.port ? `:${this.port}` : ""}`; - } - - set host(value) { - value = String(value); - const url = new URL(`http://${value}`); - parts.get(this).hostname = url.hostname; - parts.get(this).port = url.port; - } - - get hostname() { - return parts.get(this).hostname; - } - - set hostname(value) { - value = String(value); - try { - const isSpecial = specialSchemes.includes(parts.get(this).protocol); - parts.get(this).hostname = encodeHostname(value, isSpecial); - } catch {} - } - - get href() { - const authentication = this.username || this.password - ? `${this.username}${this.password ? ":" + this.password : ""}@` - : ""; - const host = this.host; - const slashes = host ? "//" : parts.get(this).slashes; - let pathname = this.pathname; - if (pathname.charAt(0) != "/" && pathname != "" && host != "") { - pathname = `/${pathname}`; - } - return `${this.protocol}${slashes}${authentication}${host}${pathname}${this.search}${this.hash}`; - } - - set href(value) { - value = String(value); - if (value !== this.href) { - const url = new URL(value); - parts.set(this, { ...parts.get(url) }); - this.#updateSearchParams(); - } - } - - get origin() { - if (this.host) { - return `${this.protocol}//${this.host}`; - } - return "null"; - } - - get password() { - return parts.get(this).password; - } - - set password(value) { - value = String(value); - parts.get(this).password = encodeUserinfo(value); - } - - get pathname() { - let path = parts.get(this).path; - if (specialSchemes.includes(parts.get(this).protocol)) { - if (path.charAt(0) != "/") { - path = `/${path}`; - } - } - return path; - } - - set pathname(value) { - parts.get(this).path = encodePathname(String(value)); - } - - get port() { - const port = parts.get(this).port; - if (schemePorts[parts.get(this).protocol] === port) { - return ""; - } - - return port; - } - - set port(value) { - if (!isValidPort(value)) { - return; - } - parts.get(this).port = value.toString(); - } - - get protocol() { - return `${parts.get(this).protocol}:`; - } - - set protocol(value) { - value = String(value); - if (value) { - if (value.charAt(value.length - 1) === ":") { - value = value.slice(0, -1); - } - parts.get(this).protocol = encodeURIComponent(value); - } - } - - get search() { - return parts.get(this).query; - } - - set search(value) { - value = String(value); - const query = value == "" || value.charAt(0) == "?" ? value : `?${value}`; - const isSpecial = specialSchemes.includes(parts.get(this).protocol); - parts.get(this).query = encodeSearch(query, isSpecial); - this.#updateSearchParams(); - } - - get username() { - return parts.get(this).username; - } - - set username(value) { - value = String(value); - parts.get(this).username = encodeUserinfo(value); - } - - get searchParams() { - return this.#searchParams; - } - - constructor(url, base) { - let baseParts = null; - new.target; - if (base) { - baseParts = base instanceof URL ? parts.get(base) : parse(base); - if (baseParts == null) { - throw new TypeError("Invalid base URL."); - } - } - - const urlParts = url instanceof URL - ? parts.get(url) - : parse(url, baseParts); - if (urlParts == null) { - throw new TypeError("Invalid URL."); - } - parts.set(this, urlParts); - - this.#updateSearchParams(); - } - - toString() { - return this.href; - } - - toJSON() { - return this.href; - } - - static createObjectURL() { - throw new Error("Not implemented"); - } - - static revokeObjectURL() { - throw new Error("Not implemented"); - } - } - - function parseIpv4Number(s) { - if (s.match(/^(0[Xx])[0-9A-Za-z]+$/)) { - return Number(s); - } - if (s.match(/^[0-9]+$/)) { - return Number(s.startsWith("0") ? `0o${s}` : s); - } - return NaN; - } - - function parseIpv4(s) { - const parts = s.split("."); - if (parts[parts.length - 1] == "" && parts.length > 1) { - parts.pop(); - } - if (parts.includes("") || parts.length > 4) { - return s; - } - const numbers = parts.map(parseIpv4Number); - if (numbers.includes(NaN)) { - return s; - } - const last = numbers.pop(); - if (last >= 256 ** (4 - numbers.length) || numbers.find((n) => n >= 256)) { - throw new TypeError("Invalid hostname."); - } - const ipv4 = numbers.reduce((sum, n, i) => sum + n * 256 ** (3 - i), last); - const ipv4Hex = ipv4.toString(16).padStart(8, "0"); - const ipv4HexParts = ipv4Hex.match(/(..)(..)(..)(..)$/).slice(1); - return ipv4HexParts.map((s) => String(Number(`0x${s}`))).join("."); - } - - function charInC0ControlSet(c) { - return (c >= "\u0000" && c <= "\u001F") || c > "\u007E"; - } - - function charInSearchSet(c, isSpecial) { - // deno-fmt-ignore - return charInC0ControlSet(c) || ["\u0020", "\u0022", "\u0023", "\u003C", "\u003E"].includes(c) || isSpecial && c == "\u0027" || c > "\u007E"; - } - - function charInFragmentSet(c) { - // deno-fmt-ignore - return charInC0ControlSet(c) || ["\u0020", "\u0022", "\u003C", "\u003E", "\u0060"].includes(c); - } - - function charInPathSet(c) { - // deno-fmt-ignore - return charInFragmentSet(c) || ["\u0023", "\u003F", "\u007B", "\u007D"].includes(c); - } - - function charInUserinfoSet(c) { - // "\u0027" ("'") seemingly isn't in the spec, but matches Chrome and Firefox. - // deno-fmt-ignore - return charInPathSet(c) || ["\u0027", "\u002F", "\u003A", "\u003B", "\u003D", "\u0040", "\u005B", "\u005C", "\u005D", "\u005E", "\u007C"].includes(c); - } - - function charIsForbiddenInHost(c) { - // deno-fmt-ignore - return ["\u0000", "\u0009", "\u000A", "\u000D", "\u0020", "\u0023", "\u0025", "\u002F", "\u003A", "\u003C", "\u003E", "\u003F", "\u0040", "\u005B", "\u005C", "\u005D", "\u005E"].includes(c); - } - - function charInFormUrlencodedSet(c) { - // deno-fmt-ignore - return charInUserinfoSet(c) || ["\u0021", "\u0024", "\u0025", "\u0026", "\u0027", "\u0028", "\u0029", "\u002B", "\u002C", "\u007E"].includes(c); - } - - const encoder = new TextEncoder(); - - function encodeChar(c) { - return [...encoder.encode(c)] - .map((n) => `%${n.toString(16)}`) - .join("") - .toUpperCase(); - } - - function encodeUserinfo(s) { - return [...s].map((c) => (charInUserinfoSet(c) ? encodeChar(c) : c)).join( - "", - ); - } - - function encodeHostname(s, isSpecial = true) { - // IPv6 parsing. - if (s.startsWith("[") && s.endsWith("]")) { - if (!s.match(/^\[[0-9A-Fa-f.:]{2,}\]$/)) { - throw new TypeError("Invalid hostname."); - } - // IPv6 address compress - return s.toLowerCase().replace(/\b:?(?:0+:?){2,}/, "::"); - } - - let result = s; - - if (!isSpecial) { - // Check against forbidden host code points except for "%". - for (const c of result) { - if (charIsForbiddenInHost(c) && c != "\u0025") { - throw new TypeError("Invalid hostname."); - } - } - - // Percent-encode C0 control set. - result = [...result] - .map((c) => (charInC0ControlSet(c) ? encodeChar(c) : c)) - .join(""); - - return result; - } - - // Percent-decode. - if (result.match(/%(?![0-9A-Fa-f]{2})/) != null) { - throw new TypeError("Invalid hostname."); - } - result = result.replace( - /%(.{2})/g, - (_, hex) => String.fromCodePoint(Number(`0x${hex}`)), - ); - - // IDNA domain to ASCII. - result = domainToAscii(result); - - // Check against forbidden host code points. - for (const c of result) { - if (charIsForbiddenInHost(c)) { - throw new TypeError("Invalid hostname."); - } - } - - // IPv4 parsing. - if (isSpecial) { - result = parseIpv4(result); - } - - return result; - } - - function encodePathname(s) { - return [...s].map((c) => (charInPathSet(c) ? encodeChar(c) : c)).join(""); - } - - function encodeSearch(s, isSpecial) { - return [...s].map(( - c, - ) => (charInSearchSet(c, isSpecial) ? encodeChar(c) : c)).join(""); - } - - function encodeHash(s) { - return [...s].map((c) => (charInFragmentSet(c) ? encodeChar(c) : c)).join( - "", - ); - } - - function encodeSearchParam(s) { - return [...s].map((c) => (charInFormUrlencodedSet(c) ? encodeChar(c) : c)) - .join("").replace(/%20/g, "+"); - } - - window.__bootstrap.url = { - URL, - URLSearchParams, - blobURLMap, - }; -})(this); diff --git a/cli/rt/11_workers.js b/cli/rt/11_workers.js index 77ac273f4..0a726a714 100644 --- a/cli/rt/11_workers.js +++ b/cli/rt/11_workers.js @@ -4,9 +4,6 @@ ((window) => { const core = window.Deno.core; const { log } = window.__bootstrap.util; - /* - import { blobURLMap } from "./web/url.ts"; - */ function createWorker( specifier, @@ -68,22 +65,6 @@ const hasSourceCode = false; const sourceCode = decoder.decode(new Uint8Array()); - /* TODO(bartlomieju): - // Handle blob URL. - if (specifier.startsWith("blob:")) { - hasSourceCode = true; - const b = blobURLMap.get(specifier); - if (!b) { - throw new Error("No Blob associated with the given URL is found"); - } - const blobBytes = blobBytesWeakMap.get(b!); - if (!blobBytes) { - throw new Error("Invalid Blob"); - } - sourceCode = blobBytes!; - } - */ - const useDenoNamespace = options ? !!options.deno : false; const { id } = createWorker( diff --git a/cli/web_worker.rs b/cli/web_worker.rs index bad017914..a4d0fe24c 100644 --- a/cli/web_worker.rs +++ b/cli/web_worker.rs @@ -115,7 +115,11 @@ impl WebWorker { let handle = web_worker.thread_safe_handle(); ops::web_worker::init(&mut web_worker.worker, sender, handle); ops::worker_host::init(&mut web_worker.worker); - ops::idna::init(&mut web_worker.worker); + ops::reg_json_sync( + &mut web_worker.worker, + "op_domain_to_ascii", + deno_web::op_domain_to_ascii, + ); ops::io::init(&mut web_worker.worker); ops::reg_json_sync( &mut web_worker.worker, diff --git a/cli/worker.rs b/cli/worker.rs index 6c22ba92b..33e9f1443 100644 --- a/cli/worker.rs +++ b/cli/worker.rs @@ -277,7 +277,11 @@ impl MainWorker { ops::websocket::init(&mut worker); ops::fs::init(&mut worker); ops::fs_events::init(&mut worker); - ops::idna::init(&mut worker); + ops::reg_json_sync( + &mut worker, + "op_domain_to_ascii", + deno_web::op_domain_to_ascii, + ); ops::io::init(&mut worker); ops::plugin::init(&mut worker); ops::net::init(&mut worker); diff --git a/op_crates/web/11_url.js b/op_crates/web/11_url.js new file mode 100644 index 000000000..fb5bece25 --- /dev/null +++ b/op_crates/web/11_url.js @@ -0,0 +1,897 @@ +// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license. + +((window) => { + const core = window.Deno.core; + + function requiredArguments( + name, + length, + required, + ) { + if (length < required) { + const errMsg = `${name} requires at least ${required} argument${ + required === 1 ? "" : "s" + }, but only ${length} present`; + throw new TypeError(errMsg); + } + } + + function isIterable( + o, + ) { + // checks for null and undefined + if (o == null) { + return false; + } + return ( + typeof (o)[Symbol.iterator] === "function" + ); + } + + /** https://url.spec.whatwg.org/#idna */ + function domainToAscii( + domain, + { beStrict = false } = {}, + ) { + return core.jsonOpSync("op_domain_to_ascii", { domain, beStrict }); + } + + function decodeSearchParam(p) { + return decodeURIComponent(p.replace(/\+/g, " ")); + } + + const urls = new WeakMap(); + + class URLSearchParams { + #params = []; + + constructor(init = "") { + if (typeof init === "string") { + this.#handleStringInitialization(init); + return; + } + + if (Array.isArray(init) || isIterable(init)) { + this.#handleArrayInitialization(init); + return; + } + + if (Object(init) !== init) { + return; + } + + if (init instanceof URLSearchParams) { + this.#params = [...init.#params]; + return; + } + + // Overload: record + for (const key of Object.keys(init)) { + this.#append(key, init[key]); + } + + urls.set(this, null); + } + + #handleStringInitialization = (init) => { + // Overload: USVString + // If init is a string and starts with U+003F (?), + // remove the first code point from init. + if (init.charCodeAt(0) === 0x003f) { + init = init.slice(1); + } + + for (const pair of init.split("&")) { + // Empty params are ignored + if (pair.length === 0) { + continue; + } + const position = pair.indexOf("="); + const name = pair.slice(0, position === -1 ? pair.length : position); + const value = pair.slice(name.length + 1); + this.#append(decodeSearchParam(name), decodeSearchParam(value)); + } + }; + + #handleArrayInitialization = ( + init, + ) => { + // Overload: sequence> + for (const tuple of init) { + // If pair does not contain exactly two items, then throw a TypeError. + if (tuple.length !== 2) { + throw new TypeError( + "URLSearchParams.constructor tuple array argument must only contain pair elements", + ); + } + this.#append(tuple[0], tuple[1]); + } + }; + + #updateSteps = () => { + const url = urls.get(this); + if (url == null) { + return; + } + parts.get(url).query = this.toString(); + }; + + #append = (name, value) => { + this.#params.push([String(name), String(value)]); + }; + + append(name, value) { + requiredArguments("URLSearchParams.append", arguments.length, 2); + this.#append(name, value); + this.#updateSteps(); + } + + delete(name) { + requiredArguments("URLSearchParams.delete", arguments.length, 1); + name = String(name); + let i = 0; + while (i < this.#params.length) { + if (this.#params[i][0] === name) { + this.#params.splice(i, 1); + } else { + i++; + } + } + this.#updateSteps(); + } + + getAll(name) { + requiredArguments("URLSearchParams.getAll", arguments.length, 1); + name = String(name); + const values = []; + for (const entry of this.#params) { + if (entry[0] === name) { + values.push(entry[1]); + } + } + + return values; + } + + get(name) { + requiredArguments("URLSearchParams.get", arguments.length, 1); + name = String(name); + for (const entry of this.#params) { + if (entry[0] === name) { + return entry[1]; + } + } + + return null; + } + + has(name) { + requiredArguments("URLSearchParams.has", arguments.length, 1); + name = String(name); + return this.#params.some((entry) => entry[0] === name); + } + + set(name, value) { + requiredArguments("URLSearchParams.set", arguments.length, 2); + + // If there are any name-value pairs whose name is name, in list, + // set the value of the first such name-value pair to value + // and remove the others. + name = String(name); + value = String(value); + let found = false; + let i = 0; + while (i < this.#params.length) { + if (this.#params[i][0] === name) { + if (!found) { + this.#params[i][1] = value; + found = true; + i++; + } else { + this.#params.splice(i, 1); + } + } else { + i++; + } + } + + // Otherwise, append a new name-value pair whose name is name + // and value is value, to list. + if (!found) { + this.#append(name, value); + } + + this.#updateSteps(); + } + + sort() { + this.#params.sort((a, b) => (a[0] === b[0] ? 0 : a[0] > b[0] ? 1 : -1)); + this.#updateSteps(); + } + + forEach( + callbackfn, + thisArg, + ) { + requiredArguments("URLSearchParams.forEach", arguments.length, 1); + + if (typeof thisArg !== "undefined") { + callbackfn = callbackfn.bind(thisArg); + } + + for (const [key, value] of this.#params) { + callbackfn(value, key, this); + } + } + + *keys() { + for (const [key] of this.#params) { + yield key; + } + } + + *values() { + for (const [, value] of this.#params) { + yield value; + } + } + + *entries() { + yield* this.#params; + } + + *[Symbol.iterator]() { + yield* this.#params; + } + + toString() { + return this.#params + .map( + (tuple) => + `${encodeSearchParam(tuple[0])}=${encodeSearchParam(tuple[1])}`, + ) + .join("&"); + } + } + + const searchParamsMethods = [ + "append", + "delete", + "set", + ]; + + const specialSchemes = ["ftp", "file", "http", "https", "ws", "wss"]; + + // https://url.spec.whatwg.org/#special-scheme + const schemePorts = { + ftp: "21", + file: "", + http: "80", + https: "443", + ws: "80", + wss: "443", + }; + const MAX_PORT = 2 ** 16 - 1; + + // Remove the part of the string that matches the pattern and return the + // remainder (RHS) as well as the first captured group of the matched substring + // (LHS). e.g. + // takePattern("https://deno.land:80", /^([a-z]+):[/]{2}/) + // = ["http", "deno.land:80"] + // takePattern("deno.land:80", /^(\[[0-9a-fA-F.:]{2,}\]|[^:]+)/) + // = ["deno.land", "80"] + function takePattern(string, pattern) { + let capture = ""; + const rest = string.replace(pattern, (_, capture_) => { + capture = capture_; + return ""; + }); + return [capture, rest]; + } + + function parse(url, baseParts = null) { + const parts = {}; + let restUrl; + let usedNonBase = false; + [parts.protocol, restUrl] = takePattern( + url.trim(), + /^([A-Za-z][+-.0-9A-Za-z]*):/, + ); + parts.protocol = parts.protocol.toLowerCase(); + if (parts.protocol == "") { + if (baseParts == null) { + return null; + } + parts.protocol = baseParts.protocol; + } else if ( + parts.protocol != baseParts?.protocol || + !specialSchemes.includes(parts.protocol) + ) { + usedNonBase = true; + } + const isSpecial = specialSchemes.includes(parts.protocol); + if (parts.protocol == "file") { + parts.slashes = "//"; + parts.username = ""; + parts.password = ""; + if (usedNonBase || restUrl.match(/^[/\\]{2}/)) { + [parts.hostname, restUrl] = takePattern( + restUrl, + /^[/\\]{2}([^/\\?#]*)/, + ); + usedNonBase = true; + } else { + parts.hostname = baseParts.hostname; + } + parts.port = ""; + } else { + if (usedNonBase || restUrl.match(/^[/\\]{2}/)) { + let restAuthority; + if (isSpecial) { + parts.slashes = "//"; + [restAuthority, restUrl] = takePattern( + restUrl, + /^[/\\]*([^/\\?#]*)/, + ); + } else { + parts.slashes = restUrl.match(/^[/\\]{2}/) ? "//" : ""; + [restAuthority, restUrl] = takePattern( + restUrl, + /^[/\\]{2}([^/\\?#]*)/, + ); + } + let restAuthentication; + [restAuthentication, restAuthority] = takePattern( + restAuthority, + /^(.*)@/, + ); + [parts.username, restAuthentication] = takePattern( + restAuthentication, + /^([^:]*)/, + ); + parts.username = encodeUserinfo(parts.username); + [parts.password] = takePattern(restAuthentication, /^:(.*)/); + parts.password = encodeUserinfo(parts.password); + [parts.hostname, restAuthority] = takePattern( + restAuthority, + /^(\[[0-9a-fA-F.:]{2,}\]|[^:]+)/, + ); + [parts.port] = takePattern(restAuthority, /^:(.*)/); + if (!isValidPort(parts.port)) { + return null; + } + if (parts.hostname == "" && isSpecial) { + return null; + } + usedNonBase = true; + } else { + parts.slashes = baseParts.slashes; + parts.username = baseParts.username; + parts.password = baseParts.password; + parts.hostname = baseParts.hostname; + parts.port = baseParts.port; + } + } + try { + parts.hostname = encodeHostname(parts.hostname, isSpecial); + } catch { + return null; + } + [parts.path, restUrl] = takePattern(restUrl, /^([^?#]*)/); + parts.path = encodePathname(parts.path.replace(/\\/g, "/")); + if (usedNonBase) { + parts.path = normalizePath(parts.path, parts.protocol == "file"); + } else { + if (parts.path != "") { + usedNonBase = true; + } + parts.path = resolvePathFromBase( + parts.path, + baseParts.path || "/", + baseParts.protocol == "file", + ); + } + // Drop the hostname if a drive letter is parsed. + if (parts.protocol == "file" && parts.path.match(/^\/+[A-Za-z]:(\/|$)/)) { + parts.hostname = ""; + } + if (usedNonBase || restUrl.startsWith("?")) { + [parts.query, restUrl] = takePattern(restUrl, /^(\?[^#]*)/); + parts.query = encodeSearch(parts.query, isSpecial); + usedNonBase = true; + } else { + parts.query = baseParts.query; + } + [parts.hash] = takePattern(restUrl, /^(#.*)/); + parts.hash = encodeHash(parts.hash); + return parts; + } + + // Resolves `.`s and `..`s where possible. + // Preserves repeating and trailing `/`s by design. + // Assumes drive letter file paths will have a leading slash. + function normalizePath(path, isFilePath) { + const isAbsolute = path.startsWith("/"); + path = path.replace(/^\//, ""); + const pathSegments = path.split("/"); + + let driveLetter = null; + if (isFilePath && pathSegments[0].match(/^[A-Za-z]:$/)) { + driveLetter = pathSegments.shift(); + } + + if (isFilePath && isAbsolute) { + while (pathSegments.length > 1 && pathSegments[0] == "") { + pathSegments.shift(); + } + } + + let ensureTrailingSlash = false; + const newPathSegments = []; + for (let i = 0; i < pathSegments.length; i++) { + const previous = newPathSegments[newPathSegments.length - 1]; + if ( + pathSegments[i] == ".." && + previous != ".." && + (previous != undefined || isAbsolute) + ) { + newPathSegments.pop(); + ensureTrailingSlash = true; + } else if (pathSegments[i] == ".") { + ensureTrailingSlash = true; + } else { + newPathSegments.push(pathSegments[i]); + ensureTrailingSlash = false; + } + } + if (driveLetter != null) { + newPathSegments.unshift(driveLetter); + } + if (newPathSegments.length == 0 && !isAbsolute) { + newPathSegments.push("."); + ensureTrailingSlash = false; + } + + let newPath = newPathSegments.join("/"); + if (isAbsolute) { + newPath = `/${newPath}`; + } + if (ensureTrailingSlash) { + newPath = newPath.replace(/\/*$/, "/"); + } + return newPath; + } + + // Standard URL basing logic, applied to paths. + function resolvePathFromBase(path, basePath, isFilePath) { + let basePrefix; + let suffix; + const baseDriveLetter = basePath.match(/^\/+[A-Za-z]:(?=\/|$)/)?.[0]; + if (isFilePath && path.match(/^\/+[A-Za-z]:(\/|$)/)) { + basePrefix = ""; + suffix = path; + } else if (path.startsWith("/")) { + if (isFilePath && baseDriveLetter) { + basePrefix = baseDriveLetter; + suffix = path; + } else { + basePrefix = ""; + suffix = path; + } + } else if (path != "") { + basePath = normalizePath(basePath, isFilePath); + path = normalizePath(path, isFilePath); + // Remove everything after the last `/` in `basePath`. + if (baseDriveLetter && isFilePath) { + basePrefix = `${baseDriveLetter}${ + basePath.slice(baseDriveLetter.length).replace(/[^\/]*$/, "") + }`; + } else { + basePrefix = basePath.replace(/[^\/]*$/, ""); + } + basePrefix = basePrefix.replace(/\/*$/, "/"); + // If `normalizedPath` ends with `.` or `..`, add a trailing slash. + suffix = path.replace(/(?<=(^|\/)(\.|\.\.))$/, "/"); + } else { + basePrefix = basePath; + suffix = ""; + } + return normalizePath(basePrefix + suffix, isFilePath); + } + + function isValidPort(value) { + // https://url.spec.whatwg.org/#port-state + if (value === "") return true; + + const port = Number(value); + return Number.isInteger(port) && port >= 0 && port <= MAX_PORT; + } + + const parts = new WeakMap(); + + class URL { + #searchParams = null; + + [Symbol.for("Deno.customInspect")]() { + const keys = [ + "href", + "origin", + "protocol", + "username", + "password", + "host", + "hostname", + "port", + "pathname", + "hash", + "search", + ]; + const objectString = keys + .map((key) => `${key}: "${this[key] || ""}"`) + .join(", "); + return `URL { ${objectString} }`; + } + + #updateSearchParams = () => { + const searchParams = new URLSearchParams(this.search); + + for (const methodName of searchParamsMethods) { + const method = searchParams[methodName]; + searchParams[methodName] = (...args) => { + method.apply(searchParams, args); + this.search = searchParams.toString(); + }; + } + this.#searchParams = searchParams; + + urls.set(searchParams, this); + }; + + get hash() { + return parts.get(this).hash; + } + + set hash(value) { + value = unescape(String(value)); + if (!value) { + parts.get(this).hash = ""; + } else { + if (value.charAt(0) !== "#") { + value = `#${value}`; + } + // hashes can contain % and # unescaped + parts.get(this).hash = encodeHash(value); + } + } + + get host() { + return `${this.hostname}${this.port ? `:${this.port}` : ""}`; + } + + set host(value) { + value = String(value); + const url = new URL(`http://${value}`); + parts.get(this).hostname = url.hostname; + parts.get(this).port = url.port; + } + + get hostname() { + return parts.get(this).hostname; + } + + set hostname(value) { + value = String(value); + try { + const isSpecial = specialSchemes.includes(parts.get(this).protocol); + parts.get(this).hostname = encodeHostname(value, isSpecial); + } catch {} + } + + get href() { + const authentication = this.username || this.password + ? `${this.username}${this.password ? ":" + this.password : ""}@` + : ""; + const host = this.host; + const slashes = host ? "//" : parts.get(this).slashes; + let pathname = this.pathname; + if (pathname.charAt(0) != "/" && pathname != "" && host != "") { + pathname = `/${pathname}`; + } + return `${this.protocol}${slashes}${authentication}${host}${pathname}${this.search}${this.hash}`; + } + + set href(value) { + value = String(value); + if (value !== this.href) { + const url = new URL(value); + parts.set(this, { ...parts.get(url) }); + this.#updateSearchParams(); + } + } + + get origin() { + if (this.host) { + return `${this.protocol}//${this.host}`; + } + return "null"; + } + + get password() { + return parts.get(this).password; + } + + set password(value) { + value = String(value); + parts.get(this).password = encodeUserinfo(value); + } + + get pathname() { + let path = parts.get(this).path; + if (specialSchemes.includes(parts.get(this).protocol)) { + if (path.charAt(0) != "/") { + path = `/${path}`; + } + } + return path; + } + + set pathname(value) { + parts.get(this).path = encodePathname(String(value)); + } + + get port() { + const port = parts.get(this).port; + if (schemePorts[parts.get(this).protocol] === port) { + return ""; + } + + return port; + } + + set port(value) { + if (!isValidPort(value)) { + return; + } + parts.get(this).port = value.toString(); + } + + get protocol() { + return `${parts.get(this).protocol}:`; + } + + set protocol(value) { + value = String(value); + if (value) { + if (value.charAt(value.length - 1) === ":") { + value = value.slice(0, -1); + } + parts.get(this).protocol = encodeURIComponent(value); + } + } + + get search() { + return parts.get(this).query; + } + + set search(value) { + value = String(value); + const query = value == "" || value.charAt(0) == "?" ? value : `?${value}`; + const isSpecial = specialSchemes.includes(parts.get(this).protocol); + parts.get(this).query = encodeSearch(query, isSpecial); + this.#updateSearchParams(); + } + + get username() { + return parts.get(this).username; + } + + set username(value) { + value = String(value); + parts.get(this).username = encodeUserinfo(value); + } + + get searchParams() { + return this.#searchParams; + } + + constructor(url, base) { + let baseParts = null; + new.target; + if (base) { + baseParts = base instanceof URL ? parts.get(base) : parse(base); + if (baseParts == null) { + throw new TypeError("Invalid base URL."); + } + } + + const urlParts = url instanceof URL + ? parts.get(url) + : parse(url, baseParts); + if (urlParts == null) { + throw new TypeError("Invalid URL."); + } + parts.set(this, urlParts); + + this.#updateSearchParams(); + } + + toString() { + return this.href; + } + + toJSON() { + return this.href; + } + + static createObjectURL() { + throw new Error("Not implemented"); + } + + static revokeObjectURL() { + throw new Error("Not implemented"); + } + } + + function parseIpv4Number(s) { + if (s.match(/^(0[Xx])[0-9A-Za-z]+$/)) { + return Number(s); + } + if (s.match(/^[0-9]+$/)) { + return Number(s.startsWith("0") ? `0o${s}` : s); + } + return NaN; + } + + function parseIpv4(s) { + const parts = s.split("."); + if (parts[parts.length - 1] == "" && parts.length > 1) { + parts.pop(); + } + if (parts.includes("") || parts.length > 4) { + return s; + } + const numbers = parts.map(parseIpv4Number); + if (numbers.includes(NaN)) { + return s; + } + const last = numbers.pop(); + if (last >= 256 ** (4 - numbers.length) || numbers.find((n) => n >= 256)) { + throw new TypeError("Invalid hostname."); + } + const ipv4 = numbers.reduce((sum, n, i) => sum + n * 256 ** (3 - i), last); + const ipv4Hex = ipv4.toString(16).padStart(8, "0"); + const ipv4HexParts = ipv4Hex.match(/(..)(..)(..)(..)$/).slice(1); + return ipv4HexParts.map((s) => String(Number(`0x${s}`))).join("."); + } + + function charInC0ControlSet(c) { + return (c >= "\u0000" && c <= "\u001F") || c > "\u007E"; + } + + function charInSearchSet(c, isSpecial) { + // deno-fmt-ignore + return charInC0ControlSet(c) || ["\u0020", "\u0022", "\u0023", "\u003C", "\u003E"].includes(c) || isSpecial && c == "\u0027" || c > "\u007E"; + } + + function charInFragmentSet(c) { + // deno-fmt-ignore + return charInC0ControlSet(c) || ["\u0020", "\u0022", "\u003C", "\u003E", "\u0060"].includes(c); + } + + function charInPathSet(c) { + // deno-fmt-ignore + return charInFragmentSet(c) || ["\u0023", "\u003F", "\u007B", "\u007D"].includes(c); + } + + function charInUserinfoSet(c) { + // "\u0027" ("'") seemingly isn't in the spec, but matches Chrome and Firefox. + // deno-fmt-ignore + return charInPathSet(c) || ["\u0027", "\u002F", "\u003A", "\u003B", "\u003D", "\u0040", "\u005B", "\u005C", "\u005D", "\u005E", "\u007C"].includes(c); + } + + function charIsForbiddenInHost(c) { + // deno-fmt-ignore + return ["\u0000", "\u0009", "\u000A", "\u000D", "\u0020", "\u0023", "\u0025", "\u002F", "\u003A", "\u003C", "\u003E", "\u003F", "\u0040", "\u005B", "\u005C", "\u005D", "\u005E"].includes(c); + } + + function charInFormUrlencodedSet(c) { + // deno-fmt-ignore + return charInUserinfoSet(c) || ["\u0021", "\u0024", "\u0025", "\u0026", "\u0027", "\u0028", "\u0029", "\u002B", "\u002C", "\u007E"].includes(c); + } + + const encoder = new TextEncoder(); + + function encodeChar(c) { + return [...encoder.encode(c)] + .map((n) => `%${n.toString(16)}`) + .join("") + .toUpperCase(); + } + + function encodeUserinfo(s) { + return [...s].map((c) => (charInUserinfoSet(c) ? encodeChar(c) : c)).join( + "", + ); + } + + function encodeHostname(s, isSpecial = true) { + // IPv6 parsing. + if (s.startsWith("[") && s.endsWith("]")) { + if (!s.match(/^\[[0-9A-Fa-f.:]{2,}\]$/)) { + throw new TypeError("Invalid hostname."); + } + // IPv6 address compress + return s.toLowerCase().replace(/\b:?(?:0+:?){2,}/, "::"); + } + + let result = s; + + if (!isSpecial) { + // Check against forbidden host code points except for "%". + for (const c of result) { + if (charIsForbiddenInHost(c) && c != "\u0025") { + throw new TypeError("Invalid hostname."); + } + } + + // Percent-encode C0 control set. + result = [...result] + .map((c) => (charInC0ControlSet(c) ? encodeChar(c) : c)) + .join(""); + + return result; + } + + // Percent-decode. + if (result.match(/%(?![0-9A-Fa-f]{2})/) != null) { + throw new TypeError("Invalid hostname."); + } + result = result.replace( + /%(.{2})/g, + (_, hex) => String.fromCodePoint(Number(`0x${hex}`)), + ); + + // IDNA domain to ASCII. + result = domainToAscii(result); + + // Check against forbidden host code points. + for (const c of result) { + if (charIsForbiddenInHost(c)) { + throw new TypeError("Invalid hostname."); + } + } + + // IPv4 parsing. + if (isSpecial) { + result = parseIpv4(result); + } + + return result; + } + + function encodePathname(s) { + return [...s].map((c) => (charInPathSet(c) ? encodeChar(c) : c)).join(""); + } + + function encodeSearch(s, isSpecial) { + return [...s].map(( + c, + ) => (charInSearchSet(c, isSpecial) ? encodeChar(c) : c)).join(""); + } + + function encodeHash(s) { + return [...s].map((c) => (charInFragmentSet(c) ? encodeChar(c) : c)).join( + "", + ); + } + + function encodeSearchParam(s) { + return [...s].map((c) => (charInFormUrlencodedSet(c) ? encodeChar(c) : c)) + .join("").replace(/%20/g, "+"); + } + + window.__bootstrap.url = { + URL, + URLSearchParams, + }; +})(this); diff --git a/op_crates/web/Cargo.toml b/op_crates/web/Cargo.toml index a7a758150..b5db36db1 100644 --- a/op_crates/web/Cargo.toml +++ b/op_crates/web/Cargo.toml @@ -15,6 +15,8 @@ path = "lib.rs" [dependencies] deno_core = { version = "0.57.0", path = "../../core" } +idna = "0.2.0" +serde = { version = "1.0.116", features = ["derive"] } [dev-dependencies] futures = "0.3.5" diff --git a/op_crates/web/lib.rs b/op_crates/web/lib.rs index 96f269a1d..f6c7137f7 100644 --- a/op_crates/web/lib.rs +++ b/op_crates/web/lib.rs @@ -1,9 +1,43 @@ // Copyright 2018-2020 the Deno authors. All rights reserved. MIT license. +use deno_core::error::uri_error; +use deno_core::error::AnyError; use deno_core::js_check; +use deno_core::serde_json; +use deno_core::serde_json::json; +use deno_core::serde_json::Value; use deno_core::JsRuntime; +use deno_core::ZeroCopyBuf; +use idna::domain_to_ascii; +use idna::domain_to_ascii_strict; +use serde::Deserialize; use std::path::{Path, PathBuf}; +pub fn op_domain_to_ascii( + _state: &mut deno_core::OpState, + args: Value, + _zero_copy: &mut [ZeroCopyBuf], +) -> Result { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct DomainToAscii { + domain: String, + be_strict: bool, + } + + let args: DomainToAscii = serde_json::from_value(args)?; + if args.be_strict { + domain_to_ascii_strict(args.domain.as_str()) + } else { + domain_to_ascii(args.domain.as_str()) + } + .map_err(|err| { + let message = format!("Invalid IDNA encoded domain name: {:?}", err); + uri_error(message) + }) + .map(|domain| json!(domain)) +} + pub fn init(isolate: &mut JsRuntime) { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); let files = vec![ @@ -11,6 +45,7 @@ pub fn init(isolate: &mut JsRuntime) { manifest_dir.join("01_event.js"), manifest_dir.join("02_abort_signal.js"), manifest_dir.join("08_text_encoding.js"), + manifest_dir.join("11_url.js"), ]; // TODO(nayeemrmn): https://github.com/rust-lang/cargo/issues/3946 to get the // workspace root. -- cgit v1.2.3