cache queries and revalidate with incremental static regeneration
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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("/")}/`;
|
||||||
|
|||||||
@@ -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 }>;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }>;
|
||||||
|
|||||||
@@ -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] } },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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 /");
|
||||||
|
|||||||
@@ -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,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user