diff --git a/bun.lockb b/bun.lockb
index 819122a89..eabb20609 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index 1705abaef..679b74d36 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/patches/@cloudflare+next-on-pages+1.8.2.patch b/patches/@cloudflare+next-on-pages+1.8.2.patch
new file mode 100644
index 000000000..21c6fd78b
--- /dev/null
+++ b/patches/@cloudflare+next-on-pages+1.8.2.patch
@@ -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 @@
++///
++/**
++ * 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 };
+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')) {
diff --git a/src/app/.gitbook/revalidate/route.ts b/src/app/.gitbook/revalidate/route.ts
index 05da126d1..049cac473 100644
--- a/src/app/.gitbook/revalidate/route.ts
+++ b/src/app/.gitbook/revalidate/route.ts
@@ -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(
diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts
index 22ee78ed9..fcf4898fc 100644
--- a/src/lib/analytics.ts
+++ b/src/lib/analytics.ts
@@ -56,7 +56,7 @@ async function fetchVisitorID(): Promise {
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;
diff --git a/src/lib/cache/backends.ts b/src/lib/cache/backends.ts
new file mode 100644
index 000000000..e4e005df0
--- /dev/null
+++ b/src/lib/cache/backends.ts
@@ -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,
+];
diff --git a/src/lib/cache/cache.ts b/src/lib/cache/cache.ts
index 2965b013f..36b2ba7e0 100644
--- a/src/lib/cache/cache.ts
+++ b/src/lib/cache/cache.ts
@@ -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: Args) => Promise) & {
/**
@@ -62,10 +62,8 @@ export function cache(
};
// 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(
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(
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 | 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 {
- 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 {
diff --git a/src/lib/cache/cloudflare.ts b/src/lib/cache/cloudflare.ts
new file mode 100644
index 000000000..0f72c3edf
--- /dev/null
+++ b/src/lib/cache/cloudflare.ts
@@ -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 {
+ 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 {
+ const entry = (await response.json()) as CacheEntry;
+ return entry;
+}
diff --git a/src/lib/cache/memory.ts b/src/lib/cache/memory.ts
index 5bde5af8b..1ae482337 100644
--- a/src/lib/cache/memory.ts
+++ b/src/lib/cache/memory.ts
@@ -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);
diff --git a/src/lib/cache/redis.ts b/src/lib/cache/redis.ts
index 24ba11b28..ff9117981 100644
--- a/src/lib/cache/redis.ts
+++ b/src/lib/cache/redis.ts
@@ -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 = [];
+ 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;
+ 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 = [];
- 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;
+
+ // 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.
diff --git a/src/lib/cache/revalidateTags.ts b/src/lib/cache/revalidateTags.ts
index a92d5c24d..f4e138a65 100644
--- a/src/lib/cache/revalidateTags.ts
+++ b/src/lib/cache/revalidateTags.ts
@@ -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();
+ 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) {
diff --git a/src/lib/cache/types.ts b/src/lib/cache/types.ts
index 414d8a80b..e38c79b5d 100644
--- a/src/lib/cache/types.ts
+++ b/src/lib/cache/types.ts
@@ -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;
+ get(key: string, options?: { signal?: AbortSignal }): Promise;
/**
* Set a value in the cache.
diff --git a/src/lib/cache/waitUntil.ts b/src/lib/cache/waitUntil.ts
new file mode 100644
index 000000000..8ec6ba4f0
--- /dev/null
+++ b/src/lib/cache/waitUntil.ts
@@ -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) {
+ const cloudflare = getRequestCloudflareContext();
+ if (cloudflare) {
+ cloudflare.ctx.waitUntil(promise);
+ } else {
+ await promise;
+ }
+}