Skip to content

Commit

Permalink
tmp
Browse files Browse the repository at this point in the history
Signed-off-by: flakey5 <73616808+flakey5@users.noreply.github.com>
  • Loading branch information
flakey5 committed Sep 2, 2024
1 parent 3544311 commit fdcf011
Show file tree
Hide file tree
Showing 9 changed files with 521 additions and 1 deletion.
6 changes: 5 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const RetryHandler = require('./lib/handler/retry-handler')
const { getGlobalDispatcher, setGlobalDispatcher } = require('./lib/global')
const DecoratorHandler = require('./lib/handler/decorator-handler')
const RedirectHandler = require('./lib/handler/redirect-handler')
const SqliteCacheStore = require('./lib/cache/sqlite-cache-store')

Object.assign(Dispatcher.prototype, api)

Expand All @@ -39,9 +40,12 @@ module.exports.RedirectHandler = RedirectHandler
module.exports.interceptors = {
redirect: require('./lib/interceptor/redirect'),
retry: require('./lib/interceptor/retry'),
dump: require('./lib/interceptor/dump')
dump: require('./lib/interceptor/dump'),
cache: require('./lib/interceptor/cache')
}

module.exports.SqliteCacheStore = SqliteCacheStore

module.exports.buildConnector = buildConnector
module.exports.errors = errors
module.exports.util = {
Expand Down
92 changes: 92 additions & 0 deletions lib/cache/sqlite-cache-store.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
'use strict'

const sqlite = require('node:sqlite')

/**
* @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore
* @implements {CacheStore}
*/
class SqliteCacheStore {
#database

#getQuery
#putQuery
#purgeQuery

#size = 0
#maxSize

/**
* @typedef {{
* location?: string
* maxSize?: number
* }} Opts
*
* @param {Opts | undefined} opts
*/
constructor (opts = undefined) {
this.#database = new sqlite.DatabaseSync(opts?.location ?? ':memory:')
this.#maxSize = opts?.maxSize ?? 128e9

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<import('../../types/cache-interceptor.d.ts').default.CacheStoreValue[]>}
*/
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]
}
}
}

module.exports = SqliteCacheStore
147 changes: 147 additions & 0 deletions lib/handler/cache-handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
'use strict'

const util = require('../core/util.js')
const DecoratorHandler = require('../handler/decorator-handler')
const { parseCacheControlHeader, shouldRequestBeCached } = 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
)
}

const cacheControlHeader = headers['cache-control']
if (cacheControlHeader) {
const cacheControlDirectives = parseCacheControlHeader(cacheControlHeader)

// TODO vary header
const contentLength = headers['content-length']
? Number(headers['content-length'])
: Infinity
const maxEntrySize = this.#store.maxEntrySize ?? Infinity

if (maxEntrySize > contentLength && shouldRequestBeCached(cacheControlDirectives)) {
const ttl = determineTtl(headers, cacheControlDirectives)

if (ttl > 0) {
this.#value = {
data: {
statusCode,
statusMessage,
// TODO remove connection, ones in no-cache & private directives
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)
}
}

/**
* @param {Record<string, string>} headers
* @param {import('../util/cache-control.js').CacheControlDirectives} cacheControlDirectives
* @returns ttl for an object, 0 if it shouldn't be cached
*/
function determineTtl (headers, cacheControlDirectives) {
// Prioritize s-maxage since we're a shared cache
// s-maxage > max-age > Expire
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3
let sMaxAge = cacheControlDirectives['s-maxage']

if (!sMaxAge) {
if (cacheControlDirectives.immutable) {
// https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2
return 31536000
}

const maxAge = cacheControlDirectives['max-age']
if (!maxAge) {
const expires = headers['expire']
? (new Date() - new Date(headers['expire'])) / 1000
: 0
return expires
}

return maxAge
}

return sMaxAge
}

module.exports = CacheHandler
103 changes: 103 additions & 0 deletions lib/interceptor/cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
'use strict'

const CacheHandler = require('../handler/cache-handler.js')
const SqliteCacheStore = require('../cache/sqlite-cache-store.js')
const { parseCacheControlHeader, shouldRequestBeCached } = require('../util/cache-control.js')

/**
* 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 SqliteCacheStore()

return dispatch => {
return (opts, handler) => {
if (globalOpts?.methods && !globalOpts.methods.includes(opts.method)) {
return dispatch(opts, handler)
}

const clientDirectives = opts.headers['cache-control'] ?
parseCacheControlHeader(opts.headers['cache-control']) :
undefined
if (!shouldRequestBeCached(clientDirectives)) {
return dispatch(opts, handler)
}

// Dump body
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
}
}
}
Loading

0 comments on commit fdcf011

Please sign in to comment.