@xingwangzhe/stalux 1.49.0 → 1.51.0

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.
@@ -1,322 +1,185 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { type Node, Renderer } from "@takumi-rs/core";
5
- import { imageSize } from "image-size";
6
- import type { SocialCardData } from "../utils/social-image";
4
+ import { Resvg } from "@resvg/resvg-js";
5
+ import satori from "satori";
6
+ import { type SocialCardData, socialCardFormat } from "../utils/social-image";
7
+ import { loadSharp, type SharpPipeline, type SharpRawBuffer } from "./sharp-loader";
8
+ import { coverDataUri, loadCover, prepareCover, type SocialCardPaths } from "./social-card-cover";
9
+ import {
10
+ CARD_FONT_FAMILY,
11
+ CARD_FONT_WEIGHT,
12
+ CARD_HEIGHT,
13
+ CARD_WIDTH,
14
+ COVER_LEFT,
15
+ socialCardTree,
16
+ } from "./social-card-layout";
7
17
 
8
- export interface SocialCardPaths {
9
- publicDir: string;
10
- cacheDir: string;
11
- base: string;
12
- fontPath: string;
13
- }
18
+ export type { SocialCardPaths } from "./social-card-cover";
19
+ export { localCoverPath } from "./social-card-cover";
20
+ export { socialCardTree } from "./social-card-layout";
21
+
22
+ /** Bump when the layout changes so every cached render is replaced. */
23
+ const TEMPLATE = "stalux-mono-v1";
24
+
25
+ type SatoriInput = Parameters<typeof satori>[0];
14
26
 
