cursedbelt-server 1.1.0 → 2.1.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 (81) 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/sync/http.d.ts +20 -3
  36. package/dist/server/sync/http.js +20 -14
  37. package/dist/server/sync/index.d.ts +10 -2
  38. package/dist/server/sync/index.js +9 -1
  39. package/dist/server/sync/planner.d.ts +38 -8
  40. package/dist/server/sync/planner.js +32 -8
  41. package/dist/server/sync/signal.d.ts +161 -0
  42. package/dist/server/sync/signal.js +348 -0
  43. package/dist/server/sync/timer.d.ts +63 -19
  44. package/dist/server/sync/timer.js +104 -45
  45. package/dist/server/sync/types.d.ts +0 -2
  46. package/package.json +21 -3
  47. package/src/leafSubpathsImportNothing.spec.ts +15 -3
  48. package/src/noTimerDialsAPeer.spec.ts +469 -0
  49. package/src/server/bench/assert.ts +192 -0
  50. package/src/server/bench/budget.spec.ts +126 -0
  51. package/src/server/bench/budget.ts +207 -0
  52. package/src/server/bench/cpuBudget.spec.ts +302 -0
  53. package/src/server/bench/cpuBudget.ts +81 -0
  54. package/src/server/bench/cpuClock.ts +119 -0
  55. package/src/server/bench/index.ts +81 -0
  56. package/src/server/bench/recorder.ts +163 -0
  57. package/src/server/bench/runBench.ts +110 -0
  58. package/src/server/d1/backup.spec.ts +121 -0
  59. package/src/server/d1/backup.ts +186 -0
  60. package/src/server/d1/fakeD1.ts +193 -0
  61. package/src/server/d1/index.ts +62 -0
  62. package/src/server/d1/kysely.spec.ts +145 -0
  63. package/src/server/d1/kysely.ts +169 -0
  64. package/src/server/d1/limits.spec.ts +90 -0
  65. package/src/server/d1/limits.ts +123 -0
  66. package/src/server/d1/local.ts +173 -0
  67. package/src/server/d1/remote.ts +182 -0
  68. package/src/server/d1/sameShape.spec.ts +279 -0
  69. package/src/server/d1/scheduling.spec.ts +120 -0
  70. package/src/server/d1/scheduling.ts +210 -0
  71. package/src/server/d1/types.ts +163 -0
  72. package/src/server/d1/values.ts +138 -0
  73. package/src/server/sync/http.ts +31 -16
  74. package/src/server/sync/index.ts +23 -1
  75. package/src/server/sync/planner.spec.ts +33 -16
  76. package/src/server/sync/planner.ts +48 -11
  77. package/src/server/sync/signal.spec.ts +306 -0
  78. package/src/server/sync/signal.ts +422 -0
  79. package/src/server/sync/timer.spec.ts +97 -16
  80. package/src/server/sync/timer.ts +124 -47
  81. package/src/server/sync/types.ts +0 -2
