cache queries and revalidate with incremental static regeneration

This commit is contained in:
2026-07-07 02:32:21 +02:00
parent d63b14b2e5
commit e600d0a663
22 changed files with 381 additions and 114 deletions
+15
View File
@@ -34,3 +34,18 @@ npm run build
prek install # registers the git hook prek install # registers the git hook
prek run --all-files # run on everything prek run --all-files # run on everything
``` ```
## Caching and revalidation
The frontend caches all pages and GraphQL responses (ISR) under a single `cms` cache tag.
Wagtail purges it via a webhook on any publish/unpublish/move/delete (see `dnscms/dnscms/revalidation.py`).
Date-sensitive queries (`futureEvents`, opening hours) additionally expire at Oslo midnight.
`REVALIDATE_WEBHOOK_SECRET` must be set to the same value on both sides
(`web/.env.local` and `dnscms/dnscms/settings/local.py` or env); unset disables the webhook.
```bash
# manual purge
curl -X POST -H "X-Revalidate-Secret: $SECRET" https://<frontend>/api/revalidate
```
Note: ISR is off under `npm run dev`; use `npm run build && npm run start` to test caching.
+3
View File
@@ -6,3 +6,6 @@ class DnsCmsConfig(AppConfig):
def ready(self): def ready(self):
from dnscms import signals # noqa: F401 from dnscms import signals # noqa: F401
from dnscms.revalidation import register_signal_handlers
register_signal_handlers()
+97
View File
@@ -0,0 +1,97 @@
"""Purge the frontend cache when content changes.
The frontend caches all GraphQL responses under a single "cms" cache tag;
any content change POSTs to its /api/revalidate endpoint for a global purge.
"""
import logging
import threading
import urllib.request
from django.conf import settings
from django.db import transaction
from django.db.models.signals import post_delete, post_save
logger = logging.getLogger(__name__)
WEBHOOK_TIMEOUT_SECONDS = 5
# Non-page models exposed over GraphQL; pages are covered by wagtail signals
REVALIDATING_MODELS = [
"contacts.ContactEntity",
"events.EventCategory",
"events.EventOrganizer",
"images.CustomImage",
"openinghours.OpeningHoursSet",
]
_warned_unconfigured = False
def _send_webhook(url, secret, reason):
request = urllib.request.Request(
url, data=b"", method="POST", headers={"X-Revalidate-Secret": secret}
)
try:
with urllib.request.urlopen(request, timeout=WEBHOOK_TIMEOUT_SECONDS) as response:
logger.info("frontend revalidation ok (%s): HTTP %s", reason, response.status)
except Exception:
logger.exception("frontend revalidation failed (%s)", reason)
def trigger_frontend_revalidation(reason):
global _warned_unconfigured
secret = settings.REVALIDATE_WEBHOOK_SECRET
if not secret:
if not _warned_unconfigured:
logger.warning("REVALIDATE_WEBHOOK_SECRET is not set, skipping frontend revalidation")
_warned_unconfigured = True
return
url = f"{settings.FRONTEND_BASE_URL}/api/revalidate"
# After commit so the frontend can't re-render from pre-commit data,
# in a thread so publishing never blocks on the frontend
transaction.on_commit(
lambda: threading.Thread(
target=_send_webhook, args=(url, secret, reason), daemon=True
).start()
)
def _on_page_published(sender, instance, **kwargs):
trigger_frontend_revalidation(f"page_published: {instance.slug}")
def _on_page_unpublished(sender, instance, **kwargs):
trigger_frontend_revalidation(f"page_unpublished: {instance.slug}")
def _on_page_moved(sender, instance, **kwargs):
trigger_frontend_revalidation(f"post_page_move: {instance.slug}")
def _on_page_deleted(sender, instance, **kwargs):
trigger_frontend_revalidation(f"page_deleted: {instance.slug}")
def _on_model_changed(sender, instance, **kwargs):
trigger_frontend_revalidation(f"{sender._meta.label}: {instance}")
def register_signal_handlers():
from wagtail.models import Page
from wagtail.signals import page_published, page_unpublished, post_page_move
page_published.connect(_on_page_published, dispatch_uid="revalidation.page_published")
page_unpublished.connect(_on_page_unpublished, dispatch_uid="revalidation.page_unpublished")
post_page_move.connect(_on_page_moved, dispatch_uid="revalidation.post_page_move")
# Wagtail sends no signal on page deletion; MTI deletes of any page type
# also emit post_delete for the base Page row
post_delete.connect(_on_page_deleted, sender=Page, dispatch_uid="revalidation.page_deleted")
for label in REVALIDATING_MODELS:
post_save.connect(
_on_model_changed, sender=label, dispatch_uid=f"revalidation.save.{label}"
)
post_delete.connect(
_on_model_changed, sender=label, dispatch_uid=f"revalidation.delete.{label}"
)
+4
View File
@@ -200,6 +200,10 @@ BASE_URL = WAGTAIL_BASE_URL
# redirect "View Live" clicks on the CMS host over to the headless frontend. # redirect "View Live" clicks on the CMS host over to the headless frontend.
FRONTEND_BASE_URL = os.environ.get("FRONTEND_BASE_URL", "http://localhost:3000").rstrip("/") FRONTEND_BASE_URL = os.environ.get("FRONTEND_BASE_URL", "http://localhost:3000").rstrip("/")
# Shared secret for the frontend cache purge webhook (see dnscms/revalidation.py).
# Unset means no webhooks are sent.
REVALIDATE_WEBHOOK_SECRET = os.environ.get("REVALIDATE_WEBHOOK_SECRET", "")
WAGTAIL_HEADLESS_PREVIEW = { WAGTAIL_HEADLESS_PREVIEW = {
"CLIENT_URLS": {"default": f"{FRONTEND_BASE_URL}/api/preview"}, "CLIENT_URLS": {"default": f"{FRONTEND_BASE_URL}/api/preview"},
"SERVE_BASE_URL": FRONTEND_BASE_URL, "SERVE_BASE_URL": FRONTEND_BASE_URL,
+127
View File
@@ -0,0 +1,127 @@
import logging
import urllib.error
import pytest
from dnscms import revalidation
from events.models import EventCategory
from tests.conftest import GenericPageFactory
WEBHOOK_URL = "https://frontend.example.com/api/revalidate"
class InlineThread:
def __init__(self, target=None, args=(), kwargs=None, daemon=None):
self._target = target
self._args = args
self._kwargs = kwargs or {}
def start(self):
self._target(*self._args, **self._kwargs)
@pytest.fixture
def webhook_calls(monkeypatch, settings):
settings.REVALIDATE_WEBHOOK_SECRET = "s3cret"
settings.FRONTEND_BASE_URL = "https://frontend.example.com"
calls = []
monkeypatch.setattr(revalidation.threading, "Thread", InlineThread)
monkeypatch.setattr(
revalidation, "_send_webhook", lambda url, secret, reason: calls.append((url, secret))
)
return calls
def test_page_publish_triggers_webhook(
home_page, webhook_calls, django_capture_on_commit_callbacks
):
page = GenericPageFactory(parent=home_page)
with django_capture_on_commit_callbacks(execute=True):
page.save_revision().publish()
assert webhook_calls == [(WEBHOOK_URL, "s3cret")]
def test_page_unpublish_triggers_webhook(
home_page, webhook_calls, django_capture_on_commit_callbacks
):
page = GenericPageFactory(parent=home_page)
page.save_revision().publish()
webhook_calls.clear()
with django_capture_on_commit_callbacks(execute=True):
page.unpublish()
assert webhook_calls == [(WEBHOOK_URL, "s3cret")]
def test_page_delete_triggers_webhook(
home_page, webhook_calls, django_capture_on_commit_callbacks
):
# live=False isolates the post_delete path: deleting a live page would
# also fire page_unpublished (a harmless duplicate purge in production)
page = GenericPageFactory(parent=home_page, live=False)
webhook_calls.clear()
with django_capture_on_commit_callbacks(execute=True):
page.delete()
assert webhook_calls == [(WEBHOOK_URL, "s3cret")]
@pytest.mark.django_db
def test_snippet_save_triggers_webhook(webhook_calls, django_capture_on_commit_callbacks):
with django_capture_on_commit_callbacks(execute=True):
EventCategory.objects.create(name="Konsert", slug="konsert")
assert webhook_calls == [(WEBHOOK_URL, "s3cret")]
def test_unset_secret_skips_and_warns_once(settings, monkeypatch, caplog):
settings.REVALIDATE_WEBHOOK_SECRET = ""
monkeypatch.setattr(revalidation, "_warned_unconfigured", False)
with caplog.at_level(logging.WARNING, logger="dnscms.revalidation"):
revalidation.trigger_frontend_revalidation("test")
revalidation.trigger_frontend_revalidation("test")
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
def test_send_webhook_failure_is_swallowed(monkeypatch, caplog):
def raise_urlerror(request, timeout):
raise urllib.error.URLError("connection refused")
monkeypatch.setattr(revalidation.urllib.request, "urlopen", raise_urlerror)
with caplog.at_level(logging.ERROR, logger="dnscms.revalidation"):
revalidation._send_webhook(WEBHOOK_URL, "s3cret", "test")
assert any("frontend revalidation failed" in r.message for r in caplog.records)
def test_send_webhook_request_shape(monkeypatch):
seen = {}
class FakeResponse:
status = 200
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def fake_urlopen(request, timeout):
seen["request"] = request
seen["timeout"] = timeout
return FakeResponse()
monkeypatch.setattr(revalidation.urllib.request, "urlopen", fake_urlopen)
revalidation._send_webhook(WEBHOOK_URL, "s3cret", "test")
request = seen["request"]
assert request.get_method() == "POST"
assert request.get_header("X-revalidate-secret") == "s3cret"
assert seen["timeout"] == revalidation.WEBHOOK_TIMEOUT_SECONDS
+1
View File
@@ -7,6 +7,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"test": "node --test src/lib/*.test.ts",
"codegen": "graphql-codegen", "codegen": "graphql-codegen",
"perf:build": "next build", "perf:build": "next build",
"perf:serve": "next start -p 3100", "perf:serve": "next start -p 3100",
-2
View File
@@ -8,8 +8,6 @@ import {
} from "@/components/general/GenericPageView"; } from "@/components/general/GenericPageView";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
export const dynamicParams = false;
function getWagtailUrlPath(url: string[]): string { function getWagtailUrlPath(url: string[]): string {
// for the page /foo/bar we need to look for `/home/foo/bar/` // for the page /foo/bar we need to look for `/home/foo/bar/`
return `/home/${url.join("/")}/`; return `/home/${url.join("/")}/`;
+2 -24
View File
@@ -1,36 +1,14 @@
import { Metadata, ResolvingMetadata } from "next"; import { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getClient } from "@/app/client";
import { import {
NewsPageView, NewsPageView,
loadNewsPageProps, loadNewsPageProps,
} from "@/components/news/NewsPageView"; } from "@/components/news/NewsPageView";
import { graphql } from "@/gql";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
export async function generateStaticParams() { export async function generateStaticParams() {
const allNewsSlugsQuery = graphql(` // Prerender nothing at build time; render and cache on first request
query allNewsSlugs { return [];
pages(contentType: "news.NewsPage") {
id
slug
}
}
`);
const { data, error } = await getClient().query(allNewsSlugsQuery, {});
if (error) {
throw new Error(error.message);
}
if (!data?.pages) {
throw new Error(
"Failed to generate static params for subpages of /aktuelt"
);
}
return data.pages.map((page: any) => ({
slug: page.slug,
}));
} }
type Params = Promise<{ slug: string }>; type Params = Promise<{ slug: string }>;
+28
View File
@@ -0,0 +1,28 @@
import { timingSafeEqual } from "node:crypto";
import { revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
import { CMS_CACHE_TAG } from "@/lib/revalidation";
function secretMatches(provided: string, expected: string): boolean {
const a = Buffer.from(provided);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
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 });
}
// expire: 0 purges immediately; the next request renders fresh
revalidateTag(CMS_CACHE_TAG, { expire: 0 });
return NextResponse.json({ revalidated: true, tag: CMS_CACHE_TAG });
}
+6 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
import { import {
eventsOverviewQuery, eventsOverviewQuery,
getSingularEvents, getSingularEvents,
@@ -45,7 +46,11 @@ export async function GET(req: NextRequest) {
); );
} }
const { data, error } = await getClient().query(eventsOverviewQuery, {}); const { data, error } = await getClient().query(
eventsOverviewQuery,
{},
cachedUntilOsloMidnight()
);
if (error) { if (error) {
throw new Error(error.message); throw new Error(error.message);
} }
+2 -24
View File
@@ -1,36 +1,14 @@
import { Metadata, ResolvingMetadata } from "next"; import { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getClient } from "@/app/client";
import { import {
EventPageView, EventPageView,
loadEventPageProps, loadEventPageProps,
} from "@/components/events/EventPageView"; } from "@/components/events/EventPageView";
import { graphql } from "@/gql";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
export async function generateStaticParams() { export async function generateStaticParams() {
const allEventSlugsQuery = graphql(` // Prerender nothing at build time; render and cache on first request
query allEventSlugs { return [];
pages(contentType: "events.EventPage") {
id
slug
}
}
`);
const { data, error } = await getClient().query(allEventSlugsQuery, {});
if (error) {
throw new Error(error.message);
}
if (!data?.pages) {
throw new Error(
"Failed to generate static params for subpages of /arrangementer"
);
}
return data.pages.map((page: any) => ({
slug: page.slug,
}));
} }
type Params = Promise<{ slug: string }>; type Params = Promise<{ slug: string }>;
+5 -2
View File
@@ -3,6 +3,8 @@ import "server-only";
import { cacheExchange, createClient, fetchExchange } from "@urql/core"; import { cacheExchange, createClient, fetchExchange } from "@urql/core";
import { registerUrql } from "@urql/next/rsc"; import { registerUrql } from "@urql/next/rsc";
import { CMS_CACHE_TAG } from "@/lib/revalidation";
const wagtailBaseUrl = process.env.WAGTAIL_BASE_URL; const wagtailBaseUrl = process.env.WAGTAIL_BASE_URL;
if (!wagtailBaseUrl) { if (!wagtailBaseUrl) {
throw new Error("WAGTAIL_BASE_URL is not set"); throw new Error("WAGTAIL_BASE_URL is not set");
@@ -13,8 +15,9 @@ const makeClient = () => {
return createClient({ return createClient({
url: graphqlEndpoint, url: graphqlEndpoint,
exchanges: [cacheExchange, fetchExchange], exchanges: [cacheExchange, fetchExchange],
// requestPolicy: "network-only", // Cache all queries in the Data Cache until revalidateTag(CMS_CACHE_TAG);
fetchOptions: { next: { revalidate: 0 } }, // override per operation with the contexts in @/lib/revalidation
fetchOptions: { cache: "force-cache", next: { tags: [CMS_CACHE_TAG] } },
}); });
}; };
+6 -1
View File
@@ -1,4 +1,5 @@
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { uncached } from "@/lib/revalidation";
import { PreviewBanner } from "@/components/general/PreviewBanner"; import { PreviewBanner } from "@/components/general/PreviewBanner";
import { import {
AssociationIndexView, AssociationIndexView,
@@ -149,7 +150,11 @@ export default async function PreviewRender() {
return <ExpiredPreview />; return <ExpiredPreview />;
} }
const { data, error } = await getClient().query(previewPageQuery, { token }); const { data, error } = await getClient().query(
previewPageQuery,
{ token },
uncached
);
if (error) { if (error) {
throw new Error(error.message); throw new Error(error.message);
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { uncached } from "@/lib/revalidation";
import { import {
type SearchResult, type SearchResult,
SearchResults, SearchResults,
@@ -65,7 +66,7 @@ export default async function Page({
} }
`); `);
const { data } = await getClient().query(searchQuery, { query }); const { data } = await getClient().query(searchQuery, { query }, uncached);
const all = (data?.results ?? []) as SearchResult[]; const all = (data?.results ?? []) as SearchResult[];
totalCount = all.length; totalCount = all.length;
results = all.slice(0, RESULT_LIMIT); results = all.slice(0, RESULT_LIMIT);
+6 -1
View File
@@ -1,5 +1,6 @@
import { VenueFragment } from "@/gql/graphql"; import { VenueFragment } from "@/gql/graphql";
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
import { EventContainer } from "@/components/events/EventContainer"; import { EventContainer } from "@/components/events/EventContainer";
import { PageHeader } from "@/components/general/PageHeader"; import { PageHeader } from "@/components/general/PageHeader";
import { import {
@@ -17,7 +18,11 @@ export type EventIndexViewProps = {
}; };
export async function loadEventIndexProps(): Promise<EventIndexViewProps> { export async function loadEventIndexProps(): Promise<EventIndexViewProps> {
const { data, error } = await getClient().query(eventsOverviewQuery, {}); const { data, error } = await getClient().query(
eventsOverviewQuery,
{},
cachedUntilOsloMidnight()
);
if (error) throw new Error(error.message); if (error) throw new Error(error.message);
if ( if (
!data?.index || !data?.index ||
+6 -1
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { HomeFragment } from "@/gql/graphql"; import { HomeFragment } from "@/gql/graphql";
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
import { EventListItemFragment } from "@/lib/event"; import { EventListItemFragment } from "@/lib/event";
import { NewsListItemFragment } from "@/lib/news"; import { NewsListItemFragment } from "@/lib/news";
import { FeaturedEvents } from "@/components/events/FeaturedEvents"; import { FeaturedEvents } from "@/components/events/FeaturedEvents";
@@ -55,7 +56,11 @@ export type HomePageViewProps = {
export async function loadHomePageProps(overrides?: { export async function loadHomePageProps(overrides?: {
homeOverride?: HomeFragment; homeOverride?: HomeFragment;
}): Promise<HomePageViewProps> { }): Promise<HomePageViewProps> {
const { data, error } = await getClient().query(homeQuery, {}); const { data, error } = await getClient().query(
homeQuery,
{},
cachedUntilOsloMidnight()
);
if (error) throw new Error(error.message); if (error) throw new Error(error.message);
const home = overrides?.homeOverride ?? (data?.home as HomeFragment | undefined); const home = overrides?.homeOverride ?? (data?.home as HomeFragment | undefined);
if (!home) throw new Error("Failed to load /"); if (!home) throw new Error("Failed to load /");
-12
View File
@@ -15,8 +15,6 @@ import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/
*/ */
type Documents = { type Documents = {
"\n query allGenericSlugs {\n pages(contentType: \"generic.GenericPage\") {\n id\n urlPath\n }\n }\n ": typeof types.AllGenericSlugsDocument, "\n query allGenericSlugs {\n pages(contentType: \"generic.GenericPage\") {\n id\n urlPath\n }\n }\n ": typeof types.AllGenericSlugsDocument,
"\n query allNewsSlugs {\n pages(contentType: \"news.NewsPage\") {\n id\n slug\n }\n }\n ": typeof types.AllNewsSlugsDocument,
"\n query allEventSlugs {\n pages(contentType: \"events.EventPage\") {\n id\n slug\n }\n }\n ": typeof types.AllEventSlugsDocument,
"\n query allAssociationSlugs {\n pages(contentType: \"associations.AssociationPage\") {\n id\n slug\n }\n }\n ": typeof types.AllAssociationSlugsDocument, "\n query allAssociationSlugs {\n pages(contentType: \"associations.AssociationPage\") {\n id\n slug\n }\n }\n ": typeof types.AllAssociationSlugsDocument,
"\n query allVenueSlugs {\n pages(contentType: \"venues.VenuePage\", limit: 100) {\n id\n slug\n }\n }\n ": typeof types.AllVenueSlugsDocument, "\n query allVenueSlugs {\n pages(contentType: \"venues.VenuePage\", limit: 100) {\n id\n slug\n }\n }\n ": typeof types.AllVenueSlugsDocument,
"\n query previewPage($token: String!) {\n page: page(token: $token) {\n __typename\n ... on GenericPage {\n ...Generic\n }\n ... on StudioPage {\n ...Studio\n }\n ... on SponsorsPage {\n ...SponsorsPage\n }\n ... on HomePage {\n ...Home\n }\n ... on EventPage {\n ...Event\n }\n ... on NewsPage {\n ...News\n }\n ... on AssociationPage {\n ...Association\n }\n ... on VenuePage {\n ...Venue\n }\n ... on NewsIndex {\n ...NewsIndex\n }\n ... on AssociationIndex {\n ...AssociationIndex\n }\n ... on VenueIndex {\n ...VenueIndex\n }\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n ... on ContactIndex {\n ...ContactIndex\n }\n }\n }\n": typeof types.PreviewPageDocument, "\n query previewPage($token: String!) {\n page: page(token: $token) {\n __typename\n ... on GenericPage {\n ...Generic\n }\n ... on StudioPage {\n ...Studio\n }\n ... on SponsorsPage {\n ...SponsorsPage\n }\n ... on HomePage {\n ...Home\n }\n ... on EventPage {\n ...Event\n }\n ... on NewsPage {\n ...News\n }\n ... on AssociationPage {\n ...Association\n }\n ... on VenuePage {\n ...Venue\n }\n ... on NewsIndex {\n ...NewsIndex\n }\n ... on AssociationIndex {\n ...AssociationIndex\n }\n ... on VenueIndex {\n ...VenueIndex\n }\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n ... on ContactIndex {\n ...ContactIndex\n }\n }\n }\n": typeof types.PreviewPageDocument,
@@ -85,8 +83,6 @@ type Documents = {
}; };
const documents: Documents = { const documents: Documents = {
"\n query allGenericSlugs {\n pages(contentType: \"generic.GenericPage\") {\n id\n urlPath\n }\n }\n ": types.AllGenericSlugsDocument, "\n query allGenericSlugs {\n pages(contentType: \"generic.GenericPage\") {\n id\n urlPath\n }\n }\n ": types.AllGenericSlugsDocument,
"\n query allNewsSlugs {\n pages(contentType: \"news.NewsPage\") {\n id\n slug\n }\n }\n ": types.AllNewsSlugsDocument,
"\n query allEventSlugs {\n pages(contentType: \"events.EventPage\") {\n id\n slug\n }\n }\n ": types.AllEventSlugsDocument,
"\n query allAssociationSlugs {\n pages(contentType: \"associations.AssociationPage\") {\n id\n slug\n }\n }\n ": types.AllAssociationSlugsDocument, "\n query allAssociationSlugs {\n pages(contentType: \"associations.AssociationPage\") {\n id\n slug\n }\n }\n ": types.AllAssociationSlugsDocument,
"\n query allVenueSlugs {\n pages(contentType: \"venues.VenuePage\", limit: 100) {\n id\n slug\n }\n }\n ": types.AllVenueSlugsDocument, "\n query allVenueSlugs {\n pages(contentType: \"venues.VenuePage\", limit: 100) {\n id\n slug\n }\n }\n ": types.AllVenueSlugsDocument,
"\n query previewPage($token: String!) {\n page: page(token: $token) {\n __typename\n ... on GenericPage {\n ...Generic\n }\n ... on StudioPage {\n ...Studio\n }\n ... on SponsorsPage {\n ...SponsorsPage\n }\n ... on HomePage {\n ...Home\n }\n ... on EventPage {\n ...Event\n }\n ... on NewsPage {\n ...News\n }\n ... on AssociationPage {\n ...Association\n }\n ... on VenuePage {\n ...Venue\n }\n ... on NewsIndex {\n ...NewsIndex\n }\n ... on AssociationIndex {\n ...AssociationIndex\n }\n ... on VenueIndex {\n ...VenueIndex\n }\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n ... on ContactIndex {\n ...ContactIndex\n }\n }\n }\n": types.PreviewPageDocument, "\n query previewPage($token: String!) {\n page: page(token: $token) {\n __typename\n ... on GenericPage {\n ...Generic\n }\n ... on StudioPage {\n ...Studio\n }\n ... on SponsorsPage {\n ...SponsorsPage\n }\n ... on HomePage {\n ...Home\n }\n ... on EventPage {\n ...Event\n }\n ... on NewsPage {\n ...News\n }\n ... on AssociationPage {\n ...Association\n }\n ... on VenuePage {\n ...Venue\n }\n ... on NewsIndex {\n ...NewsIndex\n }\n ... on AssociationIndex {\n ...AssociationIndex\n }\n ... on VenueIndex {\n ...VenueIndex\n }\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n ... on ContactIndex {\n ...ContactIndex\n }\n }\n }\n": types.PreviewPageDocument,
@@ -172,14 +168,6 @@ export function graphql(source: string): unknown;
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
export function graphql(source: "\n query allGenericSlugs {\n pages(contentType: \"generic.GenericPage\") {\n id\n urlPath\n }\n }\n "): (typeof documents)["\n query allGenericSlugs {\n pages(contentType: \"generic.GenericPage\") {\n id\n urlPath\n }\n }\n "]; export function graphql(source: "\n query allGenericSlugs {\n pages(contentType: \"generic.GenericPage\") {\n id\n urlPath\n }\n }\n "): (typeof documents)["\n query allGenericSlugs {\n pages(contentType: \"generic.GenericPage\") {\n id\n urlPath\n }\n }\n "];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query allNewsSlugs {\n pages(contentType: \"news.NewsPage\") {\n id\n slug\n }\n }\n "): (typeof documents)["\n query allNewsSlugs {\n pages(contentType: \"news.NewsPage\") {\n id\n slug\n }\n }\n "];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query allEventSlugs {\n pages(contentType: \"events.EventPage\") {\n id\n slug\n }\n }\n "): (typeof documents)["\n query allEventSlugs {\n pages(contentType: \"events.EventPage\") {\n id\n slug\n }\n }\n "];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -8,6 +8,7 @@ import {
} from "date-fns"; } from "date-fns";
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
import { graphql, unmaskFragment } from "@/gql"; import { graphql, unmaskFragment } from "@/gql";
import type { import type {
OpeningHoursRangeBlockFragment as OpeningHoursRangeBlock, OpeningHoursRangeBlockFragment as OpeningHoursRangeBlock,
@@ -50,7 +51,13 @@ const openingHoursQuery = graphql(`
`); `);
export async function fetchOpeningHoursSets() { export async function fetchOpeningHoursSets() {
const { data, error } = await getClient().query(openingHoursQuery, {}); const { data, error } = await getClient().query(
openingHoursQuery,
{},
// The footer renders today's hours on every page, so this TTL doubles as
// the sitewide midnight refresh for futureEvents rollover
cachedUntilOsloMidnight()
);
const sets = (data?.openingHoursSets ?? []) as OpeningHoursSet[]; const sets = (data?.openingHoursSets ?? []) as OpeningHoursSet[];
return sets; return sets;
} }
+24
View File
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { secondsUntilOsloMidnight } from "./revalidation.ts";
test("normal day: 12:00 CEST is 12h from midnight", () => {
const now = new Date("2026-07-07T10:00:00Z");
assert.equal(secondsUntilOsloMidnight(now), 12 * 3600);
});
test("clamps to 60s just before midnight", () => {
const now = new Date("2026-07-07T21:59:30Z");
assert.equal(secondsUntilOsloMidnight(now), 60);
});
test("DST fall-back day is 25h (2026-10-25)", () => {
const now = new Date("2026-10-24T22:00:00Z");
assert.equal(secondsUntilOsloMidnight(now), 25 * 3600);
});
test("DST spring-forward day is 23h (2026-03-29)", () => {
const now = new Date("2026-03-28T23:00:00Z");
assert.equal(secondsUntilOsloMidnight(now), 23 * 3600);
});
+38
View File
@@ -0,0 +1,38 @@
import { addDays, startOfDay } from "date-fns";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
import type { OperationContext } from "@urql/core";
const timeZone = "Europe/Oslo";
// Global cache tag: any CMS content change purges everything
export const CMS_CACHE_TAG = "cms";
const MIN_REVALIDATE_SECONDS = 60;
// Clamped so a request just before midnight never yields revalidate ~0
export function secondsUntilOsloMidnight(now: Date = new Date()): number {
const osloNow = toZonedTime(now, timeZone);
const nextOsloMidnight = startOfDay(addDays(osloNow, 1));
const instant = fromZonedTime(nextOsloMidnight, timeZone);
return Math.max(
Math.ceil((instant.getTime() - now.getTime()) / 1000),
MIN_REVALIDATE_SECONDS
);
}
// Per-operation contexts replace (not merge with) the client-default
// fetchOptions, so both contexts below are self-contained
// For per-user (search) and token-scoped (preview) queries
export const uncached: Partial<OperationContext> = {
fetchOptions: { cache: "no-store" },
};
// For queries whose results depend on "today" (futureEvents, opening hours)
export function cachedUntilOsloMidnight(): Partial<OperationContext> {
return {
fetchOptions: {
next: { revalidate: secondsUntilOsloMidnight(), tags: [CMS_CACHE_TAG] },
},
};
}
+1
View File
@@ -13,6 +13,7 @@
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"allowImportingTsExtensions": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"incremental": true, "incremental": true,