diff options
Diffstat (limited to 'node_modules/@cloudflare/kv-asset-handler/src')
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) + } +} |
