cursedbelt-core 2.10.0 → 2.12.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.
@@ -4,6 +4,12 @@ import { type GateFn, type NavDomain, type NavGroup, type NavItem, type Navigati
4
4
  * `#tab=…` in-page target), and a trailing slash. `'/'` stays `'/'`. Used so a tab
5
5
  * variant (`/tools#tab=curl`) matches the base route (`/tools`) and `/lists/`
6
6
  * matches `/lists`.
7
+ *
8
+ * 🔴 A HASH ROUTE is the route, not an in-page target: `#/queue` and `/#/queue/x.md` are
9
+ * matched on `/queue` and `/queue/x.md`. Until 2.12.0 they were cut at the `#` like a tab
10
+ * variant, so every place of a hash-routed app normalized to `/` and `SideNav` lit ALL of them
11
+ * (station's first `AppShell`, 2026-10-02). The fragment is the route only when nothing but `/`
12
+ * stands before it and it starts with `/` — `/tools#tab=curl` is still `/tools`.
7
13
  */
8
14
  export declare function normalizeNavPath(path: string): string;
9
15
  /** A flattened, gated catalogue entry — a leaf plus where it lives (for grouping). */
@@ -9,12 +9,22 @@ import { allowAllGate, } from './navigationModel.js';
9
9
  * `#tab=…` in-page target), and a trailing slash. `'/'` stays `'/'`. Used so a tab
10
10
  * variant (`/tools#tab=curl`) matches the base route (`/tools`) and `/lists/`
11
11
  * matches `/lists`.
