diff --git a/web/next.config.mjs b/web/next.config.mjs index 3ffcc6c..deac8d3 100644 --- a/web/next.config.mjs +++ b/web/next.config.mjs @@ -22,6 +22,13 @@ function remotePatterns() { const nextConfig = { output: "standalone", + cacheComponents: true, + partialPrefetching: true, + // Next renders every page per request, uncached, for the crawlers and + // link-preview bots on its "HTML-limited" list, so their metadata is never + // streamed after . No page here streams metadata (it all comes from + // cached CMS data), so no bot needs that; this pattern matches none + htmlLimitedBots: /(?!)/, images: { remotePatterns: remotePatterns(), formats: ["image/avif", "image/webp"], diff --git a/web/src/app/[...url]/page.tsx b/web/src/app/[...url]/page.tsx index b753d05..dea2a56 100644 --- a/web/src/app/[...url]/page.tsx +++ b/web/src/app/[...url]/page.tsx @@ -1,5 +1,5 @@ import { graphql } from "@/gql"; -import { getClient } from "@/app/client"; +import { cmsQuery } from "@/app/client"; import { Metadata, ResolvingMetadata } from "next"; import { notFound } from "next/navigation"; import { @@ -8,6 +8,13 @@ import { } from "@/components/general/GenericPageView"; import { getSeoMetadata } from "@/lib/seo"; +// Fail the build if this route stops being fully prerendered +export const ensureStatic = "navigation"; + +// Navigations wait for the page instead of showing a loading shell first: +// the response has to be able to turn into a real 404 for an unknown path +export const instant = false; + function getWagtailUrlPath(url: string[]): string { // for the page /foo/bar we need to look for `/home/foo/bar/` return `/home/${url.join("/")}/`; @@ -22,10 +29,7 @@ export async function generateStaticParams() { } } `); - const { data, error } = await getClient().query(allGenericSlugsQuery, {}); - if (error) { - throw new Error(error.message); - } + const data = await cmsQuery(allGenericSlugsQuery, {}); if (!data?.pages) { throw new Error("Failed to generate static params for generic subpages"); } diff --git a/web/src/app/aktuelt/[slug]/page.tsx b/web/src/app/aktuelt/[slug]/page.tsx index d20ab83..9580689 100644 --- a/web/src/app/aktuelt/[slug]/page.tsx +++ b/web/src/app/aktuelt/[slug]/page.tsx @@ -5,10 +5,17 @@ import { loadNewsPageProps, } from "@/components/news/NewsPageView"; import { getSeoMetadata } from "@/lib/seo"; +import { latestSlugParams } from "@/lib/staticParams"; + +// Fail the build if this route stops being fully prerendered +export const ensureStatic = "navigation"; + +// Navigations wait for the page instead of showing a loading shell first: +// the response has to be able to turn into a real 404 for an unknown path +export const instant = false; export async function generateStaticParams() { - // Prerender nothing at build time; render and cache on first request - return []; + return latestSlugParams("news.NewsPage"); } type Params = Promise<{ slug: string }>; diff --git a/web/src/app/aktuelt/page.tsx b/web/src/app/aktuelt/page.tsx index 7a0d54f..5e9ba5f 100644 --- a/web/src/app/aktuelt/page.tsx +++ b/web/src/app/aktuelt/page.tsx @@ -1,5 +1,5 @@ import { Metadata, ResolvingMetadata } from "next"; -import { getClient } from "@/app/client"; +import { cmsQuery } from "@/app/client"; import { NewsIndexView, loadNewsIndexProps, @@ -8,12 +8,14 @@ import { NewsIndexFragment } from "@/gql/graphql"; import { newsIndexMetadataQuery } from "@/lib/news"; import { getSeoMetadata } from "@/lib/seo"; +// Fail the build if this route stops being fully prerendered +export const ensureStatic = "navigation"; + export async function generateMetadata( _: unknown, parent: ResolvingMetadata ): Promise { - const { data, error } = await getClient().query(newsIndexMetadataQuery, {}); - if (error) throw new Error(error.message); + const data = await cmsQuery(newsIndexMetadataQuery, {}); if (!data?.index) return null; return getSeoMetadata(data.index as NewsIndexFragment, parent); } diff --git a/web/src/app/api/revalidate/route.test.ts b/web/src/app/api/revalidate/route.test.ts index 7b325ee..e99ed1c 100644 --- a/web/src/app/api/revalidate/route.test.ts +++ b/web/src/app/api/revalidate/route.test.ts @@ -1,21 +1,23 @@ -import type { NextRequest } from "next/server"; +import { NextRequest } from "next/server"; import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("next/cache", () => ({ revalidateTag: vi.fn() })); import { revalidateTag } from "next/cache"; -import { CMS_CACHE_TAG } from "@/lib/revalidation"; +import { setInternalRevalidateSecret } from "@/lib/internalRevalidate"; +import { CMS_CACHE_TAG, TODAY_CACHE_TAG } from "@/lib/revalidation"; import { POST } from "./route.ts"; -const request = (headers: Record = {}) => - new Request("http://localhost/api/revalidate", { +const request = (headers: Record = {}, query = "") => + new NextRequest(`http://localhost/api/revalidate${query}`, { method: "POST", headers, - }) as unknown as NextRequest; + }); afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); + setInternalRevalidateSecret(""); }); describe("POST /api/revalidate", () => { @@ -47,4 +49,119 @@ describe("POST /api/revalidate", () => { expect(await res.json()).toEqual({ revalidated: true, tag: CMS_CACHE_TAG }); expect(revalidateTag).toHaveBeenCalledWith(CMS_CACHE_TAG, { expire: 0 }); }); + + it("purges only the today tag when asked to", async () => { + vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", "hemmelig"); + + const res = await POST( + request({ "x-revalidate-secret": "hemmelig" }, "?tag=today") + ); + + expect(res.status).toBe(200); + expect(revalidateTag).toHaveBeenCalledTimes(1); + expect(revalidateTag).toHaveBeenCalledWith(TODAY_CACHE_TAG, { expire: 0 }); + }); + + it("rejects tags it doesn't know", async () => { + vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", "hemmelig"); + + const res = await POST( + request({ "x-revalidate-secret": "hemmelig" }, "?tag=noe-annet") + ); + + expect(res.status).toBe(400); + expect(revalidateTag).not.toHaveBeenCalled(); + }); + + describe("follow-up purge", () => { + const fetchMock = vi.fn(); + const followUpCalls = () => fetchMock.mock.calls.map(([url]) => String(url)); + + function setUp() { + vi.useFakeTimers(); + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockResolvedValue({ ok: true, status: 200 }); + vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", "hemmelig"); + vi.stubEnv("PORT", "4321"); + vi.stubEnv("HOSTNAME", "0.0.0.0"); + setInternalRevalidateSecret("intern"); + } + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + fetchMock.mockReset(); + }); + + it("purges again a few seconds after a webhook, as the server itself", async () => { + setUp(); + + await POST(request({ "x-revalidate-secret": "hemmelig" })); + await vi.advanceTimersByTimeAsync(4_000); + expect(fetchMock).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1_500); + expect(followUpCalls()).toEqual([ + "http://127.0.0.1:4321/api/revalidate?tag=cms", + ]); + expect(fetchMock.mock.calls[0][1].headers["x-revalidate-secret"]).toBe( + "intern" + ); + }); + + it("sends one follow-up after a burst of webhooks", async () => { + setUp(); + + for (let i = 0; i < 3; i++) { + await POST(request({ "x-revalidate-secret": "hemmelig" })); + await vi.advanceTimersByTimeAsync(2_000); + } + await vi.advanceTimersByTimeAsync(10_000); + + expect(followUpCalls()).toHaveLength(1); + }); + + it("is not scheduled by the server's own purges", async () => { + setUp(); + + await POST(request({ "x-revalidate-secret": "intern" }, "?tag=today")); + await vi.advanceTimersByTimeAsync(60_000); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("is skipped where the server can't call itself", async () => { + setUp(); + setInternalRevalidateSecret(""); + + await POST(request({ "x-revalidate-secret": "hemmelig" })); + await vi.advanceTimersByTimeAsync(60_000); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe("the server's own purges", () => { + it("are accepted without a configured webhook secret", async () => { + vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", undefined); + setInternalRevalidateSecret("intern"); + + const res = await POST( + request({ "x-revalidate-secret": "intern" }, "?tag=today") + ); + + expect(res.status).toBe(200); + expect(revalidateTag).toHaveBeenCalledWith(TODAY_CACHE_TAG, { expire: 0 }); + }); + + it("don't open the endpoint to an empty secret", async () => { + vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", "hemmelig"); + + expect((await POST(request())).status).toBe(401); + expect((await POST(request({ "x-revalidate-secret": "" }))).status).toBe( + 401 + ); + expect(revalidateTag).not.toHaveBeenCalled(); + }); + }); }); diff --git a/web/src/app/api/revalidate/route.ts b/web/src/app/api/revalidate/route.ts index 1c5f52d..22c8c12 100644 --- a/web/src/app/api/revalidate/route.ts +++ b/web/src/app/api/revalidate/route.ts @@ -2,7 +2,11 @@ import { timingSafeEqual } from "node:crypto"; import { revalidateTag } from "next/cache"; import { NextRequest, NextResponse } from "next/server"; -import { CMS_CACHE_TAG } from "@/lib/revalidation"; +import { + internalRevalidateSecret, + purgeOwnCache, +} from "@/lib/internalRevalidate"; +import { CACHE_TAGS, CMS_CACHE_TAG, type CacheTag } from "@/lib/revalidation"; function secretMatches(provided: string, expected: string): boolean { const a = Buffer.from(provided); @@ -10,19 +14,55 @@ function secretMatches(provided: string, expected: string): boolean { return a.length === b.length && timingSafeEqual(a, b); } +const FOLLOW_UP_DELAY_MS = 5_000; +const followUps = new Map>(); + +// Next stamps a cached page when its render finishes. A page that was being +// rendered while a purge came in (two saves in quick succession) therefore +// looks newer than the purge and would keep the older content. A second +// purge shortly after the last one in a burst catches those pages +function scheduleFollowUpPurge(tag: CacheTag) { + clearTimeout(followUps.get(tag)); + const timer = setTimeout(async () => { + followUps.delete(tag); + const error = await purgeOwnCache(tag); + if (error) console.error(`Follow-up purge of "${tag}" failed: ${error}`); + }, FOLLOW_UP_DELAY_MS); + timer.unref(); + followUps.set(tag, timer); +} + +// POST /api/revalidate purges the cms tag (the CMS webhook); +// POST /api/revalidate?tag=today purges only what depends on the day export async function POST(req: NextRequest) { - const secret = process.env.REVALIDATE_WEBHOOK_SECRET; - if (!secret) { - return NextResponse.json( - { error: "revalidation is not configured" }, - { status: 503 } - ); - } const provided = req.headers.get("x-revalidate-secret") ?? ""; - if (!secretMatches(provided, secret)) { - return NextResponse.json({ error: "invalid secret" }, { status: 401 }); + // the server's own startup and midnight purges (instrumentation.ts) + const internal = internalRevalidateSecret(); + const isInternal = internal !== undefined && secretMatches(provided, internal); + + if (!isInternal) { + const secret = process.env.REVALIDATE_WEBHOOK_SECRET; + if (!secret) { + return NextResponse.json( + { error: "revalidation is not configured" }, + { status: 503 } + ); + } + if (!secretMatches(provided, secret)) { + return NextResponse.json({ error: "invalid secret" }, { status: 401 }); + } + } + + const tag = req.nextUrl.searchParams.get("tag") ?? CMS_CACHE_TAG; + if (!CACHE_TAGS.includes(tag as CacheTag)) { + return NextResponse.json({ error: "unknown tag" }, { status: 400 }); } // expire: 0 purges immediately; the next request renders fresh - revalidateTag(CMS_CACHE_TAG, { expire: 0 }); - return NextResponse.json({ revalidated: true, tag: CMS_CACHE_TAG }); + revalidateTag(tag, { expire: 0 }); + // not after the server's own purges, which would loop; and only where the + // server can call itself (not in dev) + if (!isInternal && internal !== undefined) { + scheduleFollowUpPurge(tag as CacheTag); + } + return NextResponse.json({ revalidated: true, tag }); } diff --git a/web/src/app/api/v1/events/route.test.ts b/web/src/app/api/v1/events/route.test.ts index f469f24..17684db 100644 --- a/web/src/app/api/v1/events/route.test.ts +++ b/web/src/app/api/v1/events/route.test.ts @@ -2,9 +2,19 @@ import { NextRequest } from "next/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const queryMock = vi.fn(); +// mirrors cmsQueryForToday: returns the data, throws on a GraphQL error vi.mock("@/app/client", () => ({ - getClient: () => ({ query: queryMock }), + cmsQueryForToday: async (...args: unknown[]) => { + const { data, error } = await queryMock(...args); + if (error) throw new Error(error.message); + return data; + }, })); +// the real one is a server-only cached function; read the (fake) clock instead +vi.mock("@/lib/today", async () => { + const { osloDay, startOfOsloDay } = await import("@/lib/date"); + return { getToday: async () => startOfOsloDay(osloDay()) }; +}); import { GET } from "./route.ts"; diff --git a/web/src/app/api/v1/events/route.ts b/web/src/app/api/v1/events/route.ts index e90be01..46e3fac 100644 --- a/web/src/app/api/v1/events/route.ts +++ b/web/src/app/api/v1/events/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { getClient } from "@/app/client"; -import { cachedUntilOsloMidnight } from "@/lib/revalidation"; +import { cmsQueryForToday } from "@/app/client"; +import { getToday } from "@/lib/today"; import { eventsOverviewQuery, getSingularEvents, @@ -46,14 +46,7 @@ export async function GET(req: NextRequest) { ); } - const { data, error } = await getClient().query( - eventsOverviewQuery, - {}, - cachedUntilOsloMidnight() - ); - if (error) { - throw new Error(error.message); - } + const data = await cmsQueryForToday(eventsOverviewQuery, {}); if ( !data?.index || !data?.events?.futureEvents || @@ -64,6 +57,7 @@ export async function GET(req: NextRequest) { throw new Error("Failed to fetch events"); } + const today = await getToday(); const futureEvents = (data?.events?.futureEvents ?? []) as EventOverviewItemFragment[]; const eventCategories = (data?.eventCategories ?? []) as EventCategory[]; const eventOrganizers = (data?.eventOrganizers ?? []) as EventOrganizer[]; @@ -71,7 +65,7 @@ export async function GET(req: NextRequest) { const keepKeys = ["id", "slug", "title", "subtitle", "occurrence"]; const compactEvents = futureEvents.map((event) => { - const futureOccurrences = getFutureOccurrences(event); + const futureOccurrences = getFutureOccurrences(event, today); const nextOccurrence = futureOccurrences.length ? futureOccurrences[0] : null; diff --git a/web/src/app/arrangementer/[slug]/page.tsx b/web/src/app/arrangementer/[slug]/page.tsx index 69c607b..3658744 100644 --- a/web/src/app/arrangementer/[slug]/page.tsx +++ b/web/src/app/arrangementer/[slug]/page.tsx @@ -5,10 +5,17 @@ import { loadEventPageProps, } from "@/components/events/EventPageView"; import { getSeoMetadata } from "@/lib/seo"; +import { latestSlugParams } from "@/lib/staticParams"; + +// Fail the build if this route stops being fully prerendered +export const ensureStatic = "navigation"; + +// Navigations wait for the page instead of showing a loading shell first: +// the response has to be able to turn into a real 404 for an unknown path +export const instant = false; export async function generateStaticParams() { - // Prerender nothing at build time; render and cache on first request - return []; + return latestSlugParams("events.EventPage"); } type Params = Promise<{ slug: string }>; diff --git a/web/src/app/arrangementer/page.tsx b/web/src/app/arrangementer/page.tsx index f40dc1f..27da95b 100644 --- a/web/src/app/arrangementer/page.tsx +++ b/web/src/app/arrangementer/page.tsx @@ -1,5 +1,5 @@ import { Metadata, ResolvingMetadata } from "next"; -import { getClient } from "@/app/client"; +import { cmsQuery } from "@/app/client"; import { EventIndexView, loadEventIndexProps, @@ -8,22 +8,20 @@ import { EventIndexFragment } from "@/gql/graphql"; import { eventIndexMetadataQuery } from "@/lib/event"; import { getSeoMetadata } from "@/lib/seo"; +// The event list renders per request (it depends on the URL's filters), so +// only the shell around it has to stay prerendered +export const ensureStatic = "shell"; + export async function generateMetadata( _: unknown, parent: ResolvingMetadata ): Promise { - const { data, error } = await getClient().query(eventIndexMetadataQuery, {}); - if (error) throw new Error(error.message); + const data = await cmsQuery(eventIndexMetadataQuery, {}); if (!data?.index) return null; return getSeoMetadata(data.index as EventIndexFragment, parent); } -export default async function Page({ - searchParams, -}: { - searchParams?: Promise>; -}) { - await searchParams; +export default async function Page() { const props = await loadEventIndexProps(); return ; } diff --git a/web/src/app/client.ts b/web/src/app/client.ts index 7ff55ba..821fbc9 100644 --- a/web/src/app/client.ts +++ b/web/src/app/client.ts @@ -1,9 +1,16 @@ import "server-only"; -import { cacheExchange, createClient, fetchExchange } from "@urql/core"; +import { + type AnyVariables, + type TypedDocumentNode, + cacheExchange, + createClient, + fetchExchange, + stringifyDocument, +} from "@urql/core"; import { registerUrql } from "@urql/next/rsc"; -import { CMS_CACHE_TAG } from "@/lib/revalidation"; +import { cmsCache, cmsCacheForToday } from "@/lib/cmsCache"; const wagtailBaseUrl = process.env.WAGTAIL_BASE_URL; if (!wagtailBaseUrl) { @@ -15,10 +22,50 @@ const makeClient = () => { return createClient({ url: graphqlEndpoint, exchanges: [cacheExchange, fetchExchange], - // Cache all queries in the Data Cache until revalidateTag(CMS_CACHE_TAG); - // override per operation with the contexts in @/lib/revalidation - fetchOptions: { cache: "force-cache", next: { tags: [CMS_CACHE_TAG] } }, }); }; +// Uncached. Only for per-user (search) and token-scoped (preview) queries, +// together with the `uncached` context; everything else goes through cmsQuery export const { getClient } = registerUrql(makeClient); + +// Throws on GraphQL and network errors, which keeps them out of the cache +async function runQuery(query: string, variables: AnyVariables) { + const { data, error } = await getClient().query(query, variables); + if (error) throw new Error(error.message); + return data; +} + +// The cached functions take the query as text: their arguments are the cache +// key, and urql adds bookkeeping properties to document objects it has seen +async function cachedQuery(query: string, variables: AnyVariables) { + "use cache"; + cmsCache(); + return runQuery(query, variables); +} + +async function cachedQueryForToday(query: string, variables: AnyVariables) { + "use cache"; + cmsCacheForToday(); + return runQuery(query, variables); +} + +// Every cacheable CMS read goes through one of the two functions below, so +// all of them carry the tag the CMS webhook purges + +// Cached until the content is edited in the CMS +export function cmsQuery( + document: TypedDocumentNode, + variables: Variables +): Promise { + return cachedQuery(stringifyDocument(document), variables); +} + +// For queries where the CMS decides by today's date what to return +// (futureEvents): also purged at Oslo midnight +export function cmsQueryForToday( + document: TypedDocumentNode, + variables: Variables +): Promise { + return cachedQueryForToday(stringifyDocument(document), variables); +} diff --git a/web/src/app/foreninger/[slug]/page.tsx b/web/src/app/foreninger/[slug]/page.tsx index 4383f17..f93ebb6 100644 --- a/web/src/app/foreninger/[slug]/page.tsx +++ b/web/src/app/foreninger/[slug]/page.tsx @@ -1,6 +1,6 @@ import { Metadata, ResolvingMetadata } from "next"; import { notFound } from "next/navigation"; -import { getClient } from "@/app/client"; +import { cmsQuery } from "@/app/client"; import { AssociationPageView, loadAssociationPageProps, @@ -8,6 +8,13 @@ import { import { graphql } from "@/gql"; import { getSeoMetadata } from "@/lib/seo"; +// Fail the build if this route stops being fully prerendered +export const ensureStatic = "navigation"; + +// Navigations wait for the page instead of showing a loading shell first: +// the response has to be able to turn into a real 404 for an unknown path +export const instant = false; + type Params = Promise<{ slug: string }>; export async function generateMetadata( @@ -29,11 +36,7 @@ export async function generateStaticParams() { } } `); - const { data, error } = await getClient().query(allAssociationSlugsQuery, {}); - - if (error) { - throw new Error(error.message); - } + const data = await cmsQuery(allAssociationSlugsQuery, {}); if (!data?.pages) { throw new Error( "Failed to generate static params for subpages of /foreninger" diff --git a/web/src/app/foreninger/page.tsx b/web/src/app/foreninger/page.tsx index e77e805..97cf28b 100644 --- a/web/src/app/foreninger/page.tsx +++ b/web/src/app/foreninger/page.tsx @@ -5,6 +5,9 @@ import { } from "@/components/associations/AssociationIndexView"; import { getSeoMetadata } from "@/lib/seo"; +// Fail the build if this route stops being fully prerendered +export const ensureStatic = "navigation"; + export async function generateMetadata( _: unknown, parent: ResolvingMetadata diff --git a/web/src/app/kontakt/page.tsx b/web/src/app/kontakt/page.tsx index 1f513dc..54b9800 100644 --- a/web/src/app/kontakt/page.tsx +++ b/web/src/app/kontakt/page.tsx @@ -5,6 +5,9 @@ import { } from "@/components/contact/ContactIndexView"; import { getSeoMetadata } from "@/lib/seo"; +// Fail the build if this route stops being fully prerendered +export const ensureStatic = "navigation"; + export async function generateMetadata( _: unknown, parent: ResolvingMetadata diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 4dd9b19..01cf04e 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,5 +1,7 @@ import "@/css/main.scss"; -import { Header } from "@/components/layout/Header"; +import { SeasonalHeader } from "@/components/layout/SeasonalHeader"; +import { TodayProvider } from "@/components/general/Today"; +import { getOsloDay } from "@/lib/today"; import { Footer } from "@/components/layout/Footer"; import { Metadata } from "next"; import { NuqsAdapter } from "nuqs/adapters/next/app"; @@ -26,11 +28,12 @@ export const metadata: Metadata = { ...baseUrlMetadata, }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const day = await getOsloDay(); return ( @@ -61,9 +64,11 @@ export default function RootLayout({ -
- {children} -