Skip to content

Commit cff29da

Browse files
rosaclaude
andcommitted
Add cache size and entry limiting options
Add three new cache limiting options to offline handlers: - maxSize: total cache size limit in bytes (trims oldest when exceeded) - maxEntrySize: rejects individual entries over this size before caching - maxEntries: total number of entries (trims oldest when exceeded) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 1d7bdc0 commit cff29da

File tree

7 files changed

+360
-9
lines changed

7 files changed

+360
-9
lines changed

src/offline/cache_registry.js

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,60 @@ class CacheRegistryDatabase {
5454
return this.#performOperation(STORE_NAME, getOlderThanOp, "readonly")
5555
}
5656

57+
getEntryCount(cacheName) {
58+
const countOp = (store) => {
59+
const index = store.index("cacheNameAndTimestamp")
60+
const range = IDBKeyRange.bound(
61+
[cacheName, 0],
62+
[cacheName, Infinity]
63+
)
64+
return this.#requestToPromise(index.count(range))
65+
}
66+
return this.#performOperation(STORE_NAME, countOp, "readonly")
67+
}
68+
69+
getTotalSize(cacheName) {
70+
const sumOp = (store) => {
71+
const index = store.index("cacheNameAndTimestamp")
72+
const range = IDBKeyRange.bound(
73+
[cacheName, 0],
74+
[cacheName, Infinity]
75+
)
76+
const cursorRequest = index.openCursor(range)
77+
78+
return this.#sumSizesFromCursor(cursorRequest)
79+
}
80+
return this.#performOperation(STORE_NAME, sumOp, "readonly")
81+
}
82+
83+
getOldestEntries(cacheName, limit) {
84+
const getOldestOp = (store) => {
85+
const index = store.index("cacheNameAndTimestamp")
86+
const range = IDBKeyRange.bound(
87+
[cacheName, 0],
88+
[cacheName, Infinity]
89+
)
90+
const cursorRequest = index.openCursor(range)
91+
92+
return this.#cursorRequestToPromiseWithLimit(cursorRequest, limit)
93+
}
94+
return this.#performOperation(STORE_NAME, getOldestOp, "readonly")
95+
}
96+
97+
getEntriesForSizeReduction(cacheName, targetReduction) {
98+
const getEntriesOp = (store) => {
99+
const index = store.index("cacheNameAndTimestamp")
100+
const range = IDBKeyRange.bound(
101+
[cacheName, 0],
102+
[cacheName, Infinity]
103+
)
104+
const cursorRequest = index.openCursor(range)
105+
106+
return this.#getEntriesUntilSizeReached(cursorRequest, targetReduction)
107+
}
108+
return this.#performOperation(STORE_NAME, getEntriesOp, "readonly")
109+
}
110+
57111
delete(key) {
58112
const deleteOp = (store) => this.#requestToPromise(store.delete(key))
59113
return this.#performOperation(STORE_NAME, deleteOp, "readwrite")
@@ -101,6 +155,62 @@ class CacheRegistryDatabase {
101155
request.onerror = () => reject(request.error)
102156
})
103157
}
158+
159+
#cursorRequestToPromiseWithLimit(request, limit) {
160+
return new Promise((resolve, reject) => {
161+
const results = []
162+
163+
request.onsuccess = (event) => {
164+
const cursor = event.target.result
165+
if (cursor && results.length < limit) {
166+
results.push(cursor.value)
167+
cursor.continue()
168+
} else {
169+
resolve(results)
170+
}
171+
}
172+
173+
request.onerror = () => reject(request.error)
174+
})
175+
}
176+
177+
#sumSizesFromCursor(request) {
178+
return new Promise((resolve, reject) => {
179+
let total = 0
180+
181+
request.onsuccess = (event) => {
182+
const cursor = event.target.result
183+
if (cursor) {
184+
total += cursor.value.size ?? 0
185+
cursor.continue()
186+
} else {
187+
resolve(total)
188+
}
189+
}
190+
191+
request.onerror = () => reject(request.error)
192+
})
193+
}
194+
195+
#getEntriesUntilSizeReached(request, targetSize) {
196+
return new Promise((resolve, reject) => {
197+
const results = []
198+
let accumulated = 0
199+
200+
request.onsuccess = (event) => {
201+
const cursor = event.target.result
202+
if (cursor && accumulated < targetSize) {
203+
results.push(cursor.value)
204+
accumulated += cursor.value.size ?? 0
205+
cursor.continue()
206+
} else {
207+
resolve(results)
208+
}
209+
}
210+
211+
request.onerror = () => reject(request.error)
212+
})
213+
}
104214
}
105215

106216
let cacheRegistryDatabase = null
@@ -138,6 +248,22 @@ export class CacheRegistry {
138248
return this.database.getOlderThan(this.cacheName, timestamp)
139249
}
140250

