Skip to content

Commit

Permalink
feat: customize bypass query parameter (#79)
Browse files Browse the repository at this point in the history
* chore: use @keyvhq/core instead of keyv

* chore: add BYPASS cache state

* ci: use github actions

* chore: remove keyv dependency

* feat: customize bypass query parameter

closes #72

* docs: add bypassQueryParameter
  • Loading branch information
Kikobeats authored Aug 3, 2021
1 parent 98fa8df commit 929b8cc
Show file tree
Hide file tree
Showing 7 changed files with 169 additions and 105 deletions.
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,24 @@ In case you need you can force invalidate a cache response passing `force=true`
curl https://myserver.dev/user # MISS (first access)
curl https://myserver.dev/user # HIT (served from cache)
curl https://myserver.dev/user # HIT (served from cache)
curl https://myserver.dev/user?force=true # MISS (forcing invalidation)
curl https://myserver.dev/user?force=true # BYPASS (skip cache copy)
```

In that case, the `x-cache-status` will reflect a `'BYPASS'` value.

## API

### cacheableResponse([options])

#### options

##### bypassQueryParameter

Type: `boolean`<br/>
Default: `'force'`

The name of the query parameter to be used for skipping the cache copy in an intentional way.

##### cache

Type: `boolean`<br/>
Expand Down
18 changes: 10 additions & 8 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ const isEmpty = value =>
(typeof value === 'object' && Object.keys(value).length === 0) ||
(typeof value === 'string' && value.trim().length === 0)

const getKeyDefault = ({ req }) => {
const hasQueryParameter = (req, key) =>
Boolean(req.query ? req.query[key] : parse(req.url.split('?')[1])[key])

const getKeyDefault = ({ req }, bypassQueryParameter) => {
const url = new URL(req.url, 'http://localhost').toString()
const { origin } = new URL(url)
const baseKey = normalizeUrl(url, {
removeQueryParameters: ['force', /^utm_\w+/i]
removeQueryParameters: [bypassQueryParameter, /^utm_\w+/i]
})
return baseKey.replace(origin, '').replace('/?', '')
}
Expand Down Expand Up @@ -52,12 +55,13 @@ const createSetHeaders = ({ revalidate }) => {
}

module.exports = ({
bypassQueryParameter = 'force',
cache = new Keyv({ namespace: 'ssr' }),
compress: enableCompression = false,
getKey = getKeyDefault,
get,
send,
getKey = getKeyDefault,
revalidate = ttl => Math.round(ttl * 0.2),
send,
ttl: defaultTtl = 7200000,
...compressOpts
} = {}) => {
Expand All @@ -75,10 +79,8 @@ module.exports = ({

return async opts => {
const { req, res } = opts
const hasForce = Boolean(
req.query ? req.query.force : parse(req.url.split('?')[1]).force
)
const key = getKey(opts)
const hasForce = hasQueryParameter(req, bypassQueryParameter)
const key = getKey(opts, bypassQueryParameter)
const cachedResult = await decompress(await cache.get(key))
const isHit = !hasForce && cachedResult !== undefined
const result = isHit ? cachedResult : await get(opts)
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@
"license": "MIT",
"ava": {
"files": [
"test/**/*.js"
"test/**/*.js",
"!test/util.js"
]
},
"commitlint": {
Expand All @@ -115,7 +116,7 @@
},
"lint-staged": {
"package.json": [
"finepack"
"finepack --sort-ignore-object-at ava"
],
"*.js,!*.min.js,": [
"prettier-standard"
Expand Down
95 changes: 95 additions & 0 deletions test/custom.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
'use strict'

const test = require('ava')
const got = require('got')

const { parseCacheControl, createServer } = require('./util')

test('ttl', async t => {
const url = await createServer({
get: ({ req, res }) => ({ data: { foo: 'bar' }, ttl: 86400000 }),
send: ({ data, headers, res, req, ...props }) => {
res.end('Welcome to Micro')
}
})

const { headers } = await got(`${url}/kikobeats`)
const cacheControl = parseCacheControl(headers)

t.true(cacheControl.public)
t.true(cacheControl['must-revalidate'])
t.true([86399, 86400].includes(cacheControl['max-age']))
t.true([17279, 17280].includes(cacheControl['stale-while-revalidate']))
t.true([17279, 17280].includes(cacheControl['stale-if-error']))
})

test('revalidate', async t => {
const url = await createServer({
revalidate: ttl => ttl * 0.1,
get: ({ req, res }) => ({ data: { foo: 'bar' }, ttl: 86400000 }),
send: ({ data, headers, res, req, ...props }) => {
res.end('Welcome to Micro')
}
})

const { headers } = await got(`${url}/kikobeats`)
const cacheControl = parseCacheControl(headers)

t.true(cacheControl.public)
t.true(cacheControl['must-revalidate'])
t.true([86399, 86400].includes(cacheControl['max-age']))
t.true([8639, 8640].includes(cacheControl['stale-while-revalidate']))
t.true([8639, 8640].includes(cacheControl['stale-if-error']))
})

test('fixed revalidate', async t => {
const url = await createServer({
revalidate: 300000,
get: ({ req, res }) => ({ data: { foo: 'bar' }, ttl: 86400000 }),
send: ({ data, headers, res, req, ...props }) => {
res.end('Welcome to Micro')
}
})

const { headers } = await got(`${url}/kikobeats`)
const cacheControl = parseCacheControl(headers)

t.true(cacheControl.public)
t.true(cacheControl['must-revalidate'])
t.true([86399, 86400].includes(cacheControl['max-age']))
t.true([299, 300].includes(cacheControl['stale-while-revalidate']))
t.true([299, 300].includes(cacheControl['stale-if-error']))
})

test('bypass query parameter', async t => {
const url = await createServer({
bypassQueryParameter: 'bypass',
get: ({ req, res }) => {
return {
data: { foo: 'bar' },
ttl: 86400000,
createdAt: Date.now(),
foo: { bar: true }
}
},
send: ({ data, headers, res, req, ...props }) => {
res.end('Welcome to Micro')
}
})

const { headers: headersOne } = await got(`${url}/kikobeats`)
t.is(headersOne['x-cache-status'], 'MISS')

const { headers: headersTwo } = await got(`${url}/kikobeats`)
t.is(headersTwo['x-cache-status'], 'HIT')

const { headers: headersThree } = await got(`${url}/kikobeats?bypass=true`)
t.is(headersThree['x-cache-status'], 'BYPASS')
t.is(headersThree['x-cache-expired-at'], '0ms')

const { headers: headersFour } = await got(`${url}/kikobeats`)
t.is(headersFour['x-cache-status'], 'HIT')

const { headers: headersFive } = await got(`${url}/kikobeats?force=true`)
t.is(headersFive['x-cache-status'], 'MISS')
})
96 changes: 2 additions & 94 deletions test/index.js
Original file line number Diff line number Diff line change
@@ -1,44 +1,10 @@
const { AssertionError } = require('assert')
'use strict'

const listen = require('test-listen')
const Keyv = require('@keyvhq/core')
const micro = require('micro')
const test = require('ava')
const got = require('got')

const cacheableResponse = require('..')

const createServer = props => {
const server = cacheableResponse(props)
const api = micro((req, res) => server({ req, res }))
return listen(api)
}

const parseCacheControl = headers => {
const header = headers['cache-control']
return header.split(', ').reduce((acc, rawKey) => {
let value = true
let key = rawKey
if (rawKey.includes('=')) {
const [parsedKey, parsedValue] = rawKey.split('=')
key = parsedKey
value = Number(parsedValue)
}
return { ...acc, [key]: value }
}, {})
}

test('.get is required', t => {
const error = t.throws(() => cacheableResponse({}))
t.true(error instanceof AssertionError)
t.is(error.message, '.get required')
})

test('.send is required', t => {
const error = t.throws(() => cacheableResponse({ get: true }))
t.true(error instanceof AssertionError)
t.is(error.message, '.send required')
})
const { parseCacheControl, createServer } = require('./util')

test('default ttl and revalidate', async t => {
const url = await createServer({
Expand All @@ -58,62 +24,6 @@ test('default ttl and revalidate', async t => {
t.true([1439, 1440].includes(cacheControl['stale-if-error']))
})

test('custom ttl', async t => {
const url = await createServer({
get: ({ req, res }) => ({ data: { foo: 'bar' }, ttl: 86400000 }),
send: ({ data, headers, res, req, ...props }) => {
res.end('Welcome to Micro')
}
})

const { headers } = await got(`${url}/kikobeats`)
const cacheControl = parseCacheControl(headers)

t.true(cacheControl.public)
t.true(cacheControl['must-revalidate'])
t.true([86399, 86400].includes(cacheControl['max-age']))
t.true([17279, 17280].includes(cacheControl['stale-while-revalidate']))
t.true([17279, 17280].includes(cacheControl['stale-if-error']))
})

test('custom revalidate', async t => {
const url = await createServer({
revalidate: ttl => ttl * 0.1,
get: ({ req, res }) => ({ data: { foo: 'bar' }, ttl: 86400000 }),
send: ({ data, headers, res, req, ...props }) => {
res.end('Welcome to Micro')
}
})

const { headers } = await got(`${url}/kikobeats`)
const cacheControl = parseCacheControl(headers)

t.true(cacheControl.public)
t.true(cacheControl['must-revalidate'])
t.true([86399, 86400].includes(cacheControl['max-age']))
t.true([8639, 8640].includes(cacheControl['stale-while-revalidate']))
t.true([8639, 8640].includes(cacheControl['stale-if-error']))
})

test('custom fixed revalidate', async t => {
const url = await createServer({
revalidate: 300000,
get: ({ req, res }) => ({ data: { foo: 'bar' }, ttl: 86400000 }),
send: ({ data, headers, res, req, ...props }) => {
res.end('Welcome to Micro')
}
})

const { headers } = await got(`${url}/kikobeats`)
const cacheControl = parseCacheControl(headers)

t.true(cacheControl.public)
t.true(cacheControl['must-revalidate'])
t.true([86399, 86400].includes(cacheControl['max-age']))
t.true([299, 300].includes(cacheControl['stale-while-revalidate']))
t.true([299, 300].includes(cacheControl['stale-if-error']))
})

test('disable revalidation', async t => {
const url = await createServer({
revalidate: false,
Expand Down Expand Up @@ -187,15 +97,13 @@ test('force query params to invalidate', async t => {

const { headers: headersOne } = await got(`${url}/kikobeats`)
t.is(headersOne['x-cache-status'], 'MISS')
// t.snapshot(parseCacheControl(headersOne))

const { headers: headersTwo } = await got(`${url}/kikobeats`)
t.is(headersTwo['x-cache-status'], 'HIT')

const { headers: headersThree } = await got(`${url}/kikobeats?force=true`)
t.is(headersThree['x-cache-status'], 'BYPASS')
t.is(headersThree['x-cache-expired-at'], '0ms')
// t.snapshot(parseCacheControl(headersThree))

const { headers: headersFour } = await got(`${url}/kikobeats`)
t.is(headersFour['x-cache-status'], 'HIT')
Expand Down
19 changes: 19 additions & 0 deletions test/required.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use strict'

const test = require('ava')

const cacheableResponse = require('..')

const { AssertionError } = require('assert')

test('.get', t => {
const error = t.throws(() => cacheableResponse({}))
t.true(error instanceof AssertionError)
t.is(error.message, '.get required')
})

test('.send', t => {
const error = t.throws(() => cacheableResponse({ get: true }))
t.true(error instanceof AssertionError)
t.is(error.message, '.send required')
})
30 changes: 30 additions & 0 deletions test/util.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use strict'

const cacheableResponse = require('..')
const listen = require('test-listen')
const micro = require('micro')

const createServer = props => {
const server = cacheableResponse(props)
const api = micro((req, res) => server({ req, res }))
return listen(api)
}

const parseCacheControl = headers => {
const header = headers['cache-control']
return header.split(', ').reduce((acc, rawKey) => {
let value = true
let key = rawKey
if (rawKey.includes('=')) {
const [parsedKey, parsedValue] = rawKey.split('=')
key = parsedKey
value = Number(parsedValue)
}
return { ...acc, [key]: value }
}, {})
}

module.exports = {
parseCacheControl,
createServer
}

0 comments on commit 929b8cc

Please sign in to comment.