nuqs
Version:
581 lines (569 loc) • 18.8 kB
JavaScript
import * as React from "react";
//#region src/lib/errors.ts
const errors = {
303: "Multiple adapter contexts detected. This might happen in monorepos.",
404: "nuqs requires an adapter to work with your framework.",
409: "Multiple versions of the library are loaded. This may lead to unexpected behavior. Currently using `%s`, but `%s` (via the %s adapter) was about to load on top.",
414: "Max safe URL length exceeded. Some browsers may not be able to accept this URL. Consider limiting the amount of state stored in the URL.",
422: "Invalid options combination: `limitUrlUpdates: debounce` should be used in SSR scenarios, with `shallow: false`",
429: "URL update rate-limited by the browser. Consider increasing `throttleMs` for key(s) `%s`. %O",
500: "Empty search params cache. Search params can't be accessed in Layouts.",
501: "Search params cache already populated. Have you called `parse` twice?"
};
function error(code) {
return `[nuqs] ${errors[code]}
See https://nuqs.dev/NUQS-${code}`;
}
//#endregion
//#region src/lib/search-params.ts
function isAbsentFromUrl(query) {
return query === null || Array.isArray(query) && query.length === 0;
}
function write(serialized, key, searchParams) {
if (typeof serialized === "string") searchParams.set(key, serialized);
else {
searchParams.delete(key);
for (const v of serialized) searchParams.append(key, v);
if (!searchParams.has(key)) searchParams.set(key, "");
}
return searchParams;
}
//#endregion
//#region src/loader.ts
function createLoader(parsers, { urlKeys = {} } = {}) {
function loadSearchParams(input, { strict = false } = {}) {
if (input instanceof Promise) return input.then((i) => loadSearchParams(i, { strict }));
const searchParams = extractSearchParams(input);
const result = {};
for (const [key, parser] of Object.entries(parsers)) {
const urlKey = urlKeys[key] ?? key;
const query = parser.type === "multi" ? searchParams.getAll(urlKey) : searchParams.get(urlKey);
if (isAbsentFromUrl(query)) {
result[key] = parser.defaultValue ?? null;
continue;
}
let parsedValue;
try {
parsedValue = parser.parse(query);
} catch (error) {
if (strict) throw new Error(`[nuqs] Error while parsing query \`${query}\` for key \`${key}\`: ${error}`);
parsedValue = null;
}
if (strict && query && parsedValue === null) throw new Error(`[nuqs] Failed to parse query \`${query}\` for key \`${key}\` (got null)`);
result[key] = parsedValue ?? parser.defaultValue ?? null;
}
return result;
}
return loadSearchParams;
}
function extractSearchParams(input) {
try {
if (input instanceof Request) return input.url ? new URL(input.url).searchParams : new URLSearchParams();
if (input instanceof URL) return input.searchParams;
if (input instanceof URLSearchParams) return input;
if (typeof input === "object") {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(input)) if (Array.isArray(value)) for (const v of value) searchParams.append(key, v);
else if (value !== void 0) searchParams.set(key, value);
return searchParams;
}
if (typeof input === "string") {
if (URL.hasOwnProperty("canParse") && URL.canParse(input)) return new URL(input).searchParams;
return new URLSearchParams(input);
}
} catch {}
return new URLSearchParams();
}
//#endregion
//#region src/cache.ts
const $input = Symbol("Input");
function createSearchParamsCache(parsers, { urlKeys = {} } = {}) {
const load = createLoader(parsers, { urlKeys });
const getCache = React.cache(() => ({ searchParams: {} }));
function parseSync(searchParams, loaderOptions) {
const c = getCache();
if (Object.isFrozen(c.searchParams)) {
if (c[$input] && compareSearchParams(searchParams, c[$input])) return all();
throw new Error(error(501));
}
c.searchParams = load(searchParams, loaderOptions);
c[$input] = searchParams;
return Object.freeze(c.searchParams);
}
function parse(searchParams, loaderOptions = {}) {
if (searchParams instanceof Promise) return searchParams.then((searchParams) => parseSync(searchParams, loaderOptions));
return parseSync(searchParams, loaderOptions);
}
function all() {
const { searchParams } = getCache();
if (Object.keys(searchParams).length === 0) throw new Error(error(500));
return searchParams;
}
function get(key) {
const { searchParams } = getCache();
const entry = searchParams[key];
if (typeof entry === "undefined") throw new Error(error(500) + `
in get(${String(key)})`);
return entry;
}
return {
parse,
get,
all
};
}
function compareSearchParams(a, b) {
if (a === b) return true;
if (Object.keys(a).length !== Object.keys(b).length) return false;
for (const key in a) if (a[key] !== b[key]) return false;
return true;
}
//#endregion
//#region src/lib/queues/rate-limiting.ts
function getDefaultThrottle() {
if (typeof window === "undefined") return 50;
if (!Boolean(window.GestureEvent)) return 50;
try {
const match = navigator.userAgent?.match(/version\/([\d\.]+) safari/i);
return parseFloat(match[1]) >= 17 ? 120 : 320;
} catch {
return 320;
}
}
function throttle(timeMs) {
return {
method: "throttle",
timeMs
};
}
function debounce(timeMs) {
return {
method: "debounce",
timeMs
};
}
const defaultRateLimit = throttle(getDefaultThrottle());
//#endregion
//#region src/lib/debug.ts
const debugEnabled = isDebugEnabled();
function warn(message, ...args) {
if (!debugEnabled) return;
console.warn(message, ...args);
}
function isDebugEnabled() {
if (typeof window === "undefined") return (process.env.DEBUG || "").includes("nuqs");
try {
const test = "nuqs-localStorage-test";
if (typeof localStorage === "undefined") return false;
localStorage.setItem(test, test);
const isStorageAvailable = localStorage.getItem(test) === test;
localStorage.removeItem(test);
return isStorageAvailable && (localStorage.getItem("debug") || "").includes("nuqs");
} catch {
return false;
}
}
//#endregion
//#region src/lib/safe-parse.ts
function safeParse(parser, value, key) {
try {
return parser(value);
} catch (error) {
warn("[nuqs] Error while parsing value `%s`: %O" + (key ? " (for key `%s`)" : ""), value, error, key);
return null;
}
}
//#endregion
//#region src/parsers.ts
/**
* Wrap a set of parse/serialize functions into a builder pattern parser
* you can pass to one of the hooks, making its default value type safe.
*/
function createParser(parser) {
function parseServerSideNullable(value) {
if (typeof value === "undefined") return null;
let str = "";
if (Array.isArray(value)) {
if (value[0] === void 0) return null;
str = value[0];
}
if (typeof value === "string") str = value;
return safeParse(parser.parse, str);
}
return {
type: "single",
eq: (a, b) => a === b,
...parser,
parseServerSide: parseServerSideNullable,
withDefault(defaultValue) {
return {
...this,
defaultValue,
parseServerSide(value) {
return parseServerSideNullable(value) ?? defaultValue;
}
};
},
withOptions(options) {
return {
...this,
...options
};
}
};
}
function createMultiParser(parser) {
function parseServerSideNullable(value) {
if (typeof value === "undefined") return null;
return safeParse(parser.parse, Array.isArray(value) ? value : [value]);
}
return {
type: "multi",
eq: (a, b) => a === b,
...parser,
parseServerSide: parseServerSideNullable,
withDefault(defaultValue) {
return {
...this,
defaultValue,
parseServerSide(value) {
return parseServerSideNullable(value) ?? defaultValue;
}
};
},
withOptions(options) {
return {
...this,
...options
};
}
};
}
const parseAsString = createParser({
parse: (v) => v,
serialize: String
});
const parseAsInteger = createParser({
parse: (v) => {
const int = parseInt(v);
return int == int ? int : null;
},
serialize: (v) => "" + Math.round(v)
});
const parseAsIndex = createParser({
parse: (v) => {
const int = parseInt(v);
return int == int ? int - 1 : null;
},
serialize: (v) => "" + Math.round(v + 1)
});
const parseAsHex = createParser({
parse: (v) => {
const int = parseInt(v, 16);
return int == int ? int : null;
},
serialize: (v) => {
const hex = Math.round(v).toString(16);
return (hex.length & 1 ? "0" : "") + hex;
}
});
const parseAsFloat = createParser({
parse: (v) => {
const float = parseFloat(v);
return float == float ? float : null;
},
serialize: String
});
const parseAsBoolean = createParser({
parse: (v) => v.toLowerCase() === "true",
serialize: String
});
function compareDates(a, b) {
return a.valueOf() === b.valueOf();
}
/**
* Querystring encoded as the number of milliseconds since epoch,
* and returned as a Date object.
*/
const parseAsTimestamp = createParser({
parse: (v) => {
const ms = parseInt(v);
return ms == ms ? new Date(ms) : null;
},
serialize: (v) => "" + v.valueOf(),
eq: compareDates
});
/**
* Querystring encoded as an ISO-8601 string (UTC),
* and returned as a Date object.
*/
const parseAsIsoDateTime = createParser({
parse: (v) => {
const date = new Date(v);
return date.valueOf() == date.valueOf() ? date : null;
},
serialize: (v) => v.toISOString(),
eq: compareDates
});
/**
* Querystring encoded as an ISO-8601 string (UTC)
* without the time zone offset, and returned as
* a Date object.
*
* The Date is parsed without the time zone offset,
* making it at 00:00:00 UTC.
*/
const parseAsIsoDate = createParser({
parse: (v) => {
const date = new Date(v.slice(0, 10));
return date.valueOf() == date.valueOf() ? date : null;
},
serialize: (v) => v.toISOString().slice(0, 10),
eq: compareDates
});
/**
* String-based enums provide better type-safety for known sets of values.
* You will need to pass the parseAsStringEnum function a list of your enum values
* in order to validate the query string. Anything else will return `null`,
* or your default value if specified.
*
* Example:
* ```ts
* enum Direction {
* up = 'UP',
* down = 'DOWN',
* left = 'LEFT',
* right = 'RIGHT'
* }
*
* const [direction, setDirection] = useQueryState(
* 'direction',
* parseAsStringEnum<Direction>(Object.values(Direction)) // pass a list of allowed values
* .withDefault(Direction.up)
* )
* ```
*
* Note: the query string value will be the value of the enum, not its name
* (example above: `direction=UP`).
*
* @param validValues The values you want to accept
*/
function parseAsStringEnum(validValues) {
return parseAsStringLiteral(validValues);
}
/**
* String-based literals provide better type-safety for known sets of values.
* You will need to pass the parseAsStringLiteral function a list of your string values
* in order to validate the query string. Anything else will return `null`,
* or your default value if specified.
*
* Example:
* ```ts
* const colors = ["red", "green", "blue"] as const
*
* const [color, setColor] = useQueryState(
* 'color',
* parseAsStringLiteral(colors) // pass a readonly list of allowed values
* .withDefault("red")
* )
* ```
*
* @param validValues The values you want to accept
*/
function parseAsStringLiteral(validValues) {
return createParser({
parse: (query) => {
const asConst = query;
return validValues.includes(asConst) ? asConst : null;
},
serialize: String
});
}
/**
* Number-based literals provide better type-safety for known sets of values.
* You will need to pass the parseAsNumberLiteral function a list of your number values
* in order to validate the query string. Anything else will return `null`,
* or your default value if specified.
*
* Example:
* ```ts
* const diceSides = [1, 2, 3, 4, 5, 6] as const
*
* const [side, setSide] = useQueryState(
* 'side',
* parseAsNumberLiteral(diceSides) // pass a readonly list of allowed values
* .withDefault(4)
* )
* ```
*
* @param validValues The values you want to accept
*/
function parseAsNumberLiteral(validValues) {
return createParser({
parse: (query) => {
const asConst = parseFloat(query);
if (validValues.includes(asConst)) return asConst;
return null;
},
serialize: String
});
}
/**
* Encode any object shape into the querystring value as JSON.
* Note: you may want to use `useQueryStates` for finer control over
* multiple related query keys.
*
* @param runtimeParser Runtime parser (eg: Zod schema or Standard Schema) to validate after JSON.parse
*/
function parseAsJson(validator) {
return createParser({
parse: (query) => {
try {
const obj = JSON.parse(query);
if ("~standard" in validator) {
const result = validator["~standard"].validate(obj);
if (result instanceof Promise) throw new Error("[nuqs] Only synchronous Standard Schemas are supported in parseAsJson.");
return result.issues ? null : result.value;
}
return validator(obj);
} catch {
return null;
}
},
serialize: (value) => JSON.stringify(value),
eq(a, b) {
return a === b || JSON.stringify(a) === JSON.stringify(b);
}
});
}
/**
* A comma-separated list of items.
* Items are URI-encoded for safety, so they may not look nice in the URL.
*
* @param itemParser Parser for each individual item in the array
* @param separator The character to use to separate items (default ',')
*/
function parseAsArrayOf(itemParser, separator = ",") {
const itemEq = itemParser.eq ?? ((a, b) => a === b);
const encodedSeparator = encodeURIComponent(separator);
return createParser({
parse: (query) => {
if (query === "") return [];
return query.split(separator).map((item, index) => safeParse(itemParser.parse, item.replaceAll(encodedSeparator, separator), `[${index}]`)).filter((value) => value !== null && value !== void 0);
},
serialize: (values) => values.map((value) => {
return (itemParser.serialize ? itemParser.serialize(value) : String(value)).replaceAll(separator, encodedSeparator);
}).join(separator),
eq(a, b) {
if (a === b) return true;
if (a.length !== b.length) return false;
return a.every((value, index) => itemEq(value, b[index]));
}
});
}
function parseAsNativeArrayOf(itemParser) {
const itemEq = itemParser.eq ?? ((a, b) => a === b);
return createMultiParser({
parse: (query) => {
const parsed = query.map((item, index) => safeParse(itemParser.parse, item, `[${index}]`)).filter((value) => value !== null && value !== void 0);
return parsed.length === 0 ? null : parsed;
},
serialize: (values) => {
return (Array.isArray(values) ? values : [values]).flatMap((value) => {
const serialized = itemParser.serialize?.(value) ?? String(value);
return typeof serialized === "string" ? [serialized] : [...serialized];
});
},
eq(a, b) {
if (a === b) return true;
if (a.length !== b.length) return false;
return a.every((value, index) => itemEq(value, b[index]));
}
}).withDefault([]);
}
//#endregion
//#region src/lib/url-encoding.ts
function renderQueryString(search) {
if (search.size === 0) return "";
const query = [];
for (const [key, value] of search.entries()) {
const safeKey = key.replace(/#/g, "%23").replace(/&/g, "%26").replace(/\+/g, "%2B").replace(/=/g, "%3D").replace(/\?/g, "%3F");
query.push(`${safeKey}=${encodeQueryValue(value)}`);
}
const queryString = "?" + query.join("&");
warnIfURLIsTooLong(queryString);
return queryString;
}
function encodeQueryValue(input) {
return input.replace(/%/g, "%25").replace(/\+/g, "%2B").replace(/ /g, "+").replace(/#/g, "%23").replace(/&/g, "%26").replace(/"/g, "%22").replace(/'/g, "%27").replace(/`/g, "%60").replace(/</g, "%3C").replace(/>/g, "%3E").replace(/[\x00-\x1F]/g, (char) => encodeURIComponent(char));
}
const URL_MAX_LENGTH = 2e3;
function warnIfURLIsTooLong(queryString) {
if (process.env.NODE_ENV === "production") return;
if (typeof location === "undefined") return;
const url = new URL(location.href);
url.search = queryString;
if (url.href.length > URL_MAX_LENGTH) console.warn(error(414));
}
//#endregion
//#region src/serializer.ts
function createSerializer(parsers, { clearOnDefault = true, urlKeys = {}, processUrlSearchParams } = {}) {
function serialize(arg1BaseOrValues, arg2values = {}) {
let [base, search] = isBase(arg1BaseOrValues) ? splitBase(arg1BaseOrValues) : ["", new URLSearchParams()];
const values = isBase(arg1BaseOrValues) ? arg2values : arg1BaseOrValues;
if (values === null) {
for (const key in parsers) {
const urlKey = urlKeys[key] ?? key;
search.delete(urlKey);
}
if (processUrlSearchParams) search = processUrlSearchParams(search);
return base + renderQueryString(search);
}
for (const key in parsers) {
const parser = parsers[key];
const value = values[key];
if (!parser || value === void 0) continue;
const urlKey = urlKeys[key] ?? key;
const isMatchingDefault = parser.defaultValue !== void 0 && value !== null && (parser.eq ?? ((a, b) => a === b))(value, parser.defaultValue);
if (value === null || (parser.clearOnDefault ?? clearOnDefault ?? true) && isMatchingDefault) search.delete(urlKey);
else search = write(parser.serialize(value), urlKey, search);
}
if (processUrlSearchParams) search = processUrlSearchParams(search);
return base + renderQueryString(search);
}
return serialize;
}
function isBase(base) {
return typeof base === "string" || base instanceof URLSearchParams || base instanceof URL;
}
function splitBase(base) {
if (typeof base === "string") {
const [path = "", ...search] = base.split("?");
return [path, new URLSearchParams(search.join("?"))];
} else if (base instanceof URLSearchParams) return ["", new URLSearchParams(base)];
else return [base.origin + base.pathname, new URLSearchParams(base.searchParams)];
}
//#endregion
//#region src/standard-schema.ts
function createStandardSchemaV1(parsers, { urlKeys, partialOutput = false } = {}) {
const serialize = createSerializer(parsers, { urlKeys });
const load = createLoader(parsers, { urlKeys });
return { "~standard": {
version: 1,
vendor: "nuqs",
validate(input) {
try {
const value = load(serialize(input), { strict: true });
if (partialOutput) {
for (const key in value) if (!(key in input)) delete value[key];
}
return { value };
} catch (error) {
return { issues: [{ message: error instanceof Error ? error.message : String(error) }] };
}
}
} };
}
//#endregion
export { createLoader, createMultiParser, createParser, createSearchParamsCache, createSerializer, createStandardSchemaV1, debounce, defaultRateLimit, parseAsArrayOf, parseAsBoolean, parseAsFloat, parseAsHex, parseAsIndex, parseAsInteger, parseAsIsoDate, parseAsIsoDateTime, parseAsJson, parseAsNativeArrayOf, parseAsNumberLiteral, parseAsString, parseAsStringEnum, parseAsStringLiteral, parseAsTimestamp, throttle };
//# sourceMappingURL=server.js.map