From cc714ee52a8d2bcceccd77dec13b2144c25c3c43 Mon Sep 17 00:00:00 2001 From: Johann Schopplich Date: Thu, 5 Sep 2024 13:23:39 +0200 Subject: [PATCH 1/5] feat!: replace `ufo` with native `URL` utils --- src/fetch.ts | 2 +- src/path.ts | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 src/path.ts diff --git a/src/fetch.ts b/src/fetch.ts index 3c2b4408..bae99e70 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -1,6 +1,6 @@ import type { Readable } from "node:stream"; import destr from "destr"; -import { withBase, withQuery } from "ufo"; +import { withBase, withQuery } from "./path"; import { createFetchError } from "./error"; import { isPayloadMethod, diff --git a/src/path.ts b/src/path.ts new file mode 100644 index 00000000..676c233a --- /dev/null +++ b/src/path.ts @@ -0,0 +1,108 @@ +/* eslint-disable unicorn/prefer-at */ +export type QueryValue = + | string + | number + | boolean + | QueryValue[] + | Record + | null + | undefined; +export type QueryObject = Record; + +/** + * Removes the leading slash from the given path if it has one. + */ +export function withoutLeadingSlash(path?: string): string { + if (!path || path === "/") { + return "/"; + } + + return path[0] === "/" ? path.slice(1) : path; +} + +/** + * Removes the trailing slash from the given path if it has one. + */ +export function withoutTrailingSlash(path?: string): string { + if (!path || path === "/") { + return "/"; + } + + return path[path.length - 1] === "/" ? path.slice(0, -1) : path; +} + +/** + * Joins the given base URL and path, ensuring that there is only one slash between them. + */ +export function joinURL(base?: string, path?: string): string { + if (!base || base === "/") { + return path || "/"; + } + + if (!path || path === "/") { + return base || "/"; + } + + const baseHasTrailing = base[base.length - 1] === "/"; + const pathHasLeading = path[0] === "/"; + if (baseHasTrailing && pathHasLeading) { + return base + path.slice(1); + } + + if (!baseHasTrailing && !pathHasLeading) { + return `${base}/${path}`; + } + + return base + path; +} + +/** + * Adds the base path to the input path, if it is not already present. + */ +export function withBase(input = "", base = ""): string { + if (!base || base === "/") { + return input; + } + + const _base = withoutTrailingSlash(base); + if (input.startsWith(_base)) { + return input; + } + + return joinURL(_base, input); +} + +/** + * Returns the URL with the given query parameters. If a query parameter is undefined, it is omitted. + */ +export function withQuery(input: string, query: QueryObject): string { + const url = new URL(input, "http://localhost"); + const searchParams = new URLSearchParams(url.search); + + for (const [key, value] of Object.entries(query)) { + if (value === undefined) { + searchParams.delete(key); + } else if (typeof value === "number" || typeof value === "boolean") { + searchParams.set(key, String(value)); + } else if (!value) { + searchParams.set(key, ""); + } else if (Array.isArray(value)) { + for (const item of value) { + searchParams.append(key, String(item)); + } + } else if (typeof value === "object") { + searchParams.set(key, JSON.stringify(value)); + } else { + searchParams.set(key, String(value)); + } + } + + url.search = searchParams.toString(); + let urlWithQuery = url.toString(); + + if (urlWithQuery.startsWith("http://localhost")) { + urlWithQuery = urlWithQuery.slice(16); + } + + return urlWithQuery; +} From 997afbb0885542c7e4aec59882ac2df1fc34c0f2 Mon Sep 17 00:00:00 2001 From: Johann Schopplich Date: Thu, 5 Sep 2024 15:25:18 +0200 Subject: [PATCH 2/5] fix: reduce `URL` usage and fix tests --- src/path.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/path.ts b/src/path.ts index 676c233a..d60f5890 100644 --- a/src/path.ts +++ b/src/path.ts @@ -76,8 +76,15 @@ export function withBase(input = "", base = ""): string { * Returns the URL with the given query parameters. If a query parameter is undefined, it is omitted. */ export function withQuery(input: string, query: QueryObject): string { - const url = new URL(input, "http://localhost"); - const searchParams = new URLSearchParams(url.search); + let url: URL | undefined; + let searchParams: URLSearchParams; + + if (input.includes("?")) { + url = new URL(input, "http://localhost"); + searchParams = new URLSearchParams(url.search); + } else { + searchParams = new URLSearchParams(); + } for (const [key, value] of Object.entries(query)) { if (value === undefined) { @@ -97,12 +104,16 @@ export function withQuery(input: string, query: QueryObject): string { } } - url.search = searchParams.toString(); - let urlWithQuery = url.toString(); + const queryString = searchParams.toString(); - if (urlWithQuery.startsWith("http://localhost")) { - urlWithQuery = urlWithQuery.slice(16); + if (url) { + url.search = queryString; + let urlWithQuery = url.toString(); + if (urlWithQuery.startsWith("http://localhost")) { + urlWithQuery = urlWithQuery.slice(16); + } + return urlWithQuery; } - return urlWithQuery; + return queryString ? `${input}?${queryString}` : input; } From ad22663068b27614902fb82885f295ccd6335d94 Mon Sep 17 00:00:00 2001 From: Johann Schopplich Date: Thu, 5 Sep 2024 15:29:47 +0200 Subject: [PATCH 3/5] refactor: share `http://localhost` string --- src/path.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/path.ts b/src/path.ts index d60f5890..b7ae66c8 100644 --- a/src/path.ts +++ b/src/path.ts @@ -9,6 +9,8 @@ export type QueryValue = | undefined; export type QueryObject = Record; +const DEFAULT_BASE_URL = "http://localhost"; + /** * Removes the leading slash from the given path if it has one. */ @@ -80,7 +82,7 @@ export function withQuery(input: string, query: QueryObject): string { let searchParams: URLSearchParams; if (input.includes("?")) { - url = new URL(input, "http://localhost"); + url = new URL(input, DEFAULT_BASE_URL); searchParams = new URLSearchParams(url.search); } else { searchParams = new URLSearchParams(); @@ -109,7 +111,7 @@ export function withQuery(input: string, query: QueryObject): string { if (url) { url.search = queryString; let urlWithQuery = url.toString(); - if (urlWithQuery.startsWith("http://localhost")) { + if (urlWithQuery.startsWith(DEFAULT_BASE_URL)) { urlWithQuery = urlWithQuery.slice(16); } return urlWithQuery; From d7075bebf2034c9f60ead847b70b4f7254605c30 Mon Sep 17 00:00:00 2001 From: Johann Schopplich Date: Thu, 5 Sep 2024 16:07:05 +0200 Subject: [PATCH 4/5] perf: drop `URL` usage in `withQuery` --- src/path.ts | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/src/path.ts b/src/path.ts index b7ae66c8..f5bef635 100644 --- a/src/path.ts +++ b/src/path.ts @@ -9,8 +9,6 @@ export type QueryValue = | undefined; export type QueryObject = Record; -const DEFAULT_BASE_URL = "http://localhost"; - /** * Removes the leading slash from the given path if it has one. */ @@ -78,16 +76,16 @@ export function withBase(input = "", base = ""): string { * Returns the URL with the given query parameters. If a query parameter is undefined, it is omitted. */ export function withQuery(input: string, query: QueryObject): string { - let url: URL | undefined; - let searchParams: URLSearchParams; - - if (input.includes("?")) { - url = new URL(input, DEFAULT_BASE_URL); - searchParams = new URLSearchParams(url.search); - } else { - searchParams = new URLSearchParams(); + if (!query || Object.keys(query).length === 0) { + return input; } + const searchIndex = input.indexOf("?"); + const base = searchIndex === -1 ? input : input.slice(0, searchIndex); + const searchParams = new URLSearchParams( + searchIndex === -1 ? "" : input.slice(searchIndex + 1) + ); + for (const [key, value] of Object.entries(query)) { if (value === undefined) { searchParams.delete(key); @@ -107,15 +105,5 @@ export function withQuery(input: string, query: QueryObject): string { } const queryString = searchParams.toString(); - - if (url) { - url.search = queryString; - let urlWithQuery = url.toString(); - if (urlWithQuery.startsWith(DEFAULT_BASE_URL)) { - urlWithQuery = urlWithQuery.slice(16); - } - return urlWithQuery; - } - - return queryString ? `${input}?${queryString}` : input; + return queryString ? `${base}?${queryString}` : base; } From 8478387788a85df2b7e416cc7c00cd55ef61ef98 Mon Sep 17 00:00:00 2001 From: Johann Schopplich Date: Thu, 5 Sep 2024 19:28:51 +0200 Subject: [PATCH 5/5] perf: return early if no query params in input URL --- src/path.ts | 50 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/src/path.ts b/src/path.ts index f5bef635..b49cf2a5 100644 --- a/src/path.ts +++ b/src/path.ts @@ -75,35 +75,59 @@ export function withBase(input = "", base = ""): string { /** * Returns the URL with the given query parameters. If a query parameter is undefined, it is omitted. */ -export function withQuery(input: string, query: QueryObject): string { +export function withQuery(input: string, query?: QueryObject): string { if (!query || Object.keys(query).length === 0) { return input; } const searchIndex = input.indexOf("?"); - const base = searchIndex === -1 ? input : input.slice(0, searchIndex); - const searchParams = new URLSearchParams( - searchIndex === -1 ? "" : input.slice(searchIndex + 1) - ); + + if (searchIndex === -1) { + const normalizedQuery = Object.entries(query) + .filter(([, value]) => value !== undefined) + .flatMap(([key, value]) => { + if (Array.isArray(value)) { + return value.map((item) => [key, normalizeQueryValue(item)]); + } + + return [[key, normalizeQueryValue(value)]]; + }); + const searchParams = new URLSearchParams(normalizedQuery); + const queryString = searchParams.toString(); + return queryString ? `${input}?${queryString}` : input; + } + + const searchParams = new URLSearchParams(input.slice(searchIndex + 1)); + const base = input.slice(0, searchIndex); for (const [key, value] of Object.entries(query)) { if (value === undefined) { searchParams.delete(key); - } else if (typeof value === "number" || typeof value === "boolean") { - searchParams.set(key, String(value)); - } else if (!value) { - searchParams.set(key, ""); } else if (Array.isArray(value)) { for (const item of value) { - searchParams.append(key, String(item)); + searchParams.append(key, normalizeQueryValue(item)); } - } else if (typeof value === "object") { - searchParams.set(key, JSON.stringify(value)); } else { - searchParams.set(key, String(value)); + searchParams.set(key, normalizeQueryValue(value)); } } const queryString = searchParams.toString(); return queryString ? `${base}?${queryString}` : base; } + +function normalizeQueryValue(value: QueryValue): string { + if (value === null) { + return ""; + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + if (typeof value === "object") { + return JSON.stringify(value); + } + + return String(value); +}