Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

@cloudflare/kv-asset-handler

Package Overview
Dependencies
Maintainers
4
Versions
21
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@cloudflare/kv-asset-handler - npm Package Compare versions

Comparing version 0.0.10 to 0.0.11

.dependabot/config.yml

73

CHANGELOG.md
# 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 @@

3

dist/index.d.ts

@@ -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()')
SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc