summaryrefslogtreecommitdiff
path: root/node_modules/@cloudflare/kv-asset-handler/src
diff options
context:
space:
mode:
Diffstat (limited to 'node_modules/@cloudflare/kv-asset-handler/src')
-rw-r--r--node_modules/@cloudflare/kv-asset-handler/src/index.ts312
-rw-r--r--node_modules/@cloudflare/kv-asset-handler/src/mocks.ts148
-rw-r--r--node_modules/@cloudflare/kv-asset-handler/src/test/getAssetFromKV-optional.ts24
-rw-r--r--node_modules/@cloudflare/kv-asset-handler/src/test/getAssetFromKV.ts488
-rw-r--r--node_modules/@cloudflare/kv-asset-handler/src/test/mapRequestToAsset.ts37
-rw-r--r--node_modules/@cloudflare/kv-asset-handler/src/test/serveSinglePageApp.ts44
-rw-r--r--node_modules/@cloudflare/kv-asset-handler/src/types.ts43
7 files changed, 1096 insertions, 0 deletions
diff --git a/node_modules/@cloudflare/kv-asset-handler/src/index.ts b/node_modules/@cloudflare/kv-asset-handler/src/index.ts
new file mode 100644
index 0000000..5a91b68
--- /dev/null
+++ b/node_modules/@cloudflare/kv-asset-handler/src/index.ts
@@ -0,0 +1,312 @@
+import * as mime from 'mime'
+import {
+ Options,
+ CacheControl,
+ MethodNotAllowedError,
+ NotFoundError,
+ InternalError,
+ AssetManifestType,
+} from './types'
+
+declare global {
+ var __STATIC_CONTENT: any, __STATIC_CONTENT_MANIFEST: string
+}
+
+const defaultCacheControl: CacheControl = {
+ browserTTL: null,
+ edgeTTL: 2 * 60 * 60 * 24, // 2 days
+ bypassCache: false, // do not bypass Cloudflare's cache
+}
+
+const parseStringAsObject = <T>(maybeString: string | T): T =>
+ typeof maybeString === 'string' ? (JSON.parse(maybeString) as T) : maybeString
+
+const getAssetFromKVDefaultOptions: Partial<Options> = {
+ ASSET_NAMESPACE: typeof __STATIC_CONTENT !== 'undefined' ? __STATIC_CONTENT : undefined,
+ ASSET_MANIFEST:
+ typeof __STATIC_CONTENT_MANIFEST !== 'undefined'
+ ? parseStringAsObject<AssetManifestType>(__STATIC_CONTENT_MANIFEST)
+ : {},
+ cacheControl: defaultCacheControl,
+ defaultMimeType: 'text/plain',
+ defaultDocument: 'index.html',
+ pathIsEncoded: false,
+}
+
+function assignOptions(options?: Partial<Options>): Options {
+ // Assign any missing options passed in to the default
+ // options.mapRequestToAsset is handled manually later
+ return <Options>Object.assign({}, getAssetFromKVDefaultOptions, options)
+}
+
+/**
+ * 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: Request, options?: Partial<Options>) => {
+ options = assignOptions(options)
+
+ const parsedUrl = new URL(request.url)
+ let pathname = parsedUrl.pathname
+
+ if (pathname.endsWith('/')) {
+ // If path looks like a directory append options.defaultDocument
+ // e.g. If path is /about/ -> /about/index.html
+ pathname = pathname.concat(options.defaultDocument)
+ } else if (!mime.getType(pathname)) {
+ // If path doesn't look like valid content
+ // e.g. /about.me -> /about.me/index.html
+ pathname = pathname.concat('/' + options.defaultDocument)
+ }
+
+ parsedUrl.pathname = pathname
+ return new Request(parsedUrl.toString(), request)
+}
+
+/**
+ * maps the path of incoming request to /index.html if it evaluates to
+ * any HTML file.
+ * @param {Request} request incoming request
+ */
+function serveSinglePageApp(request: Request, options?: Partial<Options>): Request {
+ options = assignOptions(options)
+
+ // First apply the default handler, which already has logic to detect
+ // paths that should map to HTML files.
+ request = mapRequestToAsset(request, options)
+
+ const parsedUrl = new URL(request.url)
+
+ // Detect if the default handler decided to map to
+ // a HTML file in some specific directory.
+ if (parsedUrl.pathname.endsWith('.html')) {
+ // If expected HTML file was missing, just return the root index.html (or options.defaultDocument)
+ return new Request(`${parsedUrl.origin}/${options.defaultDocument}`, request)
+ } else {
+ // The default handler decided this is not an HTML page. It's probably
+ // an image, CSS, or JS file. Leave it as-is.
+ return request
+ }
+}
+
+/**
+ * takes the path of the incoming request, gathers the appropriate content from KV, and returns
+ * the response
+ *
+ * @param {FetchEvent} 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 {Object | string} [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
+ * */
+
+type Evt = {
+ request: Request
+ waitUntil: (promise: Promise<any>) => void
+}
+
+const getAssetFromKV = async (event: Evt, options?: Partial<Options>): Promise<Response> => {
+ options = assignOptions(options)
+
+ const request = event.request
+ const ASSET_NAMESPACE = options.ASSET_NAMESPACE
+ const ASSET_MANIFEST = parseStringAsObject<AssetManifestType>(options.ASSET_MANIFEST)
+
+ if (typeof ASSET_NAMESPACE === 'undefined') {
+ throw new InternalError(`there is no KV namespace bound to the script`)
+ }
+
+ const rawPathKey = new URL(request.url).pathname.replace(/^\/+/, '') // strip any preceding /'s
+ let pathIsEncoded = options.pathIsEncoded
+ let requestKey
+ // if options.mapRequestToAsset is explicitly passed in, always use it and assume user has own intentions
+ // otherwise handle request as normal, with default mapRequestToAsset below
+ if (options.mapRequestToAsset) {
+ requestKey = options.mapRequestToAsset(request)
+ } else if (ASSET_MANIFEST[rawPathKey]) {
+ requestKey = request
+ } else if (ASSET_MANIFEST[decodeURIComponent(rawPathKey)]) {
+ pathIsEncoded = true
+ requestKey = request
+ } else {
+ const mappedRequest = mapRequestToAsset(request)
+ const mappedRawPathKey = new URL(mappedRequest.url).pathname.replace(/^\/+/, '')
+ if (ASSET_MANIFEST[decodeURIComponent(mappedRawPathKey)]) {
+ pathIsEncoded = true
+ requestKey = mappedRequest
+ } else {
+ // use default mapRequestToAsset
+ requestKey = mapRequestToAsset(request, options)
+ }
+ }
+
+ const SUPPORTED_METHODS = ['GET', 'HEAD']
+ if (!SUPPORTED_METHODS.includes(requestKey.method)) {
+ throw new MethodNotAllowedError(`${requestKey.method} is not a valid request method`)
+ }
+
+ const parsedUrl = new URL(requestKey.url)
+ const pathname = pathIsEncoded ? decodeURIComponent(parsedUrl.pathname) : parsedUrl.pathname // decode percentage encoded path only when necessary
+
+ // pathKey is the file path to look up in the manifest
+ let pathKey = pathname.replace(/^\/+/, '') // remove prepended /
+
+ // @ts-ignore
+ const cache = caches.default
+ let mimeType = mime.getType(pathKey) || options.defaultMimeType
+ if (mimeType.startsWith('text') || mimeType === 'application/javascript') {
+ mimeType += '; charset=utf-8'
+ }
+
+ 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') {
+ if (ASSET_MANIFEST[pathKey]) {
+ pathKey = ASSET_MANIFEST[pathKey]
+ // if path key is in asset manifest, we can assume it contains a content hash and can be cached
+ shouldEdgeCache = true
+ }
+ }
+
+ // TODO this excludes search params from cache, investigate ideal behavior
+ let cacheKey = new Request(`${parsedUrl.origin}/${pathKey}`, request)
+
+ // if argument passed in for cacheControl is a function then
+ // evaluate that function. otherwise return the Object passed in
+ // or default Object
+ const evalCacheOpts = (() => {
+ switch (typeof options.cacheControl) {
+ case 'function':
+ return options.cacheControl(request)
+ case 'object':
+ return options.cacheControl
+ default:
+ return defaultCacheControl
+ }
+ })()
+
+ // formats the etag depending on the response context. if the entityId
+ // is invalid, returns an empty string (instead of null) to prevent the
+ // the potentially disastrous scenario where the value of the Etag resp
+ // header is "null". Could be modified in future to base64 encode etc
+ const formatETag = (entityId: any = pathKey, validatorType: string = 'strong') => {
+ if (!entityId) {
+ return ''
+ }
+ switch (validatorType) {
+ case 'weak':
+ if (!entityId.startsWith('W/')) {
+ return `W/${entityId}`
+ }
+ return entityId
+ case 'strong':
+ if (entityId.startsWith(`W/"`)) {
+ entityId = entityId.replace('W/', '')
+ }
+ if (!entityId.endsWith(`"`)) {
+ entityId = `"${entityId}"`
+ }
+ return entityId
+ default:
+ return ''
+ }
+ }
+
+ options.cacheControl = Object.assign({}, defaultCacheControl, evalCacheOpts)
+
+ // override shouldEdgeCache if options say to bypassCache
+ if (
+ options.cacheControl.bypassCache ||
+ options.cacheControl.edgeTTL === null ||
+ request.method == 'HEAD'
+ ) {
+ shouldEdgeCache = false
+ }
+ // only set max-age if explicitly passed in a number as an arg
+ const shouldSetBrowserCache = typeof options.cacheControl.browserTTL === 'number'
+
+ let response = null
+ if (shouldEdgeCache) {
+ response = await cache.match(cacheKey)
+ }
+
+ if (response) {
+ if (response.status > 300 && response.status < 400) {
+ if (response.body && 'cancel' in Object.getPrototypeOf(response.body)) {
+ // Body exists and environment supports readable streams
+ response.body.cancel()
+ } else {
+ // Environment doesnt support readable streams, or null repsonse body. Nothing to do
+ }
+ response = new Response(null, response)
+ } else {
+ // fixes #165
+ let opts = {
+ headers: new Headers(response.headers),
+ status: 0,
+ statusText: '',
+ }
+
+ opts.headers.set('cf-cache-status', 'HIT')
+
+ if (response.status) {
+ opts.status = response.status
+ opts.statusText = response.statusText
+ } else if (opts.headers.has('Content-Range')) {
+ opts.status = 206
+ opts.statusText = 'Partial Content'
+ } else {
+ opts.status = 200
+ opts.statusText = 'OK'
+ }
+ response = new Response(response.body, opts)
+ }
+ } else {
+ const body = await ASSET_NAMESPACE.get(pathKey, 'arrayBuffer')
+ if (body === null) {
+ throw new NotFoundError(`could not find ${pathKey} in your content namespace`)
+ }
+ response = new Response(body)
+
+ if (shouldEdgeCache) {
+ response.headers.set('Accept-Ranges', 'bytes')
+ response.headers.set('Content-Length', body.length)
+ // set etag before cache insertion
+ if (!response.headers.has('etag')) {
+ response.headers.set('etag', formatETag(pathKey, 'strong'))
+ }
+ // determine Cloudflare cache behavior
+ response.headers.set('Cache-Control', `max-age=${options.cacheControl.edgeTTL}`)
+ event.waitUntil(cache.put(cacheKey, response.clone()))
+ response.headers.set('CF-Cache-Status', 'MISS')
+ }
+ }
+ response.headers.set('Content-Type', mimeType)
+
+ if (response.status === 304) {
+ let etag = formatETag(response.headers.get('etag'), 'strong')
+ let ifNoneMatch = cacheKey.headers.get('if-none-match')
+ let proxyCacheStatus = response.headers.get('CF-Cache-Status')
+ if (etag) {
+ if (ifNoneMatch && ifNoneMatch === etag && proxyCacheStatus === 'MISS') {
+ response.headers.set('CF-Cache-Status', 'EXPIRED')
+ } else {
+ response.headers.set('CF-Cache-Status', 'REVALIDATED')
+ }
+ response.headers.set('etag', formatETag(etag, 'weak'))
+ }
+ }
+ if (shouldSetBrowserCache) {
+ response.headers.set('Cache-Control', `max-age=${options.cacheControl.browserTTL}`)
+ } else {
+ response.headers.delete('Cache-Control')
+ }
+ return response
+}
+
+export { getAssetFromKV, mapRequestToAsset, serveSinglePageApp }
+export { Options, CacheControl, MethodNotAllowedError, NotFoundError, InternalError }
diff --git a/node_modules/@cloudflare/kv-asset-handler/src/mocks.ts b/node_modules/@cloudflare/kv-asset-handler/src/mocks.ts
new file mode 100644
index 0000000..b42fb74
--- /dev/null
+++ b/node_modules/@cloudflare/kv-asset-handler/src/mocks.ts
@@ -0,0 +1,148 @@
+const makeServiceWorkerEnv = require('service-worker-mock')
+
+const HASH = '123HASHBROWN'
+
+export const getEvent = (request: Request): any => {
+ const waitUntil = async (callback: any) => {
+ await callback
+ }
+ return {
+ request,
+ waitUntil,
+ }
+}
+const store: any = {
+ 'key1.123HASHBROWN.txt': 'val1',
+ 'key1.123HASHBROWN.png': 'val1',
+ 'index.123HASHBROWN.html': 'index.html',
+ 'cache.123HASHBROWN.html': 'cache me if you can',
+ '测试.123HASHBROWN.html': 'My filename is non-ascii',
+ '%not-really-percent-encoded.123HASHBROWN.html': 'browser percent encoded',
+ '%2F.123HASHBROWN.html': 'user percent encoded',
+ '你好.123HASHBROWN.html': 'I shouldnt be served',
+ '%E4%BD%A0%E5%A5%BD.123HASHBROWN.html': 'Im important',
+ 'nohash.txt': 'no hash but still got some result',
+ 'sub/blah.123HASHBROWN.png': 'picturedis',
+ 'sub/index.123HASHBROWN.html': 'picturedis',
+ 'client.123HASHBROWN': 'important file',
+ 'client.123HASHBROWN/index.html': 'Im here but serve my big bro above',
+ 'image.123HASHBROWN.png': 'imagepng',
+ 'image.123HASHBROWN.webp': 'imagewebp',
+ '你好/index.123HASHBROWN.html': 'My path is non-ascii',
+}
+export const mockKV = (store: any) => {
+ return {
+ get: (path: string) => store[path] || null,
+ }
+}
+
+export const mockManifest = () => {
+ return JSON.stringify({
+ 'key1.txt': `key1.${HASH}.txt`,
+ 'key1.png': `key1.${HASH}.png`,
+ 'cache.html': `cache.${HASH}.html`,
+ '测试.html': `测试.${HASH}.html`,
+ '你好.html': `你好.${HASH}.html`,
+ '%not-really-percent-encoded.html': `%not-really-percent-encoded.${HASH}.html`,
+ '%2F.html': `%2F.${HASH}.html`,
+ '%E4%BD%A0%E5%A5%BD.html': `%E4%BD%A0%E5%A5%BD.${HASH}.html`,
+ 'index.html': `index.${HASH}.html`,
+ 'sub/blah.png': `sub/blah.${HASH}.png`,
+ 'sub/index.html': `sub/index.${HASH}.html`,
+ client: `client.${HASH}`,
+ 'client/index.html': `client.${HASH}`,
+ 'image.png': `image.${HASH}.png`,
+ 'image.webp': `image.${HASH}.webp`,
+ '你好/index.html': `你好/index.${HASH}.html`,
+ })
+}
+
+let cacheStore: any = new Map()
+interface CacheKey {
+ url: object
+ headers: object
+}
+export const mockCaches = () => {
+ return {
+ default: {
+ async match(key: any) {
+ let cacheKey: CacheKey = {
+ url: key.url,
+ headers: {},
+ }
+ let response
+ if (key.headers.has('if-none-match')) {
+ let makeStrongEtag = key.headers.get('if-none-match').replace('W/', '')
+ Reflect.set(cacheKey.headers, 'etag', makeStrongEtag)
+ response = cacheStore.get(JSON.stringify(cacheKey))
+ } else {
+ // if client doesn't send if-none-match, we need to iterate through these keys
+ // and just test the URL
+ const activeCacheKeys: Array<string> = Array.from(cacheStore.keys())
+ for (const cacheStoreKey of activeCacheKeys) {
+ if (JSON.parse(cacheStoreKey).url === key.url) {
+ response = cacheStore.get(cacheStoreKey)
+ }
+ }
+ }
+ // TODO: write test to accomodate for rare scenarios with where range requests accomodate etags
+ if (response && !key.headers.has('if-none-match')) {
+ // this appears overly verbose, but is necessary to document edge cache behavior
+ // The Range request header triggers the response header Content-Range ...
+ const range = key.headers.get('range')
+ if (range) {
+ response.headers.set(
+ 'content-range',
+ `bytes ${range.split('=').pop()}/${response.headers.get('content-length')}`,
+ )
+ }
+ // ... which we are using in this repository to set status 206
+ if (response.headers.has('content-range')) {
+ response.status = 206
+ } else {
+ response.status = 200
+ }
+ let etag = response.headers.get('etag')
+ if (etag && !etag.includes('W/')) {
+ response.headers.set('etag', `W/${etag}`)
+ }
+ }
+ return response
+ },
+ async put(key: any, val: Response) {
+ let headers = new Headers(val.headers)
+ let url = new URL(key.url)
+ let resWithBody = new Response(val.body, { headers, status: 200 })
+ let resNoBody = new Response(null, { headers, status: 304 })
+ let cacheKey: CacheKey = {
+ url: key.url,
+ headers: {
+ etag: `"${url.pathname.replace('/', '')}"`,
+ },
+ }
+ cacheStore.set(JSON.stringify(cacheKey), resNoBody)
+ cacheKey.headers = {}
+ cacheStore.set(JSON.stringify(cacheKey), resWithBody)
+ return
+ },
+ },
+ }
+}
+
+// mocks functionality used inside worker request
+export function mockRequestScope() {
+ Object.assign(global, makeServiceWorkerEnv())
+ Object.assign(global, { __STATIC_CONTENT_MANIFEST: mockManifest() })
+ Object.assign(global, { __STATIC_CONTENT: mockKV(store) })
+ Object.assign(global, { caches: mockCaches() })
+}
+
+// mocks functionality used on global isolate scope. such as the KV namespace bind
+export function mockGlobalScope() {
+ Object.assign(global, { __STATIC_CONTENT_MANIFEST: mockManifest() })
+ Object.assign(global, { __STATIC_CONTENT: mockKV(store) })
+}
+
+export const sleep = (milliseconds: number) => {
+ return new Promise((resolve) => setTimeout(resolve, milliseconds))
+}
diff --git a/node_modules/@cloudflare/kv-asset-handler/src/test/getAssetFromKV-optional.ts b/node_modules/@cloudflare/kv-asset-handler/src/test/getAssetFromKV-optional.ts
new file mode 100644
index 0000000..9740b7d
--- /dev/null
+++ b/node_modules/@cloudflare/kv-asset-handler/src/test/getAssetFromKV-optional.ts
@@ -0,0 +1,24 @@
+import test from 'ava'
+import { mockRequestScope, mockGlobalScope, getEvent, sleep, mockKV, mockManifest } from '../mocks'
+mockGlobalScope()
+
+// manually reset manifest global, to test optional behaviour
+Object.assign(global, { __STATIC_CONTENT_MANIFEST: undefined })
+
+import { getAssetFromKV, mapRequestToAsset } from '../index'
+
+test('getAssetFromKV return correct val from KV without manifest', async (t) => {
+ mockRequestScope()
+ // manually reset manifest global, to test optional behaviour
+ Object.assign(global, { __STATIC_CONTENT_MANIFEST: undefined })
+
+ const event = getEvent(new Request('https://blah.com/key1.123HASHBROWN.txt'))
+ const res = await getAssetFromKV(event)
+
+ if (res) {
+ t.is(await res.text(), 'val1')
+ t.true(res.headers.get('content-type').includes('text'))
+ } else {
+ t.fail('Response was undefined')
+ }
+})
diff --git a/node_modules/@cloudflare/kv-asset-handler/src/test/getAssetFromKV.ts b/node_modules/@cloudflare/kv-asset-handler/src/test/getAssetFromKV.ts
new file mode 100644
index 0000000..425d622
--- /dev/null
+++ b/node_modules/@cloudflare/kv-asset-handler/src/test/getAssetFromKV.ts
@@ -0,0 +1,488 @@
+import test from 'ava'
+import { mockRequestScope, mockGlobalScope, getEvent, sleep, mockKV, mockManifest } from '../mocks'
+mockGlobalScope()
+
+import { getAssetFromKV, mapRequestToAsset } from '../index'
+import { KVError } from '../types'
+
+test('getAssetFromKV return correct val from KV and default caching', async (t) => {
+ mockRequestScope()
+ const event = getEvent(new Request('https://blah.com/key1.txt'))
+ const res = await getAssetFromKV(event)
+
+ if (res) {
+ 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 {
+ t.fail('Response was undefined')
+ }
+})
+test('getAssetFromKV evaluated the file matching the extensionless path first /client/ -> client', async (t) => {
+ mockRequestScope()
+ const event = getEvent(new Request(`https://foo.com/client/`))
+ const res = await getAssetFromKV(event)
+ t.is(await res.text(), 'important file')
+ t.true(res.headers.get('content-type').includes('text'))
+})
+test('getAssetFromKV evaluated the file matching the extensionless path first /client -> client', async (t) => {
+ mockRequestScope()
+ const event = getEvent(new Request(`https://foo.com/client`))
+ const res = await getAssetFromKV(event)
+ t.is(await res.text(), 'important file')
+ t.true(res.headers.get('content-type').includes('text'))
+})
+
+test('getAssetFromKV if not in asset manifest still returns nohash.txt', async (t) => {
+ mockRequestScope()
+ 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 if no asset manifest /client -> client fails', async (t) => {
+ mockRequestScope()
+ const event = getEvent(new Request(`https://foo.com/client`))
+ const error: KVError = await t.throwsAsync(getAssetFromKV(event, { ASSET_MANIFEST: {} }))
+ t.is(error.status, 404)
+})
+
+test('getAssetFromKV if sub/ -> sub/index.html served', async (t) => {
+ mockRequestScope()
+ const event = getEvent(new Request(`https://foo.com/sub`))
+ const res = await getAssetFromKV(event)
+ if (res) {
+ t.is(await res.text(), 'picturedis')
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+
+test('getAssetFromKV gets index.html by default for / requests', async (t) => {
+ mockRequestScope()
+ const event = getEvent(new Request('https://blah.com/'))
+ const res = await getAssetFromKV(event)
+
+ if (res) {
+ t.is(await res.text(), 'index.html')
+ t.true(res.headers.get('content-type').includes('html'))
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+
+test('getAssetFromKV non ASCII path support', async (t) => {
+ mockRequestScope()
+ const event = getEvent(new Request('https://blah.com/测试.html'))
+ const res = await getAssetFromKV(event)
+
+ if (res) {
+ t.is(await res.text(), 'My filename is non-ascii')
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+
+test('getAssetFromKV supports browser percent encoded URLs', async (t) => {
+ mockRequestScope()
+ const event = getEvent(new Request('https://example.com/%not-really-percent-encoded.html'))
+ const res = await getAssetFromKV(event)
+
+ if (res) {
+ t.is(await res.text(), 'browser percent encoded')
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+
+test('getAssetFromKV supports user percent encoded URLs', async (t) => {
+ mockRequestScope()
+ const event = getEvent(new Request('https://blah.com/%2F.html'))
+ const res = await getAssetFromKV(event)
+
+ if (res) {
+ t.is(await res.text(), 'user percent encoded')
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+
+test('getAssetFromKV only decode URL when necessary', async (t) => {
+ mockRequestScope()
+ const event1 = getEvent(new Request('https://blah.com/%E4%BD%A0%E5%A5%BD.html'))
+ const event2 = getEvent(new Request('https://blah.com/你好.html'))
+ const res1 = await getAssetFromKV(event1)
+ const res2 = await getAssetFromKV(event2)
+
+ if (res1 && res2) {
+ t.is(await res1.text(), 'Im important')
+ t.is(await res2.text(), 'Im important')
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+
+test('getAssetFromKV Support for user decode url path', async (t) => {
+ mockRequestScope()
+ const event1 = getEvent(new Request('https://blah.com/%E4%BD%A0%E5%A5%BD/'))
+ const event2 = getEvent(new Request('https://blah.com/你好/'))
+ const res1 = await getAssetFromKV(event1)
+ const res2 = await getAssetFromKV(event2)
+
+ if (res1 && res2) {
+ t.is(await res1.text(), 'My path is non-ascii')
+ t.is(await res2.text(), 'My path is non-ascii')
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+
+test('getAssetFromKV custom key modifier', async (t) => {
+ mockRequestScope()
+ const event = getEvent(new Request('https://blah.com/docs/sub/blah.png'))
+
+ const customRequestMapper = (request: Request) => {
+ let defaultModifiedRequest = mapRequestToAsset(request)
+
+ let url = new URL(defaultModifiedRequest.url)
+ url.pathname = url.pathname.replace('/docs', '')
+ return new Request(url.toString(), request)
+ }
+
+ const res = await getAssetFromKV(event, { mapRequestToAsset: customRequestMapper })
+
+ if (res) {
+ t.is(await res.text(), 'picturedis')
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+
+test('getAssetFromKV request override with existing manifest file', async (t) => {
+ // see https://github.com/cloudflare/kv-asset-handler/pull/159 for more info
+ mockRequestScope()
+ const event = getEvent(new Request('https://blah.com/image.png')) // real file in manifest
+
+ const customRequestMapper = (request: Request) => {
+ let defaultModifiedRequest = mapRequestToAsset(request)
+
+ let url = new URL(defaultModifiedRequest.url)
+ url.pathname = '/image.webp' // other different file in manifest
+ return new Request(url.toString(), request)
+ }
+
+ const res = await getAssetFromKV(event, { mapRequestToAsset: customRequestMapper })
+
+ if (res) {
+ t.is(await res.text(), 'imagewebp')
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+
+test('getAssetFromKV when setting browser caching', async (t) => {
+ mockRequestScope()
+ const event = getEvent(new Request('https://blah.com/'))
+
+ const res = await getAssetFromKV(event, { cacheControl: { browserTTL: 22 } })
+
+ if (res) {
+ t.is(res.headers.get('cache-control'), 'max-age=22')
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+
+test('getAssetFromKV when setting custom cache setting', async (t) => {
+ mockRequestScope()
+ const event1 = getEvent(new Request('https://blah.com/'))
+ const event2 = getEvent(new Request('https://blah.com/key1.png?blah=34'))
+ const cacheOnlyPngs = (req: Request) => {
+ if (new URL(req.url).pathname.endsWith('.png'))
+ return {
+ browserTTL: 720,
+ edgeTTL: 720,
+ }
+ else
+ return {
+ bypassCache: true,
+ }
+ }
+
+ const res1 = await getAssetFromKV(event1, { cacheControl: cacheOnlyPngs })
+ const res2 = await getAssetFromKV(event2, { cacheControl: cacheOnlyPngs })
+
+ if (res1 && res2) {
+ 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')
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+test('getAssetFromKV caches on two sequential requests', async (t) => {
+ mockRequestScope()
+ const resourceKey = 'cache.html'
+ const resourceVersion = JSON.parse(mockManifest())[resourceKey]
+ const event1 = getEvent(new Request(`https://blah.com/${resourceKey}`))
+ const event2 = getEvent(
+ new Request(`https://blah.com/${resourceKey}`, {
+ headers: {
+ 'if-none-match': `"${resourceVersion}"`,
+ },
+ }),
+ )
+
+ const res1 = await getAssetFromKV(event1, { cacheControl: { edgeTTL: 720, browserTTL: 720 } })
+ await sleep(1)
+ const res2 = await getAssetFromKV(event2)
+
+ 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'), 'REVALIDATED')
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+test('getAssetFromKV does not store max-age on two sequential requests', async (t) => {
+ mockRequestScope()
+ const resourceKey = 'cache.html'
+ const resourceVersion = JSON.parse(mockManifest())[resourceKey]
+ const event1 = getEvent(new Request(`https://blah.com/${resourceKey}`))
+ const event2 = getEvent(
+ new Request(`https://blah.com/${resourceKey}`, {
+ headers: {
+ 'if-none-match': `"${resourceVersion}"`,
+ },
+ }),
+ )
+
+ const res1 = await getAssetFromKV(event1, { cacheControl: { edgeTTL: 720 } })
+ await sleep(100)
+ const res2 = await getAssetFromKV(event2)
+
+ if (res1 && res2) {
+ t.is(res1.headers.get('cf-cache-status'), 'MISS')
+ t.is(res1.headers.get('cache-control'), null)
+ t.is(res2.headers.get('cf-cache-status'), 'REVALIDATED')
+ t.is(res2.headers.get('cache-control'), null)
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+
+test('getAssetFromKV does not cache on Cloudflare when bypass cache set', async (t) => {
+ mockRequestScope()
+ const event = getEvent(new Request('https://blah.com/'))
+
+ const res = await getAssetFromKV(event, { cacheControl: { bypassCache: true } })
+
+ if (res) {
+ t.is(res.headers.get('cache-control'), null)
+ t.is(res.headers.get('cf-cache-status'), null)
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+
+test('getAssetFromKV with no trailing slash on root', async (t) => {
+ mockRequestScope()
+ const event = getEvent(new Request('https://blah.com'))
+ const res = await getAssetFromKV(event)
+ if (res) {
+ t.is(await res.text(), 'index.html')
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+
+test('getAssetFromKV with no trailing slash on a subdirectory', async (t) => {
+ mockRequestScope()
+ 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) => {
+ mockRequestScope()
+ const event = getEvent(new Request('https://blah.com/random'))
+ const error: KVError = await t.throwsAsync(getAssetFromKV(event))
+ t.is(error.status, 404)
+})
+test('getAssetFromKV TTls set to null should not cache on browser or edge', async (t) => {
+ mockRequestScope()
+ const event = getEvent(new Request('https://blah.com/'))
+
+ const res1 = await getAssetFromKV(event, { cacheControl: { browserTTL: null, edgeTTL: null } })
+ await sleep(100)
+ const res2 = await getAssetFromKV(event, { cacheControl: { browserTTL: null, edgeTTL: null } })
+
+ if (res1 && res2) {
+ t.is(res1.headers.get('cf-cache-status'), null)
+ t.is(res1.headers.get('cache-control'), null)
+ t.is(res2.headers.get('cf-cache-status'), null)
+ t.is(res2.headers.get('cache-control'), null)
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+test('getAssetFromKV passing in a custom NAMESPACE serves correct asset', async (t) => {
+ mockRequestScope()
+ let CUSTOM_NAMESPACE = mockKV({
+ 'key1.123HASHBROWN.txt': 'val1',
+ })
+ Object.assign(global, { CUSTOM_NAMESPACE })
+ const event = getEvent(new Request('https://blah.com/'))
+ const res = await getAssetFromKV(event)
+ if (res) {
+ t.is(await res.text(), 'index.html')
+ t.true(res.headers.get('content-type').includes('html'))
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+test('getAssetFromKV when custom namespace without the asset should fail', async (t) => {
+ mockRequestScope()
+ let CUSTOM_NAMESPACE = mockKV({
+ 'key5.123HASHBROWN.txt': 'customvalu',
+ })
+
+ const event = getEvent(new Request('https://blah.com'))
+ const error: KVError = await t.throwsAsync(
+ getAssetFromKV(event, { ASSET_NAMESPACE: CUSTOM_NAMESPACE }),
+ )
+ t.is(error.status, 404)
+})
+test('getAssetFromKV when namespace not bound fails', async (t) => {
+ mockRequestScope()
+ var MY_CUSTOM_NAMESPACE = undefined
+ Object.assign(global, { MY_CUSTOM_NAMESPACE })
+
+ const event = getEvent(new Request('https://blah.com/'))
+ const error: KVError = await t.throwsAsync(
+ getAssetFromKV(event, { ASSET_NAMESPACE: MY_CUSTOM_NAMESPACE }),
+ )
+ t.is(error.status, 500)
+})
+
+test('getAssetFromKV when if-none-match === active resource version, should revalidate', async (t) => {
+ mockRequestScope()
+ const resourceKey = 'key1.png'
+ const resourceVersion = JSON.parse(mockManifest())[resourceKey]
+ const event1 = getEvent(new Request(`https://blah.com/${resourceKey}`))
+ const event2 = getEvent(
+ new Request(`https://blah.com/${resourceKey}`, {
+ headers: {
+ 'if-none-match': `W/"${resourceVersion}"`,
+ },
+ }),
+ )
+
+ const res1 = await getAssetFromKV(event1, { cacheControl: { edgeTTL: 720 } })
+ await sleep(100)
+ const res2 = await getAssetFromKV(event2)
+
+ if (res1 && res2) {
+ t.is(res1.headers.get('cf-cache-status'), 'MISS')
+ t.is(res2.headers.get('cf-cache-status'), 'REVALIDATED')
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+
+test('getAssetFromKV when if-none-match equals etag of stale resource then should bypass cache', async (t) => {
+ mockRequestScope()
+ const resourceKey = 'key1.png'
+ const resourceVersion = JSON.parse(mockManifest())[resourceKey]
+ const req1 = new Request(`https://blah.com/${resourceKey}`, {
+ headers: {
+ 'if-none-match': `"${resourceVersion}"`,
+ },
+ })
+ const req2 = new Request(`https://blah.com/${resourceKey}`, {
+ headers: {
+ 'if-none-match': `"${resourceVersion}-another-version"`,
+ },
+ })
+ const event = getEvent(req1)
+ const event2 = getEvent(req2)
+ const res1 = await getAssetFromKV(event, { cacheControl: { edgeTTL: 720 } })
+ const res2 = await getAssetFromKV(event)
+ const res3 = await getAssetFromKV(event2)
+ if (res1 && res2 && res3) {
+ t.is(res1.headers.get('cf-cache-status'), 'MISS')
+ t.is(res2.headers.get('etag'), `W/${req1.headers.get('if-none-match')}`)
+ t.is(res2.headers.get('cf-cache-status'), 'REVALIDATED')
+ t.not(res3.headers.get('etag'), req2.headers.get('if-none-match'))
+ t.is(res3.headers.get('cf-cache-status'), 'MISS')
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+test('getAssetFromKV when resource in cache, etag should be weakened before returned to eyeball', async (t) => {
+ mockRequestScope()
+ const resourceKey = 'key1.png'
+ const resourceVersion = JSON.parse(mockManifest())[resourceKey]
+ const req1 = new Request(`https://blah.com/${resourceKey}`, {
+ headers: {
+ 'if-none-match': `"${resourceVersion}"`,
+ },
+ })
+ const event = getEvent(req1)
+ const res1 = await getAssetFromKV(event, { cacheControl: { edgeTTL: 720 } })
+ const res2 = await getAssetFromKV(event)
+ if (res1 && res2) {
+ t.is(res1.headers.get('cf-cache-status'), 'MISS')
+ t.is(res2.headers.get('etag'), `W/${req1.headers.get('if-none-match')}`)
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+
+test('getAssetFromKV if-none-match not sent but resource in cache, should return cache hit 200 OK', async (t) => {
+ const resourceKey = 'cache.html'
+ const event = getEvent(new Request(`https://blah.com/${resourceKey}`))
+ const res1 = await getAssetFromKV(event, { cacheControl: { edgeTTL: 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'), null)
+ t.is(res2.status, 200)
+ t.is(res2.headers.get('cf-cache-status'), 'HIT')
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+
+test('getAssetFromKV if range request submitted and resource in cache, request fulfilled', async (t) => {
+ const resourceKey = 'cache.html'
+ const event1 = getEvent(new Request(`https://blah.com/${resourceKey}`))
+ const event2 = getEvent(
+ new Request(`https://blah.com/${resourceKey}`, { headers: { range: 'bytes=0-10' } }),
+ )
+ const res1 = getAssetFromKV(event1, { cacheControl: { edgeTTL: 720 } })
+ await res1
+ await sleep(2)
+ const res2 = await getAssetFromKV(event2)
+ if (res2.headers.has('content-range')) {
+ t.is(res2.status, 206)
+ } else {
+ t.fail('Response was undefined')
+ }
+})
+
+test.todo('getAssetFromKV when body not empty, should invoke .cancel()')
diff --git a/node_modules/@cloudflare/kv-asset-handler/src/test/mapRequestToAsset.ts b/node_modules/@cloudflare/kv-asset-handler/src/test/mapRequestToAsset.ts
new file mode 100644
index 0000000..422165e
--- /dev/null
+++ b/node_modules/@cloudflare/kv-asset-handler/src/test/mapRequestToAsset.ts
@@ -0,0 +1,37 @@
+import test from 'ava'
+import { mockRequestScope, mockGlobalScope } from '../mocks'
+mockGlobalScope()
+
+import { mapRequestToAsset } from '../index'
+
+test('mapRequestToAsset() correctly changes /about -> /about/index.html', async (t) => {
+ mockRequestScope()
+ 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) => {
+ mockRequestScope()
+ 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) => {
+ mockRequestScope()
+ 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('mapRequestToAsset() correctly changes /about -> /about/default.html', async (t) => {
+ mockRequestScope()
+ let path = '/about'
+ let request = new Request(`https://foo.com${path}`)
+ let newRequest = mapRequestToAsset(request, { defaultDocument: 'default.html' })
+ t.is(newRequest.url, request.url + '/default.html')
+})
diff --git a/node_modules/@cloudflare/kv-asset-handler/src/test/serveSinglePageApp.ts b/node_modules/@cloudflare/kv-asset-handler/src/test/serveSinglePageApp.ts
new file mode 100644
index 0000000..6c634c8
--- /dev/null
+++ b/node_modules/@cloudflare/kv-asset-handler/src/test/serveSinglePageApp.ts
@@ -0,0 +1,44 @@
+import test from 'ava'
+import { mockRequestScope, mockGlobalScope } from '../mocks'
+mockGlobalScope()
+
+import { serveSinglePageApp } from '../index'
+
+function testRequest(path: string) {
+ mockRequestScope()
+ let url = new URL('https://example.com')
+ url.pathname = path
+ let request = new Request(url.toString())
+
+ return request
+}
+
+test('serveSinglePageApp returns root asset path when request path ends in .html', async (t) => {
+ let path = '/foo/thing.html'
+ let request = testRequest(path)
+
+ let expected_request = testRequest('/index.html')
+ let actual_request = serveSinglePageApp(request)
+
+ t.deepEqual(expected_request, actual_request)
+})
+
+test('serveSinglePageApp returns root asset path when request path does not have extension', async (t) => {
+ let path = '/foo/thing'
+ let request = testRequest(path)
+
+ let expected_request = testRequest('/index.html')
+ let actual_request = serveSinglePageApp(request)
+
+ t.deepEqual(expected_request, actual_request)
+})
+
+test('serveSinglePageApp returns requested asset when request path has non-html extension', async (t) => {
+ let path = '/foo/thing.js'
+ let request = testRequest(path)
+
+ let expected_request = request
+ let actual_request = serveSinglePageApp(request)
+
+ t.deepEqual(expected_request, actual_request)
+})
diff --git a/node_modules/@cloudflare/kv-asset-handler/src/types.ts b/node_modules/@cloudflare/kv-asset-handler/src/types.ts
new file mode 100644
index 0000000..c806076
--- /dev/null
+++ b/node_modules/@cloudflare/kv-asset-handler/src/types.ts
@@ -0,0 +1,43 @@
+export type CacheControl = {
+ browserTTL: number
+ edgeTTL: number
+ bypassCache: boolean
+}
+
+export type AssetManifestType = Record<string, string>
+
+export type Options = {
+ cacheControl: ((req: Request) => Partial<CacheControl>) | Partial<CacheControl>
+ ASSET_NAMESPACE: any
+ ASSET_MANIFEST: AssetManifestType | string
+ mapRequestToAsset?: (req: Request, options?: Partial<Options>) => Request
+ defaultMimeType: string
+ defaultDocument: string
+ pathIsEncoded: boolean
+}
+
+export class KVError extends Error {
+ constructor(message?: string, status: number = 500) {
+ super(message)
+ // see: typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html
+ Object.setPrototypeOf(this, new.target.prototype) // restore prototype chain
+ this.name = KVError.name // stack traces display correctly now
+ this.status = status
+ }
+ status: number
+}
+export class MethodNotAllowedError extends KVError {
+ constructor(message: string = `Not a valid request method`, status: number = 405) {
+ super(message, status)
+ }
+}
+export class NotFoundError extends KVError {
+ constructor(message: string = `Not Found`, status: number = 404) {
+ super(message, status)
+ }
+}
+export class InternalError extends KVError {
+ constructor(message: string = `Internal Error in KV Asset Handler`, status: number = 500) {
+ super(message, status)
+ }
+}