@coywolf/emdash 0.0.0-stage → 0.35.14

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 (267) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -2
  3. package/docs/README.md +45 -0
  4. package/docs/ai.md +39 -0
  5. package/docs/backups.md +88 -0
  6. package/docs/breadcrumbs.md +59 -0
  7. package/docs/code-blocks.md +21 -0
  8. package/docs/content-urls.md +41 -0
  9. package/docs/custom-blocks.md +27 -0
  10. package/docs/discovery.md +32 -0
  11. package/docs/files.md +44 -0
  12. package/docs/form-uploads.md +59 -0
  13. package/docs/headings.md +37 -0
  14. package/docs/images.md +141 -0
  15. package/docs/internals.md +13 -0
  16. package/docs/links.md +34 -0
  17. package/docs/performance.md +72 -0
  18. package/docs/redirects.md +84 -0
  19. package/docs/releasing.md +21 -0
  20. package/docs/reviews.md +89 -0
  21. package/docs/robots.md +81 -0
  22. package/docs/schema.md +79 -0
  23. package/docs/search.md +114 -0
  24. package/docs/videos.md +48 -0
  25. package/docs/wordpress-import.md +198 -0
  26. package/package.json +79 -5
  27. package/src/admin/admin-css.ts +57 -0
  28. package/src/admin/ai.tsx +915 -0
  29. package/src/admin/backups.tsx +613 -0
  30. package/src/admin/breadcrumbs.tsx +542 -0
  31. package/src/admin/codeBlocks.tsx +204 -0
  32. package/src/admin/customBlocks.tsx +321 -0
  33. package/src/admin/dirty.ts +20 -0
  34. package/src/admin/discovery.tsx +463 -0
  35. package/src/admin/download.ts +28 -0
  36. package/src/admin/feature-switches.tsx +124 -0
  37. package/src/admin/features.tsx +142 -0
  38. package/src/admin/files.tsx +791 -0
  39. package/src/admin/formUploads.tsx +445 -0
  40. package/src/admin/guides.tsx +18 -0
  41. package/src/admin/headings.tsx +262 -0
  42. package/src/admin/images.tsx +825 -0
  43. package/src/admin/index.tsx +62 -0
  44. package/src/admin/links.tsx +980 -0
  45. package/src/admin/performance.tsx +330 -0
  46. package/src/admin/preview.tsx +63 -0
  47. package/src/admin/redirects-removed.tsx +266 -0
  48. package/src/admin/redirects.tsx +470 -0
  49. package/src/admin/reviews.tsx +232 -0
  50. package/src/admin/robots-bots.tsx +647 -0
  51. package/src/admin/robots-shared.tsx +109 -0
  52. package/src/admin/robots-wizard.tsx +748 -0
  53. package/src/admin/robots.tsx +911 -0
  54. package/src/admin/save-bar.tsx +134 -0
  55. package/src/admin/schema.tsx +1187 -0
  56. package/src/admin/search.tsx +355 -0
  57. package/src/admin/settings-ui.tsx +87 -0
  58. package/src/admin/trash-prompt-core.ts +135 -0
  59. package/src/admin/trash-prompt.tsx +156 -0
  60. package/src/admin/usage.tsx +301 -0
  61. package/src/admin/videos-display.tsx +248 -0
  62. package/src/admin/videos.tsx +1202 -0
  63. package/src/admin/wpImport.tsx +1516 -0
  64. package/src/ai/entities.ts +59 -0
  65. package/src/ai/export.ts +61 -0
  66. package/src/ai/logic.ts +606 -0
  67. package/src/ai/pack.ts +121 -0
  68. package/src/ai/prompts.ts +87 -0
  69. package/src/ai/providers.ts +276 -0
  70. package/src/ai/routes.ts +359 -0
  71. package/src/ai/store.ts +301 -0
  72. package/src/ai/wikidata.ts +87 -0
  73. package/src/ai/worker.ts +530 -0
  74. package/src/astro/astro-modules.d.ts +6 -0
  75. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  76. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  77. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  78. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  79. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  80. package/src/astro/customBlocks/Details.astro +8 -0
  81. package/src/astro/customBlocks/Disclosure.astro +8 -0
  82. package/src/astro/customBlocks/Note.astro +8 -0
  83. package/src/astro/customBlocks/Podcast.astro +8 -0
  84. package/src/astro/customBlocks/Quote.astro +8 -0
  85. package/src/astro/customBlocks/Testimonial.astro +8 -0
  86. package/src/astro/files/FileCard.astro +103 -0
  87. package/src/astro/headings/HeadingBlock.astro +129 -0
  88. package/src/astro/headings/TableOfContents.astro +45 -0
  89. package/src/astro/headings/request.ts +19 -0
  90. package/src/astro/images/ResponsiveImage.astro +60 -0
  91. package/src/astro/index.ts +84 -0
  92. package/src/astro/reviews/CoywolfReview.astro +27 -0
  93. package/src/astro/reviews/Review.astro +49 -0
  94. package/src/astro/search/SearchBox.astro +61 -0
  95. package/src/astro/search/SearchBoxForm.astro +509 -0
  96. package/src/astro/tables/CaptionedTable.astro +72 -0
  97. package/src/astro/videos/CoywolfVideo.astro +448 -0
  98. package/src/backups/download.ts +112 -0
  99. package/src/backups/dump.ts +288 -0
  100. package/src/backups/module.ts +276 -0
  101. package/src/backups/pack.ts +33 -0
  102. package/src/backups/restore.ts +209 -0
  103. package/src/backups/store.ts +409 -0
  104. package/src/breadcrumbs/pack.ts +113 -0
  105. package/src/breadcrumbs/settings.ts +102 -0
  106. package/src/breadcrumbs/stamp.ts +40 -0
  107. package/src/breadcrumbs/trail.ts +123 -0
  108. package/src/codeBlocks/contrast.ts +179 -0
  109. package/src/codeBlocks/hljs.d.ts +5 -0
  110. package/src/codeBlocks/pack.ts +110 -0
  111. package/src/codeBlocks/render.ts +304 -0
  112. package/src/codeBlocks/settings.ts +18 -0
  113. package/src/codeBlocks/themes.generated.ts +304 -0
  114. package/src/codeBlocks/themes.ts +114 -0
  115. package/src/core/compose.ts +244 -0
  116. package/src/core/content-url.ts +888 -0
  117. package/src/core/csv.ts +119 -0
  118. package/src/core/d1-batch.ts +65 -0
  119. package/src/core/entry-docs.ts +175 -0
  120. package/src/core/features-module.ts +48 -0
  121. package/src/core/features.ts +392 -0
  122. package/src/core/generation.ts +88 -0
  123. package/src/core/guide-content.ts +166 -0
  124. package/src/core/module.ts +104 -0
  125. package/src/core/read-failures.ts +57 -0
  126. package/src/core/request-memo.ts +71 -0
  127. package/src/core/secrets.ts +83 -0
  128. package/src/core/settings-enhance.ts +74 -0
  129. package/src/core/site.ts +36 -0
  130. package/src/core/storage.ts +27 -0
  131. package/src/customBlocks/pack.ts +260 -0
  132. package/src/customBlocks/render.ts +424 -0
  133. package/src/customBlocks/rich.ts +313 -0
  134. package/src/customBlocks/settings.ts +46 -0
  135. package/src/discovery/build.ts +252 -0
  136. package/src/discovery/feed-build.ts +217 -0
  137. package/src/discovery/feed.ts +172 -0
  138. package/src/discovery/indexnow.ts +114 -0
  139. package/src/discovery/llms.ts +79 -0
  140. package/src/discovery/markdown.ts +294 -0
  141. package/src/discovery/memory-cache.ts +97 -0
  142. package/src/discovery/middleware.ts +186 -0
  143. package/src/discovery/module.ts +353 -0
  144. package/src/discovery/news.ts +99 -0
  145. package/src/discovery/pack.ts +58 -0
  146. package/src/discovery/settings.ts +91 -0
  147. package/src/discovery/urls.ts +35 -0
  148. package/src/env.d.ts +10 -0
  149. package/src/files/card.ts +231 -0
  150. package/src/files/counts.ts +60 -0
  151. package/src/files/export.ts +44 -0
  152. package/src/files/format.ts +163 -0
  153. package/src/files/module.ts +596 -0
  154. package/src/files/pack.ts +92 -0
  155. package/src/files/r2-rules.ts +41 -0
  156. package/src/files/range.ts +50 -0
  157. package/src/files/s3.ts +114 -0
  158. package/src/files/serve.ts +141 -0
  159. package/src/files/sigv4.ts +160 -0
  160. package/src/files/site.ts +137 -0
  161. package/src/files/walker.ts +58 -0
  162. package/src/formUploads/lib.ts +266 -0
  163. package/src/formUploads/pack.ts +55 -0
  164. package/src/formUploads/wrap.ts +592 -0
  165. package/src/forms.ts +16 -0
  166. package/src/headings/pack.ts +154 -0
  167. package/src/headings/render.ts +139 -0
  168. package/src/headings/settings.ts +117 -0
  169. package/src/headings/slug.ts +68 -0
  170. package/src/headings/stamp.ts +142 -0
  171. package/src/headings/test-hooks.mjs +14 -0
  172. package/src/headings/toc.ts +48 -0
  173. package/src/images/cloudflare.ts +329 -0
  174. package/src/images/lib.ts +221 -0
  175. package/src/images/module.ts +372 -0
  176. package/src/images/pack.ts +804 -0
  177. package/src/images/pending.ts +37 -0
  178. package/src/images/settings.ts +48 -0
  179. package/src/images/setup.ts +192 -0
  180. package/src/images/variants-estimate.ts +162 -0
  181. package/src/images/variants-store.ts +475 -0
  182. package/src/images/variants.ts +311 -0
  183. package/src/index.ts +143 -0
  184. package/src/links/check.ts +101 -0
  185. package/src/links/classify.ts +270 -0
  186. package/src/links/export.ts +59 -0
  187. package/src/links/module.ts +652 -0
  188. package/src/links/pack.ts +43 -0
  189. package/src/links/pt.ts +325 -0
  190. package/src/links/store.ts +704 -0
  191. package/src/middleware.ts +287 -0
  192. package/src/modules.ts +53 -0
  193. package/src/options.ts +66 -0
  194. package/src/pageCache/lib.ts +274 -0
  195. package/src/pageCache/pack.ts +240 -0
  196. package/src/pageCache/usage-core.ts +152 -0
  197. package/src/pageCache/usage.ts +262 -0
  198. package/src/pageCache/warm.ts +722 -0
  199. package/src/redirects/middleware.ts +169 -0
  200. package/src/redirects/module.ts +124 -0
  201. package/src/redirects/pack.ts +89 -0
  202. package/src/redirects/removed-core.ts +151 -0
  203. package/src/redirects/removed.ts +293 -0
  204. package/src/redirects/rules.ts +355 -0
  205. package/src/redirects/slug-changes.ts +207 -0
  206. package/src/redirects/slug-core.ts +49 -0
  207. package/src/redirects/transfer.ts +92 -0
  208. package/src/reviews/lib.ts +624 -0
  209. package/src/reviews/pack.ts +135 -0
  210. package/src/reviews/schema.ts +128 -0
  211. package/src/reviews/settings.ts +26 -0
  212. package/src/robots/bots.ts +321 -0
  213. package/src/robots/data/bots.json +696 -0
  214. package/src/robots/data/verified.json +582 -0
  215. package/src/robots/directory.ts +26 -0
  216. package/src/robots/explain.ts +191 -0
  217. package/src/robots/guided.ts +300 -0
  218. package/src/robots/importer.ts +263 -0
  219. package/src/robots/middleware.ts +151 -0
  220. package/src/robots/module.ts +446 -0
  221. package/src/robots/pack.ts +69 -0
  222. package/src/robots/radar.ts +129 -0
  223. package/src/robots/rep.ts +362 -0
  224. package/src/robots/rules.ts +883 -0
  225. package/src/robots/transfer.ts +45 -0
  226. package/src/robots/validate.ts +524 -0
  227. package/src/schema/catalog.ts +146 -0
  228. package/src/schema/graph.ts +791 -0
  229. package/src/schema/module.ts +1138 -0
  230. package/src/schema/og-image.ts +100 -0
  231. package/src/schema/pack.ts +58 -0
  232. package/src/search/engine.ts +578 -0
  233. package/src/search/fallback.ts +80 -0
  234. package/src/search/live-cache.ts +116 -0
  235. package/src/search/live-client.ts +770 -0
  236. package/src/search/live-serve.ts +282 -0
  237. package/src/search/module.ts +261 -0
  238. package/src/search/pack.ts +57 -0
  239. package/src/search/query.ts +132 -0
  240. package/src/search/ratelimit-core.ts +118 -0
  241. package/src/search/ratelimit.ts +79 -0
  242. package/src/search/snippet.ts +135 -0
  243. package/src/search/title-index.ts +139 -0
  244. package/src/search/title-match.ts +192 -0
  245. package/src/shared.ts +71 -0
  246. package/src/version.ts +2 -0
  247. package/src/videos/date.ts +40 -0
  248. package/src/videos/engagement.ts +75 -0
  249. package/src/videos/lib.ts +679 -0
  250. package/src/videos/module.ts +1160 -0
  251. package/src/videos/pack.ts +187 -0
  252. package/src/videos/pause.ts +42 -0
  253. package/src/videos/poster.ts +324 -0
  254. package/src/videos/render.ts +336 -0
  255. package/src/videos/store.ts +551 -0
  256. package/src/videos/stream.ts +134 -0
  257. package/src/wpImport/convert.ts +702 -0
  258. package/src/wpImport/guests.ts +329 -0
  259. package/src/wpImport/gutenberg.ts +157 -0
  260. package/src/wpImport/markers.ts +66 -0
  261. package/src/wpImport/pack.ts +451 -0
  262. package/src/wpImport/parents.ts +202 -0
  263. package/src/wpImport/prepare.ts +592 -0
  264. package/src/wpImport/redirects.ts +358 -0
  265. package/src/wpImport/sources.ts +67 -0
  266. package/src/wpImport/stream.ts +160 -0
  267. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Page cache: keeps Cloudflare's Workers Cache (the edge HTML cache in front of
