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,61 @@
1
+ /**
2
+ * The gate assertion. *"Document the WHY; automate the WHETHER."*
3
+ *
4
+ * ๐Ÿ”ด A report that is printed and not asserted on is the paragraph this module exists to
5
+ * replace. {@link assertCpuBudgets} THROWS, so a route that grows past its budget reddens
6
+ * a build instead of adding a line to a log nobody opens.
7
+ */
8
+ import type { CpuBudgetReport } from './recorder';
9
+ export type CpuViolationKind =
10
+ /** p99 above the route's declared (or default) budget. */
11
+ 'over-budget'
12
+ /** Too few samples for the percentile to mean anything. */
13
+ | 'insufficient-samples'
14
+ /** `requireDeclared` and the route fell through to the default. */
15
+ | 'undeclared'
16
+ /** An exempt route crossed its own `noticeAboveMs`. Never fails a build. */
17
+ | 'exempt-notice'
18
+ /** The clock could not measure at all. */
19
+ | 'unmeasurable';
20
+ export interface CpuViolation {
21
+ kind: CpuViolationKind;
22
+ /** `'GET /api/notes/:id'`. */
23
+ key: string;
24
+ method: string;
25
+ route: string;
26
+ p99: number;
27
+ budgetMs: number | null;
28
+ samples: number;
29
+ /** False for `exempt-notice`, which is informational by design. */
30
+ fails: boolean;
31
+ message: string;
32
+ }
33
+ export interface AssertCpuBudgetsOpts {
34
+ /**
35
+ * Minimum samples before a p99 is trusted. Below it the route is a violation rather
36
+ * than a pass: a budget nothing could measure is not a budget that was met.
37
+ * Default: 20.
38
+ */
39
+ minSamples?: number;
40
+ /**
41
+ * Fail any route that relied on the default budget instead of declaring one. Off by
42
+ * default; an app turns it on to prove EVERY route has been thought about.
43
+ */
44
+ requireDeclared?: boolean;
45
+ /**
46
+ * Treat an unavailable clock as a pass. Off by default โ€” a gate that goes green
47
+ * because it measured nothing is worse than no gate.
48
+ */
49
+ allowUnmeasurable?: boolean;
50
+ }
51
+ /** Compute violations without throwing. {@link assertCpuBudgets} is this plus a throw. */
52
+ export declare function checkCpuBudgets(report: CpuBudgetReport, opts?: AssertCpuBudgetsOpts): CpuViolation[];
53
+ /** A human-readable table of every route measured, worst p99 first. */
54
+ export declare function formatCpuBudgetReport(report: CpuBudgetReport): string;
55
+ /**
56
+ * Throw if any route exceeds its budget.
57
+ *
58
+ * ๐Ÿ”ด The message names the ROUTE and its NUMBER, because a failure that says only
59
+ * "a route is over budget" sends the reader back to the report to find out which.
60
+ */
61
+ export declare function assertCpuBudgets(report: CpuBudgetReport, opts?: AssertCpuBudgetsOpts): CpuViolation[];
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The gate assertion. *"Document the WHY; automate the WHETHER."*
3
+ *
4
+ * ๐Ÿ”ด A report that is printed and not asserted on is the paragraph this module exists to
5
+ * replace. {@link assertCpuBudgets} THROWS, so a route that grows past its budget reddens
6
+ * a build instead of adding a line to a log nobody opens.
7
+ */
8
+ import { DEFAULT_ROUTE_CPU_BUDGET_MS } from './budget';
9
+ const fmt = (n) => (Number.isFinite(n) ? n.toFixed(2) : 'n/a');
10
+ /** Compute violations without throwing. {@link assertCpuBudgets} is this plus a throw. */
11
+ export function checkCpuBudgets(report, opts = {}) {
12
+ const minSamples = opts.minSamples ?? 20;
13
+ const violations = [];
14
+ if (!report.available && !opts.allowUnmeasurable) {
15
+ return [
16
+ {
17
+ kind: 'unmeasurable',
18
+ key: '*',
19
+ method: '*',
20
+ route: '*',
21
+ p99: Number.NaN,
22
+ budgetMs: null,
23
+ samples: 0,
24
+ fails: true,
25
+ message: `CPU budget: nothing could be measured โ€” clock source '${report.source}' is ` +
26
+ 'unavailable. Pass a runtime CPU reader, or set allowUnmeasurable to accept it.',
27
+ },
28
+ ];
29
+ }
30
+ for (const r of report.routes) {
31
+ const base = {
32
+ key: r.key,
33
+ method: r.method,
34
+ route: r.route,
35
+ p99: r.p99,
36
+ budgetMs: r.budgetMs,
37
+ samples: r.samples,
38
+ };
39
+ if (r.budgetMs === null) {
40
+ if (r.noticeAboveMs !== undefined && r.p99 > r.noticeAboveMs) {
41
+ violations.push({
42
+ ...base,
43
+ kind: 'exempt-notice',
44
+ fails: false,
45
+ message: `${r.key} is exempt (${r.exemptReason}) and its p99 is ${fmt(r.p99)} CPU-ms, ` +
46
+ `above its own notice threshold of ${fmt(r.noticeAboveMs)}.`,
47
+ });
48
+ }
49
+ continue;
50
+ }
51
+ if (opts.requireDeclared && !r.declared) {
52
+ violations.push({
53
+ ...base,
54
+ kind: 'undeclared',
55
+ fails: true,
56
+ message: `${r.key} declares no CPU budget and fell through to the default of ` +
57
+ `${fmt(r.budgetMs)} CPU-ms. requireDeclared is on: give it a number, or an ` +
58
+ 'exemption naming the reason.',
59
+ });
60
+ continue;
61
+ }
62
+ if (r.samples < minSamples) {
63
+ violations.push({
64
+ ...base,
65
+ kind: 'insufficient-samples',
66
+ fails: true,
67
+ message: `${r.key} has ${r.samples} sample(s); a p99 needs at least ${minSamples}. ` +
68
+ 'Drive the route more times, or lower minSamples deliberately.',
69
+ });
70
+ continue;
71
+ }
72
+ if (r.p99 > r.budgetMs) {
73
+ violations.push({
74
+ ...base,
75
+ kind: 'over-budget',
76
+ fails: true,
77
+ message: `${r.key} spent ${fmt(r.p99)} CPU-ms at p99, over its budget of ` +
78
+ `${fmt(r.budgetMs)} CPU-ms (${(r.p99 / r.budgetMs).toFixed(1)}ร—). ` +
79
+ `mean ${fmt(r.mean)} ยท p50 ${fmt(r.p50)} ยท p95 ${fmt(r.p95)} ยท max ${fmt(r.max)} ` +
80
+ `over ${r.samples} samples.`,
81
+ });
82
+ }
83
+ }
84
+ return violations;
85
+ }
86
+ /** A human-readable table of every route measured, worst p99 first. */
87
+ export function formatCpuBudgetReport(report) {
88
+ const head = `CPU per route โ€” source '${report.source}'` +
89
+ (report.proxy
90
+ ? ' ๐Ÿ”ด PROXY: these are local process CPU deltas, NOT Worker CPU-ms.'
91
+ : ' (runtime-reported Worker CPU-ms).');
92
+ const rows = report.routes.map((r) => {
93
+ const budget = r.budgetMs === null ? `exempt: ${r.exemptReason}` : `${fmt(r.budgetMs)}ms budget`;
94
+ const flag = r.budgetMs !== null && r.p99 > r.budgetMs ? ' โ† OVER' : '';
95
+ return (` ${r.key.padEnd(44)} p50 ${fmt(r.p50).padStart(8)} p95 ${fmt(r.p95).padStart(8)} ` +
96
+ `p99 ${fmt(r.p99).padStart(8)} max ${fmt(r.max).padStart(8)} ` +
97
+ `n=${String(r.samples).padStart(5)} ${budget}${flag}`);
98
+ });
99
+ return [head, ...rows].join('\n');
100
+ }
101
+ /**
102
+ * Throw if any route exceeds its budget.
103
+ *
104
+ * ๐Ÿ”ด The message names the ROUTE and its NUMBER, because a failure that says only
105
+ * "a route is over budget" sends the reader back to the report to find out which.
106
+ */
107
+ export function assertCpuBudgets(report, opts = {}) {
108
+ const violations = checkCpuBudgets(report, opts);
109
+ const failing = violations.filter((v) => v.fails);
110
+ if (failing.length > 0) {
111
+ const lines = failing.map((v) => ` ยท [${v.kind}] ${v.message}`);
112
+ throw new Error(`${failing.length} route(s) over CPU budget ` +
113
+ `(default ${DEFAULT_ROUTE_CPU_BUDGET_MS} CPU-ms):\n${lines.join('\n')}\n\n` +
114
+ formatCpuBudgetReport(report));
115
+ }
116
+ return violations;
117
+ }
@@ -0,0 +1,130 @@
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
+ /** CPU-milliseconds included in the Workers Paid plan each month. */
31
+ export declare const WORKERS_PAID_INCLUDED_CPU_MS = 30000000;
32
+ /** Requests included in the Workers Paid plan each month. */
33
+ export declare const WORKERS_PAID_INCLUDED_REQUESTS = 10000000;
34
+ /** USD per additional million CPU-ms, once the included allowance is spent. */
35
+ export declare const WORKERS_PAID_USD_PER_MILLION_CPU_MS = 0.02;
36
+ /** USD per additional million requests, once the included allowance is spent. */
37
+ export declare const WORKERS_PAID_USD_PER_MILLION_REQUESTS = 0.3;
38
+ /**
39
+ * The owner's own projection, 2026-09-15. ๐Ÿ”ด A PROJECTION, not a measurement โ€” the
40
+ * whole point of {@link deriveCpuBudgetMs} is to replace it once an app is serving.
41
+ */
42
+ export declare const OWNER_PROJECTED_REQUESTS_PER_DAY = 100000;
43
+ /** 365.25 / 12 โ€” the average calendar month, since billing is monthly. */
44
+ export declare const DAYS_PER_MONTH = 30.4375;
45
+ /**
46
+ * Derive the average CPU-ms a single request may spend before the fleet exceeds its
47
+ * included CPU allowance.
48
+ *
49
+ * ๐Ÿ”ด This is an AVERAGE-per-request allowance, not a per-invocation limit. A route may
50
+ * exceed it and cost nothing, provided cheap routes carry the mean. What it is for is
51
+ * ranking: a route whose p99 sits above the average budget is a route that cannot be
52
+ * allowed to become the common case.
53
+ */
54
+ export declare function deriveCpuBudgetMs(opts?: {
55
+ /** Included CPU-ms per month. Default: the Workers Paid allowance. */
56
+ includedCpuMs?: number;
57
+ /** Measured (or projected) requests per month. */
58
+ requestsPerMonth?: number;
59
+ /** Measured (or projected) requests per day โ€” converted with {@link DAYS_PER_MONTH}. */
60
+ requestsPerDay?: number;
61
+ }): number;
62
+ /**
63
+ * What a month costs in overage once the included allowances are spent. Used by the
64
+ * report so a violation carries a PRICE and not only a number โ€” the honest framing is
65
+ * that going 3ร— over the CPU budget at this traffic is about $1.20/month, which is a
66
+ * reason to care about the outlier route and not a reason to panic.
67
+ */
68
+ export declare function monthlyOverageUsd(opts: {
69
+ requestsPerMonth: number;
70
+ meanCpuMsPerRequest: number;
71
+ }): {
72
+ cpuUsd: number;
73
+ requestsUsd: number;
74
+ totalUsd: number;
75
+ };
76
+ /**
77
+ * 9.9 CPU-ms โ€” `30,000,000 รท (100,000 ร— 30.4375)` = 9.856, to one decimal.
78
+ *
79
+ * Generous for a JSON route, tight for anything that renders, parses or derives a key.
80
+ * ๐Ÿ”ด Re-derive with {@link deriveCpuBudgetMs} once an app has real traffic.
81
+ */
82
+ export declare const DEFAULT_ROUTE_CPU_BUDGET_MS: number;
83
+ /** A route that is measured against a number. */
84
+ export interface CpuBudgetLimit {
85
+ cpuMs: number;
86
+ }
87
+ /**
88
+ * A route that is deliberately NOT measured against the default.
89
+ *
90
+ * ๐Ÿ”ด `reason` is required by the type AND checked at runtime. An exemption whose
91
+ * justification is blank is the paragraph this module exists to replace โ€” a silent
92
+ * opt-out is indistinguishable from a route nobody looked at.
93
+ */
94
+ export interface CpuBudgetExemption {
95
+ exempt: true;
96
+ reason: string;
97
+ /**
98
+ * The exemption still records, and a p99 above this is reported as a NOTICE rather
99
+ * than a violation. Optional โ€” an exemption with no ceiling is never surprising.
100
+ */
101
+ noticeAboveMs?: number;
102
+ }
103
+ export type RouteBudget = CpuBudgetLimit | CpuBudgetExemption | number;
104
+ export interface CpuBudgetConfig {
105
+ /** Applied to any route without its own entry. Default: {@link DEFAULT_ROUTE_CPU_BUDGET_MS}. */
106
+ defaultCpuMs?: number;
107
+ /**
108
+ * Per-route budgets, keyed either `'GET /api/notes/:id'` (method-specific, wins) or
109
+ * `'/api/notes/:id'` (any method). Keys are matched against the same normalized route
110
+ * label the metrics tier uses, so `:params` are already collapsed.
111
+ */
112
+ routes?: Record<string, RouteBudget>;
113
+ }
114
+ export declare function isExemption(b: RouteBudget): b is CpuBudgetExemption;
115
+ /** The resolved budget for one route label. */
116
+ export interface ResolvedBudget {
117
+ /** The ceiling in CPU-ms, or `null` when the route is exempt. */
118
+ cpuMs: number | null;
119
+ exemptReason?: string;
120
+ noticeAboveMs?: number;
121
+ /** True when no explicit entry matched and the default was applied. */
122
+ declared: boolean;
123
+ }
124
+ /**
125
+ * ๐Ÿ”ด Validate the whole config up front rather than at the moment a route is hit. A
126
+ * blank exemption reason on a route nobody exercised in the bench would otherwise ship.
127
+ */
128
+ export declare function validateCpuBudgetConfig(config: CpuBudgetConfig): void;
129
+ /** Resolve `method` + `route` against the config. Method-specific keys win. */
130
+ export declare function resolveBudget(config: CpuBudgetConfig, method: string, route: string): ResolvedBudget;
@@ -0,0 +1,131 @@
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
+ /** CPU-milliseconds included in the Workers Paid plan each month. */
31
+ export const WORKERS_PAID_INCLUDED_CPU_MS = 30_000_000;
32
+ /** Requests included in the Workers Paid plan each month. */
33
+ export const WORKERS_PAID_INCLUDED_REQUESTS = 10_000_000;
34
+ /** USD per additional million CPU-ms, once the included allowance is spent. */
35
+ export const WORKERS_PAID_USD_PER_MILLION_CPU_MS = 0.02;
36
+ /** USD per additional million requests, once the included allowance is spent. */
37
+ export const WORKERS_PAID_USD_PER_MILLION_REQUESTS = 0.3;
38
+ /**
39
+ * The owner's own projection, 2026-09-15. ๐Ÿ”ด A PROJECTION, not a measurement โ€” the
40
+ * whole point of {@link deriveCpuBudgetMs} is to replace it once an app is serving.
41
+ */
42
+ export const OWNER_PROJECTED_REQUESTS_PER_DAY = 100_000;
43
+ /** 365.25 / 12 โ€” the average calendar month, since billing is monthly. */
44
+ export const DAYS_PER_MONTH = 30.4375;
45
+ /**
46
+ * Derive the average CPU-ms a single request may spend before the fleet exceeds its
47
+ * included CPU allowance.
48
+ *
49
+ * ๐Ÿ”ด This is an AVERAGE-per-request allowance, not a per-invocation limit. A route may
50
+ * exceed it and cost nothing, provided cheap routes carry the mean. What it is for is
51
+ * ranking: a route whose p99 sits above the average budget is a route that cannot be
52
+ * allowed to become the common case.
53
+ */
54
+ export function deriveCpuBudgetMs(opts = {}) {
55
+ const includedCpuMs = opts.includedCpuMs ?? WORKERS_PAID_INCLUDED_CPU_MS;
56
+ const requestsPerMonth = opts.requestsPerMonth ??
57
+ (opts.requestsPerDay ?? OWNER_PROJECTED_REQUESTS_PER_DAY) * DAYS_PER_MONTH;
58
+ if (!(requestsPerMonth > 0)) {
59
+ throw new Error('deriveCpuBudgetMs: requestsPerMonth must be > 0');
60
+ }
61
+ return includedCpuMs / requestsPerMonth;
62
+ }
63
+ /**
64
+ * What a month costs in overage once the included allowances are spent. Used by the
65
+ * report so a violation carries a PRICE and not only a number โ€” the honest framing is
66
+ * that going 3ร— over the CPU budget at this traffic is about $1.20/month, which is a
67
+ * reason to care about the outlier route and not a reason to panic.
68
+ */
69
+ export function monthlyOverageUsd(opts) {
70
+ const cpuMs = opts.requestsPerMonth * opts.meanCpuMsPerRequest;
71
+ const cpuOver = Math.max(0, cpuMs - WORKERS_PAID_INCLUDED_CPU_MS);
72
+ const reqOver = Math.max(0, opts.requestsPerMonth - WORKERS_PAID_INCLUDED_REQUESTS);
73
+ const cpuUsd = (cpuOver / 1_000_000) * WORKERS_PAID_USD_PER_MILLION_CPU_MS;
74
+ const requestsUsd = (reqOver / 1_000_000) * WORKERS_PAID_USD_PER_MILLION_REQUESTS;
75
+ return { cpuUsd, requestsUsd, totalUsd: cpuUsd + requestsUsd };
76
+ }
77
+ /**
78
+ * 9.9 CPU-ms โ€” `30,000,000 รท (100,000 ร— 30.4375)` = 9.856, to one decimal.
79
+ *
80
+ * Generous for a JSON route, tight for anything that renders, parses or derives a key.
81
+ * ๐Ÿ”ด Re-derive with {@link deriveCpuBudgetMs} once an app has real traffic.
82
+ */
83
+ export const DEFAULT_ROUTE_CPU_BUDGET_MS = Math.round(deriveCpuBudgetMs() * 10) / 10;
84
+ export function isExemption(b) {
85
+ return typeof b === 'object' && 'exempt' in b && b.exempt === true;
86
+ }
87
+ /**
88
+ * ๐Ÿ”ด Validate the whole config up front rather than at the moment a route is hit. A
89
+ * blank exemption reason on a route nobody exercised in the bench would otherwise ship.
90
+ */
91
+ export function validateCpuBudgetConfig(config) {
92
+ if (config.defaultCpuMs !== undefined && !(config.defaultCpuMs > 0)) {
93
+ throw new Error(`cpuBudget: defaultCpuMs must be > 0, got ${config.defaultCpuMs}`);
94
+ }
95
+ for (const [key, budget] of Object.entries(config.routes ?? {})) {
96
+ if (isExemption(budget)) {
97
+ if (typeof budget.reason !== 'string' || budget.reason.trim() === '') {
98
+ throw new Error(`cpuBudget: route '${key}' is exempt with no reason. An opt-out must name why โ€” ` +
99
+ 'that is the difference between a decision and an oversight.');
100
+ }
101
+ if (budget.noticeAboveMs !== undefined && !(budget.noticeAboveMs > 0)) {
102
+ throw new Error(`cpuBudget: route '${key}' has noticeAboveMs โ‰ค 0`);
103
+ }
104
+ continue;
105
+ }
106
+ const cpuMs = typeof budget === 'number' ? budget : budget.cpuMs;
107
+ if (!(cpuMs > 0)) {
108
+ throw new Error(`cpuBudget: route '${key}' has a budget of ${cpuMs}; it must be > 0`);
109
+ }
110
+ }
111
+ }
112
+ /** Resolve `method` + `route` against the config. Method-specific keys win. */
113
+ export function resolveBudget(config, method, route) {
114
+ const routes = config.routes ?? {};
115
+ const explicit = routes[`${method} ${route}`] ?? routes[route];
116
+ if (explicit === undefined) {
117
+ return { cpuMs: config.defaultCpuMs ?? DEFAULT_ROUTE_CPU_BUDGET_MS, declared: false };
118
+ }
119
+ if (isExemption(explicit)) {
120
+ return {
121
+ cpuMs: null,
122
+ exemptReason: explicit.reason,
123
+ noticeAboveMs: explicit.noticeAboveMs,
124
+ declared: true,
125
+ };
126
+ }
127
+ return {
128
+ cpuMs: typeof explicit === 'number' ? explicit : explicit.cpuMs,
129
+ declared: true,
130
+ };
131
+ }
@@ -0,0 +1,45 @@
1
+ import type { MiddlewareHandler } from 'hono';
2
+ import type { CursedbeltEnv } from '../context';
3
+ import type { CpuBudgetConfig } from './budget';
4
+ import { type CpuClock } from './cpuClock';
5
+ import { type CpuRecorder } from './recorder';
6
+ /**
7
+ * Per-route CPU attribution, mounted like any other cursedbelt middleware.
8
+ *
9
+ * ```ts
10
+ * const cpu = createCpuRecorder({ clock: processCpuClock(), config: BUDGETS })
11
+ * app.use('*', cpuBudget({ recorder: cpu }))
12
+ * ```
13
+ *
14
+ * ๐Ÿ”ด **It records CPU, never wall clock.** `requestLogger` beside it records duration,
15
+ * and duration is the quantity Cloudflare explicitly does not bill. The two numbers
16
+ * diverge by exactly the amount a handler spends awaiting a subrequest, which on this
17
+ * fleet is most of it โ€” so a route can be slow and free, or fast and expensive, and only
18
+ * this middleware can tell you which.
19
+ *
20
+ * Like `requestLogger`, it records even when a downstream handler THROWS: the CPU was
21
+ * spent either way, and an endpoint that is expensive only on its error path is exactly
22
+ * the sort of thing a budget is supposed to catch. The error is re-thrown unchanged.
23
+ */
24
+ export interface CpuBudgetOpts {
25
+ /** The recorder to write through. Provide one so the gate can read its report. */
26
+ recorder?: CpuRecorder;
27
+ /** Built into a recorder when `recorder` is omitted. */
28
+ config?: CpuBudgetConfig;
29
+ /** Defaults to {@link processCpuClock} โ€” the local PROXY. */
30
+ clock?: CpuClock;
31
+ /**
32
+ * Called after each request with the sample. For shipping the number somewhere (a
33
+ * telemetry sink, a log line) without coupling this module to a destination.
34
+ */
35
+ onSample?: (sample: {
36
+ method: string;
37
+ route: string;
38
+ cpuMs: number;
39
+ proxy: boolean;
40
+ status: number;
41
+ }) => void;
42
+ }
43
+ export declare function cpuBudget(opts?: CpuBudgetOpts): MiddlewareHandler<CursedbeltEnv> & {
44
+ recorder: CpuRecorder;
45
+ };
@@ -0,0 +1,34 @@
1
+ import { normalizeRoute } from '../metrics/normalizeRoute';
2
+ import { processCpuClock } from './cpuClock';
3
+ import { createCpuRecorder } from './recorder';
4
+ export function cpuBudget(opts = {}) {
5
+ const recorder = opts.recorder ??
6
+ createCpuRecorder({ clock: opts.clock ?? processCpuClock(), config: opts.config });
7
+ const handler = async (c, next) => {
8
+ const end = recorder.clock.start();
9
+ let thrown;
10
+ let didThrow = false;
11
+ try {
12
+ await next();
13
+ }
14
+ catch (err) {
15
+ thrown = err;
16
+ didThrow = true;
17
+ }
18
+ const cpuMs = end();
19
+ // `normalizeRoute` must run AFTER `next()` โ€” before it, Hono has not matched a route
20
+ // pattern yet and every request would aggregate under its raw path.
21
+ const { method, route } = normalizeRoute(c);
22
+ recorder.record(method, route, cpuMs);
23
+ opts.onSample?.({
24
+ method,
25
+ route,
26
+ cpuMs,
27
+ proxy: recorder.clock.proxy,
28
+ status: didThrow ? 500 : c.res.status,
29
+ });
30
+ if (didThrow)
31
+ throw thrown;
32
+ };
33
+ return Object.assign(handler, { recorder });
34
+ }
@@ -0,0 +1,65 @@
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
+ /** A started measurement. Call it to end the measurement and get CPU-ms. */
21
+ export type CpuSpan = () => number;
22
+ export interface CpuClock {
23
+ /** Human-readable provenance, e.g. `'process.cpuUsage'`. Printed in every report. */
24
+ readonly source: string;
25
+ /**
26
+ * ๐Ÿ”ด True when this number is a PROXY for Worker CPU-ms rather than a reading of it.
27
+ * The report prints it and {@link CpuBudgetReport} carries it downstream.
28
+ */
29
+ readonly proxy: boolean;
30
+ /** True when the clock can actually measure. A false one records nothing. */
31
+ readonly available: boolean;
32
+ start(): CpuSpan;
33
+ }
34
+ /**
35
+ * `process.cpuUsage()` deltas โ€” user + system, converted from ยตs to ms.
36
+ *
37
+ * ๐Ÿ”ด **The delta is PROCESS-WIDE, not per-request.** If two requests are in flight the
38
+ * reading for each includes the other's CPU, and any background work (a metrics flush, a
39
+ * GC pause attributed to the interval) lands on whichever handler happened to be open.
40
+ * That is why {@link runCpuBench} drives its cases SERIALLY โ€” a serial driver is what
41
+ * makes this proxy sound enough to gate on. Mounted in production it is advisory: useful
42
+ * for ranking routes, not for a billing claim.
43
+ */
44
+ export declare function processCpuClock(): CpuClock;
45
+ /**
46
+ * A clock over a CPU-ms reader the runtime supplies. `proxy: false` โ€” this is the real
47
+ * quantity Cloudflare bills, so a report built on it may be quoted as one.
48
+ *
49
+ * @param readCpuMs Returns monotonically non-decreasing CPU-ms for the current invocation.
50
+ */
51
+ export declare function workerCpuClock(readCpuMs: () => number, source?: string): CpuClock;
52
+ /**
53
+ * A clock that cannot measure. Recording through it is a no-op, and the report says so
54
+ * rather than reporting a confident zero.
55
+ */
56
+ export declare function unavailableCpuClock(source?: string): CpuClock;
57
+ /**
58
+ * A deterministic clock for tests: each `start()` consumes the next value in `values`,
59
+ * repeating the last one once exhausted. Lets the assertion be proven in both directions
60
+ * without burning real CPU, which is the difference between a test that is fast and
61
+ * reliable and one that is neither.
62
+ */
63
+ export declare function fixedCpuClock(values: number[], source?: string): CpuClock;
64
+ /** `workerCpuClock` when the platform supplied a reader, else the local proxy. */
65
+ export declare function resolveCpuClock(readCpuMs?: (() => number) | null): CpuClock;