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; + } +}