diff --git a/package.json b/package.json
index 5a855cbd..a850d9bd 100644
--- a/package.json
+++ b/package.json
@@ -72,6 +72,7 @@
"hosted-git-info": "^10.1.1",
"libnpmdiff": "^9.0.2",
"lucide-react": "^0.577.0",
+ "minimatch": "^10.2.6",
"next": "~16.3.5",
"npm-package-arg": "^14.0.0",
"pacote": "^22.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 24dc3709..6705c7fb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -225,6 +225,9 @@ importers:
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.3.0)
+ minimatch:
+ specifier: ^10.2.6
+ version: 10.2.6
next:
specifier: ~16.3.5
version: 16.3.5(@babel/core@7.29.7)(@types/node@24.13.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0)(react@19.3.0)
diff --git a/src/app/[...parts]/_page/NpmDiff/ViewTypeSwitch.tsx b/src/app/[...parts]/_page/NpmDiff/ViewTypeSwitch.tsx
index 7f1e5938..139db229 100644
--- a/src/app/[...parts]/_page/NpmDiff/ViewTypeSwitch.tsx
+++ b/src/app/[...parts]/_page/NpmDiff/ViewTypeSwitch.tsx
@@ -35,37 +35,35 @@ const ViewTypeButton = forwardRef<
...props
},
ref,
- ) => (
-
+ );
+ },
);
ViewTypeButton.displayName = "ViewTypeButton";
diff --git a/src/app/[...parts]/page.tsx b/src/app/[...parts]/page.tsx
index a4b43f78..0474da73 100644
--- a/src/app/[...parts]/page.tsx
+++ b/src/app/[...parts]/page.tsx
@@ -2,16 +2,23 @@ import { type Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { type JSX, Suspense } from "react";
import { type ViewType } from "react-diff-view";
+import Code from "^/components/ui/Code";
import { createSimplePackageSpec } from "^/lib/createSimplePackageSpec";
import { DEFAULT_DIFF_FILES_GLOB } from "^/lib/default-diff-files";
-import destination from "^/lib/destination";
-import { parseQuery, type QueryParams } from "^/lib/query";
+import destination, {
+ type Destination,
+ SpecNotFoundError,
+} from "^/lib/destination";
+import {
+ parseDiffOptions,
+ type QueryParams,
+ toSearchString,
+} from "^/lib/query";
import { simplePackageSpecToString } from "^/lib/SimplePackageSpec";
-import decodeParts from "^/lib/utils/decodeParts";
import { isCatalogPage } from "^/lib/utils/isCatalogPage";
+import parseParts from "^/lib/utils/parseParts";
import specsToDiff from "^/lib/utils/specsToDiff";
-import splitParts from "^/lib/utils/splitParts";
-import validateSpecs from "^/lib/utils/validateSpecs";
+import ErrorBox from "./_error/ErrorBox";
import BundlephobiaDiff from "./_page/BundlephobiaDiff";
import CatalogPage from "./_page/catalog/CatalogPage";
import { generateCatalogMetadata } from "./_page/catalog/generateCatalogMetadata";
@@ -32,9 +39,9 @@ export async function generateMetadata({
params,
}: DiffPageProps): Promise {
const { parts } = await params;
- const specs = splitParts(decodeParts(parts));
+ const specs = parseParts(parts, { decode: true });
- if (!validateSpecs(specs)) {
+ if (specs == null) {
notFound();
}
@@ -55,11 +62,12 @@ const DiffPageInner = async ({
searchParams,
}: DiffPageProps): Promise => {
const { parts } = await params;
- const { diffFiles, ...optionsQuery } = await searchParams;
+ const query = await searchParams;
+ const { diffFiles, ...optionsQuery } = query;
- const specsOrVersions = splitParts(decodeParts(parts));
+ const specsOrVersions = parseParts(parts, { decode: true });
- if (!validateSpecs(specsOrVersions)) {
+ if (specsOrVersions == null) {
notFound();
}
@@ -67,25 +75,38 @@ const DiffPageInner = async ({
return ;
}
- const { redirect: redirectTarget, canonicalSpecs } =
- await destination(specsOrVersions);
+ const parsedOptions = parseDiffOptions({
+ // If no diffFiles is passed, use the default.
+ // This is done here, since we don't want a fall back in the API
+ diffFiles: diffFiles ?? DEFAULT_DIFF_FILES_GLOB,
+ ...optionsQuery,
+ });
- if (redirectTarget !== false) {
- const specsStr = specsToDiff(canonicalSpecs);
- const searchStr = Object.entries(await searchParams)
- .map(([key, value]) => `${key}=${value}`)
- .join("&");
-
- redirect(
- `/${specsStr}` + (searchStr?.length > 0 ? `?${searchStr}` : ""),
+ if (!parsedOptions.ok) {
+ return (
+
+ Invalid options
+ {parsedOptions.message}
+
);
+ }
+
+ let target: Destination;
+ try {
+ target = await destination(specsOrVersions);
+ } catch (e) {
+ if (e instanceof SpecNotFoundError) {
+ notFound();
+ }
+ throw e;
+ }
+
+ const { redirect: redirectTarget, canonicalSpecs } = target;
+
+ if (redirectTarget !== false) {
+ redirect(`/${specsToDiff(canonicalSpecs)}${toSearchString(query)}`);
} else {
- const options = parseQuery({
- // If no diffFiles is passed, use the default.
- // This is done here, since we don't want a fall back in the API
- diffFiles: diffFiles ?? DEFAULT_DIFF_FILES_GLOB,
- ...optionsQuery,
- });
+ const { options } = parsedOptions;
const [a, b] = canonicalSpecs.map((spec) =>
createSimplePackageSpec(spec),
diff --git a/src/app/api/[...parts]/route.ts b/src/app/api/[...parts]/route.ts
index 8bee7e31..8f4b9d0b 100644
--- a/src/app/api/[...parts]/route.ts
+++ b/src/app/api/[...parts]/route.ts
@@ -1,11 +1,13 @@
import { type NextRequest, NextResponse } from "next/server";
-import destination from "^/lib/destination";
+import destination, {
+ type Destination,
+ SpecNotFoundError,
+} from "^/lib/destination";
import npmDiff from "^/lib/npmDiff";
-import { parseQuery } from "^/lib/query";
+import { fromSearchParams, parseDiffOptions } from "^/lib/query";
import { defaultPageCachingHeaders } from "^/lib/utils/headers";
+import parseParts from "^/lib/utils/parseParts";
import specsToDiff from "^/lib/utils/specsToDiff";
-import splitParts from "^/lib/utils/splitParts";
-import validateSpecs from "^/lib/utils/validateSpecs";
export const maxDuration = 60;
@@ -21,19 +23,33 @@ export interface DiffApiContext {
export async function GET(req: NextRequest, { params }: DiffApiContext) {
const { parts } = await params;
const { searchParams } = new URL(req.url);
- const options = Object.fromEntries(searchParams);
- const specsOrVersions = splitParts(parts);
+ const specsOrVersions = parseParts(parts);
- if (!validateSpecs(specsOrVersions)) {
+ if (specsOrVersions == null) {
return NextResponse.json("Invalid package spec", { status: 400 });
}
- const { redirect: red, canonicalSpecs } =
- await destination(specsOrVersions);
+ const parsedOptions = parseDiffOptions(fromSearchParams(searchParams));
+
+ if (!parsedOptions.ok) {
+ return NextResponse.json(parsedOptions.message, { status: 400 });
+ }
+
+ let target: Destination;
+ try {
+ target = await destination(specsOrVersions);
+ } catch (e) {
+ if (e instanceof SpecNotFoundError) {
+ return NextResponse.json(e.message, { status: 404 });
+ }
+ throw e;
+ }
+
+ const { redirect: red, canonicalSpecs } = target;
if (red === false) {
- const result = await npmDiff(canonicalSpecs, parseQuery(options));
+ const result = await npmDiff(canonicalSpecs, parsedOptions.options);
if (result.ok) {
return new NextResponse(result.diff, {
@@ -47,8 +63,8 @@ export async function GET(req: NextRequest, { params }: DiffApiContext) {
} else {
const newUrl = new URL(`/api/${specsToDiff(canonicalSpecs)}`, req.url);
- Array.from(searchParams).forEach(([key, value]) => {
- newUrl.searchParams.set(key, value);
+ searchParams.forEach((value, key) => {
+ newUrl.searchParams.append(key, value);
});
return NextResponse.redirect(
diff --git a/src/app/page.client.tsx b/src/app/page.client.tsx
index 596e0d39..a0c62231 100644
--- a/src/app/page.client.tsx
+++ b/src/app/page.client.tsx
@@ -6,6 +6,7 @@ import { useBoolean } from "react-use";
import Stack from "^/components/ui/Stack";
import { type AutocompleteSuggestion } from "^/lib/autocomplete";
import { DEFAULT_DIFF_FILES_GLOB } from "^/lib/default-diff-files";
+import { toSearchString } from "^/lib/query/searchParams";
import ExamplesList from "./_page/ExamplesList";
import MainForm from "./_page/MainForm/MainForm";
import OptionsForm from "./_page/OptionsForm";
@@ -47,11 +48,7 @@ const IndexPageClient: FunctionComponent = ({
const goToDiff = (a: string | undefined, b: string | undefined): void => {
setLoading(true);
- router.push(
- `/${a}...${b}?${Object.entries(query)
- .map(([k, v]) => `${k}=${v}`)
- .join("&")}`,
- );
+ router.push(`/${a}...${b}${toSearchString(query)}`);
};
return (
diff --git a/src/lib/destination/SpecNotFoundError.ts b/src/lib/destination/SpecNotFoundError.ts
new file mode 100644
index 00000000..df55ab48
--- /dev/null
+++ b/src/lib/destination/SpecNotFoundError.ts
@@ -0,0 +1,6 @@
+export default class SpecNotFoundError extends Error {
+ constructor(spec: string) {
+ super(`Could not find "${spec}"`);
+ this.name = "SpecNotFoundError";
+ }
+}
diff --git a/src/lib/destination/canonicalSpec.test.ts b/src/lib/destination/canonicalSpec.test.ts
index a5891d15..f3728e6a 100644
--- a/src/lib/destination/canonicalSpec.test.ts
+++ b/src/lib/destination/canonicalSpec.test.ts
@@ -1,6 +1,7 @@
import { manifest, resolve } from "pacote";
import type pacote from "pacote";
import canonicalSpec from "./canonicalSpec";
+import SpecNotFoundError from "./SpecNotFoundError";
// Increase the timeout for long running tests
jest.setTimeout(15_000);
@@ -199,4 +200,12 @@ describe("canonicalSpec", () => {
expect(() => t("file:example")).rejects.toThrow(expectedError),
]);
});
+
+ it("Throws SpecNotFoundError for a package missing from the registry", () =>
+ expect(() =>
+ t("npm-diff-app-does-not-exist-9f3a2c@latest"),
+ ).rejects.toThrow(SpecNotFoundError));
+
+ it("Throws SpecNotFoundError for a range with no matching version", () =>
+ expect(() => t("chalk@^999.0.0")).rejects.toThrow(SpecNotFoundError));
});
diff --git a/src/lib/destination/canonicalSpec.ts b/src/lib/destination/canonicalSpec.ts
index 60756164..9e8244d3 100644
--- a/src/lib/destination/canonicalSpec.ts
+++ b/src/lib/destination/canonicalSpec.ts
@@ -1,6 +1,8 @@
import { cacheLife } from "next/cache";
import npa, { type AliasResult } from "npm-package-arg";
import { manifest, resolve } from "pacote";
+import { hasErrorCode } from "^/lib/utils/hasErrorCode";
+import SpecNotFoundError from "./SpecNotFoundError";
const hashFinder = /(?:\#.*)?$/;
@@ -58,14 +60,34 @@ async function handleNpaResult(result: npa.Result): Promise {
* - https://github.com/npm/npm-package-arg#result-object
* - https://docs.npmjs.com/cli/v7/commands/npm-install
*/
-async function canonicalSpec(spec: string): Promise {
+async function cachedCanonicalSpec(spec: string): Promise {
"use cache";
- cacheLife("hours");
+ try {
+ const canonical = await handleNpaResult(npa(spec));
+
+ cacheLife("hours");
+
+ return canonical;
+ } catch (e) {
+ if (hasErrorCode(e, "E404") || hasErrorCode(e, "ETARGET")) {
+ cacheLife("minutes");
+
+ return null;
+ }
+
+ throw e;
+ }
+}
- const result = npa(spec);
+async function canonicalSpec(spec: string): Promise {
+ const canonical = await cachedCanonicalSpec(spec);
+
+ if (canonical == null) {
+ throw new SpecNotFoundError(spec);
+ }
- return handleNpaResult(result);
+ return canonical;
}
export default canonicalSpec;
diff --git a/src/lib/destination/index.ts b/src/lib/destination/index.ts
index b055d745..a8763185 100644
--- a/src/lib/destination/index.ts
+++ b/src/lib/destination/index.ts
@@ -1,5 +1,6 @@
import arrayEquals from "^/lib/utils/arrayEquals";
import canonicalSpec from "./canonicalSpec";
+import SpecNotFoundError from "./SpecNotFoundError";
import versionsToSpecs from "./versionsToSpecs";
export type Redirect = false | "temporary" | "permanent";
@@ -52,3 +53,4 @@ async function destination(
}
export default destination;
+export { SpecNotFoundError };
diff --git a/src/lib/query/index.ts b/src/lib/query/index.ts
index c17aa00f..c74f873d 100644
--- a/src/lib/query/index.ts
+++ b/src/lib/query/index.ts
@@ -1,5 +1,6 @@
-import parseQuery from "./parseQuery";
+import parseDiffOptions from "./parseDiffOptions";
import type QueryParams from "./QueryParams";
+import { fromSearchParams, toSearchString } from "./searchParams";
export type { QueryParams };
-export { parseQuery };
+export { fromSearchParams, parseDiffOptions, toSearchString };
diff --git a/src/lib/query/parseDiffOptions.test.ts b/src/lib/query/parseDiffOptions.test.ts
new file mode 100644
index 00000000..afd26990
--- /dev/null
+++ b/src/lib/query/parseDiffOptions.test.ts
@@ -0,0 +1,165 @@
+import { DEFAULT_DIFF_FILES_GLOB } from "^/lib/default-diff-files";
+import parseDiffOptions from "./parseDiffOptions";
+
+describe("parseDiffOptions", () => {
+ it("accepts valid options", () => {
+ expect(
+ parseDiffOptions({
+ diffFiles: ["{lib,dist}/**", "*.md"],
+ diffUnified: "3",
+ diffSrcPrefix: "a/",
+ diffDstPrefix: "b/",
+ }),
+ ).toStrictEqual({
+ ok: true,
+ options: {
+ diffFiles: ["{lib,dist}/**", "*.md"],
+ diffUnified: 3,
+ diffSrcPrefix: "a/",
+ diffDstPrefix: "b/",
+ },
+ });
+ });
+
+ it("accepts empty options", () => {
+ expect(parseDiffOptions({})).toStrictEqual({ ok: true, options: {} });
+ });
+
+ it("accepts the default diffFiles glob", () => {
+ expect(
+ parseDiffOptions({ diffFiles: DEFAULT_DIFF_FILES_GLOB }).ok,
+ ).toBe(true);
+ });
+
+ describe("diffFiles", () => {
+ it("rejects an exponential brace pattern quickly", () => {
+ const start = Date.now();
+ const result = parseDiffOptions({
+ diffFiles: "{a,b}".repeat(17),
+ });
+
+ expect(result.ok).toBe(false);
+ expect(Date.now() - start).toBeLessThan(500);
+ });
+
+ it("rejects a large numeric brace range", () => {
+ expect(parseDiffOptions({ diffFiles: "{1..100000}" }).ok).toBe(
+ false,
+ );
+ });
+
+ it("accepts a pattern expanding to exactly 16 alternatives", () => {
+ expect(parseDiffOptions({ diffFiles: "{a,b}".repeat(4) }).ok).toBe(
+ true,
+ );
+ });
+
+ it("rejects a pattern expanding to 32 alternatives", () => {
+ expect(parseDiffOptions({ diffFiles: "{a,b}".repeat(5) }).ok).toBe(
+ false,
+ );
+ });
+
+ it("counts alternatives across all patterns", () => {
+ expect(
+ parseDiffOptions({
+ diffFiles: ["{a,b}".repeat(3), "{a,b}".repeat(3)],
+ }).ok,
+ ).toBe(true);
+ expect(
+ parseDiffOptions({
+ diffFiles: ["{a,b}".repeat(3), "{a,b}".repeat(3), "c"],
+ }).ok,
+ ).toBe(false);
+ });
+
+ it("expands braces escaped with a backslash, like libnpmdiff", () => {
+ expect(
+ parseDiffOptions({ diffFiles: "\\{a,b}".repeat(17) }).ok,
+ ).toBe(false);
+ });
+
+ it.each([
+ "*(?|?)Z",
+ "+(a|b)",
+ "!(*.map)",
+ "@(a|b)",
+ "?(a)",
+ "\\*(a|b)",
+ "{a,*(?|?)}",
+ ])("rejects extglob %p", (pattern) => {
+ expect(parseDiffOptions({ diffFiles: pattern }).ok).toBe(false);
+ });
+
+ it.each(["*.js", "**/*.d.ts", "*a*", "**/lib/**/*-*", "{*a*,*b*}"])(
+ "accepts %p",
+ (pattern) => {
+ expect(parseDiffOptions({ diffFiles: pattern }).ok).toBe(true);
+ },
+ );
+
+ it.each(["*?*?*?*?Z", "*a*b*c", "a**b*c*"])(
+ "rejects %p with more than two *",
+ (pattern) => {
+ expect(parseDiffOptions({ diffFiles: pattern }).ok).toBe(false);
+ },
+ );
+
+ it("accepts 10 patterns and rejects 11", () => {
+ expect(
+ parseDiffOptions({ diffFiles: Array(10).fill("*.js") }).ok,
+ ).toBe(true);
+ expect(
+ parseDiffOptions({ diffFiles: Array(11).fill("*.js") }).ok,
+ ).toBe(false);
+ });
+
+ it("accepts 256 character patterns and rejects longer", () => {
+ expect(parseDiffOptions({ diffFiles: "a".repeat(256) }).ok).toBe(
+ true,
+ );
+ expect(parseDiffOptions({ diffFiles: "a".repeat(257) }).ok).toBe(
+ false,
+ );
+ });
+ });
+
+ describe("diffUnified", () => {
+ it.each(["0", "3", "100"])("accepts %p", (value) => {
+ expect(parseDiffOptions({ diffUnified: value })).toStrictEqual({
+ ok: true,
+ options: { diffUnified: Number(value) },
+ });
+ });
+
+ it.each(["-5", "101", "abc", "1.5", "3abc"])("rejects %p", (value) => {
+ expect(parseDiffOptions({ diffUnified: value }).ok).toBe(false);
+ });
+ });
+
+ describe("prefixes", () => {
+ it("accepts 64 characters and rejects longer", () => {
+ expect(parseDiffOptions({ diffSrcPrefix: "a".repeat(64) }).ok).toBe(
+ true,
+ );
+ expect(parseDiffOptions({ diffSrcPrefix: "a".repeat(65) }).ok).toBe(
+ false,
+ );
+ expect(parseDiffOptions({ diffDstPrefix: "a".repeat(65) }).ok).toBe(
+ false,
+ );
+ });
+
+ it.each(["a\n+++ b/fake", "a\r", "a\t", "a\x7f"])(
+ "rejects control characters in %p",
+ (value) => {
+ expect(parseDiffOptions({ diffSrcPrefix: value }).ok).toBe(
+ false,
+ );
+ expect(parseDiffOptions({ diffDstPrefix: value }).ok).toBe(
+ false,
+ );
+ },
+ );
+ });
+});
diff --git a/src/lib/query/parseDiffOptions.ts b/src/lib/query/parseDiffOptions.ts
new file mode 100644
index 00000000..94b84e12
--- /dev/null
+++ b/src/lib/query/parseDiffOptions.ts
@@ -0,0 +1,122 @@
+import { braceExpand } from "minimatch";
+import { DEFAULT_DIFF_FILES_GLOB } from "^/lib/default-diff-files";
+import { type NpmDiffOptions } from "^/lib/npmDiff";
+import parseQuery from "./parseQuery";
+import type QueryParams from "./QueryParams";
+
+const MAX_DIFF_FILES = 10;
+const MAX_DIFF_FILES_PATTERN_LENGTH = 256;
+const MAX_DIFF_FILES_BRACE_EXPANSION = 16;
+const MAX_DIFF_FILES_STARS = 2;
+const MAX_DIFF_UNIFIED = 100;
+const MAX_DIFF_PREFIX_LENGTH = 64;
+
+// Same normalization libnpmdiff applies before matching, so `\{a,b}`
+// can't slip past the checks as a literal brace.
+function normalizeMatch(pattern: string): string {
+ return pattern.replace(/\\+/g, "/").replace(/^\.\/|^\./, "");
+}
+
+/**
+ * minimatch compiles globs to backtracking regexes. Extglobs and more than two
+ * `*` in a pattern can take seconds to match a single long file name, and
+ * libnpmdiff matches every pattern against every file in both tarballs.
+ * `**` path segments are not counted, they match whole segments.
+ */
+function isSlowGlob(pattern: string): boolean {
+ if (/[!@?*+]\(/.test(pattern)) {
+ return true;
+ }
+ const stars = pattern
+ .split("/")
+ .filter((segment) => segment !== "**")
+ .join("/")
+ .match(/\*+/g);
+ return (stars?.length ?? 0) > MAX_DIFF_FILES_STARS;
+}
+
+function validateDiffFiles(diffFiles: string[]): string | undefined {
+ if (diffFiles.length > MAX_DIFF_FILES) {
+ return `Too many diffFiles patterns, at most ${MAX_DIFF_FILES} are allowed.`;
+ }
+
+ let alternatives = 0;
+ for (const pattern of diffFiles) {
+ if (pattern.length > MAX_DIFF_FILES_PATTERN_LENGTH) {
+ return `diffFiles patterns can be at most ${MAX_DIFF_FILES_PATTERN_LENGTH} characters long.`;
+ }
+ if (pattern === DEFAULT_DIFF_FILES_GLOB) {
+ alternatives++;
+ continue;
+ }
+
+ const expanded = braceExpand(normalizeMatch(pattern), {
+ braceExpandMax: MAX_DIFF_FILES_BRACE_EXPANSION + 1,
+ });
+ alternatives += expanded.length;
+ if (alternatives > MAX_DIFF_FILES_BRACE_EXPANSION) {
+ return `diffFiles patterns can expand to at most ${MAX_DIFF_FILES_BRACE_EXPANSION} alternatives in total.`;
+ }
+ if (expanded.some(isSlowGlob)) {
+ return `diffFiles patterns can not use extglobs like !(...) and can have at most ${MAX_DIFF_FILES_STARS} * outside of ** segments.`;
+ }
+ }
+
+ return undefined;
+}
+
+function validatePrefix(
+ name: string,
+ prefix: string | undefined,
+): string | undefined {
+ if (prefix == null) {
+ return undefined;
+ }
+ if (prefix.length > MAX_DIFF_PREFIX_LENGTH) {
+ return `${name} can be at most ${MAX_DIFF_PREFIX_LENGTH} characters long.`;
+ }
+ // Prefixes are written verbatim into the ---/+++ header lines
+ if (/[\x00-\x1f\x7f]/.test(prefix)) {
+ return `${name} can not contain control characters.`;
+ }
+ return undefined;
+}
+
+function validateOptions({
+ diffFiles,
+ diffUnified,
+ diffSrcPrefix,
+ diffDstPrefix,
+}: NpmDiffOptions): string | undefined {
+ if (diffFiles != null) {
+ const diffFilesError = validateDiffFiles(diffFiles);
+ if (diffFilesError) {
+ return diffFilesError;
+ }
+ }
+
+ if (
+ diffUnified != null &&
+ !(
+ Number.isInteger(diffUnified) &&
+ diffUnified >= 0 &&
+ diffUnified <= MAX_DIFF_UNIFIED
+ )
+ ) {
+ return `diffUnified must be an integer between 0 and ${MAX_DIFF_UNIFIED}.`;
+ }
+
+ return (
+ validatePrefix("diffSrcPrefix", diffSrcPrefix) ??
+ validatePrefix("diffDstPrefix", diffDstPrefix)
+ );
+}
+
+export default function parseDiffOptions(
+ query: QueryParams,
+): { ok: true; options: NpmDiffOptions } | { ok: false; message: string } {
+ const options = parseQuery(query);
+ const message = validateOptions(options);
+
+ return message == null ? { ok: true, options } : { ok: false, message };
+}
diff --git a/src/lib/query/parseQuery/parseNumber.test.ts b/src/lib/query/parseQuery/parseNumber.test.ts
index 76430a98..818ee425 100644
--- a/src/lib/query/parseQuery/parseNumber.test.ts
+++ b/src/lib/query/parseQuery/parseNumber.test.ts
@@ -8,7 +8,10 @@ describe("query/parseNumber", () => {
it(`input is "1"`, () => expect(parseNumber("1")).toBe(1));
it(`input is "999"`, () => expect(parseNumber("999")).toBe(999));
- it(`input is "123XXX"`, () => expect(parseNumber("123XXX")).toBe(123));
+ it(`input is "-5"`, () => expect(parseNumber("-5")).toBe(-5));
+ it(`input is "123XXX"`, () => expect(parseNumber("123XXX")).toBeNaN());
+ it(`input is "1.5"`, () => expect(parseNumber("1.5")).toBeNaN());
+ it(`input is "abc"`, () => expect(parseNumber("abc")).toBeNaN());
it(`input is [1 ,2 ,3]`, () =>
expect(parseNumber(["1", "2", "3"])).toBe(3));
diff --git a/src/lib/query/parseQuery/parseNumber.ts b/src/lib/query/parseQuery/parseNumber.ts
index bfcae9a8..7c5813f0 100644
--- a/src/lib/query/parseQuery/parseNumber.ts
+++ b/src/lib/query/parseQuery/parseNumber.ts
@@ -7,7 +7,7 @@ function parseNumber(str: undefined | string | string[]): undefined | number {
return undefined;
}
- return parseInt(str);
+ return /^-?\d+$/.test(str) ? Number(str) : NaN;
}
export default parseNumber;
diff --git a/src/lib/query/searchParams.test.ts b/src/lib/query/searchParams.test.ts
new file mode 100644
index 00000000..1a4ad7d0
--- /dev/null
+++ b/src/lib/query/searchParams.test.ts
@@ -0,0 +1,52 @@
+import { fromSearchParams, toSearchString } from "./searchParams";
+
+describe("toSearchString", () => {
+ it("returns an empty string for no params", () => {
+ expect(toSearchString({})).toBe("");
+ expect(toSearchString({ diffFiles: undefined })).toBe("");
+ });
+
+ it("encodes values", () => {
+ const search = toSearchString({ diffFiles: "a&diffNameOnly=true#x" });
+
+ expect(search).toBe("?diffFiles=a%26diffNameOnly%3Dtrue%23x");
+ expect(Array.from(new URLSearchParams(search.slice(1)))).toStrictEqual([
+ ["diffFiles", "a&diffNameOnly=true#x"],
+ ]);
+ });
+
+ it("appends each value of arrays", () => {
+ expect(toSearchString({ diffFiles: ["a,b", "c"], view: "split" })).toBe(
+ "?diffFiles=a%2Cb&diffFiles=c&view=split",
+ );
+ });
+});
+
+describe("fromSearchParams", () => {
+ it("keeps single values as strings and repeated values as arrays", () => {
+ expect(
+ fromSearchParams(
+ new URLSearchParams("diffFiles=a&diffFiles=b&diffUnified=3"),
+ ),
+ ).toStrictEqual({ diffFiles: ["a", "b"], diffUnified: "3" });
+ });
+
+ it("round-trips with toSearchString", () => {
+ const input = new URLSearchParams(
+ "diffFiles=a%26b&diffFiles=c&diffText=",
+ );
+
+ expect(toSearchString(fromSearchParams(input))).toBe(`?${input}`);
+ });
+
+ it("treats __proto__ as a normal key", () => {
+ const query = fromSearchParams(
+ new URLSearchParams("__proto__=a&__proto__=b"),
+ );
+
+ expect(Object.getPrototypeOf(query)).toBe(Object.prototype);
+ expect(
+ Object.getOwnPropertyDescriptor(query, "__proto__")?.value,
+ ).toEqual(["a", "b"]);
+ });
+});
diff --git a/src/lib/query/searchParams.ts b/src/lib/query/searchParams.ts
new file mode 100644
index 00000000..745367d6
--- /dev/null
+++ b/src/lib/query/searchParams.ts
@@ -0,0 +1,34 @@
+type SearchParamValue = string | string[] | undefined;
+
+type SearchParamsRecord = { [K in keyof T]: SearchParamValue };
+
+export function toSearchString>(
+ query: T,
+): string {
+ const searchParams = new URLSearchParams();
+
+ for (const [key, value] of Object.entries(query)) {
+ for (const item of Array.isArray(value) ? value : [value]) {
+ if (item != null) {
+ searchParams.append(key, item);
+ }
+ }
+ }
+
+ const search = searchParams.toString();
+
+ return search.length > 0 ? `?${search}` : "";
+}
+
+export function fromSearchParams(
+ searchParams: URLSearchParams,
+): Record {
+ // Object.fromEntries defines own properties, so a `__proto__` key can't
+ // replace the prototype.
+ return Object.fromEntries(
+ [...new Set(searchParams.keys())].map((key) => {
+ const values = searchParams.getAll(key);
+ return [key, values.length === 1 ? values[0] : values];
+ }),
+ );
+}
diff --git a/src/lib/utils/parseParts.test.ts b/src/lib/utils/parseParts.test.ts
new file mode 100644
index 00000000..2912cccf
--- /dev/null
+++ b/src/lib/utils/parseParts.test.ts
@@ -0,0 +1,58 @@
+import parseParts from "./parseParts";
+
+describe("parseParts", () => {
+ it("parses two specs", () => {
+ expect(parseParts("semver@7.6.0...semver@7.6.3")).toEqual([
+ "semver@7.6.0",
+ "semver@7.6.3",
+ ]);
+ });
+
+ it("parses scoped specs from segments", () => {
+ expect(parseParts(["@types", "react@17...@types", "react@18"])).toEqual(
+ ["@types/react@17", "@types/react@18"],
+ );
+ });
+
+ it("parses a spec and a version", () => {
+ expect(parseParts("semver@7.6.0...7.6.3")).toEqual([
+ "semver@7.6.0",
+ "7.6.3",
+ ]);
+ });
+
+ it("parses a single package name", () => {
+ expect(parseParts("semver")).toEqual(["semver"]);
+ });
+
+ it.each(["x", "1", "1.0.0"])(
+ "parses package name %p that is also a semver range",
+ (name) => {
+ expect(parseParts(name)).toEqual([name]);
+ },
+ );
+
+ it("decodes parts when asked", () => {
+ expect(
+ parseParts("semver%407.6.0...semver%407.6.3", { decode: true }),
+ ).toEqual(["semver@7.6.0", "semver@7.6.3"]);
+ });
+
+ it.each([
+ ["too many separators", "a...b...c"],
+ ["only a version range", "^1.0.0"],
+ ["only versions", "1.0.0...2.0.0"],
+ ["invalid package name", "NOT VALID@1"],
+ ["unsupported spec type", "file:../foo...semver@1"],
+ ])("returns null for %s", (_, parts) => {
+ expect(parseParts(parts)).toBeNull();
+ });
+
+ it("returns null for malformed percent-encoding", () => {
+ expect(parseParts("%E0%A4%A", { decode: true })).toBeNull();
+ });
+
+ it("returns null for too many segments", () => {
+ expect(parseParts(["a", "b", "c", "d"])).toBeNull();
+ });
+});
diff --git a/src/lib/utils/parseParts.ts b/src/lib/utils/parseParts.ts
new file mode 100644
index 00000000..50b67932
--- /dev/null
+++ b/src/lib/utils/parseParts.ts
@@ -0,0 +1,28 @@
+import versionsToSpecs from "^/lib/destination/versionsToSpecs";
+import decodeParts from "./decodeParts";
+import { isCatalogPage } from "./isCatalogPage";
+import splitParts from "./splitParts";
+import validateSpecs from "./validateSpecs";
+
+export default function parseParts(
+ parts: string | string[] | undefined,
+ { decode = false }: { decode?: boolean } = {},
+): [string] | [string, string] | null {
+ try {
+ const specsOrVersions = splitParts(decode ? decodeParts(parts) : parts);
+
+ if (!validateSpecs(specsOrVersions)) {
+ return null;
+ }
+
+ // Bare names like `x` or `1` are also semver ranges, which
+ // versionsToSpecs rejects, but they are valid catalog pages.
+ if (!isCatalogPage(specsOrVersions)) {
+ versionsToSpecs(specsOrVersions);
+ }
+
+ return specsOrVersions;
+ } catch {
+ return null;
+ }
+}