@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.
- package/LICENSE +21 -0
- package/README.md +203 -2
- package/docs/README.md +45 -0
- package/docs/ai.md +39 -0
- package/docs/backups.md +88 -0
- package/docs/breadcrumbs.md +59 -0
- package/docs/code-blocks.md +21 -0
- package/docs/content-urls.md +41 -0
- package/docs/custom-blocks.md +27 -0
- package/docs/discovery.md +32 -0
- package/docs/files.md +44 -0
- package/docs/form-uploads.md +59 -0
- package/docs/headings.md +37 -0
- package/docs/images.md +141 -0
- package/docs/internals.md +13 -0
- package/docs/links.md +34 -0
- package/docs/performance.md +72 -0
- package/docs/redirects.md +84 -0
- package/docs/releasing.md +21 -0
- package/docs/reviews.md +89 -0
- package/docs/robots.md +81 -0
- package/docs/schema.md +79 -0
- package/docs/search.md +114 -0
- package/docs/videos.md +48 -0
- package/docs/wordpress-import.md +198 -0
- package/package.json +79 -5
- package/src/admin/admin-css.ts +57 -0
- package/src/admin/ai.tsx +915 -0
- package/src/admin/backups.tsx +613 -0
- package/src/admin/breadcrumbs.tsx +542 -0
- package/src/admin/codeBlocks.tsx +204 -0
- package/src/admin/customBlocks.tsx +321 -0
- package/src/admin/dirty.ts +20 -0
- package/src/admin/discovery.tsx +463 -0
- package/src/admin/download.ts +28 -0
- package/src/admin/feature-switches.tsx +124 -0
- package/src/admin/features.tsx +142 -0
- package/src/admin/files.tsx +791 -0
- package/src/admin/formUploads.tsx +445 -0
- package/src/admin/guides.tsx +18 -0
- package/src/admin/headings.tsx +262 -0
- package/src/admin/images.tsx +825 -0
- package/src/admin/index.tsx +62 -0
- package/src/admin/links.tsx +980 -0
- package/src/admin/performance.tsx +330 -0
- package/src/admin/preview.tsx +63 -0
- package/src/admin/redirects-removed.tsx +266 -0
- package/src/admin/redirects.tsx +470 -0
- package/src/admin/reviews.tsx +232 -0
- package/src/admin/robots-bots.tsx +647 -0
- package/src/admin/robots-shared.tsx +109 -0
- package/src/admin/robots-wizard.tsx +748 -0
- package/src/admin/robots.tsx +911 -0
- package/src/admin/save-bar.tsx +134 -0
- package/src/admin/schema.tsx +1187 -0
- package/src/admin/search.tsx +355 -0
- package/src/admin/settings-ui.tsx +87 -0
- package/src/admin/trash-prompt-core.ts +135 -0
- package/src/admin/trash-prompt.tsx +156 -0
- package/src/admin/usage.tsx +301 -0
- package/src/admin/videos-display.tsx +248 -0
- package/src/admin/videos.tsx +1202 -0
- package/src/admin/wpImport.tsx +1516 -0
- package/src/ai/entities.ts +59 -0
- package/src/ai/export.ts +61 -0
- package/src/ai/logic.ts +606 -0
- package/src/ai/pack.ts +121 -0
- package/src/ai/prompts.ts +87 -0
- package/src/ai/providers.ts +276 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.ts +87 -0
- package/src/ai/worker.ts +530 -0
- package/src/astro/astro-modules.d.ts +6 -0
- package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
- package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
- package/src/astro/codeBlocks/CodeBlock.astro +62 -0
- package/src/astro/codeBlocks/EmDashCode.astro +61 -0
- package/src/astro/customBlocks/CustomBlock.astro +64 -0
- package/src/astro/customBlocks/Details.astro +8 -0
- package/src/astro/customBlocks/Disclosure.astro +8 -0
- package/src/astro/customBlocks/Note.astro +8 -0
- package/src/astro/customBlocks/Podcast.astro +8 -0
- package/src/astro/customBlocks/Quote.astro +8 -0
- package/src/astro/customBlocks/Testimonial.astro +8 -0
- package/src/astro/files/FileCard.astro +103 -0
- package/src/astro/headings/HeadingBlock.astro +129 -0
- package/src/astro/headings/TableOfContents.astro +45 -0
- package/src/astro/headings/request.ts +19 -0
- package/src/astro/images/ResponsiveImage.astro +60 -0
- package/src/astro/index.ts +84 -0
- package/src/astro/reviews/CoywolfReview.astro +27 -0
- package/src/astro/reviews/Review.astro +49 -0
- package/src/astro/search/SearchBox.astro +61 -0
- package/src/astro/search/SearchBoxForm.astro +509 -0
- package/src/astro/tables/CaptionedTable.astro +72 -0
- package/src/astro/videos/CoywolfVideo.astro +448 -0
- package/src/backups/download.ts +112 -0
- package/src/backups/dump.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -0
- package/src/backups/restore.ts +209 -0
- package/src/backups/store.ts +409 -0
- package/src/breadcrumbs/pack.ts +113 -0
- package/src/breadcrumbs/settings.ts +102 -0
- package/src/breadcrumbs/stamp.ts +40 -0
- package/src/breadcrumbs/trail.ts +123 -0
- package/src/codeBlocks/contrast.ts +179 -0
- package/src/codeBlocks/hljs.d.ts +5 -0
- package/src/codeBlocks/pack.ts +110 -0
- package/src/codeBlocks/render.ts +304 -0
- package/src/codeBlocks/settings.ts +18 -0
- package/src/codeBlocks/themes.generated.ts +304 -0
- package/src/codeBlocks/themes.ts +114 -0
- package/src/core/compose.ts +244 -0
- package/src/core/content-url.ts +888 -0
- package/src/core/csv.ts +119 -0
- package/src/core/d1-batch.ts +65 -0
- package/src/core/entry-docs.ts +175 -0
- package/src/core/features-module.ts +48 -0
- package/src/core/features.ts +392 -0
- package/src/core/generation.ts +88 -0
- package/src/core/guide-content.ts +166 -0
- package/src/core/module.ts +104 -0
- package/src/core/read-failures.ts +57 -0
- package/src/core/request-memo.ts +71 -0
- package/src/core/secrets.ts +83 -0
- package/src/core/settings-enhance.ts +74 -0
- package/src/core/site.ts +36 -0
- package/src/core/storage.ts +27 -0
- package/src/customBlocks/pack.ts +260 -0
- package/src/customBlocks/render.ts +424 -0
- package/src/customBlocks/rich.ts +313 -0
- package/src/customBlocks/settings.ts +46 -0
- package/src/discovery/build.ts +252 -0
- package/src/discovery/feed-build.ts +217 -0
- package/src/discovery/feed.ts +172 -0
- package/src/discovery/indexnow.ts +114 -0
- package/src/discovery/llms.ts +79 -0
- package/src/discovery/markdown.ts +294 -0
- package/src/discovery/memory-cache.ts +97 -0
- package/src/discovery/middleware.ts +186 -0
- package/src/discovery/module.ts +353 -0
- package/src/discovery/news.ts +99 -0
- package/src/discovery/pack.ts +58 -0
- package/src/discovery/settings.ts +91 -0
- package/src/discovery/urls.ts +35 -0
- package/src/env.d.ts +10 -0
- package/src/files/card.ts +231 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.ts +163 -0
- package/src/files/module.ts +596 -0
- package/src/files/pack.ts +92 -0
- package/src/files/r2-rules.ts +41 -0
- package/src/files/range.ts +50 -0
- package/src/files/s3.ts +114 -0
- package/src/files/serve.ts +141 -0
- package/src/files/sigv4.ts +160 -0
- package/src/files/site.ts +137 -0
- package/src/files/walker.ts +58 -0
- package/src/formUploads/lib.ts +266 -0
- package/src/formUploads/pack.ts +55 -0
- package/src/formUploads/wrap.ts +592 -0
- package/src/forms.ts +16 -0
- package/src/headings/pack.ts +154 -0
- package/src/headings/render.ts +139 -0
- package/src/headings/settings.ts +117 -0
- package/src/headings/slug.ts +68 -0
- package/src/headings/stamp.ts +142 -0
- package/src/headings/test-hooks.mjs +14 -0
- package/src/headings/toc.ts +48 -0
- package/src/images/cloudflare.ts +329 -0
- package/src/images/lib.ts +221 -0
- package/src/images/module.ts +372 -0
- package/src/images/pack.ts +804 -0
- package/src/images/pending.ts +37 -0
- package/src/images/settings.ts +48 -0
- package/src/images/setup.ts +192 -0
- package/src/images/variants-estimate.ts +162 -0
- package/src/images/variants-store.ts +475 -0
- package/src/images/variants.ts +311 -0
- package/src/index.ts +143 -0
- package/src/links/check.ts +101 -0
- package/src/links/classify.ts +270 -0
- package/src/links/export.ts +59 -0
- package/src/links/module.ts +652 -0
- package/src/links/pack.ts +43 -0
- package/src/links/pt.ts +325 -0
- package/src/links/store.ts +704 -0
- package/src/middleware.ts +287 -0
- package/src/modules.ts +53 -0
- package/src/options.ts +66 -0
- package/src/pageCache/lib.ts +274 -0
- package/src/pageCache/pack.ts +240 -0
- package/src/pageCache/usage-core.ts +152 -0
- package/src/pageCache/usage.ts +262 -0
- package/src/pageCache/warm.ts +722 -0
- package/src/redirects/middleware.ts +169 -0
- package/src/redirects/module.ts +124 -0
- package/src/redirects/pack.ts +89 -0
- package/src/redirects/removed-core.ts +151 -0
- package/src/redirects/removed.ts +293 -0
- package/src/redirects/rules.ts +355 -0
- package/src/redirects/slug-changes.ts +207 -0
- package/src/redirects/slug-core.ts +49 -0
- package/src/redirects/transfer.ts +92 -0
- package/src/reviews/lib.ts +624 -0
- package/src/reviews/pack.ts +135 -0
- package/src/reviews/schema.ts +128 -0
- package/src/reviews/settings.ts +26 -0
- package/src/robots/bots.ts +321 -0
- package/src/robots/data/bots.json +696 -0
- package/src/robots/data/verified.json +582 -0
- package/src/robots/directory.ts +26 -0
- package/src/robots/explain.ts +191 -0
- package/src/robots/guided.ts +300 -0
- package/src/robots/importer.ts +263 -0
- package/src/robots/middleware.ts +151 -0
- package/src/robots/module.ts +446 -0
- package/src/robots/pack.ts +69 -0
- package/src/robots/radar.ts +129 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.ts +883 -0
- package/src/robots/transfer.ts +45 -0
- package/src/robots/validate.ts +524 -0
- package/src/schema/catalog.ts +146 -0
- package/src/schema/graph.ts +791 -0
- package/src/schema/module.ts +1138 -0
- package/src/schema/og-image.ts +100 -0
- package/src/schema/pack.ts +58 -0
- package/src/search/engine.ts +578 -0
- package/src/search/fallback.ts +80 -0
- package/src/search/live-cache.ts +116 -0
- package/src/search/live-client.ts +770 -0
- package/src/search/live-serve.ts +282 -0
- package/src/search/module.ts +261 -0
- package/src/search/pack.ts +57 -0
- package/src/search/query.ts +132 -0
- package/src/search/ratelimit-core.ts +118 -0
- package/src/search/ratelimit.ts +79 -0
- package/src/search/snippet.ts +135 -0
- package/src/search/title-index.ts +139 -0
- package/src/search/title-match.ts +192 -0
- package/src/shared.ts +71 -0
- package/src/version.ts +2 -0
- package/src/videos/date.ts +40 -0
- package/src/videos/engagement.ts +75 -0
- package/src/videos/lib.ts +679 -0
- package/src/videos/module.ts +1160 -0
- package/src/videos/pack.ts +187 -0
- package/src/videos/pause.ts +42 -0
- package/src/videos/poster.ts +324 -0
- package/src/videos/render.ts +336 -0
- package/src/videos/store.ts +551 -0
- package/src/videos/stream.ts +134 -0
- package/src/wpImport/convert.ts +702 -0
- package/src/wpImport/guests.ts +329 -0
- package/src/wpImport/gutenberg.ts +157 -0
- package/src/wpImport/markers.ts +66 -0
- package/src/wpImport/pack.ts +451 -0
- package/src/wpImport/parents.ts +202 -0
- package/src/wpImport/prepare.ts +592 -0
- package/src/wpImport/redirects.ts +358 -0
- package/src/wpImport/sources.ts +67 -0
- package/src/wpImport/stream.ts +160 -0
- 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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/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(/&/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
|
+
}
|