@cloudflare/kv-asset-handler
Advanced tools
Comparing version 0.0.2 to 0.0.4
{ | ||
"name": "@cloudflare/kv-asset-handler", | ||
"version": "0.0.2", | ||
"version": "0.0.4", | ||
"description": "Routes requests to KV assets", | ||
@@ -5,0 +5,0 @@ "main": "./src/index.js", |
@@ -34,11 +34,11 @@ # @cloudflare/kv-asset-handler | ||
}) | ||
const customKeyModifier = url => { | ||
//custom key mapping optional | ||
if (url.endsWith('/')) url += 'index.html' | ||
return url.replace('/docs', '').replace(/^\/+/, '') | ||
} | ||
async function handleRequest(request) { | ||
if (request.url.includes('/docs')) { | ||
try { | ||
return await getAssetFromKV(request, url => { | ||
//custom key mapping optional | ||
if (url.endsWith('/')) url += 'index.html' | ||
return url.replace('/docs', '') | ||
}) | ||
return await getAssetFromKV(request, customKeyModifier) | ||
} catch (e) { | ||
@@ -45,0 +45,0 @@ return new Response(`"${customKeyModifier(request.url)}" not found`, { |
103
src/index.js
@@ -1,16 +0,47 @@ | ||
import mime from 'mime/lite' | ||
import mime from 'mime' | ||
const defaultKeyModifier = pathname => { | ||
/** | ||
* maps the path of incoming request to the request pathKey to look up | ||
* in bucket and in cache | ||
* e.g. for a path '/' returns '/index.html' which serves | ||
* the content of bucket/index.html | ||
* @param {Request} request incoming request | ||
*/ | ||
const mapRequestToAsset = request => { | ||
const parsedUrl = new URL(request.url) | ||
let pathname = parsedUrl.pathname | ||
if (pathname.endsWith('/')) { | ||
// If path looks like a directory append index.html | ||
// e.g. If path is /about/ -> /about/index.html | ||
pathname = pathname.concat('index.html') | ||
} else if (!mime.getType(pathname)) { | ||
// If path doesn't look like valid content | ||
// e.g. /about.me -> /about.me/index.html | ||
pathname = pathname.concat('/index.html') | ||
} | ||
return pathname | ||
parsedUrl.pathname = pathname | ||
return new Request(parsedUrl, request) | ||
} | ||
const defaultCacheControl = { | ||
browserTTL: 0, | ||
edgeTTL: 100 * 60 * 60 * 24, // 100 days | ||
bypassCache: false, | ||
bypassCache: false, // do not bypass Cloudflare's cache | ||
} | ||
/** | ||
* takes the path of the incoming request, gathers the approriate cotent from KV, and returns | ||
* the response | ||
* | ||
* @param {event} event the fetch event of the triggered request | ||
* @param {{mapRequestToAsset: (string: Request) => Request, cacheControl: {bypassCache:boolean, edgeTTL: number, browserTTL:number}, ASSET_NAMESPACE: any, ASSET_MANIFEST:any}} [options] configurable options | ||
* @param {CacheControl} [options.cacheControl] determine how to cache on Cloudflare and the browser | ||
* @param {typeof(options.mapRequestToAsset)} [options.mapRequestToAsset] maps the path of incoming request to the request pathKey to look up | ||
* @param {any} [options.ASSET_NAMESPACE] the binding to the namespace that script references | ||
* @param {any} [options.ASSET_MANIFEST] the map of the key to cache and store in KV | ||
* */ | ||
const getAssetFromKV = async (event, options) => { | ||
// Assign any missing options passed in to the default | ||
options = Object.assign( | ||
@@ -20,3 +51,3 @@ { | ||
ASSET_MANIFEST: __STATIC_CONTENT_MANIFEST, | ||
keyModifier: defaultKeyModifier, | ||
mapRequestToAsset: mapRequestToAsset, | ||
cacheControl: defaultCacheControl, | ||
@@ -34,30 +65,34 @@ }, | ||
} | ||
if (typeof ASSET_NAMESPACE === 'undefined') { | ||
throw new Error(`there is no ${ASSET_NAMESPACE} namespace bound to the script`) | ||
} | ||
const parsedUrl = new URL(request.url) | ||
const pathname = options.keyModifier(parsedUrl.pathname) | ||
// remove prepended / | ||
let key = pathname.slice(1) | ||
// determine the requestKey based on the actual file served for the incoming request | ||
const requestKey = options.mapRequestToAsset(request) | ||
const parsedUrl = new URL(requestKey.url) | ||
const pathname = parsedUrl.pathname | ||
// pathKey is the file path to look up in the manifest | ||
let pathKey = pathname.replace(/^\/+/, '') // remove prepended / | ||
const cache = caches.default | ||
const mimeType = mime.getType(pathKey) || 'text/plain' | ||
let shouldEdgeCache = false | ||
let shouldEdgeCache = false // false if storing in KV by raw file path i.e. no hash | ||
// check manifest for map from file path to hash | ||
if (typeof ASSET_MANIFEST !== 'undefined') { | ||
let hashKey = JSON.parse(ASSET_MANIFEST)[key] | ||
if (typeof hashKey !== 'undefined') { | ||
key = hashKey | ||
// cache on edge if content is hashed | ||
shouldEdgeCache = true | ||
if (JSON.parse(ASSET_MANIFEST)[pathKey]) { | ||
pathKey = JSON.parse(ASSET_MANIFEST)[pathKey] | ||
shouldEdgeCache = true // cache on edge if pathKey is a unique hash | ||
} | ||
} | ||
// this excludes search params from cache key | ||
const cacheKey = `${parsedUrl.origin}/${key}` | ||
// TODO cacheKey should be a request and this excludes search params from cache | ||
const cacheKey = `${parsedUrl.origin}/${pathKey}` | ||
// options.cacheControl can be a function that takes a request or an object | ||
// the result should be formed like the defaultCacheControl object | ||
// if argument passed in for cacheControl is a function then | ||
// evaluate that function. otherwise return the Object passed in | ||
// or default Object | ||
const evalCacheOpts = (() => { | ||
@@ -78,6 +113,10 @@ switch (typeof options.cacheControl) { | ||
if (options.cacheControl.bypassCache) { | ||
shouldEdgeCache = !options.cacheControl.bypassCache | ||
shouldEdgeCache = false | ||
} | ||
let response = await cache.match(cacheKey) | ||
let response = null | ||
if (shouldEdgeCache) { | ||
response = await cache.match(cacheKey) | ||
} | ||
if (response) { | ||
@@ -88,23 +127,27 @@ let headers = new Headers(response.headers) | ||
} else { | ||
const mimeType = mime.getType(pathname) | ||
const body = await __STATIC_CONTENT.get(key, 'arrayBuffer') | ||
const body = await __STATIC_CONTENT.get(pathKey, 'arrayBuffer') | ||
if (body === null) { | ||
throw new Error(`could not find ${key} in your content namespace`) | ||
throw new Error(`could not find ${pathKey} in your content namespace`) | ||
} | ||
response = new Response(body) | ||
response.headers.set('Content-Type', mimeType) | ||
// TODO: could implement CF-Cache-Status REVALIDATE if path w/o hash existed in manifest | ||
if (shouldEdgeCache === true) { | ||
if (shouldEdgeCache) { | ||
response.headers.set('CF-Cache-Status', 'MISS') | ||
// determine Cloudflare cache behavior | ||
response.headers.set('Cache-Control', `max-age=${options.cacheControl.edgeTTL}`) | ||
event.waitUntil(cache.put(cacheKey, response.clone())) | ||
// don't assume we want same cache behavior on client | ||
// so remove the header from the response we'll return | ||
response.headers.delete('Cache-Control') | ||
} | ||
} | ||
response.headers.set('Cache-Control', `max-age=${options.cacheControl.browserTTL}`) | ||
response.headers.set('Content-Type', mimeType) | ||
if (options.cacheControl.browserTTL) { | ||
response.headers.set('Cache-Control', `max-age=${options.cacheControl.browserTTL}`) | ||
} | ||
return response | ||
} | ||
export { getAssetFromKV } | ||
export { getAssetFromKV, mapRequestToAsset } |
const makeServiceWorkerEnv = require('service-worker-mock') | ||
const HASH = '123-I-AM-A-HASH-BROWN' | ||
const HASH = '123HASHBROWN' | ||
export const getEvent = request => { | ||
const waitUntil = callback => { | ||
callback | ||
} | ||
return { | ||
request, | ||
waitUntil, | ||
} | ||
} | ||
export const mockKV = () => { | ||
const store = { | ||
'key1.txt-123-I-AM-A-HASH-BROWN': 'val1', | ||
'key1.png-123-I-AM-A-HASH-BROWN': 'val1', | ||
'index.html-123-I-AM-A-HASH-BROWN': 'index.html', | ||
'key1.123HASHBROWN.txt': 'val1', | ||
'key1.123HASHBROWN.png': 'val1', | ||
'index.123HASHBROWN.html': 'index.html', | ||
'cache.123HASHBROWN.html': 'cache me if you can', | ||
'nohash.txt': 'no hash but still got some result', | ||
'sub/blah.123HASHBROWN.png': 'picturedis', | ||
} | ||
@@ -18,14 +31,19 @@ return { | ||
return JSON.stringify({ | ||
'key1.txt': `key1.txt-${HASH}`, | ||
'key1.png': `key1.png-${HASH}`, | ||
'index.html': `index.html-${HASH}`, | ||
'key1.txt': `key1.${HASH}.txt`, | ||
'key1.png': `key1.${HASH}.png`, | ||
'cache.html': `cache.${HASH}.html`, | ||
'index.html': `index.${HASH}.html`, | ||
'sub/blah.png': `sub/blah.${HASH}.png`, | ||
}) | ||
} | ||
let cacheStore = {} | ||
export const mockCaches = () => { | ||
const store = { 'https://blah.com/key1.txt-123-I-AM-A-HASH-BROWN': 'val1' } | ||
return { | ||
default: { | ||
match: () => null, | ||
put: a => {}, | ||
match: key => { | ||
return cacheStore[key] || null | ||
}, | ||
put: (key, val) => { | ||
return (cacheStore[key] = val) | ||
}, | ||
}, | ||
@@ -32,0 +50,0 @@ } |
import test from 'ava' | ||
import { getAssetFromKV } from '../src/index' | ||
import { mockGlobal } from '../src/mocks' | ||
import { mockGlobal, getEvent } from '../src/mocks' | ||
import { getAssetFromKV, mapRequestToAsset } from '../src/index' | ||
const getEvent = request => { | ||
const waitUntil = callback => {} | ||
return { | ||
request, | ||
waitUntil, | ||
} | ||
} | ||
test('mapRequestToAsset() correctly changes /about -> /about/index.html', async t => { | ||
mockGlobal() | ||
let path = '/about' | ||
let request = new Request(`https://foo.com${path}`) | ||
let newRequest = mapRequestToAsset(request) | ||
t.is(newRequest.url, request.url + '/index.html') | ||
}) | ||
test('mapRequestToAsset() correctly changes /about/ -> /about/index.html', async t => { | ||
let path = '/about/' | ||
let request = new Request(`https://foo.com${path}`) | ||
let newRequest = mapRequestToAsset(request) | ||
t.is(newRequest.url, request.url + 'index.html') | ||
}) | ||
test('mapRequestToAsset() correctly changes /about.me/ -> /about.me/index.html', async t => { | ||
let path = '/about.me/' | ||
let request = new Request(`https://foo.com${path}`) | ||
let newRequest = mapRequestToAsset(request) | ||
t.is(newRequest.url, request.url + 'index.html') | ||
}) | ||
test('getAssetFromKV return correct val from KV and default caching', async t => { | ||
mockGlobal() | ||
const event = getEvent(new Request('https://blah.com/key1.txt')) | ||
@@ -19,5 +32,6 @@ const res = await getAssetFromKV(event) | ||
if (res) { | ||
t.is(res.headers.get('cache-control'), 'max-age=0') | ||
t.is(res.headers.get('Cf-Cache-Status'), 'MISS') | ||
t.is(res.headers.get('cache-control'), null) | ||
t.is(res.headers.get('cf-cache-status'), 'MISS') | ||
t.is(await res.text(), 'val1') | ||
t.true(res.headers.get('content-type').includes('text')) | ||
} else { | ||
@@ -27,3 +41,15 @@ t.fail('Response was undefined') | ||
}) | ||
test('getAssetFromKV if not in asset manifest still returns nohash.txt', async t => { | ||
mockGlobal() | ||
const event = getEvent(new Request('https://blah.com/nohash.txt')) | ||
const res = await getAssetFromKV(event) | ||
if (res) { | ||
t.is(await res.text(), 'no hash but still got some result') | ||
t.true(res.headers.get('content-type').includes('text')) | ||
} else { | ||
t.fail('Response was undefined') | ||
} | ||
}) | ||
test('getAssetFromKV gets index.html by default for / requests', async t => { | ||
@@ -36,2 +62,3 @@ mockGlobal() | ||
t.is(await res.text(), 'index.html') | ||
t.true(res.headers.get('content-type').includes('html')) | ||
} else { | ||
@@ -44,15 +71,16 @@ t.fail('Response was undefined') | ||
mockGlobal() | ||
const event = getEvent(new Request('https://blah.com/docs/index.html')) | ||
const event = getEvent(new Request('https://blah.com/docs/sub/blah.png')) | ||
const customKeyModifier = pathname => { | ||
if (pathname === '/') { | ||
pathname += 'index.html' | ||
} | ||
return pathname.replace('/docs', '') | ||
const customRequestMapper = request => { | ||
let defaultModifiedRequest = mapRequestToAsset(request) | ||
let url = new URL(defaultModifiedRequest.url) | ||
url.pathname = url.pathname.replace('/docs', '') | ||
return new Request(url, request) | ||
} | ||
const res = await getAssetFromKV(event, { keyModifier: customKeyModifier }) | ||
const res = await getAssetFromKV(event, { mapRequestToAsset: customRequestMapper }) | ||
if (res) { | ||
t.is(await res.text(), 'index.html') | ||
t.is(await res.text(), 'picturedis') | ||
} else { | ||
@@ -96,5 +124,6 @@ t.fail('Response was undefined') | ||
if (res1 && res2) { | ||
t.is(res1.headers.get('cache-control'), 'max-age=0') | ||
t.is(res1.headers.get('cache-control'), null) | ||
t.true(res2.headers.get('content-type').includes('png')) | ||
t.is(res2.headers.get('cache-control'), 'max-age=720') | ||
t.is(res2.headers.get('Cf-Cache-Status'), 'MISS') | ||
t.is(res2.headers.get('cf-cache-status'), 'MISS') | ||
} else { | ||
@@ -104,3 +133,22 @@ t.fail('Response was undefined') | ||
}) | ||
test('getAssetFromKV caches on two sequential requests', async t => { | ||
mockGlobal() | ||
const sleep = milliseconds => { | ||
return new Promise(resolve => setTimeout(resolve, milliseconds)) | ||
} | ||
const event = getEvent(new Request('https://blah.com/cache.html')) | ||
const res1 = await getAssetFromKV(event, { cacheControl: { edgeTTL: 720, browserTTL: 720 } }) | ||
await sleep(1) | ||
const res2 = await getAssetFromKV(event) | ||
if (res1 && res2) { | ||
t.is(res1.headers.get('cf-cache-status'), 'MISS') | ||
t.is(res1.headers.get('cache-control'), 'max-age=720') | ||
t.is(res2.headers.get('cf-cache-status'), 'HIT') | ||
} else { | ||
t.fail('Response was undefined') | ||
} | ||
}) | ||
test('getAssetFromKV does not cache on Cloudflare when bypass cache set', async t => { | ||
@@ -113,4 +161,4 @@ mockGlobal() | ||
if (res) { | ||
t.is(res.headers.get('cache-control'), 'max-age=0') | ||
t.is(res.headers.get('Cf-Cache-Status'), null) | ||
t.is(res.headers.get('cache-control'), null) | ||
t.is(res.headers.get('cf-cache-status'), null) | ||
} else { | ||
@@ -132,2 +180,13 @@ t.fail('Response was undefined') | ||
test('getAssetFromKV with no trailing slash on a subdirectory', async t => { | ||
mockGlobal() | ||
const event = getEvent(new Request('https://blah.com/sub/blah.png')) | ||
const res = await getAssetFromKV(event) | ||
if (res) { | ||
t.is(await res.text(), 'picturedis') | ||
} else { | ||
t.fail('Response was undefined') | ||
} | ||
}) | ||
test('getAssetFromKV no result throws an error', async t => { | ||
@@ -134,0 +193,0 @@ mockGlobal() |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
26612
9
340