@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,804 @@
1
+ /**
2
+ * Clean Image URLs module: resized copies of media-library images at short,
3
+ * cacheable URLs, and cleanImageUrl() / originalImageUrl() for themes. Off by
4
+ * default.
5
+ *
6
+ * Two modes:
7
+ * - Media host (recommended): an R2 custom domain on the media bucket with
8
+ * Cloudflare Image Transformations and two URL-rewrite rules
9
+ * (https://media.example.com/s/<w>x<h>/<file>). Images never touch the
10
+ * Worker. Set with `images: { cdn }` or on the Clean Image URLs page.
11
+ * - Worker route: /media/<id>-<w>x<h>.<format>, read from the media bucket,
12
+ * resized by the Images binding, cached at the edge for a year. With a
13
+ * media host set, these old URLs 301 to the media host.
14
+ *
15
+ * Stored image sizes (with a media host): WebP and AVIF copies of each image
16
+ * at fixed widths, made once when the image is uploaded (or, when the site
17
+ * opts in, by the backfill for existing images), plus the site's crops, made
18
+ * when a page first shows them; all served as plain files. responsiveImage()
19
+ * and croppedImage() give themes their srcsets; until an image's copies exist
20
+ * they return null and themes use cleanImageUrl() (/s/). See variants.ts.
21
+ */
22
+ import type { PluginContext } from "emdash";
23
+
24
+ import { batchedAll } from "../core/d1-batch.js";
25
+ import { isCurrent, registerFeatures, registerSiteOption, rememberSiteOption, settingsEpoch, siteFeatureOn, siteOption, siteSetting } from "../core/features.js";
26
+ import { purgePageCache } from "../pageCache/lib.js";
27
+ import { WARMER_AGENT, WARM_SETTING, WARM_STATE_OPTION, startWarm } from "../pageCache/warm.js";
28
+ import type { PackMiddleware, PackModule } from "../core/module.js";
29
+ import { CLOUDFLARE_API_HOST } from "./cloudflare.js";
30
+ import { afterResponse, workerEnv } from "../shared.js";
31
+ import { FORMATS, IMAGE_PATH, cdnOriginalUrl, cdnRedirectUrl, cleanImagePath, imageCdn, mediaFile, parseImagePath, parseCdnUrl, setImageCdn, snappedImagePath } from "./lib.js";
32
+ import { imagesModule } from "./module.js";
33
+ import { markPendingMedia } from "./pending.js";
34
+ import { configureMediaHostDatabase, refreshMediaHost, variantsBulkOn } from "./settings.js";
35
+ import { type ImagesBinding, type VariantBucket, type VariantDoc, type Crop, VARIANTS_VERSION, cropSrcsets, cropState, currentDoc, eligible, generateVariants, parseDoc, setImageCrops, variantSrcsets } from "./variants.js";
36
+ import {
37
+ type BackfillDeps,
38
+ type Db,
39
+ VARIANTS_COLLECTION,
40
+ VARIANTS_STATE_OPTION,
41
+ type VariantsState,
42
+ acquireLease,
43
+ addCrops,
44
+ backfillMayHaveWork,
45
+ backfillStep,
46
+ forgetMedia,
47
+ mediaRow,
48
+ pausedAt,
49
+ processMedia,
50
+ readVariantsState,
51
+ releaseLease,
52
+ startVariantsRun,
53
+ } from "./variants-store.js";
54
+ import { upgradeListedPosters } from "../videos/poster.js";
55
+
56
+ export const F = { main: "images" } as const;
57
+ export const FEATURES = [
58
+ {
59
+ id: F.main,
60
+ label: "Clean image URLs",
61
+ description:
62
+ "Resized images at short addresses instead of /_image?href=…: on your own media host (media.example.com/s/600x315/<file>, set up on the Clean Image URLs page) or at /media/<id>-600x315.webp. Themes use cleanImageUrl() to build them.",
63
+ default: false,
64
+ },
65
+ ];
66
+ registerFeatures(FEATURES);
67
+
68
+ export interface ImagesOptions {
69
+ /** R2 binding of the media library. Default "MEDIA". */
70
+ bucket?: string;
71
+ /** Images binding. Default "IMAGES" (the Astro Cloudflare adapter's). */
72
+ images?: string;
73
+ /**
74
+ * Media host: an https origin serving the media bucket with the /s/ resize
75
+ * rules, e.g. "https://media.coywolf.com" (see docs/images.md).
76
+ * A host saved on the Clean Image URLs page takes precedence.
77
+ */
78
+ cdn?: string;
79
+ /** D1 binding of the site database (for the saved media host). Default "DB". */
80
+ database?: string;
81
+ /**
82
+ * Cropped sizes the theme shows, as [width, height] pairs (e.g. list
83
+ * thumbnails and avatars at 1x and 2x). A crop is stored as WebP and AVIF
84
+ * the first time a page shows it for an image (when the image is at least
85
+ * that large); see croppedImage(). Crops not listed here always use /s/.
86
+ */
87
+ crops?: Array<Crop>;
88
+ }
89
+
90
+ let config: { bucket: string; images: string; database: string } = { bucket: "MEDIA", images: "IMAGES", database: "DB" };
91
+
92
+ /** Scheduled job that restarts the backfill (a finished run a day later, a stalled or paused chain). */
93
+ export const VARIANTS_TASK = "images-variants";
94
+
95
+ // The backfill's progress row is read with the feature switches, so page requests can tell
96
+ // whether a chain should start without a query of their own.
97
+ registerSiteOption(VARIANTS_STATE_OPTION);
98
+
99
+ /**
100
+ * The backfill runs as a chain of steps, each in a Worker invocation of its own
101
+ * (so each gets its own subrequest budget), back to back until the run is done:
102
+ *
103
+ * - A trigger (turning sizes for existing images on, "Make missing sizes now",
104
+ * a WordPress media import, the hourly task, or a page request that sees a
105
+ * run with no chain) takes the run's lease (a compare-and-swap on its
106
+ * progress row, so there's one chain at a time) and hands the first step to
107
+ * the Worker itself: a POST to BACKFILL_PATH through the SELF service
108
+ * binding, carrying the lease's token. Like cache warming
109
+ * (src/pageCache/warm.ts), no work happens in the Cron Trigger itself: it
110
+ * runs wherever Cloudflare has room, often far from the database.
111
+ * - A step renews the lease, makes sizes for up to STEP_BUDGET.maxImages
112
+ * images (claiming no batch after STEP_BUDGET.budgetMs), then, while work
113
+ * is left, renews the lease again and posts the next step the same way.
114
+ * - The endpoint only works for the chain holding the lease: a request
115
+ * without its current token (a random UUID kept in the database, never sent
116
+ * to browsers) gets a 403 and starts nothing, so the public can't trigger
117
+ * transformations.
118
+ * - Cloudflare allows 32 Worker invocations per incoming request (each
119
+ * service-binding call counts), so a chain stops after MAX_HOPS steps and
120
+ * lets go of the lease; the next page request (or the hourly task) starts a
121
+ * new one. A chain that dies (an error, a deploy) leaves a lease that runs
122
+ * out after LEASE_MS, and the next trigger takes over the same way.
123
+ * - Turning sizes for existing images off stops it (keepGoing); Cloudflare
124
+ * Images refusing work pauses the run for PAUSE_MS (see backfillStep): page
125
+ * requests and the hourly task resume it after that.
126
+ *
127
+ * Budget per step: an image takes at most ~22 subrequests (a list, the
128
+ * original, and for up to 5 widths a WebP and an AVIF transform and two puts)
129
+ * plus its record, so 25 images are ≤ 575; the claims and counts (5 batches ×
130
+ * ~5 statements), the lease (~4) and the next step's POST add ~30: ~600 of the
131
+ * 1,000 a Worker invocation may make. Images are made 2 at a time, each
132
+ * taking ~2–4 s, so a step ends ~15–25 s after it started, inside the 30
133
+ * seconds waitUntil allows after its 202 response; CPU time stays small (the
134
+ * encoding happens in the Images binding). Throughput: ~15–25 images per
135
+ * ~20-second step, back to back, ≈ 2,000–4,000 images an hour (less with
136
+ * large originals), against the ~15 an hour of the hourly job alone.
137
+ */
138
+ export const STEP_BUDGET = { budgetMs: 15_000, maxImages: 25 };
139
+ /** Internal endpoint of a chain's next step (POST, through the SELF service binding). */
140
+ export const BACKFILL_PATH = "/_coywolf-pack/images/backfill";
141
+ /** Steps per chain: Cloudflare allows 32 Worker invocations per incoming request, each service-binding call counting. */
142
+ export const MAX_HOPS = 24;
143
+ const TOKEN_HEADER = "X-Coywolf-Backfill";
144
+ const STEP_HEADER = "X-Coywolf-Backfill-Step";
145
+
146
+ interface Fetcher {
147
+ fetch(request: Request): Promise<Response>;
148
+ }
149
+
150
+ /** The SELF service binding (the site's own Worker), if bound. */
151
+ async function selfBinding(): Promise<Fetcher | undefined> {
152
+ const env: Record<string, unknown> = await workerEnv().catch(() => ({}));
153
+ const self = env.SELF as Fetcher | undefined;
154
+ return typeof self?.fetch === "function" ? self : undefined;
155
+ }
156
+
157
+ /** Keep this isolate's copy of the progress row current (page requests check it before starting a chain). */
158
+ function rememberState(state: VariantsState | null): void {
159
+ if (state) rememberSiteOption(VARIANTS_STATE_OPTION, JSON.stringify(state));
160
+ }
161
+
162
+ interface Chain {
163
+ token: string;
164
+ /** 0 for the trigger, 1… for the steps posted to BACKFILL_PATH. */
165
+ step: number;
166
+ /** The site's origin, for posting the next step; without it (or SELF), the work is done right here, one step only. */
167
+ origin?: string;
168
+ self?: Fetcher;
169
+ }
170
+
171
+ /**
172
+ * Renew the lease and post the chain's next step through SELF. The renewed
173
+ * state when the step was accepted (202); null when the run is no longer this
174
+ * chain's or the post failed (the caller then works or lets go).
175
+ */
176
+ async function postNextStep(deps: BackfillDeps, chain: Chain & { self: Fetcher; origin: string }): Promise<VariantsState | null> {
177
+ const renewed = await acquireLease(deps.db, chain.token, { follow: true });
178
+ if (!renewed) return null;
179
+ try {
180
+ const response = await chain.self.fetch(
181
+ new Request(`${chain.origin}${BACKFILL_PATH}`, { method: "POST", headers: { [TOKEN_HEADER]: chain.token, [STEP_HEADER]: String(chain.step + 1) } }),
182
+ );
183
+ await response.body?.cancel();
184
+ if (response.status === 202) return renewed;
185
+ console.error(`coywolf-pack images: the next backfill step answered ${response.status}`);
186
+ } catch (error) {
187
+ console.error("coywolf-pack images: couldn't start the next backfill step", error);
188
+ }
189
+ return null;
190
+ }
191
+
192
+ /**
193
+ * One link of the chain: take the lease; the trigger (step 0) hands the work
194
+ * to a step of its own, and a step works and then posts the next one; when the
195
+ * hand-off isn't accepted (no SELF binding, or something ahead of the pack's
196
+ * middleware refused the internal request) the work is done right here, one
197
+ * step's worth. Lets go of the lease when nothing follows.
198
+ */
199
+ async function chainStep(deps: BackfillDeps, chain: Chain): Promise<VariantsState | null> {
200
+ const lease = await acquireLease(deps.db, chain.token, { follow: chain.step > 0 });
201
+ if (!lease) return null;
202
+ const link = chain.self && chain.origin ? (chain as Chain & { self: Fetcher; origin: string }) : null;
203
+ if (chain.step === 0 && link) {
204
+ const next = await postNextStep(deps, link);
205
+ if (next) {
206
+ rememberState(next);
207
+ return next;
208
+ }
209
+ }
210
+ const state = (await backfillStep(deps, STEP_BUDGET)) ?? lease;
211
+ console.log(
212
+ `coywolf-pack images: backfill step ${chain.step}: ${state.done} made, ${state.skipped} skipped, ${state.failed} failed; ${state.phase}${state.pausedUntil ? `, paused until ${state.pausedUntil}` : ""}`,
213
+ );
214
+ const more = (state.phase === "running" || state.phase === "cleanup") && !pausedAt(state, Date.now()) && (deps.keepGoing ? await deps.keepGoing() : true);
215
+ // A trigger whose hand-off was refused doesn't try again: the next trigger starts a new chain.
216
+ if (more && link && chain.step > 0 && chain.step < MAX_HOPS) {
217
+ const next = await postNextStep(deps, link);
218
+ if (next) {
219
+ rememberState(next);
220
+ return next;
221
+ }
222
+ }
223
+ const released = (await releaseLease(deps.db, chain.token)) ?? state;
224
+ rememberState(released);
225
+ return released;
226
+ }
227
+
228
+ /** Constant-time comparison of two tokens of the same length (both are checked against the UUID shape first). */
229
+ function sameToken(a: string, b: string): boolean {
230
+ if (a.length !== b.length) return false;
231
+ let diff = 0;
232
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
233
+ return diff === 0;
234
+ }
235
+
236
+ /**
237
+ * Start a chain on the current run (starting a run first: with `force`, a new
238
+ * one; otherwise only when the last finished a day ago), unless one is under
239
+ * way. A no-op without the bindings, a media host, or sizes for existing images
240
+ * turned on. With `origin` and the SELF binding the work runs in steps of their
241
+ * own; otherwise one step runs here.
242
+ */
243
+ export async function startBackfillChain(origin?: string, options: { force?: boolean } = {}): Promise<VariantsState | null> {
244
+ if (!(await variantsBulkOn())) return null;
245
+ const deps = await variantDeps();
246
+ if (!deps) return null;
247
+ const run = await startVariantsRun(deps.db, { force: options.force });
248
+ // Another chain holds the run, it's paused or done: nothing to start (and this isolate now knows, so
249
+ // page requests stop asking until its lease runs out).
250
+ if (!backfillMayHaveWork(JSON.stringify(run))) {
251
+ rememberState(run);
252
+ return run;
253
+ }
254
+ return chainStep(deps, { token: crypto.randomUUID(), step: 0, origin, self: origin ? await selfBinding() : undefined });
255
+ }
256
+
257
+ /**
258
+ * The internal endpoint (BACKFILL_PATH): runs a chain's next step after the
259
+ * response (202) when the request carries the lease's current token; 403
260
+ * otherwise. Undefined for any other path.
261
+ */
262
+ export async function handleBackfillRequest(request: Request, waitUntil: (p: Promise<unknown>) => void): Promise<Response | undefined> {
263
+ const url = new URL(request.url);
264
+ if (url.pathname !== BACKFILL_PATH) return undefined;
265
+ const forbidden = () => new Response("Forbidden", { status: 403, headers: { "Cache-Control": "no-store" } });
266
+ const token = request.headers.get(TOKEN_HEADER) ?? "";
267
+ const step = Number(request.headers.get(STEP_HEADER));
268
+ if (request.method !== "POST" || !/^[0-9a-f-]{36}$/.test(token) || !Number.isInteger(step) || step < 1 || step > MAX_HOPS) return forbidden();
269
+ const deps = await variantDeps();
270
+ if (!deps) return forbidden();
271
+ const { state } = await readVariantsState(deps.db);
272
+ if (!state?.lease || !sameToken(state.lease, token)) return forbidden();
273
+ waitUntil(chainStep(deps, { token, step, origin: url.origin, self: await selfBinding() }).catch((error) => console.error("coywolf-pack images: backfill step failed", error)));
274
+ return new Response(null, { status: 202, headers: { "Cache-Control": "no-store" } });
275
+ }
276
+
277
+ /** Per isolate: page requests look for a chain to start at most this often. */
278
+ let chainCheckUntil = 0;
279
+ const CHAIN_CHECK_MS = 10_000;
280
+
281
+ /**
282
+ * From a page request: start a chain when the progress row (as read with the
283
+ * feature switches: no query) shows a run with no chain working on it.
284
+ */
285
+ function maybeStartChain(origin: string, waitUntil: (p: Promise<unknown>) => void): void {
286
+ const now = Date.now();
287
+ if (now < chainCheckUntil || !backfillMayHaveWork(siteOption(VARIANTS_STATE_OPTION), now)) return;
288
+ chainCheckUntil = now + CHAIN_CHECK_MS;
289
+ waitUntil(startBackfillChain(origin).catch((error) => console.error("coywolf-pack images: couldn't start the backfill", error)));
290
+ }
291
+
292
+ /** The bindings stored sizes need, or null when one is missing (or there's no media host). */
293
+ export async function variantDeps(): Promise<BackfillDeps | null> {
294
+ await refreshMediaHost();
295
+ if (!imageCdn()) return null;
296
+ const env: Record<string, unknown> = await workerEnv().catch(() => ({}));
297
+ const db = env[config.database] as Db | undefined;
298
+ const bucket = env[config.bucket] as VariantBucket | undefined;
299
+ const images = env[config.images] as ImagesBinding | undefined;
300
+ if (!db || !bucket || !images) return null;
301
+ return {
302
+ db,
303
+ bucket,
304
+ images,
305
+ upgradePosters: (deadline) => upgradeListedPosters(db as never, bucket, images, deadline),
306
+ // Read with the feature switches (per-isolate cache, FEATURES_TTL_MS), so a run under way
307
+ // stops within that long of the setting being turned off; a toggle in this isolate stops it at once.
308
+ keepGoing: variantsBulkOn,
309
+ // Pages showing images still on /s/ were cached normally (not as stopgaps): once the run made
310
+ // sizes, clear the page cache once and warm it again, so every page picks them up together.
311
+ onDone: async () => {
312
+ if (!(await purgePageCache())) return;
313
+ if (await siteSetting<boolean>(WARM_SETTING, config.database)) rememberSiteOption(WARM_STATE_OPTION, JSON.stringify(await startWarm(db as unknown as D1Database, "images")));
314
+ },
315
+ };
316
+ }
317
+
318
+ /** The site's origin (for the chain's steps), from its configured URL. */
319
+ function siteOrigin(ctx: PluginContext): string | undefined {
320
+ try {
321
+ return ctx.site?.url ? new URL(ctx.site.url).origin : undefined;
322
+ } catch {
323
+ return undefined;
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Make one uploaded image's stored sizes. Runs after the upload's response
329
+ * (compose.ts hands media:afterUpload work to waitUntil, gated by the feature
330
+ * switch), on the raw bindings. Without a media host there are no stored sizes
331
+ * (the Worker route resizes on request), so nothing is said; a media host with
332
+ * a binding missing is a misconfiguration, said once per upload: silence here
333
+ * hid a dropped hook for three releases. A job cut off at waitUntil's limit
334
+ * (about 30 seconds after the response) leaves no record, and with sizes for
335
+ * existing images off nothing comes back for it; a run of the backfill does.
336
+ */
337
+ async function variantsForUpload(media: { id: string; mimeType?: string | null }): Promise<void> {
338
+ if (!eligible(media.mimeType, 1, 0)) return;
339
+ const deps = await variantDeps();
340
+ if (!deps) {
341
+ if (imageCdn()) console.warn(`coywolf-pack images: no stored sizes for upload ${media.id}: the ${config.database}, ${config.bucket} or ${config.images} binding is missing`);
342
+ return;
343
+ }
344
+ const row = await mediaRow(deps.db, media.id);
345
+ if (row) await processMedia(deps, row);
346
+ forgetInfo(media.id);
347
+ }
348
+
349
+ const MEDIA_ITEM = /^\/_emdash\/api\/media\/([A-Za-z0-9_-]+)(?:\/(replace))?$/;
350
+ const WP_MEDIA_IMPORT = "/_emdash/api/import/wordpress/media";
351
+
352
+ /**
353
+ * Keep stored sizes in step with media-library writes the hooks don't cover,
354
+ * after a successful response (null when the request isn't one):
355
+ * - a deleted image: its copies and record go (whether or not the feature is on);
356
+ * - a replaced file (PUT …/replace, same key): its sizes are made again;
357
+ * - a WordPress media import (EmDash's importer doesn't notify plugins): a new backfill run starts (its chain
358
+ * of steps, see startBackfillChain), only when the site turned on stored sizes for existing images (an
359
+ * import can be thousands of images). `origin` is the site's, for the chain's steps.
360
+ * New uploads aren't handled here: since EmDash 1.2.0 the media:afterUpload hook runs for every upload,
361
+ * direct (POST …/confirm) ones included, and makes their sizes (see the hook in imagesPack).
362
+ */
363
+ export function variantsAfterMediaWrite(method: string, pathname: string, featureOn: boolean, origin?: string): Promise<void> | null {
364
+ if (pathname === WP_MEDIA_IMPORT && method === "POST") {
365
+ if (!featureOn) return null;
366
+ return startBackfillChain(origin, { force: true }).then(() => undefined);
367
+ }
368
+ const m = MEDIA_ITEM.exec(pathname);
369
+ if (!m) return null;
370
+ const [, id, action] = m;
371
+ if (method === "DELETE" && !action) {
372
+ return (async () => {
373
+ const env: Record<string, unknown> = await workerEnv().catch(() => ({}));
374
+ const db = env[config.database] as Db | undefined;
375
+ const bucket = env[config.bucket] as VariantBucket | undefined;
376
+ if (db && bucket) await forgetMedia({ db, bucket }, id);
377
+ forgetInfo(id);
378
+ })();
379
+ }
380
+ // PUT /media/<id>/replace keeps the key but changes the file: EmDash runs no upload hook for it.
381
+ if (method === "PUT" && action === "replace" && featureOn) {
382
+ return (async () => {
383
+ const deps = await variantDeps();
384
+ if (!deps) return;
385
+ await forgetMedia(deps, id);
386
+ const row = await mediaRow(deps.db, id);
387
+ if (row) await processMedia(deps, row, undefined, { force: true });
388
+ forgetInfo(id);
389
+ })();
390
+ }
391
+ return null;
392
+ }
393
+
394
+ export function imagesPack(options: ImagesOptions = {}): PackModule {
395
+ config = { bucket: options.bucket ?? "MEDIA", images: options.images ?? "IMAGES", database: options.database ?? "DB" };
396
+ setImageCrops(options.crops);
397
+ if (options.cdn && !/^https:\/\//i.test(options.cdn.trim())) console.warn("coywolf-pack images: images.cdn must be an https origin like https://media.example.com; ignored.");
398
+ setImageCdn(options.cdn);
399
+ configureMediaHostDatabase(options.database);
400
+ return {
401
+ id: "images",
402
+ label: "Clean Image URLs",
403
+ features: FEATURES,
404
+ routes: imagesModule({ database: options.database, variants: { start: (origin, force) => startBackfillChain(origin, { force }), deps: variantDeps } }).routes,
405
+ adminPages: [{ path: "/images", label: "Clean Image URLs", icon: "image" }],
406
+ storage: { [VARIANTS_COLLECTION]: { indexes: [] } },
407
+ hooks: {
408
+ // Every new upload (EmDash 1.2.0+ runs it for direct uploads too, after POST …/confirm). EmDash
409
+ // doesn't wait for this hook, so compose.ts runs it under waitUntil, after the upload's response;
410
+ // an image whose sizes already exist is left alone (processMedia).
411
+ "media:afterUpload": (event: { media: { id: string; mimeType?: string } }) => variantsForUpload(event.media),
412
+ },
413
+ tasks: [
414
+ {
415
+ name: VARIANTS_TASK,
416
+ schedule: "@hourly",
417
+ // Restarts the backfill: a run finished a day ago (to retry failures), or one whose chain
418
+ // stopped or paused. The steps run in requests of their own (see startBackfillChain).
419
+ handler: async (ctx: PluginContext) => {
420
+ await startBackfillChain(siteOrigin(ctx));
421
+ },
422
+ },
423
+ ],
424
+ capabilities: ["network:request"],
425
+ allowedHosts: [CLOUDFLARE_API_HOST],
426
+ };
427
+ }
428
+
429
+ /**
430
+ * A clean URL for a resized copy of a media-library image, or null when the
431
+ * feature is off or `src` isn't a media-library file (use your usual image
432
+ * code then). `height` crops to fill; without it the ratio is kept.
433
+ */
434
+ export async function cleanImageUrl(src: string | null | undefined, options: { width: number; height?: number; format?: string }): Promise<string | null> {
435
+ if (!(await siteFeatureOn(F.main))) return null;
436
+ await refreshMediaHost();
437
+ return cleanImagePath(src, options);
438
+ }
439
+
440
+ /**
441
+ * The original file on the media host (e.g. for a full-size link or an
442
+ * <img> src without resizing). Falls back to `src` itself when the feature
443
+ * is off, no media host is set, or `src` isn't a media-library file.
444
+ */
445
+ export async function originalImageUrl<T extends string | null | undefined>(src: T): Promise<string | T> {
446
+ if (!src || !(await siteFeatureOn(F.main))) return src;
447
+ await refreshMediaHost();
448
+ return cdnOriginalUrl(src) ?? src;
449
+ }
450
+
451
+ /** Same as originalImageUrl. */
452
+ export const mediaUrl = originalImageUrl;
453
+
454
+ export interface ImageDimensions {
455
+ width: number;
456
+ height: number;
457
+ }
458
+
459
+ /** A media-library image as pages need it: its size, focal point, and stored sizes (if any). */
460
+ export interface ImageInfo {
461
+ /** Media id. */
462
+ id: string;
463
+ width: number | null;
464
+ height: number | null;
465
+ mimeType: string | null;
466
+ size: number | null;
467
+ /** Its stored-sizes record, any version (null when there's none). */
468
+ variants: VariantDoc | null;
469
+ /** When it was added to the media library (ms; null when unknown). */
470
+ createdAt: number | null;
471
+ }
472
+
473
+ /** Per isolate, by media id: dropped when settings change (settingsEpoch) or after INFO_TTL_MS. */
474
+ const infoCache = new Map<string, { info: ImageInfo | null; epoch: number; at: number }>();
475
+ const INFO_TTL_MS = 10 * 60_000;
476
+
477
+ /** Forget one image (its sizes were just made or removed in this isolate). */
478
+ export function forgetInfo(id: string): void {
479
+ infoCache.delete(id);
480
+ }
481
+
482
+ /** The media id and bucket key (<id>.<ext>) of a media-library or media-host URL. */
483
+ function mediaRef(src: string): { id: string; key: string } | null {
484
+ const file = mediaFile(src) ?? parseCdnUrl(src.trim());
485
+ return file ? { id: file.id, key: `${file.id}.${file.ext}` } : null;
486
+ }
487
+
488
+ /**
489
+ * Facts about media-library images (size, focal point, stored sizes) for the
490
+ * given sources, in one D1 statement for the whole list (batched with the
491
+ * page's other pack reads of this tick); remembered per isolate for a few
492
+ * minutes. Call it once per page with every image the page shows, then
493
+ * responsiveImage()/croppedImage() for each answer from the cache. Works
494
+ * whether or not Clean image URLs is on. Unknown sources are left out.
495
+ */
496
+ export async function imageInfo(srcs: Iterable<string | null | undefined>, database = config.database): Promise<Map<string, ImageInfo>> {
497
+ const result = new Map<string, ImageInfo>();
498
+ const wanted = new Map<string, { id: string; srcs: string[] }>();
499
+ for (const src of srcs) {
500
+ if (!src) continue;
501
+ const ref = mediaRef(src);
502
+ if (!ref) continue;
503
+ const hit = infoCache.get(ref.id);
504
+ if (hit && isCurrent(hit, INFO_TTL_MS)) {
505
+ if (hit.info) result.set(src, hit.info);
506
+ continue;
507
+ }
508
+ const entry = wanted.get(ref.key) ?? { id: ref.id, srcs: [] };
509
+ entry.srcs.push(src);
510
+ wanted.set(ref.key, entry);
511
+ }
512
+ if (!wanted.size) return result;
513
+ const env: Record<string, unknown> = await workerEnv().catch(() => ({}));
514
+ const db = env[database] as D1Database | undefined;
515
+ if (!db) return result;
516
+ const keys = [...wanted.keys()];
517
+ const epoch = settingsEpoch();
518
+ // D1 allows at most 100 bound parameters per query.
519
+ for (let i = 0; i < keys.length; i += 90) {
520
+ const batch = keys.slice(i, i + 90);
521
+ type Row = { id: string; storage_key: string; width: number | null; height: number | null; mime_type: string | null; size: number | null; created_at: string | null; data: string | null };
522
+ // By storage_key: the file name in a media URL is the bucket key's stem, not the media id
523
+ // (EmDash names files with their own ULID, and uploads since 1.1 as <stem>.<attempt>.<ext>).
524
+ // storage_key has no index, so this is one scan of the media table per uncached page.
525
+ const rows = await batchedAll<Row>(
526
+ db,
527
+ db
528
+ .prepare(
529
+ `SELECT m.id, m.storage_key, m.width, m.height, m.mime_type, m.size, m.created_at, s.data FROM media AS m
530
+ LEFT JOIN _plugin_storage AS s ON s.plugin_id = 'coywolf-pack' AND s.collection = '${VARIANTS_COLLECTION}' AND s.id = m.id
531
+ WHERE m.storage_key IN (${batch.map(() => "?").join(",")})`,
532
+ )
533
+ .bind(...batch),
534
+ );
535
+ const found = new Map(rows.map((r) => [r.storage_key, r]));
536
+ if (infoCache.size > 5000) infoCache.clear();
537
+ for (const key of batch) {
538
+ const row = found.get(key);
539
+ const { id, srcs: sources } = wanted.get(key)!;
540
+ const info: ImageInfo | null = row
541
+ ? { id: row.id, width: row.width, height: row.height, mimeType: row.mime_type, size: row.size, variants: parseDoc(row.data), createdAt: mediaTime(row.created_at) }
542
+ : null;
543
+ infoCache.set(id, { info, epoch, at: Date.now() });
544
+ if (info) for (const src of sources) result.set(src, info);
545
+ }
546
+ }
547
+ return result;
548
+ }
549
+
550
+ /**
551
+ * Width and height of media-library images, from the media library itself.
552
+ * For images whose Portable Text block has none (WordPress imports don't record
553
+ * them), so themes can still build responsive srcsets and set width/height.
554
+ * Same single read and cache as imageInfo. Unknown sources are left out.
555
+ */
556
+ export async function imageDimensions(srcs: Iterable<string | null | undefined>, database = config.database): Promise<Map<string, ImageDimensions>> {
557
+ const result = new Map<string, ImageDimensions>();
558
+ for (const [src, info] of await imageInfo(srcs, database)) if (info.width && info.height) result.set(src, { width: info.width, height: info.height });
559
+ return result;
560
+ }
561
+
562
+ /** An image's stored sizes as srcsets for a <picture>: AVIF and WebP (the original is the largest candidate). */
563
+ export interface ResponsiveImage {
564
+ avif: string;
565
+ webp: string;
566
+ src: string;
567
+ full: string;
568
+ width: number;
569
+ height: number;
570
+ }
571
+
572
+ /** A media row's created_at ("YYYY-MM-DD HH:MM:SS" in UTC from SQLite's datetime(), or ISO) in ms. */
573
+ function mediaTime(value: string | null | undefined): number | null {
574
+ if (!value) return null;
575
+ const iso = /[zZ]|[+-]\d\d:?\d\d$/.test(value) ? value : `${value.replace(" ", "T")}Z`;
576
+ const ms = Date.parse(iso);
577
+ return Number.isFinite(ms) ? ms : null;
578
+ }
579
+
580
+ /** An upload this recent may still be getting its sizes (they're made right after the upload, within a minute or so). */
581
+ export const NEW_UPLOAD_MS = 15 * 60_000;
582
+
583
+ /**
584
+ * Whether an image without a current record will get its widths within minutes,
585
+ * so a page showing its /s/ stopgap should be cached briefly: only when it was
586
+ * uploaded in the last 15 minutes. Older images waiting for the backfill don't
587
+ * shorten pages (that would rebuild every page every five minutes for hours):
588
+ * the page is cached normally, and the backfill clears the page cache once when
589
+ * its run is done (BackfillDeps.onDone), so all pages pick the sizes up together.
590
+ */
591
+ function expectedSoon(info: ImageInfo, now = Date.now()): boolean {
592
+ if (!eligible(info.mimeType, info.width, info.size)) return false;
593
+ if (info.variants?.v === VARIANTS_VERSION && info.variants.skip) return false;
594
+ return info.createdAt !== null && now - info.createdAt < NEW_UPLOAD_MS;
595
+ }
596
+
597
+ /**
598
+ * The image's stored widths as AVIF and WebP srcsets (and the original), or
599
+ * null when there are none (the feature is off, there's no media host, the
600
+ * image isn't a media-library file, or its sizes aren't made yet): use
601
+ * cleanImageUrl() then. Pass `locals` (Astro.locals) so a page shown before
602
+ * the sizes exist is cached for minutes, not days.
603
+ */
604
+ export async function responsiveImage(src: string | null | undefined, options: { locals?: object | null } = {}): Promise<ResponsiveImage | null> {
605
+ if (!src || !(await siteFeatureOn(F.main))) return null;
606
+ await refreshMediaHost();
607
+ const cdn = imageCdn();
608
+ const original = cdnOriginalUrl(src);
609
+ if (!cdn || !original) return null;
610
+ const info = (await imageInfo([src])).get(src);
611
+ if (!info?.width || !info.height) return null;
612
+ if (!currentDoc(info.variants)) {
613
+ if (expectedSoon(info)) markPendingMedia(options.locals);
614
+ return null;
615
+ }
616
+ // Images under 400 pixels have no stored widths: a plain <img> of the original (or /s/) is all there is.
617
+ if (!info.variants.w.some((w) => w < (info.width as number))) return null;
618
+ return { ...variantSrcsets(cdn, info.id, info.variants, original, info.width), width: info.width, height: info.height };
619
+ }
620
+
621
+ /**
622
+ * Crops made on first use: per isolate, by "<media id>:<crop>", when each was
623
+ * queued (so it isn't queued again while it's being made) or failed (so pages
624
+ * don't wait for it, and it's tried again an hour later).
625
+ */
626
+ const cropJobs = new Map<string, { until: number; failed?: boolean }>();
627
+ const CROP_JOB_MS = 10 * 60_000;
628
+ const CROP_RETRY_MS = 60 * 60_000;
629
+ /** Images whose crops are queued in this isolate, made one at a time (each holds its original in memory). */
630
+ let cropQueue: Promise<void> = Promise.resolve();
631
+ let queuedImages = 0;
632
+ const MAX_QUEUED_IMAGES = 6;
633
+
634
+ /** Make the given crops of one image and add them to its record (after the response). */
635
+ async function makeCrops(id: string, names: string[]): Promise<void> {
636
+ const deps = await variantDeps();
637
+ const row = deps ? await mediaRow(deps.db, id) : null;
638
+ if (!deps || !row?.width) return;
639
+ const doc = await generateVariants({
640
+ bucket: deps.bucket,
641
+ images: deps.images,
642
+ key: row.storage_key,
643
+ id: row.id,
644
+ width: row.width,
645
+ height: row.height,
646
+ focal: row.focal_x != null && row.focal_y != null ? { x: row.focal_x, y: row.focal_y } : null,
647
+ widths: [],
648
+ crops: names,
649
+ });
650
+ await addCrops(deps.db, id, doc.c ?? []);
651
+ forgetInfo(id);
652
+ }
653
+
654
+ /**
655
+ * Queue missing crops of an image whose widths are stored. False when one of
656
+ * them failed recently (the page uses /s/ for it without waiting). Queued
657
+ * crops, and ones that couldn't be queued now (this isolate is busy; a later
658
+ * render queues them), are "on their way": the page is a stopgap.
659
+ */
660
+ function queueCrops(id: string, names: string[], now = Date.now()): boolean {
661
+ const jobs = names.map((name) => cropJobs.get(`${id}:${name}`));
662
+ if (jobs.some((job) => job?.failed && job.until > now)) return false;
663
+ const fresh = names.filter((_, i) => !jobs[i] || jobs[i].until <= now);
664
+ if (!fresh.length || queuedImages >= MAX_QUEUED_IMAGES) return true;
665
+ if (cropJobs.size > 5000) cropJobs.clear();
666
+ for (const name of fresh) cropJobs.set(`${id}:${name}`, { until: now + CROP_JOB_MS });
667
+ queuedImages++;
668
+ cropQueue = cropQueue
669
+ .then(() => makeCrops(id, fresh))
670
+ .catch((error) => {
671
+ console.error(`coywolf-pack images: couldn't make crops ${fresh.join(", ")} of ${id}`, error);
672
+ for (const name of fresh) cropJobs.set(`${id}:${name}`, { until: Date.now() + CROP_RETRY_MS, failed: true });
673
+ })
674
+ .finally(() => {
675
+ queuedImages--;
676
+ });
677
+ void afterResponse(cropQueue);
678
+ return true;
679
+ }
680
+
681
+ /** Wait for this isolate's queued crops (tests). */
682
+ export function cropsIdle(): Promise<void> {
683
+ return cropQueue;
684
+ }
685
+
686
+ /** Forget queued and failed crops (tests). */
687
+ export function resetCropJobs(): void {
688
+ cropJobs.clear();
689
+ }
690
+
691
+ /**
692
+ * Stored crops of an image (sizes from the site's `images.crops`) as AVIF and
693
+ * WebP srcsets: `crops` are [width, height, descriptor] such as
694
+ * [600, 315, "600w"] or [50, 50, "1x"]; `src` is the WebP of `srcIndex`. Null
695
+ * when any of them isn't stored (use cleanImageUrl() then).
696
+ *
697
+ * Crops are made on first use: when the image's widths are stored but a crop
698
+ * isn't, it's made after the response and the page is marked as a stopgap
699
+ * (through `locals`), so its next render uses it. An image without stored
700
+ * widths gets no crops until it has them.
701
+ */
702
+ export async function croppedImage(
703
+ src: string | null | undefined,
704
+ crops: Array<readonly [number, number, string]>,
705
+ options: { locals?: object | null; srcIndex?: number } = {},
706
+ ): Promise<{ avif: string; webp: string; src: string } | null> {
707
+ if (!src || !crops.length || !(await siteFeatureOn(F.main))) return null;
708
+ await refreshMediaHost();
709
+ const cdn = imageCdn();
710
+ if (!cdn || !cdnOriginalUrl(src)) return null;
711
+ const info = (await imageInfo([src])).get(src);
712
+ if (!info) return null;
713
+ const names = crops.map(([w, h]) => `${Math.round(w)}x${Math.round(h)}`);
714
+ const states = names.map((name) => cropState(info.variants, name, info.width, info.height));
715
+ if (states.every((s) => s === "stored")) return cropSrcsets(cdn, info.id, info.variants as VariantDoc, names.map((n, i) => [n, crops[i][2]]), options.srcIndex);
716
+ if (states.includes("never")) return null;
717
+ if (states.includes("none")) {
718
+ if (expectedSoon(info)) markPendingMedia(options.locals);
719
+ return null;
720
+ }
721
+ if (queueCrops(info.id, names.filter((_, i) => states[i] === "missing"))) markPendingMedia(options.locals);
722
+ return null;
723
+ }
724
+
725
+ const listCache = new Map<string, string | null>();
726
+
727
+ /** The bucket key of a file id (files are stored as <id>.<ext>). */
728
+ async function findKey(bucket: R2Bucket, id: string): Promise<string | null> {
729
+ if (listCache.has(id)) return listCache.get(id) ?? null;
730
+ const page = await bucket.list({ prefix: `${id}.`, limit: 2 });
731
+ const key = page.objects.find((o) => o.key.startsWith(`${id}.`))?.key ?? null;
732
+ if (listCache.size > 5000) listCache.clear();
733
+ listCache.set(id, key);
734
+ return key;
735
+ }
736
+
737
+ const notFound = () => new Response("Not found", { status: 404, headers: { "Cache-Control": "public, max-age=300" } });
738
+
739
+ export const imagesMiddleware: PackMiddleware = {
740
+ module: "images",
741
+ feature: F.main,
742
+ handle: async (context, env, waitUntil) => {
743
+ const { pathname } = context.url;
744
+ // A backfill chain's next step (internal; see startBackfillChain).
745
+ if (pathname === BACKFILL_PATH) return handleBackfillRequest(context.request, waitUntil);
746
+ // Page traffic restarts a backfill run that has no chain working on it (no query when there's none).
747
+ if (context.request.method === "GET" && !pathname.startsWith("/_emdash/") && context.request.headers.get("user-agent") !== WARMER_AGENT) {
748
+ maybeStartChain(context.url.origin, waitUntil);
749
+ }
750
+ if (!pathname.startsWith(IMAGE_PATH)) return undefined;
751
+ const request = parseImagePath(pathname);
752
+ if (!request) return undefined;
753
+ if (context.request.method !== "GET" && context.request.method !== "HEAD") return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } });
754
+
755
+ const bucket = env[config.bucket] as R2Bucket | undefined;
756
+ if (!bucket) return undefined;
757
+ // With a media host, old Worker-route URLs move there (same size; the host picks the format).
758
+ await refreshMediaHost();
759
+ if (imageCdn()) {
760
+ const key = await findKey(bucket, request.id);
761
+ const target = key ? cdnRedirectUrl(request, key) : null;
762
+ if (!target) return notFound();
763
+ return new Response(null, { status: 301, headers: { Location: target, "Cache-Control": "public, max-age=86400" } });
764
+ }
765
+
766
+ // Only sizes on the grid are resized; anything else moves to the nearest one up.
767
+ const snapped = snappedImagePath(request);
768
+ if (snapped) return new Response(null, { status: 301, headers: { Location: snapped, "Cache-Control": "public, max-age=86400" } });
769
+
770
+ const cache = (globalThis as unknown as { caches?: { default?: Cache } }).caches?.default;
771
+ // Keyed by path alone: a query string can't force a fresh (billed) transform.
772
+ const cacheKey = new Request(`${context.url.origin}${pathname}`, { method: "GET" });
773
+ const hit = cache ? await cache.match(cacheKey) : undefined;
774
+ // Cached responses have immutable headers; hand back a copy later middleware can still change.
775
+ if (hit) return new Response(context.request.method === "HEAD" ? null : hit.body, { status: hit.status, headers: new Headers(hit.headers) });
776
+
777
+ const images = env[config.images] as ImagesBinding | undefined;
778
+ if (!images) return undefined;
779
+ const key = await findKey(bucket, request.id);
780
+ if (!key) return notFound();
781
+ const object = await bucket.get(key);
782
+ if (!object) return notFound();
783
+ if ((object.httpMetadata?.contentType ?? "").includes("svg")) return notFound();
784
+
785
+ let out: Response;
786
+ try {
787
+ const transformed = await images
788
+ .input(object.body)
789
+ .transform(request.height === undefined ? { width: request.width } : { width: request.width, height: request.height, fit: "cover" })
790
+ .output({ format: FORMATS[request.format], quality: 85 });
791
+ out = transformed.response();
792
+ } catch (error) {
793
+ console.error("coywolf-pack images: transform failed", key, error);
794
+ return new Response("Could not resize this image", { status: 502, headers: { "Cache-Control": "no-store" } });
795
+ }
796
+ const headers = new Headers(out.headers);
797
+ headers.set("Content-Type", FORMATS[request.format]);
798
+ headers.set("Cache-Control", "public, max-age=31536000, immutable");
799
+ headers.set("X-Content-Type-Options", "nosniff");
800
+ const response = new Response(out.body, { status: 200, headers });
801
+ if (cache) waitUntil(cache.put(cacheKey, response.clone()));
802
+ return context.request.method === "HEAD" ? new Response(null, { headers: new Headers(headers) }) : response;
803
+ },
804
+ };