@netlify/next-runtime
Version:
Run Next.js seamlessly on Netlify
227 lines (226 loc) • 9.83 kB
JavaScript
;
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;
}
};