From adf6190f2cb33088a3e01c71645a8cb41411680e Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 30 Dec 2024 14:57:20 -0600 Subject: [PATCH 1/3] added vdc lib --- core/app/api/temp-cache-headers/route.ts | 11 ++ core/lib/vdc/credentials-from-fetch.ts | 65 +++++++ core/lib/vdc/in-memory-cache.ts | 99 +++++++++++ core/lib/vdc/index.ts | 205 +++++++++++++++++++++++ core/lib/vdc/types.ts | 87 ++++++++++ 5 files changed, 467 insertions(+) create mode 100644 core/app/api/temp-cache-headers/route.ts create mode 100644 core/lib/vdc/credentials-from-fetch.ts create mode 100644 core/lib/vdc/in-memory-cache.ts create mode 100644 core/lib/vdc/index.ts create mode 100644 core/lib/vdc/types.ts diff --git a/core/app/api/temp-cache-headers/route.ts b/core/app/api/temp-cache-headers/route.ts new file mode 100644 index 0000000000..ee2a8496fb --- /dev/null +++ b/core/app/api/temp-cache-headers/route.ts @@ -0,0 +1,11 @@ +import { credentialsFromFetch } from '~/lib/vdc/credentials-from-fetch'; + +export async function GET(request: Request) { + const data = await credentialsFromFetch(request.headers); + + return new Response(JSON.stringify(data), { + headers: { + 'Content-Type': 'application/json', + }, + }); +} diff --git a/core/lib/vdc/credentials-from-fetch.ts b/core/lib/vdc/credentials-from-fetch.ts new file mode 100644 index 0000000000..c404db5620 --- /dev/null +++ b/core/lib/vdc/credentials-from-fetch.ts @@ -0,0 +1,65 @@ +/* eslint-disable no-async-promise-executor */ +/* eslint-disable no-console */ +/* eslint-disable @typescript-eslint/no-base-to-string */ +/* eslint-disable @typescript-eslint/no-misused-promises */ +export async function credentialsFromFetch(headers: Headers) { + return new Promise>(async (resolve, reject) => { + if (process.env.NODE_ENV !== 'production') { + resolve({}); + + return; + } + + const host = headers.get('x-vercel-sc-host'); + + if (!host) { + reject(new Error('Missing x-vercel-sc-host header')); + } + + const basepath = headers.get('x-vercel-sc-basepath'); + const original = globalThis.fetch; + const sentinelUrl = `https://vercel.com/robots.txt?id=${Math.random()}`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + if (!input.toString().startsWith(`https://${host}/`)) { + return original(input, init); + } + + const h = new Headers(init?.headers); + const url = h.get('x-vercel-cache-item-name'); + + if (url !== sentinelUrl) { + return original(input, init); + } + + console.log('h', input, url, h); + + const authorization = h.get('authorization'); + + if (!authorization) { + reject(new Error('Missing cache authorization header')); + } + + resolve({ + 'x-vercel-sc-headers': JSON.stringify({ + authorization: h.get('authorization'), + }), + 'x-vercel-sc-host': host || '', + 'x-vercel-sc-basepath': basepath || '', + }); + globalThis.fetch = original; + + return new Response(JSON.stringify({}), { + status: 510, + }); + }; + + try { + await fetch(sentinelUrl, { + cache: 'force-cache', + }); + } catch (e) { + console.info(e); + } + }); +} diff --git a/core/lib/vdc/in-memory-cache.ts b/core/lib/vdc/in-memory-cache.ts new file mode 100644 index 0000000000..ec3f845c8a --- /dev/null +++ b/core/lib/vdc/in-memory-cache.ts @@ -0,0 +1,99 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +/* eslint-disable @typescript-eslint/no-use-before-define */ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ +/* eslint-disable max-classes-per-file */ +/* eslint-disable @typescript-eslint/no-empty-function */ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable @typescript-eslint/consistent-type-assertions */ +/* eslint-disable @typescript-eslint/require-await */ +import { CacheHandler, CacheHandlerValue, IncrementalCacheValue, Revalidate } from './types'; + +let instance: InMemoryCacheHandlerInternal; + +interface SetContext { + revalidate?: Revalidate; + fetchCache?: boolean; + fetchUrl?: string; + fetchIdx?: number; + tags?: string[]; + isRoutePPREnabled?: boolean; + isFallback?: boolean; +} + +export class InMemoryCacheHandler implements CacheHandler { + constructor() { + instance = instance ?? new InMemoryCacheHandlerInternal(); + } + get(key: string) { + return instance.get(key); + } + set(key: string, data: IncrementalCacheValue | null, ctx: SetContext) { + return instance.set(key, data, ctx); + } + revalidateTag(tags: string | string[]) { + return instance.revalidateTag(tags); + } + resetRequestCache() { + instance.resetRequestCache(); + } +} + +class InMemoryCacheHandlerInternal implements CacheHandler { + readonly cache: Map< + string, + { + value: string; + lastModified: number; + tags: string[]; + revalidate: number | undefined; + } + >; + + constructor() { + this.cache = new Map(); + } + + async get(key: string): Promise { + const content = this.cache.get(key); + + if (content?.revalidate && content.lastModified + content.revalidate * 1000 < Date.now()) { + this.cache.delete(key); + + return null; + } + + if (content) { + return { + value: JSON.parse(content.value) as IncrementalCacheValue | null, + lastModified: content.lastModified, + age: Date.now() - content.lastModified, + }; + } + + return null; + } + + async set(key: string, data: IncrementalCacheValue | null, ctx: SetContext) { + // This could be stored anywhere, like durable storage + this.cache.set(key, { + value: JSON.stringify(data), + lastModified: Date.now(), + tags: ctx.tags || [], + revalidate: ctx.revalidate ? ctx.revalidate : undefined, + }); + } + + async revalidateTag(tag: string | string[]) { + const tags = [tag].flat(); + + // Iterate over all entries in the cache + for (const [key, value] of this.cache) { + // If the value's tags include the specified tag, delete this entry + if (value.tags.some((tag: string) => tags.includes(tag))) { + this.cache.delete(key); + } + } + } + + resetRequestCache() {} +} diff --git a/core/lib/vdc/index.ts b/core/lib/vdc/index.ts new file mode 100644 index 0000000000..76669cd265 --- /dev/null +++ b/core/lib/vdc/index.ts @@ -0,0 +1,205 @@ +/* eslint-disable no-console */ +/* eslint-disable @typescript-eslint/no-confusing-void-expression */ +/* eslint-disable @typescript-eslint/consistent-type-assertions */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable no-multi-assign */ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable @typescript-eslint/no-use-before-define */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { InMemoryCacheHandler } from './in-memory-cache'; +import { CachedRouteKind, CacheHandler, CacheHandlerContext, IncrementalCacheKind } from './types'; + +export interface ComputeCache { + get: (key: string) => Promise; + set: ( + key: string, + value: any, + options?: { tags?: string[]; revalidate?: number }, + ) => Promise; + revalidateTag: (tag: string) => Promise; +} + +export async function getComputeCache( + requestHeadersOverride?: Record | Request | Headers, +): Promise> { + const requestHeaders = await getCacheHeaders(getHeadersRecord(requestHeadersOverride)); + + console.log('requestHeaders', JSON.stringify(requestHeaders)); + + const internalCache = new FetchCacheConstructor({ + fetchCacheKeyPrefix: 'compute-cache', + revalidatedTags: [], + // Constructor deletes the headers, so we need to clone them + _requestHeaders: Object.assign({}, requestHeaders) as Record, + maxMemoryCacheSize: 0, + }); + + internalCache.resetRequestCache(); + + return { + get: async (key: string): Promise => { + const fullKey = getKey(key); + const content = await internalCache.get(fullKey, { + kind: IncrementalCacheKind.FETCH, + isFallback: false, + fetchUrl: `https://vercel.cache/${fullKey}`, + }); + + if (content) { + console.log('get', fullKey, content); + + const lastModified = content.lastModified; + const revalidate = content.value?.revalidate; + + if (revalidate && lastModified && lastModified + revalidate * 1000 < Date.now()) { + internalCache.resetRequestCache(); + + return; + } + + const value = content.value; + + if (!value) { + return; + } + + return JSON.parse(value.data.body); + } + }, + set: async (key: string, value: any, options?: { tags?: string[]; revalidate?: number }) => { + const fullKey = getKey(key); + const r = await internalCache.set( + fullKey, + { + kind: CachedRouteKind.FETCH, + data: { + headers: {}, + body: JSON.stringify(value), + url: `https://vercel.cache/${fullKey}`, + status: 200, + }, + // Magic tag for forever + revalidate: options?.revalidate ?? 0xfffffffe, + }, + { + fetchCache: true, + fetchUrl: `https://vercel.cache/${fullKey}`, + revalidate: options?.revalidate, + isFallback: false, + tags: options?.tags, + }, + ); + + console.log('set', fullKey, value, { + fetchCache: true, + fetchUrl: `https://vercel.cache/${fullKey}`, + revalidate: options?.revalidate, + isFallback: false, + tags: options?.tags, + }); + + // Temp hack. For some reason we cannot turn off the in-memory cache. + if (options?.revalidate) { + internalCache.resetRequestCache(); + } + + return r; + }, + revalidateTag: (tag: string | string[]) => { + const r = internalCache.revalidateTag(tag); + + internalCache.resetRequestCache(); + + return r; + }, + } as const; +} + +function getHeadersRecord( + requestHeadersOverride: + | Record + | Request + | Headers + | undefined, +): Record | undefined { + if (!requestHeadersOverride) { + return undefined; + } + + if (requestHeadersOverride instanceof Headers) { + return Object.fromEntries([...requestHeadersOverride.entries()]); + } + + if (requestHeadersOverride instanceof Request) { + return Object.fromEntries([...requestHeadersOverride.headers.entries()]); + } + + return requestHeadersOverride; +} + +let FetchCacheConstructor: new (ctx: CacheHandlerContext) => CacheHandler; + +initializeComputeCache(); + +export function initializeComputeCache() { + const cacheHandlersSymbol = Symbol.for('@next/cache-handlers'); + const _globalThis: typeof globalThis & { + [cacheHandlersSymbol]?: { + FetchCache?: new (ctx: CacheHandlerContext) => CacheHandler; + }; + } = globalThis; + const cacheHandlers = (_globalThis[cacheHandlersSymbol] ??= {}); + let { FetchCache } = cacheHandlers; + + if (!FetchCache) { + FetchCache = InMemoryCacheHandler; + + if (process.env.NODE_ENV === 'production') { + console.error('Cache handler not found'); + } + } + + FetchCacheConstructor = FetchCache; +} + +let cacheHeaders: Promise> | undefined; + +async function getCacheHeadersFromDeployedFunction() { + const r = await fetch(`https://${process.env.VERCEL_URL}/api/temp-cache-headers`, { + cache: 'no-store', + headers: { + 'x-vercel-protection-bypass': process.env.VERCEL_AUTOMATION_BYPASS_SECRET || '', + }, + }); + + if (!r.ok) { + cacheHeaders = undefined; + throw new Error('Failed to fetch cache headers'); + } + + return r.json(); +} + +async function getCacheHeaders( + requestHeaders?: Record, +): Promise> { + if (process.env.NODE_ENV !== 'production') { + return {}; + } + + if (cacheHeaders) { + return cacheHeaders; + } + + if (requestHeaders?.['x-vercel-sc-headers']) { + return requestHeaders; + } + + cacheHeaders = getCacheHeadersFromDeployedFunction(); + + return cacheHeaders; +} + +function getKey(key: string) { + return `compute-cache/${process.env.VERCEL_ENV || 'development'}/${key}`; +} diff --git a/core/lib/vdc/types.ts b/core/lib/vdc/types.ts new file mode 100644 index 0000000000..3f31f82491 --- /dev/null +++ b/core/lib/vdc/types.ts @@ -0,0 +1,87 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +export interface CacheHandler { + get( + _cacheKey: string, + _ctx: { + kind: IncrementalCacheKind; + revalidate?: Revalidate; + fetchUrl?: string; + fetchIdx?: number; + tags?: string[]; + softTags?: string[]; + isRoutePPREnabled?: boolean; + isFallback: boolean | undefined; + }, + ): Promise; + + set( + cacheKey: string, + entry: IncrementalCacheValue, + ctx: { + revalidate?: Revalidate; + fetchCache?: boolean; + fetchUrl?: string; + fetchIdx?: number; + tags?: string[]; + isRoutePPREnabled?: boolean; + isFallback?: boolean; + }, + ): Promise; + + revalidateTag(tags: string | string[]): Promise; + + resetRequestCache(): void; +} + +type CachedFetchData = { + headers: Record; + body: string; + url: string; + status?: number; +}; + +export interface IncrementalCacheValue { + kind: CachedRouteKind.FETCH; + data: CachedFetchData; + // tags are only present with file-system-cache + // fetch cache stores tags outside of cache entry + tags?: string[]; + revalidate: number; +} + +export enum CachedRouteKind { + APP_PAGE = 'APP_PAGE', + APP_ROUTE = 'APP_ROUTE', + PAGES = 'PAGES', + FETCH = 'FETCH', + REDIRECT = 'REDIRECT', + IMAGE = 'IMAGE', +} + +export enum IncrementalCacheKind { + APP_PAGE = 'APP_PAGE', + APP_ROUTE = 'APP_ROUTE', + PAGES = 'PAGES', + FETCH = 'FETCH', + IMAGE = 'IMAGE', +} + +export interface CacheHandlerValue { + lastModified?: number; + age?: number; + cacheState?: string; + value: IncrementalCacheValue | null; +} + +export interface CacheHandlerContext { + dev?: boolean; + flushToDisk?: boolean; + serverDistDir?: string; + maxMemoryCacheSize?: number; + fetchCacheKeyPrefix?: string; + prerenderManifest?: Record; + revalidatedTags: string[]; + _requestHeaders: Record; +} + +export type Revalidate = number | false; From a5f93e8fb8e51f20333995a299c03c98b187cf76 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 30 Dec 2024 15:00:06 -0600 Subject: [PATCH 2/3] added VDC support into with-routes --- core/middlewares/with-routes.ts | 56 +++++++++++++-------------------- 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/core/middlewares/with-routes.ts b/core/middlewares/with-routes.ts index 934935431e..c8c439d957 100644 --- a/core/middlewares/with-routes.ts +++ b/core/middlewares/with-routes.ts @@ -1,16 +1,17 @@ -import { NextFetchEvent, NextRequest, NextResponse } from 'next/server'; +/* eslint-disable no-console */ +import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; -import { kvKey, STORE_STATUS_KEY } from '~/lib/kv/keys'; - -import { kv } from '../lib/kv'; +import { ComputeCache, getComputeCache } from '~/lib/vdc'; import { type MiddlewareFactory } from './compose-middlewares'; +const STORE_STATUS_KEY = 'storeStatus'; + const GetRouteQuery = graphql(` query getRoute($path: String!) { site { @@ -116,12 +117,10 @@ type StorefrontStatusType = ReturnType, ): Promise => { const routeCache: RouteCache = { route: await getRoute(pathname, channelId), - expiryTime: Date.now() + 1000 * 60 * 30, // 30 minutes }; - event.waitUntil(kv.set(kvKey(pathname, channelId), routeCache)); + await computeCache.set(`${pathname}:${channelId}`, routeCache, { revalidate: 60 * 30 }); return routeCache; }; const updateStatusCache = async ( channelId: string, - event: NextFetchEvent, + computeCache: ComputeCache, ): Promise => { const status = await getStoreStatus(channelId); @@ -192,10 +188,9 @@ const updateStatusCache = async ( const statusCache: StorefrontStatusCache = { status, - expiryTime: Date.now() + 1000 * 60 * 5, // 5 minutes }; - event.waitUntil(kv.set(kvKey(STORE_STATUS_KEY, channelId), statusCache)); + await computeCache.set(`${STORE_STATUS_KEY}:${channelId}`, statusCache, { revalidate: 60 * 5 }); return statusCache; }; @@ -208,30 +203,25 @@ const clearLocaleFromPath = (path: string, locale: string) => { return path; }; -const getRouteInfo = async (request: NextRequest, event: NextFetchEvent) => { +const getRouteInfo = async (request: NextRequest) => { + const computeCache = await getComputeCache(request); + const locale = request.headers.get('x-bc-locale') ?? ''; const channelId = request.headers.get('x-bc-channel-id') ?? ''; try { const pathname = clearLocaleFromPath(request.nextUrl.pathname, locale); - let [routeCache, statusCache] = await kv.mget( - kvKey(pathname, channelId), - kvKey(STORE_STATUS_KEY, channelId), - ); - - // If caches are old, update them in the background and return the old data (SWR-like behavior) - // If cache is missing, update it and return the new data, but write to KV in the background - if (statusCache && statusCache.expiryTime < Date.now()) { - event.waitUntil(updateStatusCache(channelId, event)); - } else if (!statusCache) { - statusCache = await updateStatusCache(channelId, event); + let statusCache = await computeCache.get(`${STORE_STATUS_KEY}:${channelId}`); + + if (!statusCache) { + statusCache = await updateStatusCache(channelId, computeCache); } - if (routeCache && routeCache.expiryTime < Date.now()) { - event.waitUntil(updateRouteCache(pathname, channelId, event)); - } else if (!routeCache) { - routeCache = await updateRouteCache(pathname, channelId, event); + let routeCache = await computeCache.get(`${pathname}:${channelId}`); + + if (!routeCache) { + routeCache = await updateRouteCache(pathname, channelId, computeCache); } const parsedRoute = RouteCacheSchema.safeParse(routeCache); @@ -242,7 +232,6 @@ const getRouteInfo = async (request: NextRequest, event: NextFetchEvent) => { status: parsedStatus.success ? parsedStatus.data.status : undefined, }; } catch (error) { - // eslint-disable-next-line no-console console.error(error); return { @@ -253,10 +242,9 @@ const getRouteInfo = async (request: NextRequest, event: NextFetchEvent) => { }; export const withRoutes: MiddlewareFactory = () => { - return async (request, event) => { + return async (request) => { const locale = request.headers.get('x-bc-locale') ?? ''; - - const { route, status } = await getRouteInfo(request, event); + const { route, status } = await getRouteInfo(request); if (status === 'MAINTENANCE') { // 503 status code not working - https://github.com/vercel/next.js/issues/50155 From b794c2c46f885f234d71b181dcd5abfed93c0f8e Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 30 Dec 2024 15:52:09 -0600 Subject: [PATCH 3/3] parallelize cache fetching --- core/middlewares/with-routes.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/core/middlewares/with-routes.ts b/core/middlewares/with-routes.ts index c8c439d957..2d011cd2d8 100644 --- a/core/middlewares/with-routes.ts +++ b/core/middlewares/with-routes.ts @@ -212,17 +212,22 @@ const getRouteInfo = async (request: NextRequest) => { try { const pathname = clearLocaleFromPath(request.nextUrl.pathname, locale); - let statusCache = await computeCache.get(`${STORE_STATUS_KEY}:${channelId}`); - - if (!statusCache) { - statusCache = await updateStatusCache(channelId, computeCache); - } + const [statusCache, routeCache] = await Promise.all([ + computeCache.get(`${STORE_STATUS_KEY}:${channelId}`).then(async (cache) => { + if (!cache) { + return updateStatusCache(channelId, computeCache); + } - let routeCache = await computeCache.get(`${pathname}:${channelId}`); + return cache; + }), + computeCache.get(`${pathname}:${channelId}`).then(async (cache) => { + if (!cache) { + return updateRouteCache(pathname, channelId, computeCache); + } - if (!routeCache) { - routeCache = await updateRouteCache(pathname, channelId, computeCache); - } + return cache; + }), + ]); const parsedRoute = RouteCacheSchema.safeParse(routeCache); const parsedStatus = StorefrontStatusCacheSchema.safeParse(statusCache);