Use Cloudflare Cache API as one of the cache backend (#59)

* Implement cache for cloudflare

* Start using it

* Fix cache use

* Generate proper URLs

* Comment

* Fix cache always resulting in cache miss

* Log

* Correctly cancel redis

* Write to fallback caches

* Disable memory cache

* Re-enable memory cache

* Try patching

* Use it

* Also patch package.json

* Fix TS errors

* Remove yarn.lock

* Fix local error

* Lint

* Try bunx to fix vercel build

* Update lockfile

* Add back revalidating
This commit is contained in:
Samy Pessé
2023-12-22 11:05:14 +01:00
committed by GitHub
parent c1d3f990f1
commit 215c724a9b
13 changed files with 361 additions and 111 deletions
BIN
View File
Binary file not shown.
+4 -1
View File
@@ -10,7 +10,8 @@
"lint": "next lint",
"format": "prettier ./ --ignore-unknown --write",
"format:check": "prettier ./ --ignore-unknown --list-different",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"postinstall": "bunx patch-package"
},
"dependencies": {
"@geist-ui/icons": "^1.0.2",
@@ -46,6 +47,7 @@
"@argos-ci/cli": "^1.0.4",
"@argos-ci/puppeteer": "^1.2.1",
"@cloudflare/next-on-pages": "^1.7.3",
"@cloudflare/workers-types": "^4.20231218.0",
"@types/js-cookie": "^3.0.6",
"@types/jsontoxml": "^1.0.5",
"@types/katex": "^0.16.5",
@@ -59,6 +61,7 @@
"eslint": "^8",
"eslint-config-next": "13.5.6",
"eslint-plugin-import": "^2.29.0",
"patch-package": "^8.0.0",
"postcss": "^8",
"prettier": "^3.0.3",
"psi": "^4.1.0",
@@ -0,0 +1,89 @@
diff --git a/node_modules/@cloudflare/next-on-pages/dist/helpers/getRequestCloudflareContext.d.ts b/node_modules/@cloudflare/next-on-pages/dist/helpers/getRequestCloudflareContext.d.ts
new file mode 100644
index 0000000..e575127
--- /dev/null
+++ b/node_modules/@cloudflare/next-on-pages/dist/helpers/getRequestCloudflareContext.d.ts
@@ -0,0 +1,11 @@
+/// <reference types="@cloudflare/workers-types" />
+/**
+ * returns the request's execution context (usually referred as ctx).
+ *
+ * Note:
+ * This function throws when run on the client, where there is execution context.
+ * This function returns the mocked execution context in non pages environments.
+ *
+ * @returns the request's execution context
+ */
+export declare const getRequestCloudflareContext: () => { ctx: ExecutionContext, cf: IncomingRequestCfProperties<any> };
diff --git a/node_modules/@cloudflare/next-on-pages/dist/helpers/index.d.ts b/node_modules/@cloudflare/next-on-pages/dist/helpers/index.d.ts
new file mode 100644
index 0000000..4a415d2
--- /dev/null
+++ b/node_modules/@cloudflare/next-on-pages/dist/helpers/index.d.ts
@@ -0,0 +1 @@
+export * from './getRequestCloudflareContext';
diff --git a/node_modules/@cloudflare/next-on-pages/dist/helpers/index.js b/node_modules/@cloudflare/next-on-pages/dist/helpers/index.js
new file mode 100644
index 0000000..c6e2065
--- /dev/null
+++ b/node_modules/@cloudflare/next-on-pages/dist/helpers/index.js
@@ -0,0 +1,15 @@
+// src/helpers/getRequestExecutionContext.ts
+var getRequestCloudflareContext = () => {
+ if (typeof process === "undefined") {
+ throw new Error(
+ "Error: trying to access the request execution context on the client"
+ );
+ }
+ const cloudflare = process.env.cloudflare;
+ if (cloudflare)
+ return cloudflare;
+ return null;
+ };
+ export {
+ getRequestCloudflareContext
+ };
\ No newline at end of file
diff --git a/node_modules/@cloudflare/next-on-pages/package.json b/node_modules/@cloudflare/next-on-pages/package.json
index 494e9a9..f0e1e21 100644
--- a/node_modules/@cloudflare/next-on-pages/package.json
+++ b/node_modules/@cloudflare/next-on-pages/package.json
@@ -3,12 +3,16 @@
"version": "1.8.2",
"bin": "./bin/index.js",
"exports": {
- "./__experimental__next-dev": "./dist/next-dev/index.cjs"
+ "./__experimental__next-dev": "./dist/next-dev/index.cjs",
+ "./helpers": "./dist/helpers/index.js"
},
"typesVersions": {
"*": {
"__experimental__next-dev": [
"./dist/next-dev/index.d.ts"
+ ],
+ "helpers": [
+ "./dist/helpers/index.d.ts"
]
}
},
diff --git a/node_modules/@cloudflare/next-on-pages/templates/_worker.js/index.ts b/node_modules/@cloudflare/next-on-pages/templates/_worker.js/index.ts
index 929f6cc..7164930 100644
--- a/node_modules/@cloudflare/next-on-pages/templates/_worker.js/index.ts
+++ b/node_modules/@cloudflare/next-on-pages/templates/_worker.js/index.ts
@@ -33,9 +33,15 @@ export default {
return new Response(responseBody, { status: 503 });
}
+ const cloudflare = {
+ cf: request.cf,
+ env,
+ ctx,
+ };
+
return envAsyncLocalStorage.run(
// NOTE: The `SUSPENSE_CACHE_URL` is used to tell the Next.js Fetch Cache where to send requests.
- { ...env, NODE_ENV: __NODE_ENV__, SUSPENSE_CACHE_URL },
+ { ...env, cloudflare, NODE_ENV: __NODE_ENV__, SUSPENSE_CACHE_URL },
async () => {
const url = new URL(request.url);
if (url.pathname.startsWith('/_next/image')) {
+6 -1
View File
@@ -3,12 +3,17 @@ import { revalidateTags } from '@/lib/cache';
export const runtime = 'edge';
interface JsonBody {
tags: string[];
purge?: boolean;
}
/**
* Revalidate cached data based on tags.
* The body should be a JSON with { tags: string[] }
*/
export async function POST(req: NextRequest) {
const json = await req.json();
const json = (await req.json()) as JsonBody;
if (!json.tags || !Array.isArray(json.tags)) {
return NextResponse.json(
+1 -1
View File
@@ -56,7 +56,7 @@ async function fetchVisitorID(): Promise<string> {
mode: 'cors', // Need to use cors as we are on a different domain.
});
const { deviceId } = await resp.json();
const { deviceId } = (await resp.json()) as { deviceId: string };
return deviceId;
} catch (error) {
return proposed;
+10
View File
@@ -0,0 +1,10 @@
import { cloudflareCache } from './cloudflare';
import { memoryCache } from './memory';
import { redisCache } from './redis';
export const cacheBackends = [
memoryCache,
redisCache,
// Cloudflare should be last to delete its cache from the listing of redis/memory
cloudflareCache,
];
+42 -28
View File
@@ -1,8 +1,8 @@
import hash from 'object-hash';
import { memoryCache } from './memory';
import { redisCache } from './redis';
import { cacheBackends } from './backends';
import { CacheEntry } from './types';
import { waitUntil } from './waitUntil';
export type CacheFunction<Args extends any[], Result> = ((...args: Args) => Promise<Result>) & {
/**
@@ -62,10 +62,8 @@ export function cache<Args extends any[], Result>(
};
// Write it to the cache
// As soon as it'll be possible with next-on-pages, we should `waitUntil`
// to delay writing the cache after the response has been sent to the client.
if (result.ttl && result.ttl > 0) {
await setCacheEntry(key, cacheEntry);
await waitUntil(setCacheEntry(key, cacheEntry));
}
const writeCacheDuration = now() - startTime - fetchDuration;
@@ -79,18 +77,15 @@ export function cache<Args extends any[], Result>(
const fetchValue = async (key: string, ...args: Args) => {
// Read the cache
const startTime = now();
const hasMemoryHit = !!(await memoryCache.get(key));
const cachedEntry = await getCacheEntry(key);
const readCacheDuration = now() - startTime;
// Returns it if it exists
if (cachedEntry !== null) {
console.log(
`cache: ${key} hit in ${readCacheDuration.toFixed(
0,
)}ms (memory: ${hasMemoryHit}, redis: ${!!redisCache})`,
`cache: ${key} hit on ${cachedEntry[1]} in ${readCacheDuration.toFixed(0)}ms`,
);
return cachedEntry.data;
return cachedEntry[0].data;
}
const fetched = await revalidate(key, ...args);
@@ -99,7 +94,7 @@ export function cache<Args extends any[], Result>(
0,
)}ms, read in ${readCacheDuration.toFixed(
0,
)}ms, write in ${fetched.writeCacheDuration.toFixed(0)}ms (redis: ${!!redisCache})`,
)}ms, write in ${fetched.writeCacheDuration.toFixed(0)}ms`,
);
return fetched.data;
@@ -153,7 +148,10 @@ export function getCache(name: string): CacheFunction<any[], any> | null {
return registeredCaches.get(name) ?? null;
}
function getCacheKey(fnName: string, args: any[]) {
/**
* Get a cache key for a function and its arguments.
*/
export function getCacheKey(fnName: string, args: any[]) {
let innerKey = args.map((arg) => JSON.stringify(arg)).join(',');
// Avoid crazy long keys, by fallbacking to a hash
@@ -165,26 +163,42 @@ function getCacheKey(fnName: string, args: any[]) {
}
async function setCacheEntry(key: string, entry: CacheEntry) {
await Promise.all([memoryCache.set(key, entry), redisCache?.set(key, entry)]);
await Promise.all(cacheBackends.map((backend) => backend.set(key, entry)));
}
async function getCacheEntry(key: string): Promise<CacheEntry | null> {
const memoryEntry = await memoryCache.get(key);
if (memoryEntry) {
return memoryEntry;
async function getCacheEntry(key: string): Promise<[CacheEntry, string] | null> {
const abort = new AbortController();
let result: [CacheEntry, string] | null = null;
await Promise.all(
cacheBackends.map(async (backend) => {
try {
const entry = await backend.get(key, { signal: abort.signal });
if (entry && !result) {
result = [entry, backend.name];
abort.abort();
}
} catch (error) {
// Ignore all errors
}
}),
);
// Write to the fallback caches
if (result) {
const [savedEntry, backendName] = result as [CacheEntry, string];
await waitUntil(
Promise.all(
cacheBackends
.filter((backend) => backend.name !== backendName && backend.fallback)
.map((backend) => backend.set(key, savedEntry)),
),
);
}
try {
const redisEntry = (await redisCache?.get(key)) ?? null;
if (redisEntry) {
await memoryCache.set(key, redisEntry);
}
return redisEntry;
} catch (error) {
console.error(`Error while getting cache entry for ${key} from redis`, error);
return null;
}
return result;
}
function now(): number {
+77
View File
@@ -0,0 +1,77 @@
import { Buffer } from 'node:buffer';
import type { CacheStorage, Cache, Response as WorkerResponse } from '@cloudflare/workers-types';
import { CacheBackend, CacheEntry } from './types';
/**
* Cache implementation using the Cloudflare Cache API.
* https://developers.cloudflare.com/workers/runtime-apis/cache/
*/
export const cloudflareCache: CacheBackend = {
name: 'cloudflare',
fallback: true,
async get(key, options) {
const cache = getCache();
if (!cache) {
return null;
}
const cacheKey = await serializeKey(key);
const response = await cache.match(cacheKey);
if (!response || options?.signal?.aborted) {
return null;
}
const entry = await deserializeEntry(response);
return entry;
},
async set(key, entry) {
const cache = getCache();
if (cache) {
const cacheKey = await serializeKey(key);
await cache.put(cacheKey, serializeEntry(entry));
}
},
async revalidateTags(tags) {
return [];
},
};
function getCache(): Cache | null {
if (typeof caches === 'undefined') {
return null;
}
// @ts-ignore
return (caches as CacheStorage).default ?? null;
}
async function serializeKey(key: string): Promise<string> {
const digest = await crypto.subtle.digest(
{
name: 'SHA-256',
},
new TextEncoder().encode(key),
);
const hash = Buffer.from(digest).toString('base64');
return `gitbook://gitbook.com/${hash}`;
}
function serializeEntry(entry: CacheEntry): WorkerResponse {
const headers = new Headers();
headers.set('Content-Type', 'application/json');
headers.set('Cache-Control', `public, max-age=${(entry.meta.expiresAt - Date.now()) / 1000}`);
// @ts-ignore
return new Response(JSON.stringify(entry), {
headers,
});
}
async function deserializeEntry(response: WorkerResponse): Promise<CacheEntry> {
const entry = (await response.json()) as CacheEntry;
return entry;
}
+2
View File
@@ -1,6 +1,8 @@
import { CacheBackend, CacheEntry } from './types';
export const memoryCache: CacheBackend = {
name: 'memory',
fallback: true,
async get(key) {
const memoryCache = getMemoryCache();
const memoryEntry = memoryCache.get(key);
+91 -74
View File
@@ -3,98 +3,115 @@ import { Redis } from '@upstash/redis/cloudflare';
import { CacheBackend, CacheEntry, CacheEntryMeta } from './types';
import { filterOutNullable } from '../typescript';
const redis =
process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN
? new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
})
: null;
const cacheNamespace = process.env.UPSTASH_REDIS_NAMESPACE ?? 'gitbook';
const cacheVersion = 1;
export const redisCache: CacheBackend | null = redis
? {
async get(key) {
try {
const [, redisEntry] = await redis
.multi()
.json.numincrby(getRedisKey(key), '$.meta.hits', 1)
.json.get(getRedisKey(key))
.exec<[any, CacheEntry | null]>();
if (!redisEntry) {
return null;
}
export const redisCache: CacheBackend = {
name: 'redis',
async get(key, options) {
const redis = getRedis(options?.signal);
if (!redis) {
return null;
}
return redisEntry;
} catch (error) {
// "JSON.NUMINCRBY" throws an error if the key does not exist
if ((error as Error).message.includes('ERR no such key')) {
return null;
}
try {
const [, redisEntry] = await redis
.multi()
.json.numincrby(getRedisKey(key), '$.meta.hits', 1)
.json.get(getRedisKey(key))
.exec<[any, CacheEntry | null]>();
if (!redisEntry) {
return null;
}
throw error;
}
},
return redisEntry;
} catch (error) {
// "JSON.NUMINCRBY" throws an error if the key does not exist
if ((error as Error).message.includes('ERR no such key')) {
return null;
}
async set(key, entry) {
const multi = redis.multi();
throw error;
}
},
const redisKey = getRedisKey(key);
const expire = Math.max(0, (entry.meta.expiresAt - Date.now()) / 1000);
async set(key, entry) {
const redis = getRedis();
if (!redis) {
return;
}
entry.meta.tags.forEach((tag) => {
const redisTagKey = getCacheTagKey(tag);
const multi = redis.multi();
multi.sadd(redisTagKey, redisKey);
const redisKey = getRedisKey(key);
const expire = Math.max(0, (entry.meta.expiresAt - Date.now()) / 1000);
// Set am expiration on the tag to be the maximum of the expiration of all keys
multi.expire(redisTagKey, expire, 'GT');
multi.expire(redisTagKey, expire, 'NX');
});
entry.meta.tags.forEach((tag) => {
const redisTagKey = getCacheTagKey(tag);
// @ts-ignore
multi.json.set(redisKey, '$', entry);
multi.expire(redisKey, expire);
multi.sadd(redisTagKey, redisKey);
await multi.exec();
},
// Set am expiration on the tag to be the maximum of the expiration of all keys
multi.expire(redisTagKey, expire, 'GT');
multi.expire(redisTagKey, expire, 'NX');
});
async revalidateTags(tags) {
const keys = new Set(
(
await Promise.all(tags.map((tag) => redis.smembers(getCacheTagKey(tag))))
).flat(),
);
// @ts-ignore
multi.json.set(redisKey, '$', entry);
multi.expire(redisKey, expire);
const pipeline = redis.pipeline();
let metas: Array<CacheEntryMeta | null> = [];
await multi.exec();
},
if (keys.size > 0) {
metas = (
await redis.json.mget(
// Hard limit to avoid fetching a massive list of data
Array.from(keys).slice(0, 1000),
'$.meta',
)
).flat() as Array<CacheEntryMeta | null>;
async revalidateTags(tags) {
const redis = getRedis();
if (!redis) {
return [];
}
// Finally, delete all keys
keys.forEach((key) => {
pipeline.del(key);
});
}
const keys = new Set(
(await Promise.all(tags.map((tag) => redis.smembers(getCacheTagKey(tag))))).flat(),
);
tags.forEach((tag) => {
pipeline.del(getCacheTagKey(tag));
});
const pipeline = redis.pipeline();
let metas: Array<CacheEntryMeta | null> = [];
await pipeline.exec();
return metas.filter(filterOutNullable);
},
}
: null;
if (keys.size > 0) {
metas = (
await redis.json.mget(
// Hard limit to avoid fetching a massive list of data
Array.from(keys).slice(0, 1000),
'$.meta',
)
).flat() as Array<CacheEntryMeta | null>;
// Finally, delete all keys
keys.forEach((key) => {
pipeline.del(key);
});
}
tags.forEach((tag) => {
pipeline.del(getCacheTagKey(tag));
});
await pipeline.exec();
return metas.filter(filterOutNullable);
},
};
/**
* Get the redis client.
*/
export function getRedis(signal?: AbortSignal) {
return process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN
? new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
signal,
})
: null;
}
/**
* Get the key for a tag.
+17 -5
View File
@@ -1,8 +1,8 @@
import pMap from 'p-map';
import { getCache } from './cache';
import { memoryCache } from './memory';
import { redisCache } from './redis';
import { cacheBackends } from './backends';
import { getCache, getCacheKey } from './cache';
import { CacheEntryMeta } from './types';
/**
* Revalidate all values associated with tags.
@@ -13,9 +13,21 @@ export async function revalidateTags(tags: string[], purge: boolean): Promise<vo
return;
}
await memoryCache.revalidateTags(tags);
const processed = new Set<string>();
const metas: CacheEntryMeta[] = [];
const metas = await redisCache?.revalidateTags(tags);
await Promise.all(
cacheBackends.map(async (backend) => {
const addedMetas = await backend.revalidateTags(tags);
addedMetas.forEach((meta) => {
const key = getCacheKey(meta.cache, meta.args);
if (!processed.has(key)) {
metas.push(meta);
processed.add(key);
}
});
}),
);
// Refresh the values in the cache
if (metas && !purge) {
+8 -1
View File
@@ -31,10 +31,17 @@ export interface CacheEntry {
}
export interface CacheBackend {
name: string;
/**
* If true, we'll set entries in this cache that have been found in another cache.
*/
fallback?: boolean;
/**
* Get a value from the cache.
*/
get(key: string): Promise<CacheEntry | null>;
get(key: string, options?: { signal?: AbortSignal }): Promise<CacheEntry | null>;
/**
* Set a value in the cache.
+14
View File
@@ -0,0 +1,14 @@
import { getRequestCloudflareContext } from '@cloudflare/next-on-pages/helpers';
/**
* Extend the lifetime of the event handler until the promise is resolved.
* https://developers.cloudflare.com/workers/runtime-apis/handlers/fetch/#contextwaituntil
*/
export async function waitUntil(promise: Promise<unknown>) {
const cloudflare = getRequestCloudflareContext();
if (cloudflare) {
cloudflare.ctx.waitUntil(promise);
} else {
await promise;
}
}