@coywolf/emdash 0.0.0-stage → 0.35.13

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 (261) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -2
  3. package/package.json +70 -5
  4. package/src/admin/admin-css.ts +57 -0
  5. package/src/admin/ai.tsx +915 -0
  6. package/src/admin/backups.tsx +613 -0
  7. package/src/admin/breadcrumbs.tsx +542 -0
  8. package/src/admin/codeBlocks.tsx +204 -0
  9. package/src/admin/customBlocks.tsx +321 -0
  10. package/src/admin/dirty.ts +20 -0
  11. package/src/admin/discovery.tsx +463 -0
  12. package/src/admin/download.ts +28 -0
  13. package/src/admin/feature-switches.tsx +124 -0
  14. package/src/admin/features.tsx +142 -0
  15. package/src/admin/files.tsx +791 -0
  16. package/src/admin/formUploads.tsx +445 -0
  17. package/src/admin/guides.tsx +18 -0
  18. package/src/admin/headings.tsx +262 -0
  19. package/src/admin/images.tsx +825 -0
  20. package/src/admin/index.tsx +62 -0
  21. package/src/admin/links.tsx +980 -0
  22. package/src/admin/performance.tsx +330 -0
  23. package/src/admin/preview.tsx +63 -0
  24. package/src/admin/redirects-removed.tsx +266 -0
  25. package/src/admin/redirects.tsx +470 -0
  26. package/src/admin/reviews.tsx +232 -0
  27. package/src/admin/robots-bots.tsx +647 -0
  28. package/src/admin/robots-shared.tsx +109 -0
  29. package/src/admin/robots-wizard.tsx +748 -0
  30. package/src/admin/robots.tsx +911 -0
  31. package/src/admin/save-bar.tsx +134 -0
  32. package/src/admin/schema.tsx +1187 -0
  33. package/src/admin/search.tsx +355 -0
  34. package/src/admin/settings-ui.tsx +87 -0
  35. package/src/admin/trash-prompt-core.ts +135 -0
  36. package/src/admin/trash-prompt.tsx +156 -0
  37. package/src/admin/usage.tsx +301 -0
  38. package/src/admin/videos-display.tsx +248 -0
  39. package/src/admin/videos.tsx +1202 -0
  40. package/src/admin/wpImport.tsx +1516 -0
  41. package/src/ai/entities.ts +59 -0
  42. package/src/ai/export.ts +61 -0
  43. package/src/ai/logic.test.ts +187 -0
  44. package/src/ai/logic.ts +606 -0
  45. package/src/ai/pack.ts +121 -0
  46. package/src/ai/prompts.ts +87 -0
  47. package/src/ai/providers.ts +276 -0
  48. package/src/ai/queue.test.ts +97 -0
  49. package/src/ai/routes.ts +359 -0
  50. package/src/ai/store.ts +301 -0
  51. package/src/ai/wikidata.test.ts +184 -0
  52. package/src/ai/wikidata.ts +87 -0
  53. package/src/ai/worker.ts +530 -0
  54. package/src/astro/astro-modules.d.ts +6 -0
  55. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  56. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  57. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  58. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  59. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  60. package/src/astro/customBlocks/Details.astro +8 -0
  61. package/src/astro/customBlocks/Disclosure.astro +8 -0
  62. package/src/astro/customBlocks/Note.astro +8 -0
  63. package/src/astro/customBlocks/Podcast.astro +8 -0
  64. package/src/astro/customBlocks/Quote.astro +8 -0
  65. package/src/astro/customBlocks/Testimonial.astro +8 -0
  66. package/src/astro/files/FileCard.astro +103 -0
  67. package/src/astro/headings/HeadingBlock.astro +129 -0
  68. package/src/astro/headings/TableOfContents.astro +45 -0
  69. package/src/astro/headings/request.ts +19 -0
  70. package/src/astro/images/ResponsiveImage.astro +60 -0
  71. package/src/astro/index.ts +84 -0
  72. package/src/astro/reviews/CoywolfReview.astro +27 -0
  73. package/src/astro/reviews/Review.astro +49 -0
  74. package/src/astro/search/SearchBox.astro +61 -0
  75. package/src/astro/search/SearchBoxForm.astro +509 -0
  76. package/src/astro/tables/CaptionedTable.astro +72 -0
  77. package/src/astro/videos/CoywolfVideo.astro +448 -0
  78. package/src/backups/download.ts +112 -0
  79. package/src/backups/dump.test.mjs +287 -0
  80. package/src/backups/dump.ts +288 -0
  81. package/src/backups/module.ts +276 -0
  82. package/src/backups/pack.ts +33 -0
  83. package/src/backups/restore.test.mjs +103 -0
  84. package/src/backups/restore.ts +209 -0
  85. package/src/backups/store.ts +409 -0
  86. package/src/breadcrumbs/pack.ts +113 -0
  87. package/src/breadcrumbs/settings.ts +102 -0
  88. package/src/breadcrumbs/stamp.ts +40 -0
  89. package/src/breadcrumbs/trail.ts +123 -0
  90. package/src/codeBlocks/contrast.ts +179 -0
  91. package/src/codeBlocks/hljs.d.ts +5 -0
  92. package/src/codeBlocks/pack.ts +110 -0
  93. package/src/codeBlocks/render.test.ts +152 -0
  94. package/src/codeBlocks/render.ts +304 -0
  95. package/src/codeBlocks/settings.ts +18 -0
  96. package/src/codeBlocks/themes.generated.ts +304 -0
  97. package/src/codeBlocks/themes.ts +114 -0
  98. package/src/core/compose.ts +244 -0
  99. package/src/core/content-url.ts +888 -0
  100. package/src/core/csv.ts +119 -0
  101. package/src/core/d1-batch.ts +65 -0
  102. package/src/core/entry-docs.ts +175 -0
  103. package/src/core/features-module.ts +48 -0
  104. package/src/core/features.ts +392 -0
  105. package/src/core/generation.ts +88 -0
  106. package/src/core/guide-content.ts +166 -0
  107. package/src/core/module.ts +104 -0
  108. package/src/core/read-failures.ts +57 -0
  109. package/src/core/request-memo.ts +71 -0
  110. package/src/core/secrets.ts +83 -0
  111. package/src/core/settings-enhance.ts +74 -0
  112. package/src/core/site.ts +36 -0
  113. package/src/core/storage.ts +27 -0
  114. package/src/customBlocks/pack.ts +260 -0
  115. package/src/customBlocks/render.ts +424 -0
  116. package/src/customBlocks/rich.ts +313 -0
  117. package/src/customBlocks/settings.ts +46 -0
  118. package/src/discovery/build.ts +252 -0
  119. package/src/discovery/feed-build.ts +217 -0
  120. package/src/discovery/feed.ts +172 -0
  121. package/src/discovery/indexnow.ts +114 -0
  122. package/src/discovery/llms.ts +79 -0
  123. package/src/discovery/markdown.ts +294 -0
  124. package/src/discovery/memory-cache.ts +97 -0
  125. package/src/discovery/middleware.ts +186 -0
  126. package/src/discovery/module.ts +353 -0
  127. package/src/discovery/news.ts +99 -0
  128. package/src/discovery/pack.ts +58 -0
  129. package/src/discovery/settings.ts +91 -0
  130. package/src/discovery/urls.ts +35 -0
  131. package/src/env.d.ts +10 -0
  132. package/src/files/card.ts +231 -0
  133. package/src/files/counts.test.ts +114 -0
  134. package/src/files/counts.ts +60 -0
  135. package/src/files/export.ts +44 -0
  136. package/src/files/format.test.ts +93 -0
  137. package/src/files/format.ts +163 -0
  138. package/src/files/module.ts +596 -0
  139. package/src/files/pack.ts +92 -0
  140. package/src/files/r2-rules.test.ts +30 -0
  141. package/src/files/r2-rules.ts +41 -0
  142. package/src/files/range.ts +50 -0
  143. package/src/files/s3.ts +114 -0
  144. package/src/files/serve.ts +141 -0
  145. package/src/files/sigv4.test.ts +64 -0
  146. package/src/files/sigv4.ts +160 -0
  147. package/src/files/site.ts +137 -0
  148. package/src/files/walker.ts +58 -0
  149. package/src/formUploads/lib.ts +266 -0
  150. package/src/formUploads/pack.ts +55 -0
  151. package/src/formUploads/wrap.ts +592 -0
  152. package/src/forms.ts +16 -0
  153. package/src/headings/headings.test.ts +159 -0
  154. package/src/headings/pack.ts +154 -0
  155. package/src/headings/render.ts +139 -0
  156. package/src/headings/settings.ts +117 -0
  157. package/src/headings/slug.ts +68 -0
  158. package/src/headings/stamp.ts +142 -0
  159. package/src/headings/test-hooks.mjs +14 -0
  160. package/src/headings/toc.ts +48 -0
  161. package/src/images/cloudflare.ts +329 -0
  162. package/src/images/lib.ts +221 -0
  163. package/src/images/module.ts +372 -0
  164. package/src/images/pack.ts +804 -0
  165. package/src/images/pending.ts +37 -0
  166. package/src/images/settings.ts +48 -0
  167. package/src/images/setup.ts +192 -0
  168. package/src/images/variants-estimate.ts +162 -0
  169. package/src/images/variants-store.ts +475 -0
  170. package/src/images/variants.ts +311 -0
  171. package/src/index.ts +143 -0
  172. package/src/links/check.ts +101 -0
  173. package/src/links/classify.ts +270 -0
  174. package/src/links/export.ts +59 -0
  175. package/src/links/module.ts +652 -0
  176. package/src/links/pack.ts +43 -0
  177. package/src/links/pt.ts +325 -0
  178. package/src/links/store.ts +704 -0
  179. package/src/middleware.ts +287 -0
  180. package/src/modules.ts +53 -0
  181. package/src/options.ts +66 -0
  182. package/src/pageCache/lib.test.mjs +224 -0
  183. package/src/pageCache/lib.ts +274 -0
  184. package/src/pageCache/pack.ts +240 -0
  185. package/src/pageCache/usage-core.ts +152 -0
  186. package/src/pageCache/usage.ts +262 -0
  187. package/src/pageCache/warm.test.mjs +670 -0
  188. package/src/pageCache/warm.ts +722 -0
  189. package/src/redirects/middleware.ts +169 -0
  190. package/src/redirects/module.ts +124 -0
  191. package/src/redirects/pack.ts +89 -0
  192. package/src/redirects/removed-core.ts +151 -0
  193. package/src/redirects/removed.ts +293 -0
  194. package/src/redirects/rules.ts +355 -0
  195. package/src/redirects/slug-changes.ts +207 -0
  196. package/src/redirects/slug-core.test.ts +50 -0
  197. package/src/redirects/slug-core.ts +49 -0
  198. package/src/redirects/transfer.ts +92 -0
  199. package/src/reviews/lib.ts +624 -0
  200. package/src/reviews/pack.ts +135 -0
  201. package/src/reviews/schema.ts +128 -0
  202. package/src/reviews/settings.ts +26 -0
  203. package/src/robots/bots.ts +321 -0
  204. package/src/robots/data/bots.json +696 -0
  205. package/src/robots/data/verified.json +582 -0
  206. package/src/robots/directory.ts +26 -0
  207. package/src/robots/explain.ts +191 -0
  208. package/src/robots/guided.ts +300 -0
  209. package/src/robots/importer.ts +263 -0
  210. package/src/robots/middleware.ts +151 -0
  211. package/src/robots/module.ts +446 -0
  212. package/src/robots/pack.ts +69 -0
  213. package/src/robots/radar.ts +129 -0
  214. package/src/robots/rep.test.ts +335 -0
  215. package/src/robots/rep.ts +362 -0
  216. package/src/robots/rules.test.ts +251 -0
  217. package/src/robots/rules.ts +883 -0
  218. package/src/robots/transfer.ts +45 -0
  219. package/src/robots/validate.ts +524 -0
  220. package/src/schema/catalog.ts +146 -0
  221. package/src/schema/graph.test.mjs +522 -0
  222. package/src/schema/graph.ts +791 -0
  223. package/src/schema/module.ts +1138 -0
  224. package/src/schema/og-image.ts +100 -0
  225. package/src/schema/pack.ts +58 -0
  226. package/src/search/engine.ts +578 -0
  227. package/src/search/fallback.ts +80 -0
  228. package/src/search/live-cache.ts +116 -0
  229. package/src/search/live-client.ts +770 -0
  230. package/src/search/live-serve.ts +282 -0
  231. package/src/search/module.ts +261 -0
  232. package/src/search/pack.ts +57 -0
  233. package/src/search/query.ts +132 -0
  234. package/src/search/ratelimit-core.ts +118 -0
  235. package/src/search/ratelimit.ts +79 -0
  236. package/src/search/snippet.ts +135 -0
  237. package/src/search/title-index.ts +139 -0
  238. package/src/search/title-match.ts +192 -0
  239. package/src/shared.ts +71 -0
  240. package/src/version.ts +2 -0
  241. package/src/videos/date.ts +40 -0
  242. package/src/videos/engagement.ts +75 -0
  243. package/src/videos/lib.ts +679 -0
  244. package/src/videos/module.ts +1160 -0
  245. package/src/videos/pack.ts +187 -0
  246. package/src/videos/pause.ts +42 -0
  247. package/src/videos/poster.ts +324 -0
  248. package/src/videos/render.ts +336 -0
  249. package/src/videos/store.ts +551 -0
  250. package/src/videos/stream.ts +134 -0
  251. package/src/wpImport/convert.ts +702 -0
  252. package/src/wpImport/guests.ts +329 -0
  253. package/src/wpImport/gutenberg.ts +157 -0
  254. package/src/wpImport/markers.ts +66 -0
  255. package/src/wpImport/pack.ts +451 -0
  256. package/src/wpImport/parents.ts +202 -0
  257. package/src/wpImport/prepare.ts +592 -0
  258. package/src/wpImport/redirects.ts +358 -0
  259. package/src/wpImport/sources.ts +67 -0
  260. package/src/wpImport/stream.ts +160 -0
  261. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Cloudflare usage for the Performance page and the dashboard widget: this
