@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,722 @@
1
+ /**
2
+ * Cache warming: after the page cache is cleared (a deploy, a full purge),
3
+ * visit every URL in the sitemap so the next visitor gets a cached page.
4
+ *
5
+ * Workers Cache is tiered: a page fetched once is stored in the data center
6
+ * that fetched it and in the region's upper tier, which every data center in
7
+ * that region consults on a miss. Warming from the Worker therefore warms the
8
+ * region it runs in (with Smart Placement, the one near the database); other
9
+ * regions still fill on their first visit.
10
+ *
11
+ * The work rides on real traffic: after the site answers a page request, the
12
+ * Worker claims a small batch from the queue and visits it in the background
13
+ * (waitUntil). Cron Triggers can't do this: they run in whatever data center
14
+ * has spare capacity (often on another continent), far from the database and
15
+ * the readers, and placement hints don't apply to them. Visits from request
16
+ * context run where the traffic is (with Smart Placement, near the database),
17
+ * so they warm the region the readers and crawlers come from.
18
+ *
19
+ * The job's progress lives in one small option row, the URLs in rows of
20
+ * their own written once per run. Batches are claimed with a compare-and-swap
21
+ * on the progress row (moving its `next` index), so isolates working in
22
+ * parallel never take the same pages. A new purge restarts it (new
23
+ * generation) and clears older runs' URL rows.
24
+ *
25
+ * EmDash clears cached pages by tag when content, menus, taxonomies or site
26
+ * settings change, and list pages carry their collection's tag, so one saved
27
+ * post clears the home page, the archives and many posts. The pack middleware
28
+ * sees those clears (cache.invalidate) and schedules a new run a minute after
29
+ * the last one (scheduleRewarm), so a burst of edits makes one run.
30
+ *
31
+ * A page whose render used a stopgap (a video's Stream poster while its copy
32
+ * on the media host is made, see src/videos/poster.ts) comes back with
33
+ * STOPGAP_HEADER and isn't cached. Such pages are listed in the progress row
34
+ * (`revisit`, at most MAX_REVISIT) and visited again once the main queue is
35
+ * done and REVISIT_DELAY_MS has passed, so they end up cached with the normal
36
+ * lifetime. Each gets at most MAX_REVISIT_ROUNDS more visits.
37
+ *
38
+ * Daily refresh: Cloudflare drops pages from its cache when they aren't
39
+ * requested often (well before their lifetime ends), so search bots and
40
+ * visitors reaching older pages would wait for them to be built. A day after
41
+ * the last run finished (any reason), the next warm step starts a new run
42
+ * ("daily"). Pages still cached come back as cache hits; only the dropped ones
43
+ * are built again. The due time is derived from `finishedAt` (nextDailyAt),
44
+ * which the isolate's copy of the progress row already has, so requests in
45
+ * between still skip the warm step without a query. WARM_DAILY_SETTING turns
46
+ * it off (`daily: false`).
47
+ */
48
+
49
+ /** Option rows: the switch, and the job's progress (its URLs are in WARM_QUEUE_PREFIX rows). */
50
+ export const WARM_SETTING = "pageCacheWarm";
51
+ /** The daily refresh (on unless set to false). */
52
+ export const WARM_DAILY_SETTING = "pageCacheWarmDaily";
53
+ export const WARM_STATE_OPTION = "plugin:coywolf-pack:pageCache:warmState";
54
+
55
+ export interface WarmState {
56
+ /** Changes on every (re)start; a running batch stops when it no longer matches. */
57
+ generation: string;
58
+ startedAt: string;
59
+ /** "collect" until the sitemap has been read, then "warm" until the queue is empty. */
60
+ phase: "collect" | "warm" | "done" | "failed";
61
+ /** When an isolate took the collect step (so a stalled one can be retried). */
62
+ collectingAt?: string;
63
+ /** Index of the next URL to hand to a batch (URLs 0…next-1 are claimed). */
64
+ next: number;
65
+ total: number;
66
+ warmed: number;
67
+ failed: number;
68
+ finishedAt?: string;
69
+ error?: string;
70
+ reason: string;
71
+ /** Read from a row in the format before 0.26 (progress only; the next claim converts it, see migrateLegacy). */
72
+ legacy?: boolean;
73
+ /** Pages to visit again (their render used a stopgap): [url, revisits so far]. */
74
+ revisit?: Array<[string, number]>;
75
+ /** Not before then (ISO), so the posters' copies are made and listed first. */
76
+ revisitAfter?: string;
77
+ /** Revisits handed to batches and not yet recorded, and when the last were claimed. */
78
+ revisitBusy?: number;
79
+ revisitBusyAt?: string;
80
+ /** Pages visited again and cached with the normal lifetime; pages still on a stopgap after their last revisit (or past MAX_REVISIT). */
81
+ rewarmed?: number;
82
+ revisitGaveUp?: number;
83
+ /** When the last batch was claimed (a batch never recorded stops keeping the run open BATCH_STALE_MS later). */
84
+ claimedAt?: string;
85
+ /** Content changed (see scheduleRewarm): start a new run ("edit") once this time (ISO) has passed. */
86
+ rewarmAfter?: string;
87
+ /** However long edits keep coming, the new run starts by then (ISO). */
88
+ rewarmBy?: string;
89
+ }
90
+
91
+ /** Most URLs one job warms (a guard for huge sitemaps). */
92
+ export const MAX_URLS = 5000;
93
+
94
+ /** Response header the pack middleware adds to the warmer's visit of a page rendered with a stopgap (that render isn't cached). */
95
+ export const STOPGAP_HEADER = "X-Coywolf-Stopgap";
96
+ /** Most pages waiting for a revisit (keeps the progress row small); longer URLs aren't listed. */
97
+ export const MAX_REVISIT = 200;
98
+ const MAX_REVISIT_URL = 500;
99
+ /** Wait before a revisit: the poster copy and its listing run in the background of the first visit. */
100
+ export const REVISIT_DELAY_MS = 20_000;
101
+ /** Revisits per page at most. */
102
+ export const MAX_REVISIT_ROUNDS = 2;
103
+ /** Revisits claimed this long ago and never recorded (the isolate stopped) no longer keep the run open. */
104
+ const REVISIT_STALE_MS = 2 * 60_000;
105
+ /** Same for a batch of the main queue: its pages count as failed and the run finishes. */
106
+ export const BATCH_STALE_MS = 2 * 60_000;
107
+ /** A run starts this long after the last content edit (edits in a burst, autosaves… make one run). */
108
+ export const REWARM_DELAY_MS = 60_000;
109
+ /** …and at most this long after the first edit of a burst, however long it goes on. */
110
+ export const REWARM_MAX_DELAY_MS = 5 * 60_000;
111
+ /** A new run starts this long after the last one finished (the daily refresh). */
112
+ export const DAILY_REFRESH_MS = 24 * 60 * 60_000;
113
+
114
+ /**
115
+ * When the daily refresh is due (ms): a day after a run finished, or a day after
116
+ * a failed one started (a sitemap error once shouldn't stop the daily refresh for
117
+ * good). Null while a run is going.
118
+ */
119
+ export function nextDailyAt(state: Pick<WarmState, "phase" | "finishedAt" | "startedAt">): number | null {
120
+ if (state.phase !== "done" && state.phase !== "failed") return null;
121
+ const from = state.phase === "done" ? state.finishedAt : (state.finishedAt ?? state.startedAt);
122
+ if (typeof from !== "string") return null;
123
+ const at = Date.parse(from);
124
+ return Number.isNaN(at) ? null : at + DAILY_REFRESH_MS;
125
+ }
126
+
127
+ export function newWarmState(reason: string, now = new Date()): WarmState {
128
+ return { generation: `${now.getTime()}-${Math.random().toString(36).slice(2, 8)}`, startedAt: now.toISOString(), phase: "collect", next: 0, total: 0, warmed: 0, failed: 0, reason };
129
+ }
130
+
131
+ /** <loc> values of a sitemap or sitemap index (XML entities decoded). */
132
+ export function sitemapLocs(xml: string): { isIndex: boolean; locs: string[] } {
133
+ const isIndex = /<sitemapindex[\s>]/i.test(xml);
134
+ const locs = [...xml.matchAll(/<loc>\s*([^<\s][^<]*?)\s*<\/loc>/gi)].map((m) =>
135
+ m[1].replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'"),
136
+ );
137
+ return { isIndex, locs };
138
+ }
139
+
140
+ /** Most links taken from the home page (its menus and section pages). */
141
+ export const MAX_HOME_LINKS = 200;
142
+
143
+ /**
144
+ * Same-origin page links in the home page: menus, footers and section pages
145
+ * (category archives such as /notes/) that sitemaps often leave out. Skips
146
+ * EmDash/plugin routes, feeds, files and URLs with a query string.
147
+ */
148
+ export function homeLinks(html: string, origin: string, max = MAX_HOME_LINKS): string[] {
149
+ const out: string[] = [];
150
+ for (const m of html.matchAll(/<a\b[^>]*?\shref\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) {
151
+ if (out.length >= max) break;
152
+ const raw = (m[1] ?? m[2] ?? "").replace(/&amp;/g, "&").trim();
153
+ if (!raw || raw.startsWith("#")) continue;
154
+ let url: URL;
155
+ try {
156
+ url = new URL(raw, origin);
157
+ } catch {
158
+ continue;
159
+ }
160
+ if (url.origin !== origin || url.search) continue;
161
+ const path = url.pathname;
162
+ if (/^\/_/.test(path) || /\/feed\/?$/.test(path) || /\.[a-z0-9]{2,5}$/i.test(path)) continue;
163
+ url.hash = "";
164
+ out.push(url.href);
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /**
170
+ * The URLs to warm, same-origin only, without duplicates, home page first,
171
+ * then in sitemap order (newest posts first on most sites), capped.
172
+ */
173
+ export function warmOrder(origin: string, urls: string[], max = MAX_URLS): string[] {
174
+ const seen = new Set<string>();
175
+ const out: string[] = [];
176
+ const add = (raw: string) => {
177
+ let url: URL;
178
+ try {
179
+ url = new URL(raw, origin);
180
+ } catch {
181
+ return;
182
+ }
183
+ if (url.origin !== origin) return;
184
+ url.hash = "";
185
+ const key = url.href;
186
+ if (seen.has(key)) return;
187
+ seen.add(key);
188
+ out.push(key);
189
+ };
190
+ add(`${origin}/`);
191
+ for (const u of urls) {
192
+ if (out.length >= max) break;
193
+ add(u);
194
+ }
195
+ return out.slice(0, max);
196
+ }
197
+
198
+ interface Fetcher {
199
+ fetch(request: Request): Promise<Response>;
200
+ }
201
+
202
+ /** Read the sitemap (following a sitemap index one level) into the warm order. */
203
+ export async function collectUrls(self: Fetcher, origin: string): Promise<string[]> {
204
+ const read = async (url: string) => {
205
+ const res = await self.fetch(new Request(url, { headers: { "User-Agent": WARMER_AGENT } }));
206
+ return res.ok ? res.text() : "";
207
+ };
208
+ // Pages the home page links to (menus, section pages) come right after it.
209
+ const linked = homeLinks(await read(`${origin}/`), origin);
210
+ const root = sitemapLocs(await read(`${origin}/sitemap.xml`));
211
+ let urls = root.locs;
212
+ if (root.isIndex) {
213
+ urls = [];
214
+ for (const child of root.locs.slice(0, 50)) {
215
+ if (new URL(child, origin).origin !== origin) continue;
216
+ urls.push(...sitemapLocs(await read(child)).locs);
217
+ if (urls.length >= MAX_URLS) break;
218
+ }
219
+ }
220
+ return warmOrder(origin, [...linked, ...urls]);
221
+ }
222
+
223
+ // ── Storage (option rows; works from middleware, routes and the minute task) ──
224
+ //
225
+ // The job's state is one small row (the progress). The URLs to visit are
226
+ // written once per run, in chunks of QUEUE_CHUNK, to rows of their own that
227
+ // never change; claiming a batch only moves the progress row's `next` index,
228
+ // so each claim rewrites a few hundred bytes instead of the whole queue.
229
+
230
+ /** Prefix of a run's URL rows: `<prefix><generation>:<chunk index>`. */
231
+ export const WARM_QUEUE_PREFIX = "plugin:coywolf-pack:pageCache:warmQueue:";
232
+ /** URLs per queue row (about 50 KB of JSON at most). */
233
+ export const QUEUE_CHUNK = 500;
234
+
235
+ const queueRow = (generation: string, chunk: number) => `${WARM_QUEUE_PREFIX}${generation}:${chunk}`;
236
+
237
+ /** A state row from before the queue moved to rows of its own (`queue` held the URLs left). */
238
+ type LegacyState = Omit<WarmState, "next"> & { next?: number; queue?: string[] };
239
+
240
+ function parseState(raw: string): WarmState | null {
241
+ try {
242
+ const state = JSON.parse(raw) as LegacyState;
243
+ // A `queue` array means the older format, even with a numeric `next`: during a deploy an
244
+ // old-version isolate can finish a new run's collect step by spreading the new state and
245
+ // adding `queue` (no URL rows). claimWork migrates it (migrateLegacy).
246
+ if (typeof state.next === "number" && !Array.isArray(state.queue)) return state as WarmState;
247
+ const left = Array.isArray(state.queue) ? state.queue.length : 0;
248
+ const { queue: _queue, ...rest } = state;
249
+ return { ...rest, next: Math.max(0, (state.total ?? 0) - left), legacy: true } as WarmState;
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Turn a state row in the older format into the current one, so the run goes
257
+ * on: a pending collect step stays pending; a warm phase's remaining `queue`
258
+ * is written to URL rows under a new generation (a stale cleanup of the old
259
+ * one can't delete them) and counted from zero. Finished runs are left as
260
+ * they are. True when the row now holds a current-format state.
261
+ */
262
+ async function migrateLegacy(db: D1Database, raw: string, now: number): Promise<boolean> {
263
+ let legacy: LegacyState;
264
+ try {
265
+ legacy = JSON.parse(raw) as LegacyState;
266
+ } catch {
267
+ return false;
268
+ }
269
+ const { queue, ...rest } = legacy;
270
+ if (rest.phase === "collect") return swap(db, raw, { ...rest, next: 0, total: 0 });
271
+ if (rest.phase !== "warm") return false;
272
+ const urls = Array.isArray(queue) ? queue.filter((u): u is string => typeof u === "string") : [];
273
+ if (!urls.length) {
274
+ return swap(db, raw, { ...rest, phase: "done", next: 0, total: 0, finishedAt: rest.finishedAt ?? new Date(now).toISOString() });
275
+ }
276
+ const generation = newWarmState(rest.reason, new Date(now)).generation;
277
+ await writeWarmQueue(db, generation, urls);
278
+ const migrated: WarmState = { ...rest, generation, next: 0, total: urls.length, warmed: 0, failed: 0 };
279
+ if (await swap(db, raw, migrated)) return true;
280
+ await deleteQueue(db, { run: generation }).catch(() => undefined);
281
+ return false;
282
+ }
283
+
284
+ export async function readWarmState(db: D1Database): Promise<WarmState | null> {
285
+ const raw = await readRaw(db);
286
+ return raw ? parseState(raw) : null;
287
+ }
288
+
289
+ /** URLs of a run not yet handed to a batch. */
290
+ export function remainingUrls(state: WarmState): number {
291
+ return state.phase === "warm" ? Math.max(0, state.total - state.next) : 0;
292
+ }
293
+
294
+ /** Pages of a run waiting for (or in) a revisit. */
295
+ export function pendingRevisits(state: WarmState): number {
296
+ return state.phase === "warm" ? (state.revisit?.length ?? 0) + (state.revisitBusy ?? 0) : 0;
297
+ }
298
+
299
+ /** Add pages to visit again (deduplicated, capped; ones that don't fit are given up on), and push the wait back. */
300
+ function addRevisits(state: WarmState, entries: Array<[string, number]>, now: number): WarmState {
301
+ if (!entries.length) return state;
302
+ const list = [...(state.revisit ?? [])];
303
+ const listed = new Set(list.map(([url]) => url));
304
+ let gaveUp = state.revisitGaveUp ?? 0;
305
+ for (const [url, round] of entries) {
306
+ if (listed.has(url)) continue;
307
+ if (list.length >= MAX_REVISIT || url.length > MAX_REVISIT_URL) {
308
+ gaveUp++;
309
+ continue;
310
+ }
311
+ listed.add(url);
312
+ list.push([url, round]);
313
+ }
314
+ return { ...state, revisit: list.length ? list : undefined, revisitAfter: new Date(now + REVISIT_DELAY_MS).toISOString(), revisitGaveUp: gaveUp || undefined };
315
+ }
316
+
317
+ /** Every URL visited and recorded, and no revisit waiting or (recently) under way. */
318
+ function runFinished(state: WarmState, now: number): boolean {
319
+ if (state.phase !== "warm" || state.next < state.total || state.warmed + state.failed < state.total || state.revisit?.length) return false;
320
+ return !state.revisitBusy || !state.revisitBusyAt || now - Date.parse(state.revisitBusyAt) >= REVISIT_STALE_MS;
321
+ }
322
+
323
+ function finished(state: WarmState, now: number): WarmState {
324
+ const lost = state.revisitBusy ?? 0;
325
+ return {
326
+ ...state,
327
+ phase: "done",
328
+ finishedAt: new Date(now).toISOString(),
329
+ revisit: undefined,
330
+ revisitAfter: undefined,
331
+ revisitBusy: undefined,
332
+ revisitBusyAt: undefined,
333
+ revisitGaveUp: (state.revisitGaveUp ?? 0) + lost || undefined,
334
+ };
335
+ }
336
+
337
+ export async function writeWarmState(db: D1Database, state: WarmState): Promise<void> {
338
+ await db
339
+ .prepare("INSERT INTO options (name, value) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET value = excluded.value")
340
+ .bind(WARM_STATE_OPTION, JSON.stringify(state))
341
+ .run();
342
+ }
343
+
344
+ /**
345
+ * Names starting with `prefix` are those in [prefix, prefixEnd): the same
346
+ * string with its last character moved one up (prefixes here end in ":", so
347
+ * the end is ";"). Range comparisons rather than LIKE: D1 refuses LIKE
348
+ * patterns over 50 bytes ("LIKE or GLOB pattern too complex"), and a queue
349
+ * row's name is longer than that, so the LIKE deletes this replaced never
350
+ * deleted anything. A range also uses the options table's primary key.
351
+ */
352
+ const prefixEnd = (prefix: string) => prefix.slice(0, -1) + String.fromCharCode(prefix.charCodeAt(prefix.length - 1) + 1);
353
+
354
+ /**
355
+ * Delete warm queue rows: `{ run }` deletes one run's rows; `{ upTo }` deletes
356
+ * that run's rows and every older run's (generations start with their start
357
+ * time in milliseconds, 13 digits, so they sort by age), leaving any newer
358
+ * run's alone; `{ keep }` deletes every run's rows but that run's.
359
+ */
360
+ async function deleteQueue(db: D1Database, which: { run: string } | { upTo: string } | { keep: string }): Promise<void> {
361
+ if ("run" in which) {
362
+ const prefix = `${WARM_QUEUE_PREFIX}${which.run}:`;
363
+ await db.prepare("DELETE FROM options WHERE name >= ? AND name < ?").bind(prefix, prefixEnd(prefix)).run();
364
+ return;
365
+ }
366
+ if ("upTo" in which) {
367
+ await db.prepare("DELETE FROM options WHERE name >= ? AND name < ?").bind(WARM_QUEUE_PREFIX, prefixEnd(`${WARM_QUEUE_PREFIX}${which.upTo}:`)).run();
368
+ return;
369
+ }
370
+ const keep = `${WARM_QUEUE_PREFIX}${which.keep}:`;
371
+ await db
372
+ .prepare("DELETE FROM options WHERE name >= ? AND name < ? AND NOT (name >= ? AND name < ?)")
373
+ .bind(WARM_QUEUE_PREFIX, prefixEnd(WARM_QUEUE_PREFIX), keep, prefixEnd(keep))
374
+ .run();
375
+ }
376
+
377
+ /** Write a run's URLs to its queue rows (immutable for the run). */
378
+ export async function writeWarmQueue(db: D1Database, generation: string, urls: string[]): Promise<void> {
379
+ const statements = [];
380
+ for (let i = 0; i * QUEUE_CHUNK < urls.length; i++) {
381
+ statements.push(
382
+ db
383
+ .prepare("INSERT INTO options (name, value) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET value = excluded.value")
384
+ .bind(queueRow(generation, i), JSON.stringify(urls.slice(i * QUEUE_CHUNK, (i + 1) * QUEUE_CHUNK))),
385
+ );
386
+ }
387
+ if (statements.length) await db.batch(statements);
388
+ }
389
+
390
+ /** Queue rows read in this isolate, for the current run only (they never change within a run). */
391
+ const chunkCache = new Map<string, string[]>();
392
+ let chunkGeneration = "";
393
+
394
+ /** URLs `start` (inclusive) to `end` (exclusive) of a run's queue; fewer if its rows are gone (a newer run cleaned them up). */
395
+ async function queueSlice(db: D1Database, generation: string, start: number, end: number): Promise<string[]> {
396
+ if (chunkGeneration !== generation) {
397
+ chunkCache.clear();
398
+ chunkGeneration = generation;
399
+ }
400
+ const first = Math.floor(start / QUEUE_CHUNK);
401
+ const last = Math.floor((end - 1) / QUEUE_CHUNK);
402
+ const missing: string[] = [];
403
+ for (let i = first; i <= last; i++) if (!chunkCache.has(queueRow(generation, i))) missing.push(queueRow(generation, i));
404
+ if (missing.length) {
405
+ const { results } = await db
406
+ .prepare(`SELECT name, value FROM options WHERE name IN (${missing.map(() => "?").join(",")})`)
407
+ .bind(...missing)
408
+ .all<{ name: string; value: string }>();
409
+ for (const row of results ?? []) {
410
+ try {
411
+ chunkCache.set(row.name, JSON.parse(row.value) as string[]);
412
+ } catch {
413
+ // An unreadable row: its URLs are skipped.
414
+ }
415
+ }
416
+ }
417
+ const out: string[] = [];
418
+ for (let i = first; i <= last; i++) {
419
+ const urls = chunkCache.get(queueRow(generation, i));
420
+ if (!urls) continue;
421
+ const from = Math.max(start - i * QUEUE_CHUNK, 0);
422
+ const to = Math.min(end - i * QUEUE_CHUNK, QUEUE_CHUNK);
423
+ out.push(...urls.slice(from, to));
424
+ }
425
+ return out;
426
+ }
427
+
428
+ /** Queue a fresh warm-up (replacing any running one, and clearing older runs' URL rows). Page traffic does the work. */
429
+ export async function startWarm(db: D1Database, reason: string): Promise<WarmState> {
430
+ const state = newWarmState(reason);
431
+ await writeWarmState(db, state);
432
+ await deleteQueue(db, { keep: state.generation }).catch((error) => console.error("coywolf-pack: could not clear old warm queues", error));
433
+ return state;
434
+ }
435
+
436
+ /**
437
+ * Content changed (EmDash cleared cached pages by tag): warm again
438
+ * REWARM_DELAY_MS after the last such change, REWARM_MAX_DELAY_MS after the
439
+ * first at most. Only marks the progress row (`rewarmAfter`); the run starts
440
+ * from the traffic-driven warm steps once it's due (claimWork), so a burst of
441
+ * edits makes one run. A run in progress goes on meanwhile and is replaced
442
+ * when the new one starts. Returns the row as written (null if it couldn't be).
443
+ */
444
+ export async function scheduleRewarm(db: D1Database, now = Date.now()): Promise<WarmState | null> {
445
+ for (let attempt = 0; attempt < 5; attempt++) {
446
+ const raw = await readRaw(db);
447
+ if (!raw) {
448
+ // No run yet: a finished, empty one carrying the schedule.
449
+ const state: WarmState = {
450
+ ...newWarmState("edit", new Date(now)),
451
+ phase: "done",
452
+ rewarmAfter: new Date(now + REWARM_DELAY_MS).toISOString(),
453
+ rewarmBy: new Date(now + REWARM_MAX_DELAY_MS).toISOString(),
454
+ };
455
+ const result = await db
456
+ .prepare("INSERT INTO options (name, value) VALUES (?, ?) ON CONFLICT(name) DO NOTHING")
457
+ .bind(WARM_STATE_OPTION, JSON.stringify(state))
458
+ .run();
459
+ if ((result.meta?.changes ?? 0) > 0) return state;
460
+ continue;
461
+ }
462
+ const state = parseState(raw);
463
+ if (!state || state.legacy) return null;
464
+ const by = state.rewarmBy ? Date.parse(state.rewarmBy) : now + REWARM_MAX_DELAY_MS;
465
+ const next: WarmState = { ...state, rewarmAfter: new Date(Math.min(now + REWARM_DELAY_MS, by)).toISOString(), rewarmBy: new Date(by).toISOString() };
466
+ if (await swap(db, raw, next)) return next;
467
+ }
468
+ return null;
469
+ }
470
+
471
+ /**
472
+ * Whether a progress row (as last read with the feature switches) may have
473
+ * work for a warm step: a run under way, a rewarm or the daily refresh
474
+ * that's due, or no row yet (the first run starts). True when unknown
475
+ * (undefined) or unreadable. Without the daily refresh (`daily` false), no
476
+ * row means nothing to do and no daily run is due.
477
+ */
478
+ export function warmMayHaveWork(raw: string | null | undefined, now = Date.now(), daily = true): boolean {
479
+ if (raw === undefined) return true;
480
+ if (raw === null) return daily;
481
+ try {
482
+ const state = JSON.parse(raw) as WarmState;
483
+ if (state.phase !== "done" && state.phase !== "failed") return true;
484
+ const dailyAt = daily ? nextDailyAt(state) : null;
485
+ if (dailyAt !== null && now >= dailyAt) return true;
486
+ return typeof state.rewarmAfter === "string" && now >= Date.parse(state.rewarmAfter);
487
+ } catch {
488
+ return true;
489
+ }
490
+ }
491
+
492
+ // ── Claims (compare-and-swap on the progress row) ──
493
+
494
+ async function readRaw(db: D1Database): Promise<string | null> {
495
+ const row = await db.prepare("SELECT value FROM options WHERE name = ?").bind(WARM_STATE_OPTION).first<{ value: string }>();
496
+ return row?.value ?? null;
497
+ }
498
+
499
+ /** Replace the row only if nobody changed it since `before` was read. */
500
+ async function swap(db: D1Database, before: string, after: WarmState): Promise<boolean> {
501
+ const result = await db.prepare("UPDATE options SET value = ? WHERE name = ? AND value = ?").bind(JSON.stringify(after), WARM_STATE_OPTION, before).run();
502
+ return (result.meta?.changes ?? 0) > 0;
503
+ }
504
+
505
+ export type Claim =
506
+ | { kind: "collect"; state: WarmState }
507
+ | { kind: "warm"; state: WarmState; urls: string[] }
508
+ | { kind: "revisit"; state: WarmState; pages: Array<[string, number]> }
509
+ | { kind: "wait"; state: WarmState; until: string }
510
+ | null;
511
+
512
+ const COLLECT_STALE_MS = 2 * 60_000;
513
+
514
+ /** Take the next piece of work, if any: reading the sitemap, or a batch of URLs. `daily`: the daily refresh is on. */
515
+ export async function claimWork(db: D1Database, batchSize: number, now = Date.now(), daily = true): Promise<Claim> {
516
+ let raw = await readRaw(db);
517
+ if (!raw) {
518
+ if (!daily) return null;
519
+ // Warming is on but nothing ran yet: start the first run (one isolate's insert wins).
520
+ const fresh = newWarmState("daily", new Date(now));
521
+ const inserted = await db.prepare("INSERT INTO options (name, value) VALUES (?, ?) ON CONFLICT(name) DO NOTHING").bind(WARM_STATE_OPTION, JSON.stringify(fresh)).run();
522
+ if (!((inserted.meta?.changes ?? 0) > 0)) return null;
523
+ raw = JSON.stringify(fresh);
524
+ }
525
+ let state = parseState(raw);
526
+ if (state?.legacy) {
527
+ // Written by an isolate still running the older version (during a deploy): convert it, then claim.
528
+ if (!(await migrateLegacy(db, raw, now))) return null;
529
+ raw = await readRaw(db);
530
+ state = raw ? parseState(raw) : null;
531
+ }
532
+ if (!raw || !state || state.legacy) return null;
533
+ const dailyAt = daily ? nextDailyAt(state) : null;
534
+ const due = state.rewarmAfter && now >= Date.parse(state.rewarmAfter) ? "edit" : dailyAt !== null && now >= dailyAt ? "daily" : null;
535
+ if (due) {
536
+ // Content changed a minute ago: a new run (it supersedes one in progress; pages that
537
+ // one already warmed and nothing invalidated since are cache hits, so they cost little).
538
+ // Or a day passed since the last run: pages Cloudflare dropped from its cache are built again.
539
+ const fresh = newWarmState(due, new Date(now));
540
+ if (!(await swap(db, raw, fresh))) return null;
541
+ await deleteQueue(db, { keep: fresh.generation }).catch((error) => console.error("coywolf-pack: could not clear old warm queues", error));
542
+ raw = JSON.stringify(fresh);
543
+ state = fresh;
544
+ }
545
+ if (state.phase === "collect") {
546
+ if (state.collectingAt && now - Date.parse(state.collectingAt) < COLLECT_STALE_MS) return null;
547
+ const next = { ...state, collectingAt: new Date(now).toISOString() };
548
+ return (await swap(db, raw, next)) ? { kind: "collect", state: next } : null;
549
+ }
550
+ if (state.phase !== "warm") return null;
551
+ if (state.next >= state.total) return claimRevisit(db, raw, state, batchSize, now);
552
+ const end = Math.min(state.next + batchSize, state.total);
553
+ const next = { ...state, next: end, claimedAt: new Date(now).toISOString() };
554
+ if (!(await swap(db, raw, next))) return null;
555
+ const urls = await queueSlice(db, state.generation, state.next, end);
556
+ // URLs whose row is gone count as failed, so the run still finishes.
557
+ if (urls.length < end - state.next) await recordBatch(db, state.generation, 0, end - state.next - urls.length, now);
558
+ return { kind: "warm", state: next, urls };
559
+ }
560
+
561
+ /**
562
+ * Once the queue is handed out: pages to visit again, when their wait is over
563
+ * ("wait" until then, so steps don't spin). Also finishes a run kept open only
564
+ * by revisits that were claimed and never recorded.
565
+ */
566
+ async function claimRevisit(db: D1Database, raw: string, state: WarmState, batchSize: number, now: number): Promise<Claim> {
567
+ const list = state.revisit ?? [];
568
+ if (!list.length) {
569
+ // A batch claimed long ago and never recorded (its isolate stopped, or its record lost every
570
+ // retry): its pages count as failed, so the run finishes and its URL rows go.
571
+ const missing = state.total - state.warmed - state.failed;
572
+ const lost = missing > 0 && now - Date.parse(state.claimedAt ?? state.startedAt) >= BATCH_STALE_MS ? missing : 0;
573
+ const settled = lost ? { ...state, failed: state.failed + lost } : state;
574
+ if ((lost || state.revisitBusy) && runFinished(settled, now) && (await swap(db, raw, finished(settled, now)))) {
575
+ await deleteQueue(db, { upTo: state.generation }).catch((error) => console.error("coywolf-pack: could not clear warm queues", error));
576
+ }
577
+ return null;
578
+ }
579
+ if (state.revisitAfter && now < Date.parse(state.revisitAfter)) return { kind: "wait", state, until: state.revisitAfter };
580
+ const pages = list.slice(0, batchSize);
581
+ const rest = list.slice(batchSize);
582
+ const next: WarmState = { ...state, revisit: rest.length ? rest : undefined, revisitBusy: (state.revisitBusy ?? 0) + pages.length, revisitBusyAt: new Date(now).toISOString() };
583
+ return (await swap(db, raw, next)) ? { kind: "revisit", state: next, pages } : null;
584
+ }
585
+
586
+ /** Store the sitemap's URLs for the run that claimed the collect step. */
587
+ export async function finishCollect(db: D1Database, generation: string, urls: string[], origin: string): Promise<void> {
588
+ if (urls.length) await writeWarmQueue(db, generation, urls);
589
+ for (let attempt = 0; attempt < 3; attempt++) {
590
+ const raw = await readRaw(db);
591
+ const state = raw ? parseState(raw) : null;
592
+ if (!raw || !state || state.generation !== generation || state.phase !== "collect") break;
593
+ const next: WarmState = urls.length
594
+ ? { ...state, phase: "warm", next: 0, total: urls.length, collectingAt: undefined }
595
+ : { ...state, phase: "failed", error: `No pages found in ${origin}/sitemap.xml.`, collectingAt: undefined };
596
+ if (await swap(db, raw, next)) return;
597
+ }
598
+ // Superseded by a newer run (or never stored): don't leave its URLs behind.
599
+ if (urls.length) await deleteQueue(db, { run: generation }).catch(() => undefined);
600
+ }
601
+
602
+ /**
603
+ * Count a finished batch, listing its `stopgap` pages for a revisit; marks the
604
+ * run done (and drops its URL rows) when nothing is left.
605
+ */
606
+ export async function recordBatch(db: D1Database, generation: string, warmed: number, failed: number, now = Date.now(), stopgap: string[] = []): Promise<void> {
607
+ await recordProgress(db, generation, now, (state) =>
608
+ // A run already finished (a lost batch's isolate recording late) keeps its counts.
609
+ state.phase === "warm"
610
+ ? addRevisits({ ...state, warmed: state.warmed + warmed, failed: state.failed + failed }, stopgap.map((url): [string, number] => [url, 0]), now)
611
+ : null,
612
+ );
613
+ }
614
+
615
+ export interface Visited {
616
+ url: string;
617
+ ok: boolean;
618
+ /** The page was rendered with a stopgap (STOPGAP_HEADER), so it wasn't cached. */
619
+ stopgap: boolean;
620
+ }
621
+
622
+ /**
623
+ * Count a finished revisit batch: pages still on a stopgap go back on the list
624
+ * until they've had MAX_REVISIT_ROUNDS revisits, then are given up on.
625
+ */
626
+ export async function recordRevisit(db: D1Database, generation: string, results: Array<Visited & { round: number }>, now = Date.now()): Promise<void> {
627
+ await recordProgress(db, generation, now, (state) => {
628
+ if (state.phase !== "warm") return null;
629
+ const again = results.filter((r) => r.stopgap && r.round + 1 < MAX_REVISIT_ROUNDS).map((r): [string, number] => [r.url, r.round + 1]);
630
+ const rewarmed = results.filter((r) => r.ok && !r.stopgap).length;
631
+ const gaveUp = results.length - rewarmed - again.length;
632
+ const busy = Math.max(0, (state.revisitBusy ?? 0) - results.length);
633
+ return addRevisits(
634
+ {
635
+ ...state,
636
+ revisitBusy: busy || undefined,
637
+ revisitBusyAt: busy ? state.revisitBusyAt : undefined,
638
+ rewarmed: (state.rewarmed ?? 0) + rewarmed || undefined,
639
+ revisitGaveUp: (state.revisitGaveUp ?? 0) + gaveUp || undefined,
640
+ },
641
+ again,
642
+ now,
643
+ );
644
+ });
645
+ }
646
+
647
+ /** Apply `update` to the run's progress row (compare-and-swap, retried), then finish the run if nothing is left. */
648
+ async function recordProgress(db: D1Database, generation: string, now: number, update: (state: WarmState) => WarmState | null): Promise<void> {
649
+ for (let attempt = 0; attempt < 5; attempt++) {
650
+ const raw = await readRaw(db);
651
+ if (!raw) return;
652
+ const state = parseState(raw);
653
+ if (!state || state.legacy || state.generation !== generation) return;
654
+ let next = update(state);
655
+ if (!next) return;
656
+ const done = runFinished(next, now);
657
+ if (done) next = finished(next, now);
658
+ if (await swap(db, raw, next)) {
659
+ if (done) await deleteQueue(db, { upTo: generation }).catch((error) => console.error("coywolf-pack: could not clear warm queues", error));
660
+ return;
661
+ }
662
+ }
663
+ }
664
+
665
+ /** Visit URLs through the Worker's own service binding (which shares its cache). */
666
+ export async function visit(self: Fetcher, urls: string[]): Promise<{ warmed: number; failed: number; results: Visited[] }> {
667
+ const results = await Promise.all(
668
+ urls.map(async (url): Promise<Visited> => {
669
+ try {
670
+ const res = await self.fetch(new Request(url, { headers: { "User-Agent": WARMER_AGENT } }));
671
+ await res.arrayBuffer();
672
+ return { url, ok: res.ok || (res.status >= 300 && res.status < 400), stopgap: res.headers.get(STOPGAP_HEADER) === "1" };
673
+ } catch {
674
+ return { url, ok: false, stopgap: false };
675
+ }
676
+ }),
677
+ );
678
+ return { warmed: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results };
679
+ }
680
+
681
+ const sameOrigin = (url: string, origin: string) => {
682
+ try {
683
+ return new URL(url).origin === origin;
684
+ } catch {
685
+ return false;
686
+ }
687
+ };
688
+
689
+ export const WARMER_AGENT = "CoywolfPack-CacheWarmer";
690
+
691
+ /**
692
+ * One step of warming, run in the background of a page request: claim a piece
693
+ * of work and do it, within a time budget. Returns what it did (for tests/logs).
694
+ */
695
+ export async function warmStep(db: D1Database, self: Fetcher, origin: string, options: { budgetMs: number; batchSize: number; now?: () => number; daily?: boolean }): Promise<string> {
696
+ const now = options.now ?? Date.now;
697
+ const deadline = now() + options.budgetMs;
698
+ let did = "idle";
699
+ while (now() < deadline) {
700
+ const claim = await claimWork(db, options.batchSize, now(), options.daily ?? true);
701
+ if (!claim) return did;
702
+ if (claim.kind === "collect") {
703
+ const urls = await collectUrls(self, origin).catch(() => []);
704
+ await finishCollect(db, claim.state.generation, urls, origin);
705
+ did = "collected";
706
+ continue;
707
+ }
708
+ // Revisits wait for the posters' copies: later steps (driven by traffic) check again.
709
+ if (claim.kind === "wait") return did === "idle" ? "waiting" : did;
710
+ if (claim.kind === "revisit") {
711
+ const { results } = await visit(self, claim.pages.map(([url]) => url));
712
+ await recordRevisit(db, claim.state.generation, results.map((r, i) => ({ ...r, round: claim.pages[i][1] })), now());
713
+ did = "revisited";
714
+ continue;
715
+ }
716
+ const { warmed, failed, results } = await visit(self, claim.urls);
717
+ const stopgap = results.filter((r) => r.ok && r.stopgap && sameOrigin(r.url, origin)).map((r) => r.url);
718
+ await recordBatch(db, claim.state.generation, warmed, failed, now(), stopgap);
719
+ did = "warmed";
720
+ }
721
+ return did;
722
+ }