cursedbelt-core 2.15.0 → 2.16.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/ctgr/codec.js +3 -2
- package/dist/ctgr/crc32.js +3 -1
- package/dist/ctgrClient/ctgrFetch.d.ts +12 -0
- package/dist/ctgrClient/ctgrFetch.js +88 -0
- package/dist/ctgrClient/index.d.ts +1 -0
- package/dist/ctgrClient/index.js +4 -1
- package/package.json +1 -1
- package/src/ctgr/codec.ts +3 -2
- package/src/ctgr/crc32.ts +3 -1
- package/src/ctgrClient/ctgrFetch.spec.ts +111 -0
- package/src/ctgrClient/ctgrFetch.ts +88 -0
- package/src/ctgrClient/index.ts +4 -1
package/dist/ctgr/codec.js
CHANGED
|
@@ -18,8 +18,9 @@ import { sha256Hex } from './digest.js';
|
|
|
18
18
|
import { ABBREV_TO_METHOD, CTGR_WIRE_VERSION, METHOD_TO_ABBREV, } from './types.js';
|
|
19
19
|
const DEFAULT_CHUNK_SIZE = 1800;
|
|
20
20
|
const DEFAULT_GZIP_THRESHOLD = 1024;
|
|
21
|
-
|
|
22
|
-
const
|
|
21
|
+
// Pure, so a bundler can leave the codec out of a chunk that only lazy-loads it (forge task 4805).
|
|
22
|
+
const textEncoder = /* @__PURE__ */ new TextEncoder();
|
|
23
|
+
const textDecoder = /* @__PURE__ */ new TextDecoder();
|
|
23
24
|
/**
|
|
24
25
|
* Encode a mutating request into one or more `/ctgr<XX>` GET-twin chunks. Small
|
|
25
26
|
* bodies become a single chunk; large bodies gzip (when over the threshold) and split
|
package/dist/ctgr/crc32.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* native addon. Client and server share this exact implementation so a chunk's
|
|
4
4
|
* `_crc` integrity check computes the same value on both ends of the tunnel.
|
|
5
5
|
*/
|
|
6
|
-
|
|
6
|
+
// Marked pure so a bundler drops the table with the codec: a client that lazy-loads the codec
|
|
7
|
+
// (`installCtgrFetch`) must not carry it in its entry chunk (forge task 4805).
|
|
8
|
+
const TABLE = /* @__PURE__ */ (() => {
|
|
7
9
|
const table = new Uint32Array(256);
|
|
8
10
|
for (let n = 0; n < 256; n++) {
|
|
9
11
|
let c = n;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The same-origin `/api/…` path (with its query) a URL names, or null when it is another
|
|
3
|
+
* origin or a path the request layer does not own (Cloudflare's `/cdn-cgi/rum` beacon, an
|
|
4
|
+
* asset, a twin that is already converted).
|
|
5
|
+
*/
|
|
6
|
+
export declare const ownApiPath: (rawUrl: string, base: string) => string | null;
|
|
7
|
+
/**
|
|
8
|
+
* Arm the guard, then route every same-origin `/api/` mutation through the GET tunnel while
|
|
9
|
+
* the mode is on. Call once at boot, after `applyCtgrFromLocation()` and before anything
|
|
10
|
+
* fetches. Idempotent; a no-op with no `window.fetch`.
|
|
11
|
+
*/
|
|
12
|
+
export declare const installCtgrFetch: () => void;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* GET-only mode for a whole site in ONE boot call: `installCtgrFetch()`.
|
|
3
|
+
*
|
|
4
|
+
* auth and desk route each request through `convertIfOnlyGETRequests` + `sendCtgrRequests`
|
|
5
|
+
* by hand, inside their own API client. An app whose mutations are spread over dozens of
|
|
6
|
+
* `fetch` call sites (music, 2026-11-09: the owner opened it with `?ctgr=1` on a machine whose
|
|
7
|
+
* network refuses every non-GET, and nothing in it went through the tunnel) would need each
|
|
8
|
+
* one rewritten. This wraps `window.fetch` instead: while the device's mode is on, a mutating
|
|
9
|
+
* request to this origin's own `/api/…` leaves as its `/ctgr<xx>` GET twin(s) and answers with
|
|
10
|
+
* the one real response; everything else — a GET, another origin, a path the app does not own
|
|
11
|
+
* — passes through untouched. With the mode off it is a pass-through.
|
|
12
|
+
*
|
|
13
|
+
* 🔴 It arms the guard FIRST and wraps it, never the other way round: wrapped by the guard,
|
|
14
|
+
* a POST would be refused before it could be converted. The server half is `mountCtgrTunnel`
|
|
15
|
+
* (`cursedbelt-server/ctgr-tunnel`), which re-dispatches each twin into the real route.
|
|
16
|
+
*/
|
|
17
|
+
import { isCtgrModeOn } from './ctgrConfig.js';
|
|
18
|
+
import { installCtgrGuard } from './ctgrGuard.js';
|
|
19
|
+
/**
|
|
20
|
+
* The codec (gzip, sha-256, crc, base64url) loads only on a device that is in the mode: in an
|
|
21
|
+
* app's entry chunk it cost every visitor ~4.5 kB gzip (music, measured 2026-11-09) for a path
|
|
22
|
+
* almost none of them take. A bundler splits it out at this `import()`.
|
|
23
|
+
*/
|
|
24
|
+
const loadTunnel = () => Promise.all([import('./convertRequest.js'), import('./sendRequests.js')]).then(([c, s]) => ({
|
|
25
|
+
convertIfOnlyGETRequests: c.convertIfOnlyGETRequests,
|
|
26
|
+
sendCtgrRequests: s.sendCtgrRequests,
|
|
27
|
+
}));
|
|
28
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD']);
|
|
29
|
+
/**
|
|
30
|
+
* The same-origin `/api/…` path (with its query) a URL names, or null when it is another
|
|
31
|
+
* origin or a path the request layer does not own (Cloudflare's `/cdn-cgi/rum` beacon, an
|
|
32
|
+
* asset, a twin that is already converted).
|
|
33
|
+
*/
|
|
34
|
+
export const ownApiPath = (rawUrl, base) => {
|
|
35
|
+
try {
|
|
36
|
+
const u = new URL(rawUrl, base);
|
|
37
|
+
if (u.origin !== new URL(base).origin)
|
|
38
|
+
return null;
|
|
39
|
+
return u.pathname.startsWith('/api/') ? `${u.pathname}${u.search}` : null;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
/** A `Request`'s body as something the converter carries: its form, or its text. */
|
|
46
|
+
const bodyOfRequest = async (request) => {
|
|
47
|
+
if (!request.body)
|
|
48
|
+
return undefined;
|
|
49
|
+
const type = request.headers.get('content-type') ?? '';
|
|
50
|
+
return type.includes('multipart/form-data') ? request.clone().formData() : request.clone().text();
|
|
51
|
+
};
|
|
52
|
+
let installed = false;
|
|
53
|
+
/**
|
|
54
|
+
* Arm the guard, then route every same-origin `/api/` mutation through the GET tunnel while
|
|
55
|
+
* the mode is on. Call once at boot, after `applyCtgrFromLocation()` and before anything
|
|
56
|
+
* fetches. Idempotent; a no-op with no `window.fetch`.
|
|
57
|
+
*/
|
|
58
|
+
export const installCtgrFetch = () => {
|
|
59
|
+
if (installed || typeof window === 'undefined' || typeof window.fetch !== 'function')
|
|
60
|
+
return;
|
|
61
|
+
installed = true;
|
|
62
|
+
installCtgrGuard();
|
|
63
|
+
const inner = window.fetch.bind(window);
|
|
64
|
+
const tunnelled = async (input, init) => {
|
|
65
|
+
if (!isCtgrModeOn())
|
|
66
|
+
return inner(input, init);
|
|
67
|
+
const request = input instanceof Request ? input : null;
|
|
68
|
+
const method = (init?.method ?? request?.method ?? 'GET').toUpperCase();
|
|
69
|
+
if (SAFE_METHODS.has(method))
|
|
70
|
+
return inner(input, init);
|
|
71
|
+
const href = request ? request.url : input instanceof URL ? input.href : String(input);
|
|
72
|
+
const path = ownApiPath(href, window.location.href);
|
|
73
|
+
if (path === null)
|
|
74
|
+
return inner(input, init);
|
|
75
|
+
const effective = { ...init, method };
|
|
76
|
+
if (request) {
|
|
77
|
+
effective.headers ??= request.headers;
|
|
78
|
+
effective.credentials ??= request.credentials;
|
|
79
|
+
effective.signal ??= request.signal;
|
|
80
|
+
if (init?.body === undefined)
|
|
81
|
+
effective.body = await bodyOfRequest(request);
|
|
82
|
+
}
|
|
83
|
+
const { convertIfOnlyGETRequests, sendCtgrRequests } = await loadTunnel();
|
|
84
|
+
const { requests } = await convertIfOnlyGETRequests(path, effective);
|
|
85
|
+
return sendCtgrRequests(requests, (twin) => inner(twin.url, twin.init));
|
|
86
|
+
};
|
|
87
|
+
window.fetch = tunnelled;
|
|
88
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { applyCtgrFromLocation, applyCtgrIntent, isCtgrModeOn, readCtgrIntent, setCtgrMode, } from './ctgrConfig.js';
|
|
2
2
|
export { type CtgrFetchRequest, convertIfOnlyGETRequests } from './convertRequest.js';
|
|
3
|
+
export { installCtgrFetch, ownApiPath } from './ctgrFetch.js';
|
|
3
4
|
export { installCtgrGuard } from './ctgrGuard.js';
|
|
4
5
|
export { CTGR_INCOMPLETE_STATUS, type CtgrSend, type SendCtgrOptions, sendCtgrRequests, } from './sendRequests.js';
|
package/dist/ctgrClient/index.js
CHANGED
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
* The wire codec is `cursedbelt-core/ctgr`; the server that decodes the twins is
|
|
10
10
|
* `cursedbelt-server` (`mountCtgrTunnel`). A site opts in with two boot calls —
|
|
11
11
|
* `applyCtgrFromLocation()` then `installCtgrGuard()` — and routes its requests
|
|
12
|
-
* through `convertIfOnlyGETRequests`, then `sendCtgrRequests`.
|
|
12
|
+
* through `convertIfOnlyGETRequests`, then `sendCtgrRequests`. A site whose mutations
|
|
13
|
+
* are spread over many `fetch` calls calls `installCtgrFetch()` instead of the guard,
|
|
14
|
+
* and every same-origin `/api/` mutation takes the tunnel with no call site changed.
|
|
13
15
|
*/
|
|
14
16
|
export { applyCtgrFromLocation, applyCtgrIntent, isCtgrModeOn, readCtgrIntent, setCtgrMode, } from './ctgrConfig.js';
|
|
15
17
|
export { convertIfOnlyGETRequests } from './convertRequest.js';
|
|
18
|
+
export { installCtgrFetch, ownApiPath } from './ctgrFetch.js';
|
|
16
19
|
export { installCtgrGuard } from './ctgrGuard.js';
|
|
17
20
|
export { CTGR_INCOMPLETE_STATUS, sendCtgrRequests, } from './sendRequests.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.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.",
|
package/src/ctgr/codec.ts
CHANGED
|
@@ -32,8 +32,9 @@ import {
|
|
|
32
32
|
const DEFAULT_CHUNK_SIZE = 1800;
|
|
33
33
|
const DEFAULT_GZIP_THRESHOLD = 1024;
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
const
|
|
35
|
+
// Pure, so a bundler can leave the codec out of a chunk that only lazy-loads it (forge task 4805).
|
|
36
|
+
const textEncoder = /* @__PURE__ */ new TextEncoder();
|
|
37
|
+
const textDecoder = /* @__PURE__ */ new TextDecoder();
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* Encode a mutating request into one or more `/ctgr<XX>` GET-twin chunks. Small
|
package/src/ctgr/crc32.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* native addon. Client and server share this exact implementation so a chunk's
|
|
4
4
|
* `_crc` integrity check computes the same value on both ends of the tunnel.
|
|
5
5
|
*/
|
|
6
|
-
|
|
6
|
+
// Marked pure so a bundler drops the table with the codec: a client that lazy-loads the codec
|
|
7
|
+
// (`installCtgrFetch`) must not carry it in its entry chunk (forge task 4805).
|
|
8
|
+
const TABLE = /* @__PURE__ */ (() => {
|
|
7
9
|
const table = new Uint32Array(256);
|
|
8
10
|
for (let n = 0; n < 256; n++) {
|
|
9
11
|
let c = n;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { decodeRequest } from '../ctgr/index.js';
|
|
3
|
+
import { installCtgrFetch, ownApiPath, setCtgrMode } from './index.js';
|
|
4
|
+
|
|
5
|
+
/** What reached the network under the shim: the URL and the method, in order. */
|
|
6
|
+
let wire: { url: string; method: string }[] = [];
|
|
7
|
+
const original = window.fetch;
|
|
8
|
+
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
// The "network": records what it was handed and answers 200 with the URL it saw.
|
|
11
|
+
window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
12
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
13
|
+
const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
|
14
|
+
wire.push({ url, method });
|
|
15
|
+
return new Response(JSON.stringify({ url }), { status: 200 });
|
|
16
|
+
}) as typeof window.fetch;
|
|
17
|
+
installCtgrFetch();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterAll(() => {
|
|
21
|
+
window.fetch = original;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
wire = [];
|
|
26
|
+
localStorage.clear();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const decodedTwin = async (url: string) => {
|
|
30
|
+
const decoded = await decodeRequest(new URL(url, 'http://x').searchParams);
|
|
31
|
+
if (decoded === 'partial') throw new Error('expected a single-chunk twin');
|
|
32
|
+
return decoded;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe('installCtgrFetch — every own-API mutation takes the GET tunnel', () => {
|
|
36
|
+
test('mode OFF: a POST goes out as the POST it is', async () => {
|
|
37
|
+
await fetch('/api/plays', { method: 'POST', body: JSON.stringify({ id: 1 }) });
|
|
38
|
+
expect(wire).toEqual([{ url: '/api/plays', method: 'POST' }]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('mode ON: a POST leaves as its GET twin, carrying the body and the query', async () => {
|
|
42
|
+
setCtgrMode(true);
|
|
43
|
+
const res = await fetch('/api/plays?x=1', {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'content-type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({ id: 1 }),
|
|
47
|
+
});
|
|
48
|
+
expect(res.status).toBe(200);
|
|
49
|
+
expect(wire).toHaveLength(1);
|
|
50
|
+
expect(wire[0]?.method).toBe('GET');
|
|
51
|
+
expect(wire[0]?.url.startsWith('/ctgrpo/api/plays?x=1&')).toBe(true);
|
|
52
|
+
const decoded = await decodedTwin(wire[0]?.url ?? '');
|
|
53
|
+
expect(decoded.method).toBe('POST');
|
|
54
|
+
expect(decoded.body).toEqual({ id: 1 });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('mode ON: a Request object is read for its method and body', async () => {
|
|
58
|
+
setCtgrMode(true);
|
|
59
|
+
const url = new URL('/api/playlists/7', window.location.href).href;
|
|
60
|
+
await fetch(new Request(url, { method: 'PATCH', body: JSON.stringify({ name: 'b' }) }));
|
|
61
|
+
expect(wire[0]?.method).toBe('GET');
|
|
62
|
+
const decoded = await decodedTwin(wire[0]?.url ?? '');
|
|
63
|
+
expect(decoded.method).toBe('PATCH');
|
|
64
|
+
expect(decoded.body).toEqual({ name: 'b' });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('mode ON: GETs, other origins and paths outside /api/ pass through untouched', async () => {
|
|
68
|
+
setCtgrMode(true);
|
|
69
|
+
await fetch('/api/library');
|
|
70
|
+
await fetch('https://media.example.com/api/upload', { method: 'POST', body: 'x' });
|
|
71
|
+
await fetch('/cdn-cgi/rum', { method: 'POST', body: 'x' });
|
|
72
|
+
expect(wire).toEqual([
|
|
73
|
+
{ url: '/api/library', method: 'GET' },
|
|
74
|
+
{ url: 'https://media.example.com/api/upload', method: 'POST' },
|
|
75
|
+
{ url: '/cdn-cgi/rum', method: 'POST' },
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('ownApiPath', () => {
|
|
81
|
+
const base = 'https://music.example.com/library';
|
|
82
|
+
test('a same-origin /api/ path, absolute or relative, keeps its query', () => {
|
|
83
|
+
expect(ownApiPath('/api/a?b=1', base)).toBe('/api/a?b=1');
|
|
84
|
+
expect(ownApiPath('https://music.example.com/api/a', base)).toBe('/api/a');
|
|
85
|
+
});
|
|
86
|
+
test('another origin, or a path the app does not own, is null', () => {
|
|
87
|
+
expect(ownApiPath('https://auth.example.com/api/a', base)).toBeNull();
|
|
88
|
+
expect(ownApiPath('/ctgrpo/api/a', base)).toBeNull();
|
|
89
|
+
expect(ownApiPath('/assets/x.js', base)).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('the codec stays out of an entry chunk that only boots the mode (forge task 4805)', () => {
|
|
94
|
+
const read = (rel: string) => Bun.file(new URL(rel, import.meta.url)).text();
|
|
95
|
+
|
|
96
|
+
test('ctgrFetch reaches the converter and the sender only through import()', async () => {
|
|
97
|
+
const src = await read('./ctgrFetch.ts');
|
|
98
|
+
expect(src).not.toMatch(/^import [^;]*from '\.\/(convertRequest|sendRequests)\.js'/m);
|
|
99
|
+
expect(src).toContain("import('./convertRequest.js')");
|
|
100
|
+
expect(src).toContain("import('./sendRequests.js')");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('every top-level `new` or IIFE in the codec is marked pure, so a bundler can drop it', async () => {
|
|
104
|
+
for (const rel of ['../ctgr/codec.ts', '../ctgr/crc32.ts']) {
|
|
105
|
+
const offenders = (await read(rel))
|
|
106
|
+
.split('\n')
|
|
107
|
+
.filter((line) => /^(const|let) \w+ = (new |\()/.test(line));
|
|
108
|
+
expect({ rel, offenders }).toEqual({ rel, offenders: [] });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* GET-only mode for a whole site in ONE boot call: `installCtgrFetch()`.
|
|
3
|
+
*
|
|
4
|
+
* auth and desk route each request through `convertIfOnlyGETRequests` + `sendCtgrRequests`
|
|
5
|
+
* by hand, inside their own API client. An app whose mutations are spread over dozens of
|
|
6
|
+
* `fetch` call sites (music, 2026-11-09: the owner opened it with `?ctgr=1` on a machine whose
|
|
7
|
+
* network refuses every non-GET, and nothing in it went through the tunnel) would need each
|
|
8
|
+
* one rewritten. This wraps `window.fetch` instead: while the device's mode is on, a mutating
|
|
9
|
+
* request to this origin's own `/api/…` leaves as its `/ctgr<xx>` GET twin(s) and answers with
|
|
10
|
+
* the one real response; everything else — a GET, another origin, a path the app does not own
|
|
11
|
+
* — passes through untouched. With the mode off it is a pass-through.
|
|
12
|
+
*
|
|
13
|
+
* 🔴 It arms the guard FIRST and wraps it, never the other way round: wrapped by the guard,
|
|
14
|
+
* a POST would be refused before it could be converted. The server half is `mountCtgrTunnel`
|
|
15
|
+
* (`cursedbelt-server/ctgr-tunnel`), which re-dispatches each twin into the real route.
|
|
16
|
+
*/
|
|
17
|
+
import { isCtgrModeOn } from './ctgrConfig.js';
|
|
18
|
+
import { installCtgrGuard } from './ctgrGuard.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The codec (gzip, sha-256, crc, base64url) loads only on a device that is in the mode: in an
|
|
22
|
+
* app's entry chunk it cost every visitor ~4.5 kB gzip (music, measured 2026-11-09) for a path
|
|
23
|
+
* almost none of them take. A bundler splits it out at this `import()`.
|
|
24
|
+
*/
|
|
25
|
+
const loadTunnel = () =>
|
|
26
|
+
Promise.all([import('./convertRequest.js'), import('./sendRequests.js')]).then(([c, s]) => ({
|
|
27
|
+
convertIfOnlyGETRequests: c.convertIfOnlyGETRequests,
|
|
28
|
+
sendCtgrRequests: s.sendCtgrRequests,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD']);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The same-origin `/api/…` path (with its query) a URL names, or null when it is another
|
|
35
|
+
* origin or a path the request layer does not own (Cloudflare's `/cdn-cgi/rum` beacon, an
|
|
36
|
+
* asset, a twin that is already converted).
|
|
37
|
+
*/
|
|
38
|
+
export const ownApiPath = (rawUrl: string, base: string): string | null => {
|
|
39
|
+
try {
|
|
40
|
+
const u = new URL(rawUrl, base);
|
|
41
|
+
if (u.origin !== new URL(base).origin) return null;
|
|
42
|
+
return u.pathname.startsWith('/api/') ? `${u.pathname}${u.search}` : null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** A `Request`'s body as something the converter carries: its form, or its text. */
|
|
49
|
+
const bodyOfRequest = async (request: Request): Promise<BodyInit | undefined> => {
|
|
50
|
+
if (!request.body) return undefined;
|
|
51
|
+
const type = request.headers.get('content-type') ?? '';
|
|
52
|
+
return type.includes('multipart/form-data') ? request.clone().formData() : request.clone().text();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
let installed = false;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Arm the guard, then route every same-origin `/api/` mutation through the GET tunnel while
|
|
59
|
+
* the mode is on. Call once at boot, after `applyCtgrFromLocation()` and before anything
|
|
60
|
+
* fetches. Idempotent; a no-op with no `window.fetch`.
|
|
61
|
+
*/
|
|
62
|
+
export const installCtgrFetch = (): void => {
|
|
63
|
+
if (installed || typeof window === 'undefined' || typeof window.fetch !== 'function') return;
|
|
64
|
+
installed = true;
|
|
65
|
+
installCtgrGuard();
|
|
66
|
+
const inner = window.fetch.bind(window);
|
|
67
|
+
const tunnelled = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
68
|
+
if (!isCtgrModeOn()) return inner(input, init);
|
|
69
|
+
const request = input instanceof Request ? input : null;
|
|
70
|
+
const method = (init?.method ?? request?.method ?? 'GET').toUpperCase();
|
|
71
|
+
if (SAFE_METHODS.has(method)) return inner(input, init);
|
|
72
|
+
const href = request ? request.url : input instanceof URL ? input.href : String(input);
|
|
73
|
+
const path = ownApiPath(href, window.location.href);
|
|
74
|
+
if (path === null) return inner(input, init);
|
|
75
|
+
|
|
76
|
+
const effective: RequestInit = { ...init, method };
|
|
77
|
+
if (request) {
|
|
78
|
+
effective.headers ??= request.headers;
|
|
79
|
+
effective.credentials ??= request.credentials;
|
|
80
|
+
effective.signal ??= request.signal;
|
|
81
|
+
if (init?.body === undefined) effective.body = await bodyOfRequest(request);
|
|
82
|
+
}
|
|
83
|
+
const { convertIfOnlyGETRequests, sendCtgrRequests } = await loadTunnel();
|
|
84
|
+
const { requests } = await convertIfOnlyGETRequests(path, effective);
|
|
85
|
+
return sendCtgrRequests(requests, (twin) => inner(twin.url, twin.init));
|
|
86
|
+
};
|
|
87
|
+
window.fetch = tunnelled as typeof window.fetch;
|
|
88
|
+
};
|
package/src/ctgrClient/index.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* The wire codec is `cursedbelt-core/ctgr`; the server that decodes the twins is
|
|
10
10
|
* `cursedbelt-server` (`mountCtgrTunnel`). A site opts in with two boot calls —
|
|
11
11
|
* `applyCtgrFromLocation()` then `installCtgrGuard()` — and routes its requests
|
|
12
|
-
* through `convertIfOnlyGETRequests`, then `sendCtgrRequests`.
|
|
12
|
+
* through `convertIfOnlyGETRequests`, then `sendCtgrRequests`. A site whose mutations
|
|
13
|
+
* are spread over many `fetch` calls calls `installCtgrFetch()` instead of the guard,
|
|
14
|
+
* and every same-origin `/api/` mutation takes the tunnel with no call site changed.
|
|
13
15
|
*/
|
|
14
16
|
export {
|
|
15
17
|
applyCtgrFromLocation,
|
|
@@ -19,6 +21,7 @@ export {
|
|
|
19
21
|
setCtgrMode,
|
|
20
22
|
} from './ctgrConfig.js';
|
|
21
23
|
export { type CtgrFetchRequest, convertIfOnlyGETRequests } from './convertRequest.js';
|
|
24
|
+
export { installCtgrFetch, ownApiPath } from './ctgrFetch.js';
|
|
22
25
|
export { installCtgrGuard } from './ctgrGuard.js';
|
|
23
26
|
export {
|
|
24
27
|
CTGR_INCOMPLETE_STATUS,
|