12
+ *
13
+ * 🔴 A HASH ROUTE is the route, not an in-page target: `#/queue` and `/#/queue/x.md` are
14
+ * matched on `/queue` and `/queue/x.md`. Until 2.12.0 they were cut at the `#` like a tab
15
+ * variant, so every place of a hash-routed app normalized to `/` and `SideNav` lit ALL of them
16
+ * (station's first `AppShell`, 2026-10-02). The fragment is the route only when nothing but `/`
17
+ * stands before it and it starts with `/` — `/tools#tab=curl` is still `/tools`.
12
18
  */
13
19
  export function normalizeNavPath(path) {
14
20
  // Null-safe: a nav selector must never throw (a breadcrumb derivation crashing
15
21
  // white-screens the whole page). Coerce a missing/non-string path to ''.
16
22
  const s = typeof path === 'string' ? path : '';
17
- let p = (s.split('#')[0] ?? '').split('?')[0]?.trim() ?? '';
23
+ const hashAt = s.indexOf('#');
24
+ const before = hashAt === -1 ? s : s.slice(0, hashAt);
25
+ const fragment = hashAt === -1 ? '' : s.slice(hashAt + 1);
26
+ const route = (before === '' || before === '/') && fragment.startsWith('/') ? fragment : before;
27
+ let p = route.split('?')[0]?.trim() ?? '';
18
28
  if (p.length > 1 && p.endsWith('/'))
19
29
  p = p.slice(0, -1);
20
30
  return p || '/';
@@ -112,4 +112,4 @@ export declare function createSearchMatcher(raw: string, options?: SearchSyntaxO
112
112
  * Case is left as typed. SQLite's `LIKE` is already case-insensitive for ASCII;
113
113
  * a database whose `LIKE` is not (Postgres) should use `ILIKE` with the same string.
114
114
  */
115
- export declare function searchPatternToLike(pattern: SearchPattern, escape?: string): string | null;
115
+ export declare function searchPatternToLike(pattern: SearchPattern, escapeChar?: string): string | null;
@@ -132,9 +132,9 @@ export function createSearchMatcher(raw, options = {}) {
132
132
  * Case is left as typed. SQLite's `LIKE` is already case-insensitive for ASCII;
133
133
  * a database whose `LIKE` is not (Postgres) should use `ILIKE` with the same string.
134
134
  */
135
- export function searchPatternToLike(pattern, escape = '\\') {
136
- const literal = (text) => text.replace(/[%_]/g, (c) => `${escape}${c}`);
137
- const escapeAll = (text) => literal(text.split(escape).join(`${escape}${escape}`));
135
+ export function searchPatternToLike(pattern, escapeChar = '\\') {
136
+ const literal = (text) => text.replace(/[%_]/g, (c) => `${escapeChar}${c}`);
137
+ const escapeAll = (text) => literal(text.split(escapeChar).join(`${escapeChar}${escapeChar}`));
138
138
  switch (pattern.kind) {
139
139
  case 'plain':
140
140
  return `%${escapeAll(pattern.text)}%`;
@@ -8,9 +8,10 @@
8
8
  */
9
9
  import { encodeRequest, METHOD_TO_ABBREV, toBase64, } from '../ctgr/index.js';
10
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;
11
+ // 7 MB per-file cap for GET-only mode. A file rides as base64 inside JSON — a third
12
+ // larger — and both chunk stores refuse a payload over 10 MB, so a 10 MB file was let
13
+ // through here only to be refused by the server after its first chunk.
14
+ const CTGR_MAX_FILE_BYTES = 7 * 1024 * 1024;
14
15
  const SAFE_METHODS = new Set(['GET', 'HEAD']);
15
16
  /**
16
17
  * Convert one request for the tunnel. Returns the chunked GET requests to send in
@@ -70,13 +70,11 @@ export const installCtgrGuard = () => {
70
70
  // ── XMLHttpRequest ───────────────────────────────────────────────────────────
71
71
  if (typeof XMLHttpRequest !== 'undefined') {
72
72
  const originalOpen = XMLHttpRequest.prototype.open;
73
- // biome-ignore lint/suspicious/noExplicitAny: matching XHR.open's variadic overloads
74
73
  XMLHttpRequest.prototype.open = function open(method, url, ...rest) {
75
74
  const upper = String(method).toUpperCase();
76
75
  if (isCtgrModeOn() && !SAFE_METHODS.has(upper) && isGuardedPath(String(url))) {
77
76
  throw block();
78
77
  }
79
- // biome-ignore lint/suspicious/noExplicitAny: forwarding the original variadic call
80
78
  return originalOpen.call(this, method, url, ...rest);
81
79
  };
82
80
  }
@@ -1,3 +1,4 @@
1
1
  export { applyCtgrFromLocation, applyCtgrIntent, isCtgrModeOn, readCtgrIntent, setCtgrMode, } from './ctgrConfig.js';
2
2
  export { type CtgrFetchRequest, convertIfOnlyGETRequests } from './convertRequest.js';
3
3
  export { installCtgrGuard } from './ctgrGuard.js';
4
+ export { CTGR_INCOMPLETE_STATUS, type CtgrSend, type SendCtgrOptions, sendCtgrRequests, } from './sendRequests.js';
@@ -3,13 +3,15 @@
3
3
  * GET-only client:
4
4
  * • the per-device mode flag + the `?ctgr=t|f` boot toggle (`ctgrConfig`),
5
5
  * • the request converter that rewrites a mutation into `/ctgr<xx>` GET twins,
6
+ * • the sender that puts those twins on the wire and answers with the one real response,
6
7
  * • the network-boundary guard that blocks a stray raw verb.
7
8
  *
8
9
  * The wire codec is `cursedbelt-core/ctgr`; the server that decodes the twins is
9
10
  * `cursedbelt-server` (`mountCtgrTunnel`). A site opts in with two boot calls —
10
11
  * `applyCtgrFromLocation()` then `installCtgrGuard()` — and routes its requests
11
- * through `convertIfOnlyGETRequests`.
12
+ * through `convertIfOnlyGETRequests`, then `sendCtgrRequests`.
12
13
  */
13
14
  export { applyCtgrFromLocation, applyCtgrIntent, isCtgrModeOn, readCtgrIntent, setCtgrMode, } from './ctgrConfig.js';
14
15
  export { convertIfOnlyGETRequests } from './convertRequest.js';
15
16
  export { installCtgrGuard } from './ctgrGuard.js';
17
+ export { CTGR_INCOMPLETE_STATUS, sendCtgrRequests, } from './sendRequests.js';
@@ -0,0 +1,12 @@
1
+ import type { CtgrFetchRequest } from './convertRequest.js';
2
+ /** How the caller puts one request on the wire — its own `fetch`, with its own headers. */
3
+ export type CtgrSend = (request: CtgrFetchRequest) => Promise<Response>;
4
+ export interface SendCtgrOptions {
5
+ /** Chunks in flight at once. Default 4 — fast, and gentle on an edge's rate limits. */
6
+ concurrency?: number;
7
+ /** Told after each chunk is acknowledged, for a progress bar: `(sent, total)`. */
8
+ onProgress?: (sent: number, total: number) => void;
9
+ }
10
+ /** The status a last chunk is given when the server still answers "waiting for more". */
11
+ export declare const CTGR_INCOMPLETE_STATUS = 502;
12
+ export declare function sendCtgrRequests(requests: readonly CtgrFetchRequest[], send: CtgrSend, options?: SendCtgrOptions): Promise<Response>;
@@ -0,0 +1,44 @@
1
+ /** The status a last chunk is given when the server still answers "waiting for more". */
2
+ export const CTGR_INCOMPLETE_STATUS = 502;
3
+ export async function sendCtgrRequests(requests, send, options = {}) {
4
+ const first = requests[0];
5
+ if (!first)
6
+ throw new Error('ctgr: no request to send');
7
+ const total = requests.length;
8
+ if (total === 1) {
9
+ const only = await send(first);
10
+ options.onProgress?.(1, 1);
11
+ return only;
12
+ }
13
+ const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
14
+ const pieces = requests.slice(0, -1);
15
+ let next = 0;
16
+ let sent = 0;
17
+ let refusal = null;
18
+ const worker = async () => {
19
+ while (refusal === null && next < pieces.length) {
20
+ const piece = pieces[next++];
21
+ const response = await send(piece);
22
+ if (response.status !== 202 && !response.ok) {
23
+ refusal ??= response;
24
+ return;
25
+ }
26
+ // An intermediate's body is `{acknowledged:true}`; read it so the connection frees.
27
+ await response.body?.cancel().catch(() => undefined);
28
+ options.onProgress?.(++sent, total);
29
+ }
30
+ };
31
+ await Promise.all(Array.from({ length: Math.min(concurrency, pieces.length) }, worker));
32
+ if (refusal)
33
+ return refusal;
34
+ const last = await send(requests[total - 1]);
35
+ if (last.status === 202) {
36
+ await last.body?.cancel().catch(() => undefined);
37
+ return Response.json({
38
+ error: 'The server did not receive every piece of this request — nothing was saved. Send it again.',
39
+ code: 'CTGR_INCOMPLETE',
40
+ }, { status: CTGR_INCOMPLETE_STATUS });
41
+ }
42
+ options.onProgress?.(total, total);
43
+ return last;
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-core",
3
- "version": "2.10.0",
3
+ "version": "2.12.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.",
@@ -23,7 +23,8 @@
23
23
  "paths": "bun run scripts/paths.ts",
24
24
  "verify": "bun run gate",
25
25
  "prepublishOnly": "bun run gate --force",
26
- "gate": "bun run scripts/paths.ts --only gate.ts"
26
+ "gate": "bun run scripts/paths.ts --only gate.ts",
27
+ "lint": "biome check --error-on-warnings ."
27
28
  },
28
29
  "files": [
29
30
  "dist",
@@ -393,19 +394,22 @@
393
394
  "zod": "^4.4.0"
394
395
  },
395
396
  "devDependencies": {
396
- "@biomejs/biome": "^2.5.0",
397
+ "@biomejs/biome": "^2.5.14",
398
+ "@happy-dom/global-registrator": "^20.10.6",
397
399
  "@types/bun": "^1.3.14",
398
400
  "@types/node": "^24",
401
+ "hono": "4.12.28",
399
402
  "typescript": "^6.0.3",
400
- "zod": "4.4.3",
401
- "@happy-dom/global-registrator": "^20.10.6",
402
- "hono": "4.12.28"
403
+ "zod": "4.4.3"
403
404
  },
404
405
  "gate": {
405
406
  "stages": [
406
407
  {
407
408
  "script": "paths"
408
409
  },
410
+ {
411
+ "script": "lint"
412
+ },
409
413
  {
410
414
  "script": "typecheck"
411
415
  },
@@ -131,6 +131,16 @@ describe('normalizeNavPath', () => {
131
131
  expect(normalizeNavPath('/')).toBe('/');
132
132
  expect(normalizeNavPath('/admin/users')).toBe('/admin/users');
133
133
  });
134
+
135
+ test('a hash route IS the route — every place of a hash-routed app must not collapse to /', () => {
136
+ expect(normalizeNavPath('#/queue')).toBe('/queue');
137
+ expect(normalizeNavPath('/#/queue/Not_Started/x.md')).toBe('/queue/Not_Started/x.md');
138
+ expect(normalizeNavPath('#/queue?folder=Blocked')).toBe('/queue');
139
+ expect(normalizeNavPath('#/')).toBe('/');
140
+ // An in-page target after a real path is still a tab variant, not a route.
141
+ expect(normalizeNavPath('/tools#/not-a-route')).toBe('/tools');
142
+ expect(normalizeNavPath('#section')).toBe('/');
143
+ });
134
144
  });
135
145
 
136
146
  describe('deriveSideNavDomains', () => {
@@ -18,12 +18,22 @@ import {
18
18
  * `#tab=…` in-page target), and a trailing slash. `'/'` stays `'/'`. Used so a tab
19
19
  * variant (`/tools#tab=curl`) matches the base route (`/tools`) and `/lists/`
20
20
  * matches `/lists`.
21
+ *
22
+ * 🔴 A HASH ROUTE is the route, not an in-page target: `#/queue` and `/#/queue/x.md` are
23
+ * matched on `/queue` and `/queue/x.md`. Until 2.12.0 they were cut at the `#` like a tab
24
+ * variant, so every place of a hash-routed app normalized to `/` and `SideNav` lit ALL of them
25
+ * (station's first `AppShell`, 2026-10-02). The fragment is the route only when nothing but `/`
26
+ * stands before it and it starts with `/` — `/tools#tab=curl` is still `/tools`.
21
27
  */
22
28
  export function normalizeNavPath(path: string): string {
23
29
  // Null-safe: a nav selector must never throw (a breadcrumb derivation crashing
24
30
  // white-screens the whole page). Coerce a missing/non-string path to ''.
25
31
  const s = typeof path === 'string' ? path : '';
26
- let p = (s.split('#')[0] ?? '').split('?')[0]?.trim() ?? '';
32
+ const hashAt = s.indexOf('#');
33
+ const before = hashAt === -1 ? s : s.slice(0, hashAt);
34
+ const fragment = hashAt === -1 ? '' : s.slice(hashAt + 1);
35
+ const route = (before === '' || before === '/') && fragment.startsWith('/') ? fragment : before;
36
+ let p = route.split('?')[0]?.trim() ?? '';
27
37
  if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
28
38
  return p || '/';
29
39
  }
@@ -165,9 +165,9 @@ export function createSearchMatcher(
165
165
  * Case is left as typed. SQLite's `LIKE` is already case-insensitive for ASCII;
166
166
  * a database whose `LIKE` is not (Postgres) should use `ILIKE` with the same string.
167
167
  */
168
- export function searchPatternToLike(pattern: SearchPattern, escape = '\\'): string | null {
169
- const literal = (text: string) => text.replace(/[%_]/g, (c) => `${escape}${c}`);
170
- const escapeAll = (text: string) => literal(text.split(escape).join(`${escape}${escape}`));
168
+ export function searchPatternToLike(pattern: SearchPattern, escapeChar = '\\'): string | null {
169
+ const literal = (text: string) => text.replace(/[%_]/g, (c) => `${escapeChar}${c}`);
170
+ const escapeAll = (text: string) => literal(text.split(escapeChar).join(`${escapeChar}${escapeChar}`));
171
171
  switch (pattern.kind) {
172
172
  case 'plain':
173
173
  return `%${escapeAll(pattern.text)}%`;
@@ -22,9 +22,10 @@ export interface CtgrFetchRequest {
22
22
  init: RequestInit;
23
23
  }
24
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;
25
+ // 7 MB per-file cap for GET-only mode. A file rides as base64 inside JSON — a third
26
+ // larger — and both chunk stores refuse a payload over 10 MB, so a 10 MB file was let
27
+ // through here only to be refused by the server after its first chunk.
28
+ const CTGR_MAX_FILE_BYTES = 7 * 1024 * 1024;
28
29
 
29
30
  const SAFE_METHODS = new Set(['GET', 'HEAD']);
30
31
 
@@ -73,13 +73,11 @@ export const installCtgrGuard = (): void => {
73
73
  // ── XMLHttpRequest ───────────────────────────────────────────────────────────
74
74
  if (typeof XMLHttpRequest !== 'undefined') {
75
75
  const originalOpen = XMLHttpRequest.prototype.open;
76
- // biome-ignore lint/suspicious/noExplicitAny: matching XHR.open's variadic overloads
77
76
  XMLHttpRequest.prototype.open = function open(method: string, url: string, ...rest: any[]) {
78
77
  const upper = String(method).toUpperCase();
79
78
  if (isCtgrModeOn() && !SAFE_METHODS.has(upper) && isGuardedPath(String(url))) {
80
79
  throw block();
81
80
  }
82
- // biome-ignore lint/suspicious/noExplicitAny: forwarding the original variadic call
83
81
  return (originalOpen as any).call(this, method, url, ...rest);
84
82
  };
85
83
  }
@@ -3,12 +3,13 @@
3
3
  * GET-only client:
4
4
  * • the per-device mode flag + the `?ctgr=t|f` boot toggle (`ctgrConfig`),
5
5
  * • the request converter that rewrites a mutation into `/ctgr<xx>` GET twins,
6
+ * • the sender that puts those twins on the wire and answers with the one real response,
6
7
  * • the network-boundary guard that blocks a stray raw verb.
7
8
  *
8
9
  * The wire codec is `cursedbelt-core/ctgr`; the server that decodes the twins is
9
10
  * `cursedbelt-server` (`mountCtgrTunnel`). A site opts in with two boot calls —
10
11
  * `applyCtgrFromLocation()` then `installCtgrGuard()` — and routes its requests
11
- * through `convertIfOnlyGETRequests`.
12
+ * through `convertIfOnlyGETRequests`, then `sendCtgrRequests`.
12
13
  */
13
14
  export {
14
15
  applyCtgrFromLocation,
@@ -19,3 +20,9 @@ export {
19
20
  } from './ctgrConfig.js';
20
21
  export { type CtgrFetchRequest, convertIfOnlyGETRequests } from './convertRequest.js';
21
22
  export { installCtgrGuard } from './ctgrGuard.js';
23
+ export {
24
+ CTGR_INCOMPLETE_STATUS,
25
+ type CtgrSend,
26
+ type SendCtgrOptions,
27
+ sendCtgrRequests,
28
+ } from './sendRequests.js';
@@ -0,0 +1,85 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test';
2
+ import { ChunkStore } from '../ctgr/chunkStore.js';
3
+ import { type CtgrFetchRequest, convertIfOnlyGETRequests, sendCtgrRequests, setCtgrMode } from './index.js';
4
+
5
+ beforeEach(() => {
6
+ localStorage.clear();
7
+ setCtgrMode(true);
8
+ });
9
+
10
+ /** A multi-chunk upload: random bytes do not gzip, so 40 KB is a couple of dozen chunks. */
11
+ async function upload(): Promise<CtgrFetchRequest[]> {
12
+ const bytes = new Uint8Array(40_000);
13
+ crypto.getRandomValues(bytes);
14
+ const form = new FormData();
15
+ form.append('file', new File([bytes], 'shot.png', { type: 'image/png' }));
16
+ const { requests } = await convertIfOnlyGETRequests('/api/v1/library/assets', { method: 'POST', body: form });
17
+ expect(requests.length).toBeGreaterThan(10);
18
+ return requests;
19
+ }
20
+
21
+ /** The server half, as `mountCtgrTunnel` answers: 202 per piece, the real response at the end. */
22
+ function server(store = new ChunkStore()) {
23
+ let inFlight = 0;
24
+ let peak = 0;
25
+ const seen: number[] = [];
26
+ const send = async (request: CtgrFetchRequest): Promise<Response> => {
27
+ inFlight++;
28
+ peak = Math.max(peak, inFlight);
29
+ await Bun.sleep(Math.random() * 3);
30
+ const params = new URL(request.url, 'http://x').searchParams;
31
+ seen.push(Number(params.get('_ci')));
32
+ const result = await store.add(params);
33
+ inFlight--;
34
+ if (result === false) return Response.json({ acknowledged: true }, { status: 202 });
35
+ if (result instanceof Error) return Response.json({ error: result.message }, { status: 400 });
36
+ return Response.json({ id: 'asset-1', name: result.files?.[0]?.originalname }, { status: 201 });
37
+ };
38
+ return { send, seen, peak: () => peak };
39
+ }
40
+
41
+ describe('sendCtgrRequests', () => {
42
+ test('sends the pieces several at a time and answers with the real response', async () => {
43
+ const requests = await upload();
44
+ const { send, seen, peak } = server();
45
+ const progress: number[] = [];
46
+ const response = await sendCtgrRequests(requests, send, { onProgress: (sent) => progress.push(sent) });
47
+ expect(response.status).toBe(201);
48
+ expect(await response.json()).toEqual({ id: 'asset-1', name: 'shot.png' });
49
+ expect(peak()).toBe(4);
50
+ expect(seen.at(-1)).toBe(requests.length - 1);
51
+ expect(progress.at(-1)).toBe(requests.length);
52
+ });
53
+
54
+ test('🔴 a piece the server lost is a 502, never the 202 `{acknowledged:true}` a caller would read as the result', async () => {
55
+ const requests = await upload();
56
+ const { send } = server();
57
+ const losing = (request: CtgrFetchRequest): Promise<Response> =>
58
+ new URL(request.url, 'http://x').searchParams.get('_ci') === '3'
59
+ ? Promise.resolve(Response.json({ acknowledged: true }, { status: 202 }))
60
+ : send(request);
61
+ const response = await sendCtgrRequests(requests, losing);
62
+ expect(response.ok).toBe(false);
63
+ expect(response.status).toBe(502);
64
+ expect(((await response.json()) as { code: string }).code).toBe('CTGR_INCOMPLETE');
65
+ });
66
+
67
+ test('the first refusal stops the send and is the answer', async () => {
68
+ const requests = await upload();
69
+ let calls = 0;
70
+ const refusing = async (request: CtgrFetchRequest): Promise<Response> => {
71
+ calls++;
72
+ return new URL(request.url, 'http://x').searchParams.get('_ci') === '0'
73
+ ? Response.json({ error: 'nope' }, { status: 503 })
74
+ : Response.json({ acknowledged: true }, { status: 202 });
75
+ };
76
+ const response = await sendCtgrRequests(requests, refusing, { concurrency: 1 });
77
+ expect(response.status).toBe(503);
78
+ expect(calls).toBe(1);
79
+ });
80
+
81
+ test('one request is sent as it is', async () => {
82
+ const response = await sendCtgrRequests([{ url: '/api/v1/x', init: { method: 'GET' } }], async () => new Response('hi'));
83
+ expect(await response.text()).toBe('hi');
84
+ });
85
+ });
@@ -0,0 +1,87 @@
1
+ /*
2
+ * Send what `convertIfOnlyGETRequests` produced, and answer with the ONE response that
3
+ * stands for the original request.
4
+ *
5
+ * Every client used to write this as `for (const req of requests) res = await fetch(…)`,
6
+ * and that loop had two faults that together made a GET-only upload vanish (desk, task
7
+ * "component attachment not attached", 2026-10-01):
8
+ *
9
+ * 1. **A 202 read as success.** The server answers 202 `{acknowledged:true}` to every
10
+ * chunk until it holds them all. If one chunk is refused (a 400, a 503 from the chunk
11
+ * store) or a partial is lost (an evicted object, a deploy), the loop kept going and
12
+ * the LAST chunk answered 202 — `ok` — so the caller parsed `{acknowledged:true}` as
13
+ * the created thing. desk stored an attachment id of `undefined`: no error, no file.
14
+ * 2. **One round trip per 1.8 KB, in series.** A 2 MB screenshot is ~1,500 GETs; at a
15
+ * remote device's ~100 ms each that is minutes with nothing to show for it.
16
+ *
17
+ * So: every chunk but the last goes out `concurrency` at a time (both chunk stores accept
18
+ * chunks in any order — `ChunkStore` and the Worker's `CtgrChunkObject` file each by
19
+ * `_ci`), the first refusal stops the send and IS the answer, and the last chunk goes
20
+ * alone once the rest are acknowledged. A last chunk that still answers 202 means the
21
+ * server lost pieces, and that becomes a 502 the caller's ordinary error path reports.
22
+ */
23
+ import type { CtgrFetchRequest } from './convertRequest.js';
24
+
25
+ /** How the caller puts one request on the wire — its own `fetch`, with its own headers. */
26
+ export type CtgrSend = (request: CtgrFetchRequest) => Promise<Response>;
27
+
28
+ export interface SendCtgrOptions {
29
+ /** Chunks in flight at once. Default 4 — fast, and gentle on an edge's rate limits. */
30
+ concurrency?: number;
31
+ /** Told after each chunk is acknowledged, for a progress bar: `(sent, total)`. */
32
+ onProgress?: (sent: number, total: number) => void;
33
+ }
34
+
35
+ /** The status a last chunk is given when the server still answers "waiting for more". */
36
+ export const CTGR_INCOMPLETE_STATUS = 502;
37
+
38
+ export async function sendCtgrRequests(
39
+ requests: readonly CtgrFetchRequest[],
40
+ send: CtgrSend,
41
+ options: SendCtgrOptions = {},
42
+ ): Promise<Response> {
43
+ const first = requests[0];
44
+ if (!first) throw new Error('ctgr: no request to send');
45
+ const total = requests.length;
46
+ if (total === 1) {
47
+ const only = await send(first);
48
+ options.onProgress?.(1, 1);
49
+ return only;
50
+ }
51
+
52
+ const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
53
+ const pieces = requests.slice(0, -1);
54
+ let next = 0;
55
+ let sent = 0;
56
+ let refusal: Response | null = null;
57
+
58
+ const worker = async (): Promise<void> => {
59
+ while (refusal === null && next < pieces.length) {
60
+ const piece = pieces[next++] as CtgrFetchRequest;
61
+ const response = await send(piece);
62
+ if (response.status !== 202 && !response.ok) {
63
+ refusal ??= response;
64
+ return;
65
+ }
66
+ // An intermediate's body is `{acknowledged:true}`; read it so the connection frees.
67
+ await response.body?.cancel().catch(() => undefined);
68
+ options.onProgress?.(++sent, total);
69
+ }
70
+ };
71
+ await Promise.all(Array.from({ length: Math.min(concurrency, pieces.length) }, worker));
72
+ if (refusal) return refusal;
73
+
74
+ const last = await send(requests[total - 1] as CtgrFetchRequest);
75
+ if (last.status === 202) {
76
+ await last.body?.cancel().catch(() => undefined);
77
+ return Response.json(
78
+ {
79
+ error: 'The server did not receive every piece of this request — nothing was saved. Send it again.',
80
+ code: 'CTGR_INCOMPLETE',
81
+ },
82
+ { status: CTGR_INCOMPLETE_STATUS },
83
+ );
84
+ }
85
+ options.onProgress?.(total, total);
86
+ return last;
87
+ }