251+
getEntryCount() {
252+
return this.database.getEntryCount(this.cacheName)
253+
}
254+
255+
getTotalSize() {
256+
return this.database.getTotalSize(this.cacheName)
257+
}
258+
259+
getOldestEntries(limit) {
260+
return this.database.getOldestEntries(this.cacheName, limit)
261+
}
262+
263+
getEntriesForSizeReduction(targetReduction) {
264+
return this.database.getEntriesForSizeReduction(this.cacheName, targetReduction)
265+
}
266+
141267
delete(key) {
142268
return this.database.delete(key)
143269
}

src/offline/cache_trimmer.js

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,23 @@ export class CacheTrimmer {
2626
}
2727

2828
#shouldTrim() {
29-
// For now, only check maxAge. To be extended for maxEntries, maxStorage, etc.
30-
return this.options.maxAge && this.options.maxAge > 0
29+
const { maxAge, maxEntries, maxSize } = this.options
30+
return (maxAge && maxAge > 0) ||
31+
(maxEntries && maxEntries > 0) ||
32+
(maxSize && maxSize > 0)
3133
}
3234

3335
async deleteEntries() {
36+
// Order: age first → count → size (each reduces the set for subsequent operations)
3437
if (this.options.maxAge) {
3538
await this.deleteEntriesByAge()
3639
}
37-
// To be extended with other options
40+
if (this.options.maxEntries) {
41+
await this.deleteEntriesByCount()
42+
}
43+
if (this.options.maxSize) {
44+
await this.deleteEntriesBySize()
45+
}
3846
}
3947

4048
async deleteEntriesByAge() {
@@ -48,18 +56,58 @@ export class CacheTrimmer {
4856
}
4957

5058
console.debug(`Trimming ${expiredEntries.length} expired entries from cache "${this.cacheName}"`)
59+
await this.#deleteEntryList(expiredEntries)
60+
console.debug(`Successfully trimmed ${expiredEntries.length} entries from cache "${this.cacheName}"`)
61+
}
62+
63+
async deleteEntriesByCount() {
64+
const currentCount = await this.cacheRegistry.getEntryCount()
65+
const excess = currentCount - this.options.maxEntries
66+
67+
if (excess <= 0) {
68+
return
69+
}
70+
71+
const entriesToDelete = await this.cacheRegistry.getOldestEntries(excess)
72+
73+
if (entriesToDelete.length === 0) {
74+
return
75+
}
76+
77+
console.debug(`Trimming ${entriesToDelete.length} entries (count limit) from cache "${this.cacheName}"`)
78+
await this.#deleteEntryList(entriesToDelete)
79+
console.debug(`Successfully trimmed ${entriesToDelete.length} entries from cache "${this.cacheName}"`)
80+
}
81+
82+
async deleteEntriesBySize() {
83+
const currentSize = await this.cacheRegistry.getTotalSize()
84+
const excess = currentSize - this.options.maxSize
5185

86+
if (excess <= 0) {
87+
return
88+
}
89+
90+
const entriesToDelete = await this.cacheRegistry.getEntriesForSizeReduction(excess)
91+
92+
if (entriesToDelete.length === 0) {
93+
return
94+
}
95+
96+
console.debug(`Trimming ${entriesToDelete.length} entries (size limit) from cache "${this.cacheName}"`)
97+
await this.#deleteEntryList(entriesToDelete)
98+
console.debug(`Successfully trimmed ${entriesToDelete.length} entries from cache "${this.cacheName}"`)
99+
}
100+
101+
async #deleteEntryList(entries) {
52102
const cache = await caches.open(this.cacheName)
53103

54-
const deletePromises = expiredEntries.map(async (entry) => {
104+
const deletePromises = entries.map(async (entry) => {
55105
const cacheDeletePromise = cache.delete(entry.key)
56106
const registryDeletePromise = this.cacheRegistry.delete(entry.key)
57107

58108
return Promise.all([cacheDeletePromise, registryDeletePromise])
59109
})
60110

61111
await Promise.all(deletePromises)
62-
63-
console.debug(`Successfully trimmed ${expiredEntries.length} entries from cache "${this.cacheName}"`)
64112
}
65113
}

src/offline/handlers/handler.js

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import { CacheTrimmer } from "../cache_trimmer"
33
import { buildPartialResponse } from "../range_request"
44

