apps/web/src/routes/api/revalidate.ts

1import { setTimeout } from 'node:timers/promises'
2import {
3	ALL_RESOURCE_TYPES,
4	DOMAIN,
5	VERCEL_LIVE_REBUILD_URL,
6} from '@local/config'
7import { clearArticleAuthorVisibilityCache, sanityClient } from '@local/sanity'
8import { sanityFetch } from '@local/sanity/utils/fetch'
9import { clearSiteDefaultsCache } from '@local/seo'
10import { isValidSignature, SIGNATURE_HEADER_NAME } from '@sanity/webhook'
11import type { APIEvent } from '@solidjs/start/server'
12import { waitUntil } from '@vercel/functions'
13import { clearEventDatesCache } from '~/lib/eventDates'
14import { clearLivePricingCache } from '~/lib/livePricing.server'
15import { syncRedirectsToBlob } from '~/lib/redirects/sync'
16import { clearNavLayoutCache } from '~/lib/sanity/navLayoutQuery'
17
18const SANITY_CLIENT = sanityClient.withConfig({
19	perspective: 'published',
20	useCdn: false,
21})
22
23/** Parallel ISR revalidation requests (limits thundering herds). */
24const REVALIDATE_CONCURRENCY = 5
25/** Delay between revalidation batches. */
26const REVALIDATE_BATCH_DELAY_MS = 250
27/** Second homepage hit after story publish — past typical Sanity GROQ CDN TTL. */
28const HOME_CDN_RETRY_MS = 60_000
29
30/** Docs whose publish should drop warm-instance singleton caches. */
31const NAV_CACHE_INVALIDATE_TYPES = new Set(['settings.header', 'event'])
32const SITE_DEFAULTS_CACHE_INVALIDATE_TYPES = new Set([
33	'seoDefaults',
34	'schemaMarkupDefaults',
35	'cookieConsentSettings',
36	'socials',
37])
38
39function clearWarmSingletonCaches(docType: string) {
40	if (docType === 'page' || docType === 'event') clearLivePricingCache()
41	if (NAV_CACHE_INVALIDATE_TYPES.has(docType)) {
42		clearNavLayoutCache()
43	}
44	if (SITE_DEFAULTS_CACHE_INVALIDATE_TYPES.has(docType)) {
45		clearSiteDefaultsCache()
46	}
47	if (docType === 'articleSettings') {
48		clearArticleAuthorVisibilityCache()
49	}
50	if (docType === 'event') {
51		// The AI routing context bakes in each event's confirmed dates.
52		clearEventDatesCache()
53	}
54}
55
56// Full rebuilds invalidate the ENTIRE ISR cache (~6,900 pages), which crawlers
57// then re-render page by page — each render is a billed ISR write. Only doc
58// types whose data is baked at BUILD time belong here. Everything else is
59// handled by targeted revalidation (`routeRules` below) plus the global ISR
60// TTL in app.config.ts, which bounds staleness for globally-embedded data
61// (header/footer/settings) without a rebuild.
62const FULL_REBUILD_DOCS = [
63	// Maps generate API endpoints at build time
64	'sessionTypeWpMap',
65	'decisionTypeMap',
66	'decisionThemeMap',
67	// Production forms render from `form-builder-cache.generated.ts`, produced
68	// by `scripts/prebuild-form-builder-cache.ts` — form edits need a build.
69	'formBuilderForm',
70	'formBuilderField',
71	'formBuilderFieldSet',
72	'formBuilderOptionPreset',
73	'formBuilderFormSettings',
74	// `redirect` is intentionally NOT in this list — those are pushed to Vercel
75	// Blob via `syncRedirectsToBlob()` (handled below) and picked up by the
76	// Routing Middleware within ~60 s. No rebuild required.
77]
78
79const storyTypes = ALL_RESOURCE_TYPES
80const basicPageTypes = ['page', 'event']
81const recommendationHostTypes = [...ALL_RESOURCE_TYPES]
82const storyRefUpdateTypes = [...basicPageTypes, ...recommendationHostTypes]
83const criticalPageFields = ['fullSlug', 'slug']
84
85// updateRefs: revalidate all documents that reference the document ID
86// updateType: revalidate all documents of a specific type
87type RouteRule = {
88	updateRefs?: string[] | 'ALL'
89	criticalFields?: string[]
90	updateType?: string[]
91}
92
93const routeRules: Record<string, RouteRule> = {
94	// Ads never appear in ISR HTML: the SSR resolver leaves `adGroup` refs
95	// unresolved (packages/sanity/utils/resolver.ts) and AdDisplay /
96	// GlobalAdSlot fetch groups + placement rules client-side per visit with
97	// `cache: no-store`. Revalidating referencing pages would rewrite
98	// thousands of ISR entries whose HTML is byte-identical — keep these no-op.
99	ad: {},
100	adGroup: {},
101	globalAdPlacements: {},
102	company: {
103		updateRefs: basicPageTypes,
104	},
105	faqSet: {
106		updateRefs: basicPageTypes,
107	},
108	// Formerly full-rebuild types — their content is resolved at page render
109	// time, so revalidating every page that references them is sufficient.
110	event: {
111		updateRefs: 'ALL',
112	},
113	venue: {
114		updateRefs: 'ALL',
115	},
116	eventLocation: {
117		updateRefs: 'ALL',
118	},
119	form: {
120		updateRefs: 'ALL',
121	},
122	home: {},
123	inwinkAgenda: {
124		updateRefs: ['page', 'event'],
125	},
126	page: {
127		criticalFields: criticalPageFields,
128	},
129	profile: {
130		updateRefs: storyRefUpdateTypes,
131	},
132	profileType: {
133		updateRefs: ['profile'],
134	},
135
136	topic: {
137		updateRefs: [...storyRefUpdateTypes, 'taxonomy-page'],
138	},
139	reportSettings: {
140		updateType: ['report'],
141	},
142	roundtableSettings: {
143		updateType: ['roundtable'],
144	},
145	researchSettings: {
146		updateType: ['research'],
147	},
148	taxonomyPageSettings: {
149		updateType: ['taxonomy-page'],
150	},
151	webinarSettings: {
152		updateType: ['webinar'],
153	},
154	resourceSettings: {
155		updateType: storyRefUpdateTypes,
156	},
157	...Object.fromEntries(
158		storyTypes.map((type) => [
159			type,
160			{
161				updateRefs: storyRefUpdateTypes,
162			},
163		]),
164	),
165}
166
167const fireFullRebuild = async () => {
168	let response: Response | undefined
169	console.log('[ISR] Triggering full Vercel rebuild')
170	try {
171		const rebuildRes = await fetch(VERCEL_LIVE_REBUILD_URL, {
172			method: 'POST',
173			headers: { 'Content-Type': 'application/json' },
174		})
175		response = rebuildRes
176	} catch (err) {
177		console.error('[ISR] Error triggering Vercel rebuild hook', err)
178		response = undefined
179	}
180	return response
181}
182
183const SITEMAP_TYPES = ['page', 'taxonomy-page']
184
185const getPathsToRevalidate = async (body: any): Promise<Set<string>> => {
186	const paths = new Set<string>()
187	const promises: Promise<any[]>[] = []
188
189	if (!body._type) return paths
190
191	if (body.fullSlug) {
192		paths.add(body.fullSlug)
193	}
194
195	if (SITEMAP_TYPES.includes(body._type)) {
196		paths.add('/site-map')
197	}
198
199	if (storyTypes.includes(body._type)) {
200		paths.add('/') // update homepage to show latest stories always
201	}
202
203	const typeRules = routeRules?.[body._type as keyof typeof routeRules]
204	if (!typeRules) return paths
205
206	const updateRefs =
207		Array.isArray(typeRules?.updateRefs) && typeRules?.updateRefs?.length > 0
208			? typeRules.updateRefs
209			: undefined
210
211	const params = {
212		id: body._id,
213		types: updateRefs,
214	}
215
216	// Get slugs for pages that refer to data in the changed docuemnt
217	if (updateRefs && updateRefs.length > 0 && typeRules?.updateRefs !== 'ALL') {
218		promises.push(
219			sanityFetch(
220				SANITY_CLIENT,
221				'*[_type in $types && references($id)]{fullSlug}',
222				params,
223				{ tag: 'revalidate' },
224			),
225		)
226	}
227
228	// Get all pages that refer to the changed document, no type restrictions
229	if (typeRules?.updateRefs === 'ALL') {
230		promises.push(
231			sanityFetch(
232				SANITY_CLIENT,
233				'*[references($id) && defined(fullSlug)]{fullSlug}',
234				params,
235				{ tag: 'revalidate' },
236			),
237		)
238	}
239
240	return Promise.all(promises).then((results) => {
241		results.forEach((result) => {
242			result.forEach((page: any) => {
243				if (page?.fullSlug) paths.add(page.fullSlug)
244			})
245		})
246		return paths
247	})
248}
249
250// Transient failures would otherwise leave a page stale until the global ISR
251// TTL expires, so retry briefly. Delays kept short: paths revalidate in
252// parallel but the webhook still has to finish within the function timeout.
253const REVALIDATE_RETRY_DELAYS_MS = [400, 1200]
254
255async function revalidatePath(path: string): Promise<boolean> {
256	const bypassToken = process.env.VERCEL_AUTOMATION_BYPASS_SECRET
257	const url = new URL(path, DOMAIN)
258
259	// GET (not HEAD): Nitro/SolidStart loaders — including editorial GROQ — run
260	// on the document request. Drain the body so SSR finishes and ISR can write
261	// before we treat the path as done. Page SSR still uses the Sanity CDN;
262	// a delayed second `/` hit (stories) plus a 5 min homepage ISR TTL cover
263	// the CDN propagation window.
264	const init: RequestInit = { method: 'GET' }
265	if (bypassToken) {
266		init.headers = { 'x-prerender-revalidate': String(bypassToken) }
267	}
268
269	for (
270		let attempt = 0;
271		attempt <= REVALIDATE_RETRY_DELAYS_MS.length;
272		attempt++
273	) {
274		try {
275			const res = await fetch(url.toString(), init)
276			await res.arrayBuffer()
277			if (res.ok) return true
278			console.warn(
279				`[ISR] Revalidate ${path} attempt ${attempt + 1} failed: ${res.status}`,
280			)
281		} catch (err) {
282			console.warn(`[ISR] Revalidate ${path} attempt ${attempt + 1} threw:`, err)
283		}
284		if (attempt < REVALIDATE_RETRY_DELAYS_MS.length) {
285			await setTimeout(REVALIDATE_RETRY_DELAYS_MS[attempt])
286		}
287	}
288
289	console.error(
290		`[ISR] Giving up on ${path} — page stays stale until the ISR TTL expires`,
291	)
292	return false
293}
294
295/** Revalidate in bounded batches; returns the paths that failed all retries. */
296async function revalidatePaths(paths: string[]): Promise<string[]> {
297	if (paths.length === 0) return []
298	const failed: string[] = []
299	for (let i = 0; i < paths.length; i += REVALIDATE_CONCURRENCY) {
300		const batch = paths.slice(i, i + REVALIDATE_CONCURRENCY)
301		const results = await Promise.all(
302			batch.map(async (path) => ({ path, ok: await revalidatePath(path) })),
303		)
304		for (const r of results) {
305			if (!r.ok) failed.push(r.path)
306		}
307		if (i + REVALIDATE_CONCURRENCY < paths.length) {
308			await setTimeout(REVALIDATE_BATCH_DELAY_MS)
309		}
310	}
311	return failed
312}
313
314/**
315 * Story publishes revalidate `/` immediately (CDN may still be stale) then
316 * again after {@link HOME_CDN_RETRY_MS} via waitUntil so the webhook can 200
317 * without holding the Sanity webhook open. Homepage ISR TTL (5 min) is the
318 * bound if this background pass is truncated.
319 */
320function scheduleHomepageCdnRetry() {
321	const retry = (async () => {
322		await setTimeout(HOME_CDN_RETRY_MS)
323		console.log('[ISR] Delayed homepage revalidate (Sanity CDN TTL)')
324		const ok = await revalidatePath('/')
325		if (!ok) {
326			console.error('[ISR] Delayed homepage revalidate failed')
327		}
328	})()
329	try {
330		waitUntil(retry)
331	} catch {
332		void retry
333	}
334}
335
336export async function POST({ request }: APIEvent) {
337	// Fail closed: with an empty secret, isValidSignature would accept payloads
338	// signed with '' — and this endpoint can trigger full production rebuilds.
339	const revalidateSecret = process.env.REVALIDATE_SECRET
340	if (!revalidateSecret) {
341		console.error('[ISR] REVALIDATE_SECRET is not set — rejecting request')
342		return new Response(
343			JSON.stringify({ success: false, message: 'Server misconfigured' }),
344			{ status: 500, headers: { 'Content-Type': 'application/json' } },
345		)
346	}
347
348	const signature = request.headers.get(SIGNATURE_HEADER_NAME)
349	const body = await new Response(request.body).json()
350	const isValid = await isValidSignature(
351		JSON.stringify(body),
352		signature ?? '',
353		revalidateSecret,
354	)
355	if (!isValid) {
356		console.error('invalid signature::', signature)
357		return new Response(
358			JSON.stringify({ success: false, message: 'Invalid signature' }),
359			{
360				status: 401,
361				headers: { 'Content-Type': 'application/json' },
362			},
363		)
364	}
365
366	if (!body._type) {
367		console.error('missing type::', body)
368		return new Response(JSON.stringify({ ok: false, error: 'missing type' }), {
369			status: 400,
370			headers: { 'Content-Type': 'application/json' },
371		})
372	}
373
374	// `redirect` docs are pushed to Vercel Blob and consumed by the Routing
375	// Middleware — no Vercel rebuild needed, and no per-route ISR revalidation
376	// either since redirects never produce cached pages of their own.
377
378	clearWarmSingletonCaches(body._type)
379
380	if (body._type === 'seoDefaults') {
381		await revalidatePath('/robots.txt')
382	}
383
384	if (body._type === 'redirect') {
385		try {
386			const result = await syncRedirectsToBlob()
387			return new Response(JSON.stringify({ ok: true, ...result }), {
388				status: 200,
389				headers: { 'Content-Type': 'application/json' },
390			})
391		} catch (err) {
392			console.error('[redirects] sync failed', err)
393			return new Response(
394				JSON.stringify({
395					ok: false,
396					error: 'redirects sync failed',
397					details: String(err),
398				}),
399				{ status: 500, headers: { 'Content-Type': 'application/json' } },
400			)
401		}
402	}
403
404	if (FULL_REBUILD_DOCS.includes(body._type)) {
405		await fireFullRebuild()
406		return new Response(
407			JSON.stringify({
408				ok: true,
409				message: `🔄 Full rebuild triggered for ${body._type}`,
410			}),
411			{
412				status: 200,
413				headers: { 'Content-Type': 'application/json' },
414			},
415		)
416	}
417
418	const pathsToRevalidate = await getPathsToRevalidate(body)
419	const paths = Array.from(pathsToRevalidate ?? [])
420
421	console.log(
422		`[ISR] Revalidating ${paths.length} path(s) for ${body._type} (${body._id})`,
423	)
424
425	if (storyTypes.includes(body._type)) {
426		scheduleHomepageCdnRetry()
427	}
428
429	let failedPaths: string[] = []
430	if (paths.length > 0) {
431		const start = performance.now()
432		failedPaths = await revalidatePaths(paths)
433		console.log(
434			`[ISR] Revalidated ${paths.length - failedPaths.length}/${paths.length} paths in ${(performance.now() - start).toFixed(0)}ms`,
435		)
436		if (failedPaths.length > 0) {
437			console.error('[ISR] Failed paths:', failedPaths)
438		}
439	}
440
441	return new Response(
442		JSON.stringify({
443			ok: true,
444			revalidated: true,
445			pathCount: paths.length,
446			paths,
447			failedPaths,
448			message: `Revalidated ${paths.length - failedPaths.length}/${paths.length} routes`,
449		}),
450		{
451			status: 200,
452			headers: { 'Content-Type': 'application/json' },
453		},
454	)
455}
456