From 636b868bf12b5f5cd82e2fe4d1e2a41f4512247e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Tue, 24 Sep 2024 12:55:49 +0200 Subject: [PATCH] Experimental new cache backend powered by DO (#2479) Co-authored-by: Johan Preynat --- .changeset/heavy-jeans-unite.md | 5 + .changeset/odd-candles-cover.md | 5 + .github/workflows/publish.yaml | 23 ++ bun.lockb | Bin 673984 -> 680712 bytes packages/cache-do/.gitignore | 3 + packages/cache-do/README.md | 22 + packages/cache-do/package.json | 42 ++ packages/cache-do/src/CacheObject.ts | 274 +++++++++++++ packages/cache-do/src/CacheObjectStub.ts | 91 +++++ packages/cache-do/src/api.ts | 1 + packages/cache-do/src/index.ts | 9 + packages/cache-do/tsconfig.json | 21 + packages/cache-do/wrangler.toml | 17 + packages/gitbook/cf-env.d.ts | 7 + packages/gitbook/package.json | 1 + .../app/(global)/~gitbook/revalidate/route.ts | 1 - packages/gitbook/src/lib/api.ts | 383 ++++++++++-------- packages/gitbook/src/lib/cache/backends.ts | 6 +- packages/gitbook/src/lib/cache/cache.test.ts | 16 +- packages/gitbook/src/lib/cache/cache.ts | 80 ++-- .../gitbook/src/lib/cache/cloudflare-cache.ts | 30 +- .../gitbook/src/lib/cache/cloudflare-do.ts | 101 +++++ .../gitbook/src/lib/cache/cloudflare-kv.ts | 42 +- packages/gitbook/src/lib/cache/http.ts | 1 - packages/gitbook/src/lib/cache/memory.ts | 25 +- packages/gitbook/src/lib/cache/redis.ts | 174 -------- .../gitbook/src/lib/cache/revalidateTags.ts | 62 ++- packages/gitbook/src/lib/cache/types.ts | 19 +- packages/gitbook/src/lib/cache/utils.ts | 2 +- packages/gitbook/src/lib/openapi.ts | 41 +- packages/gitbook/tsconfig.json | 2 +- turbo.json | 8 +- 32 files changed, 990 insertions(+), 524 deletions(-) create mode 100644 .changeset/heavy-jeans-unite.md create mode 100644 .changeset/odd-candles-cover.md create mode 100644 packages/cache-do/.gitignore create mode 100644 packages/cache-do/README.md create mode 100644 packages/cache-do/package.json create mode 100644 packages/cache-do/src/CacheObject.ts create mode 100644 packages/cache-do/src/CacheObjectStub.ts create mode 100644 packages/cache-do/src/api.ts create mode 100644 packages/cache-do/src/index.ts create mode 100644 packages/cache-do/tsconfig.json create mode 100644 packages/cache-do/wrangler.toml create mode 100644 packages/gitbook/cf-env.d.ts create mode 100644 packages/gitbook/src/lib/cache/cloudflare-do.ts delete mode 100644 packages/gitbook/src/lib/cache/redis.ts diff --git a/.changeset/heavy-jeans-unite.md b/.changeset/heavy-jeans-unite.md new file mode 100644 index 000000000..c37c39b78 --- /dev/null +++ b/.changeset/heavy-jeans-unite.md @@ -0,0 +1,5 @@ +--- +'@gitbook/cache-do': minor +--- + +First version of a new cache backend powered by Cloudflare Durable Objects diff --git a/.changeset/odd-candles-cover.md b/.changeset/odd-candles-cover.md new file mode 100644 index 000000000..27cb8fb8b --- /dev/null +++ b/.changeset/odd-candles-cover.md @@ -0,0 +1,5 @@ +--- +'gitbook': patch +--- + +Use new cache backend, powered by Durable Objects, alongside the existing ones (KV, etc). diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 2445ac1a6..c232ebd72 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -35,3 +35,26 @@ jobs: # https://github.com/orgs/community/discussions/26875#discussioncomment-3253761 GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + release-preview: + # For now it releases the cache-do to both preview and production + # Once we changed to deploy the app only on release, we should change `release:preview` in `cache-do` + name: Release Preview + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v3 + - name: Setup bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.1.18 + - name: Install dependencies + run: bun install --frozen-lockfile + env: + PUPPETEER_SKIP_DOWNLOAD: 1 + - name: Release preview packages + run: bun run release:preview + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/bun.lockb b/bun.lockb index 44eff239912c64e40187c641f7197d723fea1408..7da1719ec0874bd9a8a025c55624c70efe4589ce 100755 GIT binary patch delta 119086 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 diff --git a/packages/cache-do/.gitignore b/packages/cache-do/.gitignore new file mode 100644 index 000000000..9b16495b9 --- /dev/null +++ b/packages/cache-do/.gitignore @@ -0,0 +1,3 @@ +.wrangler +worker-configuration.d.ts +dist/ diff --git a/packages/cache-do/README.md b/packages/cache-do/README.md new file mode 100644 index 000000000..7a479a82d --- /dev/null +++ b/packages/cache-do/README.md @@ -0,0 +1,22 @@ +# `@gitbook/cache-do` + +Cache backend, powered by Cloudflare Durable Objects. The cache is optimized for GitBook use-cases. + +### Performances + +The cache backend is optimized for performances by being distributed and accessible close to the worker locations that are reading it. + +### Geo-distribution + +To achieve a good balance between **performances** and **consistency**, cache objects are distributed over 7 locations, representing continents. + +It makes it possible to purge all 7 locations in one go and achieve fast consistency. + +### Concepts + +**Cache tag**: unique tag in the cache environment. A cache tag groups multiple keys that should be purged together in one operation. +Cache tags should not contain a large set of unique keys. Exceeding thousands could lead to performances or reliability issues. + +**Cache key**: unique key in the cache environment. Each key should be assigned to a `tag`. + +**Location**: cache is distributed over 7 unique locations, one for each continent. diff --git a/packages/cache-do/package.json b/packages/cache-do/package.json new file mode 100644 index 000000000..de96bee8e --- /dev/null +++ b/packages/cache-do/package.json @@ -0,0 +1,42 @@ +{ + "name": "@gitbook/cache-do", + "type": "module", + "private": true, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "development": "./src/index.ts", + "default": "./dist/index.js" + }, + "./api": { + "types": "./dist/api.d.ts", + "development": "./src/api.ts", + "default": "./dist/api.js" + } + }, + "version": "0.0.0", + "dependencies": { + "@msgpack/msgpack": "^3.0.0-beta2", + "lru_map": "^0.4.1" + }, + "devDependencies": { + "typescript": "^5.5.3", + "wrangler": "^3.78.7" + }, + "scripts": { + "generate": "wrangler types --experimental-include-runtime", + "build": "tsc", + "typecheck": "tsc --noEmit", + "dev": "tsc -w", + "release": "wrangler deploy", + "release:preview": "wrangler deploy && wrangler deploy --env preview" + }, + "files": [ + "dist", + "src", + "bin", + "data", + "README.md", + "CHANGELOG.md" + ] +} diff --git a/packages/cache-do/src/CacheObject.ts b/packages/cache-do/src/CacheObject.ts new file mode 100644 index 000000000..9730839f5 --- /dev/null +++ b/packages/cache-do/src/CacheObject.ts @@ -0,0 +1,274 @@ +import { encode, decode } from '@msgpack/msgpack'; +import { DurableObject } from 'cloudflare:workers'; +import { LRUMap } from 'lru_map'; + +export interface CacheObjectDescriptor { + get: (key: string) => Promise; + set: (key: string, value: Value, expiresAt: number) => Promise; +} + +/** + * Value stored in a chunked binary msgpack format. + * Stored under the key `prop.${key}.${index}`. + */ +interface CacheObjectProp { + value: Value; + expiresAt: number; +} + +/** + * Expiration clock stored under the key `exp.${expiresAt}.${key}`. + */ +interface CacheObjectExp { + /** Key of the property */ + k: string; + /** Number of chunks */ + c: number; +} + +/** + * Durable Object class being deployed as a distributed cache. + */ +export class CacheObject extends DurableObject { + private lru = new LRUMap(500); + + /** + * Open a descriptor to access the cache object. + * The goal is to minimize the amount of RPC sessions between the client and the cache object. + * One session is opened per request on the client side and used to perform multiple operations. + * https://developers.cloudflare.com/workers/runtime-apis/rpc/#return-functions-from-rpc-methods + */ + public open(): CacheObjectDescriptor { + return { + get: async (key: string) => { + return this.get(key); + }, + set: async (key: string, value: Value, expiresAt: number) => { + await this.set(key, value, expiresAt); + }, + }; + } + + /** + * Get the value of a property. + */ + public async get(key: string) { + return timeFn(`get: ${key}`, async () => { + // Try the memory state first. + const memoryEntry = this.lru.get(key); + if (memoryEntry) { + console.log(`get: (memory, ${!!memoryEntry.match}) ${key}`); + if (!memoryEntry.match) { + return; + } + + if (memoryEntry.match.expiresAt > Date.now()) { + return memoryEntry.match.value as Value; + } + } + + return await this.getFromStorage(key); + }); + } + + /** + * Get the value of a property from the DO storage. + */ + public async getFromStorage(key: string) { + const entries = await this.ctx.storage.list({ + prefix: getStoragePropKey(key), + noCache: true, + }); + if (entries.size) { + const entry = decodeChunks>(entries); + if (entry && entry.expiresAt > Date.now()) { + // Found + this.lru.set(key, { match: entry }); + return entry.value; + } + } + + // Not found + this.lru.set(key, { match: undefined }); + } + + /** + * Set a value in the cache object. + */ + public async set(key: string, value: Value, expiresAt: number) { + return timeFn(`set: ${key}`, async () => { + const prop: CacheObjectProp = { + value, + expiresAt, + }; + + this.lru.set(key, { match: prop }); + await this.ctx.storage.transaction(async (tx) => { + const entries = encodeChunks(key, prop); + + const clockValue: CacheObjectExp = { + k: key, + c: Object.keys(entries).length, + }; + + await tx.put(getGCClockKey(key, expiresAt), clockValue); + await tx.put(entries); + + const currentAlarm = await tx.getAlarm(); + if (!currentAlarm) { + // Set an alarm to garbage collect all entries that have expired in 12h. + await tx.setAlarm(Date.now() + 12 * 60 * 60 * 1000); + } + }); + }); + } + + /** + * Purge all keys in the cache object. + */ + public async purge() { + let result = new Set(); + + try { + // List all the keys in the cache object. + const entries = await this.ctx.storage.list({ + prefix: 'exp.', + noCache: true, + }); + console.log(`purge: ${entries.size} entries`); + entries.forEach((exp) => { + result.add(exp.k); + }); + } catch (error) { + // If an error occurs, reset the cache object. + // This is a safety mechanism to prevent the cache object from being stuck in a bad state. + console.error('Error during purge, resetting the cache object', error); + } + + await this.reset(); + + console.log(`purge returns`, Array.from(result)); + return Array.from(result); + } + + /** + * Alarm to garbage collect all entries that have expired. + */ + async alarm() { + try { + const entries = await this.ctx.storage.list({ + prefix: 'exp.', + noCache: true, + }); + const toDeleteSet = new Set(); + + for (const [key, exp] of entries) { + const timestamp = parseInt(key.split('.')[1]); + if (timestamp < Date.now()) { + toDeleteSet.add(key); + for (let i = 0; i < exp.c; i++) { + toDeleteSet.add(getStoragePropChunkKey(exp.k, i)); + } + } + } + + // Delete the keys by batch of 128. + const toDelete = Array.from(toDeleteSet); + for (let i = 0; i < toDelete.length; i += 128) { + await this.ctx.storage.delete(toDelete.slice(i, i + 128)); + } + + // If there are still keys to delete, set an alarm to continue the deletion in 12h. + if (toDelete.length) { + await this.ctx.storage.setAlarm(Date.now() + 12 * 60 * 60 * 1000); + } + } catch (error) { + // If an error occurs, reset the cache object. + // This is a safety mechanism to prevent the cache object from being stuck in a bad state. + console.error('Error during alarm, reset the cache object', error); + await this.reset(); + } + } + + /** + * Reset the cache object. + */ + async reset() { + console.log('reset: clear all entries'); + this.lru.clear(); + await this.ctx.storage.deleteAll(); + } +} + +function getStoragePropKey(key: string): string { + return `prop.${key}.`; +} + +function getStoragePropChunkKey(key: string, index: number): string { + return `${getStoragePropKey(key)}${index}`; +} + +function getGCClockRootKey(timestamp: number): string { + return `exp.${timestamp}.`; +} + +function getGCClockKey(key: string, expiresAt: number): string { + return `${getGCClockRootKey(expiresAt)}${key}`; +} + +function encodeChunks(key: string, value: T): Record { + const buf = encode(value); + const entries: Record = {}; + const chunks = chunkUint8Array(buf, 128 * 1024); + + for (let index = 0; index < chunks.length; index++) { + entries[getStoragePropChunkKey(key, index)] = chunks[index]; + } + + return entries; +} + +function decodeChunks(entries: Map): T | undefined { + const chunks = Array.from(entries.entries()) + .map(([key, value]) => { + const index = parseInt(key.split('.').pop()!); + return [index, value] as const; + }) + .sort(([a], [b]) => a - b) + .map(([, value]) => value); + + if (chunks.length === 0) { + return; + } + + const buf = mergeUint8Array(chunks); + return decode(buf) as T; +} + +function chunkUint8Array(input: Uint8Array, chunkSize: number): Uint8Array[] { + const chunks: Uint8Array[] = []; + for (let i = 0; i < input.length; i += chunkSize) { + chunks.push(input.slice(i, i + chunkSize)); + } + return chunks; +} + +function mergeUint8Array(chunks: Uint8Array[]): Uint8Array { + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result; +} + +async function timeFn(message: string, fn: () => Promise): Promise { + const start = performance.now(); + try { + return await fn(); + } finally { + console.log(`${message} (${performance.now() - start}ms)`); + } +} diff --git a/packages/cache-do/src/CacheObjectStub.ts b/packages/cache-do/src/CacheObjectStub.ts new file mode 100644 index 000000000..54203c642 --- /dev/null +++ b/packages/cache-do/src/CacheObjectStub.ts @@ -0,0 +1,91 @@ +import type { CacheObject, CacheObjectDescriptor } from './CacheObject'; + +export type CacheLocationId = ContinentCode; +const allLocations: CacheLocationId[] = ['AF', 'AS', 'NA', 'SA', 'AN', 'EU', 'OC']; + +/** + * Location hint for the CacheObject durable object. + */ +const doLocationHints: { + [key in CacheLocationId]: DurableObjectLocationHint; +} = { + AF: 'afr', + AS: 'apac', + NA: 'wnam', + SA: 'sam', + AN: 'oc', + EU: 'weur', + OC: 'oc', +}; + +/** + * Client to access a cache tag. + */ +export class CacheObjectStub { + private opened: CacheObjectDescriptor | null = null; + + constructor( + /** Binding to the CacheObject durable object */ + private doNamespace: DurableObjectNamespace, + /** ID of the location to target */ + private locationId: CacheLocationId, + /** Name of the tag */ + private tag: string, + ) {} + + /** + * Open the cache object. + */ + async open() { + if (!this.opened) { + const groupId = getCacheObjectIdName(this.locationId, this.tag); + const cacheGroup = this.doNamespace.get(this.doNamespace.idFromName(groupId), { + // Initialize the object with a locaiton hint, + // as we might want to purge all locations before the object is created. + // https://developers.cloudflare.com/durable-objects/reference/data-location/ + locationHint: doLocationHints[this.locationId], + }); + this.opened = await cacheGroup.open(); + } + + return this.opened; + } + + /** + * Get a value from the cache. + */ + async get(key: string) { + const desc = await this.open(); + return await desc.get(key); + } + + /** + * Set a value in the cache. + */ + async set(key: string, value: Value, expiresAt: number) { + // TODO: Should we write on all locations instead of just the current one? + const desc = await this.open(); + return await desc.set(key, value, expiresAt); + } + + /** + * Purge all keys in the cache tag. + */ + async purge() { + const keys = new Set(); + await Promise.all( + allLocations.map(async (locationId) => { + const groupId = getCacheObjectIdName(locationId, this.tag); + const cacheGroup = this.doNamespace.get(this.doNamespace.idFromName(groupId)); + const locationkeys = await cacheGroup.purge(); + locationkeys.forEach((key) => keys.add(key)); + }), + ); + + return keys; + } +} + +function getCacheObjectIdName(locationId: CacheLocationId, tag: string): string { + return `${locationId}:${tag}`; +} diff --git a/packages/cache-do/src/api.ts b/packages/cache-do/src/api.ts new file mode 100644 index 000000000..6000e87c6 --- /dev/null +++ b/packages/cache-do/src/api.ts @@ -0,0 +1 @@ +export * from './CacheObjectStub'; diff --git a/packages/cache-do/src/index.ts b/packages/cache-do/src/index.ts new file mode 100644 index 000000000..13099a0f5 --- /dev/null +++ b/packages/cache-do/src/index.ts @@ -0,0 +1,9 @@ +import { WorkerEntrypoint } from 'cloudflare:workers'; + +export * from './CacheObject'; + +export default class Worker extends WorkerEntrypoint { + fetch() { + return new Response('Hello, world!'); + } +} diff --git a/packages/cache-do/tsconfig.json b/packages/cache-do/tsconfig.json new file mode 100644 index 000000000..09c216749 --- /dev/null +++ b/packages/cache-do/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": false, + "declaration": true, + "outDir": "dist", + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "incremental": true, + "types": ["./.wrangler/types/runtime.d.ts"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/cache-do/wrangler.toml b/packages/cache-do/wrangler.toml new file mode 100644 index 000000000..cf9eae37a --- /dev/null +++ b/packages/cache-do/wrangler.toml @@ -0,0 +1,17 @@ +main = "./src/index.ts" +name = "gitbook-open-cache" +compatibility_date = "2024-09-02" + +durable_objects.bindings = [ + {name = "CACHE", class_name = "CacheObject"} +] + +migrations = [ + {tag = "v1", new_classes = ["CacheObject"]} +] + +[env.preview] +name = "gitbook-open-cache-preview" +durable_objects.bindings = [ + {name = "CACHE", class_name = "CacheObject"} +] diff --git a/packages/gitbook/cf-env.d.ts b/packages/gitbook/cf-env.d.ts new file mode 100644 index 000000000..3721b9eb0 --- /dev/null +++ b/packages/gitbook/cf-env.d.ts @@ -0,0 +1,7 @@ +import type { CacheObject } from '@gitbook/cache-do'; + +declare global { + interface CloudflareEnv { + CACHE?: DurableObjectNamespace; + } +} diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 74df29241..0a9dbdd8e 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -22,6 +22,7 @@ "@gitbook/react-openapi": "workspace:*", "@gitbook/react-contentkit": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*", + "@gitbook/cache-do": "workspace:*", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-popover": "^1.0.7", "@sentry/nextjs": "^7.94.1", diff --git a/packages/gitbook/src/app/(global)/~gitbook/revalidate/route.ts b/packages/gitbook/src/app/(global)/~gitbook/revalidate/route.ts index eeb61d4fa..a719e68e3 100644 --- a/packages/gitbook/src/app/(global)/~gitbook/revalidate/route.ts +++ b/packages/gitbook/src/app/(global)/~gitbook/revalidate/route.ts @@ -29,7 +29,6 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: true, - keys: result.keys, stats: result.stats, }); } diff --git a/packages/gitbook/src/lib/api.ts b/packages/gitbook/src/lib/api.ts index 286f2e7d2..96b3f2fc3 100644 --- a/packages/gitbook/src/lib/api.ts +++ b/packages/gitbook/src/lib/api.ts @@ -72,12 +72,10 @@ export interface ContentTarget { const immutableCacheTtl_7days = { revalidateBefore: 24 * 60 * 60, ttl: 7 * 24 * 60 * 60, - tags: [], }; const immutableCacheTtl_1day = { revalidateBefore: 60 * 60, ttl: 24 * 60 * 60, - tags: [], }; const apiSyncStorage = new AsyncLocalStorage(); @@ -143,9 +141,14 @@ export type PublishedContentWithCache = /** * Get a user by its ID. */ -export const getUserById = cache( - 'api.getUserById', - async (userId: string, options: CacheFunctionOptions) => { +export const getUserById = cache({ + name: 'api.getUserById', + tag: (userId) => + getAPICacheTag({ + tag: 'user', + user: userId, + }), + get: async (userId: string, options: CacheFunctionOptions) => { try { const response = await api().users.getUserById(userId, { signal: options.signal, @@ -153,28 +156,31 @@ export const getUserById = cache( }); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [], }); } catch (error) { if ((error as GitBookAPIError).code === 404) { return { revalidateBefore: 60 * 60, data: null, - tags: [], }; } throw error; } }, -); +}); /** * Get a synced block by its ref. */ -export const getSyncedBlockContent = cache( - 'api.getSyncedBlockContent', - async ( +export const getSyncedBlockContent = cache({ + name: 'api.getSyncedBlockContent', + tag: (apiToken, organizationId, syncedBlockId) => + getAPICacheTag({ + tag: 'synced-block', + syncedBlock: syncedBlockId, + }), + get: async ( apiToken: string, organizationId: string, syncedBlockId: string, @@ -191,38 +197,36 @@ export const getSyncedBlockContent = cache( ); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [ - getAPICacheTag({ - tag: 'synced-block', - syncedBlock: syncedBlockId, - }), - ], }); } catch (error) { if ((error as GitBookAPIError).code === 404) { return { revalidateBefore: 60 * 60, data: null, - tags: [], }; } throw error; } }, - { - // We don't cache apiToken as it's not a stable key - extractArgs: (args) => [args[1], args[2]], - }, -); + // We don't cache apiToken as it's not a stable key + getKeyArgs: (args) => [args[1], args[2]], +}); /** * Resolve a URL to the content to render. */ -export const getPublishedContentByUrl = cache( - 'api.getPublishedContentByUrl.v3', - async (url: string, visitorAuthToken: string | undefined, options: CacheFunctionOptions) => { - const parsedURL = new URL(url); - +export const getPublishedContentByUrl = cache({ + name: 'api.getPublishedContentByUrl.v3', + tag: (url) => + getAPICacheTag({ + tag: 'url', + hostname: new URL(url).hostname, + }), + get: async ( + url: string, + visitorAuthToken: string | undefined, + options: CacheFunctionOptions, + ) => { try { const response = await api().urls.getPublishedContentByUrl( { @@ -243,12 +247,6 @@ export const getPublishedContentByUrl = cache( cacheTags: parsed.tags, }; return { - tags: [ - getAPICacheTag({ - tag: 'url', - hostname: parsedURL.hostname, - }), - ], ttl: parsed.ttl, data, }; @@ -264,26 +262,21 @@ export const getPublishedContentByUrl = cache( // Cache errors for max 10 minutes in case the user is making changes to its content configuration // and to avoid caching too many entries when being spammed by botss ttl: 60 * 10, - tags: [ - getAPICacheTag({ - tag: 'url', - hostname: parsedURL.hostname, - }), - ], }; } throw error; } }, -); +}); /** * Get a space by its ID. */ -export const getSpace = cache( - 'api.getSpace', - async (spaceId: string, shareKey: string | undefined, options: CacheFunctionOptions) => { +export const getSpace = cache({ + name: 'api.getSpace', + tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), + get: async (spaceId: string, shareKey: string | undefined, options: CacheFunctionOptions) => { const response = await api().spaces.getSpaceById( spaceId, { @@ -296,47 +289,47 @@ export const getSpace = cache( ); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [getAPICacheTag({ tag: 'space', space: spaceId })], }); }, -); +}); /** * Get a change request by its ID. */ -export const getChangeRequest = cache( - 'api.getChangeRequest', - async (spaceId: string, changeRequestId: string, options: CacheFunctionOptions) => { +export const getChangeRequest = cache({ + name: 'api.getChangeRequest', + tag: (spaceId, changeRequestId) => + getAPICacheTag({ tag: 'change-request', space: spaceId, changeRequest: changeRequestId }), + get: async (spaceId: string, changeRequestId: string, options: CacheFunctionOptions) => { const response = await api().spaces.getChangeRequestById(spaceId, changeRequestId, { ...noCacheFetchOptions, signal: options.signal, }); return cacheResponse(response, { // We don't cache for long as we currently don't invalidate change-request cache - // and it's only used for preview where perfs are not critical + // and it's only used for preview where perfs are not critical ttl: 60 * 60, revalidateBefore: 10 * 60, - tags: [], }); }, -); +}); /** * List the scripts to load for the space. */ -export const getSpaceIntegrationScripts = cache( - 'api.getSpaceIntegrationScripts', - async (spaceId: string, options: CacheFunctionOptions) => { +export const getSpaceIntegrationScripts = cache({ + name: 'api.getSpaceIntegrationScripts', + tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), + get: async (spaceId: string, options: CacheFunctionOptions) => { const response = await api().spaces.listSpaceIntegrationScripts(spaceId, { ...noCacheFetchOptions, signal: options.signal, }); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [getAPICacheTag({ tag: 'space', space: spaceId })], }); }, -); +}); interface GetRevisionOptions { /** @@ -351,9 +344,11 @@ interface GetRevisionOptions { /** * Get a revision by its ID. */ -export const getRevision = cache( - 'api.getRevision.v2', - async ( +export const getRevision = cache({ + name: 'api.getRevision.v2', + tag: (spaceId, revisionId) => + getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }), + get: async ( spaceId: string, revisionId: string, fetchOptions: GetRevisionOptions, @@ -376,17 +371,17 @@ export const getRevision = cache( fetchOptions.metadata ? immutableCacheTtl_7days : immutableCacheTtl_1day, ); }, - { - extractArgs: (args) => [args[0], args[1]], - }, -); + getKeyArgs: (args) => [args[0], args[1]], +}); /** * Get all the pages in a revision of a space. */ -export const getRevisionPages = cache( - 'api.getRevisionPages.v4', - async ( +export const getRevisionPages = cache({ + name: 'api.getRevisionPages.v4', + tag: (spaceId, revisionId) => + getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }), + get: async ( spaceId: string, revisionId: string, fetchOptions: GetRevisionOptions, @@ -409,17 +404,17 @@ export const getRevisionPages = cache( data: response.data.pages, }); }, - { - extractArgs: (args) => [args[0], args[1]], - }, -); + getKeyArgs: (args) => [args[0], args[1]], +}); /** * Get a revision page by its path */ -export const getRevisionPageByPath = cache( - 'api.getRevisionPageByPath.v3', - async ( +export const getRevisionPageByPath = cache({ + name: 'api.getRevisionPageByPath.v3', + tag: (spaceId, revisionId) => + getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }), + get: async ( spaceId: string, revisionId: string, pagePath: string, @@ -453,15 +448,22 @@ export const getRevisionPageByPath = cache( throw error; } }, -); +}); /** * Resolve a file by its ID. * It should not be used directly, use `getRevisionFile` instead. */ -const getRevisionFileById = cache( - 'api.getRevisionFile.v3', - async (spaceId: string, revisionId: string, fileId: string, options: CacheFunctionOptions) => { +const getRevisionFileById = cache({ + name: 'api.getRevisionFile.v3', + tag: (spaceId, revisionId) => + getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }), + get: async ( + spaceId: string, + revisionId: string, + fileId: string, + options: CacheFunctionOptions, + ) => { try { const response = await (async () => { return api().spaces.getFileInRevisionById( @@ -487,15 +489,17 @@ const getRevisionFileById = cache( throw error; } }, -); +}); /** * Get all the files in a revision of a space. * It should not be used directly, use `getRevisionFile` instead. */ -const getRevisionAllFiles = cache( - 'api.getRevisionAllFiles.v2', - async (spaceId: string, revisionId: string, options: CacheFunctionOptions) => { +const getRevisionAllFiles = cache({ + name: 'api.getRevisionAllFiles.v2', + tag: (spaceId, revisionId) => + getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }), + get: async (spaceId: string, revisionId: string, options: CacheFunctionOptions) => { const response = await getAll( (params) => api().spaces.listFilesInRevisionById( @@ -522,10 +526,8 @@ const getRevisionAllFiles = cache( return cacheResponse(response, { ...immutableCacheTtl_7days, data: files }); }, - { - timeout: 60 * 1000, - }, -); + timeout: 60 * 1000, +}); /** * Resolve a file by its ID. @@ -583,9 +585,11 @@ export const getRevisionFile = batch<[string, string, string], RevisionFile | nu /** * Get a document by its ID. */ -export const getDocument = cache( - 'api.getDocument.v2', - async (spaceId: string, documentId: string, options: CacheFunctionOptions) => { +export const getDocument = cache({ + name: 'api.getDocument.v2', + tag: (spaceId, documentId) => + getAPICacheTag({ tag: 'document', space: spaceId, document: documentId }), + get: async (spaceId: string, documentId: string, options: CacheFunctionOptions) => { const response = await api().spaces.getDocumentById( spaceId, documentId, @@ -599,20 +603,19 @@ export const getDocument = cache( ); return cacheResponse(response, immutableCacheTtl_7days); }, - { - // Temporarily allow for a longer timeout than the default 10s - // because GitBook's API currently re-normalizes all documents - // and it can take more than 10s... - timeout: 20 * 1000, - }, -); + // Temporarily allow for a longer timeout than the default 10s + // because GitBook's API currently re-normalizes all documents + // and it can take more than 10s... + timeout: 20 * 1000, +}); /** * Get the customization settings for a site-space from the API. */ -const getSiteSpaceCustomizationFromAPI = cache( - 'api.getSiteSpaceCustomizationById', - async ( +const getSiteSpaceCustomizationFromAPI = cache({ + name: 'api.getSiteSpaceCustomizationById', + tag: (organizationId, siteId, siteSpaceId) => getAPICacheTag({ tag: 'site', site: siteId }), + get: async ( organizationId: string, siteId: string, siteSpaceId: string, @@ -630,22 +633,17 @@ const getSiteSpaceCustomizationFromAPI = cache( ); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [ - getAPICacheTag({ - tag: 'site', - site: siteId, - }), - ], }); }, -); +}); /** * Get the customization settings for a site from the API. */ -const getSiteCustomizationFromAPI = cache( - 'api.getSiteCustomizationById', - async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { +const getSiteCustomizationFromAPI = cache({ + name: 'api.getSiteCustomizationById', + tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }), + get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { const response = await api().orgs.getSiteCustomizationById( organizationId, siteId, @@ -657,15 +655,9 @@ const getSiteCustomizationFromAPI = cache( ); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [ - getAPICacheTag({ - tag: 'site', - site: siteId, - }), - ], }); }, -); +}); /** * Get the customization settings for a site space from the API. @@ -698,26 +690,27 @@ async function getSiteCustomization(args: { /** * Get the infos about a site by its ID. */ -export const getSite = cache( - 'api.getSite', - async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { +export const getSite = cache({ + name: 'api.getSite', + tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }), + get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { const response = await api().orgs.getSiteById(organizationId, siteId, { ...noCacheFetchOptions, signal: options.signal, }); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [getAPICacheTag({ tag: 'site', site: siteId })], }); }, -); +}); /** * List all the site-spaces variants published in a site. */ -export const getSiteSpaces = cache( - 'api.getSiteSpaces', - async ( +export const getSiteSpaces = cache({ + name: 'api.getSiteSpaces', + tag: ({ siteId }) => getAPICacheTag({ tag: 'site', site: siteId }), + get: async ( args: { organizationId: string; siteId: string; @@ -744,27 +737,26 @@ export const getSiteSpaces = cache( return cacheResponse(response, { revalidateBefore: 60 * 60, data: response.data.items.map((siteSpace) => siteSpace), - tags: [getAPICacheTag({ tag: 'site', site: args.siteId })], }); }, -); +}); /** * List the scripts to load for the site. */ -export const getSiteIntegrationScripts = cache( - 'api.getSiteIntegrationScripts', - async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { +export const getSiteIntegrationScripts = cache({ + name: 'api.getSiteIntegrationScripts', + tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }), + get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { const response = await api().orgs.listSiteIntegrationScripts(organizationId, siteId, { ...noCacheFetchOptions, signal: options.signal, }); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [getAPICacheTag({ tag: 'site', site: siteId })], }); }, -); +}); /** * Fetch all the data to render the current site at once. @@ -835,19 +827,19 @@ export async function getCurrentSiteCustomization(args: { /** * Get the customization settings for a space from the API. */ -export const getSpaceCustomizationFromAPI = cache( - 'api.getSpaceCustomization', - async (spaceId: string, options: CacheFunctionOptions) => { +export const getSpaceCustomizationFromAPI = cache({ + name: 'api.getSpaceCustomization', + tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), + get: async (spaceId: string, options: CacheFunctionOptions) => { const response = await api().spaces.getSpacePublishingCustomizationById(spaceId, { signal: options.signal, ...noCacheFetchOptions, }); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [getAPICacheTag({ tag: 'space', space: spaceId })], }); }, -); +}); /** * Get the customization settings for a space from the API. @@ -876,26 +868,27 @@ export async function getSpaceCustomization(spaceId: string): Promise { +export const getCollection = cache({ + name: 'api.getCollection', + tag: (collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }), + get: async (collectionId: string, options: CacheFunctionOptions) => { const response = await api().collections.getCollectionById(collectionId, { ...noCacheFetchOptions, signal: options.signal, }); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [getAPICacheTag({ tag: 'collection', collection: collectionId })], }); }, -); +}); /** * List all the spaces variants published in a collection. */ -export const getCollectionSpaces = cache( - 'api.getCollectionSpaces', - async (collectionId: string, options: CacheFunctionOptions) => { +export const getCollectionSpaces = cache({ + name: 'api.getCollectionSpaces', + tag: (collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }), + get: async (collectionId: string, options: CacheFunctionOptions) => { const response = await getAll((params) => api().collections.listSpacesInCollectionById(collectionId, params, { ...noCacheFetchOptions, @@ -908,10 +901,9 @@ export const getCollectionSpaces = cache( data: response.data.items.filter( (space) => space.visibility === ContentVisibility.InCollection, ), - tags: [getAPICacheTag({ tag: 'collection', collection: collectionId })], }); }, -); +}); /** * Fetch all the data to render a space at once. @@ -979,9 +971,10 @@ export async function getSpaceLayoutData(spaceId: string) { /** * Search content in a space. */ -export const searchSpaceContent = cache( - 'api.searchSpaceContent', - async ( +export const searchSpaceContent = cache({ + name: 'api.searchSpaceContent', + tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), + get: async ( spaceId: string, /** The revision ID is used as a cache bust key, to avoid revalidating lot of cache entries by tags */ revisionId: string, @@ -996,18 +989,17 @@ export const searchSpaceContent = cache( signal: options.signal, }, ); - return cacheResponse(response, { - tags: [], - }); + return cacheResponse(response); }, -); +}); /** * Search content accross all spaces in a parent (site or collection). */ -export const searchParentContent = cache( - 'api.searchParentContent', - async (parentId: string, query: string, options: CacheFunctionOptions) => { +export const searchParentContent = cache({ + name: 'api.searchParentContent', + tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), + get: async (parentId: string, query: string, options: CacheFunctionOptions) => { const response = await api().search.searchContent( { query }, { @@ -1017,17 +1009,17 @@ export const searchParentContent = cache( ); return cacheResponse(response, { ttl: 60 * 60, - tags: [], }); }, -); +}); /** * Search content in a Site or specific SiteSpaces. */ -export const searchSiteContent = cache( - 'api.searchSiteContent', - async ( +export const searchSiteContent = cache({ + name: 'api.searchSiteContent', + tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }), + get: async ( organizationId: string, siteId: string, query: string, @@ -1052,33 +1044,32 @@ export const searchSiteContent = cache( return cacheResponse(response, { ttl: 60 * 60, - tags: [], }); }, -); +}); /** * Get a list of recommended questions in a space. */ -export const getRecommendedQuestionsInSpace = cache( - 'api.getRecommendedQuestionsInSpace', - async (spaceId: string, options: CacheFunctionOptions) => { +export const getRecommendedQuestionsInSpace = cache({ + name: 'api.getRecommendedQuestionsInSpace', + tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), + get: async (spaceId: string, options: CacheFunctionOptions) => { const response = await api().spaces.getRecommendedQuestionsInSpace(spaceId, { ...noCacheFetchOptions, signal: options.signal, }); - return cacheResponse(response, { - tags: [], - }); + return cacheResponse(response); }, -); +}); /** * Render an integration contentkit UI */ -export const renderIntegrationUi = cache( - 'api.renderIntegrationUi', - async ( +export const renderIntegrationUi = cache({ + name: 'api.renderIntegrationUi', + tag: (integrationName) => getAPICacheTag({ tag: 'integration', integration: integrationName }), + get: async ( integrationName: string, request: RequestRenderIntegrationUI, options: CacheFunctionOptions, @@ -1091,21 +1082,47 @@ export const renderIntegrationUi = cache( signal: options.signal, }, ); - return cacheResponse(response, { - tags: [], - }); + return cacheResponse(response); }, -); +}); /** * Create a cache tag for the API. */ export function getAPICacheTag( - spec: // All data related to a space + spec: // All data related to a user | { + tag: 'user'; + user: string; + } + // All data related to a space + | { tag: 'space'; space: string; } + // All data related to an integration + | { + tag: 'integration'; + integration: string; + } + // All data related to a change request + | { + tag: 'change-request'; + space: string; + changeRequest: string; + } + // Immutable data related to a revision + | { + tag: 'revision'; + space: string; + revision: string; + } + // Immutable data related to a document + | { + tag: 'document'; + space: string; + document: string; + } // All data related to the URL of a content | { tag: 'url'; @@ -1128,16 +1145,26 @@ export function getAPICacheTag( }, ): string { switch (spec.tag) { + case 'user': + return `user:${spec.user}`; case 'url': return `url:${spec.hostname}`; case 'space': return `space:${spec.space}`; + case 'change-request': + return `space:${spec.space}:change-request:${spec.changeRequest}`; + case 'revision': + return `space:${spec.space}:revision:${spec.revision}`; + case 'document': + return `space:${spec.space}:document:${spec.document}`; case 'collection': return `collection:${spec.collection}`; case 'synced-block': return `synced-block:${spec.syncedBlock}`; case 'site': return `site:${spec.site}`; + case 'integration': + return `integration:${spec.integration}`; default: assertNever(spec); } diff --git a/packages/gitbook/src/lib/cache/backends.ts b/packages/gitbook/src/lib/cache/backends.ts index e4dc4002c..ba33f23ff 100644 --- a/packages/gitbook/src/lib/cache/backends.ts +++ b/packages/gitbook/src/lib/cache/backends.ts @@ -1,18 +1,16 @@ import { cloudflareCache } from './cloudflare-cache'; +import { cloudflareDOCache } from './cloudflare-do'; import { cloudflareKVCache } from './cloudflare-kv'; import { memoryCache } from './memory'; -import { redisCache } from './redis'; export const cacheBackends = [ // Cache local to the process // (can't be globally purged or shared between processes) memoryCache, - // Global cache shared between all processes - // with proper replication and invalidation - // redisCache, // Disabled as we investigate high amount of fetch requests // Cache local to the datacenter // It can't be purged globally but it's faster cloudflareCache, // Cache global, but with slow replication cloudflareKVCache, + cloudflareDOCache, ]; diff --git a/packages/gitbook/src/lib/cache/cache.test.ts b/packages/gitbook/src/lib/cache/cache.test.ts index 9448c3cd4..e2d115d34 100644 --- a/packages/gitbook/src/lib/cache/cache.test.ts +++ b/packages/gitbook/src/lib/cache/cache.test.ts @@ -15,12 +15,16 @@ describe('cache', () => { testId += 1; getTtl = () => 1000; - fn = cache(`cache-${testId}`, async (arg: string, options: CacheFunctionOptions) => { - await new Promise((resolve) => setTimeout(resolve, 20)); - return { - data: impl(arg), - ttl: getTtl(), - }; + fn = cache({ + name: `cache-${testId}`, + tag: (arg) => 'test', + get: async (arg: string, options: CacheFunctionOptions) => { + await new Promise((resolve) => setTimeout(resolve, 20)); + return { + data: impl(arg), + ttl: getTtl(), + }; + }, }); }); diff --git a/packages/gitbook/src/lib/cache/cache.ts b/packages/gitbook/src/lib/cache/cache.ts index 8c154e672..dadabcf4d 100644 --- a/packages/gitbook/src/lib/cache/cache.ts +++ b/packages/gitbook/src/lib/cache/cache.ts @@ -43,11 +43,26 @@ export interface CacheResult { * Time before ttl where the cache should be revalidated (in seconds). */ revalidateBefore?: number; +} - /** - * Tags to associate with the cache entry. - */ - tags?: string[]; +export interface CacheDefinition { + /** Unique name for the cache */ + name: string; + + /** Tag to associate to the entry */ + tag?: (...args: Args) => string; + + /** Filter the arguments that should be taken into consideration for the cache key */ + getKeyArgs?: (args: Args) => any[]; + + /** Default ttl (in seconds) */ + defaultTtl?: number; + + /** When a request to the underlying resource will timeout. */ + timeout?: number; + + /** Function to get the value */ + get: (...args: [...Args, CacheFunctionOptions]) => Promise>; } /** @@ -55,21 +70,11 @@ export interface CacheResult { * We don't use the next.js cache because it has a 2MB limit. */ export function cache( - cacheName: string, - fn: (...args: [...Args, CacheFunctionOptions]) => Promise>, - options: { - /** Filter the arguments that should be taken into consideration for caching */ - extractArgs?: (args: Args) => any[]; - - /** Default ttl (in seconds) */ - defaultTtl?: number; - - /** When a request to the underlying resource will timeout. */ - timeout?: number; - } = {}, + cacheDef: CacheDefinition, ): CacheFunction { // We stop everything after 10s to avoid pending requests - const timeout = options.timeout ?? 1000 * 10; + const timeout = cacheDef.timeout ?? 1000 * 10; + const defaultTtl = cacheDef.defaultTtl ?? 60 * 60 * 24; const revalidate = singletonMap( async (key: string, signal: AbortSignal | undefined, ...args: Args) => { @@ -80,19 +85,18 @@ export function cache( }, async (span) => { // Fetch upstream - const result = await fn(...args, { signal }); + const result = await cacheDef.get(...args, { signal }); signal?.throwIfAborted(); const setAt = Date.now(); - const expiresAt = - setAt + (result.ttl ?? options.defaultTtl ?? 60 * 60 * 24) * 1000; + const expiresAt = setAt + (result.ttl ?? defaultTtl) * 1000; const cacheEntry: CacheEntry = { data: result.data, meta: { key, - cache: cacheName, - tags: result.tags ?? [], + cache: cacheDef.name, + tag: cacheDef.tag?.(...args), setAt, expiresAt, revalidatesAt: result.revalidateBefore @@ -106,7 +110,7 @@ export function cache( // Write it to the cache if (result.ttl && result.ttl > 0) { - await waitUntil(setCacheEntry(key, cacheEntry)); + await waitUntil(setCacheEntry(cacheEntry)); } return cacheEntry; @@ -122,9 +126,10 @@ export function cache( let fetchDuration = 0; let result: readonly [CacheEntry, string] | null = null; + const tag = cacheDef.tag?.(...args); // Try the memory backend, independently of the other backends as it doesn't have a network cost - const memoryEntry = await memoryCache.get(key); + const memoryEntry = await memoryCache.get({ key, tag }); if (memoryEntry) { span.setAttribute('memory', true); result = [memoryEntry, 'memory'] as const; @@ -132,7 +137,7 @@ export function cache( result = await race( cacheBackends, async (backend, { signal }) => { - const entry = await backend.get(key, { signal }); + const entry = await backend.get({ key, tag }, { signal }); return entry ? ([entry, backend.name] as const) : null; }, { @@ -184,7 +189,7 @@ export function cache( (backend) => backend.name !== backendName && backend.replication === 'local', ) - .map((backend) => backend.set(key, savedEntry)), + .map((backend) => backend.set(savedEntry)), ), ); } @@ -214,8 +219,8 @@ export function cache( const cacheFn = async (...rawArgs: Args | [...Args, CacheFunctionOptions]) => { const [args, { signal }] = extractCacheFunctionOptions(rawArgs); - const cacheArgs = options.extractArgs ? options.extractArgs(args) : args; - const key = getCacheKey(cacheName, cacheArgs); + const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args; + const key = getCacheKey(cacheDef.name, cacheArgs); return await trace( { @@ -231,17 +236,18 @@ export function cache( cacheFn.revalidate = async (...rawArgs: Args | [...Args, CacheFunctionOptions]) => { const [args, { signal }] = extractCacheFunctionOptions(rawArgs); - const cacheArgs = options.extractArgs ? options.extractArgs(args) : args; - const key = getCacheKey(cacheName, cacheArgs); + const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args; + const key = getCacheKey(cacheDef.name, cacheArgs); await revalidate(key, signal, ...args); }; cacheFn.hasInMemory = async (...args: Args) => { - const cacheArgs = options.extractArgs ? options.extractArgs(args) : args; - const key = getCacheKey(cacheName, cacheArgs); + const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args; + const key = getCacheKey(cacheDef.name, cacheArgs); + const tag = cacheDef.tag?.(...args); - const memoryEntry = await memoryCache.get(key); + const memoryEntry = await memoryCache.get({ key, tag }); if (memoryEntry) { return true; } @@ -250,7 +256,7 @@ export function cache( }; // @ts-ignore - registeredCaches.set(cacheName, cacheFn); + registeredCaches.set(cacheDef.name, cacheFn); return cacheFn; } @@ -289,14 +295,14 @@ function hashValue(arg: any): string { return JSON.stringify(arg); } -async function setCacheEntry(key: string, entry: CacheEntry) { +async function setCacheEntry(entry: CacheEntry) { return await trace( { operation: `cache.setCacheEntry`, - name: key, + name: entry.meta.key, }, async () => { - await Promise.all(cacheBackends.map((backend) => backend.set(key, entry))); + await Promise.all(cacheBackends.map((backend) => backend.set(entry))); }, ); } diff --git a/packages/gitbook/src/lib/cache/cloudflare-cache.ts b/packages/gitbook/src/lib/cache/cloudflare-cache.ts index 98ae1d0d8..6744298d2 100644 --- a/packages/gitbook/src/lib/cache/cloudflare-cache.ts +++ b/packages/gitbook/src/lib/cache/cloudflare-cache.ts @@ -19,7 +19,7 @@ const cacheVersion = 2; export const cloudflareCache: CacheBackend = { name: 'cloudflare', replication: 'local', - async get(key, options) { + async get(entry, options) { const cache = getCache(); if (!cache) { return null; @@ -27,10 +27,10 @@ export const cloudflareCache: CacheBackend = { return trace( { operation: `cloudflareCache.get`, - name: key, + name: entry.key, }, async (span) => { - const cacheKey = await serializeKey(key); + const cacheKey = await serializeKey(entry.key); const response = await cache.match(cacheKey); span.setAttribute('hit', !!response); @@ -39,32 +39,32 @@ export const cloudflareCache: CacheBackend = { return null; } - const entry = await deserializeEntry(response); - return entry; + const cacheEntry = await deserializeEntry(response); + return cacheEntry; }, ); }, - async set(key, entry) { + async set(entry) { const cache = getCache(); if (cache) { await trace( { operation: `cloudflareCache.set`, - name: key, + name: entry.meta.key, }, async () => { - const cacheKey = await serializeKey(key); + const cacheKey = await serializeKey(entry.meta.key); await cache.put(cacheKey, serializeEntry(entry)); }, ); } }, - async del(keys) { + async del(entries) { const cache = getCache(); if (cache) { await Promise.all( - keys.map(async (key) => { - const cacheKey = await serializeKey(key); + entries.map(async (entry) => { + const cacheKey = await serializeKey(entry.key); await cache.delete(cacheKey); }), ); @@ -72,8 +72,7 @@ export const cloudflareCache: CacheBackend = { }, async revalidateTags(tags) { return { - keys: [], - metas: [], + entries: [], }; }, }; @@ -103,7 +102,10 @@ async function serializeKey(key: string): Promise { function serializeEntry(entry: CacheEntry): WorkerResponse { const headers = new Headers(); headers.set('Content-Type', 'application/json'); - const cacheTags = ['gitbook-open', ...entry.meta.tags]; + const cacheTags = ['gitbook-open']; + if (entry.meta.tag) { + cacheTags.push(entry.meta.tag); + } const maxAge = getCacheMaxAge( entry.meta, diff --git a/packages/gitbook/src/lib/cache/cloudflare-do.ts b/packages/gitbook/src/lib/cache/cloudflare-do.ts new file mode 100644 index 000000000..629702359 --- /dev/null +++ b/packages/gitbook/src/lib/cache/cloudflare-do.ts @@ -0,0 +1,101 @@ +import { CacheObjectStub, CacheLocationId } from '@gitbook/cache-do/api'; + +import { CacheBackend, CacheEntry, CacheEntryLookup } from './types'; +import { trace } from '../tracing'; + +/** + * Cache implementation using the custom Cloudflare Durable Object. + */ +export const cloudflareDOCache: CacheBackend = { + name: 'cloudflare-do', + replication: 'global', + async get(entry, options) { + const { key, tag } = entry; + if (!tag) { + return null; + } + + return trace( + { + operation: `cloudflareDO.get`, + name: entry.key, + }, + async (span) => { + const stub = await getStub(tag); + if (!stub) { + return null; + } + + return (await stub.get(key)) ?? null; + }, + ); + }, + async set(entry) { + const { key, tag } = entry.meta; + if (!tag) { + return; + } + + return trace( + { + operation: `cloudflareDO.set`, + name: key, + }, + async () => { + const stub = await getStub(tag); + if (!stub) { + return; + } + + await stub.set(key, entry, entry.meta.expiresAt); + }, + ); + }, + async del(entries) { + // We don't need to directly delete entries from the Cloudflare DO cache. + }, + async revalidateTags(tags) { + const entries: CacheEntryLookup[] = []; + + await Promise.all( + tags.map(async (tag) => { + const stub = await getStub(tag); + if (!stub) { + return; + } + + const keys = await stub.purge(); + keys.forEach((key) => { + entries.push({ key, tag }); + }); + }), + ); + + return { entries }; + }, +}; + +const globalStubs = new WeakMap>(); + +async function getStub(tag: string): Promise { + if (process.env.NODE_ENV === 'test') { + return null; + } + + // We lazy-load the next-on-pages package to avoid errors when running tests because of 'server-only'. + const { getOptionalRequestContext } = await import('@cloudflare/next-on-pages'); + const cloudflare = getOptionalRequestContext(); + if (!cloudflare || !cloudflare.env.CACHE) { + return null; + } + + const requestStubs = globalStubs.get(cloudflare.cf) ?? new Map(); + globalStubs.set(cloudflare.cf, requestStubs); + + const locationId: CacheLocationId = cloudflare.cf.continent ?? 'NA'; + const stub = + requestStubs.get(tag) ?? new CacheObjectStub(cloudflare.env.CACHE, locationId, tag); + requestStubs.set(tag, stub); + + return stub; +} diff --git a/packages/gitbook/src/lib/cache/cloudflare-kv.ts b/packages/gitbook/src/lib/cache/cloudflare-kv.ts index 8845b6352..a6f96009e 100644 --- a/packages/gitbook/src/lib/cache/cloudflare-kv.ts +++ b/packages/gitbook/src/lib/cache/cloudflare-kv.ts @@ -17,7 +17,7 @@ interface KVTagMetadata { export const cloudflareKVCache: CacheBackend = { name: 'cloudflare-kv', replication: 'global', - async get(key, options) { + async get({ key }, options) { const kv = await getKVNamespace(); if (!kv) { return null; @@ -42,7 +42,7 @@ export const cloudflareKVCache: CacheBackend = { }, ); }, - async set(key, entry) { + async set(entry) { const kv = await getKVNamespace(); if (!kv) { return; @@ -51,7 +51,7 @@ export const cloudflareKVCache: CacheBackend = { return trace( { operation: `cloudflareKV.set`, - name: key, + name: entry.meta.key, }, async () => { const secondsFromNow = getCacheMaxAge(entry.meta, 0, 60 * 60 * 24); @@ -61,54 +61,45 @@ export const cloudflareKVCache: CacheBackend = { return; } - const kvKey = getValueKey(key); + const kvKey = getValueKey(entry.meta.key); await kv.put(kvKey, JSON.stringify(entry), { expirationTtl: secondsFromNow, }); - if (entry.meta.tags.length > 0) { + if (entry.meta.tag) { const metadata: KVTagMetadata = { meta: entry.meta, }; const jsonMetadata = JSON.stringify(metadata); + const tagKey = getTagKey(entry.meta.tag, entry.meta.key); - // Write a key for each tag - await Promise.all( - entry.meta.tags.map(async (tag) => { - const tagKey = getTagKey(tag, key); - - await kv.put(tagKey, jsonMetadata, { - metadata, - expirationTtl: secondsFromNow, - }); - }), - ); + await kv.put(tagKey, jsonMetadata, { + metadata, + expirationTtl: secondsFromNow, + }); } }, ); }, - async del(keys) { + async del(entries) { const kv = await getKVNamespace(); if (!kv) { return; } await Promise.all( - keys.map(async (key) => { + entries.map(async ({ key }) => { const kvKey = getValueKey(key); await kv.delete(kvKey); }), ); }, async revalidateTags(tags) { - const result: { keys: string[]; metas: CacheEntryMeta[] } = { - keys: [], - metas: [], - }; + const result: CacheEntryMeta[] = []; const kv = await getKVNamespace(); if (!kv) { - return result; + return { entries: result }; } const pendingDeletions: Array> = []; @@ -125,8 +116,7 @@ export const cloudflareKVCache: CacheBackend = { const metadata = entry.metadata; const key = metadata.meta.key; - result.metas.push(metadata.meta); - result.keys.push(key); + result.push(metadata.meta); // Delete the tag key and the value key pendingDeletions.push(kv.delete(getValueKey(key))); @@ -147,7 +137,7 @@ export const cloudflareKVCache: CacheBackend = { await Promise.all(pendingDeletions); - return result; + return { entries: result }; }, }; diff --git a/packages/gitbook/src/lib/cache/http.ts b/packages/gitbook/src/lib/cache/http.ts index 145ae78de..6907ec326 100644 --- a/packages/gitbook/src/lib/cache/http.ts +++ b/packages/gitbook/src/lib/cache/http.ts @@ -52,7 +52,6 @@ export function cacheResponse( return { ttl: defaultEntry.ttl ?? parsed.ttl, - tags: [...(defaultEntry.tags ?? []), ...parsed.tags], revalidateBefore: defaultEntry.revalidateBefore, // @ts-ignore data: defaultEntry.data ?? response.data, diff --git a/packages/gitbook/src/lib/cache/memory.ts b/packages/gitbook/src/lib/cache/memory.ts index 70cc8e1f0..deff0e946 100644 --- a/packages/gitbook/src/lib/cache/memory.ts +++ b/packages/gitbook/src/lib/cache/memory.ts @@ -1,13 +1,13 @@ -import { CacheBackend, CacheEntry } from './types'; +import { CacheBackend, CacheEntry, CacheEntryLookup } from './types'; import { NON_IMMUTABLE_LOCAL_CACHE_MAX_AGE_SECONDS, isCacheEntryImmutable } from './utils'; import { getGlobalContext } from '../waitUntil'; export const memoryCache: CacheBackend = { name: 'memory', replication: 'local', - async get(key) { + async get(entry) { const memoryCache = await getMemoryCache(); - const memoryEntry = memoryCache.get(key); + const memoryEntry = memoryCache.get(entry.key); if (!memoryEntry) { return null; @@ -16,12 +16,12 @@ export const memoryCache: CacheBackend = { if (memoryEntry.meta.expiresAt > Date.now()) { return memoryEntry; } else { - memoryCache.delete(key); + memoryCache.delete(entry.key); } return null; }, - async set(key, entry) { + async set(entry) { const memoryCache = await getMemoryCache(); // When the entry is immutable, we can cache it for the entire duration. // Else we cache it for a very short time. @@ -39,26 +39,25 @@ export const memoryCache: CacheBackend = { meta.expiresAt = expiresAt; } - memoryCache.set(key, { ...entry, meta }); + memoryCache.set(entry.meta.key, { ...entry, meta }); }, - async del(keys) { + async del(entries) { const memoryCache = await getMemoryCache(); - keys.forEach((key) => memoryCache.delete(key)); + entries.forEach((entry) => memoryCache.delete(entry.key)); }, async revalidateTags(tags) { const memoryCache = await getMemoryCache(); - const keys: string[] = []; + const entries: CacheEntryLookup[] = []; memoryCache.forEach((entry, key) => { - if (tags.some((tag) => entry.meta.tags.includes(tag))) { - keys.push(key); + if (entry.meta.tag && tags.includes(entry.meta.tag)) { + entries.push({ key, tag: entry.meta.tag }); memoryCache.delete(key); } }); return { - keys, - metas: [], + entries, }; }, }; diff --git a/packages/gitbook/src/lib/cache/redis.ts b/packages/gitbook/src/lib/cache/redis.ts deleted file mode 100644 index e42b25f8c..000000000 --- a/packages/gitbook/src/lib/cache/redis.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { Redis } from '@upstash/redis/cloudflare'; - -import { CacheBackend, CacheEntry, CacheEntryMeta } from './types'; -import { getCacheMaxAge } from './utils'; -import { trace } from '../tracing'; -import { filterOutNullable } from '../typescript'; - -const cacheNamespace = process.env.UPSTASH_REDIS_NAMESPACE ?? 'gitbook'; -const cacheVersion = 2; - -export const redisCache: CacheBackend = { - name: 'redis', - replication: 'global', - async get(key, options) { - const redis = getRedis(options?.signal); - if (!redis) { - return null; - } - return trace( - { - operation: `redis.get`, - name: key, - }, - async (span) => { - const valueKey = getCacheEntryKey(key, 'value'); - const redisEntry = await redis.get(valueKey); - - span.setAttribute('hit', !!redisEntry); - - if (!redisEntry) { - return null; - } - - return redisEntry; - }, - ); - }, - - async set(key, entry) { - const redis = getRedis(); - if (!redis) { - return; - } - - return trace( - { - operation: `redis.set`, - name: key, - }, - async () => { - const expire = getCacheMaxAge(entry.meta); - - // Don't cache for less than 10min, as it's not worth it - if (expire <= 10 * 60) { - return; - } - - const multi = redis.multi(); - const valueKey = getCacheEntryKey(key, 'value'); - const metaKey = getCacheEntryKey(key, 'meta'); - - entry.meta.tags.forEach((tag) => { - const redisTagKey = getCacheTagKey(tag); - - multi.sadd(redisTagKey, key); - - // 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'); - }); - - multi.set(valueKey, entry); - multi.set(metaKey, entry.meta); - multi.expire(valueKey, expire); - multi.expire(metaKey, expire); - - await multi.exec(); - }, - ); - }, - - async del(keys) { - const redis = getRedis(); - if (!redis) { - return; - } - - const multi = redis.multi(); - keys.forEach((key) => { - multi.del(getCacheEntryKey(key, 'value')); - multi.del(getCacheEntryKey(key, 'meta')); - }); - - await multi.exec(); - }, - - async revalidateTags(tags) { - const redis = getRedis(); - if (!redis) { - return { keys: [], metas: [] }; - } - - const keys = new Set( - (await Promise.all(tags.map((tag) => redis.smembers(getCacheTagKey(tag))))).flat(), - ); - - const pipeline = redis.pipeline(); - let metas: Array = []; - - if (keys.size > 0) { - // Read the meta - metas = ( - await redis.mget>( - // Hard limit to avoid fetching a massive list of data - // Starts with the smallest keys. - Array.from(keys) - .sort((a, b) => a.length - b.length) - .slice(0, 50) - .map((key) => getCacheEntryKey(key, 'meta')), - ) - ) - .flat() - .filter(filterOutNullable); - - // Delete all keys - keys.forEach((key) => { - pipeline.del(getCacheEntryKey(key, 'value')); - }); - } - - // And delete the tags - tags.forEach((tag) => { - pipeline.del(getCacheTagKey(tag)); - }); - - await pipeline.exec(); - return { keys: Array.from(keys), metas: 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, - automaticDeserialization: true, - }) - : null; -} - -/** - * Get the key for a tag. - */ -function getCacheTagKey(tag: string) { - return getRedisKey(`tags.${tag}`); -} - -/** - * Get the key for an entry. - */ -function getCacheEntryKey(key: string, type: 'meta' | 'value') { - return getRedisKey(`entry.${key}.${type}`); -} - -/** - * Create a redis key for a cache entry. - */ -function getRedisKey(key: string) { - return `${cacheNamespace}.${cacheVersion}.${key}`; -} diff --git a/packages/gitbook/src/lib/cache/revalidateTags.ts b/packages/gitbook/src/lib/cache/revalidateTags.ts index 13c7453b7..9cd029024 100644 --- a/packages/gitbook/src/lib/cache/revalidateTags.ts +++ b/packages/gitbook/src/lib/cache/revalidateTags.ts @@ -1,83 +1,71 @@ import { cacheBackends } from './backends'; -import { getCacheKey } from './cache'; -import { CacheEntryMeta } from './types'; +import { CacheEntryLookup } from './types'; interface RevalidateTagsStats { [key: string]: { + /** + * Tag associated with the key. + */ + tag?: string; + /** * Backends that have the key. */ - [backend: string]: { set: boolean; setAt?: number; expiresAt?: number }; + backends: Record; }; } /** - * Revalidate all values associated with tags. - * It clears the values from the caches, but also start a background task to revalidate them. + * Purge all cache entries associated with the given tags. + * TODO: Implement background revalidation. */ export async function revalidateTags(tags: string[]): Promise<{ - keys: string[]; stats: RevalidateTagsStats; }> { if (tags.length === 0) { - return { keys: [], stats: {} }; + return { stats: {} }; } const stats: RevalidateTagsStats = {}; - const processed = new Set(); - const keysByBackend = new Map(); - const keys = new Set(); - const metas: CacheEntryMeta[] = []; + const entries = new Map(); await Promise.all( cacheBackends.map(async (backend, backendIndex) => { - const { keys: addedKeys, metas: addedMetas } = await backend.revalidateTags(tags); + const { entries: addedEntries } = await backend.revalidateTags(tags); - console.log('revalidateTags', backend.name, addedKeys); + addedEntries.forEach(({ key, tag }) => { + stats[key] = stats[key] ?? { + tag, + backends: {}, + }; + stats[key].backends[backend.name] = { set: true }; - addedKeys.forEach((key) => { - stats[key] = stats[key] ?? {}; - stats[key][backend.name] = { set: true }; - - keys.add(key); + entries.set(key, { tag, key }); keysByBackend.set(backendIndex, [...(keysByBackend.get(backendIndex) ?? []), key]); }); - addedMetas.forEach((meta) => { - const key = meta.key ?? getCacheKey(meta.cache, meta.args); - stats[key] = stats[key] ?? {}; - stats[key][backend.name] = { set: true }; - stats[key][backend.name].setAt = meta.setAt; - stats[key][backend.name].expiresAt = meta.expiresAt; - - if (!processed.has(key)) { - metas.push(meta); - processed.add(key); - } - }); }), ); // Clear the keys on the backends that didn't return them await Promise.all( cacheBackends.map(async (backend, backendIndex) => { - const unclearedKeys = Array.from(keys).filter( - (key) => !keysByBackend.get(backendIndex)?.includes(key), + const unclearedEntries = Array.from(entries.values()).filter( + (entry) => !keysByBackend.get(backendIndex)?.includes(entry.key), ); - if (unclearedKeys.length > 0) { - unclearedKeys.forEach((key) => { - stats[key][backend.name] = { set: false }; + if (unclearedEntries.length > 0) { + unclearedEntries.forEach((entry) => { + stats[entry.key].backends[backend.name] = { set: false }; }); - await backend.del(unclearedKeys); + await backend.del(unclearedEntries); } }), ); return { - keys: Array.from(keys.keys()), stats, }; } diff --git a/packages/gitbook/src/lib/cache/types.ts b/packages/gitbook/src/lib/cache/types.ts index ec12b9b46..0f5a2550e 100644 --- a/packages/gitbook/src/lib/cache/types.ts +++ b/packages/gitbook/src/lib/cache/types.ts @@ -1,6 +1,9 @@ -export interface CacheEntryMeta { +export interface CacheEntryLookup { key: string; + tag?: string; +} +export interface CacheEntryMeta extends CacheEntryLookup { /** * Timestamp when the entry was created. */ @@ -21,12 +24,6 @@ export interface CacheEntryMeta { */ revalidatesAt?: number; - /** - * Tags associated with the entry, used for revalidation. - * If no tags is present, the entry is considered immutable. - */ - tags: string[]; - /** * Arguments that were passed to the function. */ @@ -51,21 +48,21 @@ export interface CacheBackend { /** * Get a value from the cache. */ - get(key: string, options?: { signal?: AbortSignal }): Promise; + get(entry: CacheEntryLookup, options?: { signal?: AbortSignal }): Promise; /** * Set a value in the cache. */ - set(key: string, entry: CacheEntry): Promise; + set(entry: CacheEntry): Promise; /** * Delete a value from the cache. */ - del(keys: string[]): Promise; + del(keys: CacheEntryLookup[]): Promise; /** * Revalidate all keys associated with tags. * It should return the meta of all entries that were revalidated. */ - revalidateTags(tags: string[]): Promise<{ keys: string[]; metas: CacheEntryMeta[] }>; + revalidateTags(tags: string[]): Promise<{ entries: Array }>; } diff --git a/packages/gitbook/src/lib/cache/utils.ts b/packages/gitbook/src/lib/cache/utils.ts index e4f1837fa..a14ee9ec8 100644 --- a/packages/gitbook/src/lib/cache/utils.ts +++ b/packages/gitbook/src/lib/cache/utils.ts @@ -23,5 +23,5 @@ export function getCacheMaxAge(meta: CacheEntryMeta, min?: number, max?: number) * Return true if a cache entry can be considered immutable. */ export function isCacheEntryImmutable(meta: CacheEntryMeta): boolean { - return !meta.tags || meta.tags.length === 0; + return !meta.tag; } diff --git a/packages/gitbook/src/lib/openapi.ts b/packages/gitbook/src/lib/openapi.ts index 18aff1cfc..a079295e3 100644 --- a/packages/gitbook/src/lib/openapi.ts +++ b/packages/gitbook/src/lib/openapi.ts @@ -48,27 +48,30 @@ export async function fetchOpenAPIBlock( } const fetcher: OpenAPIFetcher = { - fetch: cache('openapi.fetch', async (url: string, options: CacheFunctionOptions) => { - // Wrap the raw string to prevent invalid URLs from being passed to fetch. - // This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch: - // https://github.com/cloudflare/workerd/issues/1957 - const response = await fetch(new URL(url), { - ...noCacheFetchOptions, - signal: options.signal, - }); + fetch: cache({ + name: 'openapi.fetch', + get: async (url: string, options: CacheFunctionOptions) => { + // Wrap the raw string to prevent invalid URLs from being passed to fetch. + // This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch: + // https://github.com/cloudflare/workerd/issues/1957 + const response = await fetch(new URL(url), { + ...noCacheFetchOptions, + signal: options.signal, + }); - if (!response.ok) { - throw new Error( - `Failed to fetch OpenAPI file: ${response.status} ${response.statusText}`, - ); - } + if (!response.ok) { + throw new Error( + `Failed to fetch OpenAPI file: ${response.status} ${response.statusText}`, + ); + } - const text = await response.text(); - const data = await parseOpenAPIV3(url, text); - return { - ...parseCacheResponse(response), - data, - }; + const text = await response.text(); + const data = await parseOpenAPIV3(url, text); + return { + ...parseCacheResponse(response), + data, + }; + }, }), parseMarkdown, }; diff --git a/packages/gitbook/tsconfig.json b/packages/gitbook/tsconfig.json index 5b91e68fe..4374d4750 100644 --- a/packages/gitbook/tsconfig.json +++ b/packages/gitbook/tsconfig.json @@ -25,6 +25,6 @@ "bun-types" // add Bun global ] }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["next-env.d.ts", "cf-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules", "packages/react-openapi", "packages/react-math"] } diff --git a/turbo.json b/turbo.json index 981e863d9..7940ca37f 100644 --- a/turbo.json +++ b/turbo.json @@ -46,7 +46,13 @@ }, // Script run when the package is deployed "release": { - "dependsOn": ["^release", "build"] + "dependsOn": ["^release", "build"], + "env": ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"] + }, + // Script to run when deploying on main to preview/staging + "release:preview": { + "dependsOn": ["^release:preview", "build"], + "env": ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"] }, // Clean up the package "clean": {