Skip to content

Commit

Permalink
Initial implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
lucacasonato committed Mar 3, 2021
1 parent ef4dd60 commit 47a68f7
Show file tree
Hide file tree
Showing 8 changed files with 259 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"deno.enable": true,
"deno.lint": true,
"deno.unstable": true
}
49 changes: 47 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,47 @@
# deno_webcache
Web Cache API in Deno - in memory, on disk, and redis storage support
# deno_httpcache

HTTP Caching for Deno - in memory and redis storage support. Inspired by the
Service Worker Cache API.

## Usage

Setting, getting, and deleting items in the cache:

```ts
import { inMemoryCache } from "https://deno.land/x/[email protected]/in_memory.ts";

const cache = inMemoryCache(5);

const req = new Request("https://deno.land/[email protected]/version.ts");
const resp = await fetch(req);

await cache.set(req, resp);

const cachedResp = await cache.get(req); // or `cache.get(req.url)`
if (cachedResp === undefined) throw new Error("Response not found in cache");
console.log(cachedResp.status); // 200
console.log(cachedResp.headers.get("content-type")); // application/typescript; charset=utf-8

await cache.remove(req); // or `cache.remove(req.url)`
console.log(await cache.get(req)); // undefined
```

And with redis:

```ts
import { redisCache } from "https://deno.land/x/[email protected]/redis.ts";

const cache = await redisCache("redis://127.0.0.1:6379");

// you can also optionally specify a prefix to use for the cache key:
const cache = await redisCache("redis://127.0.0.1:6379", "v1-");
```

## Contributing

Before submitting a PR, please run these three steps and check that they pass.

1. `deno fmt`
2. `deno lint --unstable`
3. `deno test --allow-net` _this requires you to have a redis server running at
127.0.0.1:6379_
23 changes: 23 additions & 0 deletions in_memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { LRU } from "https://deno.land/x/[email protected]/mod.ts";
import { Cache, CachedResponse } from "./mod.ts";
export { Cache };

export function inMemoryCache(capacity: number): Cache {
const lru = new LRU<CachedResponse>(capacity);
return new Cache({
get(url) {
return Promise.resolve(lru.get(url));
},
set(url, resp) {
lru.set(url, resp);
return Promise.resolve();
},
delete(url) {
lru.remove(url);
return Promise.resolve();
},
close() {
lru.clear();
},
});
}
33 changes: 33 additions & 0 deletions in_memory_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { inMemoryCache } from "./in_memory.ts";
import { assert, assertEquals } from "./test_deps.ts";

Deno.test("[in memory] cache, retrieve, delete", async () => {
const cache = inMemoryCache(5);
try {
const originalResp = new Response("Hello World", {
status: 200,
headers: {
"server": "deno",
"cache-control": "public, max-age=604800, immutable",
},
});

await cache.put("https://deno.land", originalResp);

const cachedResp = await cache.match("https://deno.land");
assert(cachedResp);
assertEquals(originalResp.status, cachedResp.status);
assertEquals(
originalResp.headers.get("server"),
cachedResp.headers.get("server"),
);
assertEquals(await cachedResp.text(), "Hello World");

await cache.delete("https://deno.land");

const otherCachedResp = await cache.match("https://deno.land");
assert(otherCachedResp === undefined);
} finally {
cache.close();
}
});
78 changes: 78 additions & 0 deletions mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import CachePolicy from "https://cdn.skypack.dev/http-cache-semantics?dts";

function cacheRequest(req: Request): CachePolicy.Request {
return {
url: req.url,
headers: Object.fromEntries(req.headers.entries()),
method: req.method,
};
}

export interface CachedResponse {
body: Uint8Array;
policy: CachePolicy.CachePolicyObject;
}

export interface CacheStorage {
get(url: string): Promise<CachedResponse | undefined>;
set(url: string, resp: CachedResponse): Promise<void>;
delete(url: string): Promise<void>;
close(): void;
}

export class Cache {
#storage: CacheStorage;

constructor(storage: CacheStorage) {
this.#storage = storage;
}

close() {
this.#storage.close();
}

async match(request: RequestInfo): Promise<Response | undefined> {
const req = request instanceof Request ? request : new Request(request);

const cached = await this.#storage.get(req.url);
if (cached === undefined) return Promise.resolve(undefined);

const policy = CachePolicy.fromObject(cached.policy);

const usable = policy.satisfiesWithoutRevalidation(cacheRequest(req));
if (!usable) return Promise.resolve(undefined);

const resp = new Response(cached.body, {
headers: policy.responseHeaders() as Record<string, string>,
status: cached.policy.st,
});

return Promise.resolve(resp);
}

async put(request: RequestInfo, response: Response): Promise<void> {
const req = request instanceof Request ? request : new Request(request);

const status = response.status;
const headers = Object.fromEntries(response.headers.entries());

const policy = new CachePolicy(cacheRequest(req), { status, headers }, {
shared: true,
});

if (!policy.storable()) return;

const body = await response.arrayBuffer();

await this.#storage.set(req.url, {
body: new Uint8Array(body),
policy: policy.toObject(),
});
}

async delete(request: RequestInfo): Promise<void> {
const req = request instanceof Request ? request : new Request(request);
await this.#storage.delete(req.url);
return Promise.resolve();
}
}
36 changes: 36 additions & 0 deletions redis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { connect, parseURL } from "https://deno.land/x/[email protected]/mod.ts";
import {
decode,
encode,
} from "https://deno.land/[email protected]/encoding/base64.ts";
import { Cache } from "./mod.ts";
export { Cache };

export async function redisCache(
databaseUrl: string,
prefix = "",
): Promise<Cache> {
const conn = await connect(parseURL(databaseUrl));
return new Cache({
async get(url) {
const bulk = await conn.get(prefix + url);
if (!bulk) return undefined;
const [policyBase64, bodyBase64] = bulk.split("\n");
const policy = JSON.parse(atob(policyBase64));
const body = decode(bodyBase64);
return { policy, body };
},
async set(url, resp) {
const policyBase64 = btoa(JSON.stringify(resp.policy));
const bodyBase64 = encode(resp.body);
// TODO(lucacasonato): add ttl
await conn.set(prefix + url, `${policyBase64}\n${bodyBase64}`);
},
async delete(url) {
await conn.del(prefix + url);
},
close() {
conn.close();
},
});
}
33 changes: 33 additions & 0 deletions redis_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { redisCache } from "./redis.ts";
import { assert, assertEquals } from "./test_deps.ts";

Deno.test("[redis] cache, retrieve, delete", async () => {
const cache = await redisCache("redis://127.0.0.1:6379", "cache-");
try {
const originalResp = new Response("Hello World", {
status: 200,
headers: {
"server": "deno",
"cache-control": "public, max-age=604800, immutable",
},
});

await cache.put("https://deno.land", originalResp);

const cachedResp = await cache.match("https://deno.land");
assert(cachedResp);
assertEquals(originalResp.status, cachedResp.status);
assertEquals(
originalResp.headers.get("server"),
cachedResp.headers.get("server"),
);
assertEquals(await cachedResp.text(), "Hello World");

await cache.delete("https://deno.land");

const otherCachedResp = await cache.match("https://deno.land");
assert(otherCachedResp === undefined);
} finally {
cache.close();
}
});
4 changes: 4 additions & 0 deletions test_deps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {
assert,
assertEquals,
} from "https://deno.land/[email protected]/testing/asserts.ts";

0 comments on commit 47a68f7

Please sign in to comment.