Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,14 +206,23 @@ All functions are exported from `@metapages/hash-query`. Naming is systematic:
- `...FromWindow` / `...InWindow` — read/write `window.location` directly.
- `...FromUrl` / `...InUrl` — operate on a URL string or `URL` object, returning
a new URL. Nothing is mutated in the browser.
- `...InHashString` — operate on a bare hash string, for full manual control.
- `...InHashString` / `...FromHashString` — operate on a bare hash string, for
full manual control.

**Reading a large value? Use the `FromHashString` getter.** The `FromUrl`
getters call `new URL()`, and Firefox's url parser hard-caps at 1 MiB
(`network.standard-url.max-length`) — past it the constructor *throws*, where
Chrome and Safari parse on. Building a url out of every param just to read one
of them therefore crashes on an oversize *neighbouring* param, in one browser
only. The `FromHashString` getters never construct a URL and have no such limit.

### Core

```
getUrlHashParams(url) -> [preHashValue, Record<string,string>]
getUrlHashParamsFromHashString(hash) -> [preHashValue, Record<string,string>]
getHashParamValue(url, key) -> string | undefined
getHashParamValueFromHashString(hash, key) -> string | undefined
getHashParamFromWindow(key) -> string | undefined
getHashParamsFromWindow() -> [preHashValue, Record<string,string>]
setHashParamInWindow(key, value, opts?)
Expand All @@ -228,16 +237,16 @@ deleteHashParamFromUrl(url, key) -> URL

### Typed accessors

Each type has the same four functions:
Each type has the same set of functions:

| Type | Functions |
| --- | --- |
| JSON | `setHashParamValueJsonInUrl`, `getHashParamValueJsonFromUrl`, `setHashParamValueJsonInWindow`, `getHashParamValueJsonFromWindow`, `setHashParamValueJsonInHashString`, `getHashParamValueJsonFromHashString` |
| Float | `setHashParamValueFloatInUrl`, `getHashParamValueFloatFromUrl`, `setHashParamValueFloatInWindow`, `getHashParamValueFloatFromWindow` |
| Integer | `setHashParamValueIntInUrl`, `getHashParamValueIntFromUrl`, `setHashParamValueIntInWindow`, `getHashParamValueIntFromWindow` |
| Boolean | `setHashParamValueBooleanInUrl`, `getHashParamValueBooleanFromUrl`, `setHashParamValueBooleanInWindow`, `getHashParamValueBooleanFromWindow` |
| Base64 | `setHashParamValueBase64EncodedInUrl`, `getHashParamValueBase64DecodedFromUrl`, `setHashParamValueBase64EncodedInWindow`, `getHashParamValueBase64DecodedFromWindow` |
| URI-encoded | `setHashParamValueUriEncodedInUrl`, `getHashParamValueUriDecodedFromUrl`, `setHashParamValueUriEncodedInWindow`, `getHashParamValueUriDecodedFromWindow` |
| Float | `setHashParamValueFloatInUrl`, `getHashParamValueFloatFromUrl`, `setHashParamValueFloatInWindow`, `getHashParamValueFloatFromWindow`, `getHashParamValueFloatFromHashString` |
| Integer | `setHashParamValueIntInUrl`, `getHashParamValueIntFromUrl`, `setHashParamValueIntInWindow`, `getHashParamValueIntFromWindow`, `getHashParamValueIntFromHashString` |
| Boolean | `setHashParamValueBooleanInUrl`, `getHashParamValueBooleanFromUrl`, `setHashParamValueBooleanInWindow`, `getHashParamValueBooleanFromWindow`, `getHashParamValueBooleanFromHashString` |
| Base64 | `setHashParamValueBase64EncodedInUrl`, `getHashParamValueBase64DecodedFromUrl`, `setHashParamValueBase64EncodedInWindow`, `getHashParamValueBase64DecodedFromWindow`, `getHashParamValueBase64DecodedFromHashString` |
| URI-encoded | `setHashParamValueUriEncodedInUrl`, `getHashParamValueUriDecodedFromUrl`, `setHashParamValueUriEncodedInWindow`, `getHashParamValueUriDecodedFromWindow`, `getHashParamValueUriDecodedFromHashString` |

### Encoding helpers

Expand Down
18 changes: 14 additions & 4 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -92,17 +92,27 @@ Entry points:
## Core functions

getUrlHashParams, getUrlHashParamsFromHashString, getHashParamValue,
getHashParamValueFromHashString,
getHashParamFromWindow, getHashParamsFromWindow, setHashParamInWindow,
setHashParamValueInHashString, setHashParamValueInUrl, setHashParamsInUrl,
deleteHashParamFromWindow, deleteHashParamFromUrl

## Typed accessors

For each of JSON, Float, Int, Boolean, Base64Encoded and UriEncoded there are
`InUrl` / `FromUrl` / `InWindow` / `FromWindow` variants, e.g.
setHashParamValueJsonInWindow, getHashParamValueJsonFromWindow,
setHashParamValueFloatInUrl, getHashParamValueBooleanFromWindow,
setHashParamValueBase64EncodedInWindow, getHashParamValueUriDecodedFromUrl.
`InUrl` / `FromUrl` / `InWindow` / `FromWindow` variants, plus a
`FromHashString` getter, e.g. setHashParamValueJsonInWindow,
getHashParamValueJsonFromWindow, setHashParamValueFloatInUrl,
getHashParamValueBooleanFromWindow, setHashParamValueBase64EncodedInWindow,
getHashParamValueUriDecodedFromUrl,
getHashParamValueBase64DecodedFromHashString.

Reading a LARGE value: use the `FromHashString` getter. The `FromUrl` getters
call `new URL()`, and Firefox's url parser hard-caps at 1 MiB
(`network.standard-url.max-length`) — past it the constructor THROWS, where
Chrome and Safari parse on. So reading one param by building a url out of all
of them crashes on an oversize NEIGHBOURING param, in Firefox only. The
`FromHashString` getters construct no URL and have no such limit.

Encoding helpers: blobToBase64String, blobFromBase64String,
stringToBase64String, stringFromBase64String.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@metapages/hash-query",
"version": "0.10.3",
"version": "0.11.0",
"description": "Store state in the URL hash: typed get/set of hash query params (JSON, base64, boolean, number) + React hooks. Shareable, bookmarkable URL state, no server or database required.",
"license": "MIT",
"author": "metapages",
Expand Down
71 changes: 71 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,27 @@ export const getHashParamValue = (
return hashParams[key];
};

/**
* The `*FromHashString` getters below mirror their `*FromUrl` siblings but read
* a bare hash string, never a URL.
*
* Reach for these whenever the params are already in hand as a string, and
* ALWAYS when a value may be large. The `*FromUrl` getters call `new URL()`,
* and Firefox's url parser hard-caps at 1 MiB
* (`network.standard-url.max-length`) — past it the constructor THROWS
* (`TypeError: URL constructor: … is not a valid URL`) where Chrome and Safari
* happily parse on. So synthesizing a url out of every param in order to read
* one of them turns an oversize *neighbouring* param into a crash, in one
* browser only. Reading the hash string directly cannot hit that limit at all.
*/
export const getHashParamValueFromHashString = (
hash: string,
key: string
): string | undefined => {
const [_, hashParams] = getUrlHashParamsFromHashString(hash);
return hashParams[key];
};

const isBrowser = (): boolean => {
return typeof window !== "undefined" && typeof globalThis.location !== "undefined";
};
Expand Down Expand Up @@ -385,6 +406,14 @@ export const getHashParamValueFloatFromUrl = (
return hashParamString ? parseFloat(hashParamString) : undefined;
};

export const getHashParamValueFloatFromHashString = (
hash: string,
key: string
): number | undefined => {
const hashParamString = getHashParamValueFromHashString(hash, key);
return hashParamString ? parseFloat(hashParamString) : undefined;
};

export const setHashParamValueFloatInWindow = (
key: string,
value: number | undefined,
Expand Down Expand Up @@ -428,6 +457,14 @@ export const getHashParamValueIntFromUrl = (
return hashParamString ? parseInt(hashParamString) : undefined;
};

export const getHashParamValueIntFromHashString = (
hash: string,
key: string
): number | undefined => {
const hashParamString = getHashParamValueFromHashString(hash, key);
return hashParamString ? parseInt(hashParamString) : undefined;
};

export const setHashParamValueIntInWindow = (
key: string,
value: number | undefined,
Expand Down Expand Up @@ -463,6 +500,14 @@ export const getHashParamValueBooleanFromUrl = (
return hashParamString === "true" ? true : false;
};

export const getHashParamValueBooleanFromHashString = (
hash: string,
key: string
): boolean | undefined => {
const hashParamString = getHashParamValueFromHashString(hash, key);
return hashParamString === "true" ? true : false;
};

export const setHashParamValueBooleanInWindow = (
key: string,
value: boolean | undefined,
Expand Down Expand Up @@ -506,6 +551,22 @@ export const getHashParamValueBase64DecodedFromUrl = (
: undefined;
};

/**
* Prefer this over the `FromUrl` form for base64 values: they are the large
* ones (source, blobs, encoded documents), and building a url around them just
* to read one is what trips Firefox's 1 MiB url cap. See
* `getHashParamValueFromHashString`.
*/
export const getHashParamValueBase64DecodedFromHashString = (
hash: string,
key: string
): string | undefined => {
const valueString = getHashParamValueFromHashString(hash, key);
return valueString && valueString !== ""
? stringFromBase64String(valueString)
: undefined;
};

export const setHashParamValueBase64EncodedInWindow = (
key: string,
value: string | undefined,
Expand Down Expand Up @@ -553,6 +614,16 @@ export const getHashParamValueUriDecodedFromUrl = (
: undefined;
};

export const getHashParamValueUriDecodedFromHashString = (
hash: string,
key: string
): string | undefined => {
const valueString = getHashParamValueFromHashString(hash, key);
return valueString && valueString !== ""
? decodeURIComponent(valueString)
: undefined;
};

export const setHashParamValueUriEncodedInWindow = (
key: string,
value: string | undefined,
Expand Down
106 changes: 106 additions & 0 deletions test/from_hash_string_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/// <reference lib="deno.ns" />
import { assertEquals } from "@std/assert";

import {
getHashParamValueBase64DecodedFromHashString,
getHashParamValueBase64DecodedFromUrl,
getHashParamValueBooleanFromHashString,
getHashParamValueFloatFromHashString,
getHashParamValueFromHashString,
getHashParamValueIntFromHashString,
getHashParamValueUriDecodedFromHashString,
stringToBase64String,
} from "../src/core/index.ts";

// Each `*FromHashString` getter must agree with its `*FromUrl` sibling on
// ordinary input — they differ only in what they accept, never in what they
// decode.
Deno.test({
name: "FromHashString getters decode every value type",
fn() {
const hash = [
"#?raw=plain",
"num=1.5",
"count=42",
"flag=true",
`b64=${stringToBase64String("hello ✓")}`,
`uri=${encodeURIComponent("a&b=c")}`,
].join("&");

assertEquals(getHashParamValueFromHashString(hash, "raw"), "plain");
assertEquals(getHashParamValueFloatFromHashString(hash, "num"), 1.5);
assertEquals(getHashParamValueIntFromHashString(hash, "count"), 42);
assertEquals(getHashParamValueBooleanFromHashString(hash, "flag"), true);
assertEquals(
getHashParamValueBase64DecodedFromHashString(hash, "b64"),
"hello ✓",
);
assertEquals(
getHashParamValueUriDecodedFromHashString(hash, "uri"),
"a&b=c",
);
},
});

Deno.test({
name: "FromHashString getters return undefined for a missing key",
fn() {
const hash = "#?present=1";
assertEquals(getHashParamValueFromHashString(hash, "absent"), undefined);
assertEquals(
getHashParamValueBase64DecodedFromHashString(hash, "absent"),
undefined,
);
assertEquals(
getHashParamValueUriDecodedFromHashString(hash, "absent"),
undefined,
);
},
});

Deno.test({
name: "FromHashString accepts a bare param string with no leading #? too",
fn() {
assertEquals(getHashParamValueFromHashString("?a=1&b=2", "b"), "2");
assertEquals(getHashParamValueFromHashString("#?a=1&b=2", "b"), "2");
},
});

// The reason these getters exist. Firefox's url parser hard-caps at 1 MiB
// (`network.standard-url.max-length`) and `new URL()` THROWS past it, so
// reading one param by synthesizing a url out of all of them crashes on an
// oversize NEIGHBOUR — in Firefox only, which is how this reached production.
// Deno's parser has no such cap, so this cannot assert the throw; it asserts
// the property that makes the throw impossible: the hash-string getters read a
// multi-megabyte param set without ever constructing a URL.
Deno.test({
name: "FromHashString reads a param set far past Firefox's 1 MiB url cap",
fn() {
const FIREFOX_MAX_URL_CHARS = 1024 * 1024;
const huge = stringToBase64String("x".repeat(2 * FIREFOX_MAX_URL_CHARS));
const hash = `#?js=${huge}&css=${stringToBase64String("body{}")}`;

if (hash.length <= FIREFOX_MAX_URL_CHARS) {
throw new Error("fixture is too small to exercise the limit");
}

// Reading the SMALL param out of a set dominated by a huge one is the exact
// shape that crashed: applyCssFromUrl read `css` and died on `js`.
assertEquals(
getHashParamValueBase64DecodedFromHashString(hash, "css"),
"body{}",
);
assertEquals(
getHashParamValueBase64DecodedFromHashString(hash, "js")?.length,
2 * FIREFOX_MAX_URL_CHARS,
);

// Same values, same decode, via a url — allowed here only because Deno's
// parser has no cap. Pins that the two forms agree.
const url = `https://example.com/${hash}`;
assertEquals(
getHashParamValueBase64DecodedFromUrl(url, "css"),
getHashParamValueBase64DecodedFromHashString(hash, "css"),
);
},
});
Loading