15
- const MAX_COVER_BYTES = 8 * 1024 * 1024;
16
- let rendererPromise: Promise<Renderer> | undefined;
17
- function getRenderer(fontPath: string): Promise<Renderer> {
18
- rendererPromise ??= (async () => {
19
- const renderer = new Renderer({ cacheMaxBytes: 32 * 1024 * 1024 });
20
- await renderer.registerFont({ name: "Stalux Card", data: await readFile(fontPath) });
21
- return renderer;
22
- })();
23
- return rendererPromise;
27
+ function encodeWithSharp(
28
+ pipeline: SharpPipeline,
29
+ format: "jpg" | "png",
30
+ panel?: SharpRawBuffer,
31
+ ): Promise<Buffer> {
32
+ if (format === "jpg") {
33
+ // The artwork keeps its colour. Without artwork the whole frame is
34
+ // black and white, so the smaller true grayscale JPEG is used.
35
+ const composed = panel
36
+ ? pipeline.composite([
37
+ {
38
+ input: panel.data,
39
+ raw: {
40
+ width: panel.info.width,
41
+ height: panel.info.height,
42
+ channels: panel.info.channels,
43
+ },
44
+ left: COVER_LEFT,
45
+ top: 0,
46
+ },
47
+ ])
48
+ : pipeline.greyscale().toColourspace("b-w");
49
+ return composed.jpeg({ quality: 80, mozjpeg: true }).toBuffer();
50
+ }
51
+ return pipeline
52
+ .greyscale()
53
+ .png({ compressionLevel: 9, palette: true, colours: 256, effort: 3 })
54
+ .toBuffer();
24
55
  }
25
56
 
26
57
  /**
27
- * Font identity for the render cache.
28
- *
29
- * The key used to carry the font's size and mtime, so re-installing the theme,
30
- * restoring a checkout, or copying the package to a build machine changed every
31
- * card's key at once and re-rendered all of them (about 8s each). Hashing the
32
- * bytes keeps the key stable for identical font content and still changes when
33
- * the font really is different.
58
+ * Rasterize with Resvg, then let Sharp compose and encode in a single pass.
59
+ * System-font discovery is disabled because Satori already converted every
60
+ * glyph to a path; leaving it on made each Resvg instance scan all installed
61
+ * fonts, costing about 500ms per card.
34
62
  */
35
- let fontDigestPromise: Promise<string> | undefined;
36
- function getFontDigest(fontPath: string): Promise<string> {
37
- fontDigestPromise ??= readFile(fontPath).then((bytes) =>
38
- createHash("sha256").update(bytes).digest("hex"),
39
- );
40
- return fontDigestPromise;
41
- }
42
-
43
- /** Resolve only assets inside the final public directory, including encoded Chinese paths. */
44
- export function localCoverPath(cover: string, paths: SocialCardPaths): string | undefined {
45
- if (!cover.startsWith("/") || cover.startsWith("//")) return undefined;
63
+ async function renderCardBytes(
64
+ svg: string,
65
+ format: "jpg" | "png",
66
+ panel?: SharpRawBuffer,
67
+ ): Promise<Buffer> {
68
+ const png = new Resvg(svg, {
69
+ fitTo: { mode: "width", value: CARD_WIDTH },
70
+ background: "#ffffff",
71
+ font: { loadSystemFonts: false },
72
+ })
73
+ .render()
74
+ .asPng();
75
+ const sharp = loadSharp();
76
+ if (!sharp) {
77
+ if (format === "jpg") throw new Error("JPEG encoding requires sharp");
78
+ return png;
79
+ }
46
80
  try {
47
- let pathname = decodeURIComponent(cover.split(/[?#]/u)[0] ?? "");
48
- const base = paths.base.replace(/\/$/u, "");
49
- if (base && pathname.startsWith(`${base}/`)) pathname = pathname.slice(base.length);
50
- const target = path.resolve(paths.publicDir, `.${pathname}`);
51
- if (!target.startsWith(`${path.resolve(paths.publicDir)}${path.sep}`)) return undefined;
52
- return target;
53
- } catch {
54
- return undefined;
81
+ return await encodeWithSharp(sharp(png), format, panel);
82
+ } catch (error) {
83
+ if (format === "jpg") throw error;
84
+ return png;
55
85
  }
56
86
  }
57
87
 
58
- const coverRequests = new Map<string, Promise<Buffer>>();
59
-
60
- async function loadCover(cover: string, paths: SocialCardPaths): Promise<Buffer> {
61
- const key = `${paths.publicDir}|${cover}`;
62
- let pending = coverRequests.get(key);
88
+ const fonts = new Map<string, Promise<Buffer>>();
89
+ function readFont(fontPath: string): Promise<Buffer> {
90
+ let pending = fonts.get(fontPath);
63
91
  if (!pending) {
64
- pending = loadCoverUncached(cover, paths);
65
- coverRequests.set(key, pending);
66
- // Bound memory while sharing requests and failures between WebP and PNG renders.
67
- if (coverRequests.size > 8) coverRequests.delete(coverRequests.keys().next().value ?? "");
92
+ pending = readFile(fontPath);
93
+ fonts.set(fontPath, pending);
68
94
  }
69
95
  return pending;
70
96
  }
71
97
 
72
- async function loadCoverUncached(cover: string, paths: SocialCardPaths): Promise<Buffer> {
73
- const local = localCoverPath(cover, paths);
74
- let bytes: Buffer;
75
- if (local) {
76
- if ((await stat(local)).size > MAX_COVER_BYTES) throw new Error("cover exceeds 8 MiB");
77
- bytes = await readFile(local);
78
- } else {
79
- const url = new URL(cover.startsWith("//") ? `https:${cover}` : cover);
80
- if (!["https:", "http:"].includes(url.protocol)) throw new Error("invalid cover URL");
81
- const remoteCache = path.join(
82
- paths.cacheDir,
83
- "covers",
84
- createHash("sha256").update(url.href).digest("hex"),
85
- );
86
- const cachedStat = await stat(remoteCache).catch(() => undefined);
87
- if (cachedStat && Date.now() - cachedStat.mtimeMs < 86_400_000)
88
- return readFile(remoteCache);
89
- const response = await fetch(url, { signal: AbortSignal.timeout(8000) });
90
- if (!(response.ok && response.headers.get("content-type")?.startsWith("image/")))
91
- throw new Error(`cover returned HTTP ${response.status} or non-image content`);
92
- const chunks: Uint8Array[] = [];
93
- let size = 0;
94
- if (!response.body) throw new Error("empty cover");
95
- for await (const chunk of response.body) {
96
- size += chunk.length;
97
- if (size > MAX_COVER_BYTES) throw new Error("cover exceeds 8 MiB");
98
- chunks.push(chunk);
99
- }
100
- bytes = Buffer.concat(chunks);
101
- const dimensions = imageSize(bytes);
102
- if (
103
- !(dimensions.width && dimensions.height) ||
104
- dimensions.width * dimensions.height > 32_000_000
105
- )
106
- throw new Error("invalid cover dimensions");
107
- await mkdir(path.dirname(remoteCache), { recursive: true });
108
- await writeFile(remoteCache, bytes);
109
- }
110
- const dimensions = imageSize(bytes);
111
- if (
112
- !(dimensions.width && dimensions.height) ||
113
- dimensions.width * dimensions.height > 32_000_000
114
- )
115
- throw new Error("invalid or oversized cover dimensions");
116
- return bytes;
117
- }
118
-
119
- function text(value: string, style: Node["style"]): Node {
120
- return { type: "text", text: value, style };
121
- }
122
-
123
- /** A fixed editorial layout: site identity, readable title, optional artwork, quiet footer. */
124
- export function socialCardTree(
125
- data: SocialCardData,
126
- hasCover: boolean,
127
- titleSize = 60,
128
- title = data.title,
129
- ): Node {
130
- const titleWidth = hasCover ? 650 : 1056;
131
- const accent = /^#[\da-f]{6}$/iu.test(data.accent) ? data.accent : "#EAB308";
132
- const children: Node[] = [
133
- {
134
- type: "container",
135
- style: {
136
- position: "absolute",
137
- right: -120,
138
- top: -200,
139
- width: 650,
140
- height: 650,
141
- borderRadius: "50%",
142
- border: `1px solid ${accent}35`,
143
- background: `radial-gradient(circle, ${accent}18, transparent 70%)`,
144
- },
145
- },
146
- {
147
- type: "container",
148
- style: {
149
- position: "absolute",
150
- left: 0,
151
- top: 0,
152
- width: 1200,
153
- height: 6,
154
- backgroundColor: accent,
155
- },
156
- },
157
- text(data.siteTitle, {
158
- position: "absolute",
159
- left: 72,
160
- top: 48,
161
- width: 820,
162
- height: 46,
163
- fontSize: 28,
164
- color: accent,
165
- overflow: "hidden",
166
- }),
167
- text(data.category || "ARTICLE", {
168
- position: "absolute",
169
- right: 72,
170
- top: 54,
171
- width: 240,
172
- height: 36,
173
- textAlign: "right",
174
- fontSize: 21,
175
- color: "#a9b5c6",
176
- overflow: "hidden",
177
- }),
178
- text(title, {
179
- position: "absolute",
180
- left: 72,
181
- top: 142,
182
- width: titleWidth,
183
- height: 260,
184
- fontSize: titleSize,
185
- lineHeight: 1.24,
186
- color: "#f5f7fb",
187
- overflow: "hidden",
188
- overflowWrap: "anywhere",
189
- }),
190
- text([...data.description.replace(/\s+/gu, " ")].slice(0, hasCover ? 65 : 100).join(""), {
191
- position: "absolute",
192
- left: 72,
193
- top: 421,
194
- width: titleWidth,
195
- height: 76,
196
- fontSize: 25,
197
- lineHeight: 1.45,
198
- color: "#a9b5c6",
199
- overflow: "hidden",
200
- }),
201
- {
202
- type: "container",
203
- style: {
204
- position: "absolute",
205
- left: 72,
206
- top: 535,
207
- width: 1056,
208
- height: 1,
209
- backgroundColor: "#ffffff22",
210
- },
211
- },
212
- text(data.host, { position: "absolute", left: 72, top: 560, fontSize: 23, color: accent }),
213
- text(`${data.author} / ${data.date}`, {
214
- position: "absolute",
215
- right: 72,
216
- top: 560,
217
- width: 650,
218
- textAlign: "right",
219
- fontSize: 23,
220
- color: "#b9c4d3",
221
- }),
222
- ];
223
- if (hasCover)
224
- children.push({
225
- type: "image",
226
- src: "stalux-cover",
227
- style: {
228
- position: "absolute",
229
- right: 72,
230
- top: 142,
231
- width: 350,
232
- height: 354,
233
- objectFit: "cover",
234
- borderRadius: 20,
235
- },
236
- });
237
- return {
238
- type: "container",
239
- lang: data.lang,
240
- style: {
241
- width: 1200,
242
- height: 630,
243
- position: "relative",
244
- backgroundColor: "#101722",
245
- fontFamily: "Stalux Card",
246
- overflow: "hidden",
247
- },
248
- children,
249
- };
98
+ let fontDigestPromise: Promise<string> | undefined;
99
+ function getFontDigest(fontPath: string): Promise<string> {
100
+ fontDigestPromise ??= readFile(fontPath).then((bytes) =>
101
+ createHash("sha256").update(bytes).digest("hex"),
102
+ );
103
+ return fontDigestPromise;
250
104
  }
251
105
 
252
106
  export async function renderSocialCard(
253
107
  data: SocialCardData,
254
108
  paths: SocialCardPaths,
255
109
  warn: (message: string) => void,
256
- format: "webp" | "png" = "webp",
257
110
  ): Promise<Buffer> {
258
- let cover: Buffer | undefined;
111
+ const format = socialCardFormat(data);
112
+ const sharpAvailable = Boolean(loadSharp());
113
+ let coverBytes: Buffer | undefined;
259
114
  if (data.cover) {
260
115
  try {
261
- cover = await loadCover(data.cover, paths);
116
+ coverBytes = await loadCover(data.cover, paths);
262
117
  } catch {
263
118
  warn(`分享封面不可用,已回退标题卡片:${data.title}`);
264
119
  }
265
120
  }
266
121
  // Content-only key: identical card data + identical font + identical cover
267
122
  // always reuses the cached render, whatever the filesystem timestamps say.
123
+ // It is checked before cropping artwork so a warm build does no image work.
268
124
  const fontDigest = await getFontDigest(paths.fontPath);
269
125
  const key = createHash("sha256")
270
- .update(JSON.stringify(["v3", format, data, fontDigest]))
271
- .update(cover ?? "")
126
+ .update(
127
+ JSON.stringify([TEMPLATE, format, data, fontDigest, sharpAvailable ? "panel" : "flat"]),
128
+ )
129
+ .update(coverBytes ?? "")
272
130
  .digest("hex");
273
131
  const cacheFile = path.join(paths.cacheDir, `${key}.${format}`);
274
132
  const hit = await readFile(cacheFile).catch(() => undefined);
275
133
  if (hit) return hit;
276
- const renderer = await getRenderer(paths.fontPath);
277
- const layout = async (withCover: boolean) => {
278
- const width = withCover ? 650 : 1056;
279
- let title = [...data.title].slice(0, 150).join("");
280
- let size = 66;
281
- while (true) {
282
- const measured = await renderer.measure(
283
- {
284
- type: "text",
285
- text: title,
286
- style: {
287
- width,
288
- fontSize: size,
289
- lineHeight: 1.24,
290
- fontFamily: "Stalux Card",
291
- overflowWrap: "anywhere",
292
- },
293
- },
294
- { width },
295
- );
296
- if (measured.height <= 250) break;
297
- if (size > 30) size -= 2;
298
- else title = `${[...title].slice(0, -3).join("")}…`;
134
+
135
+ let panel: SharpRawBuffer | undefined;
136
+ let inlineCover: string | undefined;
137
+ if (coverBytes) {
138
+ if (sharpAvailable) {
139
+ try {
140
+ panel = await prepareCover(coverBytes);
141
+ } catch {
142
+ warn(`分享封面不可用,已回退标题卡片:${data.title}`);
143
+ }
144
+ } else {
145
+ // Without sharp there is no compositor, so the original bytes go
146
+ // through Satori instead (PNG/JPEG only, and no palette encoding).
147
+ inlineCover = coverDataUri(coverBytes);
299
148
  }
300
- return socialCardTree(data, withCover, size, title);
149
+ }
150
+
151
+ const withCover = Boolean(panel || inlineCover);
152
+ const renderTree = async (hasCover: boolean) => {
153
+ const svg = await satori(
154
+ socialCardTree(
155
+ data,
156
+ hasCover,
157
+ hasCover ? inlineCover : undefined,
158
+ ) as unknown as SatoriInput,
159
+ {
160
+ width: CARD_WIDTH,
161
+ height: CARD_HEIGHT,
162
+ fonts: [
163
+ {
164
+ name: CARD_FONT_FAMILY,
165
+ data: await readFont(paths.fontPath),
166
+ weight: CARD_FONT_WEIGHT,
167
+ style: "normal",
168
+ },
169
+ ],
170
+ },
171
+ );
172
+ return renderCardBytes(svg, format, hasCover ? panel : undefined);
301
173
  };
302
174
  let bytes: Buffer;
303
175
  try {
304
- bytes = await renderer.render(await layout(Boolean(cover)), {
305
- width: 1200,
306
- height: 630,
307
- format,
308
- quality: 86,
309
- images: cover ? [{ src: "stalux-cover", data: cover }] : [],
310
- });
176
+ bytes = await renderTree(withCover);
311
177
  } catch (error) {
312
- if (!cover) throw error;
178
+ if (!withCover) throw error;
313
179
  warn(`分享封面解码失败,已回退标题卡片:${data.title}`);
314
- bytes = await renderer.render(await layout(false), {
315
- width: 1200,
316
- height: 630,
317
- format,
318
- quality: 86,
319
- });
180
+ // Do not cache the fallback under the artwork key: a later build should
181
+ // retry the cover instead of inheriting a short-lived decode failure.
182
+ return renderTree(false);
320
183
  }
321
184
  await mkdir(paths.cacheDir, { recursive: true });
322
185
  await writeFile(cacheFile, bytes);
@@ -80,6 +80,7 @@ const routeKind = getRouteKind(Astro.url.pathname);
80
80
  // against the article and archive components that also request it.
81
81
  import { mountListReveal } from "../scripts/list-reveal";
82
82
  import { registerPageLifecycle } from "../scripts/page-runtime";
83
+ import "../scripts/route-prefetch";
83
84
 
84
85
  registerPageLifecycle("list-reveal", mountListReveal);
85
86
  </script>
@@ -5,7 +5,12 @@ import { createSocialCardData } from "../../internal/social-card-data";
5
5
  import { renderSocialCard } from "../../internal/social-card-renderer";
6
6
  import { getAuthorData, getSiteData } from "../../utils/config-utils";
7
7
  import { getPostContentIndex } from "../../utils/content-index";
8
- import { isSocialImageUrl, type SocialCardData, socialCardPath } from "../../utils/social-image";
8
+ import {
9
+ isSocialImageUrl,
10
+ type SocialCardData,
11
+ type SocialCardFormat,
12
+ socialCardPath,
13
+ } from "../../utils/social-image";
9
14
 
10
15
  export async function getStaticPaths() {
11
16
  const entries = await getCollection("config");
@@ -17,34 +22,28 @@ export async function getStaticPaths() {
17
22
  (post) =>
18
23
  !(post.data["og:image"]?.trim() && isSocialImageUrl(post.data["og:image"].trim())),
19
24
  )
20
- .flatMap((post) => {
25
+ .map((post) => {
21
26
  const card = createSocialCardData(post.data, site, author);
22
- // PNG only. og:image must stay PNG because several platforms refuse
23
- // WebP and AVIF in share previews, and a second format per post
24
- // doubled the render work (about 8s each) for no visible gain.
25
- return (["png"] as const).map((format) => ({
27
+ // The extension is part of the metadata contract: artwork cards are
28
+ // photographic JPEG, plain cards are palette PNG.
29
+ const file = socialCardPath(card).split("/").at(-1) ?? "";
30
+ const dot = file.lastIndexOf(".");
31
+ return {
26
32
  params: {
27
- image: socialCardPath(card)
28
- .split("/")
29
- .at(-1)
30
- ?.replace(/\.png$/u, ""),
31
- format,
33
+ image: file.slice(0, dot),
34
+ format: file.slice(dot + 1) as SocialCardFormat,
32
35
  },
33
- props: { card, format },
34
- // Covers may change independently of Markdown. Always recheck them; the renderer
35
- // has its own content cache and writes outputs even after Astro cleans dist.
36
- }));
36
+ props: { card, format: file.slice(dot + 1) as SocialCardFormat },
37
+ };
37
38
  });
38
39
  }
39
40
 
40
41
  export const GET: APIRoute = async ({ props, logger }) => {
41
- const bytes = await renderSocialCard(
42
- props.card as SocialCardData,
43
- paths,
44
- (message) => logger.warn(message),
45
- props.format as "webp" | "png",
42
+ const bytes = await renderSocialCard(props.card as SocialCardData, paths, (message) =>
43
+ logger.warn(message),
46
44
  );
45
+ const type = props.format === "jpg" ? "image/jpeg" : "image/png";
47
46
  return new Response(new Uint8Array(bytes), {
48
- headers: { "Content-Type": `image/${props.format}` },
47
+ headers: { "Content-Type": type },
49
48
  });
50
49
  };
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Warm the main navigation routes after the homepage has settled.
3
+ *
4
+ * Swup already preloads hovered/focused links. This adds a low-priority,
5
+ * idle-time preload of the visible navigation routes, so the first click from
6
+ * the homepage can reuse Swup's page cache instead of fetching and parsing the
7
+ * document during the transition. The preload plugin enforces its own throttle,
8
+ * skips saveData/2G connections, deduplicates cached pages and ignores routes
9
+ * the router cannot handle.
10
+ */
11
+ import { createClientLogger } from "./logger";
12
+ import { registerPageLifecycle } from "./page-runtime";
13
+
14
+ const logger = createClientLogger("route-prefetch");
15
+
16
+ const AFTER_LOAD_DELAY_MS = 1000;
17
+ const LOAD_CEILING_MS = 2500;
18
+ const IDLE_TIMEOUT_MS = 2000;
19
+
20
+ export function collectMainRouteUrls(
21
+ documentLike: Pick<Document, "querySelectorAll"> = document,
22
+ current = new URL(window.location.href),
23
+ ): string[] {
24
+ const urls = new Set<string>();
25
+ for (const anchor of documentLike.querySelectorAll<HTMLAnchorElement>(
26
+ 'a[data-ref="stalux-nav-link"]',
27
+ )) {
28
+ let url: URL;
29
+ try {
30
+ url = new URL(anchor.href, current);
31
+ } catch {
32
+ continue;
33
+ }
34
+ if (url.origin !== current.origin) continue;
35
+ if (url.pathname === current.pathname && url.search === current.search) continue;
36
+ urls.add(`${url.pathname}${url.search}`);
37
+ }
38
+ return [...urls];
39
+ }
40
+
41
+ export function mountRoutePrefetch(): (() => void) | undefined {
42
+ if (typeof window === "undefined" || typeof document === "undefined") return;
43
+ if (document.documentElement.dataset.staluxRoute !== "home") return;
44
+ const reducedData = window.matchMedia?.("(prefers-reduced-data: reduce)");
45
+ if (reducedData?.matches) return;
46
+
47
+ let cancelled = false;
48
+ let scheduled = false;
49
+ let delayTimer: ReturnType<typeof setTimeout> | undefined;
50
+ let ceilingTimer: ReturnType<typeof setTimeout> | undefined;
51
+ let idleId: number | undefined;
52
+
53
+ const run = () => {
54
+ if (cancelled || document.hidden) return;
55
+ const swup = window.swup;
56
+ if (!swup || typeof swup.preload !== "function") {
57
+ logger.debug("swup preload API unavailable; skipping route prefetch");
58
+ return;
59
+ }
60
+ const urls = collectMainRouteUrls();
61
+ if (!urls.length) return;
62
+ logger.debug(`prefetching ${urls.length} main routes`);
63
+ // One document at a time: keep the prefetch invisible to the current
64
+ // page's main thread and network instead of parsing six documents at once.
65
+ let chain: Promise<unknown> = Promise.resolve();
66
+ for (const url of urls) {
67
+ chain = chain
68
+ .then(() => {
69
+ if (cancelled) return;
70
+ return swup.preload?.(url, { priority: false });
71
+ })
72
+ .catch(() => {
73
+ // A failed warm-up must never affect navigation.
74
+ });
75
+ }
76
+ void chain;
77
+ };
78
+
79
+ const requestIdle = () => {
80
+ if (cancelled) return;
81
+ const idle = window.requestIdleCallback;
82
+ if (typeof idle === "function") {
83
+ idleId = idle(run, { timeout: IDLE_TIMEOUT_MS });
84
+ return;
85
+ }
86
+ idleId = setTimeout(run, 1) as unknown as number;
87
+ };
88
+
89
+ const schedule = () => {
90
+ if (scheduled || cancelled) return;
91
+ scheduled = true;
92
+ delayTimer = setTimeout(requestIdle, AFTER_LOAD_DELAY_MS);
93
+ };
94
+
95
+ if (document.readyState === "complete") {
96
+ schedule();
97
+ } else {
98
+ const onLoad = () => {
99
+ window.removeEventListener("load", onLoad);
100
+ schedule();
101
+ };
102
+ window.addEventListener("load", onLoad, { once: true });
103
+ // A slow image or third-party request must not postpone the warm-up forever.
104
+ ceilingTimer = setTimeout(() => {
105
+ window.removeEventListener("load", onLoad);
106
+ schedule();
107
+ }, LOAD_CEILING_MS);
108
+ }
109
+
110
+ return () => {
111
+ cancelled = true;
112
+ if (delayTimer !== undefined) clearTimeout(delayTimer);
113
+ if (ceilingTimer !== undefined) clearTimeout(ceilingTimer);
114
+ if (idleId === undefined) return;
115
+ if (typeof window.cancelIdleCallback === "function") window.cancelIdleCallback(idleId);
116
+ else clearTimeout(idleId);
117
+ };
118
+ }
119
+
120
+ registerPageLifecycle("route-prefetch", mountRoutePrefetch);