Skip to content

Commit 639407e

Browse files
ronagclaude
andcommitted
fix(cache): cache private redirects via a private column
Store responses marked Cache-Control: private (redirects) instead of dropping them, tracking the flag in a new `private` column so they are bypassed on read rather than shared across clients. Also fixes several bugs in the change so it actually parses/runs: - 'private' is a strict-mode reserved word; bind it under a safe name in the flush destructuring/run() call - add the missing commas in CREATE TABLE / SELECT / INSERT - node:sqlite cannot bind a JS boolean, so persist the flag as 0/1 - bump store VERSION to 11 for the schema change - guard the cache-miss case (entry?.private) to avoid a TypeError - fix the redirect status-code allow-list (302 typo, add 303) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 41c0730 commit 639407e

2 files changed

Lines changed: 35 additions & 7 deletions

File tree

lib/interceptor/cache.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,20 @@ class CacheHandler extends DecoratorHandler {
9292
return super.onHeaders(statusCode, headers, resume)
9393
}
9494

95-
if (cacheControlDirectives.private || cacheControlDirectives['no-store']) {
95+
if (cacheControlDirectives['no-store']) {
96+
return super.onHeaders(statusCode, headers, resume)
97+
}
98+
99+
// Cache redirections even if private.
100+
if (
101+
cacheControlDirectives['private'] &&
102+
statusCode !== 307 &&
103+
statusCode !== 308 &&
104+
statusCode !== 300 &&
105+
statusCode !== 301 &&
106+
statusCode !== 302 &&
107+
statusCode !== 303
108+
) {
96109
return super.onHeaders(statusCode, headers, resume)
97110
}
98111

@@ -173,6 +186,7 @@ class CacheHandler extends DecoratorHandler {
173186
etag: isEtagUsable(headers.etag) ? headers.etag : '',
174187
vary,
175188
cachedAt,
189+
private: Boolean(cacheControlDirectives['private']),
176190
// Handler state.
177191
size: 0,
178192
}
@@ -289,6 +303,10 @@ export default () => (dispatch) => (opts, handler) => {
289303
}
290304
}
291305

306+
if (entry?.private && !opts.cache.private) {
307+
entry = null
308+
}
309+
292310
// RFC 9111 Section 3.5: A shared cache must not use a cached response to a
293311
// request with Authorization unless the response includes a public directive.
294312
if (entry && opts.headers?.authorization && !entry.cacheControlDirectives?.public) {

lib/sqlite-cache-store.js

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { DatabaseSync } from 'node:sqlite'
22
import { parseRangeHeader, getFastNow } from './utils.js'
33

44
// Bump version when the URL key format or schema changes to invalidate old caches.
5-
const VERSION = 10
5+
const VERSION = 11
66

77
/** @typedef {{ gc: () => void, clear: () => void } } */
88
const stores = new Set()
@@ -44,6 +44,7 @@ const stores = new Set()
4444
* cacheControlDirectives?: string
4545
* cachedAt: number
4646
* deleteAt: number
47+
* private: boolean
4748
* }} SqliteStoreValue
4849
*/
4950
export class SqliteCacheStore {
@@ -115,7 +116,8 @@ export class SqliteCacheStore {
115116
cacheControlDirectives TEXT NULL,
116117
etag TEXT NULL,
117118
vary TEXT NULL,
118-
cachedAt INTEGER NOT NULL
119+
cachedAt INTEGER NOT NULL,
120+
private BOOLEAN NOT NULL DEFAULT 0
119121
);
120122
121123
CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_getValuesQuery ON cacheInterceptorV${VERSION}(url, method, start, deleteAt);
@@ -135,7 +137,8 @@ export class SqliteCacheStore {
135137
etag,
136138
cacheControlDirectives,
137139
vary,
138-
cachedAt
140+
cachedAt,
141+
private
139142
FROM cacheInterceptorV${VERSION}
140143
WHERE
141144
url = ?
@@ -160,8 +163,9 @@ export class SqliteCacheStore {
160163
etag,
161164
cacheControlDirectives,
162165
vary,
163-
cachedAt
164-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
166+
cachedAt,
167+
private
168+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
165169
`)
166170

167171
this.#deleteExpiredValuesQuery = this.#db.prepare(
@@ -252,7 +256,7 @@ export class SqliteCacheStore {
252256

253257
/**
254258
* @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
255-
* @param {import('undici-types/cache-interceptor.d.ts').default.CacheValue & { body: null | Buffer | Array<Buffer>, start: number, end: number }} value
259+
* @param {import('undici-types/cache-interceptor.d.ts').default.CacheValue & { body: null | Buffer | Array<Buffer>, start: number, end: number, private?: boolean }} value
256260
*/
257261
set(key, value) {
258262
assertCacheKey(key)
@@ -313,6 +317,8 @@ export class SqliteCacheStore {
313317
: null,
314318
vary: value.vary ? JSON.stringify(value.vary) : null,
315319
cachedAt: value.cachedAt,
320+
// node:sqlite cannot bind a JS boolean; store as 0/1.
321+
private: value.private ? 1 : 0,
316322
})
317323
}
318324

@@ -343,6 +349,8 @@ export class SqliteCacheStore {
343349
cacheControlDirectives,
344350
vary,
345351
cachedAt,
352+
// 'private' is a reserved word — bind under a safe name.
353+
private: isPrivate,
346354
} = this.#insertBatch[n++]
347355
this.#insertValueQuery.run(
348356
url,
@@ -358,6 +366,7 @@ export class SqliteCacheStore {
358366
cacheControlDirectives,
359367
vary,
360368
cachedAt,
369+
isPrivate,
361370
)
362371
if (!final && (n & 0xf) === 0 && performance.now() - startTime > 10) {
363372
break
@@ -580,6 +589,7 @@ function makeResult(value) {
580589
? JSON.parse(value.cacheControlDirectives)
581590
: undefined,
582591
cachedAt: value.cachedAt,
592+
private: Boolean(value.private),
583593
deleteAt: value.deleteAt,
584594
}
585595
}

0 commit comments

Comments
 (0)