UNPKG

@netlify/next-runtime

Version:
227 lines (226 loc) 9.83 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.blobStore = void 0; // Netlify Cache Handler // (CJS format because Next.js doesn't support ESM yet) // const blobs_1 = require("@netlify/blobs"); const functions_1 = require("@netlify/functions"); const node_fs_1 = require("node:fs"); const constants_js_1 = require("next/dist/lib/constants.js"); const posix_1 = require("node:path/posix"); const tagsManifestPath = '.netlify/cache/tags'; exports.blobStore = (0, blobs_1.getDeployStore)(); // load the prerender manifest const prerenderManifest = JSON.parse((0, node_fs_1.readFileSync)((0, posix_1.join)(process.cwd(), '.next/prerender-manifest.json'), 'utf-8')); /** Converts a cache key pathname to a route */ function toRoute(pathname) { return pathname.replace(/\/$/, '').replace(/\/index$/, '') || '/'; } // borrowed from https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/page-path/normalize-page-path.ts#L14 function ensureLeadingSlash(path) { return path.startsWith('/') ? path : `/${path}`; } function isDynamicRoute(path) { const dynamicRoutes = prerenderManifest.dynamicRoutes; Object.values(dynamicRoutes).find((route) => { return new RegExp(route.routeRegex).test(path); }); } function normalizePath(path) { // If there is a page that is '/index' the first statement ensures that it will be '/index/index' return /^\/index(\/|$)/.test(path) && !isDynamicRoute(path) ? `/index${path}` : path === '/' ? '/index' : ensureLeadingSlash(path); } module.exports = class NetlifyCacheHandler { options; revalidatedTags; /** Indicates if the application is using the new appDir */ #appDir; constructor(options) { this.#appDir = Boolean(options._appDir); this.options = options; this.revalidatedTags = options.revalidatedTags; } async get(...args) { const [cacheKey, ctx = {}] = args; console.debug(`[NetlifyCacheHandler.get]: ${cacheKey}`); const blob = await this.getBlobKey(cacheKey, ctx.fetchCache); // if blob is null then we don't have a cache entry if (!blob) { return null; } const revalidateAfter = this.calculateRevalidate(cacheKey, blob.lastModified, ctx); const isStale = revalidateAfter !== false && revalidateAfter < Date.now(); const staleByTags = await this.checkCacheEntryStaleByTags(blob, ctx.softTags); console.debug(`!!! CHACHE KEY: ${cacheKey} - is stale: `, { isStale, staleByTags }); if (staleByTags || isStale) { return null; } switch (blob.value.kind) { case 'FETCH': return { lastModified: blob.lastModified, value: { kind: blob.value.kind, data: blob.value.data, revalidate: ctx.revalidate || 1, }, }; case 'ROUTE': return { lastModified: blob.lastModified, value: { body: Buffer.from(blob.value.body), kind: blob.value.kind, status: blob.value.status, headers: blob.value.headers, }, }; case 'PAGE': return { lastModified: blob.lastModified, value: blob.value, }; } } async set(...args) { const [key, data, ctx] = args; console.debug(`[NetlifyCacheHandler.set]: ${key}`); let cacheKey = null; switch (data?.kind) { case 'ROUTE': cacheKey = (0, posix_1.join)('server/app', key); break; case 'FETCH': cacheKey = (0, posix_1.join)('cache/fetch-cache', key); break; case 'PAGE': cacheKey = typeof data.pageData === 'string' ? (0, posix_1.join)('server/app', key) : (0, posix_1.join)('server/pages', key); break; default: console.debug(`TODO: implement NetlifyCacheHandler.set for ${key}`, { data, ctx }); } if (cacheKey) { await exports.blobStore.setJSON(cacheKey, { lastModified: Date.now(), value: data, }); } } async revalidateTag(tag, ...args) { console.debug('NetlifyCacheHandler.revalidateTag', tag, args); const data = { revalidatedAt: Date.now(), }; try { console.log('set cache tag for ', { tag: this.tagManifestPath(tag), data }); await exports.blobStore.setJSON(this.tagManifestPath(tag), data); } catch (error) { console.warn(`Failed to update tag manifest for ${tag}`, error); } (0, functions_1.purgeCache)({ tags: [tag] }).catch((error) => { // TODO: add reporting here console.error(`[NetlifyCacheHandler]: Purging the cache for tag ${tag} failed`, error); }); } // eslint-disable-next-line class-methods-use-this tagManifestPath(tag) { return [tagsManifestPath, tag].join('/'); } /** * Computes a cache key and tries to load it for different scenarios (app/server or fetch) * @param key The cache key used by next.js * @param fetch If it is a FETCH request or not * @returns the parsed data from the cache or null if not */ async getBlobKey(key, fetch) { const normalizedKey = normalizePath(key); // Want to avoid normalizaing if '/index' is being passed as a key. const appKey = (0, posix_1.join)('server/app', key === '/index' ? key : normalizedKey); const pagesKey = (0, posix_1.join)('server/pages', key === '/index' ? key : normalizedKey); const fetchKey = (0, posix_1.join)('cache/fetch-cache', key); if (fetch) { return await exports.blobStore .get(fetchKey, { type: 'json' }) .then((res) => (res !== null ? { path: fetchKey, isAppPath: false, ...res } : null)); } // pagesKey needs to be requested first as there could be both sadly const values = await Promise.all([ exports.blobStore .get(pagesKey, { type: 'json' }) .then((res) => ({ path: pagesKey, isAppPath: false, ...res })), // only request the appKey if the whole application supports the app key !this.#appDir ? Promise.resolve(null) : exports.blobStore .get(appKey, { type: 'json' }) .then((res) => ({ path: appKey, isAppPath: true, ...res })), ]); // just get the first item out of it that is defined (either the pageRoute or the appRoute) const [cacheEntry] = values.filter((keys) => keys && !!keys.value); // TODO: set the cache tags based on the tag manifest once we have that // if (cacheEntry) { // const cacheTags: string[] = // cacheEntry.value.headers?.[NEXT_CACHE_TAGS_HEADER]?.split(',') || [] // const manifests = await Promise.all( // cacheTags.map((tag) => blobStore.get(this.tagManifestPath(tag), { type: 'json' })), // ) // console.log(manifests) // } return cacheEntry || null; } /** * Checks if a page is stale through on demand revalidated tags */ async checkCacheEntryStaleByTags(cacheEntry, softTags = []) { const tags = 'headers' in cacheEntry.value ? cacheEntry.value.headers?.[constants_js_1.NEXT_CACHE_TAGS_HEADER]?.split(',') || [] : []; const cacheTags = [...tags, ...softTags]; const allManifests = await Promise.all(cacheTags.map(async (tag) => { const key = this.tagManifestPath(tag); const res = await exports.blobStore .get(key, { type: 'json' }) .then((value) => ({ [key]: value })) .catch(console.error); return res || { [key]: null }; })); const tagsManifest = Object.assign({}, ...allManifests); const isStale = cacheTags.some((tag) => { // TODO: test for this case if (tag && this.revalidatedTags?.includes(tag)) { return true; } const { revalidatedAt } = tagsManifest[this.tagManifestPath(tag)] || {}; return revalidatedAt && revalidatedAt >= (cacheEntry.lastModified || Date.now()); }); return isStale; } /** * Retrieves the milliseconds since midnight, January 1, 1970 when it should revalidate for a path. */ calculateRevalidate(pathname, fromTime, ctx, dev) { // in development we don't have a prerender-manifest // and default to always revalidating to allow easier debugging if (dev) return Date.now() - 1_000; if (ctx?.revalidate && typeof ctx.revalidate === 'number') { return fromTime + ctx.revalidate * 1_000; } // if an entry isn't present in routes we fallback to a default const { initialRevalidateSeconds } = prerenderManifest.routes[toRoute(pathname)] || { initialRevalidateSeconds: 0, }; // the initialRevalidate can be either set to false or to a number (representing the seconds) const revalidateAfter = typeof initialRevalidateSeconds === 'number' ? initialRevalidateSeconds * 1_000 + fromTime : initialRevalidateSeconds; return revalidateAfter; } };