@cloudflare/kv-asset-handler
Advanced tools
Comparing version 0.2.0 to 0.3.0
@@ -30,3 +30,3 @@ import { Options, CacheControl, MethodNotAllowedError, NotFoundError, InternalError } from './types'; | ||
* */ | ||
declare type Evt = { | ||
type Evt = { | ||
request: Request; | ||
@@ -33,0 +33,0 @@ waitUntil: (promise: Promise<any>) => void; |
@@ -24,2 +24,3 @@ "use strict"; | ||
pathIsEncoded: false, | ||
defaultETag: 'strong', | ||
}; | ||
@@ -157,3 +158,3 @@ function assignOptions(options) { | ||
// header is "null". Could be modified in future to base64 encode etc | ||
const formatETag = (entityId = pathKey, validatorType = 'strong') => { | ||
const formatETag = (entityId = pathKey, validatorType = options.defaultETag) => { | ||
if (!entityId) { | ||
@@ -165,3 +166,6 @@ return ''; | ||
if (!entityId.startsWith('W/')) { | ||
return `W/${entityId}`; | ||
if (entityId.startsWith(`"`) && entityId.endsWith(`"`)) { | ||
return `W/${entityId}`; | ||
} | ||
return `W/"${entityId}"`; | ||
} | ||
@@ -236,6 +240,6 @@ return entityId; | ||
response.headers.set('Accept-Ranges', 'bytes'); | ||
response.headers.set('Content-Length', body.length); | ||
response.headers.set('Content-Length', String(body.byteLength)); | ||
// set etag before cache insertion | ||
if (!response.headers.has('etag')) { | ||
response.headers.set('etag', formatETag(pathKey, 'strong')); | ||
response.headers.set('etag', formatETag(pathKey)); | ||
} | ||
@@ -250,3 +254,3 @@ // determine Cloudflare cache behavior | ||
if (response.status === 304) { | ||
let etag = formatETag(response.headers.get('etag'), 'strong'); | ||
let etag = formatETag(response.headers.get('etag')); | ||
let ifNoneMatch = cacheKey.headers.get('if-none-match'); | ||
@@ -253,0 +257,0 @@ let proxyCacheStatus = response.headers.get('CF-Cache-Status'); |
@@ -415,2 +415,39 @@ "use strict"; | ||
}); | ||
(0, ava_1.default)('getAssetFromKV should support weak etag override of resource', async (t) => { | ||
(0, mocks_1.mockRequestScope)(); | ||
const resourceKey = 'key1.png'; | ||
const resourceVersion = JSON.parse((0, mocks_1.mockManifest)())[resourceKey]; | ||
const req1 = new Request(`https://blah-weak.com/${resourceKey}`, { | ||
headers: { | ||
'if-none-match': `W/"${resourceVersion}"`, | ||
}, | ||
}); | ||
const req2 = new Request(`https://blah-weak.com/${resourceKey}`, { | ||
headers: { | ||
'if-none-match': `"${resourceVersion}"`, | ||
}, | ||
}); | ||
const req3 = new Request(`https://blah-weak.com/${resourceKey}`, { | ||
headers: { | ||
'if-none-match': `"${resourceVersion}-another-version"`, | ||
}, | ||
}); | ||
const event1 = (0, mocks_1.getEvent)(req1); | ||
const event2 = (0, mocks_1.getEvent)(req2); | ||
const event3 = (0, mocks_1.getEvent)(req3); | ||
const res1 = await (0, index_1.getAssetFromKV)(event1, { defaultETag: 'weak' }); | ||
const res2 = await (0, index_1.getAssetFromKV)(event2, { defaultETag: 'weak' }); | ||
const res3 = await (0, index_1.getAssetFromKV)(event3, { defaultETag: 'weak' }); | ||
if (res1 && res2 && res3) { | ||
t.is(res1.headers.get('cf-cache-status'), 'MISS'); | ||
t.is(res1.headers.get('etag'), req1.headers.get('if-none-match')); | ||
t.is(res2.headers.get('cf-cache-status'), 'REVALIDATED'); | ||
t.is(res2.headers.get('etag'), `W/${req2.headers.get('if-none-match')}`); | ||
t.is(res3.headers.get('cf-cache-status'), 'MISS'); | ||
t.not(res3.headers.get('etag'), req2.headers.get('if-none-match')); | ||
} | ||
else { | ||
t.fail('Response was undefined'); | ||
} | ||
}); | ||
(0, ava_1.default)('getAssetFromKV if-none-match not sent but resource in cache, should return cache hit 200 OK', async (t) => { | ||
@@ -417,0 +454,0 @@ const resourceKey = 'cache.html'; |
@@ -1,2 +0,2 @@ | ||
export declare type CacheControl = { | ||
export type CacheControl = { | ||
browserTTL: number; | ||
@@ -6,4 +6,4 @@ edgeTTL: number; | ||
}; | ||
export declare type AssetManifestType = Record<string, string>; | ||
export declare type Options = { | ||
export type AssetManifestType = Record<string, string>; | ||
export type Options = { | ||
cacheControl: ((req: Request) => Partial<CacheControl>) | Partial<CacheControl>; | ||
@@ -16,2 +16,3 @@ ASSET_NAMESPACE: any; | ||
pathIsEncoded: boolean; | ||
defaultETag: 'strong' | 'weak'; | ||
}; | ||
@@ -18,0 +19,0 @@ export declare class KVError extends Error { |
102
package.json
{ | ||
"name": "@cloudflare/kv-asset-handler", | ||
"version": "0.2.0", | ||
"description": "Routes requests to KV assets", | ||
"main": "dist/index.js", | ||
"types": "dist/index.d.ts", | ||
"scripts": { | ||
"prepack": "npm run build", | ||
"build": "tsc -d", | ||
"format": "prettier --write \"**/*.{js,ts,json,md}\"", | ||
"pretest": "npm run build", | ||
"lint:code": "prettier --check \"**/*.{js,ts,json,md}\"", | ||
"lint:markdown": "markdownlint \"**/*.md\" --ignore node_modules", | ||
"test": "ava dist/test/*.js --verbose" | ||
}, | ||
"repository": { | ||
"type": "git", | ||
"url": "git+https://github.com/cloudflare/kv-asset-handler.git" | ||
}, | ||
"keywords": [ | ||
"kv", | ||
"cloudflare", | ||
"workers", | ||
"wrangler", | ||
"assets" | ||
], | ||
"files": [ | ||
"src", | ||
"dist", | ||
"LICENSE_APACHE", | ||
"LICENSE_MIT" | ||
], | ||
"author": "wrangler@cloudflare.com", | ||
"license": "MIT OR Apache-2.0", | ||
"bugs": { | ||
"url": "https://github.com/cloudflare/kv-asset-handler/issues" | ||
}, | ||
"homepage": "https://github.com/cloudflare/kv-asset-handler#readme", | ||
"dependencies": { | ||
"mime": "^3.0.0" | ||
}, | ||
"devDependencies": { | ||
"@ava/typescript": "^3.0.0", | ||
"@cloudflare/workers-types": "^3.0.0", | ||
"@types/mime": "^2.0.3", | ||
"@types/node": "^15.14.9", | ||
"ava": "^3.15.0", | ||
"prettier": "^2.4.1", | ||
"service-worker-mock": "^2.0.5", | ||
"typescript": "^4.4.4" | ||
} | ||
} | ||
"name": "@cloudflare/kv-asset-handler", | ||
"version": "0.3.0", | ||
"description": "Routes requests to KV assets", | ||
"main": "dist/index.js", | ||
"types": "dist/index.d.ts", | ||
"scripts": { | ||
"prepack": "npm run build", | ||
"build": "tsc -d", | ||
"format": "prettier --write \"**/*.{js,ts,json,md}\"", | ||
"pretest": "npm run build", | ||
"lint:code": "prettier --check \"**/*.{js,ts,json,md}\"", | ||
"lint:markdown": "markdownlint \"**/*.md\" --ignore node_modules", | ||
"test": "ava dist/test/*.js --verbose" | ||
}, | ||
"repository": { | ||
"type": "git", | ||
"url": "git+https://github.com/cloudflare/kv-asset-handler.git" | ||
}, | ||
"keywords": [ | ||
"kv", | ||
"cloudflare", | ||
"workers", | ||
"wrangler", | ||
"assets" | ||
], | ||
"files": [ | ||
"src", | ||
"dist", | ||
"LICENSE_APACHE", | ||
"LICENSE_MIT" | ||
], | ||
"author": "wrangler@cloudflare.com", | ||
"license": "MIT OR Apache-2.0", | ||
"bugs": { | ||
"url": "https://github.com/cloudflare/kv-asset-handler/issues" | ||
}, | ||
"homepage": "https://github.com/cloudflare/kv-asset-handler#readme", | ||
"dependencies": { | ||
"mime": "^3.0.0" | ||
}, | ||
"devDependencies": { | ||
"@ava/typescript": "^3.0.1", | ||
"@cloudflare/workers-types": "^4.20221111.1", | ||
"@types/mime": "^3.0.1", | ||
"@types/node": "^18.11.12", | ||
"ava": "^5.1.0", | ||
"prettier": "^2.8.1", | ||
"service-worker-mock": "^2.0.5", | ||
"typescript": "^4.9.4" | ||
} | ||
} |
136
README.md
@@ -15,3 +15,3 @@ # @cloudflare/kv-asset-handler | ||
The Cloudflare Workers Discord server is an active place where Workers users get help, share feedback, and collaborate on making our platform better. The `#workers-sites` channel in particular is a great place to chat about `kv-asset-handler`, and building cool experiences for your users using these tools! If you have questions, want to share what you're working on, or give feedback, [join us in Discord and say hello](https://discord.gg/cloudflaredev)! | ||
The Cloudflare Workers Discord server is an active place where Workers users get help, share feedback, and collaborate on making our platform better. The `#workers` channel in particular is a great place to chat about `kv-asset-handler`, and building cool experiences for your users using these tools! If you have questions, want to share what you're working on, or give feedback, [join us in Discord and say hello](https://discord.gg/cloudflaredev)! | ||
@@ -32,2 +32,3 @@ - [Installation](#installation) | ||
- [`ASSET_MANIFEST` (required for ES Modules)](#asset_manifest-required-for-es-modules) | ||
- [`defaultETag`](#defaultetag-optional) | ||
@@ -80,28 +81,28 @@ * [Helper functions](#helper-functions) | ||
export default { | ||
async fetch(request, env, ctx) { | ||
if (request.url.includes('/docs')) { | ||
try { | ||
return await getAssetFromKV( | ||
{ | ||
request, | ||
waitUntil(promise) { | ||
return ctx.waitUntil(promise) | ||
}, | ||
}, | ||
{ | ||
ASSET_NAMESPACE: env.__STATIC_CONTENT, | ||
ASSET_MANIFEST: assetManifest, | ||
}, | ||
) | ||
} catch (e) { | ||
if (e instanceof NotFoundError) { | ||
// ... | ||
} else if (e instanceof MethodNotAllowedError) { | ||
// ... | ||
} else { | ||
return new Response('An unexpected error occurred', { status: 500 }) | ||
} | ||
} | ||
} else return fetch(request) | ||
}, | ||
async fetch(request, env, ctx) { | ||
if (request.url.includes('/docs')) { | ||
try { | ||
return await getAssetFromKV( | ||
{ | ||
request, | ||
waitUntil(promise) { | ||
return ctx.waitUntil(promise) | ||
}, | ||
}, | ||
{ | ||
ASSET_NAMESPACE: env.__STATIC_CONTENT, | ||
ASSET_MANIFEST: assetManifest, | ||
}, | ||
) | ||
} catch (e) { | ||
if (e instanceof NotFoundError) { | ||
// ... | ||
} else if (e instanceof MethodNotAllowedError) { | ||
// ... | ||
} else { | ||
return new Response('An unexpected error occurred', { status: 500 }) | ||
} | ||
} | ||
} else return fetch(request) | ||
}, | ||
} | ||
@@ -116,19 +117,19 @@ ``` | ||
addEventListener('fetch', (event) => { | ||
event.respondWith(handleEvent(event)) | ||
event.respondWith(handleEvent(event)) | ||
}) | ||
async function handleEvent(event) { | ||
if (event.request.url.includes('/docs')) { | ||
try { | ||
return await getAssetFromKV(event) | ||
} catch (e) { | ||
if (e instanceof NotFoundError) { | ||
// ... | ||
} else if (e instanceof MethodNotAllowedError) { | ||
// ... | ||
} else { | ||
return new Response('An unexpected error occurred', { status: 500 }) | ||
} | ||
} | ||
} else return fetch(event.request) | ||
if (event.request.url.includes('/docs')) { | ||
try { | ||
return await getAssetFromKV(event) | ||
} catch (e) { | ||
if (e instanceof NotFoundError) { | ||
// ... | ||
} else if (e instanceof MethodNotAllowedError) { | ||
// ... | ||
} else { | ||
return new Response('An unexpected error occurred', { status: 500 }) | ||
} | ||
} | ||
} else return fetch(event.request) | ||
} | ||
@@ -179,5 +180,5 @@ ``` | ||
let cacheControl = { | ||
browserTTL: null, // do not set cache control ttl on responses | ||
edgeTTL: 2 * 60 * 60 * 24, // 2 days | ||
bypassCache: false, // do not bypass Cloudflare's cache | ||
browserTTL: null, // do not set cache control ttl on responses | ||
edgeTTL: 2 * 60 * 60 * 24, // 2 days | ||
bypassCache: false, // do not bypass Cloudflare's cache | ||
} | ||
@@ -220,11 +221,11 @@ ``` | ||
return getAssetFromKV( | ||
{ | ||
request, | ||
waitUntil(promise) { | ||
return ctx.waitUntil(promise) | ||
}, | ||
}, | ||
{ | ||
ASSET_NAMESPACE: env.__STATIC_CONTENT, | ||
}, | ||
{ | ||
request, | ||
waitUntil(promise) { | ||
return ctx.waitUntil(promise) | ||
}, | ||
}, | ||
{ | ||
ASSET_NAMESPACE: env.__STATIC_CONTENT, | ||
}, | ||
) | ||
@@ -257,12 +258,12 @@ ``` | ||
return getAssetFromKV( | ||
{ | ||
request, | ||
waitUntil(promise) { | ||
return ctx.waitUntil(promise) | ||
}, | ||
}, | ||
{ | ||
ASSET_MANIFEST: manifest, | ||
// ... | ||
}, | ||
{ | ||
request, | ||
waitUntil(promise) { | ||
return ctx.waitUntil(promise) | ||
}, | ||
}, | ||
{ | ||
ASSET_MANIFEST: manifest, | ||
// ... | ||
}, | ||
) | ||
@@ -292,2 +293,9 @@ ``` | ||
#### `defaultETag` (optional) | ||
type: `'strong' | 'weak'` | ||
This determines the format of the response [ETag header](https://developer.mozilla.org/docs/Web/HTTP/Headers/ETag). If the resource is in the cache, the ETag will always be weakened before being returned. | ||
The default value is `'strong'`. | ||
# Helper functions | ||
@@ -331,3 +339,3 @@ | ||
let cacheControl = { | ||
bypassCache: true, | ||
bypassCache: true, | ||
} | ||
@@ -334,0 +342,0 @@ ``` |
466
src/index.ts
import * as mime from 'mime' | ||
import { | ||
Options, | ||
CacheControl, | ||
MethodNotAllowedError, | ||
NotFoundError, | ||
InternalError, | ||
AssetManifestType, | ||
Options, | ||
CacheControl, | ||
MethodNotAllowedError, | ||
NotFoundError, | ||
InternalError, | ||
AssetManifestType, | ||
} from './types' | ||
declare global { | ||
var __STATIC_CONTENT: any, __STATIC_CONTENT_MANIFEST: string | ||
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 | ||
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 | ||
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, | ||
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, | ||
defaultETag: 'strong', | ||
} | ||
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) | ||
// Assign any missing options passed in to the default | ||
// options.mapRequestToAsset is handled manually later | ||
return <Options>Object.assign({}, getAssetFromKVDefaultOptions, options) | ||
} | ||
@@ -50,19 +51,19 @@ | ||
const mapRequestToAsset = (request: Request, options?: Partial<Options>) => { | ||
options = assignOptions(options) | ||
options = assignOptions(options) | ||
const parsedUrl = new URL(request.url) | ||
let pathname = parsedUrl.pathname | ||
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) | ||
} | ||
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) | ||
parsedUrl.pathname = pathname | ||
return new Request(parsedUrl.toString(), request) | ||
} | ||
@@ -76,20 +77,20 @@ | ||
function serveSinglePageApp(request: Request, options?: Partial<Options>): Request { | ||
options = assignOptions(options) | ||
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) | ||
// 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) | ||
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 | ||
} | ||
// 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 | ||
} | ||
} | ||
@@ -110,203 +111,206 @@ | ||
type Evt = { | ||
request: Request | ||
waitUntil: (promise: Promise<any>) => void | ||
request: Request | ||
waitUntil: (promise: Promise<any>) => void | ||
} | ||
const getAssetFromKV = async (event: Evt, options?: Partial<Options>): Promise<Response> => { | ||
options = assignOptions(options) | ||
options = assignOptions(options) | ||
const request = event.request | ||
const ASSET_NAMESPACE = options.ASSET_NAMESPACE | ||
const ASSET_MANIFEST = parseStringAsObject<AssetManifestType>(options.ASSET_MANIFEST) | ||
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`) | ||
} | ||
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 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 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 | ||
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 / | ||
// 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' | ||
} | ||
// @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 | ||
} | ||
} | ||
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) | ||
// 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 | ||
} | ||
})() | ||
// 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 '' | ||
} | ||
} | ||
// 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 = options.defaultETag) => { | ||
if (!entityId) { | ||
return '' | ||
} | ||
switch (validatorType) { | ||
case 'weak': | ||
if (!entityId.startsWith('W/')) { | ||
if (entityId.startsWith(`"`) && entityId.endsWith(`"`)) { | ||
return `W/${entityId}` | ||
} | ||
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) | ||
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' | ||
// 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) | ||
} | ||
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: '', | ||
} | ||
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') | ||
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 (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 (shouldEdgeCache) { | ||
response.headers.set('Accept-Ranges', 'bytes') | ||
response.headers.set('Content-Length', String(body.byteLength)) | ||
// set etag before cache insertion | ||
if (!response.headers.has('etag')) { | ||
response.headers.set('etag', formatETag(pathKey)) | ||
} | ||
// 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 | ||
if (response.status === 304) { | ||
let etag = formatETag(response.headers.get('etag')) | ||
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 | ||
} | ||
@@ -313,0 +317,0 @@ |
236
src/mocks.ts
@@ -6,54 +6,54 @@ const makeServiceWorkerEnv = require('service-worker-mock') | ||
export const getEvent = (request: Request): any => { | ||
const waitUntil = async (callback: any) => { | ||
await callback | ||
} | ||
return { | ||
request, | ||
waitUntil, | ||
} | ||
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', | ||
'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, | ||
} | ||
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`, | ||
}) | ||
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`, | ||
}) | ||
} | ||
@@ -63,70 +63,70 @@ | ||
interface CacheKey { | ||
url: object | ||
headers: object | ||
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 | ||
}, | ||
}, | ||
} | ||
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 | ||
}, | ||
}, | ||
} | ||
} | ||
@@ -136,6 +136,6 @@ | ||
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() }) | ||
Object.assign(global, makeServiceWorkerEnv()) | ||
Object.assign(global, { __STATIC_CONTENT_MANIFEST: mockManifest() }) | ||
Object.assign(global, { __STATIC_CONTENT: mockKV(store) }) | ||
Object.assign(global, { caches: mockCaches() }) | ||
} | ||
@@ -145,8 +145,8 @@ | ||
export function mockGlobalScope() { | ||
Object.assign(global, { __STATIC_CONTENT_MANIFEST: mockManifest() }) | ||
Object.assign(global, { __STATIC_CONTENT: mockKV(store) }) | ||
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)) | ||
return new Promise((resolve) => setTimeout(resolve, milliseconds)) | ||
} |
@@ -11,15 +11,15 @@ import test from 'ava' | ||
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 }) | ||
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) | ||
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') | ||
} | ||
if (res) { | ||
t.is(await res.text(), 'val1') | ||
t.true(res.headers.get('content-type').includes('text')) | ||
} else { | ||
t.fail('Response was undefined') | ||
} | ||
}) |
@@ -9,481 +9,517 @@ import test from 'ava' | ||
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) | ||
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') | ||
} | ||
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')) | ||
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')) | ||
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) | ||
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') | ||
} | ||
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) | ||
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') | ||
} | ||
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) | ||
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') | ||
} | ||
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) | ||
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') | ||
} | ||
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) | ||
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') | ||
} | ||
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) | ||
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') | ||
} | ||
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) | ||
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') | ||
} | ||
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) | ||
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') | ||
} | ||
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')) | ||
mockRequestScope() | ||
const event = getEvent(new Request('https://blah.com/docs/sub/blah.png')) | ||
const customRequestMapper = (request: Request) => { | ||
let defaultModifiedRequest = mapRequestToAsset(request) | ||
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) | ||
} | ||
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 }) | ||
const res = await getAssetFromKV(event, { mapRequestToAsset: customRequestMapper }) | ||
if (res) { | ||
t.is(await res.text(), 'picturedis') | ||
} else { | ||
t.fail('Response was undefined') | ||
} | ||
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 | ||
// 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) | ||
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) | ||
} | ||
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 }) | ||
const res = await getAssetFromKV(event, { mapRequestToAsset: customRequestMapper }) | ||
if (res) { | ||
t.is(await res.text(), 'imagewebp') | ||
} else { | ||
t.fail('Response was undefined') | ||
} | ||
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/')) | ||
mockRequestScope() | ||
const event = getEvent(new Request('https://blah.com/')) | ||
const res = await getAssetFromKV(event, { cacheControl: { browserTTL: 22 } }) | ||
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') | ||
} | ||
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, | ||
} | ||
} | ||
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 }) | ||
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') | ||
} | ||
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}"`, | ||
}, | ||
}), | ||
) | ||
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) | ||
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') | ||
} | ||
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}"`, | ||
}, | ||
}), | ||
) | ||
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) | ||
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') | ||
} | ||
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/')) | ||
mockRequestScope() | ||
const event = getEvent(new Request('https://blah.com/')) | ||
const res = await getAssetFromKV(event, { cacheControl: { bypassCache: true } }) | ||
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') | ||
} | ||
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') | ||
} | ||
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') | ||
} | ||
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) | ||
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/')) | ||
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 } }) | ||
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') | ||
} | ||
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') | ||
} | ||
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', | ||
}) | ||
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) | ||
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 }) | ||
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) | ||
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}"`, | ||
}, | ||
}), | ||
) | ||
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) | ||
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') | ||
} | ||
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') | ||
} | ||
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') | ||
} | ||
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 should support weak etag override of resource', async (t) => { | ||
mockRequestScope() | ||
const resourceKey = 'key1.png' | ||
const resourceVersion = JSON.parse(mockManifest())[resourceKey] | ||
const req1 = new Request(`https://blah-weak.com/${resourceKey}`, { | ||
headers: { | ||
'if-none-match': `W/"${resourceVersion}"`, | ||
}, | ||
}) | ||
const req2 = new Request(`https://blah-weak.com/${resourceKey}`, { | ||
headers: { | ||
'if-none-match': `"${resourceVersion}"`, | ||
}, | ||
}) | ||
const req3 = new Request(`https://blah-weak.com/${resourceKey}`, { | ||
headers: { | ||
'if-none-match': `"${resourceVersion}-another-version"`, | ||
}, | ||
}) | ||
const event1 = getEvent(req1) | ||
const event2 = getEvent(req2) | ||
const event3 = getEvent(req3) | ||
const res1 = await getAssetFromKV(event1, { defaultETag: 'weak' }) | ||
const res2 = await getAssetFromKV(event2, { defaultETag: 'weak' }) | ||
const res3 = await getAssetFromKV(event3, { defaultETag: 'weak' }) | ||
if (res1 && res2 && res3) { | ||
t.is(res1.headers.get('cf-cache-status'), 'MISS') | ||
t.is(res1.headers.get('etag'), req1.headers.get('if-none-match')) | ||
t.is(res2.headers.get('cf-cache-status'), 'REVALIDATED') | ||
t.is(res2.headers.get('etag'), `W/${req2.headers.get('if-none-match')}`) | ||
t.is(res3.headers.get('cf-cache-status'), 'MISS') | ||
t.not(res3.headers.get('etag'), req2.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') | ||
} | ||
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') | ||
} | ||
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()') |
@@ -8,31 +8,31 @@ import test from 'ava' | ||
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') | ||
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') | ||
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') | ||
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') | ||
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') | ||
}) |
@@ -8,38 +8,38 @@ import test from 'ava' | ||
function testRequest(path: string) { | ||
mockRequestScope() | ||
let url = new URL('https://example.com') | ||
url.pathname = path | ||
let request = new Request(url.toString()) | ||
mockRequestScope() | ||
let url = new URL('https://example.com') | ||
url.pathname = path | ||
let request = new Request(url.toString()) | ||
return request | ||
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 path = '/foo/thing.html' | ||
let request = testRequest(path) | ||
let expected_request = testRequest('/index.html') | ||
let actual_request = serveSinglePageApp(request) | ||
let expected_request = testRequest('/index.html') | ||
let actual_request = serveSinglePageApp(request) | ||
t.deepEqual(expected_request, actual_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 path = '/foo/thing' | ||
let request = testRequest(path) | ||
let expected_request = testRequest('/index.html') | ||
let actual_request = serveSinglePageApp(request) | ||
let expected_request = testRequest('/index.html') | ||
let actual_request = serveSinglePageApp(request) | ||
t.deepEqual(expected_request, actual_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 path = '/foo/thing.js' | ||
let request = testRequest(path) | ||
let expected_request = request | ||
let actual_request = serveSinglePageApp(request) | ||
let expected_request = request | ||
let actual_request = serveSinglePageApp(request) | ||
t.deepEqual(expected_request, actual_request) | ||
t.deepEqual(expected_request, actual_request) | ||
}) |
export type CacheControl = { | ||
browserTTL: number | ||
edgeTTL: number | ||
bypassCache: boolean | ||
browserTTL: number | ||
edgeTTL: number | ||
bypassCache: boolean | ||
} | ||
@@ -10,35 +10,36 @@ | ||
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 | ||
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 | ||
defaultETag: 'strong' | 'weak' | ||
} | ||
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 | ||
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) | ||
} | ||
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) | ||
} | ||
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) | ||
} | ||
constructor(message: string = `Internal Error in KV Asset Handler`, status: number = 500) { | ||
super(message, status) | ||
} | ||
} |
New author
Supply chain riskA new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.
Found 1 instance in 1 package
112126
2117
338
0