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