diff --git a/README.md b/README.md index 37c4d83..a1ec94c 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,19 @@ Read more: - Server side rendered - Reduced data transfer - Reduced client-side complexity - - Would require edge computing to reduce latency - - Data caching to speed up response time + - Advanced caching + - Data caching + - Reponses from the CMS are cached to speed up response time. + - When there is a change on the CMS, the cache is alerted through webhooks. + - The impacted responses are then invalidated, fetched again, and cached + - All possible reponses from the CMS are precached when the server loads. + - The cache is also saved on disk after any change, and is loading at startup if available. + - Page caching + - The pages themselves are cached to further speed up response time. + - When responses in the Data caching are invalidated, cached pages that depend on those reponses are also invalidated. They are then regenerated and cached again. + - Similarly to the data cache, all pages are precached when the server loads and the cache is saved on disk and loaded at startup. + - The server use the `Last-Modified` header and serve a `304 Not Modified` response when appropriate. + - The pages themselves use the `Cache-Control` header to allow local caching on the visitors' web browsers. - SEO diff --git a/src/cache/dataCache.ts b/src/cache/dataCache.ts index a7e4f82..da6d8c2 100644 --- a/src/cache/dataCache.ts +++ b/src/cache/dataCache.ts @@ -5,17 +5,19 @@ import { existsSync } from "fs"; const ON_DISK_ROOT = `.cache/dataCache`; const ON_DISK_RESPONSE_CACHE_FILE = `${ON_DISK_ROOT}/responseCache.json`; -const ON_DISK_INVALIDATION_MAP_FILE = `${ON_DISK_ROOT}/invalidationMap.json`; export class DataCache { private readonly logger = getLogger("[DataCache]"); private initialized = false; - private responseCache = new Map(); - private invalidationMap = new Map>(); + private readonly responseCache = new Map(); + private readonly invalidationMap = new Map>(); + + private scheduleSaveTimeout: NodeJS.Timeout | undefined; constructor( private readonly payload: PayloadSDK, + private readonly uncachedPayload: PayloadSDK, private readonly onInvalidate: (urls: string[]) => Promise ) {} @@ -30,38 +32,37 @@ export class DataCache { } private async precache() { - if (existsSync(ON_DISK_RESPONSE_CACHE_FILE) && existsSync(ON_DISK_INVALIDATION_MAP_FILE)) { + // Get all keys from CMS + const allSDKUrls = (await this.uncachedPayload.getAllSdkUrls()).data.urls; + + // Load cache from disk if available + if (existsSync(ON_DISK_RESPONSE_CACHE_FILE)) { this.logger.log("Loading cache from disk..."); - // Handle RESPONSE_CACHE_FILE - { - const buffer = await readFile(ON_DISK_RESPONSE_CACHE_FILE); - const data = JSON.parse(buffer.toString()); - this.responseCache = new Map(data); + const buffer = await readFile(ON_DISK_RESPONSE_CACHE_FILE); + const data = JSON.parse(buffer.toString()) as [string, any][]; + for (const [key, value] of data) { + // Do not include cache where the key is no longer in the CMS + if (!allSDKUrls.includes(key)) continue; + this.set(key, value); } - - // Handle INVALIDATION_MAP_FILE - { - const buffer = await readFile(ON_DISK_INVALIDATION_MAP_FILE); - const data = JSON.parse(buffer.toString()) as [string, string[]][]; - const deserialize = data.map<[string, Set]>(([key, value]) => [ - key, - new Set(value), - ]); - this.invalidationMap = new Map(deserialize); - } - } else { - const { data } = await this.payload.getAllSdkUrls(); - - for (const url of data.urls) { - try { - await this.payload.request(url); - } catch { - this.logger.warn("Precaching failed for url", url); - } - } - - await this.save(); } + + const cacheSizeBeforePrecaching = this.responseCache.size; + + for (const url of allSDKUrls) { + // Do not precache response if already included in the loaded cache from disk + if (this.responseCache.has(url)) continue; + try { + await this.payload.request(url); + } catch { + this.logger.warn("Precaching failed for url", url); + } + } + + if (cacheSizeBeforePrecaching !== this.responseCache.size) { + this.scheduleSave(); + } + this.logger.log("Precaching completed!", this.responseCache.size, "responses cached"); } @@ -91,7 +92,7 @@ export class DataCache { this.responseCache.set(url, response); this.logger.log("Cached response for", url); if (this.initialized) { - this.save(); + this.scheduleSave(); } } @@ -118,10 +119,19 @@ export class DataCache { this.onInvalidate([...urlsToInvalidate]); this.logger.log("There are currently", this.responseCache.size, "responses in cache."); if (this.initialized) { - this.save(); + this.scheduleSave(); } } + private scheduleSave() { + if (this.scheduleSaveTimeout) { + clearTimeout(this.scheduleSaveTimeout); + } + this.scheduleSaveTimeout = setTimeout(() => { + this.save(); + }, 10_000); + } + private async save() { if (!existsSync(ON_DISK_ROOT)) { await mkdir(ON_DISK_ROOT, { recursive: true }); @@ -132,13 +142,5 @@ export class DataCache { encoding: "utf-8", }); this.logger.log("Saved", ON_DISK_RESPONSE_CACHE_FILE); - - const serializedIdsCache = JSON.stringify( - [...this.invalidationMap].map(([key, value]) => [key, [...value]]) - ); - await writeFile(ON_DISK_INVALIDATION_MAP_FILE, serializedIdsCache, { - encoding: "utf-8", - }); - this.logger.log("Saved", ON_DISK_INVALIDATION_MAP_FILE); } } diff --git a/src/cache/pageCache.ts b/src/cache/pageCache.ts index 1ce70eb..409936e 100644 --- a/src/cache/pageCache.ts +++ b/src/cache/pageCache.ts @@ -19,7 +19,9 @@ export class PageCache { private responseCache = new Map(); private invalidationMap = new Map>(); - constructor(private readonly payload: PayloadSDK) {} + private scheduleSaveTimeout: NodeJS.Timeout | undefined; + + constructor(private readonly uncachedPayload: PayloadSDK) {} async init() { if (this.initialized) return; @@ -32,16 +34,36 @@ export class PageCache { } private async precacheAll() { + const { data: languages } = await this.uncachedPayload.getLanguages(); + const locales = languages.map(({ id }) => id); + + // Get all pages urls from CMS + const allPagesUrls = [ + "/", + "/settings", + "/timeline", + ...(await this.uncachedPayload.getFolderSlugs()).data.map((slug) => `/folders/${slug}`), + ...(await this.uncachedPayload.getPageSlugs()).data.map((slug) => `/pages/${slug}`), + ...(await this.uncachedPayload.getCollectibleSlugs()).data.map( + (slug) => `/collectibles/${slug}` + ), + ].flatMap((url) => locales.map((id) => `/${id}${url}`)); + + // Load cache from disk if available if (existsSync(ON_DISK_RESPONSE_CACHE_FILE) && existsSync(ON_DISK_INVALIDATION_MAP_FILE)) { this.logger.log("Loading cache from disk..."); // Handle RESPONSE_CACHE_FILE { const buffer = await readFile(ON_DISK_RESPONSE_CACHE_FILE); const data = JSON.parse(buffer.toString()) as [string, SerializableResponse][]; - const deserializedData = data.map<[string, Response]>(([key, value]) => [ + let deserializedData = data.map<[string, Response]>(([key, value]) => [ key, deserializeResponse(value), ]); + + // Do not include cache where the key is no longer in the CMS + deserializedData = deserializedData.filter(([key]) => allPagesUrls.includes(key)); + this.responseCache = new Map(deserializedData); } @@ -55,48 +77,27 @@ export class PageCache { ]); this.invalidationMap = new Map(deserialize); } - } else { - const { data: languages } = await this.payload.getLanguages(); - const locales = languages.map(({ id }) => id); + } - await this.precache("/", locales); - await this.precache("/settings", locales); - await this.precache("/timeline", locales); + const cacheSizeBeforePrecaching = this.responseCache.size; - const { data: folders } = await this.payload.getFolderSlugs(); - for (const slug of folders) { - await this.precache(`/folders/${slug}`, locales); + for (const url of allPagesUrls) { + // Do not precache response if already included in the loaded cache from disk + if (this.responseCache.has(url)) continue; + try { + await fetch(`http://${import.meta.env.ASTRO_HOST}:${import.meta.env.ASTRO_PORT}${url}`); + } catch (e) { + this.logger.warn("Precaching failed for page", url); } + } - const { data: pages } = await this.payload.getPageSlugs(); - for (const slug of pages) { - await this.precache(`/pages/${slug}`, locales); - } - - const { data: collectibles } = await this.payload.getCollectibleSlugs(); - for (const slug of collectibles) { - await this.precache(`/collectibles/${slug}`, locales); - } - - await this.save(); + if (cacheSizeBeforePrecaching !== this.responseCache.size) { + this.scheduleSave(); } this.logger.log("Precaching completed!", this.responseCache.size, "responses cached"); } - private async precache(pathname: string, locales: string[]) { - try { - await Promise.all( - locales.map((locale) => { - const url = `http://${import.meta.env.ASTRO_HOST}:${import.meta.env.ASTRO_PORT}/${locale}${pathname}`; - return fetch(url); - }) - ); - } catch (e) { - this.logger.warn("Precaching failed for page", pathname); - } - } - get(url: string): Response | undefined { const cachedPage = this.responseCache.get(url); if (cachedPage) { @@ -119,7 +120,7 @@ export class PageCache { this.responseCache.set(url, response.clone()); this.logger.log("Cached response for", url); if (this.initialized) { - this.save(); + this.scheduleSave(); } } @@ -145,10 +146,19 @@ export class PageCache { this.logger.log("There are currently", this.responseCache.size, "responses in cache."); if (this.initialized) { - this.save(); + this.scheduleSave(); } } + private scheduleSave() { + if (this.scheduleSaveTimeout) { + clearTimeout(this.scheduleSaveTimeout); + } + this.scheduleSaveTimeout = setTimeout(() => { + this.save(); + }, 10_000); + } + private async save() { if (!existsSync(ON_DISK_ROOT)) { await mkdir(ON_DISK_ROOT, { recursive: true }); diff --git a/src/pages/[locale]/api/on-startup.ts b/src/pages/[locale]/api/on-startup.ts index ead2ad9..ba6eb85 100644 --- a/src/pages/[locale]/api/on-startup.ts +++ b/src/pages/[locale]/api/on-startup.ts @@ -1,9 +1,8 @@ import type { APIRoute } from "astro"; -import { contextCache, dataCache, pageCache } from "src/utils/payload"; +import { dataCache, pageCache } from "src/utils/payload"; export const GET: APIRoute = async () => { await dataCache.init(); - await contextCache.init(); await pageCache.init(); return new Response(null, { status: 200, statusText: "Ok" }); }; diff --git a/src/utils/payload.ts b/src/utils/payload.ts index a6cd2fe..0e3aacf 100644 --- a/src/utils/payload.ts +++ b/src/utils/payload.ts @@ -4,17 +4,28 @@ import { PageCache } from "src/cache/pageCache"; import { TokenCache } from "src/cache/tokenCache"; import { PayloadSDK } from "src/shared/payload/payload-sdk"; +const tokenCache = new TokenCache(); + const payload = new PayloadSDK( import.meta.env.PAYLOAD_API_URL, import.meta.env.PAYLOAD_USER, import.meta.env.PAYLOAD_PASSWORD ); +payload.addTokenCache(tokenCache); +const uncachedPayload = new PayloadSDK( + import.meta.env.PAYLOAD_API_URL, + import.meta.env.PAYLOAD_USER, + import.meta.env.PAYLOAD_PASSWORD +); +uncachedPayload.addTokenCache(tokenCache); +const pageCache = new PageCache(uncachedPayload); + +// Loading context cache first so that the server can still serve responses while precaching. const contextCache = new ContextCache(payload); -const pageCache = new PageCache(payload); -const dataCache = new DataCache(payload, (urls) => pageCache.invalidate(urls)); +await contextCache.init(); -payload.addTokenCache(new TokenCache()); +const dataCache = new DataCache(payload, uncachedPayload, (urls) => pageCache.invalidate(urls)); payload.addDataCache(dataCache); export { payload, contextCache, pageCache, dataCache };