Skip to content
This repository has been archived by the owner on Sep 5, 2023. It is now read-only.

[WIP] http-cache middleware #15

Merged
merged 2 commits into from Jan 8, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -34,6 +34,7 @@
"homepage": "https://github.com/superfly/cdn#readme",
"dependencies": {
"@fly/fly": "^0.44.5",
"http-cache-semantics": "^4.0.1",
"lodash": "^4.17.11"
},
"peerDependencies": {},
Expand Down
1 change: 1 addition & 0 deletions src/config/middleware.ts
Expand Up @@ -9,6 +9,7 @@ const factories = new Map<string, FactoryDefinition>([
["https-upgrader", [middleware.httpsUpgrader]],
["response-headers", [middleware.responseHeaders]],
["inject-html", [middleware.injectHTML]],
["http-cache", [middleware.httpCache]]
]);

function getFactory(type: string): FactoryDefinition {
Expand Down
3 changes: 2 additions & 1 deletion src/middleware.ts
Expand Up @@ -3,4 +3,5 @@
*/
export * from "./middleware/https-upgrader";
export * from "./middleware/response-headers";
export * from "./middleware/inject-html";
export * from "./middleware/inject-html";
export * from "./middleware/http-cache";
195 changes: 195 additions & 0 deletions src/middleware/http-cache.ts
@@ -0,0 +1,195 @@
/**
* @module Middleware
*/
import cache from "@fly/v8env/lib/fly/cache";
import { FetchFunction } from "../fetch";

/**
* HTTP caching options.
*/
export interface HTTPCacheOptions{
/** Overrides the cache TTL for all cacheable requests */
overrideMaxAge?: number
}
/**
* Cache HTTP responses with `cache-control` headers.
*
* Basic example:
* ```typescript
* import httpCache from "./src/middleware/http-cache";
* import backends from "./src/backends";
*
* const glitch = backends.glitch("fly-example");
*
* const origin = httpCache(glitch, { overrideMaxAge: 3600 });
*
* fly.http.respondWith(origin);
* ```
*
* @param fetch
* @param options
*/
export function httpCache(fetch: FetchFunction, options?: HTTPCacheOptions): FetchFunction{
return async function httpCacheFetch(req: RequestInfo, init?: RequestInit): Promise<Response>{
if(!options) options = {};
if(typeof req === "string"){
req = new Request(req, init);
init = undefined;
}

// check the cache
let cacheable = true;
for(const h of ["Authorization", "Cookie"]){
if(req.headers.get(h)){
console.warn(h + " headers are not supported in http-cache")
cacheable = false;
}
}
let resp = cacheable ? await storage.match(req) : undefined;

if(resp){
// got a hit
resp.headers.set("Fly-Cache", "hit");
return resp;
}

resp = await fetch(req, init);

// this should do nothing if the response can't be cached
const cacheHappened = cacheable ? await storage.put(req, resp, options.overrideMaxAge) : false;

if(cacheHappened){
resp.headers.set("Fly-Cache", "miss");
}
return resp;
}
}

/**
* Configurable HTTP caching middleware. This is extremely useful within a `pipeline`:
*
* ```typescript
* const app = pipeline(
* httpsUpgrader,
* httpCaching.configure({overrideMaxAge: 3600}),
* glitch("fly-example")
* )
*
*/
httpCache.configure = (options?: HTTPCacheOptions) => {
return (fetch: FetchFunction) => httpCache(fetch, options)
}

// copied from fly v8env
const CachePolicy = require("http-cache-semantics");
/**
* export:
* match(req): res | null
* add(req): void
* put(req, res): void
* @private
*/

const storage = {
async match(req: Request) {
const hashed = hashData(req)
const key = "httpcache:policy:" + hashed // first try with no vary variant
for (let i = 0; i < 5; i++) {
const policyRaw = await cache.getString(key)
console.debug("Got policy:", key, policyRaw)
if (!policyRaw) {
return undefined
}
const policy = CachePolicy.fromObject(JSON.parse(policyRaw))

// if it fits i sits
if (policy.satisfiesWithoutRevalidation(req)) {
const headers = policy.responseHeaders()
const bodyKey = "httpcache:body:" + hashed

const body = await cache.get(bodyKey)
console.debug("Got body", body.constructor.name, body.byteLength)
return new Response(body, { status: policy._status, headers })
// }else if(policy._headers){
// TODO: try a new vary based key
// policy._headers has the varies / vary values
// key = hashData(req, policy._headers)
// return undefined
} else {
return undefined
}
}
return undefined // no matches found
},
async add(req: Request) {
console.debug("cache add")

const res = await fetch(req)
return await storage.put(req, res)
},
async put(req: Request, res: Response, ttl?: number): Promise<boolean> {
const resHeaders: any = {}
const key = hashData(req)

if(res.headers.get("vary")){
console.warn("Vary headers are not supported in http-cache")
return false;
}

for (const [name, value] of (res as any).headers) {
resHeaders[name] = value
}
const cacheableRes = {
status: res.status,
headers: resHeaders
}
const policy = new CachePolicy(
{
url: req.url,
method: req.method,
headers: req.headers || {}
},
cacheableRes
)

if(typeof ttl === "number"){
policy._rescc['max-age'] = ttl; // hack to make policy handle overridden ttl
console.warn("ttl:", ttl, "storable:", policy.storable());
}

ttl = typeof ttl === "number" ? ttl : Math.floor(policy.timeToLive() / 1000);
if (policy.storable() && ttl > 0) {
console.debug("Setting cache policy:", "httpcache:policy:" + key, ttl)
await cache.set("httpcache:policy:" + key, JSON.stringify(policy.toObject()), ttl)
const respBody = await res.arrayBuffer()
await cache.set("httpcache:body:" + key, respBody, ttl)
return true;
}
return false;
}
}

function hashData(req: Request) {
let toHash = ``

const u = normalizeURL(req.url)

toHash += u.toString()
toHash += req.method

// TODO: cacheable cookies
// TODO: cache version for grand busting

console.debug("hashData", toHash)
return (crypto as any).subtle.digestSync("sha-1", toHash, "hex")
}

function normalizeURL(u:string) {
const url = new URL(u)
url.hash = ""
const sp = url.searchParams
sp.sort()
url.search = sp.toString()

return url
}
94 changes: 94 additions & 0 deletions test/middleware/http-cache.spec.ts
@@ -0,0 +1,94 @@
import { expect } from "chai";
import { httpCache } from "../../src/middleware";

function withHeaders(headers:any){
return async function fetchWithHeaders(..._:any[]){
const resp = new Response(`response at: ${Date.now()}`)
for(const k of Object.getOwnPropertyNames(headers)){
const v = headers[k];
resp.headers.set(k, v);
}
return resp;
}
}

const noCacheHeaders = [
["response", {"vary": "any", "cache-control": "public, max-age=3600"}],
["response", {"cache-control": "no-cache"}],
["response", {"cache-control": "public, max-age=0"}],
["request", { "authorization": "blerp" }],
["request", { "cookie": "blerp" }]
]
describe("middleware/httpCache", function() {
for(const [type, h] of noCacheHeaders){
it(`doesn't cache ${type} headers: ` + JSON.stringify(h), async () => {
const responseHeaders = type === "response" ? h : { "cache-control": "public, max-age=3600"};
const fn = httpCache(
withHeaders(Object.assign({}, responseHeaders))
);

const requestHeaders = type === "request" ? h : {};
const resp = await fn(`http://anyurl.com/asdf-${Math.random()}`, { headers: requestHeaders});

expect(resp.status).to.eq(200);
expect(resp.headers.get("fly-cache")).is.null;
});
}

it("properly sets fly-cache to miss when cache happens", async () => {
const fn = httpCache(
withHeaders({
"cache-control": "public, max-age=3600"
})
);
const resp1 = await fn("http://anyurl.com/cached-url");
const resp2 = await fn("http://anyurl.com/cached-url");

expect(resp1.headers.get('fly-cache')).to.eq('miss');
expect(resp2.headers.get("fly-cache")).to.eq('hit')

const [body1, body2] = await Promise.all([
resp1.text(),
resp2.text()
]);

expect(body1).to.eq(body2);
})

it("accepts max-age overrides", async () => {
const generator = httpCache.configure({ overrideMaxAge: 100})
const fn = generator(withHeaders({
"cache-control": "public, max-age=0"
}))

const resp1 = await fn("http://anyurl.com/cached-url-max-age");
const resp2 = await fn("http://anyurl.com/cached-url-max-age");

expect(resp1.headers.get('fly-cache')).to.eq('miss');
expect(resp2.headers.get("fly-cache")).to.eq('hit')
})
// it("redirects with default options", async ()=>{
// const fn = httpsUpgrader(echo);
// const resp = await fn("http://wat/")
// expect(resp.status).to.eq(302)
// expect(resp.headers.get("location")).to.eq("https://wat/")
// })

// it("redirects with options", async () =>{
// const fn = httpsUpgrader(echo, {status: 307, text: "hurrdurr"});
// const resp = await fn("http://wat/")
// const body = await resp.text()
// expect(resp.status).to.eq(307)
// expect(body).to.eq("hurrdurr")
// expect(resp.headers.get("location")).to.eq("https://wat/")
// })

// it("skips redirect in dev mode", async () => {
// app.env = "development"
// const fn = httpsUpgrader(echo);
// const resp = await fn("http://wat/")
// expect(resp.status).to.eq(200)
// expect(resp.headers.get("location")).to.be.null
// app.env = "test"
// })
})