From fd5395e71f40ccc7643fa69a21b85632de11253e Mon Sep 17 00:00:00 2001 From: MrBBot Date: Sun, 17 Oct 2021 15:31:36 +0100 Subject: [PATCH] Support `cacheKey`, `cacheTtl`, `cacheTtlByStatus`, closes #37 --- TODO.md | 3 +- packages/cache/src/cache.ts | 148 ++++++++++++++-------- packages/cache/test/cache.spec.ts | 111 ++++++++++++++-- packages/core/src/standards/cf.ts | 104 +++++++++++++++ packages/core/src/standards/http.ts | 9 +- packages/core/src/standards/index.ts | 1 + packages/core/test/standards/http.spec.ts | 9 +- packages/http-server/src/index.ts | 17 ++- 8 files changed, 327 insertions(+), 75 deletions(-) create mode 100644 packages/core/src/standards/cf.ts diff --git a/TODO.md b/TODO.md index 0b2cc0de4..ccd3110f0 100644 --- a/TODO.md +++ b/TODO.md @@ -13,6 +13,7 @@ - [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?) @@ -20,7 +21,7 @@ - [ ] 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 diff --git a/packages/cache/src/cache.ts b/packages/cache/src/cache.ts index 39463a604..005aa2957 100644 --- a/packages/cache/src/cache.ts +++ b/packages/cache/src/cache.ts @@ -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, @@ -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. @@ -31,9 +38,10 @@ function normaliseHeaders(headers: Headers): Record { 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) { @@ -43,6 +51,84 @@ function getKey(req: BaseRequest): string { } } +const cacheTtlByStatusRangeRegexp = /^(?\d+)(-(?\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; readonly #clock: Clock; @@ -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, diff --git a/packages/cache/test/cache.spec.ts b/packages/cache/test/cache.spec.ts index 80c295f94..94a2c480e 100644 --- a/packages/cache/test/cache.spec.ts +++ b/packages/cache/test/cache.spec.ts @@ -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, @@ -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"; @@ -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")); @@ -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() ), { @@ -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; @@ -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); @@ -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); }); @@ -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 })); }); @@ -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); @@ -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, diff --git a/packages/core/src/standards/cf.ts b/packages/core/src/standards/cf.ts new file mode 100644 index 000000000..b3c7f7fb4 --- /dev/null +++ b/packages/core/src/standards/cf.ts @@ -0,0 +1,104 @@ +// Extracted from @cloudflare/workers-types: +// https://github.com/cloudflare/workers-types/blob/master/index.d.ts +// TODO (someday): maybe just use @cloudflare/workers-types here? + +export interface BasicImageTransformations { + width?: number; + height?: number; + fit?: "scale-down" | "contain" | "cover" | "crop" | "pad"; + gravity?: + | "left" + | "right" + | "top" + | "bottom" + | "center" + | BasicImageTransformationsGravityCoordinates; + background?: string; + rotate?: 0 | 90 | 180 | 270 | 360; +} + +export interface BasicImageTransformationsGravityCoordinates { + x: number; + y: number; +} + +export interface IncomingRequestCfProperties { + asn: number; + botManagement?: IncomingRequestCfPropertiesBotManagement; + city?: string; + clientTcpRtt: number; + clientTrustScore?: number; + colo: string; + continent?: string; + country: string; + httpProtocol: string; + latitude?: string; + longitude?: string; + metroCode?: string; + postalCode?: string; + region?: string; + regionCode?: string; + requestPriority: string; + timezone?: string; + tlsVersion: string; + tlsCipher: string; + tlsClientAuth: IncomingRequestCfPropertiesTLSClientAuth; +} + +export interface IncomingRequestCfPropertiesBotManagement { + score: number; + staticResource: boolean; + verifiedBot: boolean; +} + +export interface IncomingRequestCfPropertiesTLSClientAuth { + certIssuerDNLegacy: string; + certIssuerDN: string; + certPresented: "0" | "1"; + certSubjectDNLegacy: string; + certSubjectDN: string; + certNotBefore: string; + certNotAfter: string; + certSerial: string; + certFingerprintSHA1: string; + certVerified: string; +} + +export interface RequestInitCfProperties { + cacheEverything?: boolean; + cacheKey?: string; + cacheTtl?: number; + cacheTtlByStatus?: Record; + scrapeShield?: boolean; + apps?: boolean; + image?: RequestInitCfPropertiesImage; + minify?: RequestInitCfPropertiesImageMinify; + mirage?: boolean; + resolveOverride?: string; +} + +export interface RequestInitCfPropertiesImage + extends BasicImageTransformations { + dpr?: number; + quality?: number; + format?: "avif" | "webp" | "json"; + metadata?: "keep" | "copyright" | "none"; + draw?: RequestInitCfPropertiesImageDraw[]; +} + +export interface RequestInitCfPropertiesImageDraw + extends BasicImageTransformations { + url: string; + opacity?: number; + repeat?: true | "x" | "y"; + top?: number; + left?: number; + bottom?: number; + right?: number; +} + +export interface RequestInitCfPropertiesImageMinify { + javascript?: boolean; + css?: boolean; + html?: boolean; +} diff --git a/packages/core/src/standards/http.ts b/packages/core/src/standards/http.ts index bd0e90afc..860f52afb 100644 --- a/packages/core/src/standards/http.ts +++ b/packages/core/src/standards/http.ts @@ -35,6 +35,7 @@ import { RequestRedirect, ResponseRedirectStatus, } from "undici/types/fetch"; +import { IncomingRequestCfProperties, RequestInitCfProperties } from "./cf"; // Instead of subclassing our customised Request and Response classes from // BaseRequest and BaseResponse, we instead compose them and implement the same @@ -147,7 +148,7 @@ export function withInputGating>( export type RequestInfo = BaseRequestInfo | Request; export interface RequestInit extends BaseRequestInit { - readonly cf?: any; // TODO: type properly + readonly cf?: IncomingRequestCfProperties | RequestInitCfProperties; } export class Request @@ -155,9 +156,10 @@ export class Request implements BaseRequest { // noinspection TypeScriptFieldCanBeMadeReadonly - #cf?: any; + #cf?: IncomingRequestCfProperties | RequestInitCfProperties; constructor(input: RequestInfo, init?: RequestInit) { + const cf = input instanceof Request ? input.#cf : init?.cf; if (input instanceof BaseRequest && !init) { // For cloning super(input); @@ -166,7 +168,6 @@ export class Request if (input instanceof Request) input = input[kInner]; super(new BaseRequest(input, init)); } - const cf = input instanceof Request ? input.#cf : init?.cf; this.#cf = cf ? nonCircularClone(cf) : undefined; } @@ -177,7 +178,7 @@ export class Request return clone; } - get cf(): any | undefined { + get cf(): IncomingRequestCfProperties | RequestInitCfProperties | undefined { return this.#cf; } diff --git a/packages/core/src/standards/index.ts b/packages/core/src/standards/index.ts index d4302f84c..358c97a3c 100644 --- a/packages/core/src/standards/index.ts +++ b/packages/core/src/standards/index.ts @@ -1,3 +1,4 @@ +export * from "./cf"; export * from "./crypto"; export * from "./domexception"; export * from "./encoding"; diff --git a/packages/core/test/standards/http.spec.ts b/packages/core/test/standards/http.spec.ts index 9b85e3378..54f563391 100644 --- a/packages/core/test/standards/http.spec.ts +++ b/packages/core/test/standards/http.spec.ts @@ -2,6 +2,7 @@ import assert from "assert"; import { text } from "stream/consumers"; import { ReadableStream, TransformStream, WritableStream } from "stream/web"; import { + IncomingRequestCfProperties, InputGatedBody, Request, Response, @@ -26,6 +27,9 @@ import { BodyMixin, } from "undici"; +// @ts-expect-error filling out all properties is annoying +const cf: IncomingRequestCfProperties = { country: "GB" }; + // These tests also implicitly test withInputGating test("InputGatedBody: body isn't input gated by default", async (t) => { const inputGate = new InputGate(); @@ -159,17 +163,19 @@ test("Request: can construct new Request from existing Request", async (t) => { const req = new Request("http://localhost", { method: "POST", body: "body", + cf, }); const req2 = new Request(req); // Should be different as new instance created t.not(req2.headers, req.headers); t.not(req2.body, req.body); + t.not(req2.cf, req.cf); t.is(req2.method, "POST"); t.is(await req2.text(), "body"); + t.deepEqual(req2.cf, cf); }); test("Request: supports non-standard properties", (t) => { - const cf = { country: "GB" }; const req = new Request("http://localhost", { method: "POST", cf, @@ -180,7 +186,6 @@ test("Request: supports non-standard properties", (t) => { t.not(req.cf, cf); }); test("Request: clones non-standard properties", (t) => { - const cf = { country: "GB" }; const req = new Request("http://localhost", { method: "POST", cf, diff --git a/packages/http-server/src/index.ts b/packages/http-server/src/index.ts index a064cada6..f9d2470e7 100644 --- a/packages/http-server/src/index.ts +++ b/packages/http-server/src/index.ts @@ -5,6 +5,7 @@ import { arrayBuffer } from "stream/consumers"; import { URL } from "url"; import { CorePluginSignatures, + IncomingRequestCfProperties, MiniflareCore, Request, Response, @@ -46,6 +47,8 @@ export async function convertNodeRequest( headers.append(name, Array.isArray(value) ? value.join(", ") : value); } + // TODO: get from https://workers.cloudflare.com/cf.json + // Add additional Cloudflare specific headers: // https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers- let ip = req.socket.remoteAddress; @@ -59,7 +62,7 @@ export async function convertNodeRequest( // Create Request with additional Cloudflare specific properties: // https://developers.cloudflare.com/workers/runtime-apis/request#incomingrequestcfproperties - const cf = { + const cf: IncomingRequestCfProperties = { asn: 395747, colo: "DFW", @@ -79,6 +82,18 @@ export async function convertNodeRequest( requestPriority: "weight=192;exclusive=0", tlsCipher: "AEAD-AES128-GCM-SHA256", tlsVersion: "TLSv1.3", + tlsClientAuth: { + certIssuerDNLegacy: "", + certIssuerDN: "", + certPresented: "0", + certSubjectDNLegacy: "", + certSubjectDN: "", + certNotBefore: "", + certNotAfter: "", + certSerial: "", + certFingerprintSHA1: "", + certVerified: "NONE", + }, }; const request = new Request(url, { method: req.method, headers, body, cf });