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 Aug 30, 2024
1 parent 3544311 commit 5ea9934
Show file tree
Hide file tree
Showing 7 changed files with 439 additions and 0 deletions.
109 changes: 109 additions & 0 deletions lib/handler/cache-handler.js
Original file line number Diff line number Diff line change
@@ -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
175 changes: 175 additions & 0 deletions lib/interceptor/cache.js
Original file line number Diff line number Diff line change
@@ -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<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]
}
}
}

/**
* 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
}
}
}
88 changes: 88 additions & 0 deletions lib/util/cache-control.js
Original file line number Diff line number Diff line change
@@ -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
}
18 changes: 18 additions & 0 deletions tmp/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading

0 comments on commit 5ea9934

Please sign in to comment.