3
+ * site's D1 and Workers usage, the account's against its plan, the estimated
4
+ * bill, and an estimate of the cache warmer's share. Read with a read-only API
5
+ * token (the USAGE_API_TOKEN Worker secret, or saved on the plugin's Settings
6
+ * page) and kept for an hour in plugin KV, since the analytics API is rate
7
+ * limited.
8
+ *
9
+ * Nothing is configured per site: the site's Worker is the one whose current
10
+ * deployment runs this version (CF_VERSION_METADATA), and its D1 database is
11
+ * the one bound as DB in that Worker's settings.
12
+ */
13
+ import type { PluginContext } from "emdash";
14
+
15
+ import { CLOUDFLARE_API_HOST } from "../images/cloudflare.js";
16
+ import { IMAGES_SETTINGS } from "../images/settings.js";
17
+ import { secret, workerEnv } from "../shared.js";
18
+ import { versionId } from "./lib.js";
19
+ import {
20
+ type Bill,
21
+ type Plan,
22
+ type UsageTotals,
23
+ type WarmingEstimate,
24
+ estimateBill,
25
+ estimateWarming,
26
+ foldTotals,
27
+ planFromDailyPeaks,
28
+ planFromEntitlements,
29
+ } from "./usage-core.js";
30
+ import { WARMER_AGENT } from "./warm.js";
31
+
32
+ const API = `https://${CLOUDFLARE_API_HOST}/client/v4`;
33
+ export const USAGE_TOKEN_SETTING = "usageApiToken";
34
+ const CACHE_KEY = "cache:usage";
35
+ const TARGET_KEY = "state:usage:target";
36
+ /** After a failed read, wait this long before asking Cloudflare again (rate limits). */
37
+ const FAILED_KEY = "state:usage:failed";
38
+ const RETRY_AFTER_MS = 5 * 60_000;
39
+ /** How long a reading is kept; Refresh reads again, at most once a minute. */
40
+ const TTL_MS = 60 * 60_000;
41
+ const REFRESH_MIN_MS = 60_000;
42
+ const DAY_MS = 86_400_000;
43
+ const WARM_DAYS = 7;
44
+
45
+ export interface UsageReport {
46
+ at: string;
47
+ plan: Plan | null;
48
+ planSource: "entitlements" | "usage" | null;
49
+ worker: string | null;
50
+ databaseId: string | null;
51
+ /** Today, the last 7 days and the last 30 days, as UTC dates including today (D1 counts by date, and Free's limits reset at 00:00 UTC). */
52
+ periods: Record<"day" | "week" | "month", { site: UsageTotals; account: UsageTotals }>;
53
+ storage: { siteBytes: number | null; accountBytes: number };
54
+ bill: Bill;
55
+ warming: WarmingEstimate | null;
56
+ /** Parts that couldn't be read (the rest still shows). */
57
+ notes: string[];
58
+ }
59
+
60
+ export type UsageResult = { configured: false; reason: string } | ({ configured: true } & UsageReport);
61
+
62
+ type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
63
+
64
+ class UsageError extends Error {}
65
+
66
+ async function api<T>(doFetch: Fetch, token: string, path: string, init: RequestInit = {}): Promise<T> {
67
+ const res = await doFetch(`${API}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init.headers ?? {}) } });
68
+ const body = (await res.json().catch(() => ({}))) as { success?: boolean; result?: T; errors?: Array<{ message?: string }> | null; data?: unknown };
69
+ if (!res.ok || body.success === false || (Array.isArray(body.errors) && body.errors.length && body.data === undefined)) {
70
+ const detail = body.errors?.map((e) => e.message).filter(Boolean).join("; ") || res.statusText;
71
+ throw new UsageError(`Cloudflare API ${res.status}: ${detail}`);
72
+ }
73
+ return (path === "/graphql" ? body : body.result) as T;
74
+ }
75
+
76
+ async function access(ctx: PluginContext, env: Record<string, unknown>): Promise<{ token: string; accountId: string | null; doFetch: Fetch } | { reason: string }> {
77
+ const saved = await ctx.settings.get<string>(USAGE_TOKEN_SETTING).catch(() => null);
78
+ const token = saved?.trim() || secret(env, "USAGE_API_TOKEN");
79
+ if (!token) return { reason: "Add a read-only Cloudflare API token to see usage: the USAGE_API_TOKEN Worker secret, or Cloudflare usage on the plugin's Settings page. The steps are below." };
80
+ const accountId = secret(env, "CF_ACCOUNT_ID") || secret(env, "CLOUDFLARE_ACCOUNT_ID") || (await ctx.settings.get<string>(IMAGES_SETTINGS.accountId).catch(() => null))?.trim() || null;
81
+ const doFetch = (ctx.http ? ctx.http.fetch.bind(ctx.http) : fetch) as Fetch;
82
+ return { token, accountId, doFetch };
83
+ }
84
+
85
+ interface Target {
86
+ version: string | null;
87
+ accountId: string;
88
+ worker: string | null;
89
+ databaseId: string | null;
90
+ }
91
+
92
+ /** The account, this site's Worker and its D1 database: found once per deployed version and remembered. */
93
+ async function findTarget(ctx: PluginContext, env: Record<string, unknown>, a: { token: string; accountId: string | null; doFetch: Fetch }, notes: string[]): Promise<Target> {
94
+ const version = versionId(env) ?? null;
95
+ const known = await ctx.kv.get<Target>(TARGET_KEY);
96
+ // Remembered per deployed version, found or not (a Worker that wasn't found isn't searched for again until the next deploy).
97
+ if (known && known.version === version && (!a.accountId || known.accountId === a.accountId)) return known;
98
+
99
+ let accountId = a.accountId;
100
+ if (!accountId) {
101
+ const accounts = await api<Array<{ id: string }>>(a.doFetch, a.token, "/accounts?per_page=5").catch(() => []);
102
+ if (accounts.length !== 1) throw new UsageError("Couldn't tell which Cloudflare account to read: limit the token to one account, or set the CF_ACCOUNT_ID Worker secret.");
103
+ accountId = accounts[0].id;
104
+ }
105
+ const target: Target = { version, accountId, worker: null, databaseId: null };
106
+ try {
107
+ const scripts = await api<Array<{ id: string; modified_on?: string }>>(a.doFetch, a.token, `/accounts/${accountId}/workers/scripts`);
108
+ // The Worker deployed most recently first: usually this one.
109
+ scripts.sort((x, y) => Date.parse(y.modified_on ?? "") - Date.parse(x.modified_on ?? ""));
110
+ for (const script of version ? scripts.slice(0, 50) : []) {
111
+ const deployments = await api<{ deployments?: Array<{ versions?: Array<{ version_id?: string }> }> }>(a.doFetch, a.token, `/accounts/${accountId}/workers/scripts/${encodeURIComponent(script.id)}/deployments`).catch(() => null);
112
+ if (deployments?.deployments?.[0]?.versions?.some((v) => v.version_id === version)) {
113
+ target.worker = script.id;
114
+ break;
115
+ }
116
+ }
117
+ if (!target.worker) notes.push(version ? "Couldn't find this site's Worker among the account's Workers." : "This Worker has no CF_VERSION_METADATA binding, so its own usage can't be told apart.");
118
+ if (target.worker) {
119
+ const settings = await api<{ bindings?: Array<{ type?: string; name?: string; id?: string; database_id?: string }> }>(a.doFetch, a.token, `/accounts/${accountId}/workers/scripts/${encodeURIComponent(target.worker)}/settings`);
120
+ const d1 = settings.bindings?.find((b) => b.type === "d1" && b.name === "DB") ?? settings.bindings?.find((b) => b.type === "d1");
121
+ target.databaseId = d1?.database_id ?? d1?.id ?? null;
122
+ }
123
+ } catch (error) {
124
+ notes.push(`Couldn't find this site's Worker (the token needs Workers → Metadata Read-Only, or Account → Workers Scripts → Read on a personal token): ${String(error instanceof Error ? error.message : error)}`);
125
+ }
126
+ if (target.worker || version) await ctx.kv.set(TARGET_KEY, target);
127
+ return target;
128
+ }
129
+
130
+ const USAGE_QUERY = `query Usage($a: String!, $d1: Date!, $d7: Date!, $d30: Date!, $today: Date!, $t1: Time!, $t7: Time!, $t30: Time!, $now: Time!) {
131
+ viewer { accounts(filter: { accountTag: $a }) {
132
+ d1Day: d1AnalyticsAdaptiveGroups(limit: 1000, filter: { date_geq: $d1, date_leq: $today }) { sum { readQueries writeQueries rowsRead rowsWritten } dimensions { databaseId } }
133
+ d1Week: d1AnalyticsAdaptiveGroups(limit: 1000, filter: { date_geq: $d7, date_leq: $today }) { sum { readQueries writeQueries rowsRead rowsWritten } dimensions { databaseId } }
134
+ d1Month: d1AnalyticsAdaptiveGroups(limit: 1000, filter: { date_geq: $d30, date_leq: $today }) { sum { readQueries writeQueries rowsRead rowsWritten } dimensions { databaseId } }
135
+ d1Daily: d1AnalyticsAdaptiveGroups(limit: 100, filter: { date_geq: $d30, date_leq: $today }) { sum { rowsRead rowsWritten } dimensions { date } }
136
+ wDay: workersInvocationsAdaptive(limit: 1000, filter: { datetime_geq: $t1, datetime_leq: $now }) { sum { requests errors cpuTimeUs } dimensions { scriptName } }
137
+ wWeek: workersInvocationsAdaptive(limit: 1000, filter: { datetime_geq: $t7, datetime_leq: $now }) { sum { requests errors cpuTimeUs } dimensions { scriptName } }
138
+ wMonth: workersInvocationsAdaptive(limit: 1000, filter: { datetime_geq: $t30, datetime_leq: $now }) { sum { requests errors cpuTimeUs } dimensions { scriptName } }
139
+ wDaily: workersInvocationsAdaptive(limit: 100, filter: { datetime_geq: $t30, datetime_leq: $now }) { sum { requests } dimensions { date } }
140
+ storage: d1StorageAdaptiveGroups(limit: 1000, filter: { date_geq: $d1, date_leq: $today }) { max { databaseSizeBytes } dimensions { databaseId } }
141
+ } }
142
+ }`;
143
+
144
+ type Rows<T> = T[] | undefined;
145
+
146
+ async function readUsage(ctx: PluginContext, a: { token: string; doFetch: Fetch }, target: Target, notes: string[]): Promise<Omit<UsageReport, "at" | "notes">> {
147
+ const now = new Date();
148
+ const date = (ms: number) => new Date(ms).toISOString().slice(0, 10);
149
+ const time = (ms: number) => new Date(ms).toISOString();
150
+ const n = now.getTime();
151
+ // Whole UTC dates for both datasets, so D1 (counted by date) and Workers cover the same time.
152
+ const startOfToday = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
153
+ const body = await api<{ data?: { viewer?: { accounts?: Array<Record<string, Rows<Record<string, unknown>>>> } }; errors?: Array<{ message?: string }> | null }>(a.doFetch, a.token, "/graphql", {
154
+ method: "POST",
155
+ body: JSON.stringify({
156
+ query: USAGE_QUERY,
157
+ variables: { a: target.accountId, d1: date(startOfToday), d7: date(startOfToday - 6 * DAY_MS), d30: date(startOfToday - 29 * DAY_MS), today: date(startOfToday), t1: time(startOfToday), t7: time(startOfToday - 6 * DAY_MS), t30: time(startOfToday - 29 * DAY_MS), now: time(n) },
158
+ }),
159
+ });
160
+ if (body.errors?.length) throw new UsageError(`Cloudflare analytics: ${body.errors.map((e) => e.message).join("; ")}. The token needs Account → Account Analytics → Read.`);
161
+ const acc = body.data?.viewer?.accounts?.[0] ?? {};
162
+ // biome-ignore lint/suspicious/noExplicitAny: GraphQL rows.
163
+ const rows = (key: string) => (acc[key] ?? []) as any[];
164
+ const site = { databaseId: target.databaseId, worker: target.worker };
165
+ const periods = {
166
+ day: foldTotals(rows("d1Day"), rows("wDay"), site),
167
+ week: foldTotals(rows("d1Week"), rows("wWeek"), site),
168
+ month: foldTotals(rows("d1Month"), rows("wMonth"), site),
169
+ };
170
+ const sizes = rows("storage") as Array<{ dimensions?: { databaseId?: string }; max?: { databaseSizeBytes?: number } }>;
171
+ const accountBytes = sizes.reduce((s, r) => s + (r.max?.databaseSizeBytes ?? 0), 0);
172
+ const siteBytes = target.databaseId ? (sizes.find((r) => r.dimensions?.databaseId === target.databaseId)?.max?.databaseSizeBytes ?? null) : null;
173
+
174
+ // The plan: the account's entitlements, or a day over Free's limits.
175
+ let plan: Plan | null = null;
176
+ let planSource: UsageReport["planSource"] = null;
177
+ const entitlements = await api<unknown>(a.doFetch, a.token, `/accounts/${target.accountId}/entitlements`).catch(() => null);
178
+ plan = planFromEntitlements(entitlements);
179
+ if (plan) planSource = "entitlements";
180
+ else {
181
+ const peak = (list: Array<{ sum?: Record<string, number> }>, field: string) => Math.max(0, ...list.map((r) => r.sum?.[field] ?? 0));
182
+ plan = planFromDailyPeaks({ requests: peak(rows("wDaily"), "requests"), rowsRead: peak(rows("d1Daily"), "rowsRead"), rowsWritten: peak(rows("d1Daily"), "rowsWritten") });
183
+ if (plan) planSource = "usage";
184
+ else notes.push("Couldn't read the account's plan (the token needs Account → Account Settings → Read); the bill assumes Workers Paid.");
185
+ }
186
+
187
+ // The cache warmer's share, from Workers Logs (sampled, so an estimate).
188
+ let warming: WarmingEstimate | null = null;
189
+ if (target.worker) {
190
+ try {
191
+ const result = await api<{ calculations?: Array<{ alias?: string; aggregates?: Array<{ value?: number }> }> }>(a.doFetch, a.token, `/accounts/${target.accountId}/workers/observability/telemetry/query`, {
192
+ method: "POST",
193
+ body: JSON.stringify({
194
+ queryId: "coywolf-pack-usage-warming",
195
+ view: "calculations",
196
+ timeframe: { from: startOfToday - (WARM_DAYS - 1) * DAY_MS, to: n },
197
+ parameters: {
198
+ datasets: ["cloudflare-workers"],
199
+ filters: [
200
+ { key: "$metadata.service", operation: "eq", type: "string", value: target.worker },
201
+ { key: "$workers.event.request.headers.user-agent", operation: "eq", type: "string", value: WARMER_AGENT },
202
+ // Invocations only, not console.log lines from the same requests.
203
+ { key: "$metadata.type", operation: "eq", type: "string", value: "cf-worker-event" },
204
+ ],
205
+ calculations: [
206
+ { operator: "count", alias: "visits" },
207
+ { operator: "sum", key: "$workers.cpuTimeMs", keyType: "number", alias: "cpu" },
208
+ ],
209
+ },
210
+ }),
211
+ });
212
+ const value = (alias: string) => Number(result.calculations?.find((c) => c.alias === alias)?.aggregates?.[0]?.value ?? 0) || 0;
213
+ warming = estimateWarming({ visits: Math.round(value("visits")), cpuMs: Math.round(value("cpu")) }, periods.week.site, WARM_DAYS);
214
+ } catch (error) {
215
+ notes.push(`Couldn't estimate cache warming from Workers Logs (the token needs Workers → Metadata Read-Only, or Account → Workers Observability → Edit on a personal token, and the Worker needs Workers Logs on): ${String(error instanceof Error ? error.message : error)}`);
216
+ }
217
+ }
218
+
219
+ return { plan, planSource, worker: target.worker, databaseId: target.databaseId, periods, storage: { siteBytes, accountBytes }, bill: estimateBill(periods.month.account, accountBytes), warming };
220
+ }
221
+
222
+ let reading: Promise<UsageResult> | null = null;
223
+
224
+ /**
225
+ * The last reading (under an hour old), or a new one. `refresh` reads again unless the last one is under a minute old.
226
+ * `bindings` replaces the Worker's env (tests).
227
+ */
228
+ export async function usageReport(ctx: PluginContext, refresh = false, bindings?: Record<string, unknown>): Promise<UsageResult> {
229
+ const cached = await ctx.kv.get<UsageReport>(CACHE_KEY);
230
+ const age = cached ? Date.now() - Date.parse(cached.at) : Number.POSITIVE_INFINITY;
231
+ if (cached && age < (refresh ? REFRESH_MIN_MS : TTL_MS)) return { configured: true, ...cached };
232
+ if (reading) return reading;
233
+ const failed = await ctx.kv.get<{ at: string; message: string }>(FAILED_KEY);
234
+ if (failed && Date.now() - Date.parse(failed.at) < RETRY_AFTER_MS) {
235
+ const wait = `Couldn't read usage: ${failed.message} Trying again after ${new Date(Date.parse(failed.at) + RETRY_AFTER_MS).toISOString().slice(11, 16)} UTC.`;
236
+ return cached ? { configured: true, ...cached, notes: [...cached.notes, wait] } : { configured: false, reason: wait };
237
+ }
238
+ reading = (async (): Promise<UsageResult> => {
239
+ const env = bindings ?? (await workerEnv().catch(() => ({}) as Record<string, unknown>));
240
+ const a = await access(ctx, env);
241
+ if ("reason" in a) return { configured: false, reason: a.reason };
242
+ const notes: string[] = [];
243
+ try {
244
+ const target = await findTarget(ctx, env, a, notes);
245
+ const report: UsageReport = { at: new Date().toISOString(), ...(await readUsage(ctx, a, target, notes)), notes };
246
+ await ctx.kv.set(CACHE_KEY, report);
247
+ await ctx.kv.delete?.(FAILED_KEY);
248
+ return { configured: true, ...report };
249
+ } catch (error) {
250
+ const message = error instanceof Error ? error.message : String(error);
251
+ ctx.log.warn("Cloudflare usage: read failed", { error: message });
252
+ await ctx.kv.set(FAILED_KEY, { at: new Date().toISOString(), message });
253
+ // The last good reading, if any, with what went wrong.
254
+ if (cached) return { configured: true, ...cached, notes: [...cached.notes, `Couldn't refresh: ${message}`] };
255
+ return { configured: false, reason: message };
256
+ }
257
+ })().finally(() => {
258
+ reading = null;
259
+ });
260
+ return reading;
261
+ }
262
+