From 998ddc38ee931a9d8a31cf45a1dfb639aa6fbaec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isak=20T=C3=B6rnros?= Date: Fri, 5 Jul 2024 11:06:41 +0200 Subject: [PATCH 1/3] feat(cache): use vary headers to compare cached response with request headers. --- lib/interceptor/cache.js | 94 +++++++++++++++++++++++++++++++++++----- package.json | 3 +- test/cache.js | 71 ++++++++++++++++++++++++++++-- 3 files changed, 153 insertions(+), 15 deletions(-) diff --git a/lib/interceptor/cache.js b/lib/interceptor/cache.js index 8e633b0..ab628e7 100644 --- a/lib/interceptor/cache.js +++ b/lib/interceptor/cache.js @@ -23,6 +23,9 @@ class CacheHandler extends DecoratorHandler { } onHeaders(statusCode, rawHeaders, resume, statusMessage, headers = parseHeaders(rawHeaders)) { + // console.log('onHeaders, headers:') + // console.log(headers) + if (statusCode !== 307) { return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers) } @@ -58,7 +61,7 @@ class CacheHandler extends DecoratorHandler { statusMessage, rawHeaders, rawTrailers: null, - body: [], + body: [], // Why is the body emptied? When we cache it again it won't have a body. }, size: (rawHeaders?.reduce((xs, x) => xs + x.length, 0) ?? 0) + @@ -84,15 +87,38 @@ class CacheHandler extends DecoratorHandler { } } return this.#handler.onData(chunk) + /* + Is 'this.#handler.onData' the previous dispatcher in the chain, e.g. 'redirect'? + And in 'redirect.onData(chunk)' it once again calls 'this.#handler.onData(chunk)'. + Would that be 'responseVerify.onData(chunk)'? + */ } - onComplete(rawTrailers) { + onComplete(rawTrailers, opts) { + console.log('onComplete, value: ' + this.#value) + console.log('onComplete, opts:') + console.log(opts) + if (this.#value) { this.#value.data.rawTrailers = rawTrailers this.#value.size += rawTrailers?.reduce((xs, x) => xs + x.length, 0) ?? 0 + + console.log('OnComplete, cache store is being set to: ') + console.log([this.#key, this.#value.data, { ttl: this.#value.ttl, size: this.#value.size }]) + + /* + Why are we setting the cache with the same data as the entry we fetched earlier + from the very same cache? + + We have the request data in the `opts` variable, but where is the response data that we need to cache? + Is the response cached somewhere else? + + We have the headers we need from the request. But we need the response data to know the vary-header + and we also need it to store the response. + */ this.#store.set(this.#key, this.#value.data, { ttl: this.#value.ttl, size: this.#value.size }) } - return this.#handler.onComplete(rawTrailers) + return this.#handler.onComplete(rawTrailers, opts) } } @@ -105,6 +131,9 @@ class CacheStore { } set(key, value, opts) { + console.log('setting cache with values:') + console.log({ key, value, opts }) + this.cache.set(key, value, opts) } @@ -115,12 +144,46 @@ class CacheStore { function makeKey(opts) { // NOTE: Ignores headers... - return `${opts.origin}:${opts.method}:${opts.path}` + // return `${opts.origin}:${opts.method}:${opts.path}` + return `${opts.method}:${opts.path}` +} + +function varyHeadersMatchRequest(varyHeaders, requestHeaders) { + // const headersToString = [] + // for(const header of cachedRawHeaders){ + // headersToString.push(header.toString()) + // } + + // const varyHeaders = headersToString.reduce((acc, cur, index, arr) => { + // if (index % 2 === 0) { + // acc[cur] = arr[index + 1]; + // } + // return acc; + // }, {}); + + // Early return if `varyHeaders` is null/undefined or an empty object + if (!varyHeaders || Object.keys(varyHeaders).length === 0) { + return true + } + const varyKeys = Object.keys(varyHeaders) + // All vary headers must match request headers, return true/false. + return varyKeys.every((varyKey) => varyHeaders[varyKey] === requestHeaders[varyKey]) +} + +function findEntryByHeaders(entries, requestHeaders) { + return entries.find((entry) => varyHeadersMatchRequest(entry, requestHeaders)) } const DEFAULT_CACHE_STORE = new CacheStore({ maxSize: 128 * 1024, maxEntrySize: 1024 }) export default (opts) => (dispatch) => (opts, handler) => { + console.log('cache dispatcher:') + console.log(dispatch) + console.log('opts:') + console.log(opts) + console.log('handler:') + console.log(handler) + if (!opts.cache || opts.upgrade) { return dispatch(opts, handler) } @@ -156,15 +219,24 @@ export default (opts) => (dispatch) => (opts, handler) => { } let key = makeKey(opts) - let value = store.get(key) + console.log('getting key: ' + key) + let entries = store.get(key) - if (value == null && opts.method === 'HEAD') { + console.log('Found entries in cache: ') + console.log(entries) + + // if key with method:'HEAD' didn't yield results, retry with method:'GET' + if (entries.length === 0 && opts.method === 'HEAD') { key = makeKey({ ...opts, method: 'GET' }) - value = store.get(key) + entries = store.get(key) + // value = {data: {headers: {vary: {origin: "www.google.com"}}} } - if (value) { - const { statusCode, statusMessage, rawHeaders, rawTrailers, body } = value + // Find an entry that matches the request, if any + const entry = findEntryByHeaders(entries, opts) + + if (entry) { + const { statusCode, statusMessage, rawHeaders, rawTrailers, body } = entry const ac = new AbortController() const signal = ac.signal @@ -186,9 +258,9 @@ export default (opts) => (dispatch) => (opts, handler) => { // TODO (fix): back pressure... } } - handler.onComplete(rawTrailers) + handler.onComplete(rawTrailers, opts) } else { - handler.onComplete([]) + handler.onComplete([], opts) } } catch (err) { handler.onError(err) diff --git a/package.json b/package.json index 52fc3d5..51a2c62 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "prepare": "husky install", "prepublishOnly": "pinst --disable", "postpublish": "pinst --enable", - "test": "tap test" + "test": "tap test", + "taprun": "tap run" }, "lint-staged": { "*.{js,jsx,md,ts}": [ diff --git a/test/cache.js b/test/cache.js index e1ca974..4b4a660 100644 --- a/test/cache.js +++ b/test/cache.js @@ -1,24 +1,89 @@ import { test } from 'tap' +// import { LRUCache } from 'lru-cache' import { createServer } from 'node:http' import undici from '@nxtedition/undici' import { interceptors } from '../lib/index.js' -test('cache request', (t) => { +// test('cache request', (t) => { +// t.plan(1) +// const server = createServer((req, res) => { +// res.end('asd') +// }) + +// t.teardown(server.close.bind(server)) +// server.listen(0, async () => { +// const { body } = await undici.request(`http://0.0.0.0:${server.address().port}`, { +// dispatcher: new undici.Agent().compose(interceptors.cache()), +// cache: true, +// }) +// let str = '' +// for await (const chunk of body) { +// str += chunk +// } +// t.equal(str, 'asd') +// }) +// }) + +// class CacheStore { +// constructor({ maxSize = 1024 * 1024 }) { +// this.maxSize = maxSize +// this.cache = new LRUCache({ maxSize }) +// } + +// set(key, value, opts) { +// this.cache.set(key, value, opts) +// } + +// get(key) { +// return this.cache.get(key) +// } +// } + +// Error: "invalid size value (must be positive integer). When maxSize or maxEntrySize is used, sizeCalculation or size must be set." +// +// function exampleCache(){ +// const options = { +// max: 500, +// maxSize: 5000, +// sizeCalculation: (value, key) => { +// return 1 +// }, +// } +// const cache = new CacheStore(options) +// cache.set('GET:/', {data: 'dataFromCache', vary: {'origin': 'http://0.0.0.0:54758', 'Accept-Encoding': 'Application/json'}}, {}) +// cache.set('GET:/foobar', {data: 'dataFromCache'}, {}) +// cache.set('POST:/foo', {data: 'dataFromCache', vary: {'host': '0.0.0.0:54758'}}, {}) +// cache.set('GET:/', {data: { +// headers: [ +// 'Vary': {'origin': 'http://0.0.0.0:54758', 'Accept-Encoding': 'Application/json'} +// ], +// }}) + +// return cache +// } + +test('cache request, vary:host, populated cache', (t) => { t.plan(1) const server = createServer((req, res) => { + res.writeHead(307, { Vary: 'Host' }) res.end('asd') }) t.teardown(server.close.bind(server)) + + // const cache = exampleCache() server.listen(0, async () => { - const { body } = await undici.request(`http://0.0.0.0:${server.address().port}`, { + const response = await undici.request(`http://0.0.0.0:${server.address().port}`, { dispatcher: new undici.Agent().compose(interceptors.cache()), cache: true, }) let str = '' - for await (const chunk of body) { + for await (const chunk of response.body) { str += chunk } + + console.log('response: ') + console.log(response) t.equal(str, 'asd') }) }) From 1abc0de1b585c718c8dbf9cae8ca5e7dc281be14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isak=20T=C3=B6rnros?= Date: Tue, 9 Jul 2024 09:51:20 +0200 Subject: [PATCH 2/3] feat(cache): debugging testing 307 redirect. --- lib/interceptor/cache.js | 217 ++++++++++++++++++++++++------------ lib/interceptor/proxy.js | 3 + lib/interceptor/redirect.js | 3 + test/cache.js | 55 ++++++++- 4 files changed, 206 insertions(+), 72 deletions(-) diff --git a/lib/interceptor/cache.js b/lib/interceptor/cache.js index ab628e7..390ae3d 100644 --- a/lib/interceptor/cache.js +++ b/lib/interceptor/cache.js @@ -17,14 +17,17 @@ class CacheHandler extends DecoratorHandler { } onConnect(abort) { + console.log('onConnect abort') + console.log(abort) + this.#value = null return this.#handler.onConnect(abort) } onHeaders(statusCode, rawHeaders, resume, statusMessage, headers = parseHeaders(rawHeaders)) { - // console.log('onHeaders, headers:') - // console.log(headers) + console.log('onHeaders') + console.log({ statusCode, rawHeaders, resume, statusMessage, headers }) if (statusCode !== 307) { return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers) @@ -36,6 +39,22 @@ class CacheHandler extends DecoratorHandler { const contentLength = headers['content-length'] ? Number(headers['content-length']) : Infinity const maxEntrySize = this.#store.maxEntrySize ?? Infinity + console.log({ cacheControl, contentLength, maxEntrySize }) + + console.log('onHeaders if statement match:') + + console.log( + contentLength < maxEntrySize && + cacheControl && + cacheControl.public && + !cacheControl.private && + !cacheControl['no-store'] && + !cacheControl['no-cache'] && + !cacheControl['must-understand'] && + !cacheControl['must-revalidate'] && + !cacheControl['proxy-revalidate'], + ) + if ( contentLength < maxEntrySize && cacheControl && @@ -54,6 +73,8 @@ class CacheHandler extends DecoratorHandler { ? 31556952 // 1 year : Number(maxAge) + console.log({ ttl, maxAge, cacheControl, contentLength, maxEntrySize }) + if (ttl > 0) { this.#value = { data: { @@ -61,7 +82,7 @@ class CacheHandler extends DecoratorHandler { statusMessage, rawHeaders, rawTrailers: null, - body: [], // Why is the body emptied? When we cache it again it won't have a body. + body: [], }, size: (rawHeaders?.reduce((xs, x) => xs + x.length, 0) ?? 0) + @@ -70,8 +91,13 @@ class CacheHandler extends DecoratorHandler { ttl: ttl * 1e3, } } + + console.log({ thisvalue: this.#value }) } + console.log('onHeaders, finish:') + console.log({ statusCode, rawHeaders, resume, statusMessage, headers }) + return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers) } @@ -87,41 +113,48 @@ class CacheHandler extends DecoratorHandler { } } return this.#handler.onData(chunk) - /* - Is 'this.#handler.onData' the previous dispatcher in the chain, e.g. 'redirect'? - And in 'redirect.onData(chunk)' it once again calls 'this.#handler.onData(chunk)'. - Would that be 'responseVerify.onData(chunk)'? - */ } - onComplete(rawTrailers, opts) { - console.log('onComplete, value: ' + this.#value) - console.log('onComplete, opts:') - console.log(opts) - + onComplete(rawTrailers) { + console.log('onComplete this:') + console.log({ thisvalue: this.#value }) + console.log({ thisstore: this.#store }) // CacheStore{} + console.log({ thishandler: this.#handler }) // RequestHandler{} + console.log({ thishandlervalue: this.#handler.value }) + console.log({ this: this }) if (this.#value) { this.#value.data.rawTrailers = rawTrailers this.#value.size += rawTrailers?.reduce((xs, x) => xs + x.length, 0) ?? 0 - console.log('OnComplete, cache store is being set to: ') - console.log([this.#key, this.#value.data, { ttl: this.#value.ttl, size: this.#value.size }]) + const opts = this.#handler.opts + const entries = this.#handler.entries + console.log('onComplete this:') + console.log({ opts, entries }) + + const reqHeaders = this.#handler.opts + const resHeaders = parseHeaders(this.#value.data.rawHeaders) - /* - Why are we setting the cache with the same data as the entry we fetched earlier - from the very same cache? + const vary = formatVaryData(resHeaders, reqHeaders) - We have the request data in the `opts` variable, but where is the response data that we need to cache? - Is the response cached somewhere else? + console.log({ vary }) - We have the headers we need from the request. But we need the response data to know the vary-header - and we also need it to store the response. - */ - this.#store.set(this.#key, this.#value.data, { ttl: this.#value.ttl, size: this.#value.size }) + this.#value.vary = vary + + console.log({ entries }) + + this.#store.set(this.#key, entries.push(this.#value)) } - return this.#handler.onComplete(rawTrailers, opts) + return this.#handler.onComplete(rawTrailers) } } +function formatVaryData(resHeaders, reqHeaders) { + return resHeaders.vary + ?.split(',') + .map((key) => key.trim().toLowerCase()) + .map((key) => [key, reqHeaders[key]]) +} + // TODO (fix): Async filesystem cache. class CacheStore { constructor({ maxSize = 1024 * 1024, maxEntrySize = 128 * 1024 }) { @@ -131,9 +164,6 @@ class CacheStore { } set(key, value, opts) { - console.log('setting cache with values:') - console.log({ key, value, opts }) - this.cache.set(key, value, opts) } @@ -142,36 +172,29 @@ class CacheStore { } } -function makeKey(opts) { - // NOTE: Ignores headers... - // return `${opts.origin}:${opts.method}:${opts.path}` - return `${opts.method}:${opts.path}` -} - -function varyHeadersMatchRequest(varyHeaders, requestHeaders) { - // const headersToString = [] - // for(const header of cachedRawHeaders){ - // headersToString.push(header.toString()) - // } - - // const varyHeaders = headersToString.reduce((acc, cur, index, arr) => { - // if (index % 2 === 0) { - // acc[cur] = arr[index + 1]; - // } - // return acc; - // }, {}); - - // Early return if `varyHeaders` is null/undefined or an empty object - if (!varyHeaders || Object.keys(varyHeaders).length === 0) { - return true - } - const varyKeys = Object.keys(varyHeaders) - // All vary headers must match request headers, return true/false. - return varyKeys.every((varyKey) => varyHeaders[varyKey] === requestHeaders[varyKey]) -} - -function findEntryByHeaders(entries, requestHeaders) { - return entries.find((entry) => varyHeadersMatchRequest(entry, requestHeaders)) +function findEntryByHeaders(entries, reqHeaders) { + // Sort entries by number of vary headers in descending order, because + // we want to compare the most complex response to the request first. + entries.sort((a, b) => { + const lengthA = a.vary ? a.vary.length : 0 + const lengthB = b.vary ? b.vary.length : 0 + return lengthB - lengthA + }) + + console.log('Sort entries') + console.log({ entries }) + + console.log('reqHeaders') + console.log({ reqHeaders }) + + return entries?.find( + (entry) => + entry.vary?.every(([key, val]) => { + console.log(`reqHeaders[${key}] === ${val}`) + console.log({ reqHeadersval: reqHeaders[key] }) + return reqHeaders[key] === val + }) ?? true, + ) } const DEFAULT_CACHE_STORE = new CacheStore({ maxSize: 128 * 1024, maxEntrySize: 1024 }) @@ -218,23 +241,77 @@ export default (opts) => (dispatch) => (opts, handler) => { throw new Error(`Cache store not provided.`) } - let key = makeKey(opts) + let key = `${opts.method}:${opts.path}` console.log('getting key: ' + key) let entries = store.get(key) - console.log('Found entries in cache: ') - console.log(entries) - - // if key with method:'HEAD' didn't yield results, retry with method:'GET' - if (entries.length === 0 && opts.method === 'HEAD') { - key = makeKey({ ...opts, method: 'GET' }) + if (Array.isArray(entries) && entries.length === 0 && opts.method === 'HEAD') { + key = `GET:${opts.path}` entries = store.get(key) - // value = {data: {headers: {vary: {origin: "www.google.com"}}} } + // testing + const rawHeaders = [ + Buffer.from('Content-Type'), + Buffer.from('application/json'), + Buffer.from('Content-Length'), + Buffer.from('10'), + Buffer.from('Cache-Control'), + Buffer.from('public'), + ] + // // cannot get the cache to work inside the test, so I hardcode the entries here + entries = [ + { + statusCode: 200, + statusMessage: '', + rawHeaders, + rawTrailers: ['Hello'], + body: ['asd1'], + vary: [ + ['Accept', 'application/xml'], + ['User-Agent', 'Mozilla/5.0'], + ], + }, + { + statusCode: 200, + statusMessage: '', + rawHeaders, + rawTrailers: ['Hello'], + body: ['asd2'], + vary: [ + ['Accept', 'application/txt'], + ['User-Agent', 'Chrome'], + ['origin2', 'www.google.com/images'], + ], + }, + // { + // statusCode: 200, statusMessage: 'last', rawHeaders, rawTrailers: ['Hello'], body: ['asd3'], + // vary: null }, + { + statusCode: 200, + statusMessage: 'first', + rawHeaders, + rawTrailers: ['Hello'], + body: ['asd4'], + vary: [ + ['Accept', 'application/json'], + ['User-Agent', 'Mozilla/5.0'], + ['host2', 'www.google.com'], + ['origin2', 'www.google.com/images'], + ], + }, + ] + + // *testing + // Find an entry that matches the request, if any const entry = findEntryByHeaders(entries, opts) + console.log('Entry found:') + console.log({ entry }) + + // handler.value.vary = 'foobar' + if (entry) { const { statusCode, statusMessage, rawHeaders, rawTrailers, body } = entry const ac = new AbortController() @@ -258,9 +335,9 @@ export default (opts) => (dispatch) => (opts, handler) => { // TODO (fix): back pressure... } } - handler.onComplete(rawTrailers, opts) + handler.onComplete(rawTrailers) } else { - handler.onComplete([], opts) + handler.onComplete([]) } } catch (err) { handler.onError(err) @@ -268,6 +345,8 @@ export default (opts) => (dispatch) => (opts, handler) => { return true } else { - return dispatch(opts, new CacheHandler({ handler, store, key: makeKey(opts) })) + // handler.opts = opts + // handler.entries = entries + return dispatch(opts, new CacheHandler({ handler, store, key })) } } diff --git a/lib/interceptor/proxy.js b/lib/interceptor/proxy.js index 1dd95bb..93a7fc3 100644 --- a/lib/interceptor/proxy.js +++ b/lib/interceptor/proxy.js @@ -14,6 +14,7 @@ class Handler extends DecoratorHandler { } onUpgrade(statusCode, rawHeaders, socket) { + console.log('Proxy onUpgrade') return this.#handler.onUpgrade( statusCode, reduceHeaders( @@ -34,6 +35,7 @@ class Handler extends DecoratorHandler { } onHeaders(statusCode, rawHeaders, resume, statusMessage) { + console.log('Proxy onHeaders') return this.#handler.onHeaders( statusCode, reduceHeaders( @@ -164,6 +166,7 @@ function printIp(address, port) { } export default (opts) => (dispatch) => (opts, handler) => { + console.log('Proxy default dispatch') if (!opts.proxy) { return dispatch(opts, handler) } diff --git a/lib/interceptor/redirect.js b/lib/interceptor/redirect.js index d4e1f4a..5261703 100644 --- a/lib/interceptor/redirect.js +++ b/lib/interceptor/redirect.js @@ -36,6 +36,7 @@ class Handler extends DecoratorHandler { } onConnect(abort) { + console.log('Redirect onConnect') if (this.#aborted) { abort(this.#reason) } else { @@ -48,6 +49,7 @@ class Handler extends DecoratorHandler { } onHeaders(statusCode, rawHeaders, resume, statusText, headers = parseHeaders(rawHeaders)) { + console.log('Redirect onHeaders') if (redirectableStatusCodes.indexOf(statusCode) === -1) { assert(!this.#headersSent) this.#headersSent = true @@ -109,6 +111,7 @@ class Handler extends DecoratorHandler { } onData(chunk) { + console.log('Redirect onData') if (this.#location) { /* https://tools.ietf.org/html/rfc7231#section-6.4 diff --git a/test/cache.js b/test/cache.js index 4b4a660..1399c03 100644 --- a/test/cache.js +++ b/test/cache.js @@ -62,16 +62,63 @@ import { interceptors } from '../lib/index.js' // return cache // } -test('cache request, vary:host, populated cache', (t) => { +// test('cache request, found a matching entry in cache', (t) => { +// t.plan(1) +// const server = createServer((req, res) => { +// res.writeHead(200, { Vary: 'Host, Origin, user-agent' }) +// res.end('asd') +// }) + +// t.teardown(server.close.bind(server)) + +// // const cache = exampleCache() +// server.listen(0, async () => { +// const response = await undici.request(`http://0.0.0.0:${server.address().port}`, { +// dispatcher: new undici.Agent().compose( +// interceptors.responseError(), +// interceptors.requestBodyFactory(), +// interceptors.log(), +// interceptors.dns(), +// interceptors.lookup(), +// interceptors.requestId(), +// interceptors.responseRetry(), +// interceptors.responseVerify(), +// interceptors.redirect(), +// interceptors.cache(), +// interceptors.proxy() +// ), +// cache: true, +// Accept: 'application/txt', +// 'User-Agent': 'Chrome', +// origin2: 'www.google.com/images' +// }) +// let str = '' +// for await (const chunk of response.body) { +// str += chunk +// } + +// console.log('response: ') +// console.log(response) +// t.equal(str, 'asd2') +// }) +// }) + +test('cache request, no matching entry found. Store response in cache', (t) => { t.plan(1) const server = createServer((req, res) => { - res.writeHead(307, { Vary: 'Host' }) + res.writeHead(307, { + Vary: 'Host', + 'Cache-Control': 'public, immutable', + 'Content-Length': 1000, + 'Content-Type': 'text/html', + Connection: 'keep-alive', + Location: 'http://www.blankwebsite.com/', + }) res.end('asd') }) t.teardown(server.close.bind(server)) - // const cache = exampleCache() server.listen(0, async () => { const response = await undici.request(`http://0.0.0.0:${server.address().port}`, { dispatcher: new undici.Agent().compose(interceptors.cache()), @@ -86,4 +133,6 @@ test('cache request, vary:host, populated cache', (t) => { console.log(response) t.equal(str, 'asd') }) + + // Here we need to make another request to check if we get back the previous response but from the cache instead. }) From 01c6c72d55374c971d5b9444944c2d7604748c1c Mon Sep 17 00:00:00 2001 From: Robert Nagy Date: Sat, 17 Aug 2024 20:24:13 +0200 Subject: [PATCH 3/3] WIP --- lib/interceptor/cache.js | 262 ++++++++++++++------------------------- 1 file changed, 96 insertions(+), 166 deletions(-) diff --git a/lib/interceptor/cache.js b/lib/interceptor/cache.js index 390ae3d..76217d4 100644 --- a/lib/interceptor/cache.js +++ b/lib/interceptor/cache.js @@ -1,60 +1,39 @@ import assert from 'node:assert' -import { LRUCache } from 'lru-cache' import { DecoratorHandler, parseHeaders, parseCacheControl } from '../utils.js' +import { DatabaseSync } from 'node:sqlite' // --experimental-sqlite +import * as BJSON from 'buffer-json' class CacheHandler extends DecoratorHandler { #handler #store #key + #opts #value - constructor({ key, handler, store }) { + constructor({ key, handler, store, opts }) { super(handler) this.#key = key this.#handler = handler this.#store = store + this.#opts = opts } onConnect(abort) { - console.log('onConnect abort') - console.log(abort) - this.#value = null return this.#handler.onConnect(abort) } onHeaders(statusCode, rawHeaders, resume, statusMessage, headers = parseHeaders(rawHeaders)) { - console.log('onHeaders') - console.log({ statusCode, rawHeaders, resume, statusMessage, headers }) - - if (statusCode !== 307) { + if (statusCode !== 307 || statusCode !== 200) { return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers) } - // TODO (fix): Support vary header. const cacheControl = parseCacheControl(headers['cache-control']) - const contentLength = headers['content-length'] ? Number(headers['content-length']) : Infinity const maxEntrySize = this.#store.maxEntrySize ?? Infinity - console.log({ cacheControl, contentLength, maxEntrySize }) - - console.log('onHeaders if statement match:') - - console.log( - contentLength < maxEntrySize && - cacheControl && - cacheControl.public && - !cacheControl.private && - !cacheControl['no-store'] && - !cacheControl['no-cache'] && - !cacheControl['must-understand'] && - !cacheControl['must-revalidate'] && - !cacheControl['proxy-revalidate'], - ) - if ( contentLength < maxEntrySize && cacheControl && @@ -73,8 +52,6 @@ class CacheHandler extends DecoratorHandler { ? 31556952 // 1 year : Number(maxAge) - console.log({ ttl, maxAge, cacheControl, contentLength, maxEntrySize }) - if (ttl > 0) { this.#value = { data: { @@ -88,16 +65,10 @@ class CacheHandler extends DecoratorHandler { (rawHeaders?.reduce((xs, x) => xs + x.length, 0) ?? 0) + (statusMessage?.length ?? 0) + 64, - ttl: ttl * 1e3, + expires: Date.now() + ttl, } } - - console.log({ thisvalue: this.#value }) } - - console.log('onHeaders, finish:') - console.log({ statusCode, rawHeaders, resume, statusMessage, headers }) - return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers) } @@ -116,34 +87,25 @@ class CacheHandler extends DecoratorHandler { } onComplete(rawTrailers) { - console.log('onComplete this:') - console.log({ thisvalue: this.#value }) - console.log({ thisstore: this.#store }) // CacheStore{} - console.log({ thishandler: this.#handler }) // RequestHandler{} - console.log({ thishandlervalue: this.#handler.value }) - console.log({ this: this }) if (this.#value) { - this.#value.data.rawTrailers = rawTrailers - this.#value.size += rawTrailers?.reduce((xs, x) => xs + x.length, 0) ?? 0 - - const opts = this.#handler.opts - const entries = this.#handler.entries - console.log('onComplete this:') - console.log({ opts, entries }) - - const reqHeaders = this.#handler.opts + const reqHeaders = this.#opts const resHeaders = parseHeaders(this.#value.data.rawHeaders) - const vary = formatVaryData(resHeaders, reqHeaders) - - console.log({ vary }) + // Early return if Vary = *, uncacheable. + if (resHeaders.vary === '*') { + return this.#handler.onComplete(rawTrailers) + } - this.#value.vary = vary + this.#value.data.rawTrailers = rawTrailers + this.#value.size = this.#value.size + ? this.#value.size + rawTrailers?.reduce((xs, x) => xs + x.length, 0) + : 0 - console.log({ entries }) + this.#value.vary = formatVaryData(resHeaders, reqHeaders) - this.#store.set(this.#key, entries.push(this.#value)) + this.#store.set(this.#key, this.#value) } + return this.#handler.onComplete(rawTrailers) } } @@ -152,61 +114,86 @@ function formatVaryData(resHeaders, reqHeaders) { return resHeaders.vary ?.split(',') .map((key) => key.trim().toLowerCase()) - .map((key) => [key, reqHeaders[key]]) + .map((key) => [key, reqHeaders[key] ?? '']) + .filter(([, val]) => val) } -// TODO (fix): Async filesystem cache. -class CacheStore { - constructor({ maxSize = 1024 * 1024, maxEntrySize = 128 * 1024 }) { - this.maxSize = maxSize - this.maxEntrySize = maxEntrySize - this.cache = new LRUCache({ maxSize }) +export class CacheStore { + #database + + #insertquery + #getQuery + #purgeQuery + + #size = 0 + #maxSize = 128e9 + + constructor(location = ':memory:', opts) { + // TODO (fix): Validate args... + + this.#maxSize = opts.maxSize ?? this.#maxSize + this.#database = new DatabaseSync(location) + + this.#database.exec(` + CREATE TABLE IF NOT EXISTS cacheInterceptor( + key TEXT, + data TEXT, + vary TEXT, + size INTEGER, + expires INTEGER + ) STRICT + `) + + this.#insertquery = this.#database.prepare( + 'INSERT INTO cacheInterceptor (key, data, vary, size, expires) VALUES (?, ?, ?, ?, ?)', + ) + + this.#getQuery = this.#database.prepare( + 'SELECT * FROM cacheInterceptor WHERE key = ? AND expires > ? ', + ) + + this.#purgeQuery = this.#database.prepare('DELETE FROM cacheInterceptor WHERE expires < ?') + + this.#maybePurge() } - set(key, value, opts) { - this.cache.set(key, value, opts) + set(key, { data, vary, size, expires }) { + this.#insertquery.run(key, JSON.stringify(data), BJSON.stringify(vary), size, expires) + + this.#size += size + this.#maybePurge() } get(key) { - return this.cache.get(key) + return this.#getQuery.all(key, Date.now()).map(({ data, vary, size, expires }) => ({ + data: BJSON.parse(data), + vary: JSON.parse(vary), + size: parseInt(size), // TODO (fix): Is parseInt necessary? + expires: parseInt(expires), // TODO (fix): Is parseInt necessary? + })) } -} - -function findEntryByHeaders(entries, reqHeaders) { - // Sort entries by number of vary headers in descending order, because - // we want to compare the most complex response to the request first. - entries.sort((a, b) => { - const lengthA = a.vary ? a.vary.length : 0 - const lengthB = b.vary ? b.vary.length : 0 - return lengthB - lengthA - }) - console.log('Sort entries') - console.log({ entries }) + close() { + this.#database.close() + } - console.log('reqHeaders') - console.log({ reqHeaders }) + #maybePurge() { + if (this.#size == null || this.#size > this.#maxSize) { + this.#purgeQuery.run(Date.now()) + this.#size = this.#database.exec('SELECT SUM(size) FROM cacheInterceptor')[0].values[0][0] + } + } +} +function findEntryByHeaders(entries, reqHeaders) { return entries?.find( - (entry) => - entry.vary?.every(([key, val]) => { - console.log(`reqHeaders[${key}] === ${val}`) - console.log({ reqHeadersval: reqHeaders[key] }) - return reqHeaders[key] === val - }) ?? true, + (entry) => entry.vary?.every(([key, val]) => reqHeaders?.headers[key] === val) ?? true, ) } -const DEFAULT_CACHE_STORE = new CacheStore({ maxSize: 128 * 1024, maxEntrySize: 1024 }) +const DEFAULT_CACHE_STORE = new CacheStore() export default (opts) => (dispatch) => (opts, handler) => { - console.log('cache dispatcher:') - console.log(dispatch) - console.log('opts:') - console.log(opts) - console.log('handler:') - console.log(handler) - if (!opts.cache || opts.upgrade) { return dispatch(opts, handler) } @@ -235,85 +222,27 @@ export default (opts) => (dispatch) => (opts, handler) => { // Dump body... opts.body?.on('error', () => {}).resume() + opts.host = opts.host ?? new URL(opts.origin).host + + if (!opts.headers) { + opts.headers = {} + } + + // idea: use DEFAULT_CACHE_STORE by default if 'cache' not specified, since the cache interceptor was already specified to be used. const store = opts.cache === true ? DEFAULT_CACHE_STORE : opts.cache if (!store) { throw new Error(`Cache store not provided.`) } - let key = `${opts.method}:${opts.path}` - console.log('getting key: ' + key) - let entries = store.get(key) + const key = `${opts.method}:${opts.path}` - if (Array.isArray(entries) && entries.length === 0 && opts.method === 'HEAD') { - key = `GET:${opts.path}` - entries = store.get(key) - } + const entries = store.get(key) ?? (opts.method === 'HEAD' ? store.get(`GET:${opts.path}`) : null) - // testing - const rawHeaders = [ - Buffer.from('Content-Type'), - Buffer.from('application/json'), - Buffer.from('Content-Length'), - Buffer.from('10'), - Buffer.from('Cache-Control'), - Buffer.from('public'), - ] - // // cannot get the cache to work inside the test, so I hardcode the entries here - entries = [ - { - statusCode: 200, - statusMessage: '', - rawHeaders, - rawTrailers: ['Hello'], - body: ['asd1'], - vary: [ - ['Accept', 'application/xml'], - ['User-Agent', 'Mozilla/5.0'], - ], - }, - { - statusCode: 200, - statusMessage: '', - rawHeaders, - rawTrailers: ['Hello'], - body: ['asd2'], - vary: [ - ['Accept', 'application/txt'], - ['User-Agent', 'Chrome'], - ['origin2', 'www.google.com/images'], - ], - }, - // { - // statusCode: 200, statusMessage: 'last', rawHeaders, rawTrailers: ['Hello'], body: ['asd3'], - // vary: null }, - { - statusCode: 200, - statusMessage: 'first', - rawHeaders, - rawTrailers: ['Hello'], - body: ['asd4'], - vary: [ - ['Accept', 'application/json'], - ['User-Agent', 'Mozilla/5.0'], - ['host2', 'www.google.com'], - ['origin2', 'www.google.com/images'], - ], - }, - ] - - // *testing - - // Find an entry that matches the request, if any const entry = findEntryByHeaders(entries, opts) - console.log('Entry found:') - console.log({ entry }) - - // handler.value.vary = 'foobar' - if (entry) { - const { statusCode, statusMessage, rawHeaders, rawTrailers, body } = entry + const { statusCode, statusMessage, rawHeaders, rawTrailers, body } = entry.data const ac = new AbortController() const signal = ac.signal @@ -325,11 +254,14 @@ export default (opts) => (dispatch) => (opts, handler) => { try { handler.onConnect(abort) signal.throwIfAborted() + handler.onHeaders(statusCode, rawHeaders, resume, statusMessage) signal.throwIfAborted() + if (opts.method !== 'HEAD') { for (const chunk of body) { const ret = handler.onData(chunk) + signal.throwIfAborted() if (ret === false) { // TODO (fix): back pressure... @@ -345,8 +277,6 @@ export default (opts) => (dispatch) => (opts, handler) => { return true } else { - // handler.opts = opts - // handler.entries = entries - return dispatch(opts, new CacheHandler({ handler, store, key })) + return dispatch(opts, new CacheHandler({ handler, store, key, opts })) } }