cursedbelt-server 4.1.0 → 4.3.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.
Files changed (35) hide show
  1. package/dist/server/bench/assert.d.ts +16 -3
  2. package/dist/server/bench/assert.js +54 -0
  3. package/dist/server/bench/budget.d.ts +35 -1
  4. package/dist/server/bench/budget.js +35 -1
  5. package/dist/server/bench/cpuClock.js +21 -1
  6. package/dist/server/bench/index.d.ts +1 -1
  7. package/dist/server/bench/index.js +1 -1
  8. package/dist/server/d1/fakeD1.d.ts +5 -0
  9. package/dist/server/d1/fakeD1.js +51 -18
  10. package/dist/server/d1/index.d.ts +1 -0
  11. package/dist/server/d1/index.js +1 -0
  12. package/dist/server/d1/invocation.d.ts +72 -0
  13. package/dist/server/d1/invocation.js +166 -0
  14. package/dist/server/d1/local.js +48 -3
  15. package/dist/server/d1/values.d.ts +19 -2
  16. package/dist/server/d1/values.js +21 -2
  17. package/dist/server/middleware/bodyLimit.d.ts +152 -0
  18. package/dist/server/middleware/bodyLimit.js +161 -0
  19. package/package.json +8 -2
  20. package/src/leafSubpathsImportNothing.spec.ts +13 -0
  21. package/src/server/bench/assert.ts +78 -3
  22. package/src/server/bench/budget.spec.ts +27 -0
  23. package/src/server/bench/budget.ts +36 -1
  24. package/src/server/bench/cpuBudget.spec.ts +101 -1
  25. package/src/server/bench/cpuClock.ts +22 -1
  26. package/src/server/bench/index.ts +1 -0
  27. package/src/server/d1/fakeD1.ts +56 -18
  28. package/src/server/d1/index.ts +1 -0
  29. package/src/server/d1/invocation.spec.ts +174 -0
  30. package/src/server/d1/invocation.ts +207 -0
  31. package/src/server/d1/local.ts +56 -3
  32. package/src/server/d1/sameShape.spec.ts +73 -0
  33. package/src/server/d1/values.ts +23 -2
  34. package/src/server/middleware/bodyLimit.spec.ts +238 -0
  35. package/src/server/middleware/bodyLimit.ts +210 -0
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The per-invocation query budget — D1's 1,000-queries-per-Worker-invocation cap, counted.
3
+ *
4
+ * ## 🔴 Why this file exists: the limit the rest of the seam only documented
5
+ *
6
+ * `./limits` lists `queriesPerInvocation: 1_000` FIRST, and its header names the exact
7
+ * failure — *"An N+1 loop over `family`'s 489 people exceeds it. Join, or `batch()`; do not
8
+ * loop."* Measured here 2026-09-18, that limit was the one thing in the table nothing
9
+ * enforced: `assertBatchSize` checks the length of a SINGLE `batch()` call, so a loop
10
+ * issuing 1,001 separate `await db.prepare(…).run()` calls passed every check in the seam.
11
+ * Each statement is under 100 parameters, each batch is under 1,000 members, and the
12
+ * invocation still dies in production.
13
+ *
14
+ * That is the seam's own defining defect wearing a third costume. Sync-locally and
15
+ * lenient-locally are both "green on this Mac, red in the Worker"; so is uncounted-locally,
16
+ * because `bun:sqlite` has no such cap and never will. The N+1 loop is also the single most
17
+ * likely shape to appear during a port — it is what the un-ported synchronous code already
18
+ * looks like, and awaiting it in a `for` loop is the laziest mechanical translation.
19
+ *
20
+ * ## The counter belongs to a REQUEST, not to a handle
21
+ *
22
+ * D1's cap is per Worker invocation, and a `D1LikeDatabase` is not an invocation: on a
23
+ * Worker it happens to be built per request, but locally `createLocalD1` is built once per
24
+ * PROCESS and lives for days. A counter on the handle would therefore be correct remotely
25
+ * and a false positive locally after the 1,000th query of the morning — the mirror image of
26
+ * the bug this seam exists to prevent, and just as fatal to the rule's credibility.
27
+ *
28
+ * So the scope is explicit and cheap, and the caller opens one per request:
29
+ *
30
+ * ```ts
31
+ * export default {
32
+ * async fetch(req: Request, env: Env) {
33
+ * const db = perInvocation(createRemoteD1(env.DB));
34
+ * return handle(req, db); // 1,001st query throws, naming the loop
35
+ * },
36
+ * };
37
+ * ```
38
+ *
39
+ * It wraps either driver, because it sits ABOVE the seam and only counts. That is what
40
+ * makes the local gate able to prove a production-only limit: wrap a local database in a
41
+ * test, run the loop, and the laptop fails exactly where the Worker would.
42
+ */
43
+
44
+ import { LIMITS } from './limits';
45
+ import { D1LimitError, type D1LikeBindable, type D1LikeDatabase, type D1LikeResult, type D1LikeRow, type D1LikeStatement, type D1LikeValue } from './types';
46
+
47
+ /**
48
+ * A database handle that knows how much of its invocation budget it has spent.
49
+ *
50
+ * `used` counts QUERIES the way D1 bills them: one per terminal statement call
51
+ * (`first`/`all`/`run`/`raw`), one per member of a `batch()`, and one per statement an
52
+ * `exec()` ran. Building a statement with `prepare()` or `bind()` costs nothing, because it
53
+ * reaches the database only when it is executed.
54
+ */
55
+ export interface InvocationD1 extends D1LikeDatabase {
56
+ /** Queries charged to this invocation so far. */
57
+ readonly used: number;
58
+ /** Queries left before the cap, floored at 0. */
59
+ readonly remaining: number;
60
+ }
61
+
62
+ /**
63
+ * The statements a wrapper handed out, mapped back to the driver's own.
64
+ *
65
+ * 🔴 Both drivers identify their own statements structurally — `local.ts` probes for
66
+ * `allSync`, `remote.ts` for `binding` — and refuse anything else with "a local statement
67
+ * cannot run inside a D1 batch". A counted wrapper has neither property, so passing the
68
+ * wrappers straight through would break `batch()` on BOTH sides. `batch()` therefore
69
+ * unwraps here before delegating. A statement this does not know is passed through
70
+ * untouched, so the driver's own error is what a genuine cross-driver mix-up still gets.
71
+ */
72
+ const INNER = new WeakMap<D1LikeStatement, D1LikeStatement>();
73
+
74
+ class Budget {
75
+ used = 0;
76
+
77
+ constructor(readonly max: number) {}
78
+
79
+ /** Charge before the work goes out, so the query over the cap is never issued. */
80
+ charge(n: number, what: string): void {
81
+ if (this.used + n > this.max) {
82
+ throw new D1LimitError(
83
+ 'queries per Worker invocation',
84
+ this.used + n,
85
+ this.max,
86
+ `${what} would be query ${this.used + n} of this invocation. This is almost always an N+1 loop — ` +
87
+ 'replace the loop with a JOIN, or collect the statements and send them as one `batch()`. ' +
88
+ 'Work that genuinely needs more than a thousand queries belongs in a Queue consumer or a Cron Trigger, ' +
89
+ 'which get a fresh budget per invocation.',
90
+ );
91
+ }
92
+ this.used += n;
93
+ }
94
+
95
+ /**
96
+ * Record a cost that was only knowable after the fact — see `exec()` below. Deliberately
97
+ * unchecked: the statements have already run, so throwing here would report a limit
98
+ * breach by hiding the result that proves it. Going over simply means the next
99
+ * {@link charge} throws, which is the first moment a refusal can still prevent anything.
100
+ */
101
+ settle(n: number): void {
102
+ this.used += n;
103
+ }
104
+ }
105
+
106
+ /** Wrap one statement so every terminal call is charged to `budget`. */
107
+ function countedStatement(inner: D1LikeStatement, budget: Budget, sql: string): D1LikeStatement {
108
+ const label = `\`${sql.trim().slice(0, 60)}\``;
109
+
110
+ class CountedStatement implements D1LikeStatement {
111
+ bind(...values: D1LikeBindable[]): D1LikeStatement {
112
+ // Binding is free — it reaches nothing. The NEW statement is wrapped too, or a
113
+ // `prepare().bind().run()` would escape the count entirely.
114
+ return countedStatement(inner.bind(...values), budget, sql);
115
+ }
116
+
117
+ async first<T = D1LikeRow>(column?: string): Promise<T | null> {
118
+ budget.charge(1, label);
119
+ return column === undefined ? inner.first<T>() : (inner.first<T>(column) as Promise<T | null>);
120
+ }
121
+
122
+ async all<T = D1LikeRow>(): Promise<D1LikeResult<T>> {
123
+ budget.charge(1, label);
124
+ return inner.all<T>();
125
+ }
126
+
127
+ async run(): Promise<D1LikeResult<never>> {
128
+ budget.charge(1, label);
129
+ return inner.run();
130
+ }
131
+
132
+ async raw<V = D1LikeValue>(): Promise<V[][]> {
133
+ budget.charge(1, label);
134
+ return inner.raw<V>();
135
+ }
136
+ }
137
+
138
+ const wrapped = new CountedStatement();
139
+ INNER.set(wrapped, inner);
140
+ return wrapped;
141
+ }
142
+
143
+ /**
144
+ * Give `db` a query budget for ONE Worker invocation.
145
+ *
146
+ * Construct a fresh one per request — the count is the request's, not the handle's, and
147
+ * re-using one across requests would refuse the 1,001st query of the day rather than of the
148
+ * invocation. See the header for why that distinction is the whole design.
149
+ *
150
+ * `max` exists for tests and for a caller that wants to be refused sooner than D1 would
151
+ * (a route with a budget of 50 finds its own N+1 long before the hard cap does). It may not
152
+ * be raised above D1's real limit, because a budget larger than the service's is a number
153
+ * that reports success right up until production disagrees.
154
+ */
155
+ export function perInvocation(db: D1LikeDatabase, opts: { max?: number } = {}): InvocationD1 {
156
+ const max = opts.max ?? LIMITS.queriesPerInvocation;
157
+ if (!Number.isInteger(max) || max < 1) {
158
+ throw new RangeError(`max must be a positive integer, got ${max}`);
159
+ }
160
+ if (max > LIMITS.queriesPerInvocation) {
161
+ throw new D1LimitError(
162
+ 'queries per Worker invocation',
163
+ max,
164
+ LIMITS.queriesPerInvocation,
165
+ 'A budget above D1\'s own cap cannot be honoured — the service refuses first. Lower it, or split the work across invocations.',
166
+ );
167
+ }
168
+ const budget = new Budget(max);
169
+
170
+ return {
171
+ flavor: db.flavor,
172
+
173
+ get used() {
174
+ return budget.used;
175
+ },
176
+
177
+ get remaining() {
178
+ return Math.max(0, budget.max - budget.used);
179
+ },
180
+
181
+ prepare(sql: string): D1LikeStatement {
182
+ return countedStatement(db.prepare(sql), budget, sql);
183
+ },
184
+
185
+ async batch<T = D1LikeRow>(statements: D1LikeStatement[]): Promise<D1LikeResult<T>[]> {
186
+ // One round trip, but D1 bills each member — so does this.
187
+ budget.charge(statements.length, `a batch() of ${statements.length}`);
188
+ return db.batch<T>(statements.map((s) => INNER.get(s) ?? s));
189
+ },
190
+
191
+ /**
192
+ * 🔴 The one cost that cannot be charged up front. `exec()` runs however many
193
+ * statements the SQL contains, and remotely that number comes back FROM the service —
194
+ * there is nothing trustworthy to count beforehand. So this reserves one query, runs,
195
+ * and settles the true cost afterwards; an `exec` that blows the budget is reported on
196
+ * the next query rather than prevented. That is an acceptable trade only because
197
+ * `exec` is the DDL/migration path — it carries no user input by contract, and it is
198
+ * not the shape an N+1 loop takes.
199
+ */
200
+ async exec(sql: string): Promise<{ count: number; duration: number }> {
201
+ budget.charge(1, 'an exec()');
202
+ const res = await db.exec(sql);
203
+ if (res.count > 1) budget.settle(res.count - 1);
204
+ return res;
205
+ },
206
+ };
207
+ }
@@ -25,11 +25,22 @@ import {
25
25
  type D1LikeValue,
26
26
  D1UnsupportedError,
27
27
  } from './types';
