From 5602fdd673c12124891668600d7602e3c2486d3f Mon Sep 17 00:00:00 2001 From: Levi Zitting Date: Sun, 4 Oct 2026 00:49:00 -0500 Subject: [PATCH 1/2] feat: add CMS data cache layer --- eslint.config.mjs | 1 + generateUmbracoApiClient.ts | 2 +- src/data/umbraco/client.ts | 24 -- src/{ => lib}/config.ts | 0 src/{data/parseUtcAsCst.ts => lib/date.ts} | 17 +- src/{data/umbraco => lib}/imageUrl.ts | 0 src/lib/server/cache.ts | 283 ++++++++++++++++++ src/lib/server/config.ts | 45 +++ src/{data => lib/server}/getConference.ts | 6 +- .../server}/getDefaultConference.ts | 6 +- src/{data => lib/server}/getSchedule.ts | 12 +- .../server}/getSessionsForSpeaker.ts | 6 +- src/{data => lib/server}/getSponsors.ts | 4 +- src/{data => lib/server}/parseConference.ts | 4 +- src/{data => lib/server}/parseSession.ts | 4 +- src/lib/server/umbraco/client.ts | 14 + .../server}/umbraco/getChildNodesOfType.ts | 7 +- .../server}/umbraco/getItemByPath.ts | 8 +- src/{data => lib/server}/umbraco/getItems.ts | 8 +- .../server}/umbraco/treeByRoutePath.ts | 2 +- src/lib/server/umbraco/types.ts | 31 ++ src/{data => lib}/types.ts | 6 +- .../umbraco/defaultApiSchema.d.ts | 0 .../umbraco/deliveryApiSchema.d.ts | 0 src/{data => lib}/umbraco/types.ts | 27 +- src/{ => lib}/util.ts | 12 +- src/routes/+layout.server.ts | 4 +- src/serverConfig.ts | 38 --- tests/unit/cms-cache.test.ts | 250 ++++++++++++++++ tests/unit/cms-data.test.ts | 109 +++++++ 30 files changed, 793 insertions(+), 137 deletions(-) delete mode 100644 src/data/umbraco/client.ts rename src/{ => lib}/config.ts (100%) rename src/{data/parseUtcAsCst.ts => lib/date.ts} (62%) rename src/{data/umbraco => lib}/imageUrl.ts (100%) create mode 100644 src/lib/server/cache.ts create mode 100644 src/lib/server/config.ts rename src/{data => lib/server}/getConference.ts (58%) rename src/{data => lib/server}/getDefaultConference.ts (78%) rename src/{data => lib/server}/getSchedule.ts (76%) rename src/{data => lib/server}/getSessionsForSpeaker.ts (70%) rename src/{data => lib/server}/getSponsors.ts (60%) rename src/{data => lib/server}/parseConference.ts (74%) rename src/{data => lib/server}/parseSession.ts (77%) create mode 100644 src/lib/server/umbraco/client.ts rename src/{data => lib/server}/umbraco/getChildNodesOfType.ts (86%) rename src/{data => lib/server}/umbraco/getItemByPath.ts (79%) rename src/{data => lib/server}/umbraco/getItems.ts (75%) rename src/{data => lib/server}/umbraco/treeByRoutePath.ts (97%) create mode 100644 src/lib/server/umbraco/types.ts rename src/{data => lib}/types.ts (90%) rename src/{data => lib}/umbraco/defaultApiSchema.d.ts (100%) rename src/{data => lib}/umbraco/deliveryApiSchema.d.ts (100%) rename src/{data => lib}/umbraco/types.ts (69%) rename src/{ => lib}/util.ts (75%) delete mode 100644 src/serverConfig.ts create mode 100644 tests/unit/cms-cache.test.ts create mode 100644 tests/unit/cms-data.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index acef6f0..e700832 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,6 +18,7 @@ export default tseslint.config( 'src/components/**', 'src/config.ts', 'src/data/**', + 'src/lib/umbraco/*ApiSchema.d.ts', 'src/serverConfig.ts', 'src/util.ts', ], diff --git a/generateUmbracoApiClient.ts b/generateUmbracoApiClient.ts index 1ece74c..5e33423 100644 --- a/generateUmbracoApiClient.ts +++ b/generateUmbracoApiClient.ts @@ -26,7 +26,7 @@ async function main() { const outputAst = await openapiTS(url.toString()); await fs.promises.writeFile( - path.join(__dirname, 'src', 'data', 'umbraco', outputFile), + path.join(__dirname, 'src', 'lib', 'umbraco', outputFile), astToString(outputAst), ); }), diff --git a/src/data/umbraco/client.ts b/src/data/umbraco/client.ts deleted file mode 100644 index 16f5d40..0000000 --- a/src/data/umbraco/client.ts +++ /dev/null @@ -1,24 +0,0 @@ -import createClient from 'openapi-fetch'; -import type { paths as deliveryApiPaths } from '@/data/umbraco/deliveryApiSchema'; -import type { paths as defaultApiPaths } from '@/data/umbraco/defaultApiSchema'; -import { getUmbracoBaseUrl } from '@/serverConfig'; - -export function getUmbracoClient() { - return createClient({ - baseUrl: getUmbracoBaseUrl().toString(), - fetch: async (request) => { - let { next } = request as RequestInit; - - if ((!request.cache || request.cache === 'default') && !next) { - next = { revalidate: 60 }; - } - - try { - return await fetch(request, { next }); - } catch (err) { - console.log(err); - throw err; - } - }, - }); -} diff --git a/src/config.ts b/src/lib/config.ts similarity index 100% rename from src/config.ts rename to src/lib/config.ts diff --git a/src/data/parseUtcAsCst.ts b/src/lib/date.ts similarity index 62% rename from src/data/parseUtcAsCst.ts rename to src/lib/date.ts index 87ff64f..98cf7db 100644 --- a/src/data/parseUtcAsCst.ts +++ b/src/lib/date.ts @@ -1,7 +1,10 @@ -import { TZDate } from '@date-fns/tz'; -import { CST_TZ } from '@/config'; +import { TZDate, tz } from '@date-fns/tz'; +import { format } from 'date-fns'; +import { CST_TZ } from '#lib/config.ts'; -// Umbraco doesn't save dates with timezone information so we have to manually fix it +// Umbraco stores Chicago-local date parts without reliable zone data. Interpret +// the parts in America/Chicago, then return a plain Date instant so SvelteKit can +// serialize load data without custom transport hooks. export function parseUtcAsCst(dateStr: string): Date { const match = dateStr.match( /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,7}))?)?)?(?:Z|[+-]\d{2}:\d{2})?$/, @@ -32,7 +35,7 @@ export function parseUtcAsCst(dateStr: string): Date { (millisecondStr ?? '0').slice(0, 3).padEnd(3, '0'), ); - return new TZDate( + const parsed = new TZDate( year, month, day, @@ -42,4 +45,10 @@ export function parseUtcAsCst(dateStr: string): Date { millisecond, CST_TZ, ); + + return new Date(parsed.getTime()); +} + +export function formatDate(date: Date | string, formatStr: string): string { + return format(date, formatStr, { in: tz(CST_TZ) }); } diff --git a/src/data/umbraco/imageUrl.ts b/src/lib/imageUrl.ts similarity index 100% rename from src/data/umbraco/imageUrl.ts rename to src/lib/imageUrl.ts diff --git a/src/lib/server/cache.ts b/src/lib/server/cache.ts new file mode 100644 index 0000000..493d571 --- /dev/null +++ b/src/lib/server/cache.ts @@ -0,0 +1,283 @@ +export type CmsJsonCacheOptions = { + fetch?: typeof fetch; + now?: () => number; + maxEntries?: number; + freshMs?: number; + maxAgeMs?: number; + baseUrl: URL | string; +}; + +type CachedJsonResponse = { + status: number; + statusText: string; + headers: [string, string][]; + body: string; + storedAt: number; +}; + +type FetchedJsonResponse = Omit; + +type FetchOutcome = + | { kind: 'success'; value: CachedJsonResponse } + | { kind: 'http-failure'; value: FetchedJsonResponse } + | { kind: 'network-failure'; error: unknown }; + +type CacheEntry = { + value?: CachedJsonResponse; + inFlight?: Promise; +}; + +// This cache approximates Next's 60-second public CMS revalidation for JSON GETs. +// Stale-while-revalidate is a short resilience window, not a promise that CMS +// publishes are invisible for exactly 60 seconds. +const DEFAULT_MAX_ENTRIES = 200; +const DEFAULT_FRESH_MS = 60_000; +const DEFAULT_MAX_AGE_MS = 5 * 60_000; +const CACHE_AFFECTING_HEADERS = ['accept', 'accept-language']; +const BYPASS_HEADERS = [ + 'authorization', + 'cookie', + 'preview', + 'x-preview', + 'umbraco-preview', + 'x-umbraco-preview', + 'api-key', + 'x-api-key', +]; + +function isAllowlistedPublicCmsJsonGet(url: URL, baseUrl: URL): boolean { + if (url.origin !== baseUrl.origin) { + return false; + } + + if (url.pathname === '/umbraco/delivery/api/v2/content') { + return true; + } + + if (url.pathname.startsWith('/umbraco/delivery/api/v2/content/item/')) { + return true; + } + + return /^\/api\/v1\/conference\/[^/]+\/schedule$/.test(url.pathname); +} + +function hasBypassHeader(headers: Headers): boolean { + for (const header of BYPASS_HEADERS) { + if (headers.has(header)) { + return true; + } + } + + const cacheControl = headers.get('cache-control')?.toLowerCase(); + return cacheControl + ? /(?:^|,|\s)(?:no-store|no-cache|private)(?:,|\s|$)/.test(cacheControl) + : false; +} + +function hasBypassCacheMode(request: Request): boolean { + return ( + request.cache === 'no-store' || + request.cache === 'reload' || + request.cache === 'no-cache' + ); +} + +function getCacheKey(request: Request): string { + const parts = [request.method.toUpperCase(), request.url]; + + for (const header of CACHE_AFFECTING_HEADERS) { + parts.push(`${header}:${request.headers.get(header) ?? ''}`); + } + + return parts.join('\n'); +} + +function makeResponse( + value: FetchedJsonResponse | CachedJsonResponse, +): Response { + return new Response(value.body, { + status: value.status, + statusText: value.statusText, + headers: value.headers, + }); +} + +function cloneCacheableHeaders(headers: Headers): [string, string][] { + const copied = new Headers(headers); + copied.delete('set-cookie'); + copied.delete('set-cookie2'); + return [...copied.entries()]; +} + +async function readJsonResponse( + response: Response, + storedAt: number, +): Promise { + const headers = cloneCacheableHeaders(response.headers); + const bodyText = await response.text(); + const base = { + status: response.status, + statusText: response.statusText, + headers, + }; + + if (!response.ok) { + return { + kind: 'http-failure', + value: { ...base, body: bodyText }, + }; + } + + try { + const parsed = bodyText ? JSON.parse(bodyText) : null; + return { + kind: 'success', + value: { + ...base, + body: JSON.stringify(parsed), + storedAt, + }, + }; + } catch (error) { + return { kind: 'network-failure', error }; + } +} + +function shouldCacheRequest(request: Request, baseUrl: URL): boolean { + if (request.method.toUpperCase() !== 'GET') { + return false; + } + + if (hasBypassHeader(request.headers) || hasBypassCacheMode(request)) { + return false; + } + + return isAllowlistedPublicCmsJsonGet(new URL(request.url), baseUrl); +} + +function makeRequest(input: RequestInfo | URL, init?: RequestInit): Request { + if (input instanceof Request) { + return init ? new Request(input, init) : input.clone(); + } + + return new Request(input, init); +} + +export function createCmsJsonFetch(options: CmsJsonCacheOptions): typeof fetch { + const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis); + const now = options.now ?? Date.now; + const baseUrl = new URL(options.baseUrl); + const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + const freshMs = options.freshMs ?? DEFAULT_FRESH_MS; + const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS; + const cache = new Map(); + + function pruneExpiredEntries(currentTime: number): void { + for (const [key, entry] of cache) { + if ( + !entry.inFlight && + entry.value && + currentTime - entry.value.storedAt > maxAgeMs + ) { + cache.delete(key); + } + } + } + + function pruneSize(): void { + while (cache.size > maxEntries) { + const oldestKey = cache.keys().next().value as string | undefined; + if (!oldestKey) { + return; + } + cache.delete(oldestKey); + } + } + + function startRefresh( + key: string, + request: Request, + ): Promise { + const existing = cache.get(key); + if (existing?.inFlight) { + return existing.inFlight; + } + + const inFlight = fetchFn(request.clone()) + .then((response) => readJsonResponse(response, now())) + .then((outcome) => { + const entry = cache.get(key); + if (outcome.kind === 'success') { + cache.set(key, { value: outcome.value }); + pruneExpiredEntries(now()); + pruneSize(); + return outcome; + } + + if ( + outcome.kind === 'http-failure' && + outcome.value.status >= 400 && + outcome.value.status < 500 + ) { + cache.delete(key); + } else if (entry) { + delete entry.inFlight; + } + + return outcome; + }) + .catch((error: unknown) => { + const entry = cache.get(key); + if (entry) { + delete entry.inFlight; + } + return { + kind: 'network-failure', + error, + } satisfies FetchOutcome; + }); + + cache.set(key, { value: existing?.value, inFlight }); + return inFlight; + } + + return async (input: RequestInfo | URL, init?: RequestInit) => { + const request = makeRequest(input, init); + + if (!shouldCacheRequest(request, baseUrl)) { + return fetchFn(input, init); + } + + const currentTime = now(); + pruneExpiredEntries(currentTime); + + const key = getCacheKey(request); + const entry = cache.get(key); + const value = entry?.value; + + if (value) { + const age = currentTime - value.storedAt; + + if (age <= freshMs) { + return makeResponse(value); + } + + if (age <= maxAgeMs) { + void startRefresh(key, request); + return makeResponse(value); + } + } + + const outcome = await startRefresh(key, request); + + if (outcome.kind === 'success') { + return makeResponse(outcome.value); + } + + if (outcome.kind === 'http-failure') { + return makeResponse(outcome.value); + } + + throw outcome.error; + }; +} diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts new file mode 100644 index 0000000..40ec273 --- /dev/null +++ b/src/lib/server/config.ts @@ -0,0 +1,45 @@ +import { + CMS_PUBLIC_URL, + NEWSLETTER_ENDPOINT, + NEWSLETTER_LIST_ID, + SEARCH_INDEXING_ENABLED, + SITE_URL, + UMBRACO_BASE_URL, +} from '$app/env/private'; +import { parseUrl } from '#lib/util.ts'; + +function getRequiredUrl(name: string, value: string | undefined): URL { + const url = parseUrl(value); + + if (!url) { + throw new Error(`${name} is not a valid URL`); + } + + return url; +} + +export function getUmbracoBaseUrl(): URL { + return getRequiredUrl('UMBRACO_BASE_URL', UMBRACO_BASE_URL); +} + +export function getCmsPublicUrl(): URL { + return getRequiredUrl('CMS_PUBLIC_URL', CMS_PUBLIC_URL); +} + +export function getSiteUrl(): URL { + return getRequiredUrl('SITE_URL', SITE_URL); +} + +export function isSearchIndexingEnabled(): boolean { + return SEARCH_INDEXING_ENABLED; +} + +export function getNewsletterConfig(): { + endpoint?: URL; + listId?: string; +} { + return { + endpoint: parseUrl(NEWSLETTER_ENDPOINT), + listId: NEWSLETTER_LIST_ID, + }; +} diff --git a/src/data/getConference.ts b/src/lib/server/getConference.ts similarity index 58% rename from src/data/getConference.ts rename to src/lib/server/getConference.ts index e0d2728..d6ab9c3 100644 --- a/src/data/getConference.ts +++ b/src/lib/server/getConference.ts @@ -1,6 +1,6 @@ -import type { ParsedConference } from '@/data/types'; -import { parseConference } from '@/data/parseConference'; -import { getItemByPathOrDefault } from '@/data/umbraco/getItemByPath'; +import type { ParsedConference } from '#lib/types.ts'; +import { parseConference } from '#lib/server/parseConference.ts'; +import { getItemByPathOrDefault } from '#lib/server/umbraco/getItemByPath.ts'; export async function getConference( conferenceSlug: string, diff --git a/src/data/getDefaultConference.ts b/src/lib/server/getDefaultConference.ts similarity index 78% rename from src/data/getDefaultConference.ts rename to src/lib/server/getDefaultConference.ts index 695cb05..e6441a3 100644 --- a/src/data/getDefaultConference.ts +++ b/src/lib/server/getDefaultConference.ts @@ -1,6 +1,6 @@ -import type { ParsedConference } from '@/data/types'; -import { parseConference } from '@/data/parseConference'; -import { getItemsOrDefault } from '@/data/umbraco/getItems'; +import type { ParsedConference } from '#lib/types.ts'; +import { parseConference } from '#lib/server/parseConference.ts'; +import { getItemsOrDefault } from '#lib/server/umbraco/getItems.ts'; export async function getDefaultConference(): Promise< ParsedConference | undefined diff --git a/src/data/getSchedule.ts b/src/lib/server/getSchedule.ts similarity index 76% rename from src/data/getSchedule.ts rename to src/lib/server/getSchedule.ts index a963198..55fe8ba 100644 --- a/src/data/getSchedule.ts +++ b/src/lib/server/getSchedule.ts @@ -1,9 +1,9 @@ -import type { Schedule, ScheduleItem } from '@/data/types'; -import { treeByRoutePath } from '@/data/umbraco/treeByRoutePath'; -import { parseSession } from '@/data/parseSession'; -import { getFirstChildNodeOfType } from '@/data/umbraco/getChildNodesOfType'; -import { getItemsOrDefault } from '@/data/umbraco/getItems'; -import { getUmbracoClient } from '@/data/umbraco/client'; +import type { Schedule, ScheduleItem } from '#lib/types.ts'; +import { treeByRoutePath } from '#lib/server/umbraco/treeByRoutePath.ts'; +import { parseSession } from '#lib/server/parseSession.ts'; +import { getFirstChildNodeOfType } from '#lib/server/umbraco/getChildNodesOfType.ts'; +import { getItemsOrDefault } from '#lib/server/umbraco/getItems.ts'; +import { getUmbracoClient } from '#lib/server/umbraco/client.ts'; const MAXIMUM_SCHEDULE_ITEMS = 100; diff --git a/src/data/getSessionsForSpeaker.ts b/src/lib/server/getSessionsForSpeaker.ts similarity index 70% rename from src/data/getSessionsForSpeaker.ts rename to src/lib/server/getSessionsForSpeaker.ts index d4a1796..a9cc40a 100644 --- a/src/data/getSessionsForSpeaker.ts +++ b/src/lib/server/getSessionsForSpeaker.ts @@ -1,6 +1,6 @@ -import type { ParsedSession } from '@/data/types'; -import { parseSession } from '@/data/parseSession'; -import { getItemsOrDefault } from '@/data/umbraco/getItems'; +import type { ParsedSession } from '#lib/types.ts'; +import { parseSession } from '#lib/server/parseSession.ts'; +import { getItemsOrDefault } from '#lib/server/umbraco/getItems.ts'; export async function getSessionsForSpeaker( conferenceId: string, diff --git a/src/data/getSponsors.ts b/src/lib/server/getSponsors.ts similarity index 60% rename from src/data/getSponsors.ts rename to src/lib/server/getSponsors.ts index e4db126..af09513 100644 --- a/src/data/getSponsors.ts +++ b/src/lib/server/getSponsors.ts @@ -1,5 +1,5 @@ -import type { Sponsors } from '@/data/types'; -import { getFirstChildNodeOfType } from '@/data/umbraco/getChildNodesOfType'; +import type { Sponsors } from '#lib/types.ts'; +import { getFirstChildNodeOfType } from '#lib/server/umbraco/getChildNodesOfType.ts'; export async function getSponsors( conferenceId: string, diff --git a/src/data/parseConference.ts b/src/lib/server/parseConference.ts similarity index 74% rename from src/data/parseConference.ts rename to src/lib/server/parseConference.ts index 672b2ab..617676c 100644 --- a/src/data/parseConference.ts +++ b/src/lib/server/parseConference.ts @@ -1,5 +1,5 @@ -import type { Conference, ParsedConference } from '@/data/types'; -import { parseUtcAsCst } from '@/data/parseUtcAsCst'; +import type { Conference, ParsedConference } from '#lib/types.ts'; +import { parseUtcAsCst } from '#lib/date.ts'; export function parseConference(conference: Conference): ParsedConference { const { properties: { date, ...properties } = {}, ...original } = diff --git a/src/data/parseSession.ts b/src/lib/server/parseSession.ts similarity index 77% rename from src/data/parseSession.ts rename to src/lib/server/parseSession.ts index c8bfb5e..fee002a 100644 --- a/src/data/parseSession.ts +++ b/src/lib/server/parseSession.ts @@ -1,5 +1,5 @@ -import type { Session, ParsedSession } from '@/data/types'; -import { parseUtcAsCst } from '@/data/parseUtcAsCst'; +import type { Session, ParsedSession } from '#lib/types.ts'; +import { parseUtcAsCst } from '#lib/date.ts'; export function parseSession(session: Session): ParsedSession { const { properties: { start, end, ...properties } = {}, ...original } = diff --git a/src/lib/server/umbraco/client.ts b/src/lib/server/umbraco/client.ts new file mode 100644 index 0000000..e44f51d --- /dev/null +++ b/src/lib/server/umbraco/client.ts @@ -0,0 +1,14 @@ +import createClient from 'openapi-fetch'; +import type { paths as deliveryApiPaths } from '#lib/umbraco/deliveryApiSchema.d.ts'; +import type { paths as defaultApiPaths } from '#lib/umbraco/defaultApiSchema.d.ts'; +import { createCmsJsonFetch } from '#lib/server/cache.ts'; +import { getUmbracoBaseUrl } from '#lib/server/config.ts'; + +export function getUmbracoClient() { + const baseUrl = getUmbracoBaseUrl(); + + return createClient({ + baseUrl: baseUrl.toString(), + fetch: createCmsJsonFetch({ baseUrl }), + }); +} diff --git a/src/data/umbraco/getChildNodesOfType.ts b/src/lib/server/umbraco/getChildNodesOfType.ts similarity index 86% rename from src/data/umbraco/getChildNodesOfType.ts rename to src/lib/server/umbraco/getChildNodesOfType.ts index 0dd5888..871620c 100644 --- a/src/data/umbraco/getChildNodesOfType.ts +++ b/src/lib/server/umbraco/getChildNodesOfType.ts @@ -1,5 +1,8 @@ -import type { ContentTypeKeys, ContentTypes } from '@/data/umbraco/types'; -import { getItemsOrDefault } from '@/data/umbraco/getItems'; +import type { + ContentTypeKeys, + ContentTypes, +} from '#lib/server/umbraco/types.ts'; +import { getItemsOrDefault } from '#lib/server/umbraco/getItems.ts'; export type GetChildNodesOfTypeArgs = { nodeId: string; diff --git a/src/data/umbraco/getItemByPath.ts b/src/lib/server/umbraco/getItemByPath.ts similarity index 79% rename from src/data/umbraco/getItemByPath.ts rename to src/lib/server/umbraco/getItemByPath.ts index e88d98e..9a4e541 100644 --- a/src/data/umbraco/getItemByPath.ts +++ b/src/lib/server/umbraco/getItemByPath.ts @@ -1,10 +1,10 @@ -import { getUmbracoClient } from '@/data/umbraco/client'; -import type { paths } from '@/data/umbraco/deliveryApiSchema'; +import { getUmbracoClient } from '#lib/server/umbraco/client.ts'; +import type { paths } from '#lib/umbraco/deliveryApiSchema.d.ts'; import type { UmbracoClientOptions, UmbracoContent, -} from '@/data/umbraco/types'; -import { normalizeUmbracoContent } from '@/data/umbraco/types'; +} from '#lib/server/umbraco/types.ts'; +import { normalizeUmbracoContent } from '#lib/server/umbraco/types.ts'; type GetItemByPathOptions = paths['/umbraco/delivery/api/v2/content/item/{path}']['get']['parameters']['query'] & diff --git a/src/data/umbraco/getItems.ts b/src/lib/server/umbraco/getItems.ts similarity index 75% rename from src/data/umbraco/getItems.ts rename to src/lib/server/umbraco/getItems.ts index b601f4f..aba9615 100644 --- a/src/data/umbraco/getItems.ts +++ b/src/lib/server/umbraco/getItems.ts @@ -1,10 +1,10 @@ -import { getUmbracoClient } from '@/data/umbraco/client'; -import type { paths } from '@/data/umbraco/deliveryApiSchema'; +import { getUmbracoClient } from '#lib/server/umbraco/client.ts'; +import type { paths } from '#lib/umbraco/deliveryApiSchema.d.ts'; import type { UmbracoClientOptions, UmbracoContentCollection, -} from '@/data/umbraco/types'; -import { normalizeUmbracoContentCollection } from '@/data/umbraco/types'; +} from '#lib/server/umbraco/types.ts'; +import { normalizeUmbracoContentCollection } from '#lib/server/umbraco/types.ts'; type GetItemsOptions = NonNullable< paths['/umbraco/delivery/api/v2/content']['get']['parameters']['query'] & diff --git a/src/data/umbraco/treeByRoutePath.ts b/src/lib/server/umbraco/treeByRoutePath.ts similarity index 97% rename from src/data/umbraco/treeByRoutePath.ts rename to src/lib/server/umbraco/treeByRoutePath.ts index eed2d7c..b6b291c 100644 --- a/src/data/umbraco/treeByRoutePath.ts +++ b/src/lib/server/umbraco/treeByRoutePath.ts @@ -1,4 +1,4 @@ -import { splitBy } from '@/util'; +import { splitBy } from '#lib/util.ts'; type Nested = T & { children: Nested[] }; diff --git a/src/lib/server/umbraco/types.ts b/src/lib/server/umbraco/types.ts new file mode 100644 index 0000000..d8d06a7 --- /dev/null +++ b/src/lib/server/umbraco/types.ts @@ -0,0 +1,31 @@ +import type { + UmbracoContent, + UmbracoContentCollection, +} from '#lib/umbraco/types.ts'; + +export type { + ContentTypeKeys, + ContentTypes, + RawUmbracoContent, + RawUmbracoContentCollection, + UmbracoContent, + UmbracoContentCollection, +} from '#lib/umbraco/types.ts'; + +export function normalizeUmbracoContent(content: T): UmbracoContent { + return content as unknown as UmbracoContent; +} + +export function normalizeUmbracoContentCollection( + collection: T, +): UmbracoContentCollection { + return collection as unknown as UmbracoContentCollection; +} + +export type UmbracoClientOptions = { + requestOptions?: { + cache?: RequestInit['cache']; + headers?: RequestInit['headers']; + signal?: RequestInit['signal']; + }; +}; diff --git a/src/data/types.ts b/src/lib/types.ts similarity index 90% rename from src/data/types.ts rename to src/lib/types.ts index 6ba0df0..e0483ad 100644 --- a/src/data/types.ts +++ b/src/lib/types.ts @@ -1,6 +1,6 @@ -import type { components } from '@/data/umbraco/deliveryApiSchema'; -import type { UmbracoContent } from '@/data/umbraco/types'; -import type { Overwrite } from '@/util'; +import type { components } from '#lib/umbraco/deliveryApiSchema.d.ts'; +import type { UmbracoContent } from '#lib/umbraco/types.ts'; +import type { Overwrite } from '#lib/util.ts'; type Content = UmbracoContent; diff --git a/src/data/umbraco/defaultApiSchema.d.ts b/src/lib/umbraco/defaultApiSchema.d.ts similarity index 100% rename from src/data/umbraco/defaultApiSchema.d.ts rename to src/lib/umbraco/defaultApiSchema.d.ts diff --git a/src/data/umbraco/deliveryApiSchema.d.ts b/src/lib/umbraco/deliveryApiSchema.d.ts similarity index 100% rename from src/data/umbraco/deliveryApiSchema.d.ts rename to src/lib/umbraco/deliveryApiSchema.d.ts diff --git a/src/data/umbraco/types.ts b/src/lib/umbraco/types.ts similarity index 69% rename from src/data/umbraco/types.ts rename to src/lib/umbraco/types.ts index b7e9267..3b0fca4 100644 --- a/src/data/umbraco/types.ts +++ b/src/lib/umbraco/types.ts @@ -1,9 +1,9 @@ -import type { components } from '@/data/umbraco/deliveryApiSchema'; +import type { components } from '#lib/umbraco/deliveryApiSchema.d.ts'; -// The v18 OpenAPI schema intersects each content model with a base model that has -// `properties: null | Record`. This poisons the specific properties -// model via the index signature. ResolveContent overrides `properties` with the -// correct specific type for each content type. +// Umbraco v18's OpenAPI schema intersects each content model with a base model +// that has `properties: null | Record`. That index signature +// hides the specific properties model. ResolveContent restores the properties +// type for the content types this site reads. type ContentPropertiesFor = C extends 'conference' ? components['schemas']['ConferenceContentPropertiesModel'] : C extends 'conferences' @@ -44,20 +44,3 @@ export type RawUmbracoContentCollection = export type ContentTypes = UmbracoContent; export type ContentTypeKeys = ContentTypes['contentType']; - -export function normalizeUmbracoContent(content: T): UmbracoContent { - return content as unknown as UmbracoContent; -} - -export function normalizeUmbracoContentCollection( - collection: T, -): UmbracoContentCollection { - return collection as unknown as UmbracoContentCollection; -} - -export type UmbracoClientOptions = { - requestOptions?: { - next?: RequestInit['next']; - cache?: RequestInit['cache']; - }; -}; diff --git a/src/util.ts b/src/lib/util.ts similarity index 75% rename from src/util.ts rename to src/lib/util.ts index fbb8399..15ca706 100644 --- a/src/util.ts +++ b/src/lib/util.ts @@ -1,9 +1,5 @@ -import { format } from 'date-fns'; -import { tz } from '@date-fns/tz'; -import { CST_TZ } from '@/config'; - export function _throw(msg: string): never { - throw msg; + throw new Error(msg); } export function parseUrl(urlStr?: string | null): URL | undefined { @@ -44,10 +40,4 @@ export function splitBy( return [validItems, invalidItems]; } -// Dates are actually strings when running on the client -// hence the Date | string type -export function formatDate(date: Date | string, formatStr: string): string { - return format(date, formatStr, { in: tz(CST_TZ) }); -} - export type Overwrite = Omit & U; diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 5ea37b0..c514362 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,5 +1,5 @@ -import { SEARCH_INDEXING_ENABLED } from '$app/env/private'; +import { isSearchIndexingEnabled } from '#lib/server/config.ts'; export const load = () => ({ - searchIndexingEnabled: SEARCH_INDEXING_ENABLED, + searchIndexingEnabled: isSearchIndexingEnabled(), }); diff --git a/src/serverConfig.ts b/src/serverConfig.ts deleted file mode 100644 index 6eb442e..0000000 --- a/src/serverConfig.ts +++ /dev/null @@ -1,38 +0,0 @@ -import 'server-only'; -import { parseUrl } from '@/util'; - -function getRequiredUrl(name: string): URL { - const url = parseUrl(process.env[name]); - - if (!url) { - throw new Error(`${name} is not a valid URL`); - } - - return url; -} - -export function getUmbracoBaseUrl(): URL { - return getRequiredUrl('UMBRACO_BASE_URL'); -} - -export function getCmsPublicUrl(): URL { - return getRequiredUrl('CMS_PUBLIC_URL'); -} - -export function getSiteUrl(): URL { - return getRequiredUrl('SITE_URL'); -} - -export function isSearchIndexingEnabled(): boolean { - return process.env.SEARCH_INDEXING_ENABLED === 'true'; -} - -export function getNewsletterConfig(): { - endpoint?: URL; - listId?: string; -} { - return { - endpoint: parseUrl(process.env.NEWSLETTER_ENDPOINT), - listId: process.env.NEWSLETTER_LIST_ID, - }; -} diff --git a/tests/unit/cms-cache.test.ts b/tests/unit/cms-cache.test.ts new file mode 100644 index 0000000..dbdb6b4 --- /dev/null +++ b/tests/unit/cms-cache.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from 'vitest'; +import { createCmsJsonFetch } from '#lib/server/cache.ts'; + +type MockReply = + | { status?: number; body: unknown; headers?: HeadersInit } + | Error + | (() => Promise); + +const baseUrl = 'https://cms.methodconf.test'; +const contentUrl = `${baseUrl}/umbraco/delivery/api/v2/content?filter=contentType%3Aconference`; + +function jsonResponse(body: unknown, status = 200, headers: HeadersInit = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); +} + +function createMockFetch(replies: MockReply[]) { + const requests: Request[] = []; + + const fetch: typeof globalThis.fetch = async (input, init) => { + const request = + input instanceof Request ? input : new Request(input, init); + requests.push(request.clone()); + const reply = replies.shift(); + + if (!reply) { + throw new Error( + `No mock reply for ${request.method} ${request.url}`, + ); + } + + if (reply instanceof Error) { + throw reply; + } + + if (typeof reply === 'function') { + return await reply(); + } + + return jsonResponse(reply.body, reply.status, reply.headers); + }; + + return { fetch, requests }; +} + +async function readJson(response: Response) { + return (await response.json()) as { + value: number; + nested?: { count: number }; + }; +} + +describe('CMS JSON cache', () => { + it('serves fresh cached JSON as a detached response body', async () => { + let now = 0; + const { fetch, requests } = createMockFetch([ + { body: { value: 1, nested: { count: 1 } } }, + ]); + const cachedFetch = createCmsJsonFetch({ + baseUrl, + fetch, + now: () => now, + }); + + const first = await readJson(await cachedFetch(contentUrl)); + first.nested!.count = 99; + now += 30_000; + const second = await readJson(await cachedFetch(contentUrl)); + + expect(requests).toHaveLength(1); + expect(second).toEqual({ value: 1, nested: { count: 1 } }); + }); + + it('returns stale data during stale-while-revalidate, then uses the refreshed value', async () => { + let now = 0; + const { fetch, requests } = createMockFetch([ + { body: { value: 1 } }, + { body: { value: 2 } }, + ]); + const cachedFetch = createCmsJsonFetch({ + baseUrl, + fetch, + now: () => now, + }); + + expect(await readJson(await cachedFetch(contentUrl))).toEqual({ + value: 1, + }); + now += 61_000; + + expect(await readJson(await cachedFetch(contentUrl))).toEqual({ + value: 1, + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(await readJson(await cachedFetch(contentUrl))).toEqual({ + value: 2, + }); + expect(requests).toHaveLength(2); + }); + + it('coalesces cold requests for the same cache key', async () => { + let resolveResponse: (response: Response) => void = () => undefined; + const firstReply = new Promise((resolve) => { + resolveResponse = resolve; + }); + const { fetch, requests } = createMockFetch([() => firstReply]); + const cachedFetch = createCmsJsonFetch({ baseUrl, fetch }); + + const first = cachedFetch(contentUrl); + const second = cachedFetch(contentUrl); + resolveResponse(jsonResponse({ value: 1 })); + + await expect(readJson(await first)).resolves.toEqual({ value: 1 }); + await expect(readJson(await second)).resolves.toEqual({ value: 1 }); + expect(requests).toHaveLength(1); + }); + + it('does not serve data past the hard max age when refresh fails', async () => { + let now = 0; + const { fetch } = createMockFetch([ + { body: { value: 1 } }, + new Error('cms offline'), + ]); + const cachedFetch = createCmsJsonFetch({ + baseUrl, + fetch, + now: () => now, + }); + + await cachedFetch(contentUrl); + now += 301_000; + + await expect(cachedFetch(contentUrl)).rejects.toThrow('cms offline'); + }); + + it('keeps cache entries bounded and isolates representation headers', async () => { + let now = 0; + const { fetch, requests } = createMockFetch([ + { body: { value: 1 } }, + { body: { value: 2 } }, + { body: { value: 3 } }, + { body: { value: 4 } }, + { body: { value: 5 } }, + ]); + const cachedFetch = createCmsJsonFetch({ + baseUrl, + fetch, + now: () => now, + maxEntries: 1, + }); + + expect( + await readJson( + await cachedFetch( + `${baseUrl}/umbraco/delivery/api/v2/content?take=1`, + ), + ), + ).toEqual({ value: 1 }); + expect( + await readJson( + await cachedFetch( + `${baseUrl}/umbraco/delivery/api/v2/content?take=2`, + ), + ), + ).toEqual({ value: 2 }); + expect( + await readJson( + await cachedFetch( + `${baseUrl}/umbraco/delivery/api/v2/content?take=1`, + ), + ), + ).toEqual({ value: 3 }); + + now += 1; + await cachedFetch(contentUrl, { + headers: { accept: 'application/json' }, + }); + await cachedFetch(contentUrl, { + headers: { accept: 'application/vnd.api+json' }, + }); + + expect(requests).toHaveLength(5); + }); + + it('bypasses auth, preview, no-store, non-GET, media, and non-allowlisted requests', async () => { + const { fetch, requests } = createMockFetch([ + { body: { value: 1 } }, + { body: { value: 2 } }, + { body: { value: 3 } }, + { body: { value: 4 } }, + { body: { value: 5 } }, + { body: { value: 6 } }, + ]); + const cachedFetch = createCmsJsonFetch({ baseUrl, fetch }); + + await cachedFetch(contentUrl, { + headers: { authorization: 'Bearer token' }, + }); + await cachedFetch(contentUrl, { headers: { cookie: 'preview=true' } }); + await cachedFetch(contentUrl, { + headers: { 'x-umbraco-preview': 'true' }, + }); + await cachedFetch(new Request(contentUrl, { cache: 'no-store' })); + await cachedFetch(`${baseUrl}/cms-media/media/file.pdf`); + await cachedFetch(contentUrl, { method: 'POST', body: '{}' }); + + expect(requests).toHaveLength(6); + expect(requests.map((request) => request.method)).toEqual([ + 'GET', + 'GET', + 'GET', + 'GET', + 'GET', + 'POST', + ]); + }); + + it('does not store failed refreshes or keep stale content after an authoritative 404', async () => { + let now = 0; + const { fetch, requests } = createMockFetch([ + { body: { value: 1 } }, + { status: 404, body: { value: 404 } }, + { status: 404, body: { value: 404 } }, + ]); + const cachedFetch = createCmsJsonFetch({ + baseUrl, + fetch, + now: () => now, + }); + + expect(await readJson(await cachedFetch(contentUrl))).toEqual({ + value: 1, + }); + now += 61_000; + expect(await readJson(await cachedFetch(contentUrl))).toEqual({ + value: 1, + }); + await Promise.resolve(); + await Promise.resolve(); + + const afterEviction = await cachedFetch(contentUrl); + + expect(afterEviction.status).toBe(404); + expect(requests).toHaveLength(3); + }); +}); diff --git a/tests/unit/cms-data.test.ts b/tests/unit/cms-data.test.ts new file mode 100644 index 0000000..7136cac --- /dev/null +++ b/tests/unit/cms-data.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { formatDate, parseUtcAsCst } from '#lib/date.ts'; +import { parseConference } from '#lib/server/parseConference.ts'; +import { parseSession } from '#lib/server/parseSession.ts'; +import { treeByRoutePath } from '#lib/server/umbraco/treeByRoutePath.ts'; +import { normalizeUmbracoContent } from '#lib/server/umbraco/types.ts'; +import type { Conference, Session } from '#lib/types.ts'; + +describe('CMS dates and normalization helpers', () => { + it('interprets Umbraco date parts in America/Chicago and returns plain Date instances', () => { + const summer = parseUtcAsCst('2024-09-20T09:30:15.123Z'); + const winter = parseUtcAsCst('2024-01-15T09:30:00+00:00'); + + expect(summer).toBeInstanceOf(Date); + expect(Object.getPrototypeOf(summer)).toBe(Date.prototype); + expect(summer.toISOString()).toBe('2024-09-20T14:30:15.123Z'); + expect(formatDate(summer, 'yyyy-MM-dd HH:mm xxx')).toBe( + '2024-09-20 09:30 -05:00', + ); + expect(winter.toISOString()).toBe('2024-01-15T15:30:00.000Z'); + expect(formatDate(winter, 'yyyy-MM-dd HH:mm xxx')).toBe( + '2024-01-15 09:30 -06:00', + ); + }); + + it('keeps conference sorting based on parsed Chicago-local instants', () => { + const conferences = [ + makeConference('2023', '2023-09-20T09:00:00Z'), + makeConference('2024', '2024-09-20T09:00:00Z'), + makeConference('no-date'), + ].map(parseConference); + + const latest = conferences + .filter((conference) => conference.properties.date) + .sort( + (a, b) => + b.properties.date!.getTime() - a.properties.date!.getTime(), + )[0]; + + expect(latest.route.path).toBe('/2024/'); + expect( + formatDate(latest.properties.date!, 'yyyy-MM-dd HH:mm xxx'), + ).toBe('2024-09-20 09:00 -05:00'); + }); + + it('parses session dates without changing other properties', () => { + const session = parseSession({ + id: 'session-1', + contentType: 'session', + route: { path: '/2024/sessions/keynote/' }, + properties: { + title: 'Opening keynote', + start: '2024-09-20T10:00:00Z', + end: '2024-09-20T10:45:00Z', + }, + } as unknown as Session); + + expect(session.id).toBe('session-1'); + expect(formatDate(session.properties.start!, 'HH:mm')).toBe('10:00'); + expect(formatDate(session.properties.end!, 'HH:mm')).toBe('10:45'); + }); + + it('normalizes Umbraco content types and builds the route tree in source order', () => { + const scheduleRoot = normalizeUmbracoContent({ + id: 'sessions-root', + contentType: 'sessions', + route: { path: '/2024/sessions/' }, + properties: {}, + }); + const track = normalizeUmbracoContent({ + id: 'track-1', + contentType: 'track', + route: { path: '/2024/sessions/track-1/' }, + properties: { title: 'Track 1' }, + }); + const session = normalizeUmbracoContent({ + id: 'session-1', + contentType: 'session', + route: { path: '/2024/sessions/track-1/keynote/' }, + properties: { title: 'Opening keynote' }, + }); + const laterRoot = normalizeUmbracoContent({ + id: 'sponsors-root', + contentType: 'sponsors', + route: { path: '/2024/sponsors/' }, + properties: {}, + }); + + const tree = treeByRoutePath([scheduleRoot, track, session, laterRoot]); + + expect(tree.map((item) => item.id)).toEqual([ + 'sessions-root', + 'sponsors-root', + ]); + expect(tree[0].children.map((item) => item.id)).toEqual(['track-1']); + expect(tree[0].children[0].children.map((item) => item.id)).toEqual([ + 'session-1', + ]); + }); +}); + +function makeConference(slug: string, date?: string): Conference { + return { + id: slug, + contentType: 'conference', + route: { path: `/${slug}/` }, + properties: { date }, + } as unknown as Conference; +} From 6a2a665ef8b2526de8b9139c17a696abec5ac70f Mon Sep 17 00:00:00 2001 From: Levi Zitting Date: Sun, 4 Oct 2026 01:04:53 -0500 Subject: [PATCH 2/2] fix: share CMS data cache --- src/lib/server/cache.ts | 106 ++++++++++++++++-- src/lib/server/config.ts | 21 ++-- src/lib/server/umbraco/client.ts | 38 ++++++- tests/unit/cms-cache.test.ts | 115 +++++++++++++++++++ tests/unit/cms-client-cache.test.ts | 168 ++++++++++++++++++++++++++++ 5 files changed, 420 insertions(+), 28 deletions(-) create mode 100644 tests/unit/cms-client-cache.test.ts diff --git a/src/lib/server/cache.ts b/src/lib/server/cache.ts index 493d571..90ee07f 100644 --- a/src/lib/server/cache.ts +++ b/src/lib/server/cache.ts @@ -19,6 +19,7 @@ type FetchedJsonResponse = Omit; type FetchOutcome = | { kind: 'success'; value: CachedJsonResponse } + | { kind: 'uncacheable-success'; value: FetchedJsonResponse } | { kind: 'http-failure'; value: FetchedJsonResponse } | { kind: 'network-failure'; error: unknown }; @@ -61,6 +62,13 @@ function isAllowlistedPublicCmsJsonGet(url: URL, baseUrl: URL): boolean { return /^\/api\/v1\/conference\/[^/]+\/schedule$/.test(url.pathname); } +function hasNoStoreNoCacheOrPrivate(headers: Headers): boolean { + const cacheControl = headers.get('cache-control')?.toLowerCase(); + return cacheControl + ? /(?:^|,|\s)(?:no-store|no-cache|private)(?:,|\s|$)/.test(cacheControl) + : false; +} + function hasBypassHeader(headers: Headers): boolean { for (const header of BYPASS_HEADERS) { if (headers.has(header)) { @@ -68,10 +76,15 @@ function hasBypassHeader(headers: Headers): boolean { } } - const cacheControl = headers.get('cache-control')?.toLowerCase(); - return cacheControl - ? /(?:^|,|\s)(?:no-store|no-cache|private)(?:,|\s|$)/.test(cacheControl) - : false; + return hasNoStoreNoCacheOrPrivate(headers); +} + +function hasPrivateResponseState(headers: Headers): boolean { + return ( + hasNoStoreNoCacheOrPrivate(headers) || + headers.has('set-cookie') || + headers.has('set-cookie2') + ); } function hasBypassCacheMode(request: Request): boolean { @@ -102,18 +115,69 @@ function makeResponse( }); } +function cloneResponseHeaders(headers: Headers): [string, string][] { + return [...headers.entries()]; +} + function cloneCacheableHeaders(headers: Headers): [string, string][] { const copied = new Headers(headers); copied.delete('set-cookie'); copied.delete('set-cookie2'); - return [...copied.entries()]; + return cloneResponseHeaders(copied); +} + +function makeSharedRefreshRequest(request: Request): Request { + return new Request(request, { signal: new AbortController().signal }); +} + +function getAbortReason(signal: AbortSignal): unknown { + return ( + signal.reason ?? + new DOMException('The operation was aborted.', 'AbortError') + ); +} + +function throwIfAborted(signal: AbortSignal): void { + if (signal.aborted) { + throw getAbortReason(signal); + } +} + +async function waitForOutcome( + promise: Promise, + signal: AbortSignal, +): Promise { + throwIfAborted(signal); + + return await new Promise((resolve, reject) => { + const cleanup = () => signal.removeEventListener('abort', onAbort); + const onAbort = () => { + cleanup(); + reject(getAbortReason(signal)); + }; + + signal.addEventListener('abort', onAbort, { once: true }); + promise.then( + (value) => { + cleanup(); + resolve(value); + }, + (error: unknown) => { + cleanup(); + reject(error); + }, + ); + }); } async function readJsonResponse( response: Response, storedAt: number, ): Promise { - const headers = cloneCacheableHeaders(response.headers); + const privateResponse = hasPrivateResponseState(response.headers); + const headers = privateResponse + ? cloneResponseHeaders(response.headers) + : cloneCacheableHeaders(response.headers); const bodyText = await response.text(); const base = { status: response.status, @@ -130,11 +194,20 @@ async function readJsonResponse( try { const parsed = bodyText ? JSON.parse(bodyText) : null; + const body = JSON.stringify(parsed); + + if (privateResponse) { + return { + kind: 'uncacheable-success', + value: { ...base, body }, + }; + } + return { kind: 'success', value: { ...base, - body: JSON.stringify(parsed), + body, storedAt, }, }; @@ -203,7 +276,7 @@ export function createCmsJsonFetch(options: CmsJsonCacheOptions): typeof fetch { return existing.inFlight; } - const inFlight = fetchFn(request.clone()) + const inFlight = fetchFn(makeSharedRefreshRequest(request)) .then((response) => readJsonResponse(response, now())) .then((outcome) => { const entry = cache.get(key); @@ -214,6 +287,11 @@ export function createCmsJsonFetch(options: CmsJsonCacheOptions): typeof fetch { return outcome; } + if (outcome.kind === 'uncacheable-success') { + cache.delete(key); + return outcome; + } + if ( outcome.kind === 'http-failure' && outcome.value.status >= 400 && @@ -248,6 +326,8 @@ export function createCmsJsonFetch(options: CmsJsonCacheOptions): typeof fetch { return fetchFn(input, init); } + throwIfAborted(request.signal); + const currentTime = now(); pruneExpiredEntries(currentTime); @@ -268,9 +348,15 @@ export function createCmsJsonFetch(options: CmsJsonCacheOptions): typeof fetch { } } - const outcome = await startRefresh(key, request); + const outcome = await waitForOutcome( + startRefresh(key, request), + request.signal, + ); - if (outcome.kind === 'success') { + if ( + outcome.kind === 'success' || + outcome.kind === 'uncacheable-success' + ) { return makeResponse(outcome.value); } diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts index 40ec273..96a00a5 100644 --- a/src/lib/server/config.ts +++ b/src/lib/server/config.ts @@ -1,11 +1,4 @@ -import { - CMS_PUBLIC_URL, - NEWSLETTER_ENDPOINT, - NEWSLETTER_LIST_ID, - SEARCH_INDEXING_ENABLED, - SITE_URL, - UMBRACO_BASE_URL, -} from '$app/env/private'; +import * as privateEnv from '$app/env/private'; import { parseUrl } from '#lib/util.ts'; function getRequiredUrl(name: string, value: string | undefined): URL { @@ -19,19 +12,19 @@ function getRequiredUrl(name: string, value: string | undefined): URL { } export function getUmbracoBaseUrl(): URL { - return getRequiredUrl('UMBRACO_BASE_URL', UMBRACO_BASE_URL); + return getRequiredUrl('UMBRACO_BASE_URL', privateEnv.UMBRACO_BASE_URL); } export function getCmsPublicUrl(): URL { - return getRequiredUrl('CMS_PUBLIC_URL', CMS_PUBLIC_URL); + return getRequiredUrl('CMS_PUBLIC_URL', privateEnv.CMS_PUBLIC_URL); } export function getSiteUrl(): URL { - return getRequiredUrl('SITE_URL', SITE_URL); + return getRequiredUrl('SITE_URL', privateEnv.SITE_URL); } export function isSearchIndexingEnabled(): boolean { - return SEARCH_INDEXING_ENABLED; + return privateEnv.SEARCH_INDEXING_ENABLED; } export function getNewsletterConfig(): { @@ -39,7 +32,7 @@ export function getNewsletterConfig(): { listId?: string; } { return { - endpoint: parseUrl(NEWSLETTER_ENDPOINT), - listId: NEWSLETTER_LIST_ID, + endpoint: parseUrl(privateEnv.NEWSLETTER_ENDPOINT), + listId: privateEnv.NEWSLETTER_LIST_ID, }; } diff --git a/src/lib/server/umbraco/client.ts b/src/lib/server/umbraco/client.ts index e44f51d..00a7971 100644 --- a/src/lib/server/umbraco/client.ts +++ b/src/lib/server/umbraco/client.ts @@ -4,11 +4,41 @@ import type { paths as defaultApiPaths } from '#lib/umbraco/defaultApiSchema.d.t import { createCmsJsonFetch } from '#lib/server/cache.ts'; import { getUmbracoBaseUrl } from '#lib/server/config.ts'; +type UmbracoClient = ReturnType< + typeof createClient +>; + +const MAX_CLIENTS = 8; +const clients = new Map(); + +function rememberClient(key: string, client: UmbracoClient): UmbracoClient { + clients.set(key, client); + + while (clients.size > MAX_CLIENTS) { + const oldestKey = clients.keys().next().value as string | undefined; + if (!oldestKey) { + break; + } + clients.delete(oldestKey); + } + + return client; +} + export function getUmbracoClient() { const baseUrl = getUmbracoBaseUrl(); + const key = baseUrl.toString(); + const cached = clients.get(key); + + if (cached) { + return cached; + } - return createClient({ - baseUrl: baseUrl.toString(), - fetch: createCmsJsonFetch({ baseUrl }), - }); + return rememberClient( + key, + createClient({ + baseUrl: key, + fetch: createCmsJsonFetch({ baseUrl }), + }), + ); } diff --git a/tests/unit/cms-cache.test.ts b/tests/unit/cms-cache.test.ts index dbdb6b4..feb2773 100644 --- a/tests/unit/cms-cache.test.ts +++ b/tests/unit/cms-cache.test.ts @@ -219,6 +219,121 @@ describe('CMS JSON cache', () => { ]); }); + it('keeps the shared upstream request detached from caller aborts', async () => { + let resolveBody: () => void = () => undefined; + const body = new Promise((resolve) => { + resolveBody = resolve; + }); + const requests: Request[] = []; + const fetch: typeof globalThis.fetch = async (input, init) => { + const request = + input instanceof Request ? input : new Request(input, init); + requests.push(request.clone()); + + if (request.signal.aborted) { + throw request.signal.reason; + } + + await Promise.race([ + body, + new Promise((_, reject) => { + request.signal.addEventListener( + 'abort', + () => reject(request.signal.reason), + { once: true }, + ); + }), + ]); + + return jsonResponse({ value: 1 }); + }; + const cachedFetch = createCmsJsonFetch({ baseUrl, fetch }); + const controller = new AbortController(); + + const first = cachedFetch(contentUrl, { signal: controller.signal }); + const second = cachedFetch(contentUrl); + controller.abort(new DOMException('first caller left', 'AbortError')); + resolveBody(); + + await expect(first).rejects.toThrow(/first caller left|aborted/i); + await expect(readJson(await second)).resolves.toEqual({ value: 1 }); + expect(requests).toHaveLength(1); + expect(requests[0].signal.aborted).toBe(false); + }); + + it('does not start a cache request for an already-aborted caller', async () => { + const { fetch, requests } = createMockFetch([{ body: { value: 1 } }]); + const cachedFetch = createCmsJsonFetch({ baseUrl, fetch }); + const controller = new AbortController(); + controller.abort(new DOMException('gone', 'AbortError')); + + await expect( + cachedFetch(contentUrl, { signal: controller.signal }), + ).rejects.toThrow(/gone|aborted/i); + expect(requests).toHaveLength(0); + }); + + it('passes through private responses and set-cookie without storing them', async () => { + const { fetch, requests } = createMockFetch([ + { + body: { value: 1 }, + headers: { + 'cache-control': 'private, no-store', + 'set-cookie': 'preview=true; Path=/; HttpOnly', + }, + }, + { + body: { value: 2 }, + headers: { 'cache-control': 'no-cache' }, + }, + ]); + const cachedFetch = createCmsJsonFetch({ baseUrl, fetch }); + + const first = await cachedFetch(contentUrl); + expect(await readJson(first)).toEqual({ value: 1 }); + expect(first.headers.get('set-cookie')).toContain('preview=true'); + + expect(await readJson(await cachedFetch(contentUrl))).toEqual({ + value: 2, + }); + expect(requests).toHaveLength(2); + }); + + it('evicts stale public data when the refresh becomes private', async () => { + let now = 0; + const { fetch, requests } = createMockFetch([ + { body: { value: 1 } }, + { + body: { value: 2 }, + headers: { 'cache-control': 'private, no-store' }, + }, + { + body: { value: 3 }, + headers: { 'cache-control': 'private, no-store' }, + }, + ]); + const cachedFetch = createCmsJsonFetch({ + baseUrl, + fetch, + now: () => now, + }); + + expect(await readJson(await cachedFetch(contentUrl))).toEqual({ + value: 1, + }); + now += 61_000; + expect(await readJson(await cachedFetch(contentUrl))).toEqual({ + value: 1, + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(await readJson(await cachedFetch(contentUrl))).toEqual({ + value: 3, + }); + expect(requests).toHaveLength(3); + }); + it('does not store failed refreshes or keep stale content after an authoritative 404', async () => { let now = 0; const { fetch, requests } = createMockFetch([ diff --git a/tests/unit/cms-client-cache.test.ts b/tests/unit/cms-client-cache.test.ts new file mode 100644 index 0000000..445bab2 --- /dev/null +++ b/tests/unit/cms-client-cache.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const privateEnv = vi.hoisted(() => ({ + UMBRACO_BASE_URL: 'https://cms-a.methodconf.test', + CMS_PUBLIC_URL: 'https://cms-public.methodconf.test', + SITE_URL: 'https://www.methodconf.test', + SEARCH_INDEXING_ENABLED: false, + NEWSLETTER_ENDPOINT: undefined as string | undefined, + NEWSLETTER_LIST_ID: undefined as string | undefined, +})); + +vi.mock('$app/env/private', () => ({ + get UMBRACO_BASE_URL() { + return privateEnv.UMBRACO_BASE_URL; + }, + get CMS_PUBLIC_URL() { + return privateEnv.CMS_PUBLIC_URL; + }, + get SITE_URL() { + return privateEnv.SITE_URL; + }, + get SEARCH_INDEXING_ENABLED() { + return privateEnv.SEARCH_INDEXING_ENABLED; + }, + get NEWSLETTER_ENDPOINT() { + return privateEnv.NEWSLETTER_ENDPOINT; + }, + get NEWSLETTER_LIST_ID() { + return privateEnv.NEWSLETTER_LIST_ID; + }, +})); + +function resetPrivateEnv(): void { + privateEnv.UMBRACO_BASE_URL = 'https://cms-a.methodconf.test'; + privateEnv.CMS_PUBLIC_URL = 'https://cms-public.methodconf.test'; + privateEnv.SITE_URL = 'https://www.methodconf.test'; + privateEnv.SEARCH_INDEXING_ENABLED = false; + privateEnv.NEWSLETTER_ENDPOINT = undefined; + privateEnv.NEWSLETTER_LIST_ID = undefined; +} + +function contentItem(value: number) { + return { + id: `item-${value}`, + contentType: 'conference', + route: { path: `/item-${value}/` }, + properties: { value }, + }; +} + +function stubFetch(fetch: typeof globalThis.fetch): void { + vi.stubGlobal('fetch', fetch); +} + +describe('Umbraco helper cache wiring', () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + resetPrivateEnv(); + }); + + it('reuses the CMS cache across repeated getItemsOrDefault helper calls', async () => { + const requests: string[] = []; + stubFetch(async (input, init) => { + const request = + input instanceof Request ? input : new Request(input, init); + requests.push(request.url); + return Response.json({ total: 0, items: [] }); + }); + const { getItemsOrDefault } = + await import('#lib/server/umbraco/getItems.ts'); + + await getItemsOrDefault({ filter: ['contentType:conference'] }); + await getItemsOrDefault({ filter: ['contentType:conference'] }); + + expect(requests).toHaveLength(1); + }); + + it('coalesces simultaneous getItemsOrDefault helper calls', async () => { + let resolveResponse: (response: Response) => void = () => undefined; + const firstResponse = new Promise((resolve) => { + resolveResponse = resolve; + }); + const requests: string[] = []; + stubFetch(async (input, init) => { + const request = + input instanceof Request ? input : new Request(input, init); + requests.push(request.url); + return await firstResponse; + }); + const { getItemsOrDefault } = + await import('#lib/server/umbraco/getItems.ts'); + + const first = getItemsOrDefault({ filter: ['contentType:conference'] }); + const second = getItemsOrDefault({ + filter: ['contentType:conference'], + }); + resolveResponse(Response.json({ total: 0, items: [] })); + + await expect(first).resolves.toEqual({ total: 0, items: [] }); + await expect(second).resolves.toEqual({ total: 0, items: [] }); + expect(requests).toHaveLength(1); + }); + + it('reuses the CMS cache across repeated getItemByPathOrDefault helper calls', async () => { + const requests: string[] = []; + stubFetch(async (input, init) => { + const request = + input instanceof Request ? input : new Request(input, init); + requests.push(request.url); + return Response.json(contentItem(1)); + }); + const { getItemByPathOrDefault } = + await import('#lib/server/umbraco/getItemByPath.ts'); + + await expect(getItemByPathOrDefault('/2024/')).resolves.toEqual( + contentItem(1), + ); + await expect(getItemByPathOrDefault('/2024/')).resolves.toEqual( + contentItem(1), + ); + + expect(requests).toHaveLength(1); + }); + + it('keeps helper caches isolated by runtime Umbraco base URL', async () => { + const requests: string[] = []; + stubFetch(async (input, init) => { + const request = + input instanceof Request ? input : new Request(input, init); + requests.push(request.url); + return Response.json({ + total: 1, + items: [contentItem(request.url.includes('cms-a') ? 1 : 2)], + }); + }); + const { getItemsOrDefault } = + await import('#lib/server/umbraco/getItems.ts'); + + await expect( + getItemsOrDefault({ filter: ['contentType:conference'] }), + ).resolves.toEqual({ + total: 1, + items: [contentItem(1)], + }); + + privateEnv.UMBRACO_BASE_URL = 'https://cms-b.methodconf.test'; + await expect( + getItemsOrDefault({ filter: ['contentType:conference'] }), + ).resolves.toEqual({ + total: 1, + items: [contentItem(2)], + }); + + privateEnv.UMBRACO_BASE_URL = 'https://cms-a.methodconf.test'; + await expect( + getItemsOrDefault({ filter: ['contentType:conference'] }), + ).resolves.toEqual({ + total: 1, + items: [contentItem(1)], + }); + + expect(requests.map((url) => new URL(url).origin)).toEqual([ + 'https://cms-a.methodconf.test', + 'https://cms-b.methodconf.test', + ]); + }); +});