|
1 | 1 | const RUNTIME_CACHE = "mhfu-offline-v1"; |
2 | | -// Matches the cacheName next-pwa's default "pages" runtime-caching rule uses |
3 | | -// for plain document navigations, so pages we precache here are found by it. |
4 | | -const PAGES_CACHE = "pages"; |
5 | 2 |
|
6 | 3 | interface Manifest { |
7 | 4 | version?: string; |
8 | 5 | files?: string[]; |
9 | 6 | } |
10 | 7 |
|
11 | | -export interface OfflineUrlSet { |
12 | | - data: string[]; |
13 | | - pages: string[]; |
14 | | -} |
15 | | - |
16 | | -export async function collectOfflineUrls(): Promise<OfflineUrlSet> { |
| 8 | +/** |
| 9 | + * Offline downloads only cover the compact data bundles + images — the raw |
| 10 | + * material every page renders from client-side (see src/lib/client-data.ts). |
| 11 | + * We deliberately don't precache individual page documents here: with ~3800 |
| 12 | + * weapon/armor/monster/decoration routes, that meant thousands of near- |
| 13 | + * identical HTML/RSC fetches for pages whose actual content is just this |
| 14 | + * same data rendered client-side, which was slow and mostly redundant bytes. |
| 15 | + * Pages you actually visit still get cached for offline reuse automatically |
| 16 | + * by next-pwa's normal runtime caching (the "pages"/"pages-rsc" rules in |
| 17 | + * next.config.ts) — this button just guarantees the underlying data is there |
| 18 | + * up front so search, the calculator, and any page you do open work offline. |
| 19 | + */ |
| 20 | +export async function collectOfflineUrls(): Promise<string[]> { |
17 | 21 | const manifest: Manifest = await fetch("/data/data-manifest.json").then((r) => |
18 | 22 | r.json() |
19 | 23 | ); |
20 | | - const data = (manifest.files ?? []).map((f) => `/data/${f}`); |
21 | | - // images manifest — generated alongside the data manifest |
| 24 | + const urls = (manifest.files ?? []).map((f) => `/data/${f}`); |
22 | 25 | try { |
23 | 26 | const images: string[] = await fetch("/data/image-manifest.json").then((r) => |
24 | 27 | r.json() |
25 | 28 | ); |
26 | | - data.push(...images); |
| 29 | + urls.push(...images); |
27 | 30 | } catch { |
28 | 31 | // image manifest missing; data-only offline is still useful |
29 | 32 | } |
30 | | - data.push("/search-data.json", "/weapon-data.json"); |
31 | | - |
32 | | - let pages: string[] = []; |
33 | | - try { |
34 | | - pages = await fetch("/data/page-manifest.json").then((r) => r.json()); |
35 | | - } catch { |
36 | | - // page manifest missing (e.g. older build); data-only offline still works |
37 | | - } |
38 | | - |
39 | | - return { data, pages }; |
40 | | -} |
41 | | - |
42 | | -export function totalUrls({ data, pages }: OfflineUrlSet): number { |
43 | | - return data.length + pages.length; |
| 33 | + urls.push("/search-data.json", "/weapon-data.json"); |
| 34 | + return urls; |
44 | 35 | } |
45 | 36 |
|
46 | | -export async function countCached({ data, pages }: OfflineUrlSet): Promise<number> { |
| 37 | +export async function countCached(urls: string[]): Promise<number> { |
47 | 38 | try { |
48 | | - const dataCache = await caches.open(RUNTIME_CACHE); |
49 | | - const pagesCache = await caches.open(PAGES_CACHE); |
| 39 | + const cache = await caches.open(RUNTIME_CACHE); |
50 | 40 | let n = 0; |
51 | | - for (const u of data) if (await dataCache.match(u)) n++; |
52 | | - for (const u of pages) if (await pagesCache.match(u)) n++; |
| 41 | + for (const u of urls) if (await cache.match(u)) n++; |
53 | 42 | return n; |
54 | 43 | } catch { |
55 | 44 | return 0; |
56 | 45 | } |
57 | 46 | } |
58 | 47 |
|
| 48 | +const FETCH_TIMEOUT_MS = 15000; |
| 49 | + |
59 | 50 | export async function downloadOffline( |
60 | | - urls: OfflineUrlSet, |
| 51 | + urls: string[], |
61 | 52 | onProgress: (done: number, total: number) => void |
62 | 53 | ): Promise<number> { |
63 | | - const dataCache = await caches.open(RUNTIME_CACHE); |
64 | | - const pagesCache = await caches.open(PAGES_CACHE); |
65 | | - const jobs = [ |
66 | | - ...urls.data.map((u) => ({ u, cache: dataCache })), |
67 | | - ...urls.pages.map((u) => ({ u, cache: pagesCache })), |
68 | | - ]; |
| 54 | + const cache = await caches.open(RUNTIME_CACHE); |
69 | 55 |
|
70 | 56 | // Download in small batches for progress without hammering the server. |
| 57 | + // Each fetch is time-boxed so one stalled request can't freeze the whole |
| 58 | + // download — it just counts as failed and the batch moves on. |
71 | 59 | const BATCH = 20; |
72 | | - const fetchInto = async (u: string, cache: Cache) => { |
| 60 | + const fetchInto = async (u: string) => { |
| 61 | + const controller = new AbortController(); |
| 62 | + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); |
73 | 63 | try { |
74 | | - const res = await fetch(u, { cache: "no-cache" }); |
| 64 | + const res = await fetch(u, { cache: "no-cache", signal: controller.signal }); |
75 | 65 | if (res.ok) await cache.put(u, res); |
76 | 66 | } catch { |
77 | | - // individual file failed; keep going |
| 67 | + // individual file failed or timed out; keep going |
| 68 | + } finally { |
| 69 | + clearTimeout(timer); |
78 | 70 | } |
79 | 71 | }; |
80 | | - for (let i = 0; i < jobs.length; i += BATCH) { |
81 | | - await Promise.all( |
82 | | - jobs.slice(i, i + BATCH).map(({ u, cache }) => fetchInto(u, cache)) |
83 | | - ); |
84 | | - onProgress(Math.min(i + BATCH, jobs.length), jobs.length); |
| 72 | + for (let i = 0; i < urls.length; i += BATCH) { |
| 73 | + await Promise.all(urls.slice(i, i + BATCH).map(fetchInto)); |
| 74 | + onProgress(Math.min(i + BATCH, urls.length), urls.length); |
85 | 75 | } |
86 | 76 | return countCached(urls); |
87 | 77 | } |
88 | 78 |
|
89 | 79 | export async function clearOffline(): Promise<void> { |
90 | | - await Promise.all([caches.delete(RUNTIME_CACHE), caches.delete(PAGES_CACHE)]); |
| 80 | + await caches.delete(RUNTIME_CACHE); |
91 | 81 | } |
92 | 82 |
|
93 | 83 | export function cachesSupported(): boolean { |
|
0 commit comments