@cloudflare/kv-asset-handler
Advanced tools
Comparing version 0.0.10 to 0.0.11
# Changelog | ||
changelog | ||
changlog | ||
changelog | ||
changelog ## 0.0.10 | ||
changelog | ||
changelog | ||
changelog | ||
log | ||
log | ||
log | ||
log | ||
log | ||
## 0.0.11 | ||
- ### Features | ||
- **Support cache revalidation using ETags and If-None-Match - [shagamemnon], [issue/62] [pull/94] [pull/113]** | ||
Previously, cacheable resources were not looked up from the browser cache because `getAssetFromKV` would never return a `304 Not Modified` response. | ||
Now, `getAssetFromKV` sets an `ETag` header on all cachable assets before putting them in the Cache API, and therefore will return a `304` response when appropriate. | ||
[shagamemnon]: https://github.com/shagamemnon | ||
[pull/94]: https://github.com/cloudflare/kv-asset-handler/pull/94 | ||
[pull/113]: https://github.com/cloudflare/kv-asset-handler/issues/113 | ||
[issue/62]: https://github.com/cloudflare/kv-asset-handler/issues/62 | ||
- **Export TypeScript types - [ispivey], [issue/43] [pull/106]** | ||
[ispivey]: https://github.com/ispivey | ||
[pull/106]: https://github.com/cloudflare/kv-asset-handler/pull/106 | ||
[issue/43]: https://github.com/cloudflare/kv-asset-handler/issues/43 | ||
- ### Fixes | ||
- **Support non-ASCII characters in paths - [SukkaW], [issue/99] [pull/105]** | ||
Fixes an issue where non-ASCII paths were not URI-decoded before being looked up, causing non-ASCII paths to 404. | ||
[SukkaW]: https://github.com/SukkaW | ||
[pull/105]: https://github.com/cloudflare/kv-asset-handler/pull/105 | ||
[issue/99]: https://github.com/cloudflare/kv-asset-handler/issues/99 | ||
- **Support `charset=utf8` in MIME type - [theromis], [issue/92] [pull/97]** | ||
Fixes an issue where `Content-Type: text/*` was never appended with `; charset=utf8`, meaning clients would not render non-ASCII characters properly. | ||
[theromis]: https://github.com/theromis | ||
[pull/97]: https://github.com/cloudflare/kv-asset-handler/pull/97 | ||
[issue/92]: https://github.com/cloudflare/kv-asset-handler/issues/92 | ||
- **Fix bugs in README examples - [kentonv] [bradyjoslin], [issue/93] [pull/102] [issue/88] [pull/116]** | ||
[kentonv]: https://github.com/kentonv | ||
[bradyjoslin]: https://github.com/bradyjoslin | ||
[pull/102]: https://github.com/cloudflare/kv-asset-handler/pull/102 | ||
[pull/116]: https://github.com/cloudflare/kv-asset-handler/pull/116 | ||
[issue/93]: https://github.com/cloudflare/kv-asset-handler/issues/93 | ||
[issue/88]: https://github.com/cloudflare/kv-asset-handler/issues/88 | ||
- ### Maintenance | ||
- **Make `@cloudflare/workers-types` a dependency and update deps - [ispivey], [pull/107]** | ||
[ispivey]: https://github.com/ispivey | ||
[pull/107]: https://github.com/cloudflare/kv-asset-handler/pull/107 | ||
- **Add Code of Conduct - [EverlastingBugstopper], [pull/101]** | ||
[EverlastingBugstopper]: https://github.com/EverlastingBugstopper | ||
[pull/101]: https://github.com/cloudflare/kv-asset-handler/pull/101 | ||
## 0.0.10 | ||
- ### Features | ||
- **Allow extensionless files to be served - [victoriabernard92], [cloudflare/wrangler/issues/980], [pull/73]** | ||
@@ -19,0 +68,0 @@ |
@@ -1,2 +0,2 @@ | ||
import { Options } from './types'; | ||
import { Options, CacheControl, MethodNotAllowedError, NotFoundError, InternalError } from './types'; | ||
declare global { | ||
@@ -32,1 +32,2 @@ var __STATIC_CONTENT: any, __STATIC_CONTENT_MANIFEST: string; | ||
export { getAssetFromKV, mapRequestToAsset, serveSinglePageApp }; | ||
export { Options, CacheControl, MethodNotAllowedError, NotFoundError, InternalError }; |
@@ -41,2 +41,5 @@ "use strict"; | ||
var types_1 = require("./types"); | ||
exports.MethodNotAllowedError = types_1.MethodNotAllowedError; | ||
exports.NotFoundError = types_1.NotFoundError; | ||
exports.InternalError = types_1.InternalError; | ||
/** | ||
@@ -106,3 +109,3 @@ * maps the path of incoming request to the request pathKey to look up | ||
var getAssetFromKV = function (event, options) { return __awaiter(void 0, void 0, void 0, function () { | ||
var request, ASSET_NAMESPACE, ASSET_MANIFEST, SUPPORTED_METHODS, rawPathKey, requestKey, parsedUrl, pathname, pathKey, cache, mimeType, shouldEdgeCache, cacheKey, evalCacheOpts, shouldSetBrowserCache, response, headers, body; | ||
var request, ASSET_NAMESPACE, ASSET_MANIFEST, SUPPORTED_METHODS, rawPathKey, pathIsEncoded, requestKey, parsedUrl, pathname, pathKey, cache, mimeType, shouldEdgeCache, cacheKey, evalCacheOpts, shouldSetBrowserCache, response, headers, shouldRevalidate, body; | ||
return __generator(this, function (_a) { | ||
@@ -130,7 +133,18 @@ switch (_a.label) { | ||
} | ||
rawPathKey = new URL(request.url).pathname.replace(/^\/+/, '') // strip any preceding /'s | ||
rawPathKey = new URL(request.url).pathname.replace(/^\/+/, '') // strip any preceding /'s | ||
; | ||
requestKey = ASSET_MANIFEST[rawPathKey] ? request : options.mapRequestToAsset(request); | ||
pathIsEncoded = false; | ||
if (ASSET_MANIFEST[rawPathKey]) { | ||
requestKey = request; | ||
} | ||
else if (ASSET_MANIFEST[decodeURIComponent(rawPathKey)]) { | ||
pathIsEncoded = true; | ||
requestKey = request; | ||
} | ||
else { | ||
requestKey = options.mapRequestToAsset(request); | ||
} | ||
parsedUrl = new URL(requestKey.url); | ||
pathname = parsedUrl.pathname; | ||
pathname = pathIsEncoded ? decodeURIComponent(parsedUrl.pathname) : parsedUrl.pathname // decode percentage encoded path only when necessary | ||
; | ||
pathKey = pathname.replace(/^\/+/, '') // remove prepended / | ||
@@ -140,2 +154,5 @@ ; | ||
mimeType = mime.getType(pathKey) || 'text/plain'; | ||
if (mimeType.startsWith('text')) { | ||
mimeType += '; charset=utf8'; | ||
} | ||
shouldEdgeCache = false // false if storing in KV by raw file path i.e. no hash | ||
@@ -177,12 +194,35 @@ ; | ||
headers = new Headers(response.headers); | ||
headers.set('CF-Cache-Status', 'HIT'); | ||
if (shouldSetBrowserCache) { | ||
headers.set('cache-control', "max-age=" + options.cacheControl.browserTTL); | ||
shouldRevalidate = false; | ||
// Four preconditions must be met for a 304 Not Modified: | ||
// - the request cannot be a range request | ||
// - client sends if-none-match | ||
// - resource has etag | ||
// - test if-none-match against the pathKey so that we test against KV, rather than against | ||
// CF cache, which may modify the etag with a weak validator (e.g. W/"...") | ||
shouldRevalidate = [ | ||
request.headers.has('range') !== true, | ||
request.headers.has('if-none-match'), | ||
response.headers.has('etag'), | ||
request.headers.get('if-none-match') === "" + pathKey, | ||
].every(Boolean); | ||
if (shouldRevalidate) { | ||
// fixes issue #118 | ||
if (response.body && 'cancel' in Object.getPrototypeOf(response.body)) { | ||
response.body.cancel(); | ||
console.log('Body exists and environment supports readable streams. Body cancelled'); | ||
} | ||
else { | ||
console.log('Environment doesnt support readable streams'); | ||
} | ||
headers.set('cf-cache-status', 'REVALIDATED'); | ||
response = new Response(null, { | ||
status: 304, | ||
headers: headers, | ||
statusText: 'Not Modified', | ||
}); | ||
} | ||
else { | ||
// don't assume we want same cache behavior of edge TTL on client | ||
// so remove the header from the response we'll return | ||
headers.delete('cache-control'); | ||
headers.set('CF-Cache-Status', 'HIT'); | ||
response = new Response(response.body, { headers: headers }); | ||
} | ||
response = new Response(response.body, { headers: headers }); | ||
return [3 /*break*/, 5]; | ||
@@ -196,8 +236,13 @@ case 3: return [4 /*yield*/, ASSET_NAMESPACE.get(pathKey, 'arrayBuffer')]; | ||
response = new Response(body); | ||
// TODO: could implement CF-Cache-Status REVALIDATE if path w/o hash existed in manifest | ||
if (shouldEdgeCache) { | ||
response.headers.set('CF-Cache-Status', 'MISS'); | ||
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', "" + 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'); | ||
} | ||
@@ -204,0 +249,0 @@ _a.label = 5; |
@@ -8,4 +8,4 @@ export declare const getEvent: (request: Request) => any; | ||
default: { | ||
match: (key: Request) => any; | ||
put: (key: Request, val: Response) => Response; | ||
match(key: any): Promise<any>; | ||
put(key: any, val: Response): Promise<any>; | ||
}; | ||
@@ -12,0 +12,0 @@ }; |
@@ -62,2 +62,7 @@ "use strict"; | ||
'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', | ||
@@ -79,2 +84,7 @@ 'sub/blah.123HASHBROWN.png': 'picturedis', | ||
'cache.html': "cache." + HASH + ".html", | ||
'测试.html': "\u6D4B\u8BD5." + HASH + ".html", | ||
'你好.html': "\u4F60\u597D." + 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", | ||
@@ -87,3 +97,3 @@ 'sub/blah.png': "sub/blah." + HASH + ".png", | ||
}; | ||
var cacheStore = {}; | ||
var cacheStore = new Map(); | ||
exports.mockCaches = function () { | ||
@@ -93,10 +103,41 @@ return { | ||
match: function (key) { | ||
var url = key.url; | ||
return cacheStore[url] || null; | ||
return __awaiter(this, void 0, void 0, function () { | ||
var cacheKey, activeCacheKeys, _i, activeCacheKeys_1, cacheStoreKey; | ||
return __generator(this, function (_a) { | ||
cacheKey = { | ||
url: key.url, | ||
headers: {} | ||
}; | ||
if (key.headers.has('if-none-match')) { | ||
cacheKey.headers = { | ||
'etag': key.headers.get('if-none-match') | ||
}; | ||
return [2 /*return*/, cacheStore.get(JSON.stringify(cacheKey))]; | ||
} | ||
activeCacheKeys = Array.from(cacheStore.keys()); | ||
for (_i = 0, activeCacheKeys_1 = activeCacheKeys; _i < activeCacheKeys_1.length; _i++) { | ||
cacheStoreKey = activeCacheKeys_1[_i]; | ||
if (JSON.parse(cacheStoreKey).url === key.url) { | ||
return [2 /*return*/, cacheStore.get(cacheStoreKey)]; | ||
} | ||
} | ||
return [2 /*return*/]; | ||
}); | ||
}); | ||
}, | ||
put: function (key, val) { | ||
var headers = new Headers(val.headers); | ||
var resp = new Response(val.body, { headers: headers }); | ||
var url = key.url; | ||
return (cacheStore[url] = resp); | ||
return __awaiter(this, void 0, void 0, function () { | ||
var headers, resp, cacheKey; | ||
return __generator(this, function (_a) { | ||
headers = new Headers(val.headers); | ||
resp = new Response(val.body, { headers: headers }); | ||
cacheKey = { | ||
url: key.url, | ||
headers: { | ||
'etag': val.headers.get('etag') | ||
} | ||
}; | ||
return [2 /*return*/, cacheStore.set(JSON.stringify(cacheKey), resp)]; | ||
}); | ||
}); | ||
}, | ||
@@ -103,0 +144,0 @@ }, |
@@ -192,2 +192,102 @@ "use strict"; | ||
}); }); | ||
ava_1.default('getAssetFromKV non ASCII path support', function (t) { return __awaiter(void 0, void 0, void 0, function () { | ||
var event, res, _a, _b; | ||
return __generator(this, function (_c) { | ||
switch (_c.label) { | ||
case 0: | ||
mocks_1.mockGlobal(); | ||
event = mocks_1.getEvent(new Request('https://blah.com/测试.html')); | ||
return [4 /*yield*/, index_1.getAssetFromKV(event)]; | ||
case 1: | ||
res = _c.sent(); | ||
if (!res) return [3 /*break*/, 3]; | ||
_b = (_a = t).is; | ||
return [4 /*yield*/, res.text()]; | ||
case 2: | ||
_b.apply(_a, [_c.sent(), 'My filename is non-ascii']); | ||
return [3 /*break*/, 4]; | ||
case 3: | ||
t.fail('Response was undefined'); | ||
_c.label = 4; | ||
case 4: return [2 /*return*/]; | ||
} | ||
}); | ||
}); }); | ||
ava_1.default('getAssetFromKV supports browser percent encoded URLs', function (t) { return __awaiter(void 0, void 0, void 0, function () { | ||
var event, res, _a, _b; | ||
return __generator(this, function (_c) { | ||
switch (_c.label) { | ||
case 0: | ||
mocks_1.mockGlobal(); | ||
event = mocks_1.getEvent(new Request('https://example.com/%not-really-percent-encoded.html')); | ||
return [4 /*yield*/, index_1.getAssetFromKV(event)]; | ||
case 1: | ||
res = _c.sent(); | ||
if (!res) return [3 /*break*/, 3]; | ||
_b = (_a = t).is; | ||
return [4 /*yield*/, res.text()]; | ||
case 2: | ||
_b.apply(_a, [_c.sent(), 'browser percent encoded']); | ||
return [3 /*break*/, 4]; | ||
case 3: | ||
t.fail('Response was undefined'); | ||
_c.label = 4; | ||
case 4: return [2 /*return*/]; | ||
} | ||
}); | ||
}); }); | ||
ava_1.default('getAssetFromKV supports user percent encoded URLs', function (t) { return __awaiter(void 0, void 0, void 0, function () { | ||
var event, res, _a, _b; | ||
return __generator(this, function (_c) { | ||
switch (_c.label) { | ||
case 0: | ||
mocks_1.mockGlobal(); | ||
event = mocks_1.getEvent(new Request('https://blah.com/%2F.html')); | ||
return [4 /*yield*/, index_1.getAssetFromKV(event)]; | ||
case 1: | ||
res = _c.sent(); | ||
if (!res) return [3 /*break*/, 3]; | ||
_b = (_a = t).is; | ||
return [4 /*yield*/, res.text()]; | ||
case 2: | ||
_b.apply(_a, [_c.sent(), 'user percent encoded']); | ||
return [3 /*break*/, 4]; | ||
case 3: | ||
t.fail('Response was undefined'); | ||
_c.label = 4; | ||
case 4: return [2 /*return*/]; | ||
} | ||
}); | ||
}); }); | ||
ava_1.default('getAssetFromKV only decode URL when necessary', function (t) { return __awaiter(void 0, void 0, void 0, function () { | ||
var event1, event2, res1, res2, _a, _b, _c, _d; | ||
return __generator(this, function (_e) { | ||
switch (_e.label) { | ||
case 0: | ||
mocks_1.mockGlobal(); | ||
event1 = mocks_1.getEvent(new Request('https://blah.com/%E4%BD%A0%E5%A5%BD.html')); | ||
event2 = mocks_1.getEvent(new Request('https://blah.com/你好.html')); | ||
return [4 /*yield*/, index_1.getAssetFromKV(event1)]; | ||
case 1: | ||
res1 = _e.sent(); | ||
return [4 /*yield*/, index_1.getAssetFromKV(event2)]; | ||
case 2: | ||
res2 = _e.sent(); | ||
if (!(res1 && res2)) return [3 /*break*/, 5]; | ||
_b = (_a = t).is; | ||
return [4 /*yield*/, res1.text()]; | ||
case 3: | ||
_b.apply(_a, [_e.sent(), 'Im important']); | ||
_d = (_c = t).is; | ||
return [4 /*yield*/, res2.text()]; | ||
case 4: | ||
_d.apply(_c, [_e.sent(), 'Im important']); | ||
return [3 /*break*/, 6]; | ||
case 5: | ||
t.fail('Response was undefined'); | ||
_e.label = 6; | ||
case 6: return [2 /*return*/]; | ||
} | ||
}); | ||
}); }); | ||
ava_1.default('getAssetFromKV custom key modifier', function (t) { return __awaiter(void 0, void 0, void 0, function () { | ||
@@ -281,3 +381,3 @@ var event, customRequestMapper, res, _a, _b; | ||
ava_1.default('getAssetFromKV caches on two sequential requests', function (t) { return __awaiter(void 0, void 0, void 0, function () { | ||
var event, res1, res2; | ||
var resourceKey, resourceVersion, event1, event2, res1, res2; | ||
return __generator(this, function (_a) { | ||
@@ -287,4 +387,11 @@ switch (_a.label) { | ||
mocks_1.mockGlobal(); | ||
event = mocks_1.getEvent(new Request('https://blah.com/cache.html')); | ||
return [4 /*yield*/, index_1.getAssetFromKV(event, { cacheControl: { edgeTTL: 720, browserTTL: 720 } })]; | ||
resourceKey = 'cache.html'; | ||
resourceVersion = JSON.parse(mocks_1.mockManifest())[resourceKey]; | ||
event1 = mocks_1.getEvent(new Request("https://blah.com/" + resourceKey)); | ||
event2 = mocks_1.getEvent(new Request("https://blah.com/" + resourceKey, { | ||
headers: { | ||
'if-none-match': resourceVersion | ||
} | ||
})); | ||
return [4 /*yield*/, index_1.getAssetFromKV(event1, { cacheControl: { edgeTTL: 720, browserTTL: 720 } })]; | ||
case 1: | ||
@@ -295,3 +402,3 @@ res1 = _a.sent(); | ||
_a.sent(); | ||
return [4 /*yield*/, index_1.getAssetFromKV(event)]; | ||
return [4 /*yield*/, index_1.getAssetFromKV(event2)]; | ||
case 3: | ||
@@ -302,3 +409,3 @@ res2 = _a.sent(); | ||
t.is(res1.headers.get('cache-control'), 'max-age=720'); | ||
t.is(res2.headers.get('cf-cache-status'), 'HIT'); | ||
t.is(res2.headers.get('cf-cache-status'), 'REVALIDATED'); | ||
} | ||
@@ -313,3 +420,3 @@ else { | ||
ava_1.default('getAssetFromKV does not store max-age on two sequential requests', function (t) { return __awaiter(void 0, void 0, void 0, function () { | ||
var event, res1, res2; | ||
var resourceKey, resourceVersion, event1, event2, res1, res2; | ||
return __generator(this, function (_a) { | ||
@@ -319,4 +426,11 @@ switch (_a.label) { | ||
mocks_1.mockGlobal(); | ||
event = mocks_1.getEvent(new Request('https://blah.com/cache.html')); | ||
return [4 /*yield*/, index_1.getAssetFromKV(event, { cacheControl: { edgeTTL: 720 } })]; | ||
resourceKey = 'cache.html'; | ||
resourceVersion = JSON.parse(mocks_1.mockManifest())[resourceKey]; | ||
event1 = mocks_1.getEvent(new Request("https://blah.com/" + resourceKey)); | ||
event2 = mocks_1.getEvent(new Request("https://blah.com/" + resourceKey, { | ||
headers: { | ||
'if-none-match': resourceVersion | ||
} | ||
})); | ||
return [4 /*yield*/, index_1.getAssetFromKV(event1, { cacheControl: { edgeTTL: 720 } })]; | ||
case 1: | ||
@@ -327,3 +441,3 @@ res1 = _a.sent(); | ||
_a.sent(); | ||
return [4 /*yield*/, index_1.getAssetFromKV(event)]; | ||
return [4 /*yield*/, index_1.getAssetFromKV(event2)]; | ||
case 3: | ||
@@ -334,3 +448,3 @@ res2 = _a.sent(); | ||
t.is(res1.headers.get('cache-control'), null); | ||
t.is(res2.headers.get('cf-cache-status'), 'HIT'); | ||
t.is(res2.headers.get('cf-cache-status'), 'REVALIDATED'); | ||
t.is(res2.headers.get('cache-control'), null); | ||
@@ -519,1 +633,108 @@ } | ||
}); }); | ||
ava_1.default('getAssetFromKV when if-none-match === etag and etag === pathKey in manifest, should revalidate', function (t) { return __awaiter(void 0, void 0, void 0, function () { | ||
var resourceKey, resourceVersion, event1, event2, res1, res2; | ||
return __generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: | ||
mocks_1.mockGlobal(); | ||
resourceKey = 'key1.png'; | ||
resourceVersion = JSON.parse(mocks_1.mockManifest())[resourceKey]; | ||
event1 = mocks_1.getEvent(new Request("https://blah.com/" + resourceKey)); | ||
event2 = mocks_1.getEvent(new Request("https://blah.com/" + resourceKey, { | ||
headers: { | ||
'if-none-match': resourceVersion | ||
} | ||
})); | ||
return [4 /*yield*/, index_1.getAssetFromKV(event1, { cacheControl: { edgeTTL: 720 } })]; | ||
case 1: | ||
res1 = _a.sent(); | ||
return [4 /*yield*/, mocks_1.sleep(100)]; | ||
case 2: | ||
_a.sent(); | ||
return [4 /*yield*/, index_1.getAssetFromKV(event2)]; | ||
case 3: | ||
res2 = _a.sent(); | ||
if (res1 && res2) { | ||
t.is(res1.headers.get('cf-cache-status'), 'MISS'); | ||
t.is(res2.headers.get('etag'), resourceVersion); | ||
t.is(res2.status, 304); | ||
} | ||
else { | ||
t.fail('Response was undefined'); | ||
} | ||
return [2 /*return*/]; | ||
} | ||
}); | ||
}); }); | ||
ava_1.default('getAssetFromKV when etag and if-none-match are present but if-none-match !== etag, should bypass cache', function (t) { return __awaiter(void 0, void 0, void 0, function () { | ||
var resourceKey, resourceVersion, req1, req2, event, event2, res1, res2, res3; | ||
return __generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: | ||
mocks_1.mockGlobal(); | ||
resourceKey = 'key1.png'; | ||
resourceVersion = JSON.parse(mocks_1.mockManifest())[resourceKey]; | ||
req1 = new Request("https://blah.com/" + resourceKey, { | ||
headers: { | ||
'if-none-match': resourceVersion | ||
} | ||
}); | ||
req2 = new Request("https://blah.com/" + resourceKey, { | ||
headers: { | ||
'if-none-match': resourceVersion + "another-version" | ||
} | ||
}); | ||
event = mocks_1.getEvent(req1); | ||
event2 = mocks_1.getEvent(req2); | ||
return [4 /*yield*/, index_1.getAssetFromKV(event, { cacheControl: { edgeTTL: 720 } })]; | ||
case 1: | ||
res1 = _a.sent(); | ||
return [4 /*yield*/, index_1.getAssetFromKV(event)]; | ||
case 2: | ||
res2 = _a.sent(); | ||
return [4 /*yield*/, index_1.getAssetFromKV(event2)]; | ||
case 3: | ||
res3 = _a.sent(); | ||
if (res1 && res2 && res3) { | ||
t.is(res1.headers.get('cf-cache-status'), 'MISS'); | ||
t.is(res2.headers.get('etag'), req1.headers.get('if-none-match')); | ||
t.true(req2.headers.has('if-none-match')); | ||
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'); | ||
} | ||
return [2 /*return*/]; | ||
} | ||
}); | ||
}); }); | ||
ava_1.default('getAssetFromKV if-none-match not sent but resource in cache, should return hit', function (t) { return __awaiter(void 0, void 0, void 0, function () { | ||
var resourceKey, event, res1, res2; | ||
return __generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: | ||
resourceKey = 'cache.html'; | ||
event = mocks_1.getEvent(new Request("https://blah.com/" + resourceKey)); | ||
return [4 /*yield*/, index_1.getAssetFromKV(event, { cacheControl: { edgeTTL: 720 } })]; | ||
case 1: | ||
res1 = _a.sent(); | ||
return [4 /*yield*/, mocks_1.sleep(1)]; | ||
case 2: | ||
_a.sent(); | ||
return [4 /*yield*/, index_1.getAssetFromKV(event)]; | ||
case 3: | ||
res2 = _a.sent(); | ||
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'), 'HIT'); | ||
} | ||
else { | ||
t.fail('Response was undefined'); | ||
} | ||
return [2 /*return*/]; | ||
} | ||
}); | ||
}); }); | ||
ava_1.default.todo('getAssetFromKV when body not empty, should invoke .cancel()'); |
{ | ||
"name": "@cloudflare/kv-asset-handler", | ||
"version": "0.0.10", | ||
"version": "0.0.11", | ||
"description": "Routes requests to KV assets", | ||
@@ -32,17 +32,11 @@ "main": "dist/index.js", | ||
"dependencies": { | ||
"@types/mime": "^2.0.1", | ||
"mime": "^2.4.4" | ||
"@cloudflare/workers-types": "^2.0.0", | ||
"@types/mime": "^2.0.2", | ||
"mime": "^2.4.6" | ||
}, | ||
"ava": { | ||
"require": [ | ||
"esm" | ||
] | ||
}, | ||
"devDependencies": { | ||
"@cloudflare/workers-types": "^1.0.1", | ||
"ava": "^1.4.1", | ||
"service-worker-mock": "^2.0.3", | ||
"ts-loader": "^6.2.1", | ||
"typescript": "^3.7.2" | ||
"ava": "^3.9.0", | ||
"service-worker-mock": "^2.0.5", | ||
"typescript": "^3.9.5" | ||
} | ||
} |
# @cloudflare/kv-asset-handler | ||
* [Installation](#installation) | ||
* [Usage](#usage) log | ||
log changelog | ||
* [Usage](#usage) | ||
* [`getAssetFromKV`](#-getassetfromkv-) | ||
@@ -20,2 +19,3 @@ - [Example](#example) | ||
* [`serveSinglePageApp`](#-servesinglepageapp-) | ||
- [Cache revalidation and etags](#cache-revalidation-and-etags) | ||
@@ -54,5 +54,5 @@ ## Installation | ||
- InternalError | ||
```js | ||
import { getAssetFromKV } from '@cloudflare/kv-asset-handler' | ||
import { getAssetFromKV, NotFoundError, MethodNotAllowedError } from '@cloudflare/kv-asset-handler' | ||
@@ -68,10 +68,8 @@ addEventListener('fetch', event => { | ||
} catch (e) { | ||
switch (typeof resp) { | ||
case NotFoundError: | ||
//.. | ||
case MethodNotAllowedError: | ||
// ... | ||
default: | ||
return new Response("An unexpected error occurred", { status: 500 }) | ||
} | ||
if (e instanceof NotFoundError) { | ||
// ... | ||
} else if (e instanceof MethodNotAllowedError) { | ||
// ... | ||
} else { | ||
return new Response("An unexpected error occurred", { status: 500 }) | ||
} | ||
@@ -85,3 +83,3 @@ } | ||
You can customize the behavior of `getAssetFromKV` by passing the following properties as an object into the second argument | ||
You can customize the behavior of `getAssetFromKV` by passing the following properties as an object into the second argument. | ||
@@ -112,3 +110,3 @@ ``` | ||
//custom key mapping optional | ||
url.replace('/docs', '').replace(/^\/+/, '') | ||
url = url.replace('/docs', '').replace(/^\/+/, '') | ||
return mapRequestToAsset(new Request(url, request)) | ||
@@ -145,3 +143,3 @@ } | ||
Sets the `Cache-Control: max-age` header on the response used as the edge cache key. By default set to 2 days (`2 * 60 * 60 * 24`). When null will not cache on the edge at all. | ||
Sets the `Cache-Control: max-age` header on the response used as the edge cache key. By default set to 2 days (`2 * 60 * 60 * 24`). When null will not cache on the edge at all. | ||
@@ -159,3 +157,3 @@ ##### `bypassCache` | ||
The binding name to the KV Namespace populated with key/value entries of files for the Worker to serve. By default, Workers Sites uses a [binding to a Workers KV Namespace](https://developers.cloudflare.com/workers/reference/storage/api/#namespaces) named `__STATIC_CONTENT`. | ||
The binding name to the KV Namespace populated with key/value entries of files for the Worker to serve. By default, Workers Sites uses a [binding to a Workers KV Namespace](https://developers.cloudflare.com/workers/reference/storage/api/#namespaces) named `__STATIC_CONTENT`. | ||
@@ -167,3 +165,3 @@ It is further assumed that this namespace consists of static assets such as html, css, javascript, or image files, keyed off of a relative path that roughly matches the assumed url pathname of the incoming request. | ||
``` | ||
#### `ASSET_MANIFEST` (optional) | ||
@@ -202,2 +200,24 @@ | ||
let asset = await getAssetFromKV(event, { mapRequestToAsset: serveSinglePageApp }) | ||
``` | ||
``` | ||
# Cache revalidation and etags | ||
All responses served from cache (including those with `cf-cache-status: MISS`) include an `etag` response header that identifies the version of the resource. The `etag` value is identical to the path key used in the `ASSET_MANIFEST`. It is updated each time an asset changes and looks like this: `etag: <filename>.<hash of file contents>.<extension>` (ex. `etag: index.54321.html`). | ||
Resources served with an `etag` allow browsers to use the `if-none-match` request header to make conditional requests for that resource in the future. This has two major benefits: | ||
* When a request's `if-none-match` value matches the `etag` of the resource in Cloudflare cache, Cloudflare will send a `304 Not Modified` response without a body, saving bandwidth. | ||
* Changes to a file on the server are immediately reflected in the browser - even when the cache control directive `max-age` is unexpired. | ||
#### Disable the `etag` | ||
To turn `etags` **off**, you must bypass cache: | ||
```js | ||
/* Turn etags off */ | ||
let cacheControl = { | ||
bypassCache: true | ||
} | ||
``` | ||
#### Syntax and comparison context | ||
`kv-asset-handler` sets and evaluates etags as [strong validators](https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests#Strong_validation). To preserve `etag` integrity, the format of the value deviates from the [RFC2616 recommendation to enclose the `etag` with quotation marks](https://tools.ietf.org/html/rfc2616#section-3.11). This is intentional. Cloudflare cache applies the `W/` prefix to all `etags` that use quoted-strings -- a process that converts the `etag` to a "weak validator" when served to a client. |
@@ -100,7 +100,16 @@ import * as mime from 'mime' | ||
const rawPathKey = new URL(request.url).pathname.replace(/^\/+/, '') // strip any preceding /'s | ||
//set to the raw file if exists, else the approriate HTML file | ||
const requestKey = ASSET_MANIFEST[rawPathKey] ? request : options.mapRequestToAsset(request) | ||
const rawPathKey = new URL(request.url).pathname.replace(/^\/+/, '') // strip any preceding /'s | ||
let pathIsEncoded = false | ||
let requestKey | ||
if (ASSET_MANIFEST[rawPathKey]) { | ||
requestKey = request | ||
} else if (ASSET_MANIFEST[decodeURIComponent(rawPathKey)]) { | ||
pathIsEncoded = true; | ||
requestKey = request | ||
} else { | ||
requestKey = options.mapRequestToAsset(request) | ||
} | ||
const parsedUrl = new URL(requestKey.url) | ||
const pathname = parsedUrl.pathname | ||
const pathname = pathIsEncoded ? decodeURIComponent(parsedUrl.pathname) : parsedUrl.pathname // decode percentage encoded path only when necessary | ||
@@ -112,3 +121,6 @@ // pathKey is the file path to look up in the manifest | ||
const cache = caches.default | ||
const mimeType = mime.getType(pathKey) || 'text/plain' | ||
let mimeType = mime.getType(pathKey) || 'text/plain' | ||
if (mimeType.startsWith('text')) { | ||
mimeType += '; charset=utf8' | ||
} | ||
@@ -158,11 +170,37 @@ let shouldEdgeCache = false // false if storing in KV by raw file path i.e. no hash | ||
let headers = new Headers(response.headers) | ||
headers.set('CF-Cache-Status', 'HIT') | ||
if (shouldSetBrowserCache) { | ||
headers.set('cache-control', `max-age=${options.cacheControl.browserTTL}`) | ||
let shouldRevalidate = false | ||
// Four preconditions must be met for a 304 Not Modified: | ||
// - the request cannot be a range request | ||
// - client sends if-none-match | ||
// - resource has etag | ||
// - test if-none-match against the pathKey so that we test against KV, rather than against | ||
// CF cache, which may modify the etag with a weak validator (e.g. W/"...") | ||
shouldRevalidate = [ | ||
request.headers.has('range') !== true, | ||
request.headers.has('if-none-match'), | ||
response.headers.has('etag'), | ||
request.headers.get('if-none-match') === `${pathKey}`, | ||
].every(Boolean) | ||
if (shouldRevalidate) { | ||
// fixes issue #118 | ||
if (response.body && 'cancel' in Object.getPrototypeOf(response.body)) { | ||
response.body.cancel(); | ||
console.log('Body exists and environment supports readable streams. Body cancelled') | ||
} else { | ||
console.log('Environment doesnt support readable streams') | ||
} | ||
headers.set('cf-cache-status', 'REVALIDATED') | ||
response = new Response(null, { | ||
status: 304, | ||
headers, | ||
statusText: 'Not Modified', | ||
}) | ||
} else { | ||
// don't assume we want same cache behavior of edge TTL on client | ||
// so remove the header from the response we'll return | ||
headers.delete('cache-control') | ||
headers.set('CF-Cache-Status', 'HIT') | ||
response = new Response(response.body, { headers }) | ||
} | ||
response = new Response(response.body, { headers }) | ||
} else { | ||
@@ -175,9 +213,13 @@ const body = await ASSET_NAMESPACE.get(pathKey, 'arrayBuffer') | ||
// TODO: could implement CF-Cache-Status REVALIDATE if path w/o hash existed in manifest | ||
if (shouldEdgeCache) { | ||
response.headers.set('CF-Cache-Status', 'MISS') | ||
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', `${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') | ||
} | ||
@@ -195,1 +237,2 @@ } | ||
export { getAssetFromKV, mapRequestToAsset, serveSinglePageApp } | ||
export { Options, CacheControl, MethodNotAllowedError, NotFoundError, InternalError } |
@@ -19,2 +19,7 @@ const makeServiceWorkerEnv = require('service-worker-mock') | ||
'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', | ||
@@ -37,2 +42,7 @@ 'sub/blah.123HASHBROWN.png': 'picturedis', | ||
'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`, | ||
@@ -45,15 +55,41 @@ 'sub/blah.png': `sub/blah.${HASH}.png`, | ||
} | ||
let cacheStore: any = {} | ||
let cacheStore: any = new Map() | ||
interface CacheKey { | ||
url:object; | ||
headers:object | ||
} | ||
export const mockCaches = () => { | ||
return { | ||
default: { | ||
match: (key: Request) => { | ||
const url = key.url | ||
return cacheStore[url] || null | ||
async match (key: any) { | ||
let cacheKey: CacheKey = { | ||
url: key.url, | ||
headers: {} | ||
} | ||
if (key.headers.has('if-none-match')) { | ||
cacheKey.headers = { | ||
'etag': key.headers.get('if-none-match') | ||
} | ||
return cacheStore.get(JSON.stringify(cacheKey)) | ||
} | ||
// 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) { | ||
return cacheStore.get(cacheStoreKey) | ||
} | ||
} | ||
}, | ||
put: (key: Request, val: Response) => { | ||
async put (key: any, val: Response) { | ||
let headers = new Headers(val.headers) | ||
let resp = new Response(val.body, { headers }) | ||
const url = key.url | ||
return (cacheStore[url] = resp) | ||
let cacheKey: CacheKey = { | ||
url: key.url, | ||
headers: { | ||
'etag': val.headers.get('etag') | ||
} | ||
} | ||
return cacheStore.set(JSON.stringify(cacheKey), resp) | ||
}, | ||
@@ -60,0 +96,0 @@ }, |
import test from 'ava' | ||
import { mockGlobal, getEvent, sleep, mockKV } from '../mocks' | ||
import { mockGlobal, getEvent, sleep, mockKV, mockManifest } from '../mocks' | ||
import { getAssetFromKV, mapRequestToAsset } from '../index' | ||
@@ -79,2 +79,53 @@ import { KVError } from '../types' | ||
test('getAssetFromKV non ASCII path support', async t => { | ||
mockGlobal() | ||
const event = getEvent(new Request('https://blah.com/测试.html')) | ||
const res = await getAssetFromKV(event) | ||
if (res) { | ||
t.is(await res.text(), 'My filename is non-ascii') | ||
} else { | ||
t.fail('Response was undefined') | ||
} | ||
}) | ||
test('getAssetFromKV supports browser percent encoded URLs', async t => { | ||
mockGlobal() | ||
const event = getEvent(new Request('https://example.com/%not-really-percent-encoded.html')) | ||
const res = await getAssetFromKV(event) | ||
if (res) { | ||
t.is(await res.text(), 'browser percent encoded') | ||
} else { | ||
t.fail('Response was undefined') | ||
} | ||
}) | ||
test('getAssetFromKV supports user percent encoded URLs', async t => { | ||
mockGlobal() | ||
const event = getEvent(new Request('https://blah.com/%2F.html')) | ||
const res = await getAssetFromKV(event) | ||
if (res) { | ||
t.is(await res.text(), 'user percent encoded') | ||
} else { | ||
t.fail('Response was undefined') | ||
} | ||
}) | ||
test('getAssetFromKV only decode URL when necessary', async t => { | ||
mockGlobal() | ||
const event1 = getEvent(new Request('https://blah.com/%E4%BD%A0%E5%A5%BD.html')) | ||
const event2 = getEvent(new Request('https://blah.com/你好.html')) | ||
const res1 = await getAssetFromKV(event1) | ||
const res2 = await getAssetFromKV(event2) | ||
if (res1 && res2) { | ||
t.is(await res1.text(), 'Im important') | ||
t.is(await res2.text(), 'Im important') | ||
} else { | ||
t.fail('Response was undefined') | ||
} | ||
}) | ||
test('getAssetFromKV custom key modifier', async t => { | ||
@@ -144,7 +195,14 @@ mockGlobal() | ||
mockGlobal() | ||
const event = getEvent(new Request('https://blah.com/cache.html')) | ||
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(event, { cacheControl: { edgeTTL: 720, browserTTL: 720 } }) | ||
const res1 = await getAssetFromKV(event1, { cacheControl: { edgeTTL: 720, browserTTL: 720 } }) | ||
await sleep(1) | ||
const res2 = await getAssetFromKV(event) | ||
const res2 = await getAssetFromKV(event2) | ||
@@ -154,3 +212,3 @@ if (res1 && res2) { | ||
t.is(res1.headers.get('cache-control'), 'max-age=720') | ||
t.is(res2.headers.get('cf-cache-status'), 'HIT') | ||
t.is(res2.headers.get('cf-cache-status'), 'REVALIDATED') | ||
} else { | ||
@@ -162,8 +220,14 @@ t.fail('Response was undefined') | ||
mockGlobal() | ||
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 event = getEvent(new Request('https://blah.com/cache.html')) | ||
const res1 = await getAssetFromKV(event, { cacheControl: { edgeTTL: 720 } }) | ||
const res1 = await getAssetFromKV(event1, { cacheControl: { edgeTTL: 720 } }) | ||
await sleep(100) | ||
const res2 = await getAssetFromKV(event) | ||
const res2 = await getAssetFromKV(event2) | ||
@@ -173,3 +237,3 @@ if (res1 && res2) { | ||
t.is(res1.headers.get('cache-control'), null) | ||
t.is(res2.headers.get('cf-cache-status'), 'HIT') | ||
t.is(res2.headers.get('cf-cache-status'), 'REVALIDATED') | ||
t.is(res2.headers.get('cache-control'), null) | ||
@@ -278,1 +342,71 @@ } else { | ||
}) | ||
test('getAssetFromKV when if-none-match === etag and etag === pathKey in manifest, should revalidate', async t => { | ||
mockGlobal() | ||
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': resourceVersion | ||
} | ||
})) | ||
const res1 = await getAssetFromKV(event1, { cacheControl: { edgeTTL: 720 } }) | ||
await sleep(100) | ||
const res2 = await getAssetFromKV(event2) | ||
if (res1 && res2) { | ||
t.is(res1.headers.get('cf-cache-status'), 'MISS') | ||
t.is(res2.headers.get('etag'), resourceVersion) | ||
t.is(res2.status, 304) | ||
} else { | ||
t.fail('Response was undefined') | ||
} | ||
}) | ||
test('getAssetFromKV when etag and if-none-match are present but if-none-match !== etag, should bypass cache', async t => { | ||
mockGlobal() | ||
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'), req1.headers.get('if-none-match')) | ||
t.true(req2.headers.has('if-none-match')) | ||
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 if-none-match not sent but resource in cache, should return hit', 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.headers.get('cf-cache-status'), 'HIT') | ||
} else { | ||
t.fail('Response was undefined') | ||
} | ||
}) | ||
test.todo('getAssetFromKV when body not empty, should invoke .cancel()') |
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
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
Native code
Supply chain riskContains native code (e.g., compiled binaries or shared libraries). Including native code can obscure malicious behavior.
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
135547
3
30
2187
215
1
3
+ Added@cloudflare/workers-types@2.2.2(transitive)
Updated@types/mime@^2.0.2
Updatedmime@^2.4.6