@metapages/hash-query
Version:
Get/set URL parameters (state) in the hash string instead of the query string.
691 lines (610 loc) • 17.7 kB
text/typescript
/**
* Core logic for getting/setting hash params
* Important note: the internal hash string does NOT have the leading #
*/
import stringify from "fast-json-stable-stringify";
export type SetHashParamOpts = {
modifyHistory?: boolean;
};
export const blobToBase64String = (blob: Record<string, any>) => {
return stringToBase64String(stringify(blob));
};
export const blobFromBase64String = (value: string | undefined) => {
if (value && value.length > 0) {
try {
return JSON.parse(stringFromBase64String(value));
} catch (e: any) {
return JSON.parse(decodeURIComponent(atob(decodeURIComponent(value))));
}
}
return undefined;
};
export const stringToBase64String = (value: string): string => {
return btoa(encodeURIComponent(value));
};
export const stringFromBase64String = (value: string): string => {
try {
return decodeURIComponent(atob(value));
} catch (e: any) {
// swallow error because it might be old format
return decodeURIComponent(atob(decodeURIComponent(value)));
}
};
// Get everything after # then after ?
export const getUrlHashParams = (
url: string | URL
): [string, Record<string, string>] => {
const urlBlob = url instanceof URL ? url : new URL(url);
return getUrlHashParamsFromHashString(urlBlob.hash);
};
export const getUrlHashParamsFromHashString = (
hash: string
): [string, Record<string, string>] => {
let hashString = hash;
while (hashString.startsWith("#")) {
hashString = hashString.substring(1);
}
const queryIndex = hashString.indexOf("?");
if (queryIndex === -1) {
return [hashString, {}];
}
const preHashString = hashString.substring(0, queryIndex);
hashString = hashString.substring(queryIndex + 1);
const hashObject: Record<string, string> = {};
hashString
.split("&")
.filter((s) => s.length > 0)
.map((s) => {
const dividerIndex = s.indexOf("=");
if (dividerIndex === -1) {
return [s, ""];
}
const key = s.substring(0, dividerIndex);
const value = s.substring(dividerIndex + 1);
return [key, value];
})
.forEach(([key, value]) => {
hashObject[key] = value;
});
Object.keys(hashObject).forEach((key) => {
hashObject[key] = hashObject[key];
});
return [preHashString, hashObject];
};
export const getHashParamValue = (
url: string | URL,
key: string
): string | undefined => {
const [_, hashParams] = getUrlHashParams(url);
return hashParams[key];
};
const isBrowser = (): boolean => {
return typeof window !== "undefined" && typeof globalThis.location !== "undefined";
};
export const getHashParamFromWindow = (key: string): string | undefined => {
if (!isBrowser()) {
return undefined;
}
return getHashParamsFromWindow()[1][key];
};
export const getHashParamsFromWindow = (): [string, Record<string, string>] => {
if (!isBrowser()) {
return ["", {}];
}
return getUrlHashParams(globalThis.location.href);
};
export const setHashParamInWindow = (
key: string,
value: string | undefined,
opts?: SetHashParamOpts
) => {
if (!isBrowser()) {
return;
}
const hash = globalThis.location.hash.startsWith("#")
? globalThis.location.hash.substring(1)
: globalThis.location.hash;
const newHash = setHashParamValueInHashString(hash, key, value);
if (newHash === hash) {
return;
}
if (opts?.modifyHistory) {
// adds to browser history, so affects back button
// fires "hashchange" event
globalThis.location.hash = newHash;
} else {
// The following will NOT work to trigger a 'hashchange' event:
// Replace the state so the back button works correctly
globalThis.history.replaceState(
null,
typeof document !== "undefined" ? document.title : "",
`${globalThis.location.pathname}${globalThis.location.search}${
newHash.startsWith("#") ? "" : "#"
}${newHash}`
);
// Manually trigger a hashchange event:
// I don't know how to add the previous and new url parameters
globalThis.dispatchEvent(new HashChangeEvent("hashchange"));
}
};
// returns hash string
export const setHashParamValueInHashString = (
hash: string,
key: string,
value: string | undefined
): string => {
const [preHashParamString, hashObject] = getUrlHashParamsFromHashString(hash);
let changed = false;
if (
(hashObject.hasOwnProperty(key) && value === null) ||
value === undefined
) {
delete hashObject[key];
changed = true;
} else {
if (hashObject[key] !== value) {
hashObject[key] = value;
changed = true;
}
}
// don't do work if unneeded
if (!changed) {
return hash;
}
const keys = Object.keys(hashObject);
keys.sort();
const hashStringNew = keys
.map((key, i) => {
const value = hashObject[key];
// Check if value is already base64-encoded (contains only base64 chars and has proper length)
// This is a simple check to avoid URL-encoding base64 strings
const isBase64 =
/^[A-Za-z0-9+/]+={0,2}$/.test(value) && value.length % 4 === 0;
// Only URL-encode if it's not already base64-encoded
return `${key}=${value}`;
})
.join("&");
// replace after the ? but keep before that
if (!preHashParamString && !hashStringNew) {
return "";
}
return `${preHashParamString || ""}${
hashStringNew ? "?" + hashStringNew : ""
}`;
};
/**
* Efficiently creates a hash string with multiple parameters at once.
* This is more efficient than calling setHashParamValueInHashString repeatedly.
*/
export const createHashParamValuesInHashString = (
hash: string,
params: Record<string, string | undefined>
): string => {
// Efficiently extract prehash string and existing parameters
let hashString = hash;
while (hashString.startsWith("#")) {
hashString = hashString.substring(1);
}
const queryIndex = hashString.indexOf("?");
const preHashString =
queryIndex === -1 ? hashString : hashString.substring(0, queryIndex);
// Parse existing parameters efficiently
const hashObject: Record<string, string> = {};
if (queryIndex !== -1) {
const paramsString = hashString.substring(queryIndex + 1);
if (paramsString.length > 0) {
paramsString.split("&").forEach((s) => {
if (s.length > 0) {
const dividerIndex = s.indexOf("=");
if (dividerIndex === -1) {
hashObject[s] = "";
} else {
const key = s.substring(0, dividerIndex);
const value = s.substring(dividerIndex + 1);
hashObject[key] = value;
}
}
});
}
}
// Apply new parameters
let changed = false;
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) {
if (hashObject.hasOwnProperty(key)) {
delete hashObject[key];
changed = true;
}
} else if (hashObject[key] !== value) {
hashObject[key] = value;
changed = true;
}
}
// don't do work if unneeded
if (!changed) {
return hash;
}
// Build the new hash string efficiently
const keys = Object.keys(hashObject);
keys.sort();
const hashStringNew = keys
.map((key) => {
const value = hashObject[key];
// Check if value is already base64-encoded (contains only base64 chars and has proper length)
const isBase64 =
/^[A-Za-z0-9+/]+={0,2}$/.test(value) && value.length % 4 === 0;
// Only URL-encode if it's not already base64-encoded
return `${key}=${value}`;
})
.join("&");
// Construct final hash string
if (!preHashString && !hashStringNew) {
return "";
}
return `${preHashString || ""}${hashStringNew ? "?" + hashStringNew : ""}`;
};
// returns URL string
export const setHashParamValueInUrl = (
url: string | URL,
key: string,
value: string | undefined
): URL => {
const urlBlob = url instanceof URL ? url : new URL(url);
const newHash = setHashParamValueInHashString(urlBlob.hash, key, value);
urlBlob.hash = newHash;
return urlBlob;
};
/**
* Convenience function to set multiple hash parameters in a URL at once.
* Takes a URL (string or URL object) and a record of hash parameters,
* then returns a URL with those parameters set.
*/
export const setHashParamsInUrl = (
url: string | URL,
params: Record<string, string | undefined>
): URL => {
const urlBlob = url instanceof URL ? url : new URL(url);
let newHash = createHashParamValuesInHashString(urlBlob.hash, params);
urlBlob.hash = newHash;
return urlBlob;
};
/* json */
export const setHashParamValueJsonInUrl = <T>(
url: string | URL,
key: string,
value: T | undefined
): URL => {
const urlBlob = url instanceof URL ? url : new URL(url);
urlBlob.hash = setHashParamValueJsonInHashString(urlBlob.hash, key, value);
return urlBlob;
};
export const getHashParamValueJsonFromUrl = <T>(
url: string | URL,
key: string
): T | undefined => {
const valueString = getHashParamValue(url, key);
if (valueString && valueString !== "") {
const value = blobFromBase64String(valueString);
return value;
}
return;
};
export const getHashParamValueJsonFromHashString = <T>(
hash: string,
key: string
): T | undefined => {
const [_, hashParams] = getUrlHashParamsFromHashString(hash);
const valueString = hashParams[key];
if (valueString && valueString !== "") {
const value = blobFromBase64String(valueString);
return value;
}
return;
};
export const setHashParamValueJsonInWindow = <T>(
key: string,
value: T | undefined,
opts?: SetHashParamOpts
): void => {
const valueString = value ? blobToBase64String(value) : undefined;
setHashParamInWindow(key, valueString, opts);
};
export const getHashParamValueJsonFromWindow = <T>(
key: string
): T | undefined => {
if (!isBrowser()) {
return undefined;
}
return getHashParamValueJsonFromUrl(globalThis.location.href, key);
};
export const setHashParamValueJsonInHashString = <T>(
hash: string,
key: string,
value: T | undefined
) => {
const valueString = value ? blobToBase64String(value) : undefined;
return setHashParamValueInHashString(hash, key, valueString);
};
/* float */
export const setHashParamValueFloatInUrl = (
url: string | URL,
key: string,
value: number | undefined
): URL => {
return setHashParamValueInUrl(url, key, value ? value.toString() : undefined);
};
export const getHashParamValueFloatFromUrl = (
url: string | URL,
key: string
): number | undefined => {
const hashParamString = getHashParamValue(url, key);
return hashParamString ? parseFloat(hashParamString) : undefined;
};
export const setHashParamValueFloatInWindow = (
key: string,
value: number | undefined,
opts?: SetHashParamOpts
): void => {
setHashParamInWindow(
key,
value !== undefined && value !== null ? value.toString() : undefined,
opts
);
};
export const getHashParamValueFloatFromWindow = (
key: string
): number | undefined => {
if (!isBrowser()) {
return undefined;
}
return getHashParamValueFloatFromUrl(globalThis.location.href, key);
};
/* integer */
export const setHashParamValueIntInUrl = (
url: string | URL,
key: string,
value: number | undefined
): URL => {
return setHashParamValueInUrl(
url,
key,
value !== undefined && value !== null ? value.toString() : undefined
);
};
export const getHashParamValueIntFromUrl = (
url: string | URL,
key: string
): number | undefined => {
const hashParamString = getHashParamValue(url, key);
return hashParamString ? parseInt(hashParamString) : undefined;
};
export const setHashParamValueIntInWindow = (
key: string,
value: number | undefined,
opts?: SetHashParamOpts
): void => {
setHashParamValueFloatInWindow(key, value, opts);
};
export const getHashParamValueIntFromWindow = (
key: string
): number | undefined => {
if (!isBrowser()) {
return undefined;
}
return getHashParamValueIntFromUrl(globalThis.location.href, key);
};
/* boolean */
export const setHashParamValueBooleanInUrl = (
url: string | URL,
key: string,
value: boolean | undefined
): URL => {
return setHashParamValueInUrl(url, key, value ? "true" : undefined);
};
export const getHashParamValueBooleanFromUrl = (
url: string | URL,
key: string
): boolean | undefined => {
const hashParamString = getHashParamValue(url, key);
return hashParamString === "true" ? true : false;
};
export const setHashParamValueBooleanInWindow = (
key: string,
value: boolean | undefined,
opts?: SetHashParamOpts
): void => {
setHashParamInWindow(key, value ? "true" : undefined, opts);
};
export const getHashParamValueBooleanFromWindow = (
key: string
): boolean | undefined => {
if (!isBrowser()) {
return undefined;
}
return getHashParamValueBooleanFromUrl(globalThis.location.href, key);
};
/* HashValueBase64 */
export const setHashParamValueBase64EncodedInUrl = (
url: string | URL,
key: string,
value: string | undefined
): URL => {
return setHashParamValueInUrl(
url,
key,
value === null || value === undefined
? undefined
: stringToBase64String(value)
);
};
export const getHashParamValueBase64DecodedFromUrl = (
url: string | URL,
key: string
): string | undefined => {
const valueString = getHashParamValue(url, key);
return valueString && valueString !== ""
? stringFromBase64String(valueString)
: undefined;
};
export const setHashParamValueBase64EncodedInWindow = (
key: string,
value: string | undefined,
opts?: SetHashParamOpts
): void => {
const encodedValue =
value === null || value === undefined
? undefined
: stringToBase64String(value);
setHashParamInWindow(key, encodedValue, opts);
};
export const getHashParamValueBase64DecodedFromWindow = (
key: string
): string | undefined => {
if (!isBrowser()) {
return undefined;
}
return getHashParamValueBase64DecodedFromUrl(globalThis.location.href, key);
};
/* HashValueUriEncoded */
export const setHashParamValueUriEncodedInUrl = (
url: string | URL,
key: string,
value: string | undefined
): URL => {
return setHashParamValueInUrl(
url,
key,
value === null || value === undefined
? undefined
: encodeURIComponent(value)
);
};
export const getHashParamValueUriDecodedFromUrl = (
url: string | URL,
key: string
): string | undefined => {
const valueString = getHashParamValue(url, key);
return valueString && valueString !== ""
? decodeURIComponent(valueString)
: undefined;
};
export const setHashParamValueUriEncodedInWindow = (
key: string,
value: string | undefined,
opts?: SetHashParamOpts
): void => {
const encodedValue =
value === null || value === undefined
? undefined
: encodeURIComponent(value);
setHashParamInWindow(key, encodedValue, opts);
};
export const getHashParamValueUriDecodedFromWindow = (
key: string
): string | undefined => {
if (!isBrowser()) {
return undefined;
}
return getHashParamValueUriDecodedFromUrl(globalThis.location.href, key);
};
type HashParamValueListener<T> = (value: T | undefined) => void;
const addEventListenerHashParam = <T>(
key: string,
getValue: (key: string) => T | undefined,
onHashParamChange: HashParamValueListener<T>
): (() => void) => {
if (!isBrowser()) {
return () => {};
}
let disposed = false;
const onHashChange = (_: HashChangeEvent) => {
if (disposed) {
return;
}
onHashParamChange(getValue(key));
};
globalThis.addEventListener("hashchange", onHashChange);
// Defer initial call so consumers can subscribe and then process consistently.
setTimeout(() => {
if (disposed) {
return;
}
onHashParamChange(getValue(key));
}, 0);
return () => {
if (disposed) {
return;
}
disposed = true;
globalThis.removeEventListener("hashchange", onHashChange);
};
};
export const addEventListenerHashParamBase64 = (
key: string,
onHashParamChange: HashParamValueListener<string>
): (() => void) => {
return addEventListenerHashParam(
key,
getHashParamValueBase64DecodedFromWindow,
onHashParamChange
);
};
export const addEventListenerHashParamBoolean = (
key: string,
onHashParamChange: HashParamValueListener<boolean>
): (() => void) => {
return addEventListenerHashParam(
key,
getHashParamValueBooleanFromWindow,
onHashParamChange
);
};
export const addEventListenerHashParamFloat = (
key: string,
onHashParamChange: HashParamValueListener<number>
): (() => void) => {
return addEventListenerHashParam(
key,
getHashParamValueFloatFromWindow,
onHashParamChange
);
};
export const addEventListenerHashParamInt = (
key: string,
onHashParamChange: HashParamValueListener<number>
): (() => void) => {
return addEventListenerHashParam(
key,
getHashParamValueIntFromWindow,
onHashParamChange
);
};
export const addEventListenerHashParamJson = <T>(
key: string,
onHashParamChange: HashParamValueListener<T>
): (() => void) => {
return addEventListenerHashParam(
key,
getHashParamValueJsonFromWindow,
onHashParamChange
);
};
export const addEventListenerHashParamUriEncoded = (
key: string,
onHashParamChange: HashParamValueListener<string>
): (() => void) => {
return addEventListenerHashParam(
key,
getHashParamValueUriDecodedFromWindow,
onHashParamChange
);
};
export const deleteHashParamFromWindow = (
key: string,
opts?: SetHashParamOpts
): void => {
setHashParamInWindow(key, undefined, opts);
};
export const deleteHashParamFromUrl = (url: string | URL, key: string): URL => {
return setHashParamValueInUrl(url, key, undefined);
};