diff --git a/packages/gitbook-v2/.gitignore b/packages/gitbook-v2/.gitignore index 84254823d2..a23d5b7565 100644 --- a/packages/gitbook-v2/.gitignore +++ b/packages/gitbook-v2/.gitignore @@ -6,6 +6,7 @@ # cloudflare .open-next +.wrangler # Symbolic links public diff --git a/packages/gitbook-v2/open-next.config.ts b/packages/gitbook-v2/open-next.config.ts index 4ff4f74e88..060cad541c 100644 --- a/packages/gitbook-v2/open-next.config.ts +++ b/packages/gitbook-v2/open-next.config.ts @@ -1,6 +1,4 @@ import { defineCloudflareConfig } from '@opennextjs/cloudflare'; -import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache'; -import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache'; import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue'; import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache'; import { @@ -9,7 +7,7 @@ import { } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter'; export default defineCloudflareConfig({ - incrementalCache: withRegionalCache(r2IncrementalCache, { mode: 'long-lived' }), + incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default), tagCache: withFilter({ tagCache: doShardedTagCache({ baseShardSize: 12, diff --git a/packages/gitbook-v2/openNext/incrementalCache.ts b/packages/gitbook-v2/openNext/incrementalCache.ts new file mode 100644 index 0000000000..e96989760b --- /dev/null +++ b/packages/gitbook-v2/openNext/incrementalCache.ts @@ -0,0 +1,181 @@ +import { createHash } from 'node:crypto'; + +import { trace } from '@/lib/tracing'; +import type { + CacheEntryType, + CacheValue, + IncrementalCache, + WithLastModified, +} from '@opennextjs/aws/types/overrides.js'; +import { getCloudflareContext } from '@opennextjs/cloudflare'; + +export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET'; +export const DEFAULT_PREFIX = 'incremental-cache'; + +export type KeyOptions = { + cacheType?: CacheEntryType; +}; + +class GitbookIncrementalCache implements IncrementalCache { + name = 'GitbookIncrementalCache'; + + protected localCache: Cache | undefined; + + async get( + key: string, + cacheType?: CacheType + ): Promise> | null> { + return trace( + { + operation: 'openNextIncrementalCacheGet', + name: key, + }, + async (span) => { + span.setAttribute('cacheType', cacheType ?? 'cache'); + const r2 = getCloudflareContext().env[BINDING_NAME]; + const localCache = await this.getCacheInstance(); + if (!r2) throw new Error('No R2 bucket'); + try { + const cacheKey = this.getR2Key(key, cacheType); + // Check local cache first if available + const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey)); + if (localCacheEntry) { + span.setAttribute('cacheHit', 'local'); + return localCacheEntry.json(); + } + + const r2Object = await r2.get(this.getR2Key(key, cacheType)); + if (!r2Object) return null; + + span.setAttribute('cacheHit', 'r2'); + return { + value: await r2Object.json(), + lastModified: r2Object.uploaded.getTime(), + }; + } catch (e) { + console.error('Failed to get from cache', e); + return null; + } + } + ); + } + + async set( + key: string, + value: CacheValue, + cacheType?: CacheType + ): Promise { + return trace( + { + operation: 'openNextIncrementalCacheSet', + name: key, + }, + async (span) => { + span.setAttribute('cacheType', cacheType ?? 'cache'); + const r2 = getCloudflareContext().env[BINDING_NAME]; + const localCache = await this.getCacheInstance(); + if (!r2) throw new Error('No R2 bucket'); + + try { + const cacheKey = this.getR2Key(key, cacheType); + await r2.put(cacheKey, JSON.stringify(value)); + + //TODO: Check if there is any places where we don't have tags + // Ideally we should always have tags, but in case we don't, we need to decide how to handle it + // For now we default to a build ID tag, which allow us to invalidate the cache in case something is wrong in this deployment + const tags = this.getTagsFromCacheEntry(value) ?? [ + `build_id/${process.env.NEXT_BUILD_ID}`, + ]; + + // We consider R2 as the source of truth, so we update the local cache + // only after a successful R2 write + await localCache.put( + this.getCacheUrlKey(cacheKey), + new Response( + JSON.stringify({ + value, + // Note: `Date.now()` returns the time of the last IO rather than the actual time. + // See https://developers.cloudflare.com/workers/reference/security-model/ + lastModified: Date.now(), + }), + { + headers: { + // Cache-Control default to 30 minutes, will be overridden by `revalidate` + // In theory we should always get the `revalidate` value + 'cache-control': `max-age=${value.revalidate ?? 60 * 30}`, + 'cache-tag': tags.join(','), + }, + } + ) + ); + } catch (e) { + console.error('Failed to set to cache', e); + } + } + ); + } + + async delete(key: string): Promise { + return trace( + { + operation: 'openNextIncrementalCacheDelete', + name: key, + }, + async () => { + const r2 = getCloudflareContext().env[BINDING_NAME]; + const localCache = await this.getCacheInstance(); + if (!r2) throw new Error('No R2 bucket'); + + try { + const cacheKey = this.getR2Key(key); + + await r2.delete(cacheKey); + + // Here again R2 is the source of truth, so we delete from local cache first + await localCache.delete(this.getCacheUrlKey(cacheKey)); + } catch (e) { + console.error('Failed to delete from cache', e); + } + } + ); + } + + async getCacheInstance(): Promise { + if (this.localCache) return this.localCache; + this.localCache = await caches.open('incremental-cache'); + return this.localCache; + } + + // Utility function to generate keys for R2/Cache API + getR2Key(key: string, cacheType?: CacheEntryType): string { + const hash = createHash('sha256').update(key).digest('hex'); + return `${DEFAULT_PREFIX}/${cacheType === 'cache' ? process.env?.NEXT_BUILD_ID : 'dataCache'}/${hash}.${cacheType}`.replace( + /\/+/g, + '/' + ); + } + + getCacheUrlKey(cacheKey: string): string { + return `http://cache.local/${cacheKey}`; + } + + getTagsFromCacheEntry( + entry: CacheValue + ): string[] | undefined { + if ('tags' in entry && entry.tags) { + return entry.tags; + } + + if ('meta' in entry && entry.meta && 'headers' in entry.meta && entry.meta.headers) { + const rawTags = entry.meta.headers['x-next-cache-tags']; + if (typeof rawTags === 'string') { + return rawTags.split(','); + } + } + if ('value' in entry) { + return entry.tags; + } + } +} + +export default new GitbookIncrementalCache(); diff --git a/packages/gitbook-v2/package.json b/packages/gitbook-v2/package.json index 646bfb4719..5a18a80be4 100644 --- a/packages/gitbook-v2/package.json +++ b/packages/gitbook-v2/package.json @@ -29,8 +29,8 @@ "build:v2": "next build", "start": "next start", "build:v2:cloudflare": "opennextjs-cloudflare build", - "dev:v2:cloudflare": "wrangler dev --port 8771", + "dev:v2:cloudflare": "wrangler dev --port 8771 --env preview", "unit": "bun test", "typecheck": "tsc --noEmit" } -} +} \ No newline at end of file