cursedbelt-core 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,9 @@
13
13
  * - `install` — precache the shell and the config's `precache` list, then `skipWaiting`.
14
14
  * - `activate` — delete every cache named `<cachePrefix>*` that is not this version's,
15
15
  * then `clients.claim()`.
16
- * - `fetch` — same-origin static ASSETS cache-first; document NAVIGATIONS
16
+ * - `fetch` — same-origin static ASSETS: cache-first when the NAME carries a content
17
+ * hash, otherwise revalidated with the network and the cache as the
18
+ * offline fallback (see {@link isContentHashed}); document NAVIGATIONS
17
19
  * network-first, falling back offline to the exact URL, then the same
18
20
  * path ignoring the query, then the shell. Everything else is left on
19
21
  * the network: `respondWith` is never called for it.
@@ -44,7 +46,7 @@
44
46
  export type ShellRoute =
45
47
  /** A document navigation — network-first, cached-document fallback offline. */
46
48
  'navigate'
47
- /** A same-origin static asset (JS/CSS/WASM/font/image/JSON) — cache-first. */
49
+ /** A same-origin static asset (JS/CSS/WASM/font/image/JSON) — cache-first if content-hashed, else revalidated. */
48
50
  | 'asset'
49
51
  /** An auth-gated API call — never cached, never answered from a cache. */
50
52
  | 'api'
