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.
- package/dist/core/offline/shellWorker.d.ts +21 -2
- package/dist/core/offline/shellWorker.js +43 -2
- package/dist/ctgrClient/convertRequest.d.ts +18 -0
- package/dist/ctgrClient/convertRequest.js +98 -0
- package/dist/ctgrClient/ctgrConfig.d.ts +23 -0
- package/dist/ctgrClient/ctgrConfig.js +94 -0
- package/dist/ctgrClient/ctgrGuard.d.ts +1 -0
- package/dist/ctgrClient/ctgrGuard.js +83 -0
- package/dist/ctgrClient/index.d.ts +3 -0
- package/dist/ctgrClient/index.js +15 -0
- package/package.json +7 -1
- package/src/core/offline/shellWorker.spec.ts +43 -8
- package/src/core/offline/shellWorker.ts +45 -3
- package/src/ctgrClient/convertRequest.ts +123 -0
- package/src/ctgrClient/ctgrClient.spec.ts +135 -0
- package/src/ctgrClient/ctgrConfig.ts +89 -0
- package/src/ctgrClient/ctgrGuard.ts +86 -0
- package/src/ctgrClient/index.ts +21 -0
|
@@ -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
|
|
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
|
|
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,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.
|
|
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-
|
|
359
|
-
expect((await world.dispatch(req('/assets/app-
|
|
360
|
-
expect((await world.dispatch(req('/assets/missing.js')).settled())?.ok).toBe(false);
|
|
361
|
-
expect(world.cacheWrites).toEqual([`${ORIGIN}/assets/app-
|
|
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-
|
|
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
|
|
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';
|