cursedbelt-server 2.0.0 → 3.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.
Files changed (82) hide show
  1. package/dist/server/bench/assert.d.ts +61 -0
  2. package/dist/server/bench/assert.js +117 -0
  3. package/dist/server/bench/budget.d.ts +130 -0
  4. package/dist/server/bench/budget.js +131 -0
  5. package/dist/server/bench/cpuBudget.d.ts +45 -0
  6. package/dist/server/bench/cpuBudget.js +34 -0
  7. package/dist/server/bench/cpuClock.d.ts +65 -0
  8. package/dist/server/bench/cpuClock.js +100 -0
  9. package/dist/server/bench/index.d.ts +40 -0
  10. package/dist/server/bench/index.js +40 -0
  11. package/dist/server/bench/recorder.d.ts +70 -0
  12. package/dist/server/bench/recorder.js +95 -0
  13. package/dist/server/bench/runBench.d.ts +61 -0
  14. package/dist/server/bench/runBench.js +61 -0
  15. package/dist/server/d1/backup.d.ts +110 -0
  16. package/dist/server/d1/backup.js +128 -0
  17. package/dist/server/d1/fakeD1.d.ts +41 -0
  18. package/dist/server/d1/fakeD1.js +185 -0
  19. package/dist/server/d1/index.d.ts +24 -0
  20. package/dist/server/d1/index.js +24 -0
  21. package/dist/server/d1/kysely.d.ts +56 -0
  22. package/dist/server/d1/kysely.js +138 -0
  23. package/dist/server/d1/limits.d.ts +56 -0
  24. package/dist/server/d1/limits.js +96 -0
  25. package/dist/server/d1/local.d.ts +31 -0
  26. package/dist/server/d1/local.js +135 -0
  27. package/dist/server/d1/remote.d.ts +59 -0
  28. package/dist/server/d1/remote.js +124 -0
  29. package/dist/server/d1/scheduling.d.ts +113 -0
  30. package/dist/server/d1/scheduling.js +164 -0
  31. package/dist/server/d1/types.d.ts +143 -0
  32. package/dist/server/d1/types.js +80 -0
  33. package/dist/server/d1/values.d.ts +50 -0
  34. package/dist/server/d1/values.js +124 -0
  35. package/dist/server/master-lock/guard.d.ts +10 -0
  36. package/dist/server/master-lock/guard.js +70 -19
  37. package/dist/server/master-lock/index.d.ts +1 -1
  38. package/dist/server/master-lock/index.js +1 -1
  39. package/dist/server/master-lock/lockPage.d.ts +1 -1
  40. package/dist/server/master-lock/lockPage.js +68 -3
  41. package/dist/server/master-lock/masterLock.d.ts +250 -76
  42. package/dist/server/master-lock/masterLock.js +426 -114
  43. package/dist/server/master-lock/principals.js +6 -1
  44. package/dist/server/master-lock/seed.d.ts +5 -1
  45. package/dist/server/master-lock/seed.js +18 -1
  46. package/package.json +21 -3
  47. package/src/leafSubpathsImportNothing.spec.ts +15 -3
  48. package/src/server/bench/assert.ts +192 -0
  49. package/src/server/bench/budget.spec.ts +126 -0
  50. package/src/server/bench/budget.ts +207 -0
  51. package/src/server/bench/cpuBudget.spec.ts +302 -0
  52. package/src/server/bench/cpuBudget.ts +81 -0
  53. package/src/server/bench/cpuClock.ts +119 -0
  54. package/src/server/bench/index.ts +81 -0
  55. package/src/server/bench/recorder.ts +163 -0
  56. package/src/server/bench/runBench.ts +110 -0
  57. package/src/server/d1/backup.spec.ts +121 -0
  58. package/src/server/d1/backup.ts +186 -0
  59. package/src/server/d1/fakeD1.ts +193 -0
  60. package/src/server/d1/index.ts +62 -0
  61. package/src/server/d1/kysely.spec.ts +145 -0
  62. package/src/server/d1/kysely.ts +169 -0
  63. package/src/server/d1/limits.spec.ts +90 -0
  64. package/src/server/d1/limits.ts +123 -0
  65. package/src/server/d1/local.ts +173 -0
  66. package/src/server/d1/remote.ts +182 -0
  67. package/src/server/d1/sameShape.spec.ts +279 -0
  68. package/src/server/d1/scheduling.spec.ts +120 -0
  69. package/src/server/d1/scheduling.ts +210 -0
  70. package/src/server/d1/types.ts +163 -0
  71. package/src/server/d1/values.ts +138 -0
  72. package/src/server/master-lock/accounts.spec.ts +308 -0
  73. package/src/server/master-lock/guard.spec.ts +69 -7
  74. package/src/server/master-lock/guard.ts +78 -20
  75. package/src/server/master-lock/index.ts +3 -0
  76. package/src/server/master-lock/lockPage.ts +70 -3
  77. package/src/server/master-lock/masterLock.spec.ts +56 -23
  78. package/src/server/master-lock/masterLock.ts +529 -151
  79. package/src/server/master-lock/principals.spec.ts +45 -15
  80. package/src/server/master-lock/principals.ts +6 -1
  81. package/src/server/master-lock/seed.spec.ts +7 -2
  82. package/src/server/master-lock/seed.ts +22 -2
