methanol 0.0.8 → 0.0.10
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/README.md +3 -0
- package/index.js +7 -2
- package/package.json +9 -3
- package/src/build-system.js +50 -8
- package/src/client/sw.js +751 -0
- package/src/{assets.js → client/virtual-module/assets.js} +7 -3
- package/src/{virtual-module → client/virtual-module}/inject.js +1 -0
- package/src/{virtual-module → client/virtual-module}/loader.js +5 -5
- package/src/{virtual-module → client/virtual-module}/pagefind-loader.js +1 -1
- package/src/client/virtual-module/pwa-inject.js +25 -0
- package/src/components.js +5 -5
- package/src/config.js +38 -7
- package/src/dev-server.js +64 -66
- package/src/logger.js +84 -0
- package/src/main.js +11 -0
- package/src/mdx.js +33 -38
- package/src/pagefind.js +16 -5
- package/src/pages.js +61 -51
- package/src/public-assets.js +1 -1
- package/src/{rewind.js → reframe.js} +1 -1
- package/src/rehype-plugins/link-resolve.js +2 -2
- package/src/stage-logger.js +10 -6
- package/src/state.js +31 -4
- package/src/utils.js +23 -1
- package/src/vite-plugins.js +17 -4
- package/themes/default/components/ThemeSearchBox.client.jsx +120 -11
- package/themes/default/index.js +2 -2
- package/themes/default/pages/404.mdx +4 -0
- package/themes/default/pages/index.mdx +7 -9
- package/themes/default/pages/offline.mdx +11 -0
- package/themes/default/sources/style.css +248 -169
- package/themes/default/src/nav-tree.jsx +112 -0
- package/themes/default/{page.jsx → src/page.jsx} +10 -55
- /package/themes/default/{heading.jsx → src/heading.jsx} +0 -0
package/src/client/sw.js
ADDED
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
/* Copyright Yukino Song, SudoMaker Ltd.
|
|
2
|
+
*
|
|
3
|
+
* Licensed to the Apache Software Foundation (ASF) under one
|
|
4
|
+
* or more contributor license agreements. See the NOTICE file
|
|
5
|
+
* distributed with this work for additional information
|
|
6
|
+
* regarding copyright ownership. The ASF licenses this file
|
|
7
|
+
* to you under the Apache License, Version 2.0 (the
|
|
8
|
+
* "License"); you may not use this file except in compliance
|
|
9
|
+
* with the License. You may obtain a copy of the License at
|
|
10
|
+
*
|
|
11
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
*
|
|
13
|
+
* Unless required by applicable law or agreed to in writing,
|
|
14
|
+
* software distributed under the License is distributed on an
|
|
15
|
+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
16
|
+
* KIND, either express or implied. See the License for the
|
|
17
|
+
* specific language governing permissions and limitations
|
|
18
|
+
* under the License.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { clientsClaim } from 'workbox-core'
|
|
22
|
+
import { registerRoute } from 'workbox-routing'
|
|
23
|
+
import { cached, cachedStr } from '../utils.js'
|
|
24
|
+
|
|
25
|
+
const __WB_MANIFEST = self.__WB_MANIFEST
|
|
26
|
+
const BATCH_SIZE = 5
|
|
27
|
+
const REVISION_HEADER = 'X-Methanol-Revision'
|
|
28
|
+
|
|
29
|
+
self.skipWaiting()
|
|
30
|
+
clientsClaim()
|
|
31
|
+
|
|
32
|
+
const resolveBasePrefix = cached(() => {
|
|
33
|
+
let base = import.meta.env?.BASE_URL || '/'
|
|
34
|
+
if (!base || base === '/' || base === './') return ''
|
|
35
|
+
if (base.startsWith('http://') || base.startsWith('https://')) {
|
|
36
|
+
try {
|
|
37
|
+
base = new URL(base).pathname
|
|
38
|
+
} catch {
|
|
39
|
+
return ''
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!base.startsWith('/')) return ''
|
|
43
|
+
if (base.endsWith('/')) base = base.slice(0, -1)
|
|
44
|
+
return base
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const resolveCurrentBasePrefix = cached(() => {
|
|
48
|
+
const prefix = resolveBasePrefix()
|
|
49
|
+
if (!prefix) {
|
|
50
|
+
return self.location.origin
|
|
51
|
+
}
|
|
52
|
+
return new URL(prefix, self.location.origin).href
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const withBase = cachedStr((path) => {
|
|
56
|
+
const prefix = resolveBasePrefix()
|
|
57
|
+
if (!prefix || path.startsWith(`${prefix}/`)) return path
|
|
58
|
+
return `${prefix}${path}`
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const NOT_FOUND_URL = new URL(withBase('/404.html'), self.location.origin).href.toLowerCase()
|
|
62
|
+
const OFFLINE_FALLBACK_URL = new URL(withBase('/offline.html'), self.location.origin).href.toLowerCase()
|
|
63
|
+
|
|
64
|
+
const PAGES_CACHE = withBase(':methanol-pages-swr')
|
|
65
|
+
const ASSETS_CACHE = withBase(':methanol-assets-swr')
|
|
66
|
+
|
|
67
|
+
const stripBase = cachedStr((url) => {
|
|
68
|
+
const base = resolveCurrentBasePrefix()
|
|
69
|
+
if (!base) return url
|
|
70
|
+
if (url === base) return '/'
|
|
71
|
+
if (url.startsWith(`${base}/`)) return url.slice(base.length)
|
|
72
|
+
return url
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
function isRootOrAssets(url) {
|
|
76
|
+
const basePath = stripBase(url)
|
|
77
|
+
if (basePath.startsWith('/assets/')) return true
|
|
78
|
+
const trimmed = basePath.startsWith('/') ? basePath.slice(1) : basePath
|
|
79
|
+
return trimmed !== '' && !trimmed.includes('/')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const getManifestEntries = cached(() => {
|
|
83
|
+
const entries = []
|
|
84
|
+
for (const entry of __WB_MANIFEST) {
|
|
85
|
+
if (!entry?.url) continue
|
|
86
|
+
entries.push({
|
|
87
|
+
url: new URL(entry.url, self.location.href).toString(),
|
|
88
|
+
revision: entry.revision
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
return entries
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const getManifestIndex = cached(() => {
|
|
95
|
+
const map = new Map()
|
|
96
|
+
for (const entry of getManifestEntries()) {
|
|
97
|
+
map.set(manifestKey(entry.url), entry.revision ?? null)
|
|
98
|
+
}
|
|
99
|
+
return map
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
function collectManifestUrls() {
|
|
103
|
+
return getManifestEntries().map((entry) => entry.url)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function prioritizeManifestUrls(urls) {
|
|
107
|
+
const prioritized = []
|
|
108
|
+
const other = []
|
|
109
|
+
|
|
110
|
+
for (const url of urls) {
|
|
111
|
+
const lower = url.toLowerCase()
|
|
112
|
+
if (lower.endsWith('.css') && isRootOrAssets(url)) {
|
|
113
|
+
prioritized.push(url)
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
if ((lower.endsWith('.js') || lower.endsWith('.mjs')) && isRootOrAssets(url)) {
|
|
117
|
+
prioritized.push(url)
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
if (lower.endsWith('.html') && (lower === NOT_FOUND_URL || lower === OFFLINE_FALLBACK_URL)) {
|
|
121
|
+
prioritized.unshift(url)
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
other.push(url)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return [prioritized, other]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Precache prioritized entries during install
|
|
131
|
+
self.addEventListener('install', (event) => {
|
|
132
|
+
event.waitUntil(
|
|
133
|
+
(async () => {
|
|
134
|
+
try {
|
|
135
|
+
await idbSet(KEY_FORCE, 1)
|
|
136
|
+
await idbSet(KEY_INDEX, 0)
|
|
137
|
+
} catch {}
|
|
138
|
+
const pageCache = await openCache(PAGES_CACHE)
|
|
139
|
+
const assetCache = await openCache(ASSETS_CACHE)
|
|
140
|
+
const manifestEntries = getManifestEntries()
|
|
141
|
+
const manifestMap = buildManifestMap(manifestEntries)
|
|
142
|
+
const previousMap = await loadStoredManifestMap()
|
|
143
|
+
const manifestUrls = manifestEntries.map((entry) => entry.url)
|
|
144
|
+
const [prioritized] = prioritizeManifestUrls(manifestUrls)
|
|
145
|
+
const { failedIndex } = await runConcurrentQueue(prioritized, {
|
|
146
|
+
concurrency: BATCH_SIZE,
|
|
147
|
+
handler: async (url) => {
|
|
148
|
+
const isHtml = url.endsWith('.html')
|
|
149
|
+
const cacheName = isHtml ? PAGES_CACHE : ASSETS_CACHE
|
|
150
|
+
const cached = await matchCache(cacheName, url)
|
|
151
|
+
const key = manifestKey(url)
|
|
152
|
+
const currentRevision = manifestMap.get(key) ?? null
|
|
153
|
+
const previousRevision = previousMap.get(key) ?? null
|
|
154
|
+
const shouldFetch = shouldFetchWithRevision({
|
|
155
|
+
cached,
|
|
156
|
+
currentRevision,
|
|
157
|
+
previousRevision
|
|
158
|
+
})
|
|
159
|
+
if (!shouldFetch) return true
|
|
160
|
+
const cache = isHtml ? pageCache : assetCache
|
|
161
|
+
return fetchAndCache(cache, url, currentRevision)
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
if (failedIndex !== null) {
|
|
165
|
+
throw new Error('install cache failed')
|
|
166
|
+
}
|
|
167
|
+
})()
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
self.addEventListener('activate', (event) => {
|
|
172
|
+
warmManifestResumable()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
function stripSearch(urlString) {
|
|
176
|
+
const u = new URL(urlString, self.location.href)
|
|
177
|
+
u.search = ''
|
|
178
|
+
u.hash = ''
|
|
179
|
+
return u
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function manifestKey(urlString) {
|
|
183
|
+
return stripSearch(urlString).toString()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function hasExtension(pathname) {
|
|
187
|
+
const last = pathname.split('/').pop() || ''
|
|
188
|
+
return last.includes('.')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Clean-url mapping rules (network fallback candidates)
|
|
193
|
+
* - /foo/ -> /foo/index.html
|
|
194
|
+
* - /foo -> /foo.html
|
|
195
|
+
*/
|
|
196
|
+
function htmlFallback(pathname) {
|
|
197
|
+
if (pathname.endsWith('/')) return pathname + 'index.html'
|
|
198
|
+
if (!hasExtension(pathname)) return pathname + '.html'
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Normalized HTML cache key:
|
|
204
|
+
* - /foo/ -> /foo/index.html
|
|
205
|
+
* - /foo -> /foo.html
|
|
206
|
+
* - ignore query params
|
|
207
|
+
*/
|
|
208
|
+
function normalizeNavigationURL(url) {
|
|
209
|
+
const u = stripSearch(url.toString())
|
|
210
|
+
|
|
211
|
+
if (u.pathname.endsWith('/')) {
|
|
212
|
+
u.pathname += 'index.html'
|
|
213
|
+
return u
|
|
214
|
+
}
|
|
215
|
+
if (!hasExtension(u.pathname)) {
|
|
216
|
+
u.pathname += '.html'
|
|
217
|
+
return u
|
|
218
|
+
}
|
|
219
|
+
return u
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isHtmlNavigation(request) {
|
|
223
|
+
return request.mode === 'navigate'
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isPrefetch(request) {
|
|
227
|
+
const purpose = request.headers.get('Purpose') || request.headers.get('Sec-Purpose')
|
|
228
|
+
return purpose === 'prefetch'
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function openCache(name) {
|
|
232
|
+
return caches.open(name)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const asleep = (timeout) => new Promise((r) => setTimeout(r, timeout))
|
|
236
|
+
|
|
237
|
+
async function runConcurrentQueue(list, { concurrency, handler, stopOnError = true }) {
|
|
238
|
+
if (!list.length) return { ok: true, failedIndex: null }
|
|
239
|
+
|
|
240
|
+
let cursor = 0
|
|
241
|
+
let failedIndex = null
|
|
242
|
+
|
|
243
|
+
const workerSet = new Array(concurrency)
|
|
244
|
+
|
|
245
|
+
const worker = async (index, data) => {
|
|
246
|
+
const ok = await handler(data)
|
|
247
|
+
|
|
248
|
+
if (!ok && stopOnError) {
|
|
249
|
+
if (failedIndex === null || index < failedIndex) failedIndex = index
|
|
250
|
+
return -1
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return index
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
for (let i = 0; i < concurrency && cursor < list.length; i++) {
|
|
257
|
+
workerSet[i] = worker(i, list[cursor++], i)
|
|
258
|
+
await asleep(5)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
while (cursor < list.length) {
|
|
262
|
+
const finishedIndex = await Promise.race(workerSet)
|
|
263
|
+
if (finishedIndex < 0) {
|
|
264
|
+
break
|
|
265
|
+
}
|
|
266
|
+
await asleep(1)
|
|
267
|
+
workerSet[finishedIndex] = worker(finishedIndex, list[cursor++])
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
await Promise.allSettled(workerSet)
|
|
271
|
+
|
|
272
|
+
return { ok: failedIndex === null, failedIndex }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function shouldFetchWithRevision({ cached, currentRevision, previousRevision }) {
|
|
276
|
+
if (!cached) return true
|
|
277
|
+
if (currentRevision == null) return false
|
|
278
|
+
if (previousRevision == null) return true
|
|
279
|
+
return currentRevision !== previousRevision
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function shouldRevalidateCached(cached, currentRevision) {
|
|
283
|
+
if (!cached) return true
|
|
284
|
+
if (currentRevision == null) return false
|
|
285
|
+
const cachedRevision = cached.headers?.get?.(REVISION_HEADER)
|
|
286
|
+
if (cachedRevision == null) return true
|
|
287
|
+
return cachedRevision !== String(currentRevision)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function bufferResponse(response, revision = null) {
|
|
291
|
+
const body = await response.clone().arrayBuffer()
|
|
292
|
+
const headers = new Headers(response.headers)
|
|
293
|
+
if (revision != null) {
|
|
294
|
+
headers.set(REVISION_HEADER, String(revision))
|
|
295
|
+
}
|
|
296
|
+
return new Response(body, {
|
|
297
|
+
status: response.status,
|
|
298
|
+
statusText: response.statusText,
|
|
299
|
+
headers
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function fetchAndCache(cache, urlString, revision = null) {
|
|
304
|
+
const req = new Request(urlString, {
|
|
305
|
+
redirect: 'follow',
|
|
306
|
+
cache: 'no-store',
|
|
307
|
+
credentials: 'same-origin'
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
let res
|
|
311
|
+
try {
|
|
312
|
+
res = await fetch(req)
|
|
313
|
+
} catch {
|
|
314
|
+
return false
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!res || !res.ok || res.type === 'opaqueredirect') return false
|
|
318
|
+
const responseUrl = res.url || ''
|
|
319
|
+
|
|
320
|
+
let buffered
|
|
321
|
+
try {
|
|
322
|
+
buffered = await bufferResponse(res, revision)
|
|
323
|
+
} catch {
|
|
324
|
+
return false
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
await cache.put(stripSearch(urlString).toString(), buffered.clone())
|
|
328
|
+
if (responseUrl && responseUrl !== urlString) {
|
|
329
|
+
const redirectKey = stripSearch(responseUrl).toString()
|
|
330
|
+
if (redirectKey !== stripSearch(urlString).toString()) {
|
|
331
|
+
await cache.put(redirectKey, buffered.clone())
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return true
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function matchAnyCache(urlString) {
|
|
338
|
+
const keyUrl = stripSearch(urlString).toString()
|
|
339
|
+
return caches.match(keyUrl, { ignoreSearch: true })
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function matchCache(cacheName, urlString) {
|
|
343
|
+
const cache = await openCache(cacheName)
|
|
344
|
+
const keyUrl = stripSearch(urlString).toString()
|
|
345
|
+
return cache.match(keyUrl, { ignoreSearch: true })
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function putCache(cacheName, urlString, response, revision = null) {
|
|
349
|
+
const cache = await openCache(cacheName)
|
|
350
|
+
const keyUrl = stripSearch(urlString).toString()
|
|
351
|
+
let toCache = response
|
|
352
|
+
if (cacheName === PAGES_CACHE || cacheName === ASSETS_CACHE) {
|
|
353
|
+
try {
|
|
354
|
+
toCache = await bufferResponse(response, revision)
|
|
355
|
+
} catch {
|
|
356
|
+
return false
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
await cache.put(keyUrl, toCache.clone())
|
|
362
|
+
} catch {
|
|
363
|
+
return false
|
|
364
|
+
}
|
|
365
|
+
return true
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function fetchWithTimeout(request, timeout = 8000) {
|
|
369
|
+
return Promise.race([
|
|
370
|
+
fetch(request),
|
|
371
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeout))
|
|
372
|
+
])
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function fetchWithCleanUrlFallback(event, originalRequest, { usePreload = true, allowNotOk = false } = {}) {
|
|
376
|
+
const originalUrl = new URL(originalRequest.url)
|
|
377
|
+
|
|
378
|
+
// Prefer preload response for the ORIGINAL navigation URL
|
|
379
|
+
if (usePreload && event.preloadResponse) {
|
|
380
|
+
try {
|
|
381
|
+
// Ensure preload has a chance to settle to avoid Chrome cancellation warnings.
|
|
382
|
+
event.waitUntil(event.preloadResponse.catch(() => {}))
|
|
383
|
+
const preloaded = await event.preloadResponse
|
|
384
|
+
if (preloaded && preloaded.ok) return preloaded
|
|
385
|
+
} catch {}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
const res = await fetchWithTimeout(
|
|
390
|
+
new Request(originalRequest.url, {
|
|
391
|
+
method: 'GET',
|
|
392
|
+
headers: originalRequest.headers,
|
|
393
|
+
credentials: originalRequest.credentials,
|
|
394
|
+
redirect: 'follow',
|
|
395
|
+
referrer: originalRequest.referrer,
|
|
396
|
+
referrerPolicy: originalRequest.referrerPolicy,
|
|
397
|
+
integrity: originalRequest.integrity,
|
|
398
|
+
cache: originalRequest.cache
|
|
399
|
+
})
|
|
400
|
+
)
|
|
401
|
+
if (res && (allowNotOk || res.ok)) return res
|
|
402
|
+
} catch {}
|
|
403
|
+
|
|
404
|
+
const fallback = htmlFallback(originalUrl.pathname)
|
|
405
|
+
|
|
406
|
+
if (!fallback) {
|
|
407
|
+
return null
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const u2 = new URL(originalUrl.toString())
|
|
411
|
+
u2.pathname = fallback
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
const req2 = new Request(u2.toString(), {
|
|
415
|
+
method: 'GET',
|
|
416
|
+
headers: originalRequest.headers,
|
|
417
|
+
credentials: originalRequest.credentials,
|
|
418
|
+
redirect: 'follow',
|
|
419
|
+
referrer: originalRequest.referrer,
|
|
420
|
+
referrerPolicy: originalRequest.referrerPolicy,
|
|
421
|
+
integrity: originalRequest.integrity,
|
|
422
|
+
cache: originalRequest.cache
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
const res2 = await fetchWithTimeout(req2)
|
|
426
|
+
if (res2 && (allowNotOk || res2.ok)) return res2
|
|
427
|
+
} catch {}
|
|
428
|
+
|
|
429
|
+
return null
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function withStatus(response, status) {
|
|
433
|
+
const headers = new Headers(response.headers)
|
|
434
|
+
return new Response(response.clone().body, { status, statusText: response.statusText, headers })
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function serveNotFound() {
|
|
438
|
+
const cached = await matchCache(PAGES_CACHE, NOT_FOUND_URL)
|
|
439
|
+
if (cached) return withStatus(cached, 404)
|
|
440
|
+
|
|
441
|
+
try {
|
|
442
|
+
const res = await fetch(NOT_FOUND_URL)
|
|
443
|
+
if (res && res.ok) {
|
|
444
|
+
await putCache(PAGES_CACHE, NOT_FOUND_URL, res.clone())
|
|
445
|
+
return withStatus(res, 404)
|
|
446
|
+
}
|
|
447
|
+
} catch {}
|
|
448
|
+
|
|
449
|
+
return new Response('Not Found', {
|
|
450
|
+
status: 404,
|
|
451
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function serveOffline() {
|
|
456
|
+
const cached = await matchCache(PAGES_CACHE, OFFLINE_FALLBACK_URL)
|
|
457
|
+
if (cached) return withStatus(cached, 503)
|
|
458
|
+
|
|
459
|
+
const anyCached = await matchAnyCache(OFFLINE_FALLBACK_URL)
|
|
460
|
+
if (anyCached) {
|
|
461
|
+
await putCache(PAGES_CACHE, OFFLINE_FALLBACK_URL, anyCached.clone())
|
|
462
|
+
return withStatus(anyCached, 503)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return new Response('Offline', {
|
|
466
|
+
status: 503,
|
|
467
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// NAVIGATIONS: cache-first for manifest pages, network fallback for others
|
|
472
|
+
registerRoute(
|
|
473
|
+
({ request, url }) => (isHtmlNavigation(request) || isPrefetch(request)) && url.origin === self.location.origin,
|
|
474
|
+
async ({ event, request }) => {
|
|
475
|
+
const normalizedKey = normalizeNavigationURL(new URL(request.url)).toString()
|
|
476
|
+
const key = manifestKey(normalizedKey)
|
|
477
|
+
const manifestRevision = getManifestIndex().get(key) ?? null
|
|
478
|
+
const inManifest = getManifestIndex().has(key)
|
|
479
|
+
|
|
480
|
+
if (inManifest) {
|
|
481
|
+
const cached = await matchCache(PAGES_CACHE, normalizedKey)
|
|
482
|
+
const shouldRevalidate = shouldRevalidateCached(cached, manifestRevision)
|
|
483
|
+
if (cached && !shouldRevalidate) return cached
|
|
484
|
+
if (cached && shouldRevalidate) {
|
|
485
|
+
const fresh = await fetchWithCleanUrlFallback(event, request, {
|
|
486
|
+
usePreload: isHtmlNavigation(request),
|
|
487
|
+
allowNotOk: true
|
|
488
|
+
})
|
|
489
|
+
if (fresh && fresh.status === 200) {
|
|
490
|
+
await putCache(PAGES_CACHE, normalizedKey, fresh.clone(), manifestRevision)
|
|
491
|
+
return fresh
|
|
492
|
+
}
|
|
493
|
+
if (fresh && fresh.status === 404) {
|
|
494
|
+
return serveNotFound()
|
|
495
|
+
}
|
|
496
|
+
if (fresh) return fresh
|
|
497
|
+
return cached
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const fresh = await fetchWithCleanUrlFallback(event, request, {
|
|
502
|
+
usePreload: isHtmlNavigation(request),
|
|
503
|
+
allowNotOk: true
|
|
504
|
+
})
|
|
505
|
+
if (fresh && fresh.status === 200) {
|
|
506
|
+
if (inManifest) {
|
|
507
|
+
await putCache(PAGES_CACHE, normalizedKey, fresh.clone(), manifestRevision)
|
|
508
|
+
}
|
|
509
|
+
return fresh
|
|
510
|
+
}
|
|
511
|
+
if (fresh && fresh.status === 404) {
|
|
512
|
+
return serveNotFound()
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (fresh) return fresh
|
|
516
|
+
|
|
517
|
+
return serveOffline()
|
|
518
|
+
}
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
registerRoute(
|
|
522
|
+
({ request, url }) =>
|
|
523
|
+
url.origin === self.location.origin &&
|
|
524
|
+
request.method === 'GET' &&
|
|
525
|
+
request.destination !== 'document' &&
|
|
526
|
+
!isHtmlNavigation(request) &&
|
|
527
|
+
getManifestIndex().has(manifestKey(request.url)),
|
|
528
|
+
async ({ request }) => {
|
|
529
|
+
const key = manifestKey(request.url)
|
|
530
|
+
const manifestRevision = getManifestIndex().get(key) ?? null
|
|
531
|
+
const cached = await matchCache(ASSETS_CACHE, key)
|
|
532
|
+
const shouldRevalidate = shouldRevalidateCached(cached, manifestRevision)
|
|
533
|
+
if (cached && !shouldRevalidate) return cached
|
|
534
|
+
|
|
535
|
+
try {
|
|
536
|
+
const res = await fetch(request)
|
|
537
|
+
if (res && res.status === 200) {
|
|
538
|
+
await putCache(ASSETS_CACHE, key, res.clone(), manifestRevision)
|
|
539
|
+
}
|
|
540
|
+
return res
|
|
541
|
+
} catch {
|
|
542
|
+
if (cached) return cached
|
|
543
|
+
return new Response(null, { status: 503 })
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
const DB_NAME = withBase(':methanol-pwa-warm-db')
|
|
549
|
+
const DB_STORE = 'kv'
|
|
550
|
+
const KEY_INDEX = 'warmIndex'
|
|
551
|
+
const KEY_LEASE = 'warmLease'
|
|
552
|
+
const KEY_FORCE = 'warmForce'
|
|
553
|
+
const KEY_MANIFEST = 'warmManifest'
|
|
554
|
+
|
|
555
|
+
function idbOpen() {
|
|
556
|
+
return new Promise((resolve, reject) => {
|
|
557
|
+
const req = indexedDB.open(DB_NAME, 1)
|
|
558
|
+
req.onupgradeneeded = () => {
|
|
559
|
+
const db = req.result
|
|
560
|
+
if (!db.objectStoreNames.contains(DB_STORE)) db.createObjectStore(DB_STORE)
|
|
561
|
+
}
|
|
562
|
+
req.onsuccess = () => resolve(req.result)
|
|
563
|
+
req.onerror = () => reject(req.error)
|
|
564
|
+
})
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function idbGet(key) {
|
|
568
|
+
const db = await idbOpen()
|
|
569
|
+
try {
|
|
570
|
+
return await new Promise((resolve, reject) => {
|
|
571
|
+
const tx = db.transaction(DB_STORE, 'readonly')
|
|
572
|
+
const store = tx.objectStore(DB_STORE)
|
|
573
|
+
const req = store.get(key)
|
|
574
|
+
req.onsuccess = () => resolve(req.result)
|
|
575
|
+
req.onerror = () => reject(req.error)
|
|
576
|
+
})
|
|
577
|
+
} finally {
|
|
578
|
+
db.close()
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function idbSet(key, value) {
|
|
583
|
+
const db = await idbOpen()
|
|
584
|
+
try {
|
|
585
|
+
await new Promise((resolve, reject) => {
|
|
586
|
+
const tx = db.transaction(DB_STORE, 'readwrite')
|
|
587
|
+
const store = tx.objectStore(DB_STORE)
|
|
588
|
+
const req = store.put(value, key)
|
|
589
|
+
req.onsuccess = () => resolve()
|
|
590
|
+
req.onerror = () => reject(req.error)
|
|
591
|
+
})
|
|
592
|
+
} finally {
|
|
593
|
+
db.close()
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function nowMs() {
|
|
598
|
+
return Date.now()
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function randomId() {
|
|
602
|
+
return `${nowMs().toString(36)}-${Math.random().toString(36).slice(2)}`
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function buildManifestMap(entries) {
|
|
606
|
+
const map = new Map()
|
|
607
|
+
for (const entry of entries || []) {
|
|
608
|
+
if (!entry?.url) continue
|
|
609
|
+
map.set(manifestKey(entry.url), entry.revision ?? null)
|
|
610
|
+
}
|
|
611
|
+
return map
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function loadStoredManifestMap() {
|
|
615
|
+
const stored = await idbGet(KEY_MANIFEST)
|
|
616
|
+
if (!stored) return new Map()
|
|
617
|
+
if (Array.isArray(stored)) return buildManifestMap(stored)
|
|
618
|
+
return new Map()
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async function tryAcquireLease(leaseMs) {
|
|
622
|
+
const leaseId = randomId()
|
|
623
|
+
const deadline = nowMs() + leaseMs
|
|
624
|
+
|
|
625
|
+
const cur = await idbGet(KEY_LEASE)
|
|
626
|
+
if (cur && cur.expiresAt && cur.expiresAt > nowMs()) return null
|
|
627
|
+
|
|
628
|
+
await idbSet(KEY_LEASE, { id: leaseId, expiresAt: deadline })
|
|
629
|
+
|
|
630
|
+
const verify = await idbGet(KEY_LEASE)
|
|
631
|
+
if (!verify || verify.id !== leaseId) return null
|
|
632
|
+
|
|
633
|
+
return { id: leaseId }
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function renewLease(lease, leaseMs) {
|
|
637
|
+
const cur = await idbGet(KEY_LEASE)
|
|
638
|
+
if (!cur || cur.id !== lease.id) return false
|
|
639
|
+
await idbSet(KEY_LEASE, { id: lease.id, expiresAt: nowMs() + leaseMs })
|
|
640
|
+
return true
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async function releaseLease(lease) {
|
|
644
|
+
const cur = await idbGet(KEY_LEASE)
|
|
645
|
+
if (cur && cur.id === lease.id) {
|
|
646
|
+
await idbSet(KEY_LEASE, { id: '', expiresAt: 0 })
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function warmManifestResumable({ force = false } = {}) {
|
|
651
|
+
if (!__WB_MANIFEST.length) return
|
|
652
|
+
|
|
653
|
+
const forceFlag = await idbGet(KEY_FORCE)
|
|
654
|
+
if (forceFlag) force = true
|
|
655
|
+
|
|
656
|
+
let index = (await idbGet(KEY_INDEX)) ?? 0
|
|
657
|
+
if (index < 0) {
|
|
658
|
+
if (!force) return
|
|
659
|
+
index = 0
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const leaseMs = 30_000
|
|
663
|
+
const lease = await tryAcquireLease(leaseMs)
|
|
664
|
+
if (!lease) return
|
|
665
|
+
|
|
666
|
+
let completed = false
|
|
667
|
+
try {
|
|
668
|
+
const manifestEntries = getManifestEntries()
|
|
669
|
+
const manifestMap = buildManifestMap(manifestEntries)
|
|
670
|
+
const previousMap = await loadStoredManifestMap()
|
|
671
|
+
const [, urls] = prioritizeManifestUrls(manifestEntries.map((entry) => entry.url))
|
|
672
|
+
if (!urls.length) return
|
|
673
|
+
if (index >= urls.length) {
|
|
674
|
+
completed = true
|
|
675
|
+
return
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const startIndex = index
|
|
679
|
+
const { failedIndex } = await runConcurrentQueue(urls.slice(startIndex), {
|
|
680
|
+
concurrency: BATCH_SIZE,
|
|
681
|
+
handler: async (abs) => {
|
|
682
|
+
const leaseOk = await renewLease(lease, leaseMs)
|
|
683
|
+
if (!leaseOk) return false
|
|
684
|
+
|
|
685
|
+
const isHtml = abs.endsWith('.html')
|
|
686
|
+
const key = manifestKey(abs)
|
|
687
|
+
const currentRevision = manifestMap.get(key) ?? null
|
|
688
|
+
const previousRevision = previousMap.get(key) ?? null
|
|
689
|
+
|
|
690
|
+
if (isHtml) {
|
|
691
|
+
const cached = await matchCache(PAGES_CACHE, abs)
|
|
692
|
+
const shouldFetch = shouldFetchWithRevision({
|
|
693
|
+
cached,
|
|
694
|
+
currentRevision,
|
|
695
|
+
previousRevision
|
|
696
|
+
})
|
|
697
|
+
if (!shouldFetch) return true
|
|
698
|
+
|
|
699
|
+
let res
|
|
700
|
+
try {
|
|
701
|
+
res = await fetch(abs)
|
|
702
|
+
} catch {
|
|
703
|
+
return false
|
|
704
|
+
}
|
|
705
|
+
if (!res || res.status !== 200) return false
|
|
706
|
+
const ok = await putCache(PAGES_CACHE, abs, res, currentRevision)
|
|
707
|
+
if (!ok) return false
|
|
708
|
+
} else {
|
|
709
|
+
const cached = await matchCache(ASSETS_CACHE, abs)
|
|
710
|
+
const shouldFetch = shouldFetchWithRevision({
|
|
711
|
+
cached,
|
|
712
|
+
currentRevision,
|
|
713
|
+
previousRevision
|
|
714
|
+
})
|
|
715
|
+
if (!shouldFetch) return true
|
|
716
|
+
|
|
717
|
+
let res
|
|
718
|
+
try {
|
|
719
|
+
res = await fetch(abs)
|
|
720
|
+
} catch {
|
|
721
|
+
return false
|
|
722
|
+
}
|
|
723
|
+
if (!res || res.status !== 200) return false
|
|
724
|
+
const ok = await putCache(ASSETS_CACHE, abs, res, currentRevision)
|
|
725
|
+
if (!ok) return false
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
return true
|
|
729
|
+
}
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
if (failedIndex !== null) {
|
|
733
|
+
await idbSet(KEY_INDEX, startIndex + failedIndex)
|
|
734
|
+
return
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
await idbSet(KEY_INDEX, -1) // done
|
|
738
|
+
completed = true
|
|
739
|
+
} finally {
|
|
740
|
+
if (completed) {
|
|
741
|
+
await idbSet(KEY_MANIFEST, getManifestEntries())
|
|
742
|
+
await idbSet(KEY_FORCE, 0)
|
|
743
|
+
}
|
|
744
|
+
await releaseLease(lease)
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
self.addEventListener('message', (event) => {
|
|
749
|
+
if (event.data?.type !== 'METHANOL_WARM_MANIFEST') return
|
|
750
|
+
warmManifestResumable()
|
|
751
|
+
})
|