From 5ea9934976887197e3d30a7ef2926e6e803c2d17 Mon Sep 17 00:00:00 2001 From: flakey5 <73616808+flakey5@users.noreply.github.com> Date: Tue, 27 Aug 2024 18:49:05 -0700 Subject: [PATCH] tmp Signed-off-by: flakey5 <73616808+flakey5@users.noreply.github.com> --- lib/handler/cache-handler.js | 109 ++++++++++++++++++++++ lib/interceptor/cache.js | 175 +++++++++++++++++++++++++++++++++++ lib/util/cache-control.js | 88 ++++++++++++++++++ tmp/package.json | 18 ++++ tmp/test.js | 7 ++ types/cache-interceptor.d.ts | 39 ++++++++ types/interceptors.d.ts | 3 + 7 files changed, 439 insertions(+) create mode 100644 lib/handler/cache-handler.js create mode 100644 lib/interceptor/cache.js create mode 100644 lib/util/cache-control.js create mode 100644 tmp/package.json create mode 100644 tmp/test.js create mode 100644 types/cache-interceptor.d.ts diff --git a/lib/handler/cache-handler.js b/lib/handler/cache-handler.js new file mode 100644 index 00000000000..f370cd0dad7 --- /dev/null +++ b/lib/handler/cache-handler.js @@ -0,0 +1,109 @@ +'use strict' + +const util = require('../core/util.js') +const DecoratorHandler = require('../handler/decorator-handler') +const { parseCacheControlHeader, shouldBeCached } = require('../util/cache-control.js') + +class CacheHandler extends DecoratorHandler { + #opts + #handler + #store + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue} + */ + #value = null + + /** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} opts + * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler + * @param {import('../../types/cache-interceptor.d.ts').default.CacheStore} store + */ + constructor (opts, handler, store) { + super(handler) + + this.#opts = opts + this.#handler = handler + this.#store = store + } + + onHeaders ( + statusCode, + rawHeaders, + resume, + statusMessage, + headers = util.parseHeaders(rawHeaders) + ) { + if (statusCode !== 307 || statusCode !== 200) { + return this.#handler.onHeaders( + statusCode, + rawHeaders, + resume, + statusMessage, + headers + ) + } + + // TODO vary header + const cacheControlDirectives = parseCacheControlHeader(headers['cache-control']) + const contentLength = headers['content-length'] + ? Number(headers['content-length']) + : Infinity + const maxEntrySize = this.#store.maxEntrySize ?? Infinity + + if (maxEntrySize > contentLength && shouldBeCached(cacheControlDirectives)) { + const maxAge = cacheControlDirectives['s-max-age'] ?? + cacheControlDirectives['max-age'] + const ttl = cacheControlDirectives.immutable + ? 31556952 // 1 year + : Number(maxAge) + + if (ttl > 0) { + this.#value = { + data: { + statusCode, + statusMessage, + rawHeaders, + rawTrailers: null, + body: [] + }, + size: (rawHeaders?.reduce((xs, x) => xs + x.length, 0) ?? 0) + + (statusMessage?.length ?? 0) + + 64, + ttl: ttl * 1e3 + } + } + } + + return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers) + } + + onData (chunk) { + if (this.#value) { + this.#value.size += chunk.bodyLength + + const maxEntrySize = this.#store.maxEntrySize ?? Infinity + if (this.#value.size > maxEntrySize) { + this.#value = null + } else { + this.#value.data.body.push(chunk) + } + } + + return this.#handler.onData(chunk) + } + + onComplete (rawTrailers) { + if (this.#value) { + this.#value.data.rawTrailers = rawTrailers + this.#value.size += rawTrailers?.reduce((xs, x) => xs + x.length, 0) ?? 0 + + this.#store.put(this.#opts, this.#value).catch(err => { + throw err + }) + } + + return this.#handler.onComplete(rawTrailers) + } +} + +module.exports = CacheHandler diff --git a/lib/interceptor/cache.js b/lib/interceptor/cache.js new file mode 100644 index 00000000000..7f89744eadb --- /dev/null +++ b/lib/interceptor/cache.js @@ -0,0 +1,175 @@ +'use strict' + +const sqlite = require('node:sqlite') +const CacheHandler = require('../handler/cache-handler.js') + +/** + * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore + * @implements {CacheStore} + */ +class DefaultCacheStore { + #database + + #getQuery + #putQuery + #purgeQuery + + #size = 0 + #maxSize = 128e9 + + constructor () { + this.#database = new sqlite.DatabaseSync(':memory:') + + this.#database.exec(` + CREATE TABLE IF NOT EXISTS cacheInterceptor( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL, + vary TEXT, + size INTEGER, + expires INTEGER + -- Subject to change depending on implementation specifics + ) STRICT; + + CREATE INDEX IF NOT EXISTS idxCacheInterceptorExpires ON cacheInterceptor(expires); + `) + + this.#getQuery = this.#database.prepare('SELECT * FROM cacheInterceptor WHERE key = ? AND expires = ?') + + this.#putQuery = this.#database.prepare('INSERT INTO cacheInterceptor (key, value, vary, size, expires) VALUES (?, ?, ?, ?, ?)') + + this.#purgeQuery = this.#database.prepare('DELETE FROM cacheInterceptor WHERE expires < ?') + } + + /** + * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts + * @returns {Promise} + */ + get (req) { + const key = this.#makeKey(req) + return this.#getQuery.all(key, Date.now()).map((entry) => ({ + body: entry.body, + vary: entry.vary, + size: entry.size, + expires: entry.expires + })) + } + + /** + * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req + * @param {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue} opts + */ + put (req, opts) { + const key = this.#makeKey(req) + + this.#putQuery.run(/* TODO map args */) + + this.#purge() + } + + /** + * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts + * @returns {string} + */ + #makeKey (opts) { + return `${opts.origin}:${opts.path}:${opts.method}` + } + + #purge () { + if (this.#size >= this.#maxSize) { + this.#purgeQuery.run(Date.now()) + this.#size = this.#database.exec('SELECT SUM(size) FROM cacheInterceptor')[0].values[0][0] + } + } +} + +/** + * TODO better func name + * @param {*} dispatch TODO type + * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts + * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler + * @param {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue[]} entries + */ +function sendCachedResponse (dispatch, opts, handler, store, entries) { + if (entries.length === 0) { + // Request isn't cached, let's continue dispatching it + dispatch(opts, new CacheHandler(opts, handler, store)) + return + } + + // TODO finish selection logic + const value = entries[0] + + if (value === null) { + dispatch(opts, new CacheHandler(opts, handler, store)) + return + } + + const ac = new AbortController() + const signal = ac.signal + + // Request is cached, let's return it + try { + const { + statusCode, + statusMessage, + rawHeaders, + rawTrailers, + body + } = value.data + + handler.onConnect(ac.abort) + signal.throwIfAborted() + + // TODO add age header + handler.onHeaders(statusCode, rawHeaders, () => {}, statusMessage) + signal.throwIfAborted() + + if (opts.method !== 'HEAD') { + handler.onComplete([]) + } else { + for (const chunk of body) { + // TODO there's probably a better way to handle backpressure lol + let ret = false + while (ret === false) { + ret = handler.onData(chunk) + signal.throwIfAborted() + } + } + + handler.onComplete(rawTrailers) + } + } catch (err) { + handler.onError(err) + } +} + +/** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions | undefined} globalOpts + * @returns {import('../../types/dispatcher.d.ts').default.DispatcherComposeInterceptor} + */ +module.exports = globalOpts => { + const store = globalOpts?.store ?? new DefaultCacheStore() + + return dispatch => { + return (opts, handler) => { + if (globalOpts?.methods && !globalOpts.methods.includes(opts.method)) { + return dispatch(opts, handler) + } + + // Dump body + // TODO is htis needed? + opts.body?.on('error', () => {}).resume() + + const result = Promise.resolve(store.get(opts)) + .catch(err => { + throw err + }) + + result.then(entries => { + sendCachedResponse(dispatch, opts, handler, store, entries) + }) + + return true + } + } +} diff --git a/lib/util/cache-control.js b/lib/util/cache-control.js new file mode 100644 index 00000000000..82083884718 --- /dev/null +++ b/lib/util/cache-control.js @@ -0,0 +1,88 @@ +/** + * @typedef {{ + * 'max-age'?: number; + * 's-max-age'?: number; + * 'stale-while-revalidate'?: number; + * 'stale-if-error'?: number; + * public?: true; + * private?: true; + * 'no-store'?: true; + * 'no-cache'?: true; + * 'must-revalidate'?: true; + * 'proxy-revalidate'?: true; + * immutable?: true; + * 'no-transform'?: true; + * }} CacheControlDirectives + * + * @param {string} header + * @returns {CacheControlDirectives} + */ +function parseCacheControlHeader (header) { + /** + * @type {CacheControlDirectives} + */ + const output = {} + + const directives = header.toLowerCase().split(',') + for (const directive of directives) { + let keyValueDelimiter = 0 + for (; keyValueDelimiter < directive.length; keyValueDelimiter++) { + if (directive[keyValueDelimiter] === '=') { + break + } + } + + const key = directive.substring(0, keyValueDelimiter).trim() + + let value + if (keyValueDelimiter !== directive.length) { + value = directive + .substring(keyValueDelimiter + 1, directive.length) + .trim() + } + + switch (key) { + case 'max-age': + case 's-max-age': + case 'stable-while-revalidate': + case 'stable-if-error': { + const parsedValue = parseInt(value, 10) + if (isNaN(parsedValue)) { + continue + } + + output[key] = parsedValue + + break + } + case 'public': + case 'private': + case 'no-store': + case 'no-cache': + case 'must-revalidate': + case 'proxy-revalidate': + case 'immutable': + case 'no-transform': + output[key] = true + break + default: + continue + } + } + + return output +} + +/** + * + * @param {CacheControlDirectives} directives + * @returns {boolean} + */ +function shouldBeCached (directives) { + +} + +module.exports = { + parseCacheControlHeader, + shouldBeCached +} diff --git a/tmp/package.json b/tmp/package.json new file mode 100644 index 00000000000..1c8f14bfbc9 --- /dev/null +++ b/tmp/package.json @@ -0,0 +1,18 @@ +{ + "name": "tmp", + "version": "1.0.0", + "main": "index.js", + "directories": { + "test": "test" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "undici": "^6.19.8" + } +} diff --git a/tmp/test.js b/tmp/test.js new file mode 100644 index 00000000000..dafbcc4bc94 --- /dev/null +++ b/tmp/test.js @@ -0,0 +1,7 @@ +const { Client } = require('undici') +const cache = require('../lib/interceptor/cache') + +const cli = new Client('https://google.com') + .compose(cache()) + +cli.request({ path: '/', method: 'GET' }) diff --git a/types/cache-interceptor.d.ts b/types/cache-interceptor.d.ts new file mode 100644 index 00000000000..aaa8aa0289f --- /dev/null +++ b/types/cache-interceptor.d.ts @@ -0,0 +1,39 @@ +import Dispatcher from "./dispatcher"; + +export default CacheHandler; + +declare class CacheHandler implements Dispatcher.DispatchHandlers { + constructor( + options: Dispatcher.DispatchOptions & { + cacheOptions?: CacheHandler.CacheOptions; + }, + cacheHandlers: CacheHandler.CacheOptions + ); +} + +declare namespace CacheHandler { + export interface CacheOptions { + store?: CacheStore + methods?: ('GET' | 'HEAD' | 'POST' | 'PATCH')[] + } + + export interface CacheStore { + maxEntrySize?: number + + get(key: Dispatcher.RequestOptions): CacheStoreValue[] | Promise; + put(key: Dispatcher.RequestOptions, opts: CacheStoreValue): Promise; + } + + export interface CacheStoreValue { + data: { + statusCode: number; + statusMessage: string; + rawHeaders: any; // TODO type + rawTrailers: any; + body: string[] + }; + vary: Record; + size: number; + expires: number; + } +} diff --git a/types/interceptors.d.ts b/types/interceptors.d.ts index 24166b61f4f..a397e6e3d7f 100644 --- a/types/interceptors.d.ts +++ b/types/interceptors.d.ts @@ -1,3 +1,4 @@ +import CacheHandler from "./cache"; import Dispatcher from "./dispatcher"; import RetryHandler from "./retry-handler"; @@ -8,10 +9,12 @@ declare namespace Interceptors { export type RetryInterceptorOpts = RetryHandler.RetryOptions export type RedirectInterceptorOpts = { maxRedirections?: number } export type ResponseErrorInterceptorOpts = { throwOnError: boolean } + export type CacheInterceptorOpts = CacheHandler.CacheOptions export function createRedirectInterceptor(opts: RedirectInterceptorOpts): Dispatcher.DispatcherComposeInterceptor export function dump(opts?: DumpInterceptorOpts): Dispatcher.DispatcherComposeInterceptor export function retry(opts?: RetryInterceptorOpts): Dispatcher.DispatcherComposeInterceptor export function redirect(opts?: RedirectInterceptorOpts): Dispatcher.DispatcherComposeInterceptor export function responseError(opts?: ResponseErrorInterceptorOpts): Dispatcher.DispatcherComposeInterceptor + export function cache(opts?: CacheInterceptorOpts): Dispatcher.DispatcherComposeInterceptor }