-
Notifications
You must be signed in to change notification settings - Fork 256
Expand file tree
/
Copy pathcacheService.ts
More file actions
1237 lines (1098 loc) · 47.4 KB
/
Copy pathcacheService.ts
File metadata and controls
1237 lines (1098 loc) · 47.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { revalidateTag, revalidatePath } from 'next/cache';
import { invalidateByTag } from '@vercel/functions';
import { getSupabaseAdmin, getSupabaseConfig } from '@/lib/supabase-server';
import { buildSlugPath, normalizeSlugSegment } from '@/lib/page-utils';
import type { Page, PageFolder } from '@/types';
import type {
ChangedLocale,
ChangedTranslation,
PublishLocalisationResult,
SlugSnapshot,
} from '@/lib/services/localisationService';
/**
* Number of routes warmed per function invocation. All routes in a batch are
* fetched in parallel, so the batch wall-time is roughly one URL's render
* time. Sized to comfortably finish inside the warm endpoint's maxDuration
* (with 50 parallel 15s-timeout fetches fitting in a 60s budget).
*/
const WARM_BATCH_SIZE = 50;
/**
* Overall safety cap on how many routes a single invalidation event will warm
* across the whole self-chaining batch sequence. Warming is a best-effort
* optimisation, not a correctness requirement — anything beyond this self-warms
* on first real visit. The cap bounds runaway cost when a dynamic page expands
* to thousands of CMS items. Configurable via CACHE_WARM_MAX_TOTAL.
*/
const MAX_ROUTES_TO_WARM_TOTAL = (() => {
const raw = Number(process.env.CACHE_WARM_MAX_TOTAL);
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 2000;
})();
type SupabaseAdmin = NonNullable<Awaited<ReturnType<typeof getSupabaseAdmin>>>;
const SUPABASE_IN_LIMIT = 500;
/**
* Vercel's bulk cache-tag purge API accepts at most 16 tags per call
* (https://vercel.com/docs/caching/cdn-cache/purge). Passing a larger array
* gets rejected, so every tag beyond the cap would silently keep serving
* stale content. Dynamic (CMS) pages make this trivial to hit: a single
* dynamic page expands to one route per published item, so a component/style
* change touching a dynamic template can produce dozens of routes in one
* selective-invalidation pass. We chunk to stay under the cap.
*/
const MAX_TAGS_PER_INVALIDATION = 16;
/** Split an array into chunks safe for Supabase `.in()` queries. */
function chunk<T>(arr: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < arr.length; i += size) {
chunks.push(arr.slice(i, i + size));
}
return chunks;
}
/**
* Purge a set of cache tags via Vercel's CDN purge API, chunked to respect the
* 16-tags-per-call limit. Batches are settled independently so one failed batch
* doesn't abort the rest; the first error is rethrown for the caller to handle.
*/
export async function purgeTagsOnVercel(tags: string[]): Promise<void> {
if (tags.length === 0) return;
const batches = chunk(tags, MAX_TAGS_PER_INVALIDATION);
const results = await Promise.allSettled(batches.map((batch) => invalidateByTag(batch)));
const failure = results.find((r): r is PromiseRejectedResult => r.status === 'rejected');
if (failure) throw failure.reason;
}
/**
* Cache Invalidation Service
*
* Handles CDN cache invalidation for published pages using Next.js revalidation.
* Supports both full-site invalidation and selective per-page invalidation.
*/
/**
* Invalidate cache for a specific page by route path.
*
* On Vercel: uses invalidateByTag exclusively, which talks directly to Vercel's
* CDN purge API and covers all three cache layers (CDN, Runtime, Data). We
* deliberately avoid revalidateTag here because Next.js bug #63509 causes it
* to cascade-invalidate other tags consumed by the page render, breaking
* selective invalidation on Vercel.
*
* On self-hosted (no Vercel runtime): invalidateByTag no-ops, so we fall
* back to revalidateTag to clear the in-process Next.js data cache.
*
* @param routePath - Route path (without leading slash for tag, with for path)
*/
export async function invalidatePage(routePath: string): Promise<boolean> {
const tag = `route-/${routePath}`;
try {
if (process.env.VERCEL === '1') {
await invalidateByTag(tag);
} else {
revalidateTag(tag, { expire: 0 });
}
return true;
} catch (error) {
console.error('❌ [Cache] Invalidation error:', error);
return false;
}
}
/**
* Invalidate cache for multiple pages.
* Uses Vercel's batched invalidateByTag on Vercel, revalidateTag elsewhere.
*
* @param routePaths - Array of route paths
*/
export async function invalidatePages(routePaths: string[]): Promise<boolean> {
if (routePaths.length === 0) return true;
try {
const tags = routePaths.map((p) => `route-/${p}`);
if (process.env.VERCEL === '1') {
// Chunked to respect Vercel's 16-tags-per-purge cap — without this,
// routes beyond the first 16 (common when a dynamic CMS page expands to
// many item URLs) would never be invalidated and keep serving stale HTML.
await purgeTagsOnVercel(tags);
} else {
for (const tag of tags) {
revalidateTag(tag, { expire: 0 });
}
}
return true;
} catch (error) {
console.error('❌ [Cache] Invalidation error:', error);
return false;
}
}
/**
* Clear all cache (full site invalidation)
* Invalidates the root layout which cascades to all pages
*/
export async function clearAllCache(): Promise<void> {
try {
if (process.env.VERCEL === '1') {
// Vercel: direct CDN purge by the 'all-pages' tag set on every page
// response. Covers CDN, Runtime, and Data caches in one call. Avoids
// revalidateTag's cascade bug (#63509).
await invalidateByTag('all-pages');
} else {
// Self-hosted: clear Next.js's in-process caches.
revalidateTag('all-pages', { expire: 0 });
revalidatePath('/', 'layout');
}
} catch (error) {
console.error('❌ [Cache] Clear all error:', error);
throw new Error('Failed to clear all cache');
}
}
/**
* Resolve published page IDs to their route paths (for cache invalidation).
* Returns all URL paths each page can be reached at, including locale variants.
*
* For dynamic pages, enumerates actual collection item slugs rather than
* returning a {slug} placeholder (which would never match a real cache tag).
*/
export async function getRoutePathsForPages(pageIds: string[]): Promise<string[]> {
if (pageIds.length === 0) return [];
const client = await getSupabaseAdmin();
if (!client) return [];
const [
{ data: pages },
{ data: folders },
{ data: locales },
{ data: translations },
] = await Promise.all([
client.from('pages').select('*').in('id', pageIds).eq('is_published', true).is('deleted_at', null),
client.from('page_folders').select('*').eq('is_published', true).is('deleted_at', null),
client.from('locales').select('*').is('deleted_at', null),
client.from('translations').select('*').eq('is_published', true).is('deleted_at', null),
]);
if (!pages || !folders) return [];
const routePaths: string[] = [];
const dynamicPages: Page[] = [];
// Build translations lookup
const translationsMap: Record<string, Record<string, string>> = {};
if (translations) {
for (const t of translations) {
if (!translationsMap[t.locale_id]) translationsMap[t.locale_id] = {};
const key = `${t.source_type}:${t.source_id}:${t.content_key}`;
translationsMap[t.locale_id][key] = t.content_value;
}
}
for (const page of pages as Page[]) {
if (page.is_dynamic) {
dynamicPages.push(page);
continue;
}
// Default locale path
const defaultPath = buildSlugPath(page, folders as PageFolder[], 'page');
const trimmed = defaultPath.slice(1); // Remove leading "/"
if (page.is_index && page.page_folder_id === null) {
routePaths.push('');
} else if (trimmed) {
routePaths.push(trimmed);
}
// Locale variant paths
if (locales) {
for (const locale of locales) {
if (locale.is_default) continue;
const localeTranslations = translationsMap[locale.id] || {};
const slugParts: string[] = [locale.code];
let currentFolderId = page.page_folder_id;
const folderSegments: string[] = [];
while (currentFolderId) {
const folder = (folders as PageFolder[]).find(f => f.id === currentFolderId);
if (!folder) break;
const tKey = `folder:${folder.id}:slug`;
folderSegments.unshift(localeTranslations[tKey] || folder.slug);
currentFolderId = folder.page_folder_id;
}
slugParts.push(...folderSegments);
if (!page.is_index && page.slug) {
const pageKey = `page:${page.id}:slug`;
slugParts.push(localeTranslations[pageKey] || page.slug);
}
const localePath = slugParts.map(normalizeSlugSegment).filter(Boolean).join('/');
if (localePath) routePaths.push(localePath);
}
}
}
// Resolve actual URLs for dynamic pages by enumerating collection item slugs
if (dynamicPages.length > 0) {
const dynamicRoutes = await resolveDynamicPageRoutes(
client, dynamicPages, folders as PageFolder[], locales || [], translationsMap,
);
routePaths.push(...dynamicRoutes);
}
return [...new Set(routePaths)];
}
/**
* Enumerate all published instance URLs for dynamic (CMS-driven) pages.
* Each dynamic page is bound to a collection; we look up the slug field
* values of published items to build the real URL paths.
*/
async function resolveDynamicPageRoutes(
client: SupabaseAdmin,
dynamicPages: Page[],
folders: PageFolder[],
locales: Array<{ id: string; code: string; is_default: boolean }>,
translationsMap: Record<string, Record<string, string>>,
): Promise<string[]> {
const routes: string[] = [];
for (const page of dynamicPages) {
const collectionId = (page.settings as any)?.cms?.collection_id;
if (!collectionId) continue;
const { data: slugField } = await client
.from('collection_fields')
.select('id')
.eq('collection_id', collectionId)
.eq('key', 'slug')
.is('deleted_at', null)
.limit(1)
.single();
if (!slugField) continue;
const { data: items } = await client
.from('collection_items')
.select('id')
.eq('collection_id', collectionId)
.eq('is_published', true)
.is('deleted_at', null);
if (!items || items.length === 0) continue;
const itemIds = items.map(i => i.id);
const slugValues: Array<{ item_id: string; value: unknown }> = [];
for (const idChunk of chunk(itemIds, SUPABASE_IN_LIMIT)) {
const { data } = await client
.from('collection_item_values')
.select('item_id, value')
.eq('field_id', slugField.id)
.eq('is_published', true)
.is('deleted_at', null)
.in('item_id', idChunk);
if (data) slugValues.push(...data);
}
if (slugValues.length === 0) continue;
// Folder base path (everything before the {slug} segment)
const basePath = buildSlugPath(page, folders, 'page', '').slice(1).replace(/\/$/, '');
for (const sv of slugValues) {
if (!sv.value) continue;
const itemSlug = sv.value as string;
const fullPath = basePath ? `${basePath}/${itemSlug}` : itemSlug;
routes.push(fullPath);
// Locale variant paths for each item
for (const locale of locales) {
if (locale.is_default) continue;
const lt = translationsMap[locale.id] || {};
const slugParts: string[] = [locale.code];
let currentFolderId = page.page_folder_id;
const folderSegments: string[] = [];
while (currentFolderId) {
const folder = folders.find(f => f.id === currentFolderId);
if (!folder) break;
folderSegments.unshift(lt[`folder:${folder.id}:slug`] || folder.slug);
currentFolderId = folder.page_folder_id;
}
slugParts.push(...folderSegments);
// Use the item's translated slug for this locale (the actual localized
// URL); fall back to the default slug when no translation exists.
slugParts.push(lt[`cms:${sv.item_id}:field:key:slug`] || itemSlug);
const localePath = slugParts.map(normalizeSlugSegment).filter(Boolean).join('/');
if (localePath) routes.push(localePath);
}
}
}
return routes;
}
/**
* Build route paths for deleted CMS items from their old slug values.
* Maps each collection's deleted slugs to the dynamic pages that use that
* collection, constructing the full URL paths that should be invalidated.
*
* @param deletedSlugs - Map of collectionId → array of deleted item slug values
*/
export async function getRoutePathsForDeletedCollectionItems(
deletedSlugs: Map<string, string[]>,
): Promise<string[]> {
if (deletedSlugs.size === 0) return [];
const client = await getSupabaseAdmin();
if (!client) return [];
const routes: string[] = [];
const [
{ data: dynamicPages },
{ data: folders },
{ data: locales },
{ data: translations },
] = await Promise.all([
client.from('pages').select('*').eq('is_published', true).eq('is_dynamic', true).is('deleted_at', null),
client.from('page_folders').select('*').eq('is_published', true).is('deleted_at', null),
client.from('locales').select('*').is('deleted_at', null),
client.from('translations')
.select('locale_id, source_type, source_id, content_key, content_value')
.eq('is_published', true).is('deleted_at', null)
.in('content_key', ['slug', 'field:key:slug']),
]);
if (!dynamicPages || !folders) return [];
// Build translations lookup: locale_id → "type:source:key" → value
const translationsMap: Record<string, Record<string, string>> = {};
for (const t of translations || []) {
if (!translationsMap[t.locale_id]) translationsMap[t.locale_id] = {};
translationsMap[t.locale_id][`${t.source_type}:${t.source_id}:${t.content_key}`] = t.content_value;
}
// Translated slugs are keyed by item id, but callers only pass default slug
// values. Map each old slug back to its item id (draft rows survive unpublish,
// so they still resolve) to look up per-locale translated slugs.
const slugToItemIdByCollection = new Map<string, Map<string, string>>();
for (const [collectionId, slugs] of deletedSlugs) {
if (!slugs || slugs.length === 0) continue;
const { data: slugField } = await client
.from('collection_fields')
.select('id')
.eq('collection_id', collectionId)
.eq('key', 'slug')
.is('deleted_at', null)
.limit(1)
.single();
if (!slugField) continue;
const { data: values } = await client
.from('collection_item_values')
.select('item_id, value')
.eq('field_id', slugField.id)
.is('deleted_at', null)
.in('value', slugs);
const map = new Map<string, string>();
for (const v of values || []) {
if (typeof v.value === 'string') map.set(v.value, v.item_id);
}
slugToItemIdByCollection.set(collectionId, map);
}
for (const page of dynamicPages as Page[]) {
const collectionId = (page.settings as any)?.cms?.collection_id;
if (!collectionId) continue;
const slugs = deletedSlugs.get(collectionId);
if (!slugs || slugs.length === 0) continue;
const slugToItemId = slugToItemIdByCollection.get(collectionId) || new Map<string, string>();
const basePath = buildSlugPath(page, folders as PageFolder[], 'page', '').slice(1).replace(/\/$/, '');
for (const itemSlug of slugs) {
const fullPath = basePath ? `${basePath}/${itemSlug}` : itemSlug;
routes.push(fullPath);
const itemId = slugToItemId.get(itemSlug);
// Locale-prefixed paths with translated folder + item slugs
if (locales) {
for (const locale of locales) {
if (locale.is_default) continue;
const lt = translationsMap[locale.id] || {};
const slugParts: string[] = [locale.code];
let currentFolderId = page.page_folder_id;
const folderSegments: string[] = [];
while (currentFolderId) {
const folder = (folders as PageFolder[]).find(f => f.id === currentFolderId);
if (!folder) break;
folderSegments.unshift(lt[`folder:${folder.id}:slug`] || folder.slug);
currentFolderId = folder.page_folder_id;
}
slugParts.push(...folderSegments);
const translatedSlug = itemId ? lt[`cms:${itemId}:field:key:slug`] : undefined;
slugParts.push(translatedSlug || itemSlug);
const localePath = slugParts.map(normalizeSlugSegment).filter(Boolean).join('/');
if (localePath) routes.push(localePath);
}
}
}
}
return [...new Set(routes)];
}
/**
* Invalidate cache for pages affected by a change to a single CMS collection.
*
* Used by external integrations that mutate published collection items without
* going through the builder's publish flow (v1 REST API, Webflow sync, etc.).
*
* Covers two kinds of dependents:
* - Pages that render a collection-list/collection-grid block of this collection.
* - The dynamic page bound to this collection (one URL per published item).
*
* For deletes and slug renames, pass the pre-mutation slug(s) in `removedSlugs`
* so we can invalidate the old URL — once the item is soft-deleted or renamed,
* `getRoutePathsForPages` no longer enumerates it, and the CDN would keep
* serving the deleted/old content as a 200.
*/
export async function invalidateForCollectionChange(
collectionId: string,
options: { removedSlugs?: string[] } = {},
): Promise<{ invalidatedRoutes: string[] }> {
const { findAffectedPages } = await import('@/lib/repositories/pageLayersRepository');
const affected = await findAffectedPages([], [], [collectionId]);
const pageIds = affected.collectionPageIds;
const liveRoutes = pageIds.length > 0 ? await getRoutePathsForPages(pageIds) : [];
const removedRoutes = (options.removedSlugs && options.removedSlugs.length > 0)
? await getRoutePathsForDeletedCollectionItems(new Map([[collectionId, options.removedSlugs]]))
: [];
const routes = [...new Set([...liveRoutes, ...removedRoutes])];
if (routes.length > 0) {
await invalidatePages(routes);
}
return { invalidatedRoutes: routes };
}
export interface SelectiveInvalidationResult {
strategy: 'selective' | 'full';
invalidatedRoutes: string[];
reason?: string;
}
/**
* Returns true if any of the given page IDs is a published error page
* (404/401, etc.). Error pages carry an `error_page` code instead of a slug.
*/
async function hasErrorPage(pageIds: string[]): Promise<boolean> {
if (pageIds.length === 0) return false;
const client = await getSupabaseAdmin();
if (!client) return false;
for (const ids of chunk(pageIds, SUPABASE_IN_LIMIT)) {
const { data } = await client
.from('pages')
.select('id')
.in('id', ids)
.eq('is_published', true)
.not('error_page', 'is', null)
.is('deleted_at', null)
.limit(1);
if (data && data.length > 0) return true;
}
return false;
}
/**
* Perform selective cache invalidation based on what actually changed.
*
* Receives the exact page IDs that were modified during publish (content_hash
* changed, new page, or folder moved) — no guessing via timestamps.
* Falls back to full invalidation when global resources changed.
*
* @param changedPageIds - Page IDs that actually changed during publish (from publishPages)
* @param globalChanged - Whether global resources changed (triggers full nuke)
* @param indirectlyAffectedPageIds - Page IDs affected by component, style, or collection changes
*/
export async function selectiveInvalidation(
changedPageIds: string[],
globalChanged: boolean,
indirectlyAffectedPageIds: string[] = [],
): Promise<SelectiveInvalidationResult> {
if (globalChanged) {
await clearAllCache();
return { strategy: 'full', invalidatedRoutes: [], reason: 'global resources changed' };
}
const allAffectedIds = [...new Set([...changedPageIds, ...indirectlyAffectedPageIds])];
if (allAffectedIds.length === 0) {
return { strategy: 'selective', invalidatedRoutes: [], reason: 'no pages changed' };
}
// Error pages (401/404) have empty slugs, so they resolve to no route and
// can't be selectively invalidated. They're also embedded into other routes
// (the 401 page renders inside every password-protected page; the 404 page
// renders on unmatched URLs) and served via the `all-pages`-tagged
// `error-<code>` data cache. A selective route purge would never refresh
// them, so escalate to a full invalidation when an error page changed.
if (await hasErrorPage(allAffectedIds)) {
await clearAllCache();
return { strategy: 'full', invalidatedRoutes: [], reason: 'error page changed' };
}
const routePaths = await getRoutePathsForPages(allAffectedIds);
if (routePaths.length > 0) {
await invalidatePages(routePaths);
}
return { strategy: 'selective', invalidatedRoutes: routePaths };
}
/**
* Resolve every URL the public site currently serves from published pages.
* Includes static pages, locale variants, and every dynamic-page instance
* (one URL per published CMS item).
*
* Used to warm the cache after a full invalidation so the first real
* visitor doesn't pay the cold-cache cost.
*/
export async function getAllPublishedRoutes(): Promise<string[]> {
const client = await getSupabaseAdmin();
if (!client) return [];
const { data: pages } = await client
.from('pages')
.select('id')
.eq('is_published', true)
.is('deleted_at', null);
if (!pages || pages.length === 0) return [];
return getRoutePathsForPages(pages.map((p) => p.id));
}
/** Build the absolute origin (`https://host`) from forwarded request headers. */
function resolveBaseUrl(request: Request): string | null {
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host');
if (!host) return null;
const proto = request.headers.get('x-forwarded-proto') ?? 'https';
return `${proto}://${host}`;
}
/**
* Keep only safe, same-origin relative routes. Guards the chain endpoint (which
* takes routes from a request body) against absolute URLs or protocol-relative
* paths that would point warming fetches off-origin.
*/
function sanitiseWarmRoutes(routes: unknown): string[] {
if (!Array.isArray(routes)) return [];
return routes.filter(
(r): r is string =>
typeof r === 'string' &&
r.length > 0 &&
!r.includes('://') &&
!r.startsWith('/') &&
!r.startsWith('\\'),
);
}
/** Fetch a batch of routes in parallel, swallowing per-route failures. */
async function warmBatch(routes: string[], baseUrl: string): Promise<void> {
await Promise.allSettled(
routes.map((route) =>
fetch(`${baseUrl}/${route}`, {
signal: AbortSignal.timeout(15000),
}).catch(() => null),
),
);
}
// ── Internal chain authentication ────────────────────────────────────────
// The warm endpoint issues GETs to same-origin paths, so it must not be an
// open amplification endpoint. Rather than make self-hosters configure a
// dedicated secret, we sign each chain hop with an HMAC keyed on the Supabase
// service-role key — a credential every deployment already has, that is
// server-only and never sent to the browser. The raw key is never
// transmitted; only the per-payload signature travels over the wire.
const WARM_SIGNATURE_HEADER = 'x-warm-signature';
/** The HMAC key for chain auth: the service-role key the app already requires. */
async function getChainSigningKey(): Promise<string | null> {
try {
const creds = await getSupabaseConfig();
return creds?.serviceRoleKey ?? null;
} catch {
return null;
}
}
/** HMAC-SHA256 of `message` keyed by `key`, hex-encoded. Web Crypto = runtime-agnostic. */
async function hmacHex(message: string, key: string): Promise<string> {
const enc = new TextEncoder();
const cryptoKey = await crypto.subtle.importKey(
'raw',
enc.encode(key),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, enc.encode(message));
return Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/** Length-safe constant-time-ish comparison of two hex strings. */
function safeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
/**
* Verify a warm-chain request actually came from this deployment by checking
* the HMAC signature against the raw request body. Exported for the endpoint.
*/
export async function verifyWarmChainSignature(
rawBody: string,
signature: string | null,
): Promise<boolean> {
if (!signature) return false;
const key = await getChainSigningKey();
if (!key) return false;
return safeEqual(await hmacHex(rawBody, key), signature);
}
/**
* Fire-and-forget trigger for the next link in the warming chain. Hits the
* dedicated warm endpoint, which warms a fresh batch in its own function
* invocation (sidestepping the triggering function's lifetime limit) and
* chains onward until the route list is drained or the overall cap is hit.
*
* Signs the payload with the service-role-derived HMAC; if no signing key is
* available the chain simply stops and remaining routes self-warm on first
* visit. No user-configured secret required.
*/
async function scheduleWarmChain(
baseUrl: string,
routes: string[],
alreadyWarmed: number,
): Promise<void> {
if (routes.length === 0) return;
const key = await getChainSigningKey();
if (!key) return;
const body = JSON.stringify({ routes, warmed: alreadyWarmed });
const signature = await hmacHex(body, key);
await fetch(`${baseUrl}/ycode/api/cache/warm`, {
method: 'POST',
headers: { 'content-type': 'application/json', [WARM_SIGNATURE_HEADER]: signature },
body,
// The endpoint returns as soon as it has scheduled its own background
// work, so this resolves fast — the timeout only guards a stuck connect.
signal: AbortSignal.timeout(10000),
}).catch(() => null);
}
/**
* Warm a batch of routes in the background, then chain to a fresh invocation
* for the next batch until every route is warmed or MAX_ROUTES_TO_WARM_TOTAL
* is reached. Shared by the initial `warmRoutes` call and the self-chaining
* `/ycode/api/cache/warm` endpoint.
*
* @param alreadyWarmed routes warmed by earlier links in this chain, used to
* enforce the cumulative overall cap.
* @returns how many routes this link scheduled, and how many remain queued for
* the next link.
*/
export async function warmRouteChain(
routes: string[],
alreadyWarmed: number,
request: Request,
): Promise<{ scheduled: number; remaining: number }> {
if (process.env.VERCEL !== '1') return { scheduled: 0, remaining: 0 };
const safeRoutes = sanitiseWarmRoutes(routes);
if (safeRoutes.length === 0) return { scheduled: 0, remaining: 0 };
const baseUrl = resolveBaseUrl(request);
if (!baseUrl) return { scheduled: 0, remaining: 0 };
// Enforce the overall cap using the cumulative counter carried through the
// chain, so a long route list can't exceed the budget across invocations.
const budget = Math.max(0, MAX_ROUTES_TO_WARM_TOTAL - alreadyWarmed);
if (budget === 0) return { scheduled: 0, remaining: safeRoutes.length };
const allowed = safeRoutes.slice(0, budget);
const batch = allowed.slice(0, WARM_BATCH_SIZE);
const remaining = allowed.slice(WARM_BATCH_SIZE);
try {
const { waitUntil } = await import('@vercel/functions');
waitUntil(
(async () => {
await warmBatch(batch, baseUrl);
if (remaining.length > 0) {
await scheduleWarmChain(baseUrl, remaining, alreadyWarmed + batch.length);
}
})(),
);
return { scheduled: batch.length, remaining: remaining.length };
} catch {
return { scheduled: 0, remaining: safeRoutes.length };
}
}
/**
* Background-warm a set of routes by issuing GET requests to them, so the
* next real visitor sees x-vercel-cache: HIT instead of STALE/MISS.
*
* Uses Vercel's waitUntil so warming runs AFTER the response is sent: zero
* added latency on the triggering request. Warms the first batch here and
* self-chains through `/ycode/api/cache/warm` for the rest, draining the
* whole list up to MAX_ROUTES_TO_WARM_TOTAL — anything beyond that self-warms
* on first real visit.
*
* Vercel-only: warming via internal fetch only makes sense when there's a
* CDN in front of the function. No-ops elsewhere.
*
* @returns null if not on Vercel, no host header, no routes, or warming
* failed to schedule. Otherwise reports how many will be warmed (across the
* whole chain) vs the total requested.
*/
export async function warmRoutes(
routes: string[],
request: Request,
): Promise<{ warmed: number; total: number } | null> {
if (process.env.VERCEL !== '1' || routes.length === 0) return null;
const total = routes.length;
const result = await warmRouteChain(routes, 0, request);
if (result.scheduled === 0 && result.remaining === 0) return null;
// scheduled = this batch; remaining = what the chain will drain next. The
// chain is capped at MAX_ROUTES_TO_WARM_TOTAL, so report the capped total.
const willWarm = Math.min(total, MAX_ROUTES_TO_WARM_TOTAL);
return { warmed: willWarm, total };
}
// ══════════════════════════════════════════════════════════════════════════
// Localisation-aware invalidation
// ══════════════════════════════════════════════════════════════════════════
//
// Translation and locale changes don't update page/component `content_hash`
// — they live in their own table. Without targeted invalidation here, the
// CDN keeps serving the old translation forever.
//
// We compute exactly which locale-prefixed URLs each change affects, so we
// invalidate only those routes instead of nuking the entire site. The
// `slugSnapshot` captured before the upsert in `publishLocalisation` lets
// us reconstruct the OLD URL for slug renames so the orphan is purged.
export interface LocalisationInvalidationResult {
newRoutes: string[]; // Live URLs to invalidate AND warm
oldRoutes: string[]; // Orphaned URLs to invalidate (don't warm)
needsFullInvalidation: boolean; // is_default flip — too far-reaching for selective
reason?: string;
}
interface CurrentLocalisationState {
pagesById: Map<string, Page>;
pages: Page[];
folders: PageFolder[];
localesById: Map<string, { id: string; code: string; is_default: boolean }>;
currentFolderSlugs: Map<string, Map<string, string>>; // locale_id → folder_id → slug
currentPageSlugs: Map<string, Map<string, string>>; // locale_id → page_id → slug
currentCmsSlugs: Map<string, Map<string, string>>; // locale_id → item_id → slug
itemSlugByItemId: Map<string, string>; // default-locale slug for each CMS item
itemCollectionByItemId: Map<string, string>; // item_id → collection_id
dynamicPageByCollectionId: Map<string, Page>;
collectionItemsByCollectionId: Map<string, string[]>;
}
/** Read everything we need to construct locale URLs (post-upsert state). */
async function loadCurrentLocalisationState(): Promise<CurrentLocalisationState | null> {
const client = await getSupabaseAdmin();
if (!client) return null;
const [
{ data: pages },
{ data: folders },
{ data: locales },
{ data: translations },
{ data: collectionItems },
{ data: collectionFields },
{ data: collectionItemValues },
] = await Promise.all([
client.from('pages').select('*').eq('is_published', true).is('deleted_at', null),
client.from('page_folders').select('*').eq('is_published', true).is('deleted_at', null),
client.from('locales').select('*').eq('is_published', true).is('deleted_at', null),
client.from('translations').select('locale_id, source_type, source_id, content_key, content_value')
.eq('is_published', true).is('deleted_at', null)
.in('content_key', ['slug', 'field:key:slug']),
client.from('collection_items').select('id, collection_id').eq('is_published', true).is('deleted_at', null),
client.from('collection_fields').select('id, collection_id, key').eq('key', 'slug').is('deleted_at', null),
client.from('collection_item_values').select('item_id, field_id, value').eq('is_published', true).is('deleted_at', null),
]);
if (!pages || !folders) return null;
const pagesById = new Map<string, Page>();
for (const p of pages as Page[]) pagesById.set(p.id, p);
const localesById = new Map<string, { id: string; code: string; is_default: boolean }>();
for (const l of locales || []) localesById.set(l.id, { id: l.id, code: l.code, is_default: l.is_default });
const currentFolderSlugs = new Map<string, Map<string, string>>();
const currentPageSlugs = new Map<string, Map<string, string>>();
const currentCmsSlugs = new Map<string, Map<string, string>>();
for (const t of translations || []) {
const target = t.source_type === 'folder' && t.content_key === 'slug'
? currentFolderSlugs
: t.source_type === 'page' && t.content_key === 'slug'
? currentPageSlugs
: t.source_type === 'cms' && t.content_key === 'field:key:slug'
? currentCmsSlugs
: null;
if (!target) continue;
if (!target.has(t.locale_id)) target.set(t.locale_id, new Map());
target.get(t.locale_id)!.set(t.source_id, t.content_value);
}
const slugFieldIds = new Set((collectionFields || []).map((f) => f.id));
const itemSlugByItemId = new Map<string, string>();
for (const v of collectionItemValues || []) {
if (slugFieldIds.has(v.field_id) && typeof v.value === 'string' && v.value) {
itemSlugByItemId.set(v.item_id, v.value);
}
}
const itemCollectionByItemId = new Map<string, string>();
const collectionItemsByCollectionId = new Map<string, string[]>();
for (const it of collectionItems || []) {
itemCollectionByItemId.set(it.id, it.collection_id);
if (!collectionItemsByCollectionId.has(it.collection_id)) collectionItemsByCollectionId.set(it.collection_id, []);
collectionItemsByCollectionId.get(it.collection_id)!.push(it.id);
}
const dynamicPageByCollectionId = new Map<string, Page>();
for (const p of pages as Page[]) {
if (!p.is_dynamic) continue;
const cid = (p.settings as any)?.cms?.collection_id;
if (cid) dynamicPageByCollectionId.set(cid, p);
}
return {
pagesById,
pages: pages as Page[],
folders: folders as PageFolder[],
localesById,
currentFolderSlugs,
currentPageSlugs,
currentCmsSlugs,
itemSlugByItemId,
itemCollectionByItemId,
dynamicPageByCollectionId,
collectionItemsByCollectionId,
};
}
/** Build the slug segments for a folder chain using a given overrides map. */
function buildFolderSegments(
folderId: string | null,
folders: PageFolder[],
folderSlugOverrides: Map<string, string> | undefined,
): string[] {
const segments: string[] = [];
let cur = folderId;
while (cur) {
const folder = folders.find((f) => f.id === cur);
if (!folder) break;
segments.unshift(folderSlugOverrides?.get(folder.id) ?? folder.slug);
cur = folder.page_folder_id;
}
return segments;
}
/** Build the locale-prefixed URL for a static page in a specific locale. */
function buildStaticLocaleUrl(
page: Page,
folders: PageFolder[],
localeCode: string,
folderSlugOverrides: Map<string, string> | undefined,
pageSlugOverride: string | undefined,
): string {
if (page.is_index && page.page_folder_id === null) return localeCode;
const folderSegs = buildFolderSegments(page.page_folder_id, folders, folderSlugOverrides);
const parts = [localeCode, ...folderSegs];
if (!page.is_index && page.slug) parts.push(pageSlugOverride ?? page.slug);
return parts.filter(Boolean).join('/');
}
/** Build the locale-prefixed URL for one item of a dynamic page. */
function buildDynamicLocaleUrl(
page: Page,
folders: PageFolder[],
localeCode: string,
folderSlugOverrides: Map<string, string> | undefined,
itemSlug: string,
): string {
const folderSegs = buildFolderSegments(page.page_folder_id, folders, folderSlugOverrides);
return [localeCode, ...folderSegs, itemSlug].filter(Boolean).join('/');
}
/** Resolve a non-CMS translation source to the pages it affects. */
async function resolvePagesForTranslationSource(
sourceType: 'page' | 'folder' | 'component',
sourceId: string,
state: CurrentLocalisationState,
): Promise<Page[]> {
if (sourceType === 'page') {
const p = state.pagesById.get(sourceId);
return p ? [p] : [];
}
if (sourceType === 'folder') {
// All descendant pages (recursive folder match)
const descendantFolderIds = new Set<string>([sourceId]);
let added = true;
while (added) {
added = false;
for (const f of state.folders) {
if (f.page_folder_id && descendantFolderIds.has(f.page_folder_id) && !descendantFolderIds.has(f.id)) {
descendantFolderIds.add(f.id);
added = true;
}
}
}
return state.pages.filter((p) => p.page_folder_id && descendantFolderIds.has(p.page_folder_id));
}
// component
try {
const { findAffectedPages } = await import('@/lib/repositories/pageLayersRepository');
const affected = await findAffectedPages([sourceId], [], []);
return affected.componentPageIds
.map((id) => state.pagesById.get(id))
.filter((p): p is Page => Boolean(p));
} catch {
return [];