55
export class Handler {
6-
constructor({ cacheName, networkTimeout, maxAge, fetchOptions }) {
6+
constructor({ cacheName, networkTimeout, maxAge, maxEntries, maxSize, maxEntrySize, fetchOptions }) {
77
this.cacheName = cacheName
88
this.networkTimeout = networkTimeout
99
this.fetchOptions = fetchOptions || {}
10+
this.maxEntrySize = maxEntrySize
1011

1112
this.cacheRegistry = new CacheRegistry(cacheName)
12-
this.cacheTrimmer = new CacheTrimmer(cacheName, this.cacheRegistry, { maxAge })
13+
this.cacheTrimmer = new CacheTrimmer(cacheName, this.cacheRegistry, { maxAge, maxEntries, maxSize })
1314
}
1415

1516
async handle(request) {
@@ -45,11 +46,22 @@ export class Handler {
4546

4647
async saveToCache(request, response) {
4748
if (response && this.canCacheResponse(response)) {
49+
const size = await this.#getResponseSize(response)
50+
51+
if (this.maxEntrySize && this.maxEntrySize > 0) {
52+
if (size === null) {
53+
console.warn(`Cannot determine size for opaque response to "${request.url}". maxEntrySize check skipped.`)
54+
} else if (size > this.maxEntrySize) {
55+
console.debug(`Skipping cache for "${request.url}": response size ${size} exceeds maxEntrySize ${this.maxEntrySize}`)
56+
return
57+
}
58+
}
59+
4860
const cacheKeyUrl = buildCacheKey(request, response)
4961
const cache = await caches.open(this.cacheName)
5062

5163
const cachePromise = cache.put(cacheKeyUrl, response)
52-
const registryPromise = this.cacheRegistry.put(cacheKeyUrl)
64+
const registryPromise = this.cacheRegistry.put(cacheKeyUrl, { size })
5365
const trimPromise = this.cacheTrimmer.trim()
5466

5567
return Promise.all([ cachePromise, registryPromise, trimPromise ]).catch(async (error) => {
@@ -61,6 +73,21 @@ export class Handler {
6173
}
6274
}
6375

76+
async #getResponseSize(response) {
77+
if (response.type === "opaque" || response.status === 0) {
78+
return null
79+
}
80+
81+
const contentLength = response.headers.get("Content-Length")
82+
if (contentLength) {
83+
return parseInt(contentLength, 10)
84+
}
85+
86+
const clone = response.clone()
87+
const blob = await clone.blob()
88+
return blob.size
89+
}
90+
6491
canCacheResponse(response) {
6592
// OK response and opaque responses (due to CORS), that have a 0 status
6693
return response.status === 200 || response.status === 0
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Registered as type="classic", can't use module imports
2+
importScripts("/dist/turbo-offline-umd.js")
3+
4+
// Cache-first handler with maxEntries limit for testing entry count trimming
5+
TurboOffline.addRule({
6+
match: /\/dynamic\.txt/,
7+
handler: TurboOffline.handlers.cacheFirst({
8+
cacheName: "test-max-entries",
9+
maxEntries: 3
10+
})
11+
})
12+
13+
// Take control of all pages immediately when activated
14+
self.addEventListener('activate', (event) => {
15+
event.waitUntil(self.clients.claim())
16+
})
17+
18+
// Start the service worker
19+
TurboOffline.start()
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Registered as type="classic", can't use module imports
2+
importScripts("/dist/turbo-offline-umd.js")
3+
4+
// Cache-first handler with maxEntrySize limit for testing individual entry size rejection
5+
// The dynamic.txt responses are ~60-70 bytes, so 50 bytes should reject them
6+
TurboOffline.addRule({
7+
match: /\/dynamic\.txt/,
8+
handler: TurboOffline.handlers.cacheFirst({
9+
cacheName: "test-max-entry-size",
10+
maxEntrySize: 50
11+
})
12+
})
13+
14+
// Handler with larger maxEntrySize that should allow caching
15+
TurboOffline.addRule({
16+
match: /\/dynamic\.json$/,
17+
handler: TurboOffline.handlers.cacheFirst({
18+
cacheName: "test-max-entry-size-allowed",
19+
maxEntrySize: 500
20+
})
21+
})
22+
23+
// Take control of all pages immediately when activated
24+
self.addEventListener('activate', (event) => {
25+
event.waitUntil(self.clients.claim())
26+
})
27+
28+
// Start the service worker
29+
TurboOffline.start()
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Registered as type="classic", can't use module imports
2+
importScripts("/dist/turbo-offline-umd.js")
3+
4+
// Cache-first handler with maxSize limit for testing total size trimming
5+
// The dynamic.txt responses are ~60-70 bytes each, so 150 bytes allows ~2 entries
6+
TurboOffline.addRule({
7+
match: /\/dynamic\.txt/,
8+
handler: TurboOffline.handlers.cacheFirst({
9+
cacheName: "test-max-size",
10+
maxSize: 150
11+
})
12+
})
13+
14+
// Take control of all pages immediately when activated
15+
self.addEventListener('activate', (event) => {
16+
event.waitUntil(self.clients.claim())
17+
})
18+
19+
// Start the service worker
20+
TurboOffline.start()

0 commit comments

Comments
 (0)