28
- import { makeMeta, normalizeBinds, normalizeRows, normalizeValue } from './values';
28
+ import { isPureReadSql, makeMeta, normalizeBinds, normalizeRows, normalizeValue } from './values';
29
29
 
30
30
  /** Shared clock, so `meta.duration` means the same thing on both sides of the seam. */
31
31
  const now = (): number => performance.now();
32
32
 
33
+ /**
34
+ * Rows changed on this CONNECTION since it was opened — monotonic, so a difference across
35
+ * one statement is that statement's own count. Used only where `.run()` cannot report it.
36
+ */
37
+ const totalChanges = (db: Database): number =>
38
+ (db.query('SELECT total_changes() AS n').get() as { n: number }).n;
39
+
40
+ /** The connection's last inserted rowid, which is what D1 reports in `meta.last_row_id`. */
41
+ const lastInsertRowid = (db: Database): number =>
42
+ (db.query('SELECT last_insert_rowid() AS r').get() as { r: number }).r;
43
+
33
44
  /**
34
45
  * 🔴 The synchronous core, kept separate from the async surface on purpose.
35
46
  *
@@ -63,11 +74,53 @@ class LocalStatement implements D1LikeStatement, SyncExecutable {
63
74
 
64
75
  allSync<T = D1LikeRow>(): D1LikeResult<T> {
65
76
  const started = now();
66
- const rows = this.stmt().all(...(this.params as never[])) as Record<string, unknown>[];
77
+ const stmt = this.stmt();
78
+
79
+ // 🔴 A statement with NO result columns cannot return rows, so `.run()` is both the
80
+ // cheaper path and the only one that reports `changes` at all — `.all()` does not.
81
+ // `columnNames` is `bun:sqlite`'s own answer and is exact: `[]` for INSERT/UPDATE/
82
+ // DELETE, populated for SELECT and for anything carrying RETURNING (probed 2026-09-17).
83
+ if (stmt.columnNames.length === 0) {
84
+ const res = stmt.run(...(this.params as never[]));
85
+ return {
86
+ results: [],
87
+ success: true,
88
+ meta: makeMeta({
89
+ changes: res.changes,
90
+ last_row_id: res.lastInsertRowid,
91
+ duration: now() - started,
92
+ }),
93
+ };
94
+ }
95
+
96
+ const rowsOf = () => this.stmt().all(...(this.params as never[])) as Record<string, unknown>[];
97
+
98
+ // A pure read changed nothing, and its counters are the PREVIOUS statement's — see
99
+ // `isPureReadSql`. Reporting zero is the measurement, not a default.
100
+ if (isPureReadSql(this.sql)) {
101
+ const rows = rowsOf();
102
+ return {
103
+ results: normalizeRows<T>(rows),
104
+ success: true,
105
+ meta: makeMeta({ duration: now() - started }),
106
+ };
107
+ }
108
+
109
+ // Returns rows AND may write: `INSERT … RETURNING`, `WITH … UPDATE … RETURNING`.
110
+ // `total_changes()` is cumulative for the connection, so the DIFFERENCE across the
111
+ // statement is this statement's own count — the one reading that stays correct when
112
+ // the statement is neither a plain read nor a plain write.
113
+ const before = totalChanges(this.db);
114
+ const rows = rowsOf();
115
+ const changes = totalChanges(this.db) - before;
67
116
  return {
68
117
  results: normalizeRows<T>(rows),
69
118
  success: true,
70
- meta: makeMeta({ duration: now() - started }),
119
+ meta: makeMeta({
120
+ changes,
121
+ last_row_id: changes > 0 ? lastInsertRowid(this.db) : null,
122
+ duration: now() - started,
123
+ }),
71
124
  };
72
125
  }
73
126
 
@@ -276,4 +276,77 @@ describe('local and D1 drivers return identical shapes', () => {
276
276
  expect(await attempt(p.local)).toBe(0);
277
277
  expect(await attempt(p.remote)).toBe(0);
278
278
  });
279
+
280
+ // ── batch META, which both sides got wrong the same way until 2026-09-17 ──────────
281
+ //
282
+ // 🔴 These four assert VALUES, not just agreement. The bug they pin was `changes: 0`
283
+ // hardcoded in `local.ts`'s `allSync` AND in `fakeD1`'s `batch`, so the two drivers
284
+ // agreed perfectly on a wrong answer and every `expect(local).toEqual(remote)` above
285
+ // stayed green. A shared bug is the one failure mode a two-implementation test cannot
286
+ // see, so the numbers have to be written down here. Found by porting
287
+ // `apps/patterns/src/server/tokens.ts` onto the seam, on its first atomic write.
288
+
289
+ test('batch() reports each member’s OWN changes — not zero, on both', async () => {
290
+ p.seed(`INSERT INTO t (id, text_col) VALUES (1, 'a'), (2, 'b'), (3, 'c')`);
291
+ const { local, remote } = await both(p, async (db) => {
292
+ const res = await db.batch([
293
+ db.prepare("UPDATE t SET text_col = 'z'"),
294
+ db.prepare('DELETE FROM t WHERE id = ?').bind(1),
295
+ ]);
296
+ return res.map((r) => r.meta.changes);
297
+ });
298
+ expect(local).toEqual(remote);
299
+ // The whole point: three rows updated and one deleted, reported per statement.
300
+ expect(local).toEqual([3, 1]);
301
+ });
302
+
303
+ test('batch() reports last_row_id for an insert, on both', async () => {
304
+ const { local, remote } = await both(p, async (db) => {
305
+ const res = await db.batch([
306
+ db.prepare('INSERT INTO t (id, text_col) VALUES (41, ?)').bind('a'),
307
+ db.prepare('INSERT INTO t (id, text_col) VALUES (42, ?)').bind('b'),
308
+ ]);
309
+ return res.map((r) => ({ changes: r.meta.changes, last_row_id: r.meta.last_row_id }));
310
+ });
311
+ expect(local).toEqual(remote);
312
+ expect(local).toEqual([
313
+ { changes: 1, last_row_id: 41 },
314
+ { changes: 1, last_row_id: 42 },
315
+ ]);
316
+ });
317
+
318
+ test('a READ inside a batch reports changes 0, even right after a write', async () => {
319
+ // 🔴 The trap in the obvious fix. SQLite's `changes()` is connection-wide and
320
+ // sticky, so a driver that simply reads it after every statement reports the
321
+ // UPDATE's 3 as the SELECT's own count. Measured 2026-09-17: a `SELECT` following
322
+ // a one-row insert still answers `changes() = 1`.
323
+ p.seed(`INSERT INTO t (id, text_col) VALUES (1, 'a'), (2, 'b'), (3, 'c')`);
324
+ const { local, remote } = await both(p, async (db) => {
325
+ const res = await db.batch([
326
+ db.prepare("UPDATE t SET text_col = 'z'"),
327
+ db.prepare('SELECT id FROM t ORDER BY id'),
328
+ ]);
329
+ return {
330
+ changes: res.map((r) => r.meta.changes),
331
+ rows: res[1]?.results,
332
+ };
333
+ });
334
+ expect(local).toEqual(remote);
335
+ expect(local.changes).toEqual([3, 0]);
336
+ expect(local.rows).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
337
+ });
338
+
339
+ test('INSERT … RETURNING in a batch reports its rows AND its changes, on both', async () => {
340
+ // Returns rows and writes, so neither the `.run()` path nor the pure-read path
341
+ // answers it — this is the case `total_changes()` differencing exists for.
342
+ const { local, remote } = await both(p, async (db) => {
343
+ const res = await db.batch([
344
+ db.prepare('INSERT INTO t (id, text_col) VALUES (7, ?), (8, ?) RETURNING id').bind('a', 'b'),
345
+ ]);
346
+ return { rows: res[0]?.results, changes: res[0]?.meta.changes };
347
+ });
348
+ expect(local).toEqual(remote);
349
+ expect(local.changes).toBe(2);
350
+ expect(local.rows).toEqual([{ id: 7 }, { id: 8 }]);
351
+ });
279
352
  });
@@ -1,6 +1,6 @@
1
1
  /**
2
- * The one place that decides what a bound parameter and a returned column MEAN, shared by
3
- * both drivers.
2
+ * The one place that decides what a bound parameter, a returned column and a statement's
3
+ * own `meta` MEAN, shared by both drivers.
4
4
  *
5
5
  * 🔴 **Both drivers call these, and that is why the identical-shapes test can pass.** If
6
6
  * the local driver normalized its own way and the remote driver normalized its own way,
@@ -81,6 +81,27 @@ export function normalizeBind(value: unknown, index: number): D1LikeBindable {
81
81
  export const normalizeBinds = (values: readonly unknown[]): D1LikeBindable[] =>
82
82
  values.map((v, i) => normalizeBind(v, i));
83
83
 
84
+ /** Leading whitespace and SQL comments, which sit in front of the real first keyword. */
85
+ const LEADING_NOISE = /^(?:\s|--[^\n]*\n?|\/\*[\s\S]*?\*\/)+/;
86
+
87
+ /**
88
+ * Is this statement a PURE READ — one that cannot possibly have changed a row?
89
+ *
90
+ * 🔴 Measured 2026-09-17, and this is why the question has to be asked at all:
91
+ * SQLite's `changes()` and `last_insert_rowid()` are **connection-wide and sticky**.
92
+ * After a plain `SELECT` they still read the counters left by the last write — probed
93
+ * here, a `SELECT` following a 1-row insert reports `changes = 1`. So those counters may
94
+ * only ever be attributed to a statement that actually wrote something, and a driver
95
+ * that reads them after a read invents a `changes` out of the previous statement's.
96
+ *
97
+ * Only a leading `SELECT` is claimed, because that is the one keyword that is
98
+ * unambiguous: `WITH … INSERT … RETURNING` and `INSERT … RETURNING` both return rows AND
99
+ * write. Anything this returns `false` for is measured exactly instead (see
100
+ * `local.ts`), so a statement shape this does not recognise costs two extra queries —
101
+ * never a wrong number.
102
+ */
103
+ export const isPureReadSql = (sql: string): boolean => /^select\b/i.test(sql.replace(LEADING_NOISE, ''));
104
+
84
105
  /**
85
106
  * Normalize one column value coming BACK from a driver.
86
107
  *
@@ -0,0 +1,238 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import {
3
+ type BodyBytesSource,
4
+ type BodyHeaderSource,
5
+ type BodyTextSource,
6
+ readBoundedBytes,
7
+ readBoundedJson,
8
+ readBoundedText,
9
+ refuseOversizedBody,
10
+ } from './bodyLimit';
11
+
12
+ /**
13
+ * 🔴 A guard that is the LAST line of defense ships with the test of its failure path,
14
+ * not only of its success path.
15
+ *
16
+ * `apps/roms/src/server/limits.test.ts` is where this came from (2026-09-17). Most of
17
+ * that file is about roms' own `routes.ts` — that every bounded-body call site takes its
18
+ * ceiling from `MAX_BODY_BYTES` — and stays there, because it asserts about an app's
19
+ * routes. What travels with the module is its two behavioural tests (an honest oversized
20
+ * declaration is refused with both numbers in the message; a body with no declared length
21
+ * is NOT refused on that ground alone), extended here to the three readers the app-side
22
+ * file never exercised.
23
+ *
24
+ * Every source below is a plain object literal. That is the module's design showing
25
+ * through rather than a shortcut: the four entry points take structural
26
+ * `{ req: { … } }` sources, so nothing here needs Hono, a server, or a socket — which is
27
+ * also why `cursedbelt-server/body-limit` can promise to import nothing at runtime.
28
+ */
29
+
30
+ const textSource = (raw: string): BodyTextSource => ({ req: { text: async () => raw } });
31
+
32
+ const bytesSource = (bytes: Uint8Array): BodyBytesSource => ({
33
+ req: {
34
+ async arrayBuffer() {
35
+ return bytes.buffer.slice(
36
+ bytes.byteOffset,
37
+ bytes.byteOffset + bytes.byteLength,
38
+ ) as ArrayBuffer;
39
+ },
40
+ },
41
+ });
42
+
43
+ /** A body that dies mid-read — a client that hung up, a socket reset. */
44
+ const brokenText: BodyTextSource = {
45
+ req: {
46
+ text: async () => {
47
+ throw new Error('connection reset');
48
+ },
49
+ },
50
+ };
51
+
52
+ const brokenBytes: BodyBytesSource = {
53
+ req: {
54
+ arrayBuffer: async () => {
55
+ throw new Error('connection reset');
56
+ },
57
+ },
58
+ };
59
+
60
+ const headerSource = (contentLength: string | undefined): BodyHeaderSource => ({
61
+ req: { header: (name) => (name === 'content-length' ? contentLength : undefined) },
62
+ });
63
+
64
+ describe('readBoundedJson', () => {
65
+ it('parses a body inside the ceiling', async () => {
66
+ const result = await readBoundedJson<{ hello: string }>(
67
+ textSource('{"hello":"world"}'),
68
+ { maxBytes: 1024 },
69
+ );
70
+ expect(result.ok).toBe(true);
71
+ expect(result.ok === true ? result.value : null).toEqual({ hello: 'world' });
72
+ });
73
+
74
+ it('refuses an oversized body with 413 and BOTH numbers in the message', async () => {
75
+ const raw = JSON.stringify({ blob: 'x'.repeat(200) });
76
+ const result = await readBoundedJson(textSource(raw), {
77
+ maxBytes: 64,
78
+ label: 'save state',
79
+ });
80
+ expect(result.ok).toBe(false);
81
+ expect(result.ok === false ? result.status : 0).toBe(413);
82
+ const error = result.ok === false ? result.error : '';
83
+ expect(error).toContain('save state');
84
+ expect(error).toContain(String(raw.length)); // what arrived
85
+ expect(error).toContain('64'); // what was allowed
86
+ });
87
+
88
+ /**
89
+ * 🔴 The reason the module measures `TextEncoder().encode(raw).length` and not
90
+ * `raw.length`. A 4-byte emoji is 2 UTF-16 code units, so a `String.length` check
91
+ * measures this body at 8 against a real size of 14 — and lets it past a byte limit
92
+ * the proxy in front is enforcing in real bytes. That mismatch is what this module
93
+ * exists to close, so it gets an assertion rather than a sentence.
94
+ */
95
+ it('measures BYTES, not UTF-16 code units', async () => {
96
+ const raw = JSON.stringify('😀😀😀');
97
+ expect(raw.length).toBe(8);
98
+ expect(new TextEncoder().encode(raw).length).toBe(14);
99
+ const result = await readBoundedJson(textSource(raw), { maxBytes: 10 });
100
+ expect(result.ok).toBe(false);
101
+ expect(result.ok === false ? result.status : 0).toBe(413);
102
+ });
103
+
104
+ it('allows a body exactly ON the ceiling — the limit is inclusive', async () => {
105
+ const raw = '"abcd"'; // 6 bytes
106
+ const result = await readBoundedJson(textSource(raw), { maxBytes: 6 });
107
+ expect(result.ok).toBe(true);
108
+ });
109
+
110
+ /** A 400, never a throw: the caller gets a status to return, not an exception to catch. */
111
+ it('answers 400 for a body that is not JSON', async () => {
112
+ const result = await readBoundedJson(textSource('not json at all'), {
113
+ maxBytes: 1024,
114
+ label: 'preferences',
115
+ });
116
+ expect(result.ok).toBe(false);
117
+ expect(result.ok === false ? result.status : 0).toBe(400);
118
+ expect(result.ok === false ? result.error : '').toBe('preferences must be JSON');
119
+ });
120
+
121
+ it('answers 400 when the body cannot be read at all', async () => {
122
+ const result = await readBoundedJson(brokenText, { maxBytes: 1024 });
123
+ expect(result.ok).toBe(false);
124
+ expect(result.ok === false ? result.status : 0).toBe(400);
125
+ expect(result.ok === false ? result.error : '').toBe('body could not be read');
126
+ });
127
+ });
128
+
129
+ describe('readBoundedText', () => {
130
+ it('hands back the raw text inside the ceiling', async () => {
131
+ const result = await readBoundedText(textSource('chunk-payload'), { maxBytes: 64 });
132
+ expect(result.ok).toBe(true);
133
+ expect(result.ok === true ? result.value : '').toBe('chunk-payload');
134
+ });
135
+
136
+ it('refuses an oversized body with 413, measured in bytes', async () => {
137
+ const result = await readBoundedText(textSource('😀'.repeat(4)), {
138
+ maxBytes: 8,
139
+ label: 'chunk',
140
+ });
141
+ expect(result.ok).toBe(false);
142
+ expect(result.ok === false ? result.status : 0).toBe(413);
143
+ expect(result.ok === false ? result.error : '').toContain('chunk too large (16 bytes');
144
+ });
145
+
146
+ it('answers 400 when the body cannot be read', async () => {
147
+ const result = await readBoundedText(brokenText, { maxBytes: 64 });
148
+ expect(result.ok === false ? result.status : 0).toBe(400);
149
+ });
150
+ });
151
+
152
+ describe('readBoundedBytes', () => {
153
+ it('hands back the exact bytes inside the ceiling', async () => {
154
+ const payload = new Uint8Array([0x00, 0xff, 0x80, 0x41]);
155
+ const result = await readBoundedBytes(bytesSource(payload), { maxBytes: 16 });
156
+ expect(result.ok).toBe(true);
157
+ expect([...(result.ok === true ? result.value : [])]).toEqual([0x00, 0xff, 0x80, 0x41]);
158
+ });
159
+
160
+ /**
161
+ * The reason this exists beside {@link readBoundedText}: 0x80 and 0xff are not valid
162
+ * UTF-8, so decoding cartridge bytes as text would replace them with U+FFFD and measure
163
+ * a size that is not the size the proxy is enforcing.
164
+ */
165
+ it('does not corrupt bytes that are invalid UTF-8', async () => {
166
+ const payload = new Uint8Array([0x80, 0xfe, 0xff]);
167
+ const result = await readBoundedBytes(bytesSource(payload), { maxBytes: 16 });
168
+ expect(result.ok === true ? result.value.length : -1).toBe(3);
169
+ expect(new TextDecoder().decode(result.ok === true ? result.value : new Uint8Array())).toBe(
170
+ '���',
171
+ );
172
+ });
173
+
174
+ it('refuses an oversized buffer with 413 and both numbers', async () => {
175
+ const result = await readBoundedBytes(bytesSource(new Uint8Array(100)), {
176
+ maxBytes: 32,
177
+ label: 'rom chunk',
178
+ });
179
+ expect(result.ok === false ? result.status : 0).toBe(413);
180
+ expect(result.ok === false ? result.error : '').toBe(
181
+ 'rom chunk too large (100 bytes; the limit is 32)',
182
+ );
183
+ });
184
+
185
+ it('answers 400 when the body cannot be read', async () => {
186
+ const result = await readBoundedBytes(brokenBytes, { maxBytes: 32 });
187
+ expect(result.ok === false ? result.status : 0).toBe(400);
188
+ });
189
+ });
190
+
191
+ describe('refuseOversizedBody', () => {
192
+ /** The guard itself: an honest oversized declaration is refused, and the message names
193
+ * both numbers so a 413 is actionable rather than mysterious. */
194
+ it('refuses an oversized declared body with both numbers in the message', () => {
195
+ const refusal = refuseOversizedBody(headerSource('4194305'), {
196
+ maxBytes: 4 * 1024 * 1024,
197
+ label: 'save state',
198
+ });
199
+ expect(refusal?.status).toBe(413);
200
+ expect(refusal?.error).toContain('save state');
201
+ expect(refusal?.error).toContain('4194305');
202
+ expect(refusal?.error).toContain('4194304');
203
+ });
204
+
205
+ /**
206
+ * A chunked request legitimately omits `Content-Length`. Refusing it would break a
207
+ * correct client to catch a dishonest one — and the dishonest one is caught by the
208
+ * buffering readers anyway, which measure what actually arrived.
209
+ */
210
+ it('does not refuse a body with no declared length on that ground alone', () => {
211
+ expect(refuseOversizedBody(headerSource(undefined), { maxBytes: 1024 })).toBeNull();
212
+ });
213
+
214
+ it('does not refuse an unparseable declared length', () => {
215
+ expect(refuseOversizedBody(headerSource('not-a-number'), { maxBytes: 1024 })).toBeNull();
216
+ });
217
+
218
+ it('does not refuse a body exactly on the ceiling', () => {
219
+ expect(refuseOversizedBody(headerSource('1024'), { maxBytes: 1024 })).toBeNull();
220
+ });
221
+
222
+ /**
223
+ * 🔴 The compensating control, asserted rather than described. This function TRUSTS the
224
+ * header — it has to, because a `multipart/form-data` body cannot be measured without
225
+ * buffering it first, which is the thing the limit exists to prevent. What makes that
226
+ * safe is that a liar only gets as far as the next guard: a body declaring 1 byte and
227
+ * carrying 5 MB is still refused by the reader that measures what arrived.
228
+ */
229
+ it('is bypassed by a lying header — and the buffering reader still refuses', async () => {
230
+ const liar = headerSource('1');
231
+ expect(refuseOversizedBody(liar, { maxBytes: 1024 })).toBeNull();
232
+
233
+ const actuallyHuge = await readBoundedBytes(bytesSource(new Uint8Array(5000)), {
234
+ maxBytes: 1024,
235
+ });
236
+ expect(actuallyHuge.ok === false ? actuallyHuge.status : 0).toBe(413);
237
+ });
238
+ });