cursedbelt-core 1.0.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/LICENSE +15 -0
- package/dist/client/wsWire.d.ts +49 -0
- package/dist/client/wsWire.js +57 -0
- package/dist/config/appConfig.d.ts +361 -0
- package/dist/config/appConfig.js +308 -0
- package/dist/core/activity/index.d.ts +8 -0
- package/dist/core/activity/index.js +7 -0
- package/dist/core/activity/model.d.ts +115 -0
- package/dist/core/activity/model.js +63 -0
- package/dist/core/analytics/index.d.ts +18 -0
- package/dist/core/analytics/index.js +18 -0
- package/dist/core/analytics/range.d.ts +186 -0
- package/dist/core/analytics/range.js +271 -0
- package/dist/core/analytics/schedule.d.ts +301 -0
- package/dist/core/analytics/schedule.js +372 -0
- package/dist/core/category/index.d.ts +3 -0
- package/dist/core/category/index.js +6 -0
- package/dist/core/category/memoryStore.d.ts +21 -0
- package/dist/core/category/memoryStore.js +97 -0
- package/dist/core/category/tree.d.ts +26 -0
- package/dist/core/category/tree.js +113 -0
- package/dist/core/category/types.d.ts +48 -0
- package/dist/core/category/types.js +8 -0
- package/dist/core/color.d.ts +14 -0
- package/dist/core/color.js +32 -0
- package/dist/core/colorScheme/colorSchemeStore.d.ts +70 -0
- package/dist/core/colorScheme/colorSchemeStore.js +123 -0
- package/dist/core/colorScheme/index.d.ts +7 -0
- package/dist/core/colorScheme/index.js +7 -0
- package/dist/core/domainOwners.d.ts +19 -0
- package/dist/core/domainOwners.js +15 -0
- package/dist/core/download.d.ts +14 -0
- package/dist/core/download.js +37 -0
- package/dist/core/events/ccEventBus.d.ts +44 -0
- package/dist/core/events/ccEventBus.js +70 -0
- package/dist/core/file-tree/fileTreeModel.d.ts +276 -0
- package/dist/core/file-tree/fileTreeModel.js +312 -0
- package/dist/core/folder-tree/actions.d.ts +84 -0
- package/dist/core/folder-tree/actions.js +119 -0
- package/dist/core/folder-tree/index.d.ts +3 -0
- package/dist/core/folder-tree/index.js +20 -0
- package/dist/core/folder-tree/path.d.ts +76 -0
- package/dist/core/folder-tree/path.js +146 -0
- package/dist/core/folder-tree/tree.d.ts +86 -0
- package/dist/core/folder-tree/tree.js +184 -0
- package/dist/core/layout/index.d.ts +1 -0
- package/dist/core/layout/index.js +7 -0
- package/dist/core/master-lock/index.d.ts +14 -0
- package/dist/core/master-lock/index.js +14 -0
- package/dist/core/master-lock/kdf.d.ts +63 -0
- package/dist/core/master-lock/kdf.js +117 -0
- package/dist/core/master-lock/policy.d.ts +32 -0
- package/dist/core/master-lock/policy.js +52 -0
- package/dist/core/master-lock/presence.d.ts +52 -0
- package/dist/core/master-lock/presence.js +61 -0
- package/dist/core/master-lock/wire.d.ts +139 -0
- package/dist/core/master-lock/wire.js +81 -0
- package/dist/core/media/bgRemoval.d.ts +27 -0
- package/dist/core/media/bgRemoval.js +101 -0
- package/dist/core/media/ffmpeg.d.ts +57 -0
- package/dist/core/media/ffmpeg.js +126 -0
- package/dist/core/media/format.d.ts +20 -0
- package/dist/core/media/format.js +54 -0
- package/dist/core/media/index.d.ts +12 -0
- package/dist/core/media/index.js +18 -0
- package/dist/core/media/mediaItem.d.ts +15 -0
- package/dist/core/media/mediaItem.js +34 -0
- package/dist/core/media/renderPlan.d.ts +63 -0
- package/dist/core/media/renderPlan.js +232 -0
- package/dist/core/media/segment.d.ts +79 -0
- package/dist/core/media/segment.js +247 -0
- package/dist/core/media/size.d.ts +1 -0
- package/dist/core/media/size.js +3 -0
- package/dist/core/media/trim.d.ts +21 -0
- package/dist/core/media/trim.js +40 -0
- package/dist/core/media/types.d.ts +148 -0
- package/dist/core/media/types.js +11 -0
- package/dist/core/media/uploadLimits.d.ts +7 -0
- package/dist/core/media/uploadLimits.js +16 -0
- package/dist/core/media/validate.d.ts +9 -0
- package/dist/core/media/validate.js +121 -0
- package/dist/core/media/videoCuts.d.ts +152 -0
- package/dist/core/media/videoCuts.js +284 -0
- package/dist/core/navigation/breadcrumbCompat.d.ts +24 -0
- package/dist/core/navigation/breadcrumbCompat.js +21 -0
- package/dist/core/navigation/coverageTest.d.ts +24 -0
- package/dist/core/navigation/coverageTest.js +48 -0
- package/dist/core/navigation/index.d.ts +6 -0
- package/dist/core/navigation/index.js +10 -0
- package/dist/core/navigation/migrateNavPreferences.d.ts +55 -0
- package/dist/core/navigation/migrateNavPreferences.js +63 -0
- package/dist/core/navigation/navigationModel.d.ts +165 -0
- package/dist/core/navigation/navigationModel.js +14 -0
- package/dist/core/navigation/navigationSelectors.d.ts +63 -0
- package/dist/core/navigation/navigationSelectors.js +167 -0
- package/dist/core/navigation/searchCatalogue.d.ts +23 -0
- package/dist/core/navigation/searchCatalogue.js +76 -0
- package/dist/core/net/reconnectSchedule.d.ts +46 -0
- package/dist/core/net/reconnectSchedule.js +38 -0
- package/dist/core/palette/ShortcutRegistry.d.ts +33 -0
- package/dist/core/palette/ShortcutRegistry.js +52 -0
- package/dist/core/palette/fuzzyRank.d.ts +18 -0
- package/dist/core/palette/fuzzyRank.js +73 -0
- package/dist/core/palette/hotkeys.d.ts +28 -0
- package/dist/core/palette/hotkeys.js +118 -0
- package/dist/core/palette/index.d.ts +3 -0
- package/dist/core/palette/index.js +8 -0
- package/dist/core/schema/projection.d.ts +84 -0
- package/dist/core/schema/projection.js +184 -0
- package/dist/core/session/claims.d.ts +145 -0
- package/dist/core/session/claims.js +157 -0
- package/dist/core/sharing/index.d.ts +5 -0
- package/dist/core/sharing/index.js +4 -0
- package/dist/core/sharing/model.d.ts +195 -0
- package/dist/core/sharing/model.js +150 -0
- package/dist/core/site/siteCopy.d.ts +92 -0
- package/dist/core/site/siteCopy.js +91 -0
- package/dist/core/slots.d.ts +22 -0
- package/dist/core/slots.js +31 -0
- package/dist/core/styles/constructableSheet.d.ts +51 -0
- package/dist/core/styles/constructableSheet.js +80 -0
- package/dist/core/tokens/registry.d.ts +119 -0
- package/dist/core/tokens/registry.js +447 -0
- package/dist/core/uiScale/index.d.ts +8 -0
- package/dist/core/uiScale/index.js +8 -0
- package/dist/core/uiScale/scale.d.ts +36 -0
- package/dist/core/uiScale/scale.js +47 -0
- package/dist/core/uiScale/uiScaleStore.d.ts +47 -0
- package/dist/core/uiScale/uiScaleStore.js +63 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.d.ts +1 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +23 -0
- package/dist/core/wire/index.d.ts +17 -0
- package/dist/core/wire/index.js +17 -0
- package/dist/core/wire/ingress.d.ts +44 -0
- package/dist/core/wire/ingress.js +134 -0
- package/dist/core/wire/marshalWorker.d.ts +70 -0
- package/dist/core/wire/marshalWorker.js +273 -0
- package/dist/core/wire/marshalling.d.ts +122 -0
- package/dist/core/wire/marshalling.js +126 -0
- package/dist/core/wire/wireHub.d.ts +80 -0
- package/dist/core/wire/wireHub.js +116 -0
- package/dist/ctgr/base64url.d.ts +8 -0
- package/dist/ctgr/base64url.js +39 -0
- package/dist/ctgr/chunkAccumulator.d.ts +214 -0
- package/dist/ctgr/chunkAccumulator.js +307 -0
- package/dist/ctgr/chunkStore.d.ts +24 -0
- package/dist/ctgr/chunkStore.js +125 -0
- package/dist/ctgr/codec.d.ts +44 -0
- package/dist/ctgr/codec.js +153 -0
- package/dist/ctgr/compress.d.ts +2 -0
- package/dist/ctgr/compress.js +20 -0
- package/dist/ctgr/crc32.d.ts +2 -0
- package/dist/ctgr/crc32.js +23 -0
- package/dist/ctgr/digest.d.ts +1 -0
- package/dist/ctgr/digest.js +15 -0
- package/dist/ctgr/index.d.ts +9 -0
- package/dist/ctgr/index.js +17 -0
- package/dist/ctgr/types.d.ts +64 -0
- package/dist/ctgr/types.js +21 -0
- package/dist/ctgr/v0compat.d.ts +27 -0
- package/dist/ctgr/v0compat.js +128 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +17 -0
- package/dist/shared/errors.d.ts +101 -0
- package/dist/shared/errors.js +99 -0
- package/dist/testing/bytes.d.ts +32 -0
- package/dist/testing/bytes.js +33 -0
- package/dist/testing/domInspect.d.ts +59 -0
- package/dist/testing/domInspect.js +115 -0
- package/dist/testing/fakeFetch.d.ts +23 -0
- package/dist/testing/fakeFetch.js +21 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.js +11 -0
- package/package.json +373 -0
- package/src/client/wsWire.ts +83 -0
- package/src/config/appConfig.spec.ts +158 -0
- package/src/config/appConfig.ts +372 -0
- package/src/core/activity/index.ts +21 -0
- package/src/core/activity/model.ts +160 -0
- package/src/core/analytics/index.ts +18 -0
- package/src/core/analytics/range.spec.ts +280 -0
- package/src/core/analytics/range.ts +383 -0
- package/src/core/analytics/schedule.spec.ts +311 -0
- package/src/core/analytics/schedule.ts +624 -0
- package/src/core/category/category.spec.ts +144 -0
- package/src/core/category/index.ts +19 -0
- package/src/core/category/memoryStore.ts +104 -0
- package/src/core/category/tree.ts +118 -0
- package/src/core/category/types.ts +59 -0
- package/src/core/color.spec.ts +35 -0
- package/src/core/color.ts +35 -0
- package/src/core/colorScheme/colorScheme.spec.ts +127 -0
- package/src/core/colorScheme/colorSchemeStore.ts +184 -0
- package/src/core/colorScheme/index.ts +23 -0
- package/src/core/domainOwners.ts +32 -0
- package/src/core/download.spec.ts +38 -0
- package/src/core/download.ts +35 -0
- package/src/core/events/ccEventBus.spec.ts +90 -0
- package/src/core/events/ccEventBus.ts +120 -0
- package/src/core/file-tree/fileTreeModel.spec.ts +462 -0
- package/src/core/file-tree/fileTreeModel.ts +511 -0
- package/src/core/folder-tree/actions.spec.ts +103 -0
- package/src/core/folder-tree/actions.ts +153 -0
- package/src/core/folder-tree/index.ts +46 -0
- package/src/core/folder-tree/path.spec.ts +113 -0
- package/src/core/folder-tree/path.ts +148 -0
- package/src/core/folder-tree/tree.spec.ts +157 -0
- package/src/core/folder-tree/tree.ts +244 -0
- package/src/core/layout/index.ts +7 -0
- package/src/core/master-lock/index.ts +50 -0
- package/src/core/master-lock/kdf.spec.ts +95 -0
- package/src/core/master-lock/kdf.ts +145 -0
- package/src/core/master-lock/policy.ts +55 -0
- package/src/core/master-lock/presence.spec.ts +73 -0
- package/src/core/master-lock/presence.ts +71 -0
- package/src/core/master-lock/wire.ts +155 -0
- package/src/core/media/bgRemoval.spec.ts +120 -0
- package/src/core/media/bgRemoval.ts +123 -0
- package/src/core/media/ffmpeg.spec.ts +135 -0
- package/src/core/media/ffmpeg.ts +162 -0
- package/src/core/media/format.spec.ts +41 -0
- package/src/core/media/format.ts +58 -0
- package/src/core/media/index.ts +105 -0
- package/src/core/media/mediaItem.spec.ts +46 -0
- package/src/core/media/mediaItem.ts +40 -0
- package/src/core/media/renderPlan.spec.ts +140 -0
- package/src/core/media/renderPlan.ts +318 -0
- package/src/core/media/segment.spec.ts +159 -0
- package/src/core/media/segment.ts +352 -0
- package/src/core/media/size.spec.ts +19 -0
- package/src/core/media/size.ts +3 -0
- package/src/core/media/trim.spec.ts +75 -0
- package/src/core/media/trim.ts +60 -0
- package/src/core/media/types.ts +147 -0
- package/src/core/media/uploadLimits.ts +18 -0
- package/src/core/media/validate.spec.ts +106 -0
- package/src/core/media/validate.ts +131 -0
- package/src/core/media/videoCuts.spec.ts +327 -0
- package/src/core/media/videoCuts.ts +355 -0
- package/src/core/navigation/breadcrumbCompat.ts +44 -0
- package/src/core/navigation/coverageTest.ts +54 -0
- package/src/core/navigation/index.ts +50 -0
- package/src/core/navigation/migrateNavPreferences.ts +119 -0
- package/src/core/navigation/navigation.spec.ts +388 -0
- package/src/core/navigation/navigationModel.ts +185 -0
- package/src/core/navigation/navigationSelectors.ts +206 -0
- package/src/core/navigation/searchCatalogue.ts +89 -0
- package/src/core/net/reconnectSchedule.spec.ts +49 -0
- package/src/core/net/reconnectSchedule.ts +78 -0
- package/src/core/palette/ShortcutRegistry.ts +69 -0
- package/src/core/palette/fuzzyRank.ts +78 -0
- package/src/core/palette/hotkeys.ts +132 -0
- package/src/core/palette/index.ts +22 -0
- package/src/core/palette/palette.spec.ts +160 -0
- package/src/core/schema/projection.spec.ts +122 -0
- package/src/core/schema/projection.ts +225 -0
- package/src/core/session/claims.ts +181 -0
- package/src/core/sharing/index.ts +33 -0
- package/src/core/sharing/model.spec.ts +104 -0
- package/src/core/sharing/model.ts +296 -0
- package/src/core/site/siteCopy.ts +115 -0
- package/src/core/slots.ts +38 -0
- package/src/core/styles/constructableSheet.ts +86 -0
- package/src/core/tokens/registry.spec.ts +160 -0
- package/src/core/tokens/registry.ts +536 -0
- package/src/core/uiScale/index.ts +32 -0
- package/src/core/uiScale/scale.ts +63 -0
- package/src/core/uiScale/uiScale.spec.ts +77 -0
- package/src/core/uiScale/uiScaleStore.ts +100 -0
- package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +24 -0
- package/src/core/wire/index.ts +56 -0
- package/src/core/wire/ingress.spec.ts +276 -0
- package/src/core/wire/ingress.ts +182 -0
- package/src/core/wire/marshalWorker.spec.ts +196 -0
- package/src/core/wire/marshalWorker.ts +361 -0
- package/src/core/wire/marshalling.spec.ts +139 -0
- package/src/core/wire/marshalling.ts +238 -0
- package/src/core/wire/wireHub.spec.ts +137 -0
- package/src/core/wire/wireHub.ts +213 -0
- package/src/ctgr/base64url.ts +44 -0
- package/src/ctgr/chunkAccumulator.ts +403 -0
- package/src/ctgr/chunkStore.spec.ts +101 -0
- package/src/ctgr/chunkStore.ts +168 -0
- package/src/ctgr/codec.spec.ts +208 -0
- package/src/ctgr/codec.ts +202 -0
- package/src/ctgr/compress.ts +21 -0
- package/src/ctgr/crc32.ts +23 -0
- package/src/ctgr/digest.ts +14 -0
- package/src/ctgr/index.ts +39 -0
- package/src/ctgr/types.ts +80 -0
- package/src/ctgr/v0compat.spec.ts +73 -0
- package/src/ctgr/v0compat.ts +156 -0
- package/src/declaredDepsAreImported.spec.ts +87 -0
- package/src/index.ts +25 -0
- package/src/leafSubpathsImportNothing.spec.ts +287 -0
- package/src/lockfileIsTracked.spec.ts +50 -0
- package/src/namedSubpathsResolve.spec.ts +122 -0
- package/src/noPathDeps.spec.ts +24 -0
- package/src/publishShape.spec.ts +89 -0
- package/src/shared/errors.ts +153 -0
- package/src/skippedTestsAreAnswered.spec.ts +221 -0
- package/src/testTmpRoot.spec.ts +102 -0
- package/src/testing/bytes.ts +38 -0
- package/src/testing/domInspect.spec.ts +79 -0
- package/src/testing/domInspect.ts +127 -0
- package/src/testing/fakeFetch.ts +26 -0
- package/src/testing/index.ts +11 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { toBase64 } from './base64url';
|
|
3
|
+
import { ChunkStore } from './chunkStore';
|
|
4
|
+
import { decodePayload, decodeRequest, encodeRequest, extractFiles, parseChunk } from './codec';
|
|
5
|
+
import { ABBREV_TO_METHOD } from './types';
|
|
6
|
+
|
|
7
|
+
const paramsOf = (url: string) => Object.fromEntries(new URL(url, 'http://x').searchParams);
|
|
8
|
+
|
|
9
|
+
// Deterministic, gzip-resistant payload (an LCG over printable ASCII) — used to force
|
|
10
|
+
// genuine multi-chunk splits even with compression on.
|
|
11
|
+
const incompressible = (n: number): string => {
|
|
12
|
+
let s = '';
|
|
13
|
+
let x = 0x12345;
|
|
14
|
+
for (let i = 0; i < n; i++) {
|
|
15
|
+
x = (x * 1103515245 + 12345) & 0x7fffffff;
|
|
16
|
+
s += String.fromCharCode(33 + (x % 90));
|
|
17
|
+
}
|
|
18
|
+
return s;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('encodeRequest / decodeRequest', () => {
|
|
22
|
+
test('single chunk, no gzip — round-trips body + method', async () => {
|
|
23
|
+
const req = await encodeRequest('POST', '/api/v1/notes', { body: 'hello' });
|
|
24
|
+
expect(req.chunks).toHaveLength(1);
|
|
25
|
+
expect(req.chunks[0].url.startsWith('/ctgrpo/api/v1/notes?')).toBe(true);
|
|
26
|
+
expect(req.chunks[0].params._cv).toBe('1');
|
|
27
|
+
expect(req.chunks[0].params._cg).toBe('0');
|
|
28
|
+
expect(req.chunks[0].params._sha).toBeDefined();
|
|
29
|
+
|
|
30
|
+
const decoded = await decodeRequest(new URL(req.chunks[0].url, 'http://x').searchParams);
|
|
31
|
+
expect(decoded).not.toBe('partial');
|
|
32
|
+
if (decoded === 'partial') throw new Error('unreachable');
|
|
33
|
+
expect(decoded.body).toEqual({ body: 'hello' });
|
|
34
|
+
expect(decoded.method).toBe('POST');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the chunk URL is well-formed and URL-safe (no encoding blowup)', async () => {
|
|
38
|
+
const req = await encodeRequest('DELETE', '/things/1', { a: 1 });
|
|
39
|
+
const [chunk] = req.chunks;
|
|
40
|
+
// _d is base64url: only [A-Za-z0-9_-]; URLSearchParams must not have re-encoded it.
|
|
41
|
+
expect(chunk.params._d).toMatch(/^[A-Za-z0-9_-]*$/);
|
|
42
|
+
expect(new URL(chunk.url, 'http://x').searchParams.get('_d')).toBe(chunk.params._d);
|
|
43
|
+
expect(chunk.params._cm).toBe('de');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('every method maps to its twin abbreviation', async () => {
|
|
47
|
+
for (const [abbrev, method] of Object.entries(ABBREV_TO_METHOD)) {
|
|
48
|
+
const req = await encodeRequest(method, '/x', { ok: true });
|
|
49
|
+
expect(req.chunks[0].url.startsWith(`/ctgr${abbrev}/x?`)).toBe(true);
|
|
50
|
+
expect(req.chunks[0].params._cm).toBe(abbrev);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('round-trips unicode + emoji', async () => {
|
|
55
|
+
const body = { msg: 'héllo — 世界 🎲', tags: ['café', 'naïve'] };
|
|
56
|
+
const req = await encodeRequest('PUT', '/x', body);
|
|
57
|
+
const decoded = await decodeRequest(req.chunks[0].params);
|
|
58
|
+
expect(decoded).not.toBe('partial');
|
|
59
|
+
if (decoded === 'partial') throw new Error('unreachable');
|
|
60
|
+
expect(decoded.body).toEqual(body);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a body-less mutation encodes as a null body', async () => {
|
|
64
|
+
const req = await encodeRequest('DELETE', '/things/9', undefined);
|
|
65
|
+
const decoded = await decodeRequest(req.chunks[0].params);
|
|
66
|
+
if (decoded === 'partial') throw new Error('unreachable');
|
|
67
|
+
expect(decoded.method).toBe('DELETE');
|
|
68
|
+
expect(decoded.body).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('gzip flips on past the threshold and shrinks the payload', async () => {
|
|
72
|
+
const med = { data: 'x'.repeat(2000) };
|
|
73
|
+
const req = await encodeRequest('POST', '/x', med, { gzipThresholdBytes: 1000 });
|
|
74
|
+
expect(req.chunks[0].params._cg).toBe('1');
|
|
75
|
+
const decoded = await decodeRequest(req.chunks[0].params);
|
|
76
|
+
if (decoded === 'partial') throw new Error('unreachable');
|
|
77
|
+
expect(decoded.body).toEqual(med);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('large payload splits into multiple chunks; decodeRequest returns partial', async () => {
|
|
81
|
+
// gzip disabled → deterministic chunk count from the raw JSON size.
|
|
82
|
+
const big = { data: 'x'.repeat(10000) };
|
|
83
|
+
const req = await encodeRequest('PATCH', '/api/v1/lists/123', big, {
|
|
84
|
+
chunkSizeBytes: 500,
|
|
85
|
+
gzipThresholdBytes: 10_000_000,
|
|
86
|
+
});
|
|
87
|
+
expect(req.chunks.length).toBeGreaterThan(1);
|
|
88
|
+
// A lone non-final chunk cannot be decoded standalone.
|
|
89
|
+
expect(await decodeRequest(req.chunks[0].params)).toBe('partial');
|
|
90
|
+
|
|
91
|
+
const store = new ChunkStore();
|
|
92
|
+
for (const chunk of req.chunks.slice(0, -1)) {
|
|
93
|
+
expect(await store.add(chunk.params)).toBe(false);
|
|
94
|
+
}
|
|
95
|
+
const result = await store.add(req.chunks.at(-1)!.params);
|
|
96
|
+
expect(result).not.toBe(false);
|
|
97
|
+
expect(result).not.toBeInstanceOf(Error);
|
|
98
|
+
expect((result as { body: unknown }).body).toEqual(big);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('gzip + multi-chunk together: incompressible data still round-trips', async () => {
|
|
102
|
+
const big = { blob: incompressible(6000) };
|
|
103
|
+
const req = await encodeRequest('POST', '/x', big, {
|
|
104
|
+
chunkSizeBytes: 800,
|
|
105
|
+
gzipThresholdBytes: 1024,
|
|
106
|
+
});
|
|
107
|
+
expect(req.chunks.length).toBeGreaterThan(1); // gzip couldn't shrink it under one chunk
|
|
108
|
+
expect(req.chunks[0].params._cg).toBe('1');
|
|
109
|
+
|
|
110
|
+
const store = new ChunkStore();
|
|
111
|
+
let result: unknown = false;
|
|
112
|
+
for (const chunk of req.chunks) result = await store.add(chunk.params);
|
|
113
|
+
expect((result as { body: unknown }).body).toEqual(big);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('reconstructs files from the __files body shape', async () => {
|
|
117
|
+
const bytes = new Uint8Array([1, 2, 3, 4, 250, 0, 99]);
|
|
118
|
+
const body = {
|
|
119
|
+
title: 'memo',
|
|
120
|
+
__files: [
|
|
121
|
+
{
|
|
122
|
+
field: 'doc',
|
|
123
|
+
name: 'a.bin',
|
|
124
|
+
type: 'application/octet-stream',
|
|
125
|
+
dataBase64: toBase64(bytes),
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
const req = await encodeRequest('POST', '/files', body);
|
|
130
|
+
const decoded = await decodeRequest(req.chunks[0].params);
|
|
131
|
+
if (decoded === 'partial') throw new Error('unreachable');
|
|
132
|
+
expect(decoded.body).toEqual({ title: 'memo' });
|
|
133
|
+
expect(decoded.files).toHaveLength(1);
|
|
134
|
+
expect(decoded.files?.[0]).toMatchObject({
|
|
135
|
+
fieldname: 'doc',
|
|
136
|
+
originalname: 'a.bin',
|
|
137
|
+
mimetype: 'application/octet-stream',
|
|
138
|
+
size: bytes.length,
|
|
139
|
+
});
|
|
140
|
+
expect(Array.from(decoded.files![0].buffer)).toEqual(Array.from(bytes));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('extractFiles leaves a plain body untouched', () => {
|
|
144
|
+
expect(extractFiles({ a: 1 })).toEqual({ body: { a: 1 } });
|
|
145
|
+
expect(extractFiles(null)).toEqual({ body: null });
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('integrity + validation', () => {
|
|
150
|
+
test('a chunk whose declared crc does not match its bytes is rejected', async () => {
|
|
151
|
+
const req = await encodeRequest('POST', '/x', { hello: 'world' });
|
|
152
|
+
const tampered = { ...req.chunks[0].params, _crc: '123' }; // valid base64, wrong crc
|
|
153
|
+
expect(() => parseChunk(tampered)).toThrow(/crc/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('a structurally invalid _d is rejected', async () => {
|
|
157
|
+
const req = await encodeRequest('POST', '/x', { hello: 'world' });
|
|
158
|
+
const tampered = { ...req.chunks[0].params, _d: '!!!not base64!!!' };
|
|
159
|
+
expect(() => parseChunk(tampered)).toThrow();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('a tampered sha256 throws on single-chunk decode', async () => {
|
|
163
|
+
const req = await encodeRequest('POST', '/x', { hello: 'world' });
|
|
164
|
+
const tampered = { ...req.chunks[0].params, _sha: 'deadbeef' };
|
|
165
|
+
await expect(decodeRequest(tampered)).rejects.toThrow(/sha256/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('an unknown wire version is rejected', () => {
|
|
169
|
+
expect(() =>
|
|
170
|
+
parseChunk({
|
|
171
|
+
_cv: '9',
|
|
172
|
+
_cm: 'po',
|
|
173
|
+
_ci: '0',
|
|
174
|
+
_ct: '1',
|
|
175
|
+
_cl: '0',
|
|
176
|
+
_cid: 'x',
|
|
177
|
+
_crc: '0',
|
|
178
|
+
_d: '',
|
|
179
|
+
}),
|
|
180
|
+
).toThrow(/version/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('an unknown method abbreviation is rejected', () => {
|
|
184
|
+
expect(() =>
|
|
185
|
+
parseChunk({
|
|
186
|
+
_cv: '1',
|
|
187
|
+
_cm: 'zz',
|
|
188
|
+
_ci: '0',
|
|
189
|
+
_ct: '1',
|
|
190
|
+
_cl: '0',
|
|
191
|
+
_cid: 'x',
|
|
192
|
+
_crc: '0',
|
|
193
|
+
_d: '',
|
|
194
|
+
}),
|
|
195
|
+
).toThrow(/method/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('decodePayload is the inverse of the encode pipeline (plain + gzip)', async () => {
|
|
199
|
+
const enc = new TextEncoder();
|
|
200
|
+
const plain = enc.encode(JSON.stringify({ a: 1 }));
|
|
201
|
+
expect((await decodePayload(plain, false, 'POST')).body).toEqual({ a: 1 });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('paramsOf parses a real chunk url', async () => {
|
|
205
|
+
const req = await encodeRequest('POST', '/x', { z: 9 });
|
|
206
|
+
expect(paramsOf(req.chunks[0].url)._cv).toBe('1');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ctgr v1 codec — pack ANY mutating request body into URL-safe GET-twin chunks, and
|
|
3
|
+
* the pure inverse. Isomorphic (browser + Bun), no runtime deps.
|
|
4
|
+
*
|
|
5
|
+
* Pipeline: JSON → UTF-8 bytes → gzip when > threshold → split into chunks → each
|
|
6
|
+
* chunk base64url-encoded with a crc32, the envelope carrying a self-describing
|
|
7
|
+
* header (version, gzip flag, method, index/total, payload length, payload id) and a
|
|
8
|
+
* full-payload sha256 on the last chunk. The decoder needs nothing but the params.
|
|
9
|
+
*
|
|
10
|
+
* This replaces the legacy `encode→reverse→swap→base64→swap` chain, which
|
|
11
|
+
* double-base64'd and whose swap/reverse was trivially-reversible obfuscation, not
|
|
12
|
+
* protection — the real protection (getOnly JWT claim + CSRF guard) is unchanged.
|
|
13
|
+
*/
|
|
14
|
+
import { fromBase64, fromBase64url, toBase64url } from './base64url';
|
|
15
|
+
import { gunzip, gzip } from './compress';
|
|
16
|
+
import { crc32 } from './crc32';
|
|
17
|
+
import { sha256Hex } from './digest';
|
|
18
|
+
import {
|
|
19
|
+
ABBREV_TO_METHOD,
|
|
20
|
+
CTGR_WIRE_VERSION,
|
|
21
|
+
type CtgrAbbrev,
|
|
22
|
+
type CtgrDecoded,
|
|
23
|
+
type CtgrMethod,
|
|
24
|
+
type EncodedChunk,
|
|
25
|
+
type EncodedRequest,
|
|
26
|
+
type EncodeOpts,
|
|
27
|
+
METHOD_TO_ABBREV,
|
|
28
|
+
type SerializedFile,
|
|
29
|
+
type UploadedFile,
|
|
30
|
+
} from './types';
|
|
31
|
+
|
|
32
|
+
const DEFAULT_CHUNK_SIZE = 1800;
|
|
33
|
+
const DEFAULT_GZIP_THRESHOLD = 1024;
|
|
34
|
+
|
|
35
|
+
const textEncoder = new TextEncoder();
|
|
36
|
+
const textDecoder = new TextDecoder();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Encode a mutating request into one or more `/ctgr<XX>` GET-twin chunks. Small
|
|
40
|
+
* bodies become a single chunk; large bodies gzip (when over the threshold) and split
|
|
41
|
+
* into `chunkSizeBytes` pieces. Each chunk carries a crc32; the last carries the
|
|
42
|
+
* full-payload sha256.
|
|
43
|
+
*/
|
|
44
|
+
export async function encodeRequest(
|
|
45
|
+
method: CtgrMethod,
|
|
46
|
+
url: string,
|
|
47
|
+
body: unknown,
|
|
48
|
+
opts: EncodeOpts = {},
|
|
49
|
+
): Promise<EncodedRequest> {
|
|
50
|
+
const chunkSize = opts.chunkSizeBytes ?? DEFAULT_CHUNK_SIZE;
|
|
51
|
+
const gzipThreshold = opts.gzipThresholdBytes ?? DEFAULT_GZIP_THRESHOLD;
|
|
52
|
+
|
|
53
|
+
// `JSON.stringify(undefined)` is `undefined`; coerce a missing body to `null` so a
|
|
54
|
+
// body-less mutation (e.g. DELETE) still rides the normal, single-versioned path.
|
|
55
|
+
const raw = textEncoder.encode(JSON.stringify(body ?? null));
|
|
56
|
+
const useGzip = raw.length > gzipThreshold;
|
|
57
|
+
const payload = useGzip ? await gzip(raw) : raw;
|
|
58
|
+
const sha = await sha256Hex(payload);
|
|
59
|
+
const cid = crypto.randomUUID();
|
|
60
|
+
const abbrev = METHOD_TO_ABBREV[method];
|
|
61
|
+
const totalChunks = Math.max(1, Math.ceil(payload.length / chunkSize));
|
|
62
|
+
|
|
63
|
+
const chunks: EncodedChunk[] = [];
|
|
64
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
65
|
+
const slice = payload.subarray(i * chunkSize, (i + 1) * chunkSize);
|
|
66
|
+
const params: Record<string, string> = {
|
|
67
|
+
_cv: CTGR_WIRE_VERSION,
|
|
68
|
+
_cm: abbrev,
|
|
69
|
+
_cg: useGzip ? '1' : '0',
|
|
70
|
+
_ci: String(i),
|
|
71
|
+
_ct: String(totalChunks),
|
|
72
|
+
_cl: String(payload.length),
|
|
73
|
+
_cid: cid,
|
|
74
|
+
_crc: String(crc32(slice)),
|
|
75
|
+
_d: toBase64url(slice),
|
|
76
|
+
};
|
|
77
|
+
if (i === totalChunks - 1) params._sha = sha;
|
|
78
|
+
const path = `/ctgr${abbrev}${url}`;
|
|
79
|
+
const separator = path.includes('?') ? '&' : '?';
|
|
80
|
+
const query = new URLSearchParams(params).toString();
|
|
81
|
+
chunks.push({ url: `${path}${separator}${query}`, params });
|
|
82
|
+
}
|
|
83
|
+
return { chunks };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A validated, crc-verified single chunk — ready to buffer or decode. */
|
|
87
|
+
export interface ParsedChunk {
|
|
88
|
+
method: CtgrMethod;
|
|
89
|
+
gzip: boolean;
|
|
90
|
+
index: number;
|
|
91
|
+
total: number;
|
|
92
|
+
/** Declared full-payload length in bytes (post-gzip, pre-base64). */
|
|
93
|
+
payloadLength: number;
|
|
94
|
+
cid: string;
|
|
95
|
+
/** Full-payload sha256 (hex) — present on the last chunk only. */
|
|
96
|
+
sha?: string;
|
|
97
|
+
bytes: Uint8Array;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function asRecord(params: Record<string, string> | URLSearchParams): Record<string, string> {
|
|
101
|
+
return params instanceof URLSearchParams ? Object.fromEntries(params) : params;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parse + crc-verify ONE chunk's params. Throws on an unknown version, a bad method
|
|
106
|
+
* abbrev, malformed index/total/length, or a crc mismatch — callers that must not
|
|
107
|
+
* throw (the chunk store, the middleware) catch and map to an Error/400.
|
|
108
|
+
*/
|
|
109
|
+
export function parseChunk(params: Record<string, string> | URLSearchParams): ParsedChunk {
|
|
110
|
+
const p = asRecord(params);
|
|
111
|
+
|
|
112
|
+
if (p._cv !== CTGR_WIRE_VERSION) {
|
|
113
|
+
throw new Error(`ctgr: unsupported wire version "${p._cv ?? ''}"`);
|
|
114
|
+
}
|
|
115
|
+
const method = ABBREV_TO_METHOD[p._cm as CtgrAbbrev];
|
|
116
|
+
if (!method) throw new Error(`ctgr: unknown method abbreviation "${p._cm ?? ''}"`);
|
|
117
|
+
|
|
118
|
+
const index = Number(p._ci);
|
|
119
|
+
const total = Number(p._ct);
|
|
120
|
+
const payloadLength = Number(p._cl);
|
|
121
|
+
if (!Number.isInteger(total) || total <= 0) throw new Error('ctgr: invalid chunk total');
|
|
122
|
+
if (!Number.isInteger(index) || index < 0 || index >= total) {
|
|
123
|
+
throw new Error('ctgr: chunk index out of range');
|
|
124
|
+
}
|
|
125
|
+
if (!Number.isInteger(payloadLength) || payloadLength < 0) {
|
|
126
|
+
throw new Error('ctgr: invalid payload length');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const bytes = fromBase64url(p._d ?? '');
|
|
130
|
+
const expectedCrc = Number(p._crc);
|
|
131
|
+
if (!Number.isInteger(expectedCrc) || crc32(bytes) !== expectedCrc) {
|
|
132
|
+
throw new Error('ctgr: chunk crc mismatch');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
method,
|
|
137
|
+
gzip: p._cg === '1',
|
|
138
|
+
index,
|
|
139
|
+
total,
|
|
140
|
+
payloadLength,
|
|
141
|
+
cid: p._cid ?? '',
|
|
142
|
+
sha: p._sha,
|
|
143
|
+
bytes,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Split an `__files` array out of a decoded body into multer-shaped uploads. The
|
|
149
|
+
* `__files` data is standard base64 (what the client's FormData serializer emits);
|
|
150
|
+
* everything else stays on the returned `body`.
|
|
151
|
+
*/
|
|
152
|
+
export function extractFiles(parsed: unknown): { body: unknown; files?: UploadedFile[] } {
|
|
153
|
+
if (
|
|
154
|
+
parsed &&
|
|
155
|
+
typeof parsed === 'object' &&
|
|
156
|
+
Array.isArray((parsed as { __files?: unknown }).__files)
|
|
157
|
+
) {
|
|
158
|
+
const source = parsed as { __files: SerializedFile[] } & Record<string, unknown>;
|
|
159
|
+
const files: UploadedFile[] = source.__files.map((f) => {
|
|
160
|
+
const buffer = fromBase64(f.dataBase64 ?? '');
|
|
161
|
+
return {
|
|
162
|
+
fieldname: f.field ?? 'file',
|
|
163
|
+
originalname: f.name ?? 'file',
|
|
164
|
+
mimetype: f.type || 'application/octet-stream',
|
|
165
|
+
buffer,
|
|
166
|
+
size: buffer.length,
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
const { __files, ...body } = source;
|
|
170
|
+
return { body, files };
|
|
171
|
+
}
|
|
172
|
+
return { body: parsed };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Decode an assembled payload (gunzip when flagged) → method + body (+ files). */
|
|
176
|
+
export async function decodePayload(
|
|
177
|
+
payload: Uint8Array,
|
|
178
|
+
gzipped: boolean,
|
|
179
|
+
method: CtgrMethod,
|
|
180
|
+
): Promise<CtgrDecoded> {
|
|
181
|
+
const raw = gzipped ? await gunzip(payload) : payload;
|
|
182
|
+
const json = textDecoder.decode(raw);
|
|
183
|
+
const parsed: unknown = json.length ? JSON.parse(json) : undefined;
|
|
184
|
+
const { body, files } = extractFiles(parsed);
|
|
185
|
+
return files ? { method, body, files } : { method, body };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Decode a single set of chunk params. For the common single-chunk request this
|
|
190
|
+
* fully decodes (verifying the payload sha256); for one chunk of a multi-chunk
|
|
191
|
+
* payload it returns `'partial'` — those reassemble through {@link ChunkStore}.
|
|
192
|
+
*/
|
|
193
|
+
export async function decodeRequest(
|
|
194
|
+
params: Record<string, string> | URLSearchParams,
|
|
195
|
+
): Promise<CtgrDecoded | 'partial'> {
|
|
196
|
+
const chunk = parseChunk(params);
|
|
197
|
+
if (chunk.total !== 1) return 'partial';
|
|
198
|
+
if (chunk.sha && (await sha256Hex(chunk.bytes)) !== chunk.sha) {
|
|
199
|
+
throw new Error('ctgr: payload sha256 mismatch');
|
|
200
|
+
}
|
|
201
|
+
return decodePayload(chunk.bytes, chunk.gzip, chunk.method);
|
|
202
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Isomorphic gzip via the web-standard CompressionStream / DecompressionStream —
|
|
3
|
+
* native on Bun and in the browser, so one implementation serves both the browser
|
|
4
|
+
* encoder and the Bun server decoder (no `node:zlib`, no `Bun.gzipSync` branch).
|
|
5
|
+
* Async on both ends, which is why the whole ctgr codec is async.
|
|
6
|
+
*/
|
|
7
|
+
// `new Uint8Array(bytes)` re-backs the view on a plain ArrayBuffer — Blob/SubtleCrypto
|
|
8
|
+
// reject the `ArrayBufferLike` default (which admits SharedArrayBuffer) under TS 6.
|
|
9
|
+
export async function gzip(bytes: Uint8Array): Promise<Uint8Array> {
|
|
10
|
+
const stream = new Blob([new Uint8Array(bytes)])
|
|
11
|
+
.stream()
|
|
12
|
+
.pipeThrough(new CompressionStream('gzip'));
|
|
13
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function gunzip(bytes: Uint8Array): Promise<Uint8Array> {
|
|
17
|
+
const stream = new Blob([new Uint8Array(bytes)])
|
|
18
|
+
.stream()
|
|
19
|
+
.pipeThrough(new DecompressionStream('gzip'));
|
|
20
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Pure-JS CRC-32 (IEEE 802.3 / zlib polynomial) over a 256-entry lookup table — no
|
|
3
|
+
* native addon. Client and server share this exact implementation so a chunk's
|
|
4
|
+
* `_crc` integrity check computes the same value on both ends of the tunnel.
|
|
5
|
+
*/
|
|
6
|
+
const TABLE = (() => {
|
|
7
|
+
const table = new Uint32Array(256);
|
|
8
|
+
for (let n = 0; n < 256; n++) {
|
|
9
|
+
let c = n;
|
|
10
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
11
|
+
table[n] = c >>> 0;
|
|
12
|
+
}
|
|
13
|
+
return table;
|
|
14
|
+
})();
|
|
15
|
+
|
|
16
|
+
/** CRC-32 of `bytes` as an unsigned 32-bit integer. */
|
|
17
|
+
export function crc32(bytes: Uint8Array): number {
|
|
18
|
+
let crc = 0xffffffff;
|
|
19
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
20
|
+
crc = (crc >>> 8) ^ TABLE[(crc ^ bytes[i]) & 0xff];
|
|
21
|
+
}
|
|
22
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
23
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Hex SHA-256 via web-standard SubtleCrypto — isomorphic (Bun + browser both expose
|
|
3
|
+
* `crypto.subtle`). Used for the full-payload integrity check the wire format carries
|
|
4
|
+
* on the last chunk (`_sha`); per-chunk integrity is the cheaper {@link crc32}.
|
|
5
|
+
*/
|
|
6
|
+
export async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
|
7
|
+
// `new Uint8Array(bytes)` re-backs the view on a plain ArrayBuffer — SubtleCrypto's
|
|
8
|
+
// BufferSource rejects the `ArrayBufferLike` default under TS 6.
|
|
9
|
+
const digest = await crypto.subtle.digest('SHA-256', new Uint8Array(bytes));
|
|
10
|
+
const view = new Uint8Array(digest);
|
|
11
|
+
let hex = '';
|
|
12
|
+
for (let i = 0; i < view.length; i++) hex += view[i].toString(16).padStart(2, '0');
|
|
13
|
+
return hex;
|
|
14
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* src/ctgr — the isomorphic ctgr codec, re-exported from `cursedbelt-server`; (browser + Bun, no runtime deps). The
|
|
3
|
+
* Hono transport binding (`unGETify` + `buildRoutes`) lives in `cursedbelt-server`;
|
|
4
|
+
* the browser guard + request converter live in `cursedbelt-cc/client`.
|
|
5
|
+
*
|
|
6
|
+
* The ChunkAccumulator is the primary abstraction for multi-chunk payload assembly;
|
|
7
|
+
* ChunkStore is kept for backward compatibility (they are identical implementations).
|
|
8
|
+
*/
|
|
9
|
+
export { fromBase64, fromBase64url, toBase64, toBase64url } from './base64url';
|
|
10
|
+
export {
|
|
11
|
+
ChunkAccumulator,
|
|
12
|
+
type ChunkAccumulatorConfig,
|
|
13
|
+
} from './chunkAccumulator';
|
|
14
|
+
export { ChunkStore, type ChunkStoreOpts } from './chunkStore';
|
|
15
|
+
export {
|
|
16
|
+
decodePayload,
|
|
17
|
+
decodeRequest,
|
|
18
|
+
encodeRequest,
|
|
19
|
+
extractFiles,
|
|
20
|
+
type ParsedChunk,
|
|
21
|
+
parseChunk,
|
|
22
|
+
} from './codec';
|
|
23
|
+
export { gunzip, gzip } from './compress';
|
|
24
|
+
export { crc32 } from './crc32';
|
|
25
|
+
export { sha256Hex } from './digest';
|
|
26
|
+
export {
|
|
27
|
+
ABBREV_TO_METHOD,
|
|
28
|
+
CTGR_WIRE_VERSION,
|
|
29
|
+
type CtgrAbbrev,
|
|
30
|
+
type CtgrDecoded,
|
|
31
|
+
type CtgrMethod,
|
|
32
|
+
type EncodedChunk,
|
|
33
|
+
type EncodedRequest,
|
|
34
|
+
type EncodeOpts,
|
|
35
|
+
METHOD_TO_ABBREV,
|
|
36
|
+
type SerializedFile,
|
|
37
|
+
type UploadedFile,
|
|
38
|
+
} from './types';
|
|
39
|
+
export { decodeV0Payload, V0ChunkStore, type V0ChunkStoreOpts } from './v0compat';
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ctgr wire-format types + the method⇄abbreviation maps shared by the codec, the
|
|
3
|
+
* chunk store, the Hono transport, and the browser client. Pure types + literal
|
|
4
|
+
* const maps only (no runtime APIs) so this module is trivially isomorphic.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** The mutating HTTP methods ctgr tunnels through a `/ctgr<XX>` GET twin. */
|
|
8
|
+
export type CtgrMethod = 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
|
9
|
+
|
|
10
|
+
/** Method → its 2-char abbreviation (the `/ctgr<XX>` path segment + the `_cm` param). */
|
|
11
|
+
export const METHOD_TO_ABBREV = {
|
|
12
|
+
POST: 'po',
|
|
13
|
+
PATCH: 'pa',
|
|
14
|
+
PUT: 'pu',
|
|
15
|
+
DELETE: 'de',
|
|
16
|
+
} as const satisfies Record<CtgrMethod, string>;
|
|
17
|
+
|
|
18
|
+
/** The inverse of {@link METHOD_TO_ABBREV}. */
|
|
19
|
+
export const ABBREV_TO_METHOD = {
|
|
20
|
+
po: 'POST',
|
|
21
|
+
pa: 'PATCH',
|
|
22
|
+
pu: 'PUT',
|
|
23
|
+
de: 'DELETE',
|
|
24
|
+
} as const satisfies Record<string, CtgrMethod>;
|
|
25
|
+
|
|
26
|
+
export type CtgrAbbrev = keyof typeof ABBREV_TO_METHOD;
|
|
27
|
+
|
|
28
|
+
/** Current wire-format version. v0 (the legacy `_cv`-absent codec) lives in `v0compat.ts`. */
|
|
29
|
+
export const CTGR_WIRE_VERSION = '1';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A reconstructed multipart upload, multer-shaped so the file came over the ctgr
|
|
33
|
+
* GET-tunnel the same way it would over multipart. `buffer` is raw isomorphic bytes
|
|
34
|
+
* (a `Uint8Array`, not a Node `Buffer`) — wrap it where a `Buffer` is required.
|
|
35
|
+
*/
|
|
36
|
+
export interface UploadedFile {
|
|
37
|
+
fieldname: string;
|
|
38
|
+
originalname: string;
|
|
39
|
+
mimetype: string;
|
|
40
|
+
buffer: Uint8Array;
|
|
41
|
+
size: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The fully-decoded request a downstream handler / validator consumes. */
|
|
45
|
+
export interface CtgrDecoded {
|
|
46
|
+
/** The original HTTP method the GET twin stands in for. */
|
|
47
|
+
method: CtgrMethod;
|
|
48
|
+
/** The parsed JSON body, with any `__files` array extracted into {@link files}. */
|
|
49
|
+
body: unknown;
|
|
50
|
+
/** Reconstructed uploads — present only when the body carried an `__files` array. */
|
|
51
|
+
files?: UploadedFile[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One encoded chunk: the GET-twin URL plus the query params it carries (same data). */
|
|
55
|
+
export interface EncodedChunk {
|
|
56
|
+
/** `/ctgr<XX><path>?<params>` — a path (no origin); the caller adds any prefix. */
|
|
57
|
+
url: string;
|
|
58
|
+
/** The `_c*` params, also serialized into {@link url}. Handy for tests + reassembly. */
|
|
59
|
+
params: Record<string, string>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The output of `encodeRequest`: one chunk for small bodies, N for large ones. */
|
|
63
|
+
export interface EncodedRequest {
|
|
64
|
+
chunks: EncodedChunk[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface EncodeOpts {
|
|
68
|
+
/** Max payload bytes per chunk (pre-base64url). Default 1800 → ~2400 URL chars. */
|
|
69
|
+
chunkSizeBytes?: number;
|
|
70
|
+
/** Gzip the payload when it exceeds this many bytes. Default 1024. */
|
|
71
|
+
gzipThresholdBytes?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The `__files` entry shape the client serializes an upload into (base64 data). */
|
|
75
|
+
export interface SerializedFile {
|
|
76
|
+
field?: string;
|
|
77
|
+
name?: string;
|
|
78
|
+
type?: string;
|
|
79
|
+
dataBase64?: string;
|
|
80
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { toBase64 } from './base64url';
|
|
3
|
+
import { decodeV0Payload, V0ChunkStore } from './v0compat';
|
|
4
|
+
|
|
5
|
+
// Mirror the legacy client encode pipeline exactly: encode→reverse→swap→encode→swap,
|
|
6
|
+
// where `encode` is standard base64 of the UTF-8 bytes. Used to prove the v1 server's
|
|
7
|
+
// v0 fallback decodes what an un-updated client still sends.
|
|
8
|
+
const encodeV0 = (jsonString: string): string => {
|
|
9
|
+
const encode = (s: string) => toBase64(new TextEncoder().encode(s));
|
|
10
|
+
const reverse = (s: string) => s.split('').reverse().join('');
|
|
11
|
+
const swap = (s: string) => {
|
|
12
|
+
if (s.length < 2) return s;
|
|
13
|
+
const arr = s.split('');
|
|
14
|
+
arr[0] = s[s.length - 1];
|
|
15
|
+
arr[s.length - 1] = s[0];
|
|
16
|
+
return arr.join('');
|
|
17
|
+
};
|
|
18
|
+
return swap(encode(swap(reverse(encode(jsonString)))));
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('v0 compat', () => {
|
|
22
|
+
test('decodeV0Payload reverses the legacy pipeline', () => {
|
|
23
|
+
const body = { hello: 'world', n: 42, nested: { a: [1, 2, 3] }, emoji: '🎲 — café' };
|
|
24
|
+
expect(decodeV0Payload(encodeV0(JSON.stringify(body)))).toEqual(body);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('V0ChunkStore decodes a single payload with the path-derived method', () => {
|
|
28
|
+
const store = new V0ChunkStore();
|
|
29
|
+
const body = { title: 'legacy note' };
|
|
30
|
+
const result = store.add({ p: encodeV0(JSON.stringify(body)) }, 'POST');
|
|
31
|
+
expect(result).not.toBe(false);
|
|
32
|
+
expect(result).toEqual({ method: 'POST', body });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('V0ChunkStore treats a missing payload as a body-less request', () => {
|
|
36
|
+
const store = new V0ChunkStore();
|
|
37
|
+
expect(store.add({}, 'DELETE')).toEqual({ method: 'DELETE', body: undefined });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('V0ChunkStore reassembles a chunked legacy payload', () => {
|
|
41
|
+
const store = new V0ChunkStore();
|
|
42
|
+
const body = { data: 'z'.repeat(2000) };
|
|
43
|
+
const encoded = encodeV0(JSON.stringify(body));
|
|
44
|
+
const size = Math.ceil(encoded.length / 3);
|
|
45
|
+
const parts = [encoded.slice(0, size), encoded.slice(size, size * 2), encoded.slice(size * 2)];
|
|
46
|
+
expect(store.add({ cid: 'u1', ci: '0', ct: '3', p: parts[0] }, 'PATCH')).toBe(false);
|
|
47
|
+
expect(store.add({ cid: 'u1', ci: '1', ct: '3', p: parts[1] }, 'PATCH')).toBe(false);
|
|
48
|
+
const result = store.add({ cid: 'u1', ci: '2', ct: '3', p: parts[2] }, 'PATCH');
|
|
49
|
+
expect(result).toEqual({ method: 'PATCH', body });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('V0ChunkStore reconstructs files from a legacy __files body', () => {
|
|
53
|
+
const store = new V0ChunkStore();
|
|
54
|
+
const bytes = new Uint8Array([9, 8, 7]);
|
|
55
|
+
const body = {
|
|
56
|
+
__files: [{ field: 'f', name: 'x.bin', type: 'image/png', dataBase64: toBase64(bytes) }],
|
|
57
|
+
};
|
|
58
|
+
const result = store.add({ p: encodeV0(JSON.stringify(body)) }, 'POST');
|
|
59
|
+
expect(result).not.toBe(false);
|
|
60
|
+
if (result === false || result instanceof Error) throw new Error('unreachable');
|
|
61
|
+
expect(result.body).toEqual({});
|
|
62
|
+
expect(result.files?.[0]).toMatchObject({
|
|
63
|
+
fieldname: 'f',
|
|
64
|
+
originalname: 'x.bin',
|
|
65
|
+
mimetype: 'image/png',
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('V0ChunkStore rejects invalid chunk metadata', () => {
|
|
70
|
+
const store = new V0ChunkStore();
|
|
71
|
+
expect(store.add({ cid: 'u', ci: '5', ct: '3', p: 'x' }, 'POST')).toBeInstanceOf(Error);
|
|
72
|
+
});
|
|
73
|
+
});
|