@@ -110,6 +112,23 @@ export interface ResolvedShellWorkerConfig {
110
112
  /** roms' `ASSET_EXT`, verbatim. */
111
113
  export declare const DEFAULT_ASSET_EXT: RegExp;
112
114
  /** Always an API prefix, whatever the config says. */
115
+ /**
116
+ * A built file whose NAME carries its content hash — Vite's `index-BcHBclrL.js` — so
117
+ * the bytes under that name can never change, and cache-first is exact.
118
+ *
119
+ * 🔴 Every other asset is REVALIDATED (network, then the cache when offline), because
120
+ * cache-first on a fixed name is "the first copy this device ever saw, for ever":
121
+ * the cache is named by `version`, which an app bumps by hand, and nothing else
122
+ * evicts an entry. Measured on roms 2026-09-27: its emulator cores
123
+ * (`/vendor/cores/melonds_libretro.js`) and frame runtime (`/emulator-runtime.js`)
124
+ * are fixed names under a `v4` cache, so the DS audio fix deployed that day — and
125
+ * every core or runtime fix before it — could never reach a device that had already
126
+ * played the console. The edge answers those names `no-cache` with an ETag, so a
127
+ * revalidation is a 304 and no body.
128
+ */
129
+ export declare const CONTENT_HASHED_NAME: RegExp;
130
+ /** Whether this pathname's last segment is a content-hashed build output. */
131
+ export declare function isContentHashed(pathname: string): boolean;
113
132
  export declare const ALWAYS_API_PREFIX = "/api/";
114
133
  /**
115
134
  * Classify a request. Order matters and is roms' order: non-GET and cross-origin
@@ -13,7 +13,9 @@
13
13
  * - `install` — precache the shell and the config's `precache` list, then `skipWaiting`.
14
14
  * - `activate` — delete every cache named `<cachePrefix>*` that is not this version's,
15
15
  * then `clients.claim()`.
16
- * - `fetch` — same-origin static ASSETS cache-first; document NAVIGATIONS
16
+ * - `fetch` — same-origin static ASSETS: cache-first when the NAME carries a content
17
+ * hash, otherwise revalidated with the network and the cache as the
18
+ * offline fallback (see {@link isContentHashed}); document NAVIGATIONS
17
19
  * network-first, falling back offline to the exact URL, then the same
18
20
  * path ignoring the query, then the shell. Everything else is left on
19
21
  * the network: `respondWith` is never called for it.
@@ -43,6 +45,25 @@
43
45
  /** roms' `ASSET_EXT`, verbatim. */
44
46
  export const DEFAULT_ASSET_EXT = /\.(?:js|mjs|css|wasm|woff2?|ttf|png|jpe?g|webp|svg|ico|json)$/i;
45
47
  /** Always an API prefix, whatever the config says. */
48
+ /**
49
+ * A built file whose NAME carries its content hash — Vite's `index-BcHBclrL.js` — so
50
+ * the bytes under that name can never change, and cache-first is exact.
51
+ *
52
+ * 🔴 Every other asset is REVALIDATED (network, then the cache when offline), because
53
+ * cache-first on a fixed name is "the first copy this device ever saw, for ever":
54
+ * the cache is named by `version`, which an app bumps by hand, and nothing else
55
+ * evicts an entry. Measured on roms 2026-09-27: its emulator cores
56
+ * (`/vendor/cores/melonds_libretro.js`) and frame runtime (`/emulator-runtime.js`)
57
+ * are fixed names under a `v4` cache, so the DS audio fix deployed that day — and
58
+ * every core or runtime fix before it — could never reach a device that had already
59
+ * played the console. The edge answers those names `no-cache` with an ETag, so a
60
+ * revalidation is a 304 and no body.
61
+ */
62
+ export const CONTENT_HASHED_NAME = /-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/;
63
+ /** Whether this pathname's last segment is a content-hashed build output. */
64
+ export function isContentHashed(pathname) {
65
+ return CONTENT_HASHED_NAME.test(pathname);
66
+ }
46
67
  export const ALWAYS_API_PREFIX = '/api/';
47
68
  const apiPrefixesOf = (config) => {
48
69
  const extra = config.apiPrefixes ?? [];
@@ -143,6 +164,26 @@ export function installShellWorker(scope, config) {
143
164
  store(event, 'asset', response);
144
165
  return response;
145
166
  };
167
+ // A fixed-name asset: the network's answer when it has a good one (stored for
168
+ // offline), else the cached copy — offline, or an edge error — else whatever the
169
+ // network said. Never the cache first: see `isContentHashed`.
170
+ const revalidate = async (event) => {
171
+ let response;
172
+ try {
173
+ response = await scope.fetch(event.request);
174
+ }
175
+ catch (error) {
176
+ const cached = await caches.match(event.request);
177
+ if (cached)
178
+ return cached;
179
+ throw error;
180
+ }
181
+ if (response.ok) {
182
+ store(event, 'asset', response);
183
+ return response;
184
+ }
185
+ return (await caches.match(event.request)) ?? response;
186
+ };
146
187
  const networkFirst = async (event) => {
147
188
  const { request } = event;
148
189
  try {
@@ -190,7 +231,7 @@ export function installShellWorker(scope, config) {
190
231
  isNavigate: event.request.mode === 'navigate',
191
232
  }, resolved);
192
233
  if (route === 'asset')
193
- event.respondWith(cacheFirst(event));
234
+ event.respondWith(isContentHashed(url.pathname) ? cacheFirst(event) : revalidate(event));
194
235
  else if (route === 'navigate')
195
236
  event.respondWith(networkFirst(event));
196
237
  // `api` and `passthrough`: no respondWith, so the browser goes to the network as
@@ -0,0 +1,18 @@
1
+ /** One converted request ready for `fetch`: the GET-twin path + the GET init. */
2
+ export interface CtgrFetchRequest {
3
+ /** `/ctgr<xx><path>?<params>` — a path; the caller prepends any API prefix. */
4
+ url: string;
5
+ init: RequestInit;
6
+ }
7
+ /**
8
+ * Convert one request for the tunnel. Returns the chunked GET requests to send in
9
+ * order (usually one); when the mode is off, or it is already a body-less GET, the
10
+ * original request passes through unchanged as a single entry.
11
+ *
12
+ * `formData` may be passed explicitly, or left off and put on `init.body` (the shape
13
+ * a typed client already uses) — an `init.body` that is a `FormData` is detected and
14
+ * serialized the same way.
15
+ */
16
+ export declare function convertIfOnlyGETRequests(url: string, init?: RequestInit, formData?: FormData): Promise<{
17
+ requests: CtgrFetchRequest[];
18
+ }>;
@@ -0,0 +1,98 @@
1
+ /*
2
+ * The browser half of the GET-only tunnel. When the mode is on, a mutating request
3
+ * is rewritten into one or more `/ctgr<xx>` GET twins via the isomorphic codec (gzip
4
+ * + single base64url + integrity); a plain GET passes through untouched. FormData
5
+ * uploads are serialized into the JSON body's `__files` shape (the server
6
+ * reconstructs the uploads from it). When the mode is off, every request passes
7
+ * through unchanged.
8
+ */
9
+ import { encodeRequest, METHOD_TO_ABBREV, toBase64, } from '../ctgr/index.js';
10
+ import { isCtgrModeOn } from './ctgrConfig.js';
11
+ // 10 MB per-file cap for GET-only mode. base64 + URL transport inflates each file and
12
+ // needs many sequential GETs per MB; beyond this the upload hangs/OOMs on mobile.
13
+ const CTGR_MAX_FILE_BYTES = 10 * 1024 * 1024;
14
+ const SAFE_METHODS = new Set(['GET', 'HEAD']);
15
+ /**
16
+ * Convert one request for the tunnel. Returns the chunked GET requests to send in
17
+ * order (usually one); when the mode is off, or it is already a body-less GET, the
18
+ * original request passes through unchanged as a single entry.
19
+ *
20
+ * `formData` may be passed explicitly, or left off and put on `init.body` (the shape
21
+ * a typed client already uses) — an `init.body` that is a `FormData` is detected and
22
+ * serialized the same way.
23
+ */
24
+ export async function convertIfOnlyGETRequests(url, init = { method: 'GET' }, formData) {
25
+ const form = formData ??
26
+ (typeof FormData !== 'undefined' && init.body instanceof FormData ? init.body : undefined);
27
+ const method = (init.method ?? (form ? 'POST' : 'GET')).toUpperCase();
28
+ if (!isCtgrModeOn() || (SAFE_METHODS.has(method) && !form)) {
29
+ return { requests: [{ url, init }] };
30
+ }
31
+ const upper = method;
32
+ if (!METHOD_TO_ABBREV[upper]) {
33
+ throw new Error(`ctgr: "${method}" is not a tunnelable method`);
34
+ }
35
+ const body = form ? await formDataToBody(form) : bodyFromInit(init.body);
36
+ const getInit = toGetInit(init);
37
+ const { chunks } = await encodeRequest(upper, url, body);
38
+ return { requests: chunks.map((chunk) => ({ url: chunk.url, init: getInit })) };
39
+ }
40
+ /** Turn a FormData upload into the `__files` JSON body shape the GET path carries. */
41
+ async function formDataToBody(formData) {
42
+ const files = [];
43
+ const fields = {};
44
+ for (const [key, value] of formData.entries()) {
45
+ if (typeof value === 'object' && value !== null && 'arrayBuffer' in value) {
46
+ const file = value;
47
+ if (file.size > CTGR_MAX_FILE_BYTES) {
48
+ throw new Error(`"${file.name}" (${Math.round(file.size / 1024 / 1024)} MB) is too large to upload in ` +
49
+ `GET-only mode`);
50
+ }
51
+ files.push({
52
+ field: key,
53
+ name: file.name,
54
+ type: file.type,
55
+ dataBase64: toBase64(new Uint8Array(await file.arrayBuffer())),
56
+ });
57
+ }
58
+ else {
59
+ fields[key] = String(value);
60
+ }
61
+ }
62
+ return { __files: files, ...fields };
63
+ }
64
+ /** The request body as a JS value: a JSON string is parsed; anything else passes through. */
65
+ function bodyFromInit(body) {
66
+ if (typeof body !== 'string')
67
+ return body ?? undefined;
68
+ try {
69
+ return JSON.parse(body);
70
+ }
71
+ catch {
72
+ return body;
73
+ }
74
+ }
75
+ /** A GET copy of the init: no body, and the now-meaningless content-type dropped. */
76
+ function toGetInit(init) {
77
+ const next = { ...init, method: 'GET', headers: stripContentType(init.headers) };
78
+ delete next.body;
79
+ return next;
80
+ }
81
+ function stripContentType(headers) {
82
+ if (!headers)
83
+ return headers;
84
+ if (headers instanceof Headers) {
85
+ const next = new Headers(headers);
86
+ next.delete('content-type');
87
+ return next;
88
+ }
89
+ if (Array.isArray(headers)) {
90
+ return headers.filter(([key]) => key.toLowerCase() !== 'content-type');
91
+ }
92
+ const next = {};
93
+ for (const [key, value] of Object.entries(headers)) {
94
+ if (key.toLowerCase() !== 'content-type')
95
+ next[key] = value;
96
+ }
97
+ return next;
98
+ }
@@ -0,0 +1,23 @@
1
+ export declare const isCtgrModeOn: () => boolean;
2
+ export declare const setCtgrMode: (on: boolean) => void;
3
+ /**
4
+ * The state a `ctgr` query value asks for: on, off, or (unknown/absent) leave the
5
+ * device as it is. Pure — no storage, no DOM — so it is trivially testable and the
6
+ * one place the accepted spellings live.
7
+ */
8
+ export declare const readCtgrIntent: (value: string | null | undefined) => "on" | "off" | "keep";
9
+ /**
10
+ * Apply a `ctgr` query value to this device's persisted mode and report whether the
11
+ * mode is on afterwards. Called by `applyCtgrFromLocation` with the value read off
12
+ * the URL; exposed on its own so a host that already parsed its query can reuse it.
13
+ */
14
+ export declare const applyCtgrIntent: (value: string | null | undefined) => boolean;
15
+ /**
16
+ * Boot hook: read `?ctgr=…` off the current location (or a supplied search string /
17
+ * URL), persist the change for this device, and return whether the mode is on. Safe
18
+ * to call with no argument in the browser; returns `false` with no `window`.
19
+ *
20
+ * Call this ONCE, before the app issues its first request — pairing it with
21
+ * `installCtgrGuard()` so a request made during that first tick is still caught.
22
+ */
23
+ export declare const applyCtgrFromLocation: (search?: string) => boolean;
@@ -0,0 +1,94 @@
1
+ /*
2
+ * Single source of truth for GET-only request mode. When on, the request layer
3
+ * rewrites every mutating call to a GET (see `convertIfOnlyGETRequests`) and the
4
+ * network-boundary guard hard-blocks any raw non-GET. Both read this one flag, so
5
+ * they can never disagree.
6
+ *
7
+ * The mode is per-DEVICE, persisted in localStorage so it survives reloads and is
8
+ * never tied to an account — a device that has it on keeps it on, a device that does
9
+ * not never gains it. Default is OFF.
10
+ *
11
+ * A site sets it up front, before its first request, by reading the URL:
12
+ * • `?ctgr=t` (or `on`/`1`/`yes`) → turn on, persist for this device.
13
+ * • `?ctgr=f` (or `off`/`0`/`no`) → turn off, clear it for this device.
14
+ * • no `ctgr` param → whatever this device already had.
15
+ * `applyCtgrFromLocation()` is the one call that does this on boot.
16
+ */
17
+ const CTGR_KEY = 'ctgr';
18
+ const ON_VALUES = new Set(['t', 'on', '1', 'yes', 'true']);
19
+ const OFF_VALUES = new Set(['f', 'off', '0', 'no', 'false']);
20
+ export const isCtgrModeOn = () => {
21
+ try {
22
+ return localStorage.getItem(CTGR_KEY) === 'on';
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ };
28
+ export const setCtgrMode = (on) => {
29
+ try {
30
+ if (on)
31
+ localStorage.setItem(CTGR_KEY, 'on');
32
+ else
33
+ localStorage.removeItem(CTGR_KEY);
34
+ }
35
+ catch {
36
+ // localStorage unavailable (private mode / SSR) — nothing to persist.
37
+ }
38
+ };
39
+ /**
40
+ * The state a `ctgr` query value asks for: on, off, or (unknown/absent) leave the
41
+ * device as it is. Pure — no storage, no DOM — so it is trivially testable and the
42
+ * one place the accepted spellings live.
43
+ */
44
+ export const readCtgrIntent = (value) => {
45
+ if (value == null)
46
+ return 'keep';
47
+ const v = value.trim().toLowerCase();
48
+ if (ON_VALUES.has(v))
49
+ return 'on';
50
+ if (OFF_VALUES.has(v))
51
+ return 'off';
52
+ return 'keep';
53
+ };
54
+ /**
55
+ * Apply a `ctgr` query value to this device's persisted mode and report whether the
56
+ * mode is on afterwards. Called by `applyCtgrFromLocation` with the value read off
57
+ * the URL; exposed on its own so a host that already parsed its query can reuse it.
58
+ */
59
+ export const applyCtgrIntent = (value) => {
60
+ const intent = readCtgrIntent(value);
61
+ if (intent === 'on')
62
+ setCtgrMode(true);
63
+ else if (intent === 'off')
64
+ setCtgrMode(false);
65
+ return isCtgrModeOn();
66
+ };
67
+ /**
68
+ * Boot hook: read `?ctgr=…` off the current location (or a supplied search string /
69
+ * URL), persist the change for this device, and return whether the mode is on. Safe
70
+ * to call with no argument in the browser; returns `false` with no `window`.
71
+ *
72
+ * Call this ONCE, before the app issues its first request — pairing it with
73
+ * `installCtgrGuard()` so a request made during that first tick is still caught.
74
+ */
75
+ export const applyCtgrFromLocation = (search) => {
76
+ let query = search;
77
+ if (query === undefined) {
78
+ if (typeof window === 'undefined' || !window.location)
79
+ return isCtgrModeOn();
80
+ query = window.location.search;
81
+ }
82
+ let value = null;
83
+ try {
84
+ // Accept a bare search (`?ctgr=t`), a full href, or already-extracted params.
85
+ const params = query.includes('://')
86
+ ? new URL(query).searchParams
87
+ : new URLSearchParams(query.startsWith('?') ? query.slice(1) : query);
88
+ value = params.get(CTGR_KEY);
89
+ }
90
+ catch {
91
+ value = null;
92
+ }
93
+ return applyCtgrIntent(value);
94
+ };
@@ -0,0 +1 @@
1
+ export declare const installCtgrGuard: () => void;
@@ -0,0 +1,83 @@
1
+ /*
2
+ * Safety net for GET-only mode. Even though the request layer is supposed to rewrite
3
+ * every mutating call to a GET (`convertIfOnlyGETRequests`), this guard sits at the
4
+ * actual network boundary and hard-blocks any non-GET request to our own API surface
5
+ * when the mode is on — so a code path that forgot to run through the converter can
6
+ * never put a raw POST/PUT/PATCH/DELETE on the wire. Catches both `fetch` and
7
+ * `XMLHttpRequest`. It only ever tightens; it must never weaken.
8
+ */
9
+ import { isCtgrModeOn } from './ctgrConfig.js';
10
+ // Methods that never mutate and are always safe to allow.
11
+ const SAFE_METHODS = new Set(['GET', 'HEAD']);
12
+ const urlOf = (input) => {
13
+ if (typeof input === 'string')
14
+ return input;
15
+ if (input instanceof URL)
16
+ return input.href;
17
+ if (input instanceof Request)
18
+ return input.url;
19
+ return String(input);
20
+ };
21
+ // Only guard our OWN API surface — same-origin AND under a path the request layer
22
+ // owns (`/api/…` or a `/ctgr<xx>` twin). Restricting to these prefixes (rather than
23
+ // "any same-origin path") matters: same-origin also covers paths we do not own, like
24
+ // Cloudflare's `/cdn-cgi/rum` beacon, which fires a raw POST on every page view —
25
+ // guarding it would break RUM and fire for every ordinary visitor, not just a
26
+ // misbehaving call site.
27
+ const isGuardedPath = (rawUrl) => {
28
+ let path;
29
+ if (rawUrl.startsWith('/')) {
30
+ path = rawUrl;
31
+ }
32
+ else {
33
+ try {
34
+ const u = new URL(rawUrl, window.location.href);
35
+ if (u.origin !== window.location.origin)
36
+ return false;
37
+ path = u.pathname;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ return path.startsWith('/api/') || path.startsWith('/ctgr');
44
+ };
45
+ // Deliberately generic: this message can reach the console of ANY visitor, so it says
46
+ // nothing about the request layer, the call site, or what a blocked request was.
47
+ const block = () => {
48
+ const message = 'Service unavailable.';
49
+ console.error(message, new Error('blocked request'));
50
+ return new Error(message);
51
+ };
52
+ let installed = false;
53
+ export const installCtgrGuard = () => {
54
+ if (installed || typeof window === 'undefined')
55
+ return;
56
+ installed = true;
57
+ // ── fetch ──────────────────────────────────────────────────────────────────
58
+ if (typeof window.fetch === 'function') {
59
+ const originalFetch = window.fetch.bind(window);
60
+ const guardedFetch = (input, init) => {
61
+ const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
62
+ const url = urlOf(input);
63
+ if (isCtgrModeOn() && !SAFE_METHODS.has(method) && isGuardedPath(url)) {
64
+ return Promise.reject(block());
65
+ }
66
+ return originalFetch(input, init);
67
+ };
68
+ window.fetch = guardedFetch;
69
+ }
70
+ // ── XMLHttpRequest ───────────────────────────────────────────────────────────
71
+ if (typeof XMLHttpRequest !== 'undefined') {
72
+ const originalOpen = XMLHttpRequest.prototype.open;
73
+ // biome-ignore lint/suspicious/noExplicitAny: matching XHR.open's variadic overloads
74
+ XMLHttpRequest.prototype.open = function open(method, url, ...rest) {
75
+ const upper = String(method).toUpperCase();
76
+ if (isCtgrModeOn() && !SAFE_METHODS.has(upper) && isGuardedPath(String(url))) {
77
+ throw block();
78
+ }
79
+ // biome-ignore lint/suspicious/noExplicitAny: forwarding the original variadic call
80
+ return originalOpen.call(this, method, url, ...rest);
81
+ };
82
+ }
83
+ };
@@ -0,0 +1,3 @@
1
+ export { applyCtgrFromLocation, applyCtgrIntent, isCtgrModeOn, readCtgrIntent, setCtgrMode, } from './ctgrConfig.js';
2
+ export { type CtgrFetchRequest, convertIfOnlyGETRequests } from './convertRequest.js';
3
+ export { installCtgrGuard } from './ctgrGuard.js';
@@ -0,0 +1,15 @@
1
+ /*
2
+ * cursedbelt-core/ctgr-client — the browser adapters that turn any site into a
3
+ * GET-only client:
4
+ * • the per-device mode flag + the `?ctgr=t|f` boot toggle (`ctgrConfig`),
5
+ * • the request converter that rewrites a mutation into `/ctgr<xx>` GET twins,
6
+ * • the network-boundary guard that blocks a stray raw verb.
7
+ *
8
+ * The wire codec is `cursedbelt-core/ctgr`; the server that decodes the twins is
9
+ * `cursedbelt-server` (`mountCtgrTunnel`). A site opts in with two boot calls —
10
+ * `applyCtgrFromLocation()` then `installCtgrGuard()` — and routes its requests
11
+ * through `convertIfOnlyGETRequests`.
12
+ */
13
+ export { applyCtgrFromLocation, applyCtgrIntent, isCtgrModeOn, readCtgrIntent, setCtgrMode, } from './ctgrConfig.js';
14
+ export { convertIfOnlyGETRequests } from './convertRequest.js';
15
+ export { installCtgrGuard } from './ctgrGuard.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-core",
3
- "version": "2.7.0",
3
+ "version": "2.9.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "Framework-agnostic core of the cursedbelt split — isomorphic domain logic. cwip below it, cursedbelt-server and cursedbelt (react) above it.",
@@ -371,6 +371,12 @@
371
371
  "bun": "./src/client/wsWire.ts",
372
372
  "source": "./src/client/wsWire.ts",
373
373
  "import": "./dist/client/wsWire.js"
374
+ },
375
+ "./ctgr-client": {
376
+ "types": "./dist/ctgrClient/index.d.ts",
377
+ "bun": "./src/ctgrClient/index.ts",
378
+ "source": "./src/ctgrClient/index.ts",
379
+ "import": "./dist/ctgrClient/index.js"
374
380
  }
375
381
  },
376
382
  "dependencies": {
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test';
2
2
  import {
3
3
  classifyRequest,
4
4
  installShellWorker,
5
+ isContentHashed,
5
6
  mayFallBackToShell,
6
7
  type RequestFacts,
7
8
  resolveShellWorkerConfig,
@@ -353,16 +354,50 @@ describe('installShellWorker — fetch', () => {
353
354
  );
354
355
  });
355
356
 
356
- it('serves assets cache-first and caches only an ok response', async () => {
357
+ it('serves CONTENT-HASHED assets cache-first and caches only an ok response', async () => {
357
358
  const world = await installedRoms();
358
- world.server.set(`${ORIGIN}/assets/app-1.js`, new FakeResponse('app'));
359
- expect((await world.dispatch(req('/assets/app-1.js')).settled())?.body).toBe('app');
360
- expect((await world.dispatch(req('/assets/missing.js')).settled())?.ok).toBe(false);
361
- expect(world.cacheWrites).toEqual([`${ORIGIN}/assets/app-1.js`]);
362
- world.goOffline();
359
+ world.server.set(`${ORIGIN}/assets/app-BcHBclrL.js`, new FakeResponse('app'));
360
+ expect((await world.dispatch(req('/assets/app-BcHBclrL.js')).settled())?.body).toBe('app');
361
+ expect((await world.dispatch(req('/assets/missing-DeadBeef.js')).settled())?.ok).toBe(false);
362
+ expect(world.cacheWrites).toEqual([`${ORIGIN}/assets/app-BcHBclrL.js`]);
363
363
  const calls = world.networkCalls.length;
364
- expect((await world.dispatch(req('/assets/app-1.js')).settled())?.body).toBe('app');
365
- expect(world.networkCalls.length).toBe(calls); // from the cache, not the network
364
+ expect((await world.dispatch(req('/assets/app-BcHBclrL.js')).settled())?.body).toBe('app');
365
+ expect(world.networkCalls.length).toBe(calls); // from the cache, not the network — even online
366
+ });
367
+
368
+ it('🔴 a FIXED-NAME asset reaches the device when its bytes change — the roms core that never updated (2026-09-27)', async () => {
369
+ const world = await installedRoms();
370
+ const core = `${ORIGIN}/vendor/cores/melonds_libretro.js`;
371
+ world.server.set(core, new FakeResponse('old driver'));
372
+ expect((await world.dispatch(req('/vendor/cores/melonds_libretro.js')).settled())?.body).toBe('old driver');
373
+ world.server.set(core, new FakeResponse('new driver')); // a deploy
374
+ expect((await world.dispatch(req('/vendor/cores/melonds_libretro.js')).settled())?.body).toBe('new driver');
375
+ // …and the runtime precached at install is refreshed the same way.
376
+ world.server.set(`${ORIGIN}/emulator-runtime.js`, new FakeResponse('runtime 2'));
377
+ expect((await world.dispatch(req('/emulator-runtime.js')).settled())?.body).toBe('runtime 2');
378
+ // Offline, the LATEST copy answers, not the first one.
379
+ world.goOffline();
380
+ expect((await world.dispatch(req('/vendor/cores/melonds_libretro.js')).settled())?.body).toBe('new driver');
381
+ expect((await world.dispatch(req('/emulator-runtime.js')).settled())?.body).toBe('runtime 2');
382
+ });
383
+
384
+ it('a fixed-name asset the edge errors on is served from the cache; with no copy, the error stands', async () => {
385
+ const world = await installedRoms();
386
+ world.server.set(`${ORIGIN}/controller-ds.webp`, new FakeResponse('pad'));
387
+ await world.dispatch(req('/controller-ds.webp')).settled();
388
+ world.server.set(`${ORIGIN}/controller-ds.webp`, new FakeResponse('bad gateway', false));
389
+ expect((await world.dispatch(req('/controller-ds.webp')).settled())?.body).toBe('pad');
390
+ expect((await world.dispatch(req('/favicon.svg')).settled())?.ok).toBe(false);
391
+ world.goOffline();
392
+ await expect(world.dispatch(req('/favicon.svg')).response as Promise<ShellWorkerResponse>).rejects.toThrow('Failed to fetch');
393
+ });
394
+
395
+ it('isContentHashed: Vite names are, fixed names are not', () => {
396
+ expect(isContentHashed('/assets/index-BcHBclrL.js')).toBe(true);
397
+ expect(isContentHashed('/assets/ps2-C7Rs0q_e.css')).toBe(true);
398
+ expect(isContentHashed('/vendor/cores/melonds_libretro.wasm')).toBe(false);
399
+ expect(isContentHashed('/emulator-runtime.js')).toBe(false);
400
+ expect(isContentHashed('/controller-ds.webp')).toBe(false);
366
401
  });
367
402
 
368
403
  it('🔴 never intercepts, reads or caches /api/ — even with a stale copy planted in the cache', async () => {
@@ -13,7 +13,9 @@
13
13
  * - `install` — precache the shell and the config's `precache` list, then `skipWaiting`.
14
14
  * - `activate` — delete every cache named `<cachePrefix>*` that is not this version's,
15
15
  * then `clients.claim()`.
16
- * - `fetch` — same-origin static ASSETS cache-first; document NAVIGATIONS
16
+ * - `fetch` — same-origin static ASSETS: cache-first when the NAME carries a content
17
+ * hash, otherwise revalidated with the network and the cache as the
18
+ * offline fallback (see {@link isContentHashed}); document NAVIGATIONS
17
19
  * network-first, falling back offline to the exact URL, then the same
18
20
  * path ignoring the query, then the shell. Everything else is left on
19
21
  * the network: `respondWith` is never called for it.
@@ -45,7 +47,7 @@
45
47
  export type ShellRoute =
46
48
  /** A document navigation — network-first, cached-document fallback offline. */
47
49
  | 'navigate'
48
- /** A same-origin static asset (JS/CSS/WASM/font/image/JSON) — cache-first. */
50
+ /** A same-origin static asset (JS/CSS/WASM/font/image/JSON) — cache-first if content-hashed, else revalidated. */
49
51
  | 'asset'
50
52
  /** An auth-gated API call — never cached, never answered from a cache. */
51
53
  | 'api'
@@ -116,6 +118,27 @@ export interface ResolvedShellWorkerConfig {
116
118
  export const DEFAULT_ASSET_EXT = /\.(?:js|mjs|css|wasm|woff2?|ttf|png|jpe?g|webp|svg|ico|json)$/i;
117
119
 
118
120
  /** Always an API prefix, whatever the config says. */
121
+ /**
122
+ * A built file whose NAME carries its content hash — Vite's `index-BcHBclrL.js` — so
123
+ * the bytes under that name can never change, and cache-first is exact.
124
+ *
125
+ * 🔴 Every other asset is REVALIDATED (network, then the cache when offline), because
126
+ * cache-first on a fixed name is "the first copy this device ever saw, for ever":
127
+ * the cache is named by `version`, which an app bumps by hand, and nothing else
128
+ * evicts an entry. Measured on roms 2026-09-27: its emulator cores
129
+ * (`/vendor/cores/melonds_libretro.js`) and frame runtime (`/emulator-runtime.js`)
130
+ * are fixed names under a `v4` cache, so the DS audio fix deployed that day — and
131
+ * every core or runtime fix before it — could never reach a device that had already
132
+ * played the console. The edge answers those names `no-cache` with an ETag, so a
133
+ * revalidation is a 304 and no body.
134
+ */
135
+ export const CONTENT_HASHED_NAME = /-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/;
136
+
137
+ /** Whether this pathname's last segment is a content-hashed build output. */
138
+ export function isContentHashed(pathname: string): boolean {
139
+ return CONTENT_HASHED_NAME.test(pathname);
140
+ }
141
+
119
142
  export const ALWAYS_API_PREFIX = '/api/';
120
143
 
121
144
  const apiPrefixesOf = (config: Pick<ShellWorkerConfig, 'apiPrefixes'>): readonly string[] => {
@@ -279,6 +302,25 @@ export function installShellWorker(scope: ShellWorkerScope, config: ShellWorkerC
279
302
  return response;
280
303
  };
281
304
 
305
+ // A fixed-name asset: the network's answer when it has a good one (stored for
306
+ // offline), else the cached copy — offline, or an edge error — else whatever the
307
+ // network said. Never the cache first: see `isContentHashed`.
308
+ const revalidate = async (event: ShellFetchEvent): Promise<ShellWorkerResponse> => {
309
+ let response: ShellWorkerResponse;
310
+ try {
311
+ response = await scope.fetch(event.request);
312
+ } catch (error) {
313
+ const cached = await caches.match(event.request);
314
+ if (cached) return cached;
315
+ throw error;
316
+ }
317
+ if (response.ok) {
318
+ store(event, 'asset', response);
319
+ return response;
320
+ }
321
+ return (await caches.match(event.request)) ?? response;
322
+ };
323
+
282
324
  const networkFirst = async (event: ShellFetchEvent): Promise<ShellWorkerResponse> => {
283
325
  const { request } = event;
284
326
  try {
@@ -336,7 +378,7 @@ export function installShellWorker(scope: ShellWorkerScope, config: ShellWorkerC
336
378
  },
337
379
  resolved,
338
380
  );
339
- if (route === 'asset') event.respondWith(cacheFirst(event));
381
+ if (route === 'asset') event.respondWith(isContentHashed(url.pathname) ? cacheFirst(event) : revalidate(event));
340
382
  else if (route === 'navigate') event.respondWith(networkFirst(event));
341
383
  // `api` and `passthrough`: no respondWith, so the browser goes to the network as
342
384
  // if this worker did not exist. Never a cache read, never a cache write.
@@ -0,0 +1,123 @@
1
+ /*
2
+ * The browser half of the GET-only tunnel. When the mode is on, a mutating request
3
+ * is rewritten into one or more `/ctgr<xx>` GET twins via the isomorphic codec (gzip
4
+ * + single base64url + integrity); a plain GET passes through untouched. FormData
5
+ * uploads are serialized into the JSON body's `__files` shape (the server
6
+ * reconstructs the uploads from it). When the mode is off, every request passes
7
+ * through unchanged.
8
+ */
9
+ import {
10
+ type CtgrMethod,
11
+ encodeRequest,
12
+ METHOD_TO_ABBREV,
13
+ type SerializedFile,
14
+ toBase64,
15
+ } from '../ctgr/index.js';
16
+ import { isCtgrModeOn } from './ctgrConfig.js';
17
+
18
+ /** One converted request ready for `fetch`: the GET-twin path + the GET init. */
19
+ export interface CtgrFetchRequest {
20
+ /** `/ctgr<xx><path>?<params>` — a path; the caller prepends any API prefix. */
21
+ url: string;
22
+ init: RequestInit;
23
+ }
24
+
25
+ // 10 MB per-file cap for GET-only mode. base64 + URL transport inflates each file and
26
+ // needs many sequential GETs per MB; beyond this the upload hangs/OOMs on mobile.
27
+ const CTGR_MAX_FILE_BYTES = 10 * 1024 * 1024;
28
+
29
+ const SAFE_METHODS = new Set(['GET', 'HEAD']);
30
+
31
+ /**
32
+ * Convert one request for the tunnel. Returns the chunked GET requests to send in
33
+ * order (usually one); when the mode is off, or it is already a body-less GET, the
34
+ * original request passes through unchanged as a single entry.
35
+ *
36
+ * `formData` may be passed explicitly, or left off and put on `init.body` (the shape
37
+ * a typed client already uses) — an `init.body` that is a `FormData` is detected and
38
+ * serialized the same way.
39
+ */
40
+ export async function convertIfOnlyGETRequests(
41
+ url: string,
42
+ init: RequestInit = { method: 'GET' },
43
+ formData?: FormData,
44
+ ): Promise<{ requests: CtgrFetchRequest[] }> {
45
+ const form =
46
+ formData ??
47
+ (typeof FormData !== 'undefined' && init.body instanceof FormData ? init.body : undefined);
48
+ const method = (init.method ?? (form ? 'POST' : 'GET')).toUpperCase();
49
+
50
+ if (!isCtgrModeOn() || (SAFE_METHODS.has(method) && !form)) {
51
+ return { requests: [{ url, init }] };
52
+ }
53
+
54
+ const upper = method as CtgrMethod;
55
+ if (!METHOD_TO_ABBREV[upper]) {
56
+ throw new Error(`ctgr: "${method}" is not a tunnelable method`);
57
+ }
58
+
59
+ const body: unknown = form ? await formDataToBody(form) : bodyFromInit(init.body);
60
+ const getInit = toGetInit(init);
61
+ const { chunks } = await encodeRequest(upper, url, body);
62
+ return { requests: chunks.map((chunk) => ({ url: chunk.url, init: getInit })) };
63
+ }
64
+
65
+ /** Turn a FormData upload into the `__files` JSON body shape the GET path carries. */
66
+ async function formDataToBody(formData: FormData): Promise<Record<string, unknown>> {
67
+ const files: SerializedFile[] = [];
68
+ const fields: Record<string, string> = {};
69
+ for (const [key, value] of formData.entries()) {
70
+ if (typeof value === 'object' && value !== null && 'arrayBuffer' in value) {
71
+ const file = value as File;
72
+ if (file.size > CTGR_MAX_FILE_BYTES) {
73
+ throw new Error(
74
+ `"${file.name}" (${Math.round(file.size / 1024 / 1024)} MB) is too large to upload in ` +
75
+ `GET-only mode`,
76
+ );
77
+ }
78
+ files.push({
79
+ field: key,
80
+ name: file.name,
81
+ type: file.type,
82
+ dataBase64: toBase64(new Uint8Array(await file.arrayBuffer())),
83
+ });
84
+ } else {
85
+ fields[key] = String(value);
86
+ }
87
+ }
88
+ return { __files: files, ...fields };
89
+ }
90
+
91
+ /** The request body as a JS value: a JSON string is parsed; anything else passes through. */
92
+ function bodyFromInit(body: BodyInit | null | undefined): unknown {
93
+ if (typeof body !== 'string') return body ?? undefined;
94
+ try {
95
+ return JSON.parse(body);
96
+ } catch {
97
+ return body;
98
+ }
99
+ }
100
+
101
+ /** A GET copy of the init: no body, and the now-meaningless content-type dropped. */
102
+ function toGetInit(init: RequestInit): RequestInit {
103
+ const next: RequestInit = { ...init, method: 'GET', headers: stripContentType(init.headers) };
104
+ delete next.body;
105
+ return next;
106
+ }
107
+
108
+ function stripContentType(headers: HeadersInit | undefined): HeadersInit | undefined {
109
+ if (!headers) return headers;
110
+ if (headers instanceof Headers) {
111
+ const next = new Headers(headers);
112
+ next.delete('content-type');
113
+ return next;
114
+ }
115
+ if (Array.isArray(headers)) {
116
+ return headers.filter(([key]) => key.toLowerCase() !== 'content-type');
117
+ }
118
+ const next: Record<string, string> = {};
119
+ for (const [key, value] of Object.entries(headers)) {
120
+ if (key.toLowerCase() !== 'content-type') next[key] = value;
121
+ }
122
+ return next;
123
+ }
@@ -0,0 +1,135 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test';
2
+ import { decodeRequest } from '../ctgr/index.js';
3
+ import {
4
+ applyCtgrFromLocation,
5
+ applyCtgrIntent,
6
+ convertIfOnlyGETRequests,
7
+ isCtgrModeOn,
8
+ readCtgrIntent,
9
+ setCtgrMode,
10
+ } from './index.js';
11
+
12
+ beforeEach(() => {
13
+ localStorage.clear();
14
+ });
15
+
16
+ describe('the per-device mode flag', () => {
17
+ test('is off by default and round-trips through setCtgrMode', () => {
18
+ expect(isCtgrModeOn()).toBe(false);
19
+ setCtgrMode(true);
20
+ expect(isCtgrModeOn()).toBe(true);
21
+ setCtgrMode(false);
22
+ expect(isCtgrModeOn()).toBe(false);
23
+ });
24
+ });
25
+
26
+ describe('readCtgrIntent — the accepted spellings', () => {
27
+ test('on-values → on', () => {
28
+ for (const v of ['t', 'on', '1', 'yes', 'true', 'T', ' On ']) {
29
+ expect(readCtgrIntent(v)).toBe('on');
30
+ }
31
+ });
32
+ test('off-values → off', () => {
33
+ for (const v of ['f', 'off', '0', 'no', 'false']) {
34
+ expect(readCtgrIntent(v)).toBe('off');
35
+ }
36
+ });
37
+ test('absent or unknown → keep', () => {
38
+ expect(readCtgrIntent(null)).toBe('keep');
39
+ expect(readCtgrIntent(undefined)).toBe('keep');
40
+ expect(readCtgrIntent('maybe')).toBe('keep');
41
+ });
42
+ });
43
+
44
+ describe('applyCtgrIntent persists per device', () => {
45
+ test('on then off', () => {
46
+ expect(applyCtgrIntent('t')).toBe(true);
47
+ expect(isCtgrModeOn()).toBe(true);
48
+ expect(applyCtgrIntent('f')).toBe(false);
49
+ expect(isCtgrModeOn()).toBe(false);
50
+ });
51
+ test('an absent value leaves an already-on device on', () => {
52
+ setCtgrMode(true);
53
+ expect(applyCtgrIntent(null)).toBe(true);
54
+ expect(isCtgrModeOn()).toBe(true);
55
+ });
56
+ });
57
+
58
+ describe('applyCtgrFromLocation — the boot toggle', () => {
59
+ test('?ctgr=t turns it on, ?ctgr=f turns it off, from a bare search string', () => {
60
+ expect(applyCtgrFromLocation('?ctgr=t')).toBe(true);
61
+ expect(isCtgrModeOn()).toBe(true);
62
+ expect(applyCtgrFromLocation('?ctgr=f')).toBe(false);
63
+ expect(isCtgrModeOn()).toBe(false);
64
+ });
65
+ test('accepts a full href and other params alongside', () => {
66
+ expect(applyCtgrFromLocation('https://desk.example.com/x?foo=1&ctgr=on')).toBe(true);
67
+ expect(isCtgrModeOn()).toBe(true);
68
+ });
69
+ test('no ctgr param leaves the device as it was', () => {
70
+ setCtgrMode(true);
71
+ expect(applyCtgrFromLocation('?foo=1')).toBe(true);
72
+ setCtgrMode(false);
73
+ expect(applyCtgrFromLocation('?foo=1')).toBe(false);
74
+ });
75
+ });
76
+
77
+ describe('convertIfOnlyGETRequests', () => {
78
+ test('mode OFF: every request passes through unchanged', async () => {
79
+ const init = { method: 'POST', body: JSON.stringify({ a: 1 }) };
80
+ const { requests } = await convertIfOnlyGETRequests('/api/v1/files', init);
81
+ expect(requests).toHaveLength(1);
82
+ expect(requests[0].url).toBe('/api/v1/files');
83
+ expect(requests[0].init).toBe(init);
84
+ });
85
+
86
+ test('mode ON: a POST becomes a decodable GET twin', async () => {
87
+ setCtgrMode(true);
88
+ const body = { title: 'hello', n: 42 };
89
+ const { requests } = await convertIfOnlyGETRequests('/api/v1/files', {
90
+ method: 'POST',
91
+ headers: { 'content-type': 'application/json' },
92
+ body: JSON.stringify(body),
93
+ });
94
+ expect(requests).toHaveLength(1);
95
+ const twin = requests[0];
96
+ expect(twin.url.startsWith('/ctgrpo/api/v1/files?')).toBe(true);
97
+ expect((twin.init.method ?? 'GET').toUpperCase()).toBe('GET');
98
+ expect(twin.init.body).toBeUndefined();
99
+ // The server codec reverses it exactly.
100
+ const params = new URL(twin.url, 'http://x').searchParams;
101
+ const decoded = await decodeRequest(params);
102
+ expect(decoded).not.toBe('partial');
103
+ if (decoded === 'partial') throw new Error('unreachable');
104
+ expect(decoded.method).toBe('POST');
105
+ expect(decoded.body).toEqual(body);
106
+ });
107
+
108
+ test('mode ON: a plain GET still passes through untouched', async () => {
109
+ setCtgrMode(true);
110
+ const { requests } = await convertIfOnlyGETRequests('/api/v1/files', { method: 'GET' });
111
+ expect(requests).toHaveLength(1);
112
+ expect(requests[0].url).toBe('/api/v1/files');
113
+ });
114
+
115
+ test('mode ON: a DELETE with no body tunnels too', async () => {
116
+ setCtgrMode(true);
117
+ const { requests } = await convertIfOnlyGETRequests('/api/v1/files/x', { method: 'DELETE' });
118
+ expect(requests[0].url.startsWith('/ctgrde/api/v1/files/x')).toBe(true);
119
+ const decoded = await decodeRequest(new URL(requests[0].url, 'http://x').searchParams);
120
+ if (decoded === 'partial') throw new Error('unreachable');
121
+ expect(decoded.method).toBe('DELETE');
122
+ });
123
+
124
+ test('mode ON: a FormData upload rides as __files and reconstructs', async () => {
125
+ setCtgrMode(true);
126
+ const form = new FormData();
127
+ form.append('name', 'pic');
128
+ form.append('file', new File([new Uint8Array([1, 2, 3])], 'p.bin', { type: 'image/png' }));
129
+ const { requests } = await convertIfOnlyGETRequests('/api/v1/media', { method: 'POST' }, form);
130
+ const decoded = await decodeRequest(new URL(requests[0].url, 'http://x').searchParams);
131
+ if (decoded === 'partial') throw new Error('unreachable');
132
+ expect(decoded.files?.[0]).toMatchObject({ originalname: 'p.bin', mimetype: 'image/png' });
133
+ expect(decoded.body).toEqual({ name: 'pic' });
134
+ });
135
+ });
@@ -0,0 +1,89 @@
1
+ /*
2
+ * Single source of truth for GET-only request mode. When on, the request layer
3
+ * rewrites every mutating call to a GET (see `convertIfOnlyGETRequests`) and the
4
+ * network-boundary guard hard-blocks any raw non-GET. Both read this one flag, so
5
+ * they can never disagree.
6
+ *
7
+ * The mode is per-DEVICE, persisted in localStorage so it survives reloads and is
8
+ * never tied to an account — a device that has it on keeps it on, a device that does
9
+ * not never gains it. Default is OFF.
10
+ *
11
+ * A site sets it up front, before its first request, by reading the URL:
12
+ * • `?ctgr=t` (or `on`/`1`/`yes`) → turn on, persist for this device.
13
+ * • `?ctgr=f` (or `off`/`0`/`no`) → turn off, clear it for this device.
14
+ * • no `ctgr` param → whatever this device already had.
15
+ * `applyCtgrFromLocation()` is the one call that does this on boot.
16
+ */
17
+ const CTGR_KEY = 'ctgr';
18
+
19
+ const ON_VALUES = new Set(['t', 'on', '1', 'yes', 'true']);
20
+ const OFF_VALUES = new Set(['f', 'off', '0', 'no', 'false']);
21
+
22
+ export const isCtgrModeOn = (): boolean => {
23
+ try {
24
+ return localStorage.getItem(CTGR_KEY) === 'on';
25
+ } catch {
26
+ return false;
27
+ }
28
+ };
29
+
30
+ export const setCtgrMode = (on: boolean): void => {
31
+ try {
32
+ if (on) localStorage.setItem(CTGR_KEY, 'on');
33
+ else localStorage.removeItem(CTGR_KEY);
34
+ } catch {
35
+ // localStorage unavailable (private mode / SSR) — nothing to persist.
36
+ }
37
+ };
38
+
39
+ /**
40
+ * The state a `ctgr` query value asks for: on, off, or (unknown/absent) leave the
41
+ * device as it is. Pure — no storage, no DOM — so it is trivially testable and the
42
+ * one place the accepted spellings live.
43
+ */
44
+ export const readCtgrIntent = (value: string | null | undefined): 'on' | 'off' | 'keep' => {
45
+ if (value == null) return 'keep';
46
+ const v = value.trim().toLowerCase();
47
+ if (ON_VALUES.has(v)) return 'on';
48
+ if (OFF_VALUES.has(v)) return 'off';
49
+ return 'keep';
50
+ };
51
+
52
+ /**
53
+ * Apply a `ctgr` query value to this device's persisted mode and report whether the
54
+ * mode is on afterwards. Called by `applyCtgrFromLocation` with the value read off
55
+ * the URL; exposed on its own so a host that already parsed its query can reuse it.
56
+ */
57
+ export const applyCtgrIntent = (value: string | null | undefined): boolean => {
58
+ const intent = readCtgrIntent(value);
59
+ if (intent === 'on') setCtgrMode(true);
60
+ else if (intent === 'off') setCtgrMode(false);
61
+ return isCtgrModeOn();
62
+ };
63
+
64
+ /**
65
+ * Boot hook: read `?ctgr=…` off the current location (or a supplied search string /
66
+ * URL), persist the change for this device, and return whether the mode is on. Safe
67
+ * to call with no argument in the browser; returns `false` with no `window`.
68
+ *
69
+ * Call this ONCE, before the app issues its first request — pairing it with
70
+ * `installCtgrGuard()` so a request made during that first tick is still caught.
71
+ */
72
+ export const applyCtgrFromLocation = (search?: string): boolean => {
73
+ let query = search;
74
+ if (query === undefined) {
75
+ if (typeof window === 'undefined' || !window.location) return isCtgrModeOn();
76
+ query = window.location.search;
77
+ }
78
+ let value: string | null = null;
79
+ try {
80
+ // Accept a bare search (`?ctgr=t`), a full href, or already-extracted params.
81
+ const params = query.includes('://')
82
+ ? new URL(query).searchParams
83
+ : new URLSearchParams(query.startsWith('?') ? query.slice(1) : query);
84
+ value = params.get(CTGR_KEY);
85
+ } catch {
86
+ value = null;
87
+ }
88
+ return applyCtgrIntent(value);
89
+ };
@@ -0,0 +1,86 @@
1
+ /*
2
+ * Safety net for GET-only mode. Even though the request layer is supposed to rewrite
3
+ * every mutating call to a GET (`convertIfOnlyGETRequests`), this guard sits at the
4
+ * actual network boundary and hard-blocks any non-GET request to our own API surface
5
+ * when the mode is on — so a code path that forgot to run through the converter can
6
+ * never put a raw POST/PUT/PATCH/DELETE on the wire. Catches both `fetch` and
7
+ * `XMLHttpRequest`. It only ever tightens; it must never weaken.
8
+ */
9
+ import { isCtgrModeOn } from './ctgrConfig.js';
10
+
11
+ // Methods that never mutate and are always safe to allow.
12
+ const SAFE_METHODS = new Set(['GET', 'HEAD']);
13
+
14
+ const urlOf = (input: RequestInfo | URL): string => {
15
+ if (typeof input === 'string') return input;
16
+ if (input instanceof URL) return input.href;
17
+ if (input instanceof Request) return input.url;
18
+ return String(input);
19
+ };
20
+
21
+ // Only guard our OWN API surface — same-origin AND under a path the request layer
22
+ // owns (`/api/…` or a `/ctgr<xx>` twin). Restricting to these prefixes (rather than
23
+ // "any same-origin path") matters: same-origin also covers paths we do not own, like
24
+ // Cloudflare's `/cdn-cgi/rum` beacon, which fires a raw POST on every page view —
25
+ // guarding it would break RUM and fire for every ordinary visitor, not just a
26
+ // misbehaving call site.
27
+ const isGuardedPath = (rawUrl: string): boolean => {
28
+ let path: string;
29
+ if (rawUrl.startsWith('/')) {
30
+ path = rawUrl;
31
+ } else {
32
+ try {
33
+ const u = new URL(rawUrl, window.location.href);
34
+ if (u.origin !== window.location.origin) return false;
35
+ path = u.pathname;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+ return path.startsWith('/api/') || path.startsWith('/ctgr');
41
+ };
42
+
43
+ // Deliberately generic: this message can reach the console of ANY visitor, so it says
44
+ // nothing about the request layer, the call site, or what a blocked request was.
45
+ const block = (): Error => {
46
+ const message = 'Service unavailable.';
47
+ console.error(message, new Error('blocked request'));
48
+ return new Error(message);
49
+ };
50
+
51
+ let installed = false;
52
+
53
+ export const installCtgrGuard = (): void => {
54
+ if (installed || typeof window === 'undefined') return;
55
+ installed = true;
56
+
57
+ // ── fetch ──────────────────────────────────────────────────────────────────
58
+ if (typeof window.fetch === 'function') {
59
+ const originalFetch = window.fetch.bind(window);
60
+ const guardedFetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
61
+ const method = (
62
+ init?.method ?? (input instanceof Request ? input.method : 'GET')
63
+ ).toUpperCase();
64
+ const url = urlOf(input);
65
+ if (isCtgrModeOn() && !SAFE_METHODS.has(method) && isGuardedPath(url)) {
66
+ return Promise.reject(block());
67
+ }
68
+ return originalFetch(input, init);
69
+ };
70
+ window.fetch = guardedFetch as typeof window.fetch;
71
+ }
72
+
73
+ // ── XMLHttpRequest ───────────────────────────────────────────────────────────
74
+ if (typeof XMLHttpRequest !== 'undefined') {
75
+ const originalOpen = XMLHttpRequest.prototype.open;
76
+ // biome-ignore lint/suspicious/noExplicitAny: matching XHR.open's variadic overloads
77
+ XMLHttpRequest.prototype.open = function open(method: string, url: string, ...rest: any[]) {
78
+ const upper = String(method).toUpperCase();
79
+ if (isCtgrModeOn() && !SAFE_METHODS.has(upper) && isGuardedPath(String(url))) {
80
+ throw block();
81
+ }
82
+ // biome-ignore lint/suspicious/noExplicitAny: forwarding the original variadic call
83
+ return (originalOpen as any).call(this, method, url, ...rest);
84
+ };
85
+ }
86
+ };
@@ -0,0 +1,21 @@
1
+ /*
2
+ * cursedbelt-core/ctgr-client — the browser adapters that turn any site into a
3
+ * GET-only client:
4
+ * • the per-device mode flag + the `?ctgr=t|f` boot toggle (`ctgrConfig`),
5
+ * • the request converter that rewrites a mutation into `/ctgr<xx>` GET twins,
6
+ * • the network-boundary guard that blocks a stray raw verb.
7
+ *
8
+ * The wire codec is `cursedbelt-core/ctgr`; the server that decodes the twins is
9
+ * `cursedbelt-server` (`mountCtgrTunnel`). A site opts in with two boot calls —
10
+ * `applyCtgrFromLocation()` then `installCtgrGuard()` — and routes its requests
11
+ * through `convertIfOnlyGETRequests`.
12
+ */
13
+ export {
14
+ applyCtgrFromLocation,
15
+ applyCtgrIntent,
16
+ isCtgrModeOn,
17
+ readCtgrIntent,
18
+ setCtgrMode,
19
+ } from './ctgrConfig.js';
20
+ export { type CtgrFetchRequest, convertIfOnlyGETRequests } from './convertRequest.js';
21
+ export { installCtgrGuard } from './ctgrGuard.js';