@@ -0,0 +1,126 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import {
3
+ DAYS_PER_MONTH,
4
+ DEFAULT_ROUTE_CPU_BUDGET_MS,
5
+ deriveCpuBudgetMs,
6
+ monthlyOverageUsd,
7
+ OWNER_PROJECTED_REQUESTS_PER_DAY,
8
+ resolveBudget,
9
+ validateCpuBudgetConfig,
10
+ WORKERS_PAID_INCLUDED_CPU_MS,
11
+ } from './budget';
12
+ import { percentile } from './recorder';
13
+
14
+ describe('the derivation', () => {
15
+ it('derives 9.9 CPU-ms from the Workers Paid allowance and the projected traffic', () => {
16
+ // 30,000,000 CPU-ms ÷ (100,000/day × 30.4375 days) = 9.856…
17
+ const raw = deriveCpuBudgetMs();
18
+ expect(raw).toBeCloseTo(9.856, 2);
19
+ expect(DEFAULT_ROUTE_CPU_BUDGET_MS).toBe(9.9);
20
+ });
21
+
22
+ it('is a FUNCTION of traffic, so the number moves when the measurement replaces the guess', () => {
23
+ // Twice the traffic halves what each request may spend. This is the call the outcome
24
+ // asks for once an app is live — not a constant somebody has to remember to edit.
25
+ expect(deriveCpuBudgetMs({ requestsPerDay: 200_000 })).toBeCloseTo(4.928, 2);
26
+ expect(deriveCpuBudgetMs({ requestsPerDay: 10_000 })).toBeCloseTo(98.56, 1);
27
+ expect(deriveCpuBudgetMs({ requestsPerMonth: 30_000_000 })).toBe(1);
28
+ });
29
+
30
+ it('holds the owner projection it was derived from, so a reader can check the arithmetic', () => {
31
+ expect(OWNER_PROJECTED_REQUESTS_PER_DAY * DAYS_PER_MONTH).toBeCloseTo(3_043_750, 0);
32
+ expect(WORKERS_PAID_INCLUDED_CPU_MS).toBe(30_000_000);
33
+ });
34
+
35
+ it('refuses a zero/negative request volume rather than returning Infinity', () => {
36
+ expect(() => deriveCpuBudgetMs({ requestsPerMonth: 0 })).toThrow(/must be > 0/);
37
+ });
38
+ });
39
+
40
+ describe('what going over actually costs', () => {
41
+ it('prices the overage — 3× the CPU budget at this traffic is about $1.20/month', () => {
42
+ const requestsPerMonth = OWNER_PROJECTED_REQUESTS_PER_DAY * DAYS_PER_MONTH;
43
+ const { cpuUsd, requestsUsd, totalUsd } = monthlyOverageUsd({
44
+ requestsPerMonth,
45
+ meanCpuMsPerRequest: DEFAULT_ROUTE_CPU_BUDGET_MS * 3,
46
+ });
47
+ // Requests are still well inside the included 10 M, so the whole bill is CPU.
48
+ expect(requestsUsd).toBe(0);
49
+ expect(cpuUsd).toBeCloseTo(1.2, 1);
50
+ expect(totalUsd).toBeCloseTo(1.2, 1);
51
+ });
52
+
53
+ it('charges nothing while both allowances are unspent', () => {
54
+ const { totalUsd } = monthlyOverageUsd({
55
+ requestsPerMonth: 1_000_000,
56
+ meanCpuMsPerRequest: 5,
57
+ });
58
+ expect(totalUsd).toBe(0);
59
+ });
60
+ });
61
+
62
+ describe('declaring a budget', () => {
63
+ it('prefers a method-specific key over a path-wide one', () => {
64
+ const config = { routes: { 'POST /api/x': 40, '/api/x': 2 } };
65
+ expect(resolveBudget(config, 'POST', '/api/x').cpuMs).toBe(40);
66
+ expect(resolveBudget(config, 'GET', '/api/x').cpuMs).toBe(2);
67
+ });
68
+
69
+ it('falls through to the default and says it was not declared', () => {
70
+ const r = resolveBudget({}, 'GET', '/whatever');
71
+ expect(r.cpuMs).toBe(DEFAULT_ROUTE_CPU_BUDGET_MS);
72
+ expect(r.declared).toBe(false);
73
+ });
74
+
75
+ it('carries an exemption through as a null ceiling plus its reason', () => {
76
+ const r = resolveBudget(
77
+ { routes: { 'POST /unlock': { exempt: true, reason: 'argon2id KDF' } } },
78
+ 'POST',
79
+ '/unlock',
80
+ );
81
+ expect(r.cpuMs).toBeNull();
82
+ expect(r.exemptReason).toBe('argon2id KDF');
83
+ expect(r.declared).toBe(true);
84
+ });
85
+
86
+ // 🔴 The rule the task names: an opt-out must justify itself.
87
+ it('REFUSES an exemption with no reason', () => {
88
+ expect(() =>
89
+ validateCpuBudgetConfig({
90
+ routes: { '/unlock': { exempt: true, reason: ' ' } },
91
+ }),
92
+ ).toThrow(/exempt with no reason/);
93
+ });
94
+
95
+ it('refuses a nonsense budget up front, not when the route is first hit', () => {
96
+ expect(() => validateCpuBudgetConfig({ routes: { '/a': 0 } })).toThrow(/must be > 0/);
97
+ expect(() => validateCpuBudgetConfig({ routes: { '/a': -3 } })).toThrow(/must be > 0/);
98
+ expect(() => validateCpuBudgetConfig({ defaultCpuMs: 0 })).toThrow(/defaultCpuMs/);
99
+ });
100
+ });
101
+
102
+ describe('percentiles', () => {
103
+ // 🔴 The reason the report is a p99 and not a mean, stated as a test: this exact
104
+ // distribution PASSES a 9.9 ms budget on its mean and FAILS it on its p99.
105
+ it('reports the tail the mean hides', () => {
106
+ const samples = [...Array(98).fill(1), 400, 400].sort((a, b) => a - b);
107
+ const mean = samples.reduce((a, b) => a + b, 0) / samples.length;
108
+ expect(mean).toBeCloseTo(8.98, 2);
109
+ expect(mean).toBeLessThan(DEFAULT_ROUTE_CPU_BUDGET_MS); // a mean-based gate: green
110
+ expect(percentile(samples, 99)).toBe(400); // a p99-based gate: red, correctly
111
+ expect(percentile(samples, 50)).toBe(1);
112
+ });
113
+
114
+ it('uses nearest-rank, so it never reports a cost no request actually paid', () => {
115
+ const s = [1, 2, 3, 4];
116
+ expect(percentile(s, 50)).toBe(2);
117
+ expect(percentile(s, 75)).toBe(3);
118
+ expect(percentile(s, 99)).toBe(4);
119
+ // An interpolating p75 would answer 3.25 — a number no sample equals.
120
+ expect(s).toContain(percentile(s, 75));
121
+ });
122
+
123
+ it('answers NaN for no samples rather than 0', () => {
124
+ expect(percentile([], 99)).toBeNaN();
125
+ });
126
+ });
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The CPU budget, DERIVED rather than asserted.
3
+ *
4
+ * ## Why CPU and not requests
5
+ *
6
+ * The Workers Paid plan (`developers.cloudflare.com/workers/platform/pricing/`) meters
7
+ * requests and CPU time as two SEPARATE included allowances:
8
+ *
9
+ * · $5/month base
10
+ * · 10 million requests included, then +$0.30 per additional million
11
+ * · 30 million CPU-milliseconds included, then +$0.02 per additional million
12
+ * · 30 s CPU limit per invocation (raisable to 5 min)
13
+ * · duration: "No charge or limit for duration" — wall clock is NEVER billed
14
+ *
15
+ * 🔴 There is no 125 ms per-request ceiling and no wall-clock fallback. That was the
16
+ * retired Bundled/Unbound model. Nothing here should be reasoned about in wall time:
17
+ * an `await` on a subrequest costs duration, and duration is free.
18
+ *
19
+ * At the owner's projection of 100k requests/day the two allowances are not equally
20
+ * tight — requests run to 30 % of the included 10 M while CPU is the binding one, which
21
+ * is the whole reason this module exists.
22
+ *
23
+ * ## 🔴 The default is a PROJECTION until an app is live
24
+ *
25
+ * {@link DEFAULT_ROUTE_CPU_BUDGET_MS} falls out of {@link OWNER_PROJECTED_REQUESTS_PER_DAY},
26
+ * which is the owner's estimate and not a measurement. {@link deriveCpuBudgetMs} is the
27
+ * function to re-run against real traffic — the re-derivation is a call, not a paragraph
28
+ * somebody has to remember to do.
29
+ */
30
+
31
+ /** CPU-milliseconds included in the Workers Paid plan each month. */
32
+ export const WORKERS_PAID_INCLUDED_CPU_MS = 30_000_000;
33
+
34
+ /** Requests included in the Workers Paid plan each month. */
35
+ export const WORKERS_PAID_INCLUDED_REQUESTS = 10_000_000;
36
+
37
+ /** USD per additional million CPU-ms, once the included allowance is spent. */
38
+ export const WORKERS_PAID_USD_PER_MILLION_CPU_MS = 0.02;
39
+
40
+ /** USD per additional million requests, once the included allowance is spent. */
41
+ export const WORKERS_PAID_USD_PER_MILLION_REQUESTS = 0.3;
42
+
43
+ /**
44
+ * The owner's own projection, 2026-09-15. 🔴 A PROJECTION, not a measurement — the
45
+ * whole point of {@link deriveCpuBudgetMs} is to replace it once an app is serving.
46
+ */
47
+ export const OWNER_PROJECTED_REQUESTS_PER_DAY = 100_000;
48
+
49
+ /** 365.25 / 12 — the average calendar month, since billing is monthly. */
50
+ export const DAYS_PER_MONTH = 30.4375;
51
+
52
+ /**
53
+ * Derive the average CPU-ms a single request may spend before the fleet exceeds its
54
+ * included CPU allowance.
55
+ *
56
+ * 🔴 This is an AVERAGE-per-request allowance, not a per-invocation limit. A route may
57
+ * exceed it and cost nothing, provided cheap routes carry the mean. What it is for is
58
+ * ranking: a route whose p99 sits above the average budget is a route that cannot be
59
+ * allowed to become the common case.
60
+ */
61
+ export function deriveCpuBudgetMs(
62
+ opts: {
63
+ /** Included CPU-ms per month. Default: the Workers Paid allowance. */
64
+ includedCpuMs?: number;
65
+ /** Measured (or projected) requests per month. */
66
+ requestsPerMonth?: number;
67
+ /** Measured (or projected) requests per day — converted with {@link DAYS_PER_MONTH}. */
68
+ requestsPerDay?: number;
69
+ } = {},
70
+ ): number {
71
+ const includedCpuMs = opts.includedCpuMs ?? WORKERS_PAID_INCLUDED_CPU_MS;
72
+ const requestsPerMonth =
73
+ opts.requestsPerMonth ??
74
+ (opts.requestsPerDay ?? OWNER_PROJECTED_REQUESTS_PER_DAY) * DAYS_PER_MONTH;
75
+ if (!(requestsPerMonth > 0)) {
76
+ throw new Error('deriveCpuBudgetMs: requestsPerMonth must be > 0');
77
+ }
78
+ return includedCpuMs / requestsPerMonth;
79
+ }
80
+
81
+ /**
82
+ * What a month costs in overage once the included allowances are spent. Used by the
83
+ * report so a violation carries a PRICE and not only a number — the honest framing is
84
+ * that going 3× over the CPU budget at this traffic is about $1.20/month, which is a
85
+ * reason to care about the outlier route and not a reason to panic.
86
+ */
87
+ export function monthlyOverageUsd(opts: {
88
+ requestsPerMonth: number;
89
+ meanCpuMsPerRequest: number;
90
+ }): { cpuUsd: number; requestsUsd: number; totalUsd: number } {
91
+ const cpuMs = opts.requestsPerMonth * opts.meanCpuMsPerRequest;
92
+ const cpuOver = Math.max(0, cpuMs - WORKERS_PAID_INCLUDED_CPU_MS);
93
+ const reqOver = Math.max(0, opts.requestsPerMonth - WORKERS_PAID_INCLUDED_REQUESTS);
94
+ const cpuUsd = (cpuOver / 1_000_000) * WORKERS_PAID_USD_PER_MILLION_CPU_MS;
95
+ const requestsUsd = (reqOver / 1_000_000) * WORKERS_PAID_USD_PER_MILLION_REQUESTS;
96
+ return { cpuUsd, requestsUsd, totalUsd: cpuUsd + requestsUsd };
97
+ }
98
+
99
+ /**
100
+ * 9.9 CPU-ms — `30,000,000 ÷ (100,000 × 30.4375)` = 9.856, to one decimal.
101
+ *
102
+ * Generous for a JSON route, tight for anything that renders, parses or derives a key.
103
+ * 🔴 Re-derive with {@link deriveCpuBudgetMs} once an app has real traffic.
104
+ */
105
+ export const DEFAULT_ROUTE_CPU_BUDGET_MS = Math.round(deriveCpuBudgetMs() * 10) / 10;
106
+
107
+ /** A route that is measured against a number. */
108
+ export interface CpuBudgetLimit {
109
+ cpuMs: number;
110
+ }
111
+
112
+ /**
113
+ * A route that is deliberately NOT measured against the default.
114
+ *
115
+ * 🔴 `reason` is required by the type AND checked at runtime. An exemption whose
116
+ * justification is blank is the paragraph this module exists to replace — a silent
117
+ * opt-out is indistinguishable from a route nobody looked at.
118
+ */
119
+ export interface CpuBudgetExemption {
120
+ exempt: true;
121
+ reason: string;
122
+ /**
123
+ * The exemption still records, and a p99 above this is reported as a NOTICE rather
124
+ * than a violation. Optional — an exemption with no ceiling is never surprising.
125
+ */
126
+ noticeAboveMs?: number;
127
+ }
128
+
129
+ export type RouteBudget = CpuBudgetLimit | CpuBudgetExemption | number;
130
+
131
+ export interface CpuBudgetConfig {
132
+ /** Applied to any route without its own entry. Default: {@link DEFAULT_ROUTE_CPU_BUDGET_MS}. */
133
+ defaultCpuMs?: number;
134
+ /**
135
+ * Per-route budgets, keyed either `'GET /api/notes/:id'` (method-specific, wins) or
136
+ * `'/api/notes/:id'` (any method). Keys are matched against the same normalized route
137
+ * label the metrics tier uses, so `:params` are already collapsed.
138
+ */
139
+ routes?: Record<string, RouteBudget>;
140
+ }
141
+
142
+ export function isExemption(b: RouteBudget): b is CpuBudgetExemption {
143
+ return typeof b === 'object' && 'exempt' in b && b.exempt === true;
144
+ }
145
+
146
+ /** The resolved budget for one route label. */
147
+ export interface ResolvedBudget {
148
+ /** The ceiling in CPU-ms, or `null` when the route is exempt. */
149
+ cpuMs: number | null;
150
+ exemptReason?: string;
151
+ noticeAboveMs?: number;
152
+ /** True when no explicit entry matched and the default was applied. */
153
+ declared: boolean;
154
+ }
155
+
156
+ /**
157
+ * 🔴 Validate the whole config up front rather than at the moment a route is hit. A
158
+ * blank exemption reason on a route nobody exercised in the bench would otherwise ship.
159
+ */
160
+ export function validateCpuBudgetConfig(config: CpuBudgetConfig): void {
161
+ if (config.defaultCpuMs !== undefined && !(config.defaultCpuMs > 0)) {
162
+ throw new Error(`cpuBudget: defaultCpuMs must be > 0, got ${config.defaultCpuMs}`);
163
+ }
164
+ for (const [key, budget] of Object.entries(config.routes ?? {})) {
165
+ if (isExemption(budget)) {
166
+ if (typeof budget.reason !== 'string' || budget.reason.trim() === '') {
167
+ throw new Error(
168
+ `cpuBudget: route '${key}' is exempt with no reason. An opt-out must name why — ` +
169
+ 'that is the difference between a decision and an oversight.',
170
+ );
171
+ }
172
+ if (budget.noticeAboveMs !== undefined && !(budget.noticeAboveMs > 0)) {
173
+ throw new Error(`cpuBudget: route '${key}' has noticeAboveMs ≤ 0`);
174
+ }
175
+ continue;
176
+ }
177
+ const cpuMs = typeof budget === 'number' ? budget : budget.cpuMs;
178
+ if (!(cpuMs > 0)) {
179
+ throw new Error(`cpuBudget: route '${key}' has a budget of ${cpuMs}; it must be > 0`);
180
+ }
181
+ }
182
+ }
183
+
184
+ /** Resolve `method` + `route` against the config. Method-specific keys win. */
185
+ export function resolveBudget(
186
+ config: CpuBudgetConfig,
187
+ method: string,
188
+ route: string,
189
+ ): ResolvedBudget {
190
+ const routes = config.routes ?? {};
191
+ const explicit = routes[`${method} ${route}`] ?? routes[route];
192
+ if (explicit === undefined) {
193
+ return { cpuMs: config.defaultCpuMs ?? DEFAULT_ROUTE_CPU_BUDGET_MS, declared: false };
194
+ }
195
+ if (isExemption(explicit)) {
196
+ return {
197
+ cpuMs: null,
198
+ exemptReason: explicit.reason,
199
+ noticeAboveMs: explicit.noticeAboveMs,
200
+ declared: true,
201
+ };
202
+ }
203
+ return {
204
+ cpuMs: typeof explicit === 'number' ? explicit : explicit.cpuMs,
205
+ declared: true,
206
+ };
207
+ }
@@ -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
+ });