@cloudflare/kv-asset-handler
Advanced tools
Comparing version 0.3.1 to 0.3.2
@@ -1,2 +0,2 @@ | ||
import { Options, CacheControl, MethodNotAllowedError, NotFoundError, InternalError } from './types'; | ||
import { CacheControl, InternalError, MethodNotAllowedError, NotFoundError, Options } from "./types"; | ||
declare global { | ||
@@ -36,2 +36,2 @@ var __STATIC_CONTENT: any, __STATIC_CONTENT_MANIFEST: string; | ||
export { getAssetFromKV, mapRequestToAsset, serveSinglePageApp }; | ||
export { Options, CacheControl, MethodNotAllowedError, NotFoundError, InternalError }; | ||
export { Options, CacheControl, MethodNotAllowedError, NotFoundError, InternalError, }; |
@@ -6,21 +6,23 @@ "use strict"; | ||
const types_1 = require("./types"); | ||
Object.defineProperty(exports, "InternalError", { enumerable: true, get: function () { return types_1.InternalError; } }); | ||
Object.defineProperty(exports, "MethodNotAllowedError", { enumerable: true, get: function () { return types_1.MethodNotAllowedError; } }); | ||
Object.defineProperty(exports, "NotFoundError", { enumerable: true, get: function () { return types_1.NotFoundError; } }); | ||
Object.defineProperty(exports, "InternalError", { enumerable: true, get: function () { return types_1.InternalError; } }); | ||
const defaultCacheControl = { | ||
browserTTL: null, | ||
edgeTTL: 2 * 60 * 60 * 24, // 2 days | ||
edgeTTL: 2 * 60 * 60 * 24, | ||
bypassCache: false, // do not bypass Cloudflare's cache | ||
}; | ||
const parseStringAsObject = (maybeString) => typeof maybeString === 'string' ? JSON.parse(maybeString) : maybeString; | ||
const parseStringAsObject = (maybeString) => typeof maybeString === "string" | ||
? JSON.parse(maybeString) | ||
: maybeString; | ||
const getAssetFromKVDefaultOptions = { | ||
ASSET_NAMESPACE: typeof __STATIC_CONTENT !== 'undefined' ? __STATIC_CONTENT : undefined, | ||
ASSET_MANIFEST: typeof __STATIC_CONTENT_MANIFEST !== 'undefined' | ||
ASSET_NAMESPACE: typeof __STATIC_CONTENT !== "undefined" ? __STATIC_CONTENT : undefined, | ||
ASSET_MANIFEST: typeof __STATIC_CONTENT_MANIFEST !== "undefined" | ||
? parseStringAsObject(__STATIC_CONTENT_MANIFEST) | ||
: {}, | ||
cacheControl: defaultCacheControl, | ||
defaultMimeType: 'text/plain', | ||
defaultDocument: 'index.html', | ||
defaultMimeType: "text/plain", | ||
defaultDocument: "index.html", | ||
pathIsEncoded: false, | ||
defaultETag: 'strong', | ||
defaultETag: "strong", | ||
}; | ||
@@ -43,3 +45,3 @@ function assignOptions(options) { | ||
let pathname = parsedUrl.pathname; | ||
if (pathname.endsWith('/')) { | ||
if (pathname.endsWith("/")) { | ||
// If path looks like a directory append options.defaultDocument | ||
@@ -52,3 +54,3 @@ // e.g. If path is /about/ -> /about/index.html | ||
// e.g. /about.me -> /about.me/index.html | ||
pathname = pathname.concat('/' + options.defaultDocument); | ||
pathname = pathname.concat("/" + options.defaultDocument); | ||
} | ||
@@ -72,3 +74,3 @@ parsedUrl.pathname = pathname; | ||
// a HTML file in some specific directory. | ||
if (parsedUrl.pathname.endsWith('.html')) { | ||
if (parsedUrl.pathname.endsWith(".html")) { | ||
// If expected HTML file was missing, just return the root index.html (or options.defaultDocument) | ||
@@ -89,6 +91,6 @@ return new Request(`${parsedUrl.origin}/${options.defaultDocument}`, request); | ||
const ASSET_MANIFEST = parseStringAsObject(options.ASSET_MANIFEST); | ||
if (typeof ASSET_NAMESPACE === 'undefined') { | ||
if (typeof ASSET_NAMESPACE === "undefined") { | ||
throw new types_1.InternalError(`there is no KV namespace bound to the script`); | ||
} | ||
const rawPathKey = new URL(request.url).pathname.replace(/^\/+/, ''); // strip any preceding /'s | ||
const rawPathKey = new URL(request.url).pathname.replace(/^\/+/, ""); // strip any preceding /'s | ||
let pathIsEncoded = options.pathIsEncoded; | ||
@@ -110,3 +112,3 @@ let requestKey; | ||
const mappedRequest = mapRequestToAsset(request); | ||
const mappedRawPathKey = new URL(mappedRequest.url).pathname.replace(/^\/+/, ''); | ||
const mappedRawPathKey = new URL(mappedRequest.url).pathname.replace(/^\/+/, ""); | ||
if (ASSET_MANIFEST[decodeURIComponent(mappedRawPathKey)]) { | ||
@@ -121,3 +123,3 @@ pathIsEncoded = true; | ||
} | ||
const SUPPORTED_METHODS = ['GET', 'HEAD']; | ||
const SUPPORTED_METHODS = ["GET", "HEAD"]; | ||
if (!SUPPORTED_METHODS.includes(requestKey.method)) { | ||
@@ -127,14 +129,16 @@ throw new types_1.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 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 / | ||
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'; | ||
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 (typeof ASSET_MANIFEST !== "undefined") { | ||
if (ASSET_MANIFEST[pathKey]) { | ||
@@ -153,5 +157,5 @@ pathKey = ASSET_MANIFEST[pathKey]; | ||
switch (typeof options.cacheControl) { | ||
case 'function': | ||
case "function": | ||
return options.cacheControl(request); | ||
case 'object': | ||
case "object": | ||
return options.cacheControl; | ||
@@ -168,7 +172,7 @@ default: | ||
if (!entityId) { | ||
return ''; | ||
return ""; | ||
} | ||
switch (validatorType) { | ||
case 'weak': | ||
if (!entityId.startsWith('W/')) { | ||
case "weak": | ||
if (!entityId.startsWith("W/")) { | ||
if (entityId.startsWith(`"`) && entityId.endsWith(`"`)) { | ||
@@ -180,5 +184,5 @@ return `W/${entityId}`; | ||
return entityId; | ||
case 'strong': | ||
case "strong": | ||
if (entityId.startsWith(`W/"`)) { | ||
entityId = entityId.replace('W/', ''); | ||
entityId = entityId.replace("W/", ""); | ||
} | ||
@@ -190,3 +194,3 @@ if (!entityId.endsWith(`"`)) { | ||
default: | ||
return ''; | ||
return ""; | ||
} | ||
@@ -198,7 +202,7 @@ }; | ||
options.cacheControl.edgeTTL === null || | ||
request.method == 'HEAD') { | ||
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'; | ||
const shouldSetBrowserCache = typeof options.cacheControl.browserTTL === "number"; | ||
let response = null; | ||
@@ -210,3 +214,3 @@ if (shouldEdgeCache) { | ||
if (response.status > 300 && response.status < 400) { | ||
if (response.body && 'cancel' in Object.getPrototypeOf(response.body)) { | ||
if (response.body && "cancel" in Object.getPrototypeOf(response.body)) { | ||
// Body exists and environment supports readable streams | ||
@@ -225,5 +229,5 @@ response.body.cancel(); | ||
status: 0, | ||
statusText: '', | ||
statusText: "", | ||
}; | ||
opts.headers.set('cf-cache-status', 'HIT'); | ||
opts.headers.set("cf-cache-status", "HIT"); | ||
if (response.status) { | ||
@@ -233,9 +237,9 @@ opts.status = response.status; | ||
} | ||
else if (opts.headers.has('Content-Range')) { | ||
else if (opts.headers.has("Content-Range")) { | ||
opts.status = 206; | ||
opts.statusText = 'Partial Content'; | ||
opts.statusText = "Partial Content"; | ||
} | ||
else { | ||
opts.status = 200; | ||
opts.statusText = 'OK'; | ||
opts.statusText = "OK"; | ||
} | ||
@@ -246,3 +250,3 @@ response = new Response(response.body, opts); | ||
else { | ||
const body = await ASSET_NAMESPACE.get(pathKey, 'arrayBuffer'); | ||
const body = await ASSET_NAMESPACE.get(pathKey, "arrayBuffer"); | ||
if (body === null) { | ||
@@ -253,34 +257,34 @@ throw new types_1.NotFoundError(`could not find ${pathKey} in your content namespace`); | ||
if (shouldEdgeCache) { | ||
response.headers.set('Accept-Ranges', 'bytes'); | ||
response.headers.set('Content-Length', String(body.byteLength)); | ||
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)); | ||
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}`); | ||
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("CF-Cache-Status", "MISS"); | ||
} | ||
} | ||
response.headers.set('Content-Type', mimeType); | ||
response.headers.set("Content-Type", mimeType); | ||
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'); | ||
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'); | ||
if (ifNoneMatch && ifNoneMatch === etag && proxyCacheStatus === "MISS") { | ||
response.headers.set("CF-Cache-Status", "EXPIRED"); | ||
} | ||
else { | ||
response.headers.set('CF-Cache-Status', 'REVALIDATED'); | ||
response.headers.set("CF-Cache-Status", "REVALIDATED"); | ||
} | ||
response.headers.set('etag', formatETag(etag, 'weak')); | ||
response.headers.set("etag", formatETag(etag, "weak")); | ||
} | ||
} | ||
if (shouldSetBrowserCache) { | ||
response.headers.set('Cache-Control', `max-age=${options.cacheControl.browserTTL}`); | ||
response.headers.set("Cache-Control", `max-age=${options.cacheControl.browserTTL}`); | ||
} | ||
else { | ||
response.headers.delete('Cache-Control'); | ||
response.headers.delete("Cache-Control"); | ||
} | ||
@@ -287,0 +291,0 @@ return response; |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.sleep = exports.mockGlobalScope = exports.mockRequestScope = exports.mockCaches = exports.mockManifest = exports.mockKV = exports.getEvent = void 0; | ||
const makeServiceWorkerEnv = require('service-worker-mock'); | ||
const HASH = '123HASHBROWN'; | ||
const makeServiceWorkerEnv = require("service-worker-mock"); | ||
const HASH = "123HASHBROWN"; | ||
const getEvent = (request) => { | ||
@@ -17,19 +17,19 @@ const waitUntil = async (callback) => { | ||
const store = { | ||
'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", | ||
}; | ||
@@ -44,18 +44,18 @@ const mockKV = (store) => { | ||
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`, | ||
"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`, | ||
"client/index.html": `client.${HASH}`, | ||
"image.png": `image.${HASH}.png`, | ||
"image.webp": `image.${HASH}.webp`, | ||
"你好/index.html": `你好/index.${HASH}.html`, | ||
}); | ||
@@ -74,5 +74,7 @@ }; | ||
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); | ||
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)); | ||
@@ -91,11 +93,11 @@ } | ||
// TODO: write test to accomodate for rare scenarios with where range requests accomodate etags | ||
if (response && !key.headers.has('if-none-match')) { | ||
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'); | ||
const range = key.headers.get("range"); | ||
if (range) { | ||
response.headers.set('content-range', `bytes ${range.split('=').pop()}/${response.headers.get('content-length')}`); | ||
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')) { | ||
if (response.headers.has("content-range")) { | ||
response.status = 206; | ||
@@ -106,5 +108,5 @@ } | ||
} | ||
let etag = response.headers.get('etag'); | ||
if (etag && !etag.includes('W/')) { | ||
response.headers.set('etag', `W/${etag}`); | ||
let etag = response.headers.get("etag"); | ||
if (etag && !etag.includes("W/")) { | ||
response.headers.set("etag", `W/${etag}`); | ||
} | ||
@@ -122,3 +124,3 @@ } | ||
headers: { | ||
etag: `"${url.pathname.replace('/', '')}"`, | ||
etag: `"${url.pathname.replace("/", "")}"`, | ||
}, | ||
@@ -125,0 +127,0 @@ }; |
@@ -15,3 +15,3 @@ export type CacheControl = { | ||
pathIsEncoded: boolean; | ||
defaultETag: 'strong' | 'weak'; | ||
defaultETag: "strong" | "weak"; | ||
}; | ||
@@ -18,0 +18,0 @@ export declare class KVError extends Error { |
109
package.json
{ | ||
"name": "@cloudflare/kv-asset-handler", | ||
"version": "0.3.1", | ||
"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", | ||
"!src/test", | ||
"!dist/test", | ||
"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": "^4.1.0", | ||
"@cloudflare/workers-types": "^4.20231218.0", | ||
"@types/mime": "^3.0.4", | ||
"@types/node": "^18.11.12", | ||
"ava": "^6.0.1", | ||
"prettier": "^3.2.2", | ||
"service-worker-mock": "^2.0.5", | ||
"typescript": "^5.3.3" | ||
} | ||
} | ||
"name": "@cloudflare/kv-asset-handler", | ||
"version": "0.3.2", | ||
"description": "Routes requests to KV assets", | ||
"main": "dist/index.js", | ||
"types": "dist/index.d.ts", | ||
"repository": { | ||
"type": "git", | ||
"url": "git+https://github.com/cloudflare/workers-sdk.git", | ||
"directory": "packages/kv-asset-handler" | ||
}, | ||
"keywords": [ | ||
"kv", | ||
"cloudflare", | ||
"workers", | ||
"wrangler", | ||
"assets" | ||
], | ||
"files": [ | ||
"src", | ||
"dist", | ||
"!src/test", | ||
"!dist/test" | ||
], | ||
"author": "wrangler@cloudflare.com", | ||
"license": "MIT OR Apache-2.0", | ||
"bugs": { | ||
"url": "https://github.com/cloudflare/workers-sdk/issues" | ||
}, | ||
"homepage": "https://github.com/cloudflare/workers-sdk#readme", | ||
"dependencies": { | ||
"mime": "^3.0.0" | ||
}, | ||
"devDependencies": { | ||
"@ava/typescript": "^4.1.0", | ||
"@cloudflare/workers-types": "^4.20240419.0", | ||
"@types/mime": "^3.0.4", | ||
"@types/node": "^18.11.12", | ||
"ava": "^6.0.1", | ||
"service-worker-mock": "^2.0.5" | ||
}, | ||
"engines": { | ||
"node": ">=16.13" | ||
}, | ||
"publishConfig": { | ||
"access": "public" | ||
}, | ||
"workers-sdk": { | ||
"prerelease": true | ||
}, | ||
"scripts": { | ||
"build": "tsc -d", | ||
"pretest": "npm run build", | ||
"test": "ava dist/test/*.js --verbose", | ||
"test:ci": "npm run build && ava dist/test/*.js --verbose" | ||
} | ||
} |
@@ -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` 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.cloudflare.com)! | ||
@@ -75,9 +75,14 @@ - [Installation](#installation) | ||
```js | ||
import { getAssetFromKV, NotFoundError, MethodNotAllowedError } from '@cloudflare/kv-asset-handler' | ||
import manifestJSON from '__STATIC_CONTENT_MANIFEST' | ||
const assetManifest = JSON.parse(manifestJSON) | ||
import manifestJSON from "__STATIC_CONTENT_MANIFEST"; | ||
import { | ||
getAssetFromKV, | ||
MethodNotAllowedError, | ||
NotFoundError, | ||
} from "@cloudflare/kv-asset-handler"; | ||
const assetManifest = JSON.parse(manifestJSON); | ||
export default { | ||
async fetch(request, env, ctx) { | ||
if (request.url.includes('/docs')) { | ||
if (request.url.includes("/docs")) { | ||
try { | ||
@@ -88,3 +93,3 @@ return await getAssetFromKV( | ||
waitUntil(promise) { | ||
return ctx.waitUntil(promise) | ||
return ctx.waitUntil(promise); | ||
}, | ||
@@ -95,4 +100,4 @@ }, | ||
ASSET_MANIFEST: assetManifest, | ||
}, | ||
) | ||
} | ||
); | ||
} catch (e) { | ||
@@ -104,8 +109,8 @@ if (e instanceof NotFoundError) { | ||
} else { | ||
return new Response('An unexpected error occurred', { status: 500 }) | ||
return new Response("An unexpected error occurred", { status: 500 }); | ||
} | ||
} | ||
} else return fetch(request) | ||
} else return fetch(request); | ||
}, | ||
} | ||
}; | ||
``` | ||
@@ -116,12 +121,16 @@ | ||
```js | ||
import { getAssetFromKV, NotFoundError, MethodNotAllowedError } from '@cloudflare/kv-asset-handler' | ||
import { | ||
getAssetFromKV, | ||
MethodNotAllowedError, | ||
NotFoundError, | ||
} from "@cloudflare/kv-asset-handler"; | ||
addEventListener('fetch', (event) => { | ||
event.respondWith(handleEvent(event)) | ||
}) | ||
addEventListener("fetch", (event) => { | ||
event.respondWith(handleEvent(event)); | ||
}); | ||
async function handleEvent(event) { | ||
if (event.request.url.includes('/docs')) { | ||
if (event.request.url.includes("/docs")) { | ||
try { | ||
return await getAssetFromKV(event) | ||
return await getAssetFromKV(event); | ||
} catch (e) { | ||
@@ -133,6 +142,6 @@ if (e instanceof NotFoundError) { | ||
} else { | ||
return new Response('An unexpected error occurred', { status: 500 }) | ||
return new Response("An unexpected error occurred", { status: 500 }); | ||
} | ||
} | ||
} else return fetch(event.request) | ||
} else return fetch(event.request); | ||
} | ||
@@ -186,3 +195,3 @@ ``` | ||
bypassCache: false, // do not bypass Cloudflare's cache | ||
} | ||
}; | ||
``` | ||
@@ -340,3 +349,3 @@ | ||
bypassCache: true, | ||
} | ||
}; | ||
``` | ||
@@ -343,0 +352,0 @@ |
299
src/index.ts
@@ -1,13 +0,13 @@ | ||
import * as mime from 'mime' | ||
import * as mime from "mime"; | ||
import { | ||
Options, | ||
AssetManifestType, | ||
CacheControl, | ||
InternalError, | ||
MethodNotAllowedError, | ||
NotFoundError, | ||
InternalError, | ||
AssetManifestType, | ||
} from './types' | ||
Options, | ||
} from "./types"; | ||
declare global { | ||
var __STATIC_CONTENT: any, __STATIC_CONTENT_MANIFEST: string | ||
var __STATIC_CONTENT: any, __STATIC_CONTENT_MANIFEST: string; | ||
} | ||
@@ -19,19 +19,22 @@ | ||
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_NAMESPACE: | ||
typeof __STATIC_CONTENT !== "undefined" ? __STATIC_CONTENT : undefined, | ||
ASSET_MANIFEST: | ||
typeof __STATIC_CONTENT_MANIFEST !== 'undefined' | ||
typeof __STATIC_CONTENT_MANIFEST !== "undefined" | ||
? parseStringAsObject<AssetManifestType>(__STATIC_CONTENT_MANIFEST) | ||
: {}, | ||
cacheControl: defaultCacheControl, | ||
defaultMimeType: 'text/plain', | ||
defaultDocument: 'index.html', | ||
defaultMimeType: "text/plain", | ||
defaultDocument: "index.html", | ||
pathIsEncoded: false, | ||
defaultETag: 'strong', | ||
} | ||
defaultETag: "strong", | ||
}; | ||
@@ -41,3 +44,3 @@ function assignOptions(options?: Partial<Options>): Options { | ||
// options.mapRequestToAsset is handled manually later | ||
return <Options>Object.assign({}, getAssetFromKVDefaultOptions, options) | ||
return <Options>Object.assign({}, getAssetFromKVDefaultOptions, options); | ||
} | ||
@@ -53,20 +56,20 @@ | ||
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 (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) | ||
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) | ||
pathname = pathname.concat("/" + options.defaultDocument); | ||
} | ||
parsedUrl.pathname = pathname | ||
return new Request(parsedUrl.toString(), request) | ||
} | ||
parsedUrl.pathname = pathname; | ||
return new Request(parsedUrl.toString(), request); | ||
}; | ||
@@ -78,20 +81,26 @@ /** | ||
*/ | ||
function serveSinglePageApp(request: Request, options?: Partial<Options>): Request { | ||
options = assignOptions(options) | ||
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) | ||
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 (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) | ||
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 | ||
return request; | ||
} | ||
@@ -113,66 +122,78 @@ } | ||
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) | ||
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) | ||
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 | ||
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) | ||
requestKey = options.mapRequestToAsset(request); | ||
} else if (ASSET_MANIFEST[rawPathKey]) { | ||
requestKey = request | ||
requestKey = request; | ||
} else if (ASSET_MANIFEST[decodeURIComponent(rawPathKey)]) { | ||
pathIsEncoded = true | ||
requestKey = request | ||
pathIsEncoded = true; | ||
requestKey = request; | ||
} else { | ||
const mappedRequest = mapRequestToAsset(request) | ||
const mappedRawPathKey = new URL(mappedRequest.url).pathname.replace(/^\/+/, '') | ||
const mappedRequest = mapRequestToAsset(request); | ||
const mappedRawPathKey = new URL(mappedRequest.url).pathname.replace( | ||
/^\/+/, | ||
"" | ||
); | ||
if (ASSET_MANIFEST[decodeURIComponent(mappedRawPathKey)]) { | ||
pathIsEncoded = true | ||
requestKey = mappedRequest | ||
pathIsEncoded = true; | ||
requestKey = mappedRequest; | ||
} else { | ||
// use default mapRequestToAsset | ||
requestKey = mapRequestToAsset(request, options) | ||
requestKey = mapRequestToAsset(request, options); | ||
} | ||
} | ||
const SUPPORTED_METHODS = ['GET', 'HEAD'] | ||
const SUPPORTED_METHODS = ["GET", "HEAD"]; | ||
if (!SUPPORTED_METHODS.includes(requestKey.method)) { | ||
throw new MethodNotAllowedError(`${requestKey.method} is not a valid request 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 / | ||
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' | ||
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 | ||
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 (typeof ASSET_MANIFEST !== "undefined") { | ||
if (ASSET_MANIFEST[pathKey]) { | ||
pathKey = 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 | ||
shouldEdgeCache = true; | ||
} | ||
@@ -182,3 +203,3 @@ } | ||
// TODO this excludes search params from cache, investigate ideal behavior | ||
let cacheKey = new Request(`${parsedUrl.origin}/${pathKey}`, request) | ||
let cacheKey = new Request(`${parsedUrl.origin}/${pathKey}`, request); | ||
@@ -190,10 +211,10 @@ // if argument passed in for cacheControl is a function then | ||
switch (typeof options.cacheControl) { | ||
case 'function': | ||
return options.cacheControl(request) | ||
case 'object': | ||
return options.cacheControl | ||
case "function": | ||
return options.cacheControl(request); | ||
case "object": | ||
return options.cacheControl; | ||
default: | ||
return defaultCacheControl | ||
return defaultCacheControl; | ||
} | ||
})() | ||
})(); | ||
@@ -204,29 +225,32 @@ // formats the etag depending on the response context. if the entityId | ||
// header is "null". Could be modified in future to base64 encode etc | ||
const formatETag = (entityId: any = pathKey, validatorType: string = options.defaultETag) => { | ||
const formatETag = ( | ||
entityId: any = pathKey, | ||
validatorType: string = options.defaultETag | ||
) => { | ||
if (!entityId) { | ||
return '' | ||
return ""; | ||
} | ||
switch (validatorType) { | ||
case 'weak': | ||
if (!entityId.startsWith('W/')) { | ||
case "weak": | ||
if (!entityId.startsWith("W/")) { | ||
if (entityId.startsWith(`"`) && entityId.endsWith(`"`)) { | ||
return `W/${entityId}` | ||
return `W/${entityId}`; | ||
} | ||
return `W/"${entityId}"` | ||
return `W/"${entityId}"`; | ||
} | ||
return entityId | ||
case 'strong': | ||
return entityId; | ||
case "strong": | ||
if (entityId.startsWith(`W/"`)) { | ||
entityId = entityId.replace('W/', '') | ||
entityId = entityId.replace("W/", ""); | ||
} | ||
if (!entityId.endsWith(`"`)) { | ||
entityId = `"${entityId}"` | ||
entityId = `"${entityId}"`; | ||
} | ||
return entityId | ||
return entityId; | ||
default: | ||
return '' | ||
return ""; | ||
} | ||
} | ||
}; | ||
options.cacheControl = Object.assign({}, defaultCacheControl, evalCacheOpts) | ||
options.cacheControl = Object.assign({}, defaultCacheControl, evalCacheOpts); | ||
@@ -237,12 +261,13 @@ // override shouldEdgeCache if options say to bypassCache | ||
options.cacheControl.edgeTTL === null || | ||
request.method == 'HEAD' | ||
request.method == "HEAD" | ||
) { | ||
shouldEdgeCache = false | ||
shouldEdgeCache = false; | ||
} | ||
// only set max-age if explicitly passed in a number as an arg | ||
const shouldSetBrowserCache = typeof options.cacheControl.browserTTL === 'number' | ||
const shouldSetBrowserCache = | ||
typeof options.cacheControl.browserTTL === "number"; | ||
let response = null | ||
let response = null; | ||
if (shouldEdgeCache) { | ||
response = await cache.match(cacheKey) | ||
response = await cache.match(cacheKey); | ||
} | ||
@@ -252,9 +277,9 @@ | ||
if (response.status > 300 && response.status < 400) { | ||
if (response.body && 'cancel' in Object.getPrototypeOf(response.body)) { | ||
if (response.body && "cancel" in Object.getPrototypeOf(response.body)) { | ||
// Body exists and environment supports readable streams | ||
response.body.cancel() | ||
response.body.cancel(); | ||
} else { | ||
// Environment doesnt support readable streams, or null repsonse body. Nothing to do | ||
} | ||
response = new Response(null, response) | ||
response = new Response(null, response); | ||
} else { | ||
@@ -265,63 +290,77 @@ // fixes #165 | ||
status: 0, | ||
statusText: '', | ||
} | ||
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' | ||
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' | ||
opts.status = 200; | ||
opts.statusText = "OK"; | ||
} | ||
response = new Response(response.body, opts) | ||
response = new Response(response.body, opts); | ||
} | ||
} else { | ||
const body = await ASSET_NAMESPACE.get(pathKey, 'arrayBuffer') | ||
const body = await ASSET_NAMESPACE.get(pathKey, "arrayBuffer"); | ||
if (body === null) { | ||
throw new NotFoundError(`could not find ${pathKey} in your content namespace`) | ||
throw new NotFoundError( | ||
`could not find ${pathKey} in your content namespace` | ||
); | ||
} | ||
response = new Response(body) | ||
response = new Response(body); | ||
if (shouldEdgeCache) { | ||
response.headers.set('Accept-Ranges', 'bytes') | ||
response.headers.set('Content-Length', String(body.byteLength)) | ||
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)) | ||
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( | ||
"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) | ||
response.headers.set("Content-Type", mimeType); | ||
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') | ||
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') | ||
if (ifNoneMatch && ifNoneMatch === etag && proxyCacheStatus === "MISS") { | ||
response.headers.set("CF-Cache-Status", "EXPIRED"); | ||
} else { | ||
response.headers.set('CF-Cache-Status', 'REVALIDATED') | ||
response.headers.set("CF-Cache-Status", "REVALIDATED"); | ||
} | ||
response.headers.set('etag', formatETag(etag, 'weak')) | ||
response.headers.set("etag", formatETag(etag, "weak")); | ||
} | ||
} | ||
if (shouldSetBrowserCache) { | ||
response.headers.set('Cache-Control', `max-age=${options.cacheControl.browserTTL}`) | ||
response.headers.set( | ||
"Cache-Control", | ||
`max-age=${options.cacheControl.browserTTL}` | ||
); | ||
} else { | ||
response.headers.delete('Cache-Control') | ||
response.headers.delete("Cache-Control"); | ||
} | ||
return response | ||
} | ||
return response; | ||
}; | ||
export { getAssetFromKV, mapRequestToAsset, serveSinglePageApp } | ||
export { Options, CacheControl, MethodNotAllowedError, NotFoundError, InternalError } | ||
export { getAssetFromKV, mapRequestToAsset, serveSinglePageApp }; | ||
export { | ||
Options, | ||
CacheControl, | ||
MethodNotAllowedError, | ||
NotFoundError, | ||
InternalError, | ||
}; |
176
src/mocks.ts
@@ -1,64 +0,64 @@ | ||
const makeServiceWorkerEnv = require('service-worker-mock') | ||
const makeServiceWorkerEnv = require("service-worker-mock"); | ||
const HASH = '123HASHBROWN' | ||
const HASH = "123HASHBROWN"; | ||
export const getEvent = (request: Request): any => { | ||
const waitUntil = async (callback: any) => { | ||
await callback | ||
} | ||
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, | ||
} | ||
} | ||
}; | ||
}; | ||
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`, | ||
"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`, | ||
}) | ||
} | ||
"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() | ||
let cacheStore: any = new Map(); | ||
interface CacheKey { | ||
url: object | ||
headers: object | ||
url: object; | ||
headers: object; | ||
} | ||
@@ -72,15 +72,17 @@ export const mockCaches = () => { | ||
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)) | ||
}; | ||
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()) | ||
const activeCacheKeys: Array<string> = Array.from(cacheStore.keys()); | ||
for (const cacheStoreKey of activeCacheKeys) { | ||
if (JSON.parse(cacheStoreKey).url === key.url) { | ||
response = cacheStore.get(cacheStoreKey) | ||
response = cacheStore.get(cacheStoreKey); | ||
} | ||
@@ -90,51 +92,53 @@ } | ||
// TODO: write test to accomodate for rare scenarios with where range requests accomodate etags | ||
if (response && !key.headers.has('if-none-match')) { | ||
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') | ||
const range = key.headers.get("range"); | ||
if (range) { | ||
response.headers.set( | ||
'content-range', | ||
`bytes ${range.split('=').pop()}/${response.headers.get('content-length')}`, | ||
) | ||
"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 | ||
if (response.headers.has("content-range")) { | ||
response.status = 206; | ||
} else { | ||
response.status = 200 | ||
response.status = 200; | ||
} | ||
let etag = response.headers.get('etag') | ||
if (etag && !etag.includes('W/')) { | ||
response.headers.set('etag', `W/${etag}`) | ||
let etag = response.headers.get("etag"); | ||
if (etag && !etag.includes("W/")) { | ||
response.headers.set("etag", `W/${etag}`); | ||
} | ||
} | ||
return response | ||
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 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('/', '')}"`, | ||
etag: `"${url.pathname.replace("/", "")}"`, | ||
}, | ||
} | ||
cacheStore.set(JSON.stringify(cacheKey), resNoBody) | ||
cacheKey.headers = {} | ||
cacheStore.set(JSON.stringify(cacheKey), resWithBody) | ||
return | ||
}; | ||
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() }) | ||
Object.assign(global, makeServiceWorkerEnv()); | ||
Object.assign(global, { __STATIC_CONTENT_MANIFEST: mockManifest() }); | ||
Object.assign(global, { __STATIC_CONTENT: mockKV(store) }); | ||
Object.assign(global, { caches: mockCaches() }); | ||
} | ||
@@ -144,8 +148,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)); | ||
}; |
export type CacheControl = { | ||
browserTTL: number | ||
edgeTTL: number | ||
bypassCache: boolean | ||
} | ||
browserTTL: number; | ||
edgeTTL: number; | ||
bypassCache: boolean; | ||
}; | ||
export type AssetManifestType = Record<string, string> | ||
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 | ||
defaultETag: 'strong' | 'weak' | ||
} | ||
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) | ||
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 | ||
Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain | ||
this.name = KVError.name; // stack traces display correctly now | ||
this.status = status; | ||
} | ||
status: number | ||
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); | ||
} | ||
@@ -37,9 +42,12 @@ } | ||
constructor(message: string = `Not Found`, status: number = 404) { | ||
super(message, status) | ||
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); | ||
} | ||
} |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
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
Mixed license
License(Experimental) Package contains multiple licenses.
Found 1 instance in 1 package
6
0
1046
347
0
55576
11