|
4 | 4 | })(this, (function(exports) { |
5 | 5 | "use strict"; |
6 | 6 | /*! |
7 | | - Turbo 8.0.20 |
8 | | - Copyright © 2025 37signals LLC |
| 7 | + Turbo 8.0.23 |
| 8 | + Copyright © 2026 37signals LLC |
9 | 9 | */ class Rule { |
10 | | - constructor({handler: handler, match: match = /.*/} = {}) { |
| 10 | + constructor({handler: handler, match: match = /.*/, except: except} = {}) { |
11 | 11 | this.handler = handler; |
12 | 12 | this.match = match; |
| 13 | + this.except = except; |
13 | 14 | } |
14 | 15 | matches(request) { |
15 | | - if (typeof this.match === "function") return this.match(request); |
16 | | - const regexes = Array.isArray(this.match) ? this.match : [ this.match ]; |
| 16 | + return this.#matchesCondition(request, this.match) && !this.#matchesCondition(request, this.except); |
| 17 | + } |
| 18 | + #matchesCondition(request, condition) { |
| 19 | + if (!condition) return false; |
| 20 | + if (typeof condition === "function") return condition(request); |
| 21 | + const regexes = Array.isArray(condition) ? condition : [ condition ]; |
17 | 22 | return regexes.some((regex => regex.test(request.url))); |
18 | 23 | } |
19 | 24 | async handle(event) { |
|
22 | 27 | return response; |
23 | 28 | } |
24 | 29 | } |
25 | | - class ServiceWorker { |
26 | | - #started=false; |
27 | | - #rules=[]; |
28 | | - addRule(rule) { |
29 | | - this.#rules.push(new Rule(rule)); |
30 | | - } |
31 | | - start() { |
32 | | - this.#warnIfNoRulesConfigured(); |
33 | | - if (!this.#started) { |
34 | | - self.addEventListener("install", this.installed); |
35 | | - self.addEventListener("message", this.messageReceived); |
36 | | - self.addEventListener("fetch", this.fetch); |
37 | | - this.#started = true; |
38 | | - } |
39 | | - } |
40 | | - installed=event => { |
41 | | - console.log("Service worker installed"); |
42 | | - }; |
43 | | - messageReceived=event => { |
44 | | - if (this[event.data.action]) { |
45 | | - const actionCall = this[event.data.action](event.data.params); |
46 | | - event.waitUntil(actionCall); |
47 | | - } |
48 | | - }; |
49 | | - fetch=event => { |
50 | | - if (this.#canInterceptRequest(event.request)) { |
51 | | - const rule = this.#findMatchingRule(event.request); |
52 | | - if (!rule) return; |
53 | | - const response = rule.handle(event); |
54 | | - event.respondWith(response); |
55 | | - } |
56 | | - }; |
57 | | - #warnIfNoRulesConfigured() { |
58 | | - if (this.#rules.length === 0) { |
59 | | - console.warn("No rules configured for service worker. No requests will be intercepted."); |
60 | | - } |
61 | | - } |
62 | | - #canInterceptRequest(request) { |
63 | | - const url = new URL(request.url, location.href); |
64 | | - return request.method === "GET" && url.protocol.startsWith("http"); |
65 | | - } |
66 | | - #findMatchingRule(request) { |
67 | | - return this.#rules.find((rule => rule.matches(request))); |
68 | | - } |
69 | | - } |
70 | 30 | const DATABASE_NAME = "turbo-offline-database"; |
71 | 31 | const DATABASE_VERSION = 1; |
72 | 32 | const STORE_NAME = "cache-registry"; |
| 33 | + function deleteCacheRegistries() { |
| 34 | + return new Promise(((resolve, reject) => { |
| 35 | + const request = indexedDB.deleteDatabase(DATABASE_NAME); |
| 36 | + request.onsuccess = () => resolve(); |
| 37 | + request.onerror = () => reject(request.error); |
| 38 | + })).then((() => { |
| 39 | + cacheRegistryDatabase = null; |
| 40 | + })); |
| 41 | + } |
73 | 42 | class CacheRegistryDatabase { |
74 | | - get(cacheName, key) { |
| 43 | + get(key) { |
75 | 44 | const getOp = store => this.#requestToPromise(store.get(key)); |
76 | 45 | return this.#performOperation(STORE_NAME, getOp, "readonly"); |
77 | 46 | } |
78 | | - has(cacheName, key) { |
| 47 | + has(key) { |
79 | 48 | const countOp = store => this.#requestToPromise(store.count(key)); |
80 | 49 | return this.#performOperation(STORE_NAME, countOp, "readonly").then((result => result === 1)); |
81 | 50 | } |
|
92 | 61 | }; |
93 | 62 | return this.#performOperation(STORE_NAME, putOp, "readwrite"); |
94 | 63 | } |
95 | | - getTimestamp(cacheName, key) { |
96 | | - return this.get(cacheName, key).then((result => result?.timestamp)); |
| 64 | + getTimestamp(key) { |
| 65 | + return this.get(key).then((result => result?.timestamp)); |
97 | 66 | } |
98 | 67 | getOlderThan(cacheName, timestamp) { |
99 | 68 | const getOlderThanOp = store => { |
|
104 | 73 | }; |
105 | 74 | return this.#performOperation(STORE_NAME, getOlderThanOp, "readonly"); |
106 | 75 | } |
107 | | - delete(cacheName, key) { |
| 76 | + delete(key) { |
108 | 77 | const deleteOp = store => this.#requestToPromise(store.delete(key)); |
109 | 78 | return this.#performOperation(STORE_NAME, deleteOp, "readwrite"); |
110 | 79 | } |
|
160 | 129 | this.database = getDatabase(); |
161 | 130 | } |
162 | 131 | get(key) { |
163 | | - return this.database.get(this.cacheName, key); |
| 132 | + return this.database.get(key); |
164 | 133 | } |
165 | 134 | has(key) { |
166 | | - return this.database.has(this.cacheName, key); |
| 135 | + return this.database.has(key); |
167 | 136 | } |
168 | 137 | put(key, value = {}) { |
169 | 138 | return this.database.put(this.cacheName, key, value); |
170 | 139 | } |
171 | 140 | getTimestamp(key) { |
172 | | - return this.database.getTimestamp(this.cacheName, key); |
| 141 | + return this.database.getTimestamp(key); |
173 | 142 | } |
174 | 143 | getOlderThan(timestamp) { |
175 | 144 | return this.database.getOlderThan(this.cacheName, timestamp); |
176 | 145 | } |
177 | 146 | delete(key) { |
178 | | - return this.database.delete(this.cacheName, key); |
| 147 | + return this.database.delete(key); |
| 148 | + } |
| 149 | + } |
| 150 | + class ServiceWorker { |
| 151 | + #started=false; |
| 152 | + #rules=[]; |
| 153 | + addRule(rule) { |
| 154 | + this.#rules.push(new Rule(rule)); |
| 155 | + } |
| 156 | + start() { |
| 157 | + this.#warnIfNoRulesConfigured(); |
| 158 | + if (!this.#started) { |
| 159 | + self.addEventListener("install", this.installed); |
| 160 | + self.addEventListener("message", this.messageReceived); |
| 161 | + self.addEventListener("fetch", this.fetch); |
| 162 | + this.#started = true; |
| 163 | + } |
| 164 | + } |
| 165 | + installed=event => { |
| 166 | + console.log("Service worker installed"); |
| 167 | + }; |
| 168 | + messageReceived=event => { |
| 169 | + if (this[event.data.action]) { |
| 170 | + const actionCall = this[event.data.action](event.data.params); |
| 171 | + event.waitUntil(actionCall); |
| 172 | + } |
| 173 | + }; |
| 174 | + fetch=event => { |
| 175 | + if (this.#canInterceptRequest(event.request)) { |
| 176 | + const rule = this.#findMatchingRule(event.request); |
| 177 | + if (!rule) return; |
| 178 | + const response = rule.handle(event); |
| 179 | + event.respondWith(response); |
| 180 | + } |
| 181 | + }; |
| 182 | + async clearCache() { |
| 183 | + const cacheNames = await caches.keys(); |
| 184 | + await Promise.all(cacheNames.map((name => caches.delete(name)))); |
| 185 | + await deleteCacheRegistries(); |
| 186 | + } |
| 187 | + #warnIfNoRulesConfigured() { |
| 188 | + if (this.#rules.length === 0) { |
| 189 | + console.warn("No rules configured for service worker. No requests will be intercepted."); |
| 190 | + } |
| 191 | + } |
| 192 | + #canInterceptRequest(request) { |
| 193 | + const url = new URL(request.url, location.href); |
| 194 | + return request.method === "GET" && url.protocol.startsWith("http"); |
| 195 | + } |
| 196 | + #findMatchingRule(request) { |
| 197 | + return this.#rules.find((rule => rule.matches(request))); |
179 | 198 | } |
180 | 199 | } |
181 | 200 | class CacheTrimmer { |
|
225 | 244 | console.debug(`Successfully trimmed ${expiredEntries.length} entries from cache "${this.cacheName}"`); |
226 | 245 | } |
227 | 246 | } |
| 247 | + async function buildPartialResponse(request, response) { |
| 248 | + if (response.status === 206) { |
| 249 | + return response; |
| 250 | + } |
| 251 | + const rangeHeader = request.headers.get("range"); |
| 252 | + if (!rangeHeader) { |
| 253 | + return response; |
| 254 | + } |
| 255 | + try { |
| 256 | + const {start: start, end: end} = parseRangeHeader(rangeHeader); |
| 257 | + const blob = await response.blob(); |
| 258 | + const {effectiveStart: effectiveStart, effectiveEnd: effectiveEnd} = calculateEffectiveBoundaries(blob, start, end); |
| 259 | + const slicedBlob = blob.slice(effectiveStart, effectiveEnd); |
| 260 | + const slicedSize = slicedBlob.size; |
| 261 | + const totalSize = blob.size; |
| 262 | + const partialResponse = new Response(slicedBlob, { |
| 263 | + status: 206, |
| 264 | + statusText: "Partial Content", |
| 265 | + headers: response.headers |
| 266 | + }); |
| 267 | + partialResponse.headers.set("Content-Length", slicedSize); |
| 268 | + partialResponse.headers.set("Content-Range", `bytes ${effectiveStart}-${effectiveEnd - 1}/${totalSize}`); |
| 269 | + return partialResponse; |
| 270 | + } catch (error) { |
| 271 | + console.warn("Range request error:", error.message); |
| 272 | + return new Response("", { |
| 273 | + status: 416, |
| 274 | + statusText: "Range Not Satisfiable" |
| 275 | + }); |
| 276 | + } |
| 277 | + } |
| 278 | + function parseRangeHeader(rangeHeader) { |
| 279 | + const normalized = rangeHeader.trim().toLowerCase(); |
| 280 | + if (!normalized.startsWith("bytes=")) { |
| 281 | + throw new Error("Range unit must be 'bytes'"); |
| 282 | + } |
| 283 | + if (normalized.includes(",")) { |
| 284 | + throw new Error("Multiple ranges are not supported"); |
| 285 | + } |
| 286 | + const rangeValue = normalized.slice(6); |
| 287 | + const match = rangeValue.match(/^(\d*)-(\d*)$/); |
| 288 | + if (!match) { |
| 289 | + throw new Error("Invalid range format"); |
| 290 | + } |
| 291 | + const [, startStr, endStr] = match; |
| 292 | + const start = startStr ? parseInt(startStr, 10) : undefined; |
| 293 | + const end = endStr ? parseInt(endStr, 10) : undefined; |
| 294 | + if (start === undefined && end === undefined) { |
| 295 | + throw new Error("Invalid range: both start and end are missing"); |
| 296 | + } |
| 297 | + return { |
| 298 | + start: start, |
| 299 | + end: end |
| 300 | + }; |
| 301 | + } |
| 302 | + function calculateEffectiveBoundaries(blob, start, end) { |
| 303 | + const size = blob.size; |
| 304 | + let effectiveStart; |
| 305 | + let effectiveEnd; |
| 306 | + if (start !== undefined && end !== undefined) { |
| 307 | + effectiveStart = start; |
| 308 | + effectiveEnd = end + 1; |
| 309 | + } else if (start !== undefined) { |
| 310 | + effectiveStart = start; |
| 311 | + effectiveEnd = size; |
| 312 | + } else { |
| 313 | + effectiveStart = size - end; |
| 314 | + effectiveEnd = size; |
| 315 | + } |
| 316 | + if (effectiveStart < 0 || effectiveStart >= size || effectiveEnd > size) { |
| 317 | + throw new Error("Range not satisfiable"); |
| 318 | + } |
| 319 | + return { |
| 320 | + effectiveStart: effectiveStart, |
| 321 | + effectiveEnd: effectiveEnd |
| 322 | + }; |
| 323 | + } |
228 | 324 | class Handler { |
229 | | - constructor({cacheName: cacheName, networkTimeout: networkTimeout, maxAge: maxAge}) { |
| 325 | + constructor({cacheName: cacheName, networkTimeout: networkTimeout, maxAge: maxAge, fetchOptions: fetchOptions}) { |
230 | 326 | this.cacheName = cacheName; |
231 | 327 | this.networkTimeout = networkTimeout; |
| 328 | + this.fetchOptions = fetchOptions || {}; |
232 | 329 | this.cacheRegistry = new CacheRegistry(cacheName); |
233 | 330 | this.cacheTrimmer = new CacheTrimmer(cacheName, this.cacheRegistry, { |
234 | 331 | maxAge: maxAge |
|
240 | 337 | let response = await caches.match(cacheKeyUrl, { |
241 | 338 | ignoreVary: true |
242 | 339 | }); |
| 340 | + if (response !== undefined && request.headers.has("range")) { |
| 341 | + response = await buildPartialResponse(request, response); |
| 342 | + } |
243 | 343 | if (response !== undefined && request.redirect === "manual" && response.redirected) { |
244 | 344 | response = new Response(response.body, { |
245 | 345 | headers: response.headers, |
|
252 | 352 | async fetchFromNetwork(request) { |
253 | 353 | const referrer = request.referrer; |
254 | 354 | return await fetch(request, { |
255 | | - referrer: referrer |
| 355 | + referrer: referrer, |
| 356 | + ...this.fetchOptions |
256 | 357 | }); |
257 | 358 | } |
258 | 359 | async saveToCache(request, response) { |
|
262 | 363 | const cachePromise = cache.put(cacheKeyUrl, response); |
263 | 364 | const registryPromise = this.cacheRegistry.put(cacheKeyUrl); |
264 | 365 | const trimPromise = this.cacheTrimmer.trim(); |
265 | | - return Promise.all([ cachePromise, registryPromise, trimPromise ]); |
| 366 | + return Promise.all([ cachePromise, registryPromise, trimPromise ]).catch((async error => { |
| 367 | + if (this.#isQuotaExceededError(error)) { |
| 368 | + await this.#clearAllStorage(); |
| 369 | + } |
| 370 | + throw error; |
| 371 | + })); |
266 | 372 | } |
267 | 373 | } |
268 | 374 | canCacheResponse(response) { |
269 | 375 | return response.status === 200 || response.status === 0; |
270 | 376 | } |
| 377 | + #isQuotaExceededError(error) { |
| 378 | + return error?.name === "QuotaExceededError" || error?.inner && error.inner.name === "QuotaExceededError"; |
| 379 | + } |
| 380 | + async #clearAllStorage() { |
| 381 | + const cacheNames = await caches.keys(); |
| 382 | + for (const cacheName of cacheNames) { |
| 383 | + await caches.delete(cacheName); |
| 384 | + } |
| 385 | + await deleteCacheRegistries(); |
| 386 | + } |
271 | 387 | } |
272 | 388 | function buildCacheKey(requestOrUrl, response) { |
273 | 389 | const request = new Request(requestOrUrl); |
|
0 commit comments