3
+ * the Worker, turned on by Astro's cacheCloudflare() provider) in step with the
4
+ * site. EmDash already purges the pages it tagged when content, menus or site
5
+ * settings change. This covers what EmDash can't know about: a new deploy (theme
6
+ * or code changes) and Coywolf Pack settings, which change page output (schema,
7
+ * blocks, reviews, videos…).
8
+ *
9
+ * Purging runs inside the Worker with cache.purge() from cloudflare:workers, so
10
+ * it needs no API token. Outside Workers Cache it does nothing.
11
+ */
12
+
13
+ /** Option row holding the Worker version whose pages are in the cache. */
14
+ export const VERSION_OPTION = "plugin:coywolf-pack:pageCache:version";
15
+
16
+ /**
17
+ * Pack routes anyone can call (search, discovery files, video player actions).
18
+ * Everything else under the pack's API is an admin action, so a successful POST
19
+ * there purges the cache. The Stream webhook isn't listed: it changes video
20
+ * details that pages show.
21
+ */
22
+ const PUBLIC_ROUTE = /^\/_emdash\/api\/plugins\/coywolf-pack\/(search\/|discovery\/public\/|videos\/(like|play|caption|embed|sitemap)(\/|$))/;
23
+ const PACK_API = "/_emdash/api/plugins/coywolf-pack/";
24
+
25
+ /** True when a request is a pack admin write whose success should purge the cache. */
26
+ export function purgesAfter(method: string, pathname: string): boolean {
27
+ return method === "POST" && pathname.startsWith(PACK_API) && !PUBLIC_ROUTE.test(pathname);
28
+ }
29
+
30
+ export type PurgeScope = { purgeEverything: true } | { pathPrefixes: string[] } | { tags: string[] };
31
+
32
+ /** Cache tag on every redirect the pack serves from the edge cache (src/redirects/middleware.ts). */
33
+ export const REDIRECTS_TAG = "coywolf-redirects";
34
+ /** robots.txt in the edge cache (cleared with everything else after the pack's admin saves and settings changes). */
35
+ export const ROBOTS_TAG = "coywolf-robots";
36
+
37
+ interface EdgeRouteCache {
38
+ enabled?: boolean;
39
+ set(options: { maxAge?: number; swr?: number; tags?: string[] } | false): void;
40
+ }
41
+
42
+ /**
43
+ * Keep a response the pack serves itself in the edge cache (Workers Cache) for
44
+ * `maxAge` seconds, replacing any lifetime the site's route rules or Plugins →
45
+ * Performance gave the request. For middleware marked `ownsCache`. Does
46
+ * nothing without Astro route caching.
47
+ */
48
+ export function keepAtEdge(context: unknown, maxAge: number, tags: string[] = []): void {
49
+ const cache = (context as { cache?: EdgeRouteCache }).cache;
50
+ if (!cache?.enabled) return;
51
+ cache.set(false);
52
+ cache.set({ maxAge, swr: 0, tags });
53
+ }
54
+
55
+ /** Keep a response out of the edge cache (for an `ownsCache` middleware's other answers). */
56
+ export function keepOffEdge(context: unknown): void {
57
+ (context as { cache?: EdgeRouteCache }).cache?.set(false);
58
+ }
59
+
60
+ /**
61
+ * What a successful pack admin write has to clear. Most settings change page
62
+ * output, so everything goes. These don't touch cached pages:
63
+ * - redirect edits clear only the cached redirects (their tag), and so does
64
+ * rewinding the database (it may bring other rules back);
65
+ * - other backup actions, redirect lookups, the link report, and the Performance
66
+ * page's own media rule and warming controls clear nothing;
67
+ * - robots.txt rules change only /robots.txt.
68
+ * null: nothing to clear.
69
+ */
70
+ export function purgeScope(pathname: string): PurgeScope | null {
71
+ const route = pathname.slice(PACK_API.length);
72
+ if (/^redirects\/(?!(list|test|removed)$)|^backups\/(rewind|undo)$/.test(route)) return { tags: [REDIRECTS_TAG] };
73
+ if (/^(redirects|backups|links)\//.test(route) || /^cache\/(media|warm|purge)(\/|$)/.test(route)) return null;
74
+ // Stored image sizes: new uploads and crops are picked up after a short stopgap lifetime; a backfill run clears the cache itself once when it is done.
75
+ if (/^images\/variants\/(settings|run|bulk|estimate)$/.test(route)) return null;
76
+ // Videos admin reads (library, details, status, storage, settings, captions list) change nothing.
77
+ if (/^videos\/(list|status|detail|options|storage|test|settings|captions\/list)$/.test(route)) return null;
78
+ if (/^robots\//.test(route)) return { pathPrefixes: ["/robots.txt"] };
79
+ return { purgeEverything: true };
80
+ }
81
+
82
+ /** Most sources one redirect save clears by path; more than this clears everything instead. */
83
+ const MAX_REDIRECT_PATHS = 30;
84
+
85
+ /** True for the redirect writes whose request body redirectPagesScope reads (read it before the route consumes it). */
86
+ export function isRedirectSave(pathname: string): boolean {
87
+ return /^redirects\/(save|import)$/.test(pathname.slice(PACK_API.length));
88
+ }
89
+
90
+ /**
91
+ * The pages a redirect save or import also has to clear: the cached page at
92
+ * each new source. Cached pages are served before the Worker runs, so the
93
+ * redirect would otherwise wait for them to expire (purgeScope only clears the
94
+ * cached redirects). An exact source clears by path prefix, which covers its
95
+ * trailing-slash form and query strings; a pattern can cover any page, so it
96
+ * clears everything. null: nothing to clear (disabled rules, an unreadable body).
97
+ */
98
+ export function redirectPagesScope(body: unknown): PurgeScope | null {
99
+ if (!body || typeof body !== "object") return null;
100
+ const input = body as { rules?: unknown };
101
+ const rules = (Array.isArray(input.rules) ? input.rules : [body]) as Array<{ source?: unknown; isRegex?: unknown; enabled?: unknown }>;
102
+ const paths = new Set<string>();
103
+ for (const rule of rules) {
104
+ if (!rule || typeof rule !== "object" || rule.enabled === false || typeof rule.source !== "string") continue;
105
+ if (rule.isRegex === true) return { purgeEverything: true };
106
+ const path = rule.source.trim().split("?")[0].replace(/\/+$/, "") || "/";
107
+ if (!path.startsWith("/")) continue;
108
+ if (path === "/") return { purgeEverything: true };
109
+ paths.add(path);
110
+ }
111
+ if (!paths.size) return null;
112
+ return paths.size > MAX_REDIRECT_PATHS ? { purgeEverything: true } : { pathPrefixes: [...paths] };
113
+ }
114
+
115
+ /** Release an RPC result's stubs (Symbol.dispose, where the runtime has it). Anything else is left alone. */
116
+ export function disposeRpcResult(result: unknown): void {
117
+ if (typeof Symbol.dispose !== "symbol" || !result || typeof result !== "object") return;
118
+ try {
119
+ (result as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.();
120
+ } catch {
121
+ // Already released.
122
+ }
123
+ }
124
+
125
+ /** Purge cached pages (all of them unless a scope says otherwise). False when there's no Workers Cache or the purge was refused. */
126
+ export async function purgePageCache(scope: PurgeScope = { purgeEverything: true }): Promise<boolean> {
127
+ try {
128
+ const workers = (await import("cloudflare:workers")) as unknown as {
129
+ cache?: { purge(options: PurgeScope): Promise<{ success?: boolean; errors?: Array<{ message: string }> } | undefined> };
130
+ };
131
+ if (!workers.cache?.purge) return false;
132
+ const result = await workers.cache.purge(scope);
133
+ try {
134
+ // The purge API rate-limits (Free-tier limits for Workers Cache) and says so in the result.
135
+ if (result && result.success === false) {
136
+ console.error("coywolf-pack: page cache purge refused", result.errors);
137
+ return false;
138
+ }
139
+ return true;
140
+ } finally {
141
+ // The result is an RPC result: dispose of it, or the runtime warns that it wasn't.
142
+ disposeRpcResult(result);
143
+ }
144
+ } catch (error) {
145
+ console.error("coywolf-pack: page cache purge failed", error);
146
+ return false;
147
+ }
148
+ }
149
+
150
+ /** The deployed Worker version (CF_VERSION_METADATA binding), if bound. */
151
+ export function versionId(env: Record<string, unknown>): string | undefined {
152
+ const metadata = env.CF_VERSION_METADATA as { id?: unknown } | undefined;
153
+ return typeof metadata?.id === "string" && metadata.id ? metadata.id : undefined;
154
+ }
155
+
156
+ /**
157
+ * Purge once per deploy: the first isolate of a new version that sees a request
158
+ * compares its version with the stored one, purges, and records it. Other
159
+ * isolates of the same version find it recorded. Racing isolates may both purge,
160
+ * which is harmless.
161
+ */
162
+ export async function purgeIfNewVersion(db: D1Database, id: string, known?: string | null): Promise<boolean> {
163
+ // `known`: the row as read with the feature switches (undefined when not read; then it's read here).
164
+ const value = known !== undefined ? known : ((await db.prepare("SELECT value FROM options WHERE name = ?").bind(VERSION_OPTION).first<{ value: string }>())?.value ?? null);
165
+ if (value && JSON.parse(value) === id) return false;
166
+ if (!(await purgePageCache())) return false;
167
+ await db
168
+ .prepare("INSERT INTO options (name, value) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET value = excluded.value")
169
+ .bind(VERSION_OPTION, JSON.stringify(id))
170
+ .run();
171
+ return true;
172
+ }
173
+
174
+ /** Astro's per-request cache controls (route caching), as far as the pack uses them. */
175
+ interface RouteCache {
176
+ enabled?: boolean;
177
+ options?: { maxAge?: number };
178
+ set(options: { maxAge?: number; swr?: number } | false): void;
179
+ }
180
+
181
+ /**
182
+ * Apply the Page cache lifetimes to a request whose route the site made
183
+ * cacheable (a routeRules entry gave it a maxAge). Other routes are left alone,
184
+ * so this never turns caching on for anything the site didn't.
185
+ */
186
+ export function applyPageLifetime(cache: RouteCache | undefined, maxAgeDays: number, refreshDays: number): boolean {
187
+ if (!cache?.enabled || cache.options?.maxAge === undefined) return false;
188
+ cache.set({ maxAge: Math.round(maxAgeDays * 86400), swr: Math.round(refreshDays * 86400) });
189
+ return true;
190
+ }
191
+
192
+ /** Lifetime of a page rendered with a stopgap (a Stream poster while its media-host copy is made). */
193
+ export const STOPGAP_LIFETIME = { maxAge: 300, swr: 60 } as const;
194
+
195
+ /** What settlePage asks about a response once it's rendered. */
196
+ export interface SettleChecks {
197
+ /** A read the render relied on failed (src/core/read-failures.ts): the response isn't edge-cached at all. */
198
+ failed(): boolean;
199
+ /** The render used temporary media (src/images/pending.ts): the page is cached for minutes, not days. */
200
+ stopgap?(): boolean;
201
+ /** For the cache warmer's own visits: the header that tells it the page wasn't cached (see below). */
202
+ markHeader?: string;
203
+ /** Names the request in the log line when a page isn't cached after a failed read (e.g. "GET /about/"). */
204
+ label?: string;
205
+ /** What failed (for the same log line). */
206
+ reason?(): string | undefined;
207
+ }
208
+
209
+ /**
210
+ * The last word on a GET or HEAD response's edge caching, given once it's
211
+ * rendered:
212
+ * - `failed()`: the pack fell back to "off" or "absent" for something it
213
+ * couldn't read, so the response isn't the site's real page. Not cached.
214
+ * - `stopgap()`: the page showed temporary media. Cached for minutes (STOPGAP_LIFETIME).
215
+ *
216
+ * Astro streams pages: components deeper in the page render after `next()`
217
+ * returns, and they can read settings (a failure noticed only then), mark a
218
+ * stopgap, or call cache.set(), which turns caching back on after a
219
+ * set(false). The cache headers are applied once the middleware chain has
220
+ * returned (Astro's handleCache). So a page about to be cached (HTML, a 200,
221
+ * a lifetime from the route rules) is read in full first, then asked, then its
222
+ * caching set, last. Any other response isn't read: one rendered after a
223
+ * failed read is left out of the cache at once (headers aren't applied until
224
+ * the chain returns, so nothing it renders later can undo that).
225
+ *
226
+ * `markHeader` is for the cache warmer's own visits: a stopgap page (or one
227
+ * rendered after a failed read) isn't cached at all, and the response carries
228
+ * `markHeader: 1` so the warmer visits it again later (see
229
+ * src/pageCache/warm.ts). Not caching it keeps the header out of the cache
230
+ * (visitors never see it) and makes sure the warmer's next visit renders the
231
+ * page instead of getting this copy back.
232
+ */
233
+ export async function settlePage(cache: RouteCache | undefined, response: Response, checks: SettleChecks): Promise<Response> {
234
+ // No route caching (dev, or a site without a cache provider). Astro's real cache stays `enabled` after set(false).
235
+ if (!cache?.enabled) return response;
236
+ const maxAge = cache.options?.maxAge;
237
+ if (maxAge === undefined || maxAge <= 0 || response.status !== 200 || !response.body || !(response.headers.get("content-type") ?? "").startsWith("text/html")) {
238
+ if (checks.failed()) cache.set(false);
239
+ return response;
240
+ }
241
+ const body = await response.arrayBuffer();
242
+ const headers = new Headers(response.headers);
243
+ if (checks.failed()) {
244
+ console.error(`coywolf-pack: ${checks.label ?? "a page"} not edge-cached: a read failed while it rendered (last failure: ${checks.reason?.() ?? "unknown"})`);
245
+ cache.set(false);
246
+ if (checks.markHeader) headers.set(checks.markHeader, "1");
247
+ } else if (checks.stopgap?.() && cache.options?.maxAge !== undefined) {
248
+ // (A component may have turned caching off while rendering, which clears maxAge: that's left alone.)
249
+ if (checks.markHeader) {
250
+ cache.set(false);
251
+ headers.set(checks.markHeader, "1");
252
+ } else cache.set({ maxAge: Math.min(maxAge, STOPGAP_LIFETIME.maxAge), swr: STOPGAP_LIFETIME.swr });
253
+ }
254
+ return new Response(body, { status: response.status, statusText: response.statusText, headers });
255
+ }
256
+
257
+ /**
258
+ * Notice when the request clears cached pages by tag (EmDash does on content,
259
+ * menu, taxonomy, widget and site settings writes, and skips it for saves that
260
+ * don't change live content, such as draft autosaves): wraps this request's
261
+ * cache.invalidate. Astro gives the middleware and the route the same cache
262
+ * object. Returns a function telling whether it was called (and succeeded).
263
+ */
264
+ export function watchInvalidation(cache: { invalidate?: (...args: unknown[]) => Promise<unknown> } | undefined): () => boolean {
265
+ let invalidated = false;
266
+ const original = cache?.invalidate;
267
+ if (!cache || typeof original !== "function") return () => false;
268
+ cache.invalidate = async (...args: unknown[]) => {
269
+ const result = await original.apply(cache, args);
270
+ invalidated = true;
271
+ return result;
272
+ };
273
+ return () => invalidated;
274
+ }
@@ -0,0 +1,240 @@
1
+ import { PluginRouteError, definePluginRoute } from "emdash";
2
+ import { z } from "zod";
3
+
4
+ import { registerSiteOption, registerSiteSetting, rememberSiteOption, rememberSiteSetting, siteFeatureOn, siteOption, siteSetting } from "../core/features.js";
5
+ import { parseInput } from "../shared.js";
6
+ import { CLOUDFLARE_API_HOST, CloudflareApiError, applyMediaCacheRule, readMediaCacheRule } from "../images/cloudflare.js";
7
+ import { mediaApiAccess, purgeMediaHost } from "../images/module.js";
8
+ import type { PackMiddleware, PackModule } from "../core/module.js";
9
+ import { workerEnv } from "../shared.js";
10
+ import { ROBOTS_TAG, VERSION_OPTION, keepAtEdge, purgeIfNewVersion, purgePageCache, versionId } from "./lib.js";
11
+ import { usageReport } from "./usage.js";
12
+ import { WARM_DAILY_SETTING, WARM_SETTING, WARM_STATE_OPTION, nextDailyAt, pendingRevisits, readWarmState, remainingUrls, startWarm } from "./warm.js";
13
+
14
+ /**
15
+ * Performance is always on (its page sits right below Coywolf Pack). On a site
16
+ * without Workers Cache its purges do nothing and its lifetimes apply to no
17
+ * route, so there's nothing to switch off.
18
+ */
19
+ export const ALWAYS = "*";
20
+
21
+ /** How long the edge keeps a page, and how long after that it may serve it while refreshing. */
22
+ export const LIFETIME_SETTINGS = { maxAgeDays: "pageCacheMaxAgeDays", refreshDays: "pageCacheRefreshDays" } as const;
23
+ export const LIFETIME_DEFAULTS = { maxAgeDays: 7, refreshDays: 1 } as const;
24
+ registerSiteSetting(LIFETIME_SETTINGS.maxAgeDays);
25
+ registerSiteSetting(LIFETIME_SETTINGS.refreshDays);
26
+ registerSiteSetting(WARM_SETTING);
27
+ registerSiteSetting(WARM_DAILY_SETTING);
28
+ // The deployed version whose pages are cached, read with the switches (no query of its own per isolate).
29
+ registerSiteOption(VERSION_OPTION);
30
+
31
+ async function siteDb(): Promise<D1Database | undefined> {
32
+ const env: Record<string, unknown> = await workerEnv().catch(() => ({}));
33
+ return env.DB as D1Database | undefined;
34
+ }
35
+
36
+
37
+ const lifetimeInput = z.object({
38
+ maxAgeDays: z.number().int().min(1, "Keep pages for at least 1 day.").max(365, "Keep pages for at most 365 days."),
39
+ refreshDays: z.number().int().min(0).max(365, "Refresh for at most 365 days."),
40
+ });
41
+
42
+ const apiError = (error: unknown): never => {
43
+ if (error instanceof CloudflareApiError) throw PluginRouteError.badRequest(error.message);
44
+ throw error;
45
+ };
46
+
47
+ export function pageCachePack(): PackModule {
48
+ return {
49
+ id: "pageCache",
50
+ label: "Page cache",
51
+ features: [],
52
+ routes: {
53
+ /** The Performance section: page lifetimes and whether the site has Workers Cache. */
54
+ "cache/settings": {
55
+ permission: "plugins:manage" as const,
56
+ handler: async (ctx: Parameters<typeof mediaApiAccess>[0]) => {
57
+ const [maxAgeDays, refreshDays] = await Promise.all([
58
+ ctx.settings.get<number>(LIFETIME_SETTINGS.maxAgeDays),
59
+ ctx.settings.get<number>(LIFETIME_SETTINGS.refreshDays),
60
+ ]);
61
+ return {
62
+ maxAgeDays: maxAgeDays ?? LIFETIME_DEFAULTS.maxAgeDays,
63
+ refreshDays: refreshDays ?? LIFETIME_DEFAULTS.refreshDays,
64
+ images: await siteFeatureOn("images"),
65
+ };
66
+ },
67
+ },
68
+
69
+ /** Cache warming: the switch and where the current (or last) run is. */
70
+ "cache/warm/status": {
71
+ permission: "plugins:manage" as const,
72
+ handler: async (ctx: Parameters<typeof mediaApiAccess>[0]) => {
73
+ const db = await siteDb();
74
+ const state = db ? await readWarmState(db) : null;
75
+ return {
76
+ enabled: Boolean(await ctx.settings.get<boolean>(WARM_SETTING)),
77
+ // The daily refresh: on unless turned off.
78
+ daily: (await ctx.settings.get<boolean>(WARM_DAILY_SETTING)) !== false,
79
+ // `revisiting`: pages warmed with a stopgap poster, to be warmed again once their posters are copied (the URLs stay out).
80
+ // `nextDailyAt`: when the daily refresh starts a new run (ms; only once a run has finished).
81
+ state: state && { ...state, revisit: undefined, remaining: remainingUrls(state), revisiting: pendingRevisits(state), nextDailyAt: nextDailyAt(state) ?? undefined },
82
+ };
83
+ },
84
+ },
85
+
86
+ "cache/warm/settings/save": definePluginRoute({
87
+ permission: "plugins:manage",
88
+ methods: ["POST"],
89
+ request: { body: "json" },
90
+ handler: async (ctx) => {
91
+ const { enabled, daily } = parseInput(z.object({ enabled: z.boolean().optional(), daily: z.boolean().optional() }), ctx.input);
92
+ if (enabled !== undefined) {
93
+ await ctx.settings.set(WARM_SETTING, enabled);
94
+ rememberSiteSetting(WARM_SETTING, enabled);
95
+ }
96
+ if (daily !== undefined) {
97
+ await ctx.settings.set(WARM_DAILY_SETTING, daily);
98
+ rememberSiteSetting(WARM_DAILY_SETTING, daily);
99
+ }
100
+ return { enabled, daily };
101
+ },
102
+ }),
103
+
104
+ /** Warm every page now (also restarts a run in progress). */
105
+ "cache/warm/start": definePluginRoute({
106
+ permission: "plugins:manage",
107
+ methods: ["POST"],
108
+ request: { body: "none" },
109
+ handler: async (ctx) => {
110
+ const db = await siteDb();
111
+ if (!db) throw PluginRouteError.badRequest("Missing the DB binding.");
112
+ if (!(await ctx.settings.get<boolean>(WARM_SETTING))) throw PluginRouteError.badRequest("Turn on cache warming first.");
113
+ const state = await startWarm(db, "manual");
114
+ return { state: { ...state, remaining: 0 } };
115
+ },
116
+ }),
117
+
118
+ "cache/settings/save": definePluginRoute({
119
+ permission: "plugins:manage",
120
+ methods: ["POST"],
121
+ request: { body: "json" },
122
+ handler: async (ctx) => {
123
+ const input = parseInput(lifetimeInput, ctx.input);
124
+ await ctx.settings.set(LIFETIME_SETTINGS.maxAgeDays, input.maxAgeDays);
125
+ await ctx.settings.set(LIFETIME_SETTINGS.refreshDays, input.refreshDays);
126
+ rememberSiteSetting(LIFETIME_SETTINGS.maxAgeDays, input.maxAgeDays);
127
+ rememberSiteSetting(LIFETIME_SETTINGS.refreshDays, input.refreshDays);
128
+ // Pages already cached keep their old lifetime; the pack's purge-after-save clears them.
129
+ return input;
130
+ },
131
+ }),
132
+
133
+ /** The media host's Cloudflare Cache Rule (read through the Clean Image URLs token). */
134
+ "cache/media": {
135
+ permission: "plugins:manage" as const,
136
+ handler: async (ctx: Parameters<typeof mediaApiAccess>[0]) => {
137
+ const access = await mediaApiAccess(ctx);
138
+ if (!("config" in access)) return { host: access.hostname, reason: access.reason, rule: null };
139
+ try {
140
+ const { zone, rule } = await readMediaCacheRule(access.config, access.hostname);
141
+ return { host: access.hostname, zone, rule };
142
+ } catch (error) {
143
+ return { host: access.hostname, reason: error instanceof Error ? error.message : String(error), rule: null };
144
+ }
145
+ },
146
+ },
147
+
148
+ "cache/media/apply": definePluginRoute({
149
+ permission: "plugins:manage",
150
+ methods: ["POST"],
151
+ request: { body: "none" },
152
+ handler: async (ctx) => {
153
+ const access = await mediaApiAccess(ctx);
154
+ if (!("config" in access)) throw PluginRouteError.badRequest(access.reason);
155
+ const zone = await applyMediaCacheRule(access.config, access.hostname).catch(apiError);
156
+ ctx.log.info("Media cache rule applied", { host: access.hostname, zone });
157
+ return { host: access.hostname, zone, ...(await readMediaCacheRule(access.config, access.hostname).catch(() => ({ rule: null }))) };
158
+ },
159
+ }),
160
+
161
+ /** Cloudflare usage (Performance page and the dashboard widget): kept for an hour; `refresh` reads again. */
162
+ "cache/usage": {
163
+ permission: "plugins:manage" as const,
164
+ handler: async (ctx: Parameters<typeof mediaApiAccess>[0] & { input?: unknown }) => {
165
+ const { refresh } = parseInput(z.object({ refresh: z.union([z.literal("1"), z.literal(1), z.literal(true)]).optional() }), ctx.input ?? {});
166
+ return usageReport(ctx, Boolean(refresh));
167
+ },
168
+ },
169
+
170
+ /**
171
+ * Clear every cached page now, and the media host's images in the
172
+ * zone cache when Clean image URLs has a media host.
173
+ */
174
+ "cache/purge": definePluginRoute({
175
+ permission: "plugins:manage",
176
+ methods: ["POST"],
177
+ request: { body: "none" },
178
+ handler: async (ctx) => {
179
+ const pages = await purgePageCache();
180
+ if (pages && (await ctx.settings.get<boolean>(WARM_SETTING))) {
181
+ const db = await siteDb();
182
+ if (db) await startWarm(db, "cleared");
183
+ }
184
+ const images = (await siteFeatureOn("images")) ? await purgeMediaHost(ctx) : { purged: false, host: null };
185
+ if (!pages && !images.purged) {
186
+ throw PluginRouteError.badRequest(images.message ?? "This site isn't using Workers Cache, so there are no pages to clear.");
187
+ }
188
+ ctx.log.info("Page cache cleared", { pages, images: images.purged ? images.host : false });
189
+ return { pages, images };
190
+ },
191
+ }),
192
+ },
193
+ // Its page (/performance) is listed by index.ts, always, right below Coywolf Pack.
194
+ adminPages: [],
195
+ widgets: [{ id: "cloudflare-usage", title: "Cloudflare usage", size: "third" }],
196
+ // Clearing the media host's images goes through the Cloudflare API.
197
+ capabilities: ["network:request"],
198
+ allowedHosts: [CLOUDFLARE_API_HOST],
199
+ };
200
+ }
201
+
202
+ let checkedVersion = false;
203
+
204
+ /** Once per isolate: if this is a new deploy, clear the cache (never answers a request). */
205
+ export const pageCacheMiddleware: PackMiddleware = {
206
+ module: "pageCache",
207
+ feature: ALWAYS,
208
+ handle: (_context, env, waitUntil) => {
209
+ if (checkedVersion) return undefined;
210
+ checkedVersion = true;
211
+ const id = versionId(env);
212
+ const db = env.DB as D1Database | undefined;
213
+ if (id && db)
214
+ waitUntil(
215
+ purgeIfNewVersion(db, id, siteOption(VERSION_OPTION))
216
+ .then(async (purged) => {
217
+ if (purged && (await siteSetting<boolean>(WARM_SETTING))) rememberSiteOption(WARM_STATE_OPTION, JSON.stringify(await startWarm(db, "deploy")));
218
+ })
219
+ .catch((error) => console.error("coywolf-pack: page cache version check failed", error)),
220
+ );
221
+ return undefined;
222
+ },
223
+ };
224
+
225
+ /** robots.txt is only ever read at the root; a crawler asking at /news/robots.txt gets a small 404, kept at the edge for a day, instead of a rendered 404 page. */
226
+ export const strayRobotsMiddleware: PackMiddleware = {
227
+ module: "pageCache",
228
+ feature: ALWAYS,
229
+ ownsCache: true,
230
+ handle: (context) => {
231
+ const { pathname } = context.url;
232
+ const method = context.request.method;
233
+ if (!pathname.endsWith("/robots.txt") || pathname === "/robots.txt" || (method !== "GET" && method !== "HEAD")) return undefined;
234
+ keepAtEdge(context, 86400, [ROBOTS_TAG]);
235
+ return new Response(method === "HEAD" ? null : "Not found. robots.txt is at the site root.\n", {
236
+ status: 404,
237
+ headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "public, max-age=86400", "X-Robots-Tag": "noindex" },
238
+ });
239
+ },
240
+ };
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Cloudflare usage (pure part): plan allowances and prices, plan detection,
3
+ * the estimated bill, and the cache-warming estimate. No imports, so
4
+ * `node --test` can load this file.
5
+ *
6
+ * Prices and allowances are Cloudflare's published Workers and D1 pricing
7
+ * (Workers Paid, Standard usage model). The account's allowances are shared
8
+ * by every Worker and database in it, so the bill is worked out from the
9
+ * account's totals, not this site's.
10
+ */
11
+
12
+ export type Plan = "paid" | "free";
13
+
14
+ export interface D1Usage {
15
+ rowsRead: number;
16
+ rowsWritten: number;
17
+ readQueries: number;
18
+ writeQueries: number;
19
+ }
20
+
21
+ export interface WorkersUsage {
22
+ requests: number;
23
+ cpuMs: number;
24
+ errors: number;
25
+ }
26
+
27
+ export interface UsageTotals {
28
+ d1: D1Usage;
29
+ workers: WorkersUsage;
30
+ }
31
+
32
+ export const EMPTY_TOTALS: UsageTotals = {
33
+ d1: { rowsRead: 0, rowsWritten: 0, readQueries: 0, writeQueries: 0 },
34
+ workers: { requests: 0, cpuMs: 0, errors: 0 },
35
+ };
36
+
37
+ /** Workers Paid: what the $5 a month includes, and the price of each unit over it. */
38
+ export const PAID = {
39
+ base: 5,
40
+ monthly: { requests: 10_000_000, cpuMs: 30_000_000, rowsRead: 25_000_000_000, rowsWritten: 50_000_000, storageBytes: 5e9 },
41
+ /** Per million (storage: per GB-month). */
42
+ price: { requests: 0.3, cpuMs: 0.02, rowsRead: 0.001, rowsWritten: 1, storageGb: 0.75 },
43
+ } as const;
44
+
45
+ /** Workers Free: daily limits (requests and D1 rows reset every day at 00:00 UTC); 10 ms CPU per request; 5 GB of D1 storage. */
46
+ export const FREE = {
47
+ daily: { requests: 100_000, rowsRead: 5_000_000, rowsWritten: 100_000 },
48
+ cpuMsPerRequest: 10,
49
+ storageBytes: 5e9,
50
+ } as const;
51
+
52
+ interface Entitlement {
53
+ id?: string;
54
+ feature?: { key?: string };
55
+ allocation?: { value?: unknown };
56
+ }
57
+
58
+ /**
59
+ * The Workers plan from the account's entitlements: D1's per-database size
60
+ * limit is 10 GB on Workers Paid and 500 MB on Free. Null when the list
61
+ * doesn't say (the key is missing, or the token can't read it).
62
+ */
63
+ export function planFromEntitlements(entitlements: unknown): Plan | null {
64
+ const list = Array.isArray(entitlements) ? (entitlements as Entitlement[]) : [];
65
+ const perDb = list.find((e) => (e.feature?.key ?? e.id) === "d1.max_storage_per_db_mb");
66
+ const mb = Number(perDb?.allocation?.value);
67
+ if (!Number.isFinite(mb) || mb <= 0) return null;
68
+ return mb > 500 ? "paid" : "free";
69
+ }
70
+
71
+ /** Without the entitlements: a day over Free's limits means the account is on Paid (Free would have stopped it). Null when it can't tell. */
72
+ export function planFromDailyPeaks(peaks: { requests: number; rowsRead: number; rowsWritten: number }): Plan | null {
73
+ return peaks.requests > FREE.daily.requests || peaks.rowsRead > FREE.daily.rowsRead || peaks.rowsWritten > FREE.daily.rowsWritten ? "paid" : null;
74
+ }
75
+
76
+ const over = (used: number, included: number) => Math.max(0, used - included);
77
+ const round2 = (n: number) => Math.round(n * 100) / 100;
78
+
79
+ export interface Bill {
80
+ base: number;
81
+ overage: { requests: number; cpu: number; rowsRead: number; rowsWritten: number; storage: number };
82
+ total: number;
83
+ }
84
+
85
+ /**
86
+ * The estimated bill for 30 days at this rate on Workers Paid: $5 plus anything
87
+ * over the included amounts. A billing month isn't exactly these 30 days, so
88
+ * it's an estimate.
89
+ */
90
+ export function estimateBill(account: UsageTotals, storageBytes: number): Bill {
91
+ const m = PAID.monthly;
92
+ const p = PAID.price;
93
+ const overage = {
94
+ requests: round2((over(account.workers.requests, m.requests) / 1e6) * p.requests),
95
+ cpu: round2((over(account.workers.cpuMs, m.cpuMs) / 1e6) * p.cpuMs),
96
+ rowsRead: round2((over(account.d1.rowsRead, m.rowsRead) / 1e6) * p.rowsRead),
97
+ rowsWritten: round2((over(account.d1.rowsWritten, m.rowsWritten) / 1e6) * p.rowsWritten),
98
+ storage: round2((over(storageBytes, m.storageBytes) / 1e9) * p.storageGb),
99
+ };
100
+ const total = round2(PAID.base + Object.values(overage).reduce((a, b) => a + b, 0));
101
+ return { base: PAID.base, overage, total };
102
+ }
103
+
104
+ export interface WarmingEstimate {
105
+ /** Cache warmer visits in the window (from sampled Workers Logs). */
106
+ visits: number;
107
+ cpuMs: number;
108
+ /** Share of the site's requests and CPU time in the same window (0–1). */
109
+ requestShare: number;
110
+ cpuShare: number;
111
+ /** The site's D1 rows read in the window times the warmer's share of requests. */
112
+ rowsRead: number;
113
+ days: number;
114
+ }
115
+
116
+ /** The warmer's share of the site's work over the same days. Logs are sampled, so it's an estimate. */
117
+ export function estimateWarming(warm: { visits: number; cpuMs: number }, site: UsageTotals, days: number): WarmingEstimate {
118
+ const requestShare = site.workers.requests > 0 ? Math.min(1, warm.visits / site.workers.requests) : 0;
119
+ const cpuShare = site.workers.cpuMs > 0 ? Math.min(1, warm.cpuMs / site.workers.cpuMs) : 0;
120
+ return { visits: warm.visits, cpuMs: warm.cpuMs, requestShare, cpuShare, rowsRead: Math.round(site.d1.rowsRead * requestShare), days };
121
+ }
122
+
123
+ /** GraphQL rows (grouped by database or Worker) folded into one site's totals and the account's. */
124
+ export function foldTotals(
125
+ d1Rows: Array<{ dimensions?: { databaseId?: string }; sum?: Partial<D1Usage> }>,
126
+ workerRows: Array<{ dimensions?: { scriptName?: string }; sum?: { requests?: number; errors?: number; cpuTimeUs?: number } }>,
127
+ site: { databaseId: string | null; worker: string | null },
128
+ ): { site: UsageTotals; account: UsageTotals } {
129
+ const blank = (): UsageTotals => ({ d1: { ...EMPTY_TOTALS.d1 }, workers: { ...EMPTY_TOTALS.workers } });
130
+ const out = { site: blank(), account: blank() };
131
+ for (const row of d1Rows) {
132
+ const s = row.sum ?? {};
133
+ const targets = [out.account, ...(site.databaseId && row.dimensions?.databaseId === site.databaseId ? [out.site] : [])];
134
+ for (const t of targets) {
135
+ t.d1.rowsRead += s.rowsRead ?? 0;
136
+ t.d1.rowsWritten += s.rowsWritten ?? 0;
137
+ t.d1.readQueries += s.readQueries ?? 0;
138
+ t.d1.writeQueries += s.writeQueries ?? 0;
139
+ }
140
+ }
141
+ for (const row of workerRows) {
142
+ const s = row.sum ?? {};
143
+ const targets = [out.account, ...(site.worker && row.dimensions?.scriptName === site.worker ? [out.site] : [])];
144
+ for (const t of targets) {
145
+ t.workers.requests += s.requests ?? 0;
146
+ t.workers.errors += s.errors ?? 0;
147
+ t.workers.cpuMs += (s.cpuTimeUs ?? 0) / 1000;
148
+ }
149
+ }
150
+ for (const t of [out.site, out.account]) t.workers.cpuMs = Math.round(t.workers.cpuMs);
151
+ return out;
152
+ }