Skip to content

Commit

Permalink
Support cacheKey, cacheTtl, cacheTtlByStatus, closes #37
Browse files Browse the repository at this point in the history
  • Loading branch information
mrbbot committed Jan 7, 2022
1 parent 981aa0f commit fd5395e
Show file tree
Hide file tree
Showing 8 changed files with 327 additions and 75 deletions.
3 changes: 2 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@
- [x] Make WebSocket implementation behave more like workers
- [x] Restrict Durable Object IDs to objects they were created for
- [x] Durable Object gates, `blockConcurrencyWhile`, etc
- [ ] Live reload with HTMLRewriter, in http-server package
- [ ] Wrangler compatibility flag support
- [ ] Package descriptions & JSDocs (automatically include in READMEs?)

## Beta 2

- [ ] Unit testing for workers with Jest:
https://jestjs.io/docs/configuration#testenvironment-string
- [ ] Live reload with HTMLRewriter, in http-server package
- [ ] Multiple workers & Durable Object `script_name` option
- [ ] Make some error messages more helpful, suggest fixes
- [ ] Add remote KV storage
- [ ] Multiple Miniflare processes, Durable Object coordination
148 changes: 95 additions & 53 deletions packages/cache/src/cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { URL } from "url";
import { Request, RequestInfo, Response } from "@miniflare/core";
import {
Request,
RequestInfo,
RequestInitCfProperties,
Response,
} from "@miniflare/core";
import {
Clock,
MaybePromise,
Expand All @@ -17,8 +22,10 @@ import {
} from "undici";
import { CacheInterface, CacheMatchOptions, CachedMeta } from "./helpers";

function normaliseRequest(req: RequestInfo): BaseRequest {
return req instanceof BaseRequest ? req : new Request(req);
function normaliseRequest(req: RequestInfo): BaseRequest | Request {
return req instanceof Request || req instanceof BaseRequest
? req
: new Request(req);
}

// Normalises headers to object mapping lower-case names to single values.
Expand All @@ -31,9 +38,10 @@ function normaliseHeaders(headers: Headers): Record<string, string> {
return result;
}

function getKey(req: BaseRequest): string {
function getKey(req: BaseRequest | Request): string {
// @ts-expect-error cf doesn't exist on BaseRequest, but we're using `?.`
if (req.cf?.cacheKey) return req.cf.cacheKey;
try {
// TODO: support request cacheKey
const url = new URL(req.url);
return url.toString();
} catch (e) {
Expand All @@ -43,6 +51,84 @@ function getKey(req: BaseRequest): string {
}
}

const cacheTtlByStatusRangeRegexp = /^(?<from>\d+)(-(?<to>\d+))?$/;

function getExpirationTtl(
clock: Clock,
req: BaseRequest | Request,
res: BaseResponse | Response
): number | undefined {
// Check cf property first for expiration TTL
// @ts-expect-error cf doesn't exist on BaseRequest, but it will just be
// undefined if it doesn't
const cf: RequestInitCfProperties | undefined = req.cf;
if (cf?.cacheTtl) return cf.cacheTtl * 1000;
if (cf?.cacheTtlByStatus) {
for (const [range, ttl] of Object.entries(cf.cacheTtlByStatus)) {
const match = cacheTtlByStatusRangeRegexp.exec(range);
const fromString: string | undefined = match?.groups?.from;
// If no match, skip to next range
if (!fromString) continue;
const from = parseInt(fromString);
const toString: string | undefined = match?.groups?.to;
// If matched "to" group, check range, otherwise, just check equal status
if (toString) {
const to = parseInt(toString);
if (from <= res.status && res.status <= to) return ttl * 1000;
} else if (res.status === from) {
return ttl * 1000;
}
}
}

// Cloudflare ignores request Cache-Control
const reqHeaders = normaliseHeaders(req.headers);
delete reqHeaders["cache-control"];

// Cloudflare never caches responses with Set-Cookie headers
// If Cache-Control contains private=set-cookie, Cloudflare will remove
// the Set-Cookie header automatically
const resHeaders = normaliseHeaders(res.headers);
if (
resHeaders["cache-control"]?.toLowerCase().includes("private=set-cookie")
) {
resHeaders["cache-control"] = resHeaders["cache-control"].replace(
/private=set-cookie/i,
""
);
delete resHeaders["set-cookie"];
}

// Build request and responses suitable for CachePolicy
const cacheReq: CachePolicy.Request = {
url: req.url,
method: req.method,
headers: reqHeaders,
};
const cacheRes: CachePolicy.Response = {
status: res.status,
headers: resHeaders,
};

// @ts-expect-error `now` isn't included in CachePolicy's type definitions
const originalNow = CachePolicy.prototype.now;
// @ts-expect-error `now` isn't included in CachePolicy's type definitions
CachePolicy.prototype.now = clock;
try {
const policy = new CachePolicy(cacheReq, cacheRes, { shared: true });

// Check if the request & response is cacheable, if not return undefined
if ("set-cookie" in resHeaders || !policy.storable()) {
return;
}

return policy.timeToLive();
} finally {
// @ts-expect-error `now` isn't included in CachePolicy's type definitions
CachePolicy.prototype.now = originalNow;
}
}

export class Cache implements CacheInterface {
readonly #storage: MaybePromise<StorageOperator>;
readonly #clock: Clock;
Expand Down Expand Up @@ -73,55 +159,11 @@ export class Cache implements CacheInterface {
throw new TypeError("Cannot cache response with 'Vary: *' header.");
}

// Cloudflare ignores request Cache-Control
const reqHeaders = normaliseHeaders(req.headers);
delete reqHeaders["cache-control"];

// Cloudflare never caches responses with Set-Cookie headers
// If Cache-Control contains private=set-cookie, Cloudflare will remove
// the Set-Cookie header automatically
const resHeaders = normaliseHeaders(res.headers);
if (
resHeaders["cache-control"]?.toLowerCase().includes("private=set-cookie")
) {
resHeaders["cache-control"] = resHeaders["cache-control"].replace(
/private=set-cookie/i,
""
);
delete resHeaders["set-cookie"];
}

// Build request and responses suitable for CachePolicy
const cacheReq: CachePolicy.Request = {
url: req.url,
method: req.method,
headers: reqHeaders,
};
const cacheRes: CachePolicy.Response = {
status: res.status,
headers: resHeaders,
};

// @ts-expect-error `now` isn't included in CachePolicy's type definitions
const originalNow = CachePolicy.prototype.now;
// @ts-expect-error `now` isn't included in CachePolicy's type definitions
CachePolicy.prototype.now = this.#clock;
let expirationTtl: number;
try {
const policy = new CachePolicy(cacheReq, cacheRes, { shared: true });

// Check if the request & response is cacheable, if not return undefined
if ("set-cookie" in resHeaders || !policy.storable()) {
return;
}

expirationTtl = policy.timeToLive();
} finally {
// @ts-expect-error `now` isn't included in CachePolicy's type definitions
CachePolicy.prototype.now = originalNow;
}
// Check if response cacheable and get expiration TTL if any
const expirationTtl = getExpirationTtl(this.#clock, req, res);
if (expirationTtl === undefined) return;

// If it is cacheable, store it in KV
// If it is cacheable, store it
const key = getKey(req);
const metadata: CachedMeta = {
status: res.status,
Expand Down
111 changes: 97 additions & 14 deletions packages/cache/test/cache.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from "assert";
import { URL } from "url";
import { Cache, CachedMeta } from "@miniflare/cache";
import { Response } from "@miniflare/core";
import { Request, RequestInitCfProperties, Response } from "@miniflare/core";
import { StorageOperator } from "@miniflare/shared";
import {
getObjectProperties,
Expand All @@ -13,9 +13,9 @@ import { MemoryStorageOperator } from "@miniflare/storage-memory";
import { WebSocketPair } from "@miniflare/web-sockets";
import anyTest, { Macro, TestInterface } from "ava";
import {
Request as BaseRequest,
Response as BaseResponse,
HeadersInit,
Request,
RequestInfo,
} from "undici";
import { testResponse } from "./helpers";
Expand Down Expand Up @@ -59,7 +59,7 @@ const putMacro: Macro<[RequestInfo], Context> = async (t, req) => {
t.is(utf8Decode(stored.value), "value");
};
putMacro.title = (providedTitle) => `Cache: puts ${providedTitle}`;
test("request", putMacro, new Request("http://localhost:8787/test"));
test("request", putMacro, new BaseRequest("http://localhost:8787/test"));
test("string request", putMacro, "http://localhost:8787/test");
test("url request", putMacro, new URL("http://localhost:8787/test"));

Expand All @@ -80,7 +80,7 @@ test("Cache: only puts GET requests", async (t) => {
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
await t.throwsAsync(
cache.put(
new Request(`http://localhost:8787/${method}`, { method }),
new BaseRequest(`http://localhost:8787/${method}`, { method }),
testResponse()
),
{
Expand Down Expand Up @@ -118,6 +118,77 @@ test("Cache: doesn't cache vary all responses", async (t) => {
message: "Cannot cache response with 'Vary: *' header.",
});
});
test("Cache: respects cache key", async (t) => {
const { storage, cache } = t.context;

const req1 = new Request("http://localhost/", { cf: { cacheKey: "1" } });
const req2 = new Request("http://localhost/", { cf: { cacheKey: "2" } });
const res1 = testResponse("value1");
const res2 = testResponse("value2");

await cache.put(req1, res1);
await cache.put(req2, res2);
t.true(await storage.has("1"));
t.true(await storage.has("2"));

const match1 = await cache.match(req1);
const match2 = await cache.match(req2);
t.is(await match1?.text(), "value1");
t.is(await match2?.text(), "value2");
});
test("Cache: put respects cf cacheTtl", async (t) => {
const { clock, cache } = t.context;
await cache.put(
new Request("http://localhost/test", { cf: { cacheTtl: 1 } }),
new BaseResponse("value")
);
t.not(await cache.match("http://localhost/test"), undefined);
clock.timestamp += 500;
t.not(await cache.match("http://localhost/test"), undefined);
clock.timestamp += 500;
t.is(await cache.match("http://localhost/test"), undefined);
});
test("Cache: put respects cf cacheTtlByStatus", async (t) => {
const { clock, cache } = t.context;
const cf: RequestInitCfProperties = {
cacheTtlByStatus: { "200-299": 2, "? :D": 99, "404": 1, "500-599": 0 },
};
const headers = { "Cache-Control": "max-age=5" };
const req200 = new Request("http://localhost/200", { cf });
const req201 = new Request("http://localhost/201", { cf });
const req302 = new Request("http://localhost/302", { cf });
const req404 = new Request("http://localhost/404", { cf });
const req599 = new Request("http://localhost/599", { cf });
await cache.put(req200, new BaseResponse(null, { status: 200, headers }));
await cache.put(req201, new BaseResponse(null, { status: 201, headers }));
await cache.put(req302, new BaseResponse(null, { status: 302, headers }));
await cache.put(req404, new BaseResponse(null, { status: 404, headers }));
await cache.put(req599, new BaseResponse(null, { status: 599, headers }));

// Check all but 5xx responses cached
t.not(await cache.match("http://localhost/200"), undefined);
t.not(await cache.match("http://localhost/201"), undefined);
t.not(await cache.match("http://localhost/302"), undefined);
t.not(await cache.match("http://localhost/404"), undefined);
t.is(await cache.match("http://localhost/599"), undefined);

// Check 404 response expires after 1 second
clock.timestamp += 1000;
t.not(await cache.match("http://localhost/200"), undefined);
t.not(await cache.match("http://localhost/201"), undefined);
t.not(await cache.match("http://localhost/302"), undefined);
t.is(await cache.match("http://localhost/404"), undefined);

// Check 2xx responses expire after 2 seconds
clock.timestamp += 1000;
t.is(await cache.match("http://localhost/200"), undefined);
t.is(await cache.match("http://localhost/201"), undefined);
t.not(await cache.match("http://localhost/302"), undefined);

// Check 302 response expires after 5 seconds
clock.timestamp += 3000;
t.is(await cache.match("http://localhost/302"), undefined);
});

test("Cache: put waits for output gate to open before storing", (t) => {
const { cache } = t.context;
Expand All @@ -136,7 +207,10 @@ test("Cache: put waits for input gate to open before returning", (t) => {

const matchMacro: Macro<[RequestInfo], Context> = async (t, req) => {
const { cache } = t.context;
await cache.put(new Request("http://localhost:8787/test"), testResponse());
await cache.put(
new BaseRequest("http://localhost:8787/test"),
testResponse()
);

const cached = await cache.match(req);
t.not(cached, undefined);
Expand All @@ -153,14 +227,17 @@ const matchMacro: Macro<[RequestInfo], Context> = async (t, req) => {
t.is(await cached?.text(), "value");
};
matchMacro.title = (providedTitle) => `Cache: matches ${providedTitle}`;
test("request", matchMacro, new Request("http://localhost:8787/test"));
test("request", matchMacro, new BaseRequest("http://localhost:8787/test"));
test("string request", matchMacro, "http://localhost:8787/test");
test("url request", matchMacro, new URL("http://localhost:8787/test"));

test("Cache: only matches non-GET requests when ignoring method", async (t) => {
const { cache } = t.context;
await cache.put(new Request("http://localhost:8787/test"), testResponse());
const req = new Request("http://localhost:8787/test", { method: "POST" });
await cache.put(
new BaseRequest("http://localhost:8787/test"),
testResponse()
);
const req = new BaseRequest("http://localhost:8787/test", { method: "POST" });
t.is(await cache.match(req), undefined);
t.not(await cache.match(req, { ignoreMethod: true }), undefined);
});
Expand All @@ -177,21 +254,27 @@ test("Cache: match HIT waits for input gate to open before returning", async (t)

const deleteMacro: Macro<[RequestInfo], Context> = async (t, req) => {
const { storage, cache } = t.context;
await cache.put(new Request("http://localhost:8787/test"), testResponse());
await cache.put(
new BaseRequest("http://localhost:8787/test"),
testResponse()
);
t.not(await storage.get("http://localhost:8787/test"), undefined);
t.true(await cache.delete(req));
t.is(await storage.get("http://localhost:8787/test"), undefined);
t.false(await cache.delete(req));
};
deleteMacro.title = (providedTitle) => `Cache: deletes ${providedTitle}`;
test("request", deleteMacro, new Request("http://localhost:8787/test"));
test("request", deleteMacro, new BaseRequest("http://localhost:8787/test"));
test("string request", deleteMacro, "http://localhost:8787/test");
test("url request", deleteMacro, new URL("http://localhost:8787/test"));

test("Cache: only deletes non-GET requests when ignoring method", async (t) => {
const { cache } = t.context;
await cache.put(new Request("http://localhost:8787/test"), testResponse());
const req = new Request("http://localhost:8787/test", { method: "POST" });
await cache.put(
new BaseRequest("http://localhost:8787/test"),
testResponse()
);
const req = new BaseRequest("http://localhost:8787/test", { method: "POST" });
t.false(await cache.delete(req));
t.true(await cache.delete(req, { ignoreMethod: true }));
});
Expand All @@ -217,7 +300,7 @@ const expireMacro: Macro<
> = async (t, { headers, expectedTtl }) => {
const { clock, cache } = t.context;
await cache.put(
new Request("http://localhost:8787/test"),
new BaseRequest("http://localhost:8787/test"),
new BaseResponse("value", { headers })
);
t.not(await cache.match("http://localhost:8787/test"), undefined);
Expand Down Expand Up @@ -246,7 +329,7 @@ const isCachedMacro: Macro<
> = async (t, { headers, cached }) => {
const { storage, cache } = t.context;
await cache.put(
new Request("http://localhost:8787/test"),
new BaseRequest("http://localhost:8787/test"),
new BaseResponse("value", {
headers: {
...headers,
Expand Down
Loading

0 comments on commit fd5395e

Please sign in to comment.