@@ -0,0 +1,302 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { Hono } from 'hono';
3
+ import { assertCpuBudgets, checkCpuBudgets, formatCpuBudgetReport } from './assert';
4
+ import { type CpuBudgetConfig, DEFAULT_ROUTE_CPU_BUDGET_MS } from './budget';
5
+ import { cpuBudget } from './cpuBudget';
6
+ import { fixedCpuClock, processCpuClock, unavailableCpuClock } from './cpuClock';
7
+ import { createCpuRecorder } from './recorder';
8
+ import { runCpuBench } from './runBench';
9
+
10
+ /**
11
+ * 🔴 **The assertion must fire in BOTH directions or it is decoration.** A gate that can
12
+ * only pass is indistinguishable from no gate, and a gate that always fails gets deleted
13
+ * in a week. Everything below is one of those two directions.
14
+ *
15
+ * The end-to-end cases burn REAL CPU through a REAL Hono app, because the thing being
16
+ * proven is that the middleware attributes cost to the right route — which a mocked clock
17
+ * cannot show. The deterministic cases use `fixedCpuClock` for the edges, where a real
18
+ * timing would make the suite flaky for no extra truth.
19
+ */
20
+
21
+ /** Burn approximately `ms` of CPU. Deliberately un-optimizable: the result escapes. */
22
+ function burnCpu(ms: number): number {
23
+ const deadline = performance.now() + ms;
24
+ let acc = 0;
25
+ while (performance.now() < deadline) {
26
+ for (let i = 0; i < 2_000; i += 1) acc += Math.sqrt(i + acc % 7);
27
+ }
28
+ return acc;
29
+ }
30
+
31
+ const SLOW_MS = 30; // ~3× the 9.9 ms budget — far outside measurement noise.
32
+
33
+ function buildApp(config: CpuBudgetConfig, clock = processCpuClock()) {
34
+ const recorder = createCpuRecorder({ clock, config });
35
+ const app = new Hono();
36
+ app.use('*', cpuBudget({ recorder }));
37
+ app.get('/api/cheap', (c) => c.json({ ok: true }));
38
+ app.get('/api/slow', (c) => c.json({ ok: true, acc: burnCpu(SLOW_MS) }));
39
+ app.post('/api/unlock', (c) => c.json({ ok: true, acc: burnCpu(SLOW_MS) }));
40
+ return { app, recorder };
41
+ }
42
+
43
+ describe('direction 1 — a route under budget PASSES', () => {
44
+ it('measures a cheap JSON route and asserts green', async () => {
45
+ const { app, recorder } = buildApp({ routes: { 'GET /api/cheap': 5 } });
46
+ const report = await runCpuBench({
47
+ app,
48
+ recorder,
49
+ cases: [{ path: '/api/cheap' }],
50
+ iterations: 25,
51
+ });
52
+
53
+ expect(report.routes).toHaveLength(1);
54
+ const row = report.routes[0];
55
+ expect(row.key).toBe('GET /api/cheap');
56
+ expect(row.samples).toBe(25);
57
+ expect(row.p99).toBeLessThan(5);
58
+ // Does not throw — that IS the assertion under test.
59
+ expect(assertCpuBudgets(report)).toEqual([]);
60
+ });
61
+ });
62
+
63
+ describe('direction 2 — a route over budget FAILS, and names itself', () => {
64
+ it('reddens on a deliberately slow route and puts the route and its number in the message', async () => {
65
+ const { app, recorder } = buildApp({}); // no declaration → the 9.9 ms default applies
66
+ const report = await runCpuBench({
67
+ app,
68
+ recorder,
69
+ cases: [{ path: '/api/slow' }],
70
+ iterations: 20,
71
+ warmup: 3,
72
+ });
73
+
74
+ const row = report.routes[0];
75
+ expect(row.key).toBe('GET /api/slow');
76
+ expect(row.p99).toBeGreaterThan(DEFAULT_ROUTE_CPU_BUDGET_MS);
77
+
78
+ let message = '';
79
+ expect(() => {
80
+ try {
81
+ assertCpuBudgets(report);
82
+ } catch (err) {
83
+ message = (err as Error).message;
84
+ throw err;
85
+ }
86
+ }).toThrow(/over CPU budget/);
87
+
88
+ // 🔴 The failure names the ROUTE…
89
+ expect(message).toContain('GET /api/slow');
90
+ // …and its NUMBER, and the budget it broke.
91
+ expect(message).toMatch(/spent \d+\.\d+ CPU-ms at p99/);
92
+ expect(message).toContain('9.90 CPU-ms');
93
+ expect(message).toMatch(/\d+\.\d+×/);
94
+ // …and the whole table, so the reader does not have to go looking.
95
+ expect(message).toContain('p50');
96
+ });
97
+
98
+ it('attributes cost to the RIGHT route when a cheap and an expensive one share an app', async () => {
99
+ const { app, recorder } = buildApp({ routes: { 'GET /api/cheap': 5, 'GET /api/slow': 5 } });
100
+ const report = await runCpuBench({
101
+ app,
102
+ recorder,
103
+ cases: [{ path: '/api/cheap' }, { path: '/api/slow' }],
104
+ iterations: 20,
105
+ warmup: 3,
106
+ });
107
+
108
+ const cheap = report.routes.find((r) => r.route === '/api/cheap');
109
+ const slow = report.routes.find((r) => r.route === '/api/slow');
110
+ expect(cheap?.p99).toBeLessThan(5);
111
+ expect(slow?.p99).toBeGreaterThan(SLOW_MS * 0.5);
112
+
113
+ const violations = checkCpuBudgets(report);
114
+ expect(violations.map((v) => v.key)).toEqual(['GET /api/slow']);
115
+ // Worst offender is sorted to the top of the report.
116
+ expect(report.routes[0].route).toBe('/api/slow');
117
+ });
118
+ });
119
+
120
+ describe('the exemption — the KDF is the type specimen', () => {
121
+ it('lets a named exemption through while the same cost fails undeclared', async () => {
122
+ const config: CpuBudgetConfig = {
123
+ routes: {
124
+ 'POST /api/unlock': {
125
+ exempt: true,
126
+ reason: 'argon2id KDF: 332 CPU-ms and 64 MiB measured — expensive on purpose',
127
+ },
128
+ },
129
+ };
130
+ const { app, recorder } = buildApp(config);
131
+ const report = await runCpuBench({
132
+ app,
133
+ recorder,
134
+ cases: [{ method: 'POST', path: '/api/unlock' }],
135
+ iterations: 20,
136
+ warmup: 3,
137
+ });
138
+
139
+ const row = report.routes[0];
140
+ expect(row.budgetMs).toBeNull();
141
+ expect(row.exemptReason).toContain('argon2id');
142
+ expect(row.p99).toBeGreaterThan(DEFAULT_ROUTE_CPU_BUDGET_MS); // genuinely over
143
+ expect(assertCpuBudgets(report)).toEqual([]); // and deliberately allowed
144
+
145
+ expect(formatCpuBudgetReport(report)).toContain('exempt: argon2id KDF');
146
+ });
147
+
148
+ it('still NOTICES an exempt route that crosses its own threshold, without failing', () => {
149
+ const recorder = createCpuRecorder({
150
+ clock: fixedCpuClock([500]),
151
+ config: {
152
+ routes: {
153
+ '/unlock': { exempt: true, reason: 'KDF', noticeAboveMs: 400 },
154
+ },
155
+ },
156
+ });
157
+ for (let i = 0; i < 25; i += 1) recorder.record('POST', '/unlock', 500);
158
+ const violations = checkCpuBudgets(recorder.report());
159
+ expect(violations).toHaveLength(1);
160
+ expect(violations[0].kind).toBe('exempt-notice');
161
+ expect(violations[0].fails).toBe(false);
162
+ expect(() => assertCpuBudgets(recorder.report())).not.toThrow();
163
+ });
164
+ });
165
+
166
+ describe('the ways a green could be a lie', () => {
167
+ it('FAILS a route it could not sample enough times to have a p99', () => {
168
+ const recorder = createCpuRecorder({ clock: fixedCpuClock([1]) });
169
+ for (let i = 0; i < 3; i += 1) recorder.record('GET', '/api/x', 1);
170
+ expect(() => assertCpuBudgets(recorder.report())).toThrow(/needs at least 20/);
171
+ });
172
+
173
+ it('FAILS when the clock could not measure at all, rather than passing on zero', () => {
174
+ const recorder = createCpuRecorder({ clock: unavailableCpuClock() });
175
+ recorder.record('GET', '/api/x', Number.NaN);
176
+ const report = recorder.report();
177
+ expect(report.routes).toHaveLength(0); // an unmeasurable request is not a sample
178
+ expect(() => assertCpuBudgets(report)).toThrow(/nothing could be measured/);
179
+ expect(assertCpuBudgets(report, { allowUnmeasurable: true })).toEqual([]);
180
+ });
181
+
182
+ it('FAILS an undeclared route under requireDeclared, so an app must budget every route', () => {
183
+ const recorder = createCpuRecorder({ clock: fixedCpuClock([1]) });
184
+ for (let i = 0; i < 25; i += 1) recorder.record('GET', '/api/undeclared', 1);
185
+ expect(() => assertCpuBudgets(recorder.report(), { requireDeclared: true })).toThrow(
186
+ /declares no CPU budget/,
187
+ );
188
+ // The same route passes without the flag — the default still applies.
189
+ expect(assertCpuBudgets(recorder.report())).toEqual([]);
190
+ });
191
+
192
+ // 🔴 A 404 costs no CPU. Without this guard the bench reports a beautiful number for a
193
+ // handler that never ran.
194
+ it('REFUSES to bench a route that errors, instead of reporting its cheap 404', async () => {
195
+ const { app, recorder } = buildApp({});
196
+ await expect(
197
+ runCpuBench({ app, recorder, cases: [{ path: '/api/does-not-exist' }], iterations: 5 }),
198
+ ).rejects.toThrow(/returned 404, which this case does not expect/);
199
+ });
200
+
201
+ it('can bench an error path deliberately, when the case says so', async () => {
202
+ const { app, recorder } = buildApp({});
203
+ const report = await runCpuBench({
204
+ app,
205
+ recorder,
206
+ cases: [{ path: '/api/does-not-exist', expectStatus: 404 }],
207
+ iterations: 20,
208
+ warmup: 2,
209
+ });
210
+ expect(report.routes[0].samples).toBe(20);
211
+ });
212
+ });
213
+
214
+ describe('honesty of the number', () => {
215
+ it('labels a local reading as a PROXY, in the report and in its formatting', async () => {
216
+ const { app, recorder } = buildApp({ routes: { 'GET /api/cheap': 5 } });
217
+ const report = await runCpuBench({
218
+ app,
219
+ recorder,
220
+ cases: [{ path: '/api/cheap' }],
221
+ iterations: 20,
222
+ });
223
+ expect(report.proxy).toBe(true);
224
+ expect(report.source).toBe('process.cpuUsage');
225
+ expect(formatCpuBudgetReport(report)).toContain('🔴 PROXY');
226
+ expect(formatCpuBudgetReport(report)).toContain('NOT Worker CPU-ms');
227
+ });
228
+
229
+ it('does NOT label a runtime-supplied reading as a proxy', () => {
230
+ let cpu = 0;
231
+ const recorder = createCpuRecorder({
232
+ clock: {
233
+ source: 'worker-runtime',
234
+ proxy: false,
235
+ available: true,
236
+ start: () => {
237
+ const before = cpu;
238
+ cpu += 1;
239
+ return () => cpu - before;
240
+ },
241
+ },
242
+ });
243
+ for (let i = 0; i < 25; i += 1) recorder.record('GET', '/x', 1);
244
+ const report = recorder.report();
245
+ expect(report.proxy).toBe(false);
246
+ expect(formatCpuBudgetReport(report)).toContain('runtime-reported Worker CPU-ms');
247
+ });
248
+ });
249
+
250
+ describe('the bench runner', () => {
251
+ it('DISCARDS warm-up, so a cold first call cannot become the p99', async () => {
252
+ // The first span through this clock reads 1000; every later one reads 1. With warm-up
253
+ // discarded the route is cheap; without it, one cold call owns the p99 of 20 samples.
254
+ const clock = fixedCpuClock([1000, 1, 1]);
255
+ const recorder = createCpuRecorder({ clock, config: { routes: { '/api/cheap': 5 } } });
256
+ const app = new Hono();
257
+ app.use('*', cpuBudget({ recorder }));
258
+ app.get('/api/cheap', (c) => c.json({ ok: true }));
259
+
260
+ const report = await runCpuBench({
261
+ app,
262
+ recorder,
263
+ cases: [{ path: '/api/cheap' }],
264
+ iterations: 20,
265
+ warmup: 1,
266
+ });
267
+ expect(report.routes[0].max).toBe(1);
268
+ expect(report.routes[0].samples).toBe(20);
269
+ expect(assertCpuBudgets(report)).toEqual([]);
270
+ });
271
+
272
+ // A handler that is expensive only on its error path is exactly what a budget should
273
+ // catch, so the sample must survive the throw. The middleware re-throws unchanged and
274
+ // Hono's own onError turns it into the 500 — the CPU is attributed either way.
275
+ it('records CPU even when the handler throws, and leaves error handling alone', async () => {
276
+ const recorder = createCpuRecorder({ clock: fixedCpuClock([42]) });
277
+ const seen: string[] = [];
278
+ const app = new Hono();
279
+ app.onError((err, c) => {
280
+ seen.push((err as Error).message);
281
+ return c.json({ error: 'handled' }, 500);
282
+ });
283
+ app.use('*', cpuBudget({ recorder }));
284
+ app.get('/boom', () => {
285
+ throw new Error('kaboom');
286
+ });
287
+
288
+ const res = await app.request('http://cpu-bench.invalid/boom');
289
+ expect(res.status).toBe(500);
290
+ // Re-thrown unchanged: the app's own onError saw the original error.
291
+ expect(seen).toEqual(['kaboom']);
292
+
293
+ const report = recorder.report();
294
+ expect(report.routes[0].key).toBe('GET /boom');
295
+ expect(report.routes[0].max).toBe(42);
296
+ });
297
+
298
+ it('refuses a run with no cases rather than passing vacuously', async () => {
299
+ const { app, recorder } = buildApp({});
300
+ await expect(runCpuBench({ app, recorder, cases: [] })).rejects.toThrow(/no cases/);
301
+ });
302
+ });
@@ -0,0 +1,81 @@
1
+ import type { MiddlewareHandler } from 'hono';
2
+ import type { CursedbeltEnv } from '../context';
3
+ import { normalizeRoute } from '../metrics/normalizeRoute';
4
+ import type { CpuBudgetConfig } from './budget';
5
+ import { type CpuClock, processCpuClock } from './cpuClock';
6
+ import { type CpuRecorder, createCpuRecorder } from './recorder';
7
+
8
+ /**
9
+ * Per-route CPU attribution, mounted like any other cursedbelt middleware.
10
+ *
11
+ * ```ts
12
+ * const cpu = createCpuRecorder({ clock: processCpuClock(), config: BUDGETS })
13
+ * app.use('*', cpuBudget({ recorder: cpu }))
14
+ * ```
15
+ *
16
+ * 🔴 **It records CPU, never wall clock.** `requestLogger` beside it records duration,
17
+ * and duration is the quantity Cloudflare explicitly does not bill. The two numbers
18
+ * diverge by exactly the amount a handler spends awaiting a subrequest, which on this
19
+ * fleet is most of it — so a route can be slow and free, or fast and expensive, and only
20
+ * this middleware can tell you which.
21
+ *
22
+ * Like `requestLogger`, it records even when a downstream handler THROWS: the CPU was
23
+ * spent either way, and an endpoint that is expensive only on its error path is exactly
24
+ * the sort of thing a budget is supposed to catch. The error is re-thrown unchanged.
25
+ */
26
+ export interface CpuBudgetOpts {
27
+ /** The recorder to write through. Provide one so the gate can read its report. */
28
+ recorder?: CpuRecorder;
29
+ /** Built into a recorder when `recorder` is omitted. */
30
+ config?: CpuBudgetConfig;
31
+ /** Defaults to {@link processCpuClock} — the local PROXY. */
32
+ clock?: CpuClock;
33
+ /**
34
+ * Called after each request with the sample. For shipping the number somewhere (a
35
+ * telemetry sink, a log line) without coupling this module to a destination.
36
+ */
37
+ onSample?: (sample: {
38
+ method: string;
39
+ route: string;
40
+ cpuMs: number;
41
+ proxy: boolean;
42
+ status: number;
43
+ }) => void;
44
+ }
45
+
46
+ export function cpuBudget(
47
+ opts: CpuBudgetOpts = {},
48
+ ): MiddlewareHandler<CursedbeltEnv> & { recorder: CpuRecorder } {
49
+ const recorder =
50
+ opts.recorder ??
51
+ createCpuRecorder({ clock: opts.clock ?? processCpuClock(), config: opts.config });
52
+
53
+ const handler: MiddlewareHandler<CursedbeltEnv> = async (c, next) => {
54
+ const end = recorder.clock.start();
55
+ let thrown: unknown;
56
+ let didThrow = false;
57
+ try {
58
+ await next();
59
+ } catch (err) {
60
+ thrown = err;
61
+ didThrow = true;
62
+ }
63
+ const cpuMs = end();
64
+
65
+ // `normalizeRoute` must run AFTER `next()` — before it, Hono has not matched a route
66
+ // pattern yet and every request would aggregate under its raw path.
67
+ const { method, route } = normalizeRoute(c);
68
+ recorder.record(method, route, cpuMs);
69
+ opts.onSample?.({
70
+ method,
71
+ route,
72
+ cpuMs,
73
+ proxy: recorder.clock.proxy,
74
+ status: didThrow ? 500 : c.res.status,
75
+ });
76
+
77
+ if (didThrow) throw thrown;
78
+ };
79
+
80
+ return Object.assign(handler, { recorder });
81
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Where a CPU-millisecond comes from, and how honest it is.
3
+ *
4
+ * 🔴 **Every reading carries `proxy`, and it is not decoration.** The task that asked for
5
+ * this module asked for the label in the same breath as the measurement: *"a local number
6
+ * that is called a CPU-ms measurement will be quoted as one."* A number printed without
7
+ * its provenance becomes a fact about Cloudflare's billing the first time somebody pastes
8
+ * it into a summary.
9
+ *
10
+ * Two sources, and they are not interchangeable:
11
+ *
12
+ * · {@link processCpuClock} — `process.cpuUsage()` deltas on Bun/Node. `proxy: true`.
13
+ * · {@link workerCpuClock} — a reader the Worker runtime supplies. `proxy: false`.
14
+ *
15
+ * There is deliberately no built-in Cloudflare reader here. The isolate does not hand a
16
+ * handler its own CPU time; `cpuTime` arrives on the trace event a **tail worker** sees.
17
+ * Inventing an API that does not exist would produce a clock that reads zero for ever and
18
+ * a gate that can never go red — so the platform passes its reader in, or there is none.
19
+ */
20
+
21
+ /** A started measurement. Call it to end the measurement and get CPU-ms. */
22
+ export type CpuSpan = () => number;
23
+
24
+ export interface CpuClock {
25
+ /** Human-readable provenance, e.g. `'process.cpuUsage'`. Printed in every report. */
26
+ readonly source: string;
27
+ /**
28
+ * 🔴 True when this number is a PROXY for Worker CPU-ms rather than a reading of it.
29
+ * The report prints it and {@link CpuBudgetReport} carries it downstream.
30
+ */
31
+ readonly proxy: boolean;
32
+ /** True when the clock can actually measure. A false one records nothing. */
33
+ readonly available: boolean;
34
+ start(): CpuSpan;
35
+ }
36
+
37
+ /**
38
+ * `process.cpuUsage()` deltas — user + system, converted from µs to ms.
39
+ *
40
+ * 🔴 **The delta is PROCESS-WIDE, not per-request.** If two requests are in flight the
41
+ * reading for each includes the other's CPU, and any background work (a metrics flush, a
42
+ * GC pause attributed to the interval) lands on whichever handler happened to be open.
43
+ * That is why {@link runCpuBench} drives its cases SERIALLY — a serial driver is what
44
+ * makes this proxy sound enough to gate on. Mounted in production it is advisory: useful
45
+ * for ranking routes, not for a billing claim.
46
+ */
47
+ export function processCpuClock(): CpuClock {
48
+ const usable = typeof process !== 'undefined' && typeof process.cpuUsage === 'function';
49
+ if (!usable) return unavailableCpuClock('process.cpuUsage (absent)');
50
+ return {
51
+ source: 'process.cpuUsage',
52
+ proxy: true,
53
+ available: true,
54
+ start(): CpuSpan {
55
+ const before = process.cpuUsage();
56
+ return () => {
57
+ const d = process.cpuUsage(before);
58
+ return (d.user + d.system) / 1000;
59
+ };
60
+ },
61
+ };
62
+ }
63
+
64
+ /**
65
+ * A clock over a CPU-ms reader the runtime supplies. `proxy: false` — this is the real
66
+ * quantity Cloudflare bills, so a report built on it may be quoted as one.
67
+ *
68
+ * @param readCpuMs Returns monotonically non-decreasing CPU-ms for the current invocation.
69
+ */
70
+ export function workerCpuClock(readCpuMs: () => number, source = 'worker-runtime'): CpuClock {
71
+ return {
72
+ source,
73
+ proxy: false,
74
+ available: true,
75
+ start(): CpuSpan {
76
+ const before = readCpuMs();
77
+ return () => Math.max(0, readCpuMs() - before);
78
+ },
79
+ };
80
+ }
81
+
82
+ /**
83
+ * A clock that cannot measure. Recording through it is a no-op, and the report says so
84
+ * rather than reporting a confident zero.
85
+ */
86
+ export function unavailableCpuClock(source = 'unavailable'): CpuClock {
87
+ return {
88
+ source,
89
+ proxy: true,
90
+ available: false,
91
+ start: () => () => Number.NaN,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * A deterministic clock for tests: each `start()` consumes the next value in `values`,
97
+ * repeating the last one once exhausted. Lets the assertion be proven in both directions
98
+ * without burning real CPU, which is the difference between a test that is fast and
99
+ * reliable and one that is neither.
100
+ */
101
+ export function fixedCpuClock(values: number[], source = 'fixed'): CpuClock {
102
+ if (values.length === 0) throw new Error('fixedCpuClock: needs at least one value');
103
+ let i = 0;
104
+ return {
105
+ source,
106
+ proxy: true,
107
+ available: true,
108
+ start(): CpuSpan {
109
+ const v = values[Math.min(i, values.length - 1)];
110
+ i += 1;
111
+ return () => v;
112
+ },
113
+ };
114
+ }
115
+
116
+ /** `workerCpuClock` when the platform supplied a reader, else the local proxy. */
117
+ export function resolveCpuClock(readCpuMs?: (() => number) | null): CpuClock {
118
+ return readCpuMs ? workerCpuClock(readCpuMs) : processCpuClock();
119
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `cursedbelt-server/bench` — per-route CPU attribution for a Hono app, a budget the app
3
+ * declares, and an assertion that reddens a build when a route outgrows it.
4
+ *
5
+ * ## The shape of a use
6
+ *
7
+ * ```ts
8
+ * // routes.budgets.ts — the app declares what each route may cost
9
+ * export const BUDGETS: CpuBudgetConfig = {
10
+ * routes: {
11
+ * 'GET /api/notes/:id': 4,
12
+ * 'POST /api/unlock': { exempt: true, reason: 'argon2id KDF — 332 CPU-ms, measured' },
13
+ * },
14
+ * }
15
+ *
16
+ * // app.ts
17
+ * export const cpu = createCpuRecorder({ clock: processCpuClock(), config: BUDGETS })
18
+ * app.use('*', cpuBudget({ recorder: cpu }))
19
+ *
20
+ * // cpuBudget.spec.ts — the gate
21
+ * const report = await runCpuBench({ app, recorder: cpu, cases: CASES })
22
+ * assertCpuBudgets(report, { requireDeclared: true })
23
+ * ```
24
+ *
25
+ * ## 🔴 Two things to carry away
26
+ *
27
+ * **Wall clock is never billed.** Cloudflare's own pricing page says *"No charge or limit
28
+ * for duration"*. `requestLogger` records duration; this records CPU; they are different
29
+ * numbers and only the second one costs money.
30
+ *
31
+ * **A local reading is a PROXY.** `process.cpuUsage()` on this Mac is not Worker CPU-ms.
32
+ * Every report carries `proxy: true` and every formatted report says so, because the
33
+ * alternative is a number that gets quoted as a billing fact.
34
+ */
35
+
36
+ export {
37
+ type AssertCpuBudgetsOpts,
38
+ assertCpuBudgets,
39
+ checkCpuBudgets,
40
+ type CpuViolation,
41
+ type CpuViolationKind,
42
+ formatCpuBudgetReport,
43
+ } from './assert';
44
+ export {
45
+ type CpuBudgetConfig,
46
+ type CpuBudgetExemption,
47
+ type CpuBudgetLimit,
48
+ DAYS_PER_MONTH,
49
+ DEFAULT_ROUTE_CPU_BUDGET_MS,
50
+ deriveCpuBudgetMs,
51
+ isExemption,
52
+ monthlyOverageUsd,
53
+ OWNER_PROJECTED_REQUESTS_PER_DAY,
54
+ type ResolvedBudget,
55
+ resolveBudget,
56
+ type RouteBudget,
57
+ validateCpuBudgetConfig,
58
+ WORKERS_PAID_INCLUDED_CPU_MS,
59
+ WORKERS_PAID_INCLUDED_REQUESTS,
60
+ WORKERS_PAID_USD_PER_MILLION_CPU_MS,
61
+ WORKERS_PAID_USD_PER_MILLION_REQUESTS,
62
+ } from './budget';
63
+ export { type CpuBudgetOpts, cpuBudget } from './cpuBudget';
64
+ export {
65
+ type CpuClock,
66
+ type CpuSpan,
67
+ fixedCpuClock,
68
+ processCpuClock,
69
+ resolveCpuClock,
70
+ unavailableCpuClock,
71
+ workerCpuClock,
72
+ } from './cpuClock';
73
+ export {
74
+ type CpuBudgetReport,
75
+ type CpuRecorder,
76
+ type CpuRecorderOpts,
77
+ createCpuRecorder,
78
+ percentile,
79
+ type RouteCpuStats,
80
+ } from './recorder';
81
+ export { BENCH_ORIGIN, type BenchCase, runCpuBench, type RunCpuBenchOpts } from './runBench';