From 4df6927abc99fa5e7e01d4a99ec630b780d7fe0a Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Wed, 12 Nov 2025 19:02:00 -0300 Subject: [PATCH] feat(Page): add cache control options and headers for CDN caching --- .gitignore | 2 + website/pages/Page.tsx | 95 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..765fc497c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.cursorindexingignore +.specstory/ \ No newline at end of file diff --git a/website/pages/Page.tsx b/website/pages/Page.tsx index 9d7ef46a0..da0c4d610 100644 --- a/website/pages/Page.tsx +++ b/website/pages/Page.tsx @@ -16,6 +16,11 @@ import { import { logger } from "@deco/deco/o11y"; import { Component, JSX } from "preact"; import ErrorPageComponent from "../../utils/defaultErrorPage.tsx"; +import { + DECO_PAGE_CACHE_ALLOW_HEADER, + DECO_PAGE_CACHE_CONTROL_HEADER, + normalizeCacheControlHeader, +} from "@deco/deco/utils"; import OneDollarStats from "../components/OneDollarStats.tsx"; import Events from "../components/Events.tsx"; import { SEOSection } from "../components/Seo.tsx"; @@ -50,9 +55,55 @@ export interface Props { /** @hide true */ seo?: Section; sections: Sections; + /** + * @title Cache this page in CDN + * @description When enabled, the page is cached at the CDN edge. Only device and time variants are respected while caching; any other variant types will be ignored. If any uncached loader is preset, this will be automatically disabled. + * @default false + */ + cacheControl?: boolean; + /** + * @title Cache-Control header + * @description Choose the default safe header or provide a custom one + */ + cacheHeader?: CacheHeaderConfig; /** @hide true */ unindexedDomain?: boolean; } +/** + * @title Cache-Control header + */ +export type CacheHeaderConfig = CacheHeaderDefault | CacheHeaderCustom; + +/** + * @title Default + */ +interface CacheHeaderDefault { + /** + * @title Mode + * @default default + * @hide true + * @readOnly true + */ + mode?: "default"; +} + +/** + * @title Custom + */ +interface CacheHeaderCustom { + /** + * @title Mode + * @default custom + * @hide true + * @readOnly true + */ + mode?: "custom"; + /** + * @title Cache-Control header + * @description Example: public, s-maxage=60, max-age=10, stale-while-revalidate=3600, stale-if-error=86400 + */ + value: string; +} export function renderSection(section: Props["sections"][number]) { if (section === undefined || section === null) { return
; @@ -174,6 +225,50 @@ export const loader = async ( ? [ctx.theme, ...resolvedGlobals] : resolvedGlobals; + // Page-level cache-control: if configured, set headers early so render-time matchers can react. + const normalizedCC = (() => { + // Primary: boolean|string cacheControl + header options + const cc = (restProps as unknown as { cacheControl?: boolean | string }) + ?.cacheControl; + if (cc === true) { + const headerCfg = (restProps as unknown as { + cacheHeader?: CacheHeaderConfig; + })?.cacheHeader; + if ( + (headerCfg as CacheHeaderCustom)?.mode === "custom" && + (headerCfg as CacheHeaderCustom)?.value + ) { + return normalizeCacheControlHeader( + (headerCfg as CacheHeaderCustom).value, + ); + } + return normalizeCacheControlHeader(true); + } + + // Backward compatibility: union object previously used + const legacyUnion = (restProps as unknown as { + cache?: + | { mode?: "off" | "default" | "custom"; value?: string } + | undefined; + })?.cache; + if (legacyUnion?.mode === "default") { + return normalizeCacheControlHeader(true); + } + if (legacyUnion?.mode === "custom" && legacyUnion.value) { + return normalizeCacheControlHeader(legacyUnion.value); + } + // Legacy direct string support + if (typeof cc === "string") { + return normalizeCacheControlHeader(cc); + } + return undefined; // off or not set + })(); + if (normalizedCC) { + ctx.response.headers.set(DECO_PAGE_CACHE_CONTROL_HEADER, normalizedCC); + // Only device/time variants are allowed when page cache-control is on + ctx.response.headers.set(DECO_PAGE_CACHE_ALLOW_HEADER, "device,time"); + } + return { ...restProps, sections: [...globalSections, ...(Array.isArray(sections) ? sections : [])],