alabjs 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/analytics/handler.d.ts +24 -0
  2. package/dist/analytics/handler.d.ts.map +1 -0
  3. package/dist/analytics/handler.js +87 -0
  4. package/dist/analytics/handler.js.map +1 -0
  5. package/dist/analytics/store.d.ts +33 -0
  6. package/dist/analytics/store.d.ts.map +1 -0
  7. package/dist/analytics/store.js +68 -0
  8. package/dist/analytics/store.js.map +1 -0
  9. package/dist/analytics/store.test.d.ts +2 -0
  10. package/dist/analytics/store.test.d.ts.map +1 -0
  11. package/dist/analytics/store.test.js +42 -0
  12. package/dist/analytics/store.test.js.map +1 -0
  13. package/dist/commands/build.d.ts.map +1 -1
  14. package/dist/commands/build.js +104 -2
  15. package/dist/commands/build.js.map +1 -1
  16. package/dist/commands/dev.d.ts.map +1 -1
  17. package/dist/commands/dev.js +6 -0
  18. package/dist/commands/dev.js.map +1 -1
  19. package/dist/components/Analytics.d.ts +48 -0
  20. package/dist/components/Analytics.d.ts.map +1 -0
  21. package/dist/components/Analytics.js +154 -0
  22. package/dist/components/Analytics.js.map +1 -0
  23. package/dist/components/Dynamic.d.ts +88 -0
  24. package/dist/components/Dynamic.d.ts.map +1 -0
  25. package/dist/components/Dynamic.js +86 -0
  26. package/dist/components/Dynamic.js.map +1 -0
  27. package/dist/components/index.d.ts +4 -0
  28. package/dist/components/index.d.ts.map +1 -1
  29. package/dist/components/index.js +2 -0
  30. package/dist/components/index.js.map +1 -1
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/server/app.d.ts.map +1 -1
  35. package/dist/server/app.js +72 -8
  36. package/dist/server/app.js.map +1 -1
  37. package/dist/server/cdn.d.ts +72 -0
  38. package/dist/server/cdn.d.ts.map +1 -0
  39. package/dist/server/cdn.js +132 -0
  40. package/dist/server/cdn.js.map +1 -0
  41. package/dist/server/revalidate.d.ts.map +1 -1
  42. package/dist/server/revalidate.js +6 -1
  43. package/dist/server/revalidate.js.map +1 -1
  44. package/dist/ssr/html.d.ts +7 -0
  45. package/dist/ssr/html.d.ts.map +1 -1
  46. package/dist/ssr/html.js +2 -1
  47. package/dist/ssr/html.js.map +1 -1
  48. package/dist/ssr/ppr.d.ts +69 -0
  49. package/dist/ssr/ppr.d.ts.map +1 -0
  50. package/dist/ssr/ppr.js +132 -0
  51. package/dist/ssr/ppr.js.map +1 -0
  52. package/dist/ssr/render.d.ts +2 -0
  53. package/dist/ssr/render.d.ts.map +1 -1
  54. package/dist/ssr/render.js +2 -1
  55. package/dist/ssr/render.js.map +1 -1
  56. package/dist/types/index.d.ts +20 -1
  57. package/dist/types/index.d.ts.map +1 -1
  58. package/package.json +5 -1
  59. package/src/analytics/handler.ts +110 -0
  60. package/src/analytics/store.test.ts +45 -0
  61. package/src/analytics/store.ts +94 -0
  62. package/src/commands/build.ts +117 -2
  63. package/src/commands/dev.ts +7 -0
  64. package/src/components/Analytics.tsx +164 -0
  65. package/src/components/Dynamic.tsx +124 -0
  66. package/src/components/index.ts +4 -0
  67. package/src/index.ts +1 -0
  68. package/src/server/app.ts +82 -9
  69. package/src/server/cdn.ts +187 -0
  70. package/src/server/revalidate.ts +7 -1
  71. package/src/ssr/html.ts +9 -0
  72. package/src/ssr/ppr.ts +167 -0
  73. package/src/ssr/render.ts +4 -0
  74. package/src/types/index.ts +23 -0
  75. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Alab PPR — Partial Prerendering support.
3
+ *
4
+ * Pages that export `export const ppr = true` get their static HTML shell
5
+ * pre-rendered at build time and stored in `.alabjs/ppr-cache/`. At runtime,
6
+ * the shell is served instantly (CDN-cacheable) while `<Dynamic>` sections
7
+ * fill in per-request via React's Suspense streaming or client-side hydration.
8
+ *
9
+ * ## How it works
10
+ *
11
+ * During the **build-time static render** (pre-render pass):
12
+ * • `PPRShellProvider` sets the PPR context to `true`.
13
+ * • `<Dynamic>` sees the context and renders only its `fallback` inside a
14
+ * `data-ppr-hole` marker — children are omitted entirely.
15
+ * • The resulting HTML is the "static shell": complete page minus dynamic parts.
16
+ *
17
+ * At **runtime**:
18
+ * • `PPRShellProvider` is never rendered → context defaults to `false`.
19
+ * • `<Dynamic>` behaves as a plain `<Suspense>` boundary, streaming children
20
+ * as their async work resolves.
21
+ *
22
+ * ## Usage
23
+ *
24
+ * ```tsx
25
+ * // app/posts/[id]/page.tsx
26
+ * import { Dynamic } from "alabjs/components";
27
+ *
28
+ * export const ppr = true;
29
+ *
30
+ * export default function PostPage({ params }: { params: { id: string } }) {
31
+ * return (
32
+ * <article>
33
+ * <h1>Post {params.id}</h1>
34
+ * <Dynamic id="sidebar" fallback={<SidebarSkeleton />}>
35
+ * <PersonalisedSidebar userId={userId} />
36
+ * </Dynamic>
37
+ * </article>
38
+ * );
39
+ * }
40
+ * ```
41
+ */
42
+
43
+ import { Suspense, createContext, useContext, type ReactNode } from "react";
44
+
45
+ // ─── PPR shell context ─────────────────────────────────────────────────────────
46
+
47
+ /**
48
+ * When `true`, `<Dynamic>` renders only its `fallback` placeholder.
49
+ * Set exclusively by `PPRShellProvider` during build-time pre-renders.
50
+ */
51
+ const PPRShellCtx = createContext(false);
52
+
53
+ /**
54
+ * @internal
55
+ * Wrap the root element with this during build-time PPR pre-rendering so that
56
+ * every `<Dynamic>` in the tree emits a stable `data-ppr-hole` placeholder
57
+ * instead of its children.
58
+ *
59
+ * Do **not** use this at runtime — it is an implementation detail of
60
+ * `preRenderPPRShell` in `src/ssr/ppr.ts`.
61
+ */
62
+ export function PPRShellProvider({ children }: { children: ReactNode }) {
63
+ return <PPRShellCtx.Provider value={true}>{children}</PPRShellCtx.Provider>;
64
+ }
65
+
66
+ // ─── Dynamic component ────────────────────────────────────────────────────────
67
+
68
+ export interface DynamicProps {
69
+ /**
70
+ * Unique identifier for this dynamic section within the page.
71
+ *
72
+ * Used to correlate the placeholder emitted in the static shell with the
73
+ * live content streamed at runtime. **Must be stable across renders** —
74
+ * treat it like a React key: short, descriptive, no dynamic values.
75
+ *
76
+ * @example "sidebar", "user-nav", "related-posts"
77
+ */
78
+ id: string;
79
+ /** Per-request dynamic content. Never rendered in the pre-built static shell. */
80
+ children: ReactNode;
81
+ /**
82
+ * Shown in the pre-built static shell **and** as the React Suspense fallback
83
+ * while the dynamic content is streaming in.
84
+ *
85
+ * Keep this lightweight — it is inlined into every CDN-cached response.
86
+ */
87
+ fallback?: ReactNode;
88
+ }
89
+
90
+ /**
91
+ * Marks a subtree as **dynamic** (per-request) within a PPR page.
92
+ *
93
+ * - **Build time** (static shell pre-render): renders `fallback` inside a
94
+ * `<div data-ppr-hole="{id}">` marker. Children are not rendered.
95
+ * - **Runtime** (SSR + hydration): acts as a `<Suspense>` boundary. Children
96
+ * stream in as their async work resolves; `fallback` is shown meanwhile.
97
+ *
98
+ * The `display: contents` style on the wrapper div means it has no visual
99
+ * footprint — it exists only as a DOM anchor for Alab's PPR machinery.
100
+ */
101
+ export function Dynamic({ id, children, fallback = null }: DynamicProps) {
102
+ const isShell = useContext(PPRShellCtx);
103
+
104
+ const holeWrapper = (content: ReactNode) => (
105
+ <div data-ppr-hole={id} style={{ display: "contents" }}>
106
+ {content}
107
+ </div>
108
+ );
109
+
110
+ if (isShell) {
111
+ // Build-time pre-render: emit only the placeholder + fallback.
112
+ // Children are intentionally omitted — they contain per-request logic.
113
+ return holeWrapper(fallback);
114
+ }
115
+
116
+ // Runtime: standard Suspense boundary.
117
+ // The hole wrapper on the fallback preserves the DOM anchor so client-side
118
+ // hydration can match it to the pre-rendered shell.
119
+ return (
120
+ <Suspense fallback={holeWrapper(fallback)}>
121
+ {children}
122
+ </Suspense>
123
+ );
124
+ }
@@ -7,3 +7,7 @@ export { Script } from "./Script.js";
7
7
  export type { ScriptProps } from "./Script.js";
8
8
  export { Font } from "./Font.js";
9
9
  export type { FontProps } from "./Font.js";
10
+ export { Dynamic, PPRShellProvider } from "./Dynamic.js";
11
+ export type { DynamicProps } from "./Dynamic.js";
12
+ export { Analytics } from "./Analytics.js";
13
+ export type { AnalyticsProps } from "./Analytics.js";
package/src/index.ts CHANGED
@@ -6,5 +6,6 @@ export type {
6
6
  PageMetadata,
7
7
  GenerateMetadata,
8
8
  RouteParams,
9
+ CdnCache,
9
10
  } from "./types/index.js";
10
11
  export { createApp } from "./server/app.js";
package/src/server/app.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createApp as createH3App, createRouter, defineEventHandler, getQuery, readBody } from "h3";
2
2
  import { createServer } from "node:http";
3
3
  import { resolve, dirname, join, extname } from "node:path";
4
- import { existsSync, createReadStream, statSync } from "node:fs";
4
+ import { existsSync, createReadStream, statSync, readFileSync } from "node:fs";
5
5
  import { toNodeListener } from "h3";
6
6
  import type { RouteManifest } from "../router/manifest.js";
7
7
  import { renderToResponse } from "../ssr/render.js";
@@ -12,6 +12,9 @@ import type { MiddlewareModule } from "./middleware.js";
12
12
  import { runMiddleware } from "./middleware.js";
13
13
  import type { PageMetadata } from "../types/index.js";
14
14
  import { checkRevalidateAuth, applyRevalidate } from "./revalidate.js";
15
+ import { applyCdnHeaders, type CdnCache } from "./cdn.js";
16
+ import { getPPRShell, injectBuildIdIntoPPRShell, PPR_CACHE_SUBDIR } from "../ssr/ppr.js";
17
+ import { handleVitalsBeacon, handleAnalyticsDashboard } from "../analytics/handler.js";
15
18
 
16
19
  /**
17
20
  * Find layout file paths (relative to cwd root) for a given route.file, ordered outermost→innermost.
@@ -75,6 +78,17 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
75
78
  const router = createRouter();
76
79
  const publicDir = resolve(distDir, "../../public");
77
80
 
81
+ // Load the build ID written by `alab build` for skew protection.
82
+ // If the file is absent (first-run / non-standard setup) skew detection
83
+ // is silently disabled — existing behaviour is unchanged.
84
+ let buildId: string | undefined;
85
+ try {
86
+ buildId = readFileSync(resolve(distDir, "BUILD_ID"), "utf8").trim() || undefined;
87
+ } catch { /* no BUILD_ID file — skew protection disabled */ }
88
+
89
+ // Absolute path to the PPR shell cache directory.
90
+ const pprCacheDir = resolve(distDir, "../../", PPR_CACHE_SUBDIR);
91
+
78
92
  // ─── Global middleware ───────────────────────────────────────────────────────
79
93
  app.use(
80
94
  defineEventHandler((event) => {
@@ -230,6 +244,23 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
230
244
  }),
231
245
  );
232
246
 
247
+ // Core Web Vitals beacon — receives POST from <Analytics> component
248
+ router.post(
249
+ "/_alabjs/vitals",
250
+ defineEventHandler((event) => {
251
+ return handleVitalsBeacon(event.node.req, event.node.res);
252
+ }),
253
+ );
254
+
255
+ // Analytics dashboard — GET aggregated per-route stats
256
+ router.get(
257
+ "/_alabjs/analytics",
258
+ defineEventHandler((event) => {
259
+ handleAnalyticsDashboard(event.node.req, event.node.res);
260
+ return null;
261
+ }),
262
+ );
263
+
233
264
  // Auto sitemap.xml from route manifest
234
265
  router.get(
235
266
  "/sitemap.xml",
@@ -319,12 +350,14 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
319
350
  defineEventHandler(async (event) => {
320
351
  const res = event.node.res;
321
352
 
322
- // HTML pages must not be cached by intermediaries they contain
323
- // user-specific CSRF tokens and may reflect auth state.
324
- res.setHeader("cache-control", "no-store");
325
-
326
- // Set CSRF cookie so the client can send it on mutations.
327
- const csrfToken = setCsrfCookie(event);
353
+ // Skew protection: tell the client which build this server is running.
354
+ if (buildId) {
355
+ res.setHeader("x-alab-build-id", buildId);
356
+ const clientBuildId = event.node.req.headers["x-alab-build-id"];
357
+ if (clientBuildId && clientBuildId !== buildId) {
358
+ res.setHeader("x-alab-revalidate", "1");
359
+ }
360
+ }
328
361
 
329
362
  const rawParams = (event.context.params ?? {}) as Record<string, string>;
330
363
  const params = rawParams;
@@ -342,6 +375,8 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
342
375
  metadata?: PageMetadata;
343
376
  generateMetadata?: (params: Record<string, string>) => PageMetadata | Promise<PageMetadata>;
344
377
  ssr?: boolean;
378
+ cdnCache?: CdnCache;
379
+ ppr?: boolean;
345
380
  };
346
381
 
347
382
  const Page = mod.default;
@@ -351,6 +386,31 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
351
386
  return;
352
387
  }
353
388
 
389
+ // ── PPR: serve pre-rendered static shell ──────────────────────────────
390
+ // Pages with `export const ppr = true` get a static HTML shell built
391
+ // at `alab build` time. Serve it instantly with a long CDN TTL so the
392
+ // static portion is edge-cached. Dynamic sections (`<Dynamic>`) render
393
+ // their fallback in the shell and are filled in client-side via React
394
+ // hydration, or server-side via Suspense streaming on direct hits.
395
+ if (mod.ppr === true) {
396
+ let shell = getPPRShell(route.path, pprCacheDir);
397
+ if (shell !== null) {
398
+ // Inject the per-build skew-protection tag into the pre-rendered HTML.
399
+ if (buildId) shell = injectBuildIdIntoPPRShell(shell, buildId);
400
+
401
+ res.statusCode = 200;
402
+ res.setHeader("content-type", "text/html; charset=utf-8");
403
+ // Long CDN TTL: static shell doesn't change until the next build.
404
+ res.setHeader("cache-control", "public, s-maxage=3600, stale-while-revalidate=86400");
405
+ res.setHeader("x-alab-ppr", "shell");
406
+ if (buildId) res.setHeader("x-alab-build-id", buildId);
407
+ res.end(shell);
408
+ return;
409
+ }
410
+ // Shell not found — fall through to normal SSR and warn once.
411
+ console.warn(`[alabjs] ppr: no pre-rendered shell for ${route.path} — run \`alab build\` to generate it. Falling back to SSR.`);
412
+ }
413
+
354
414
  // Support both static metadata and dynamic generateMetadata (production fix)
355
415
  const metadata: PageMetadata =
356
416
  typeof mod.generateMetadata === "function"
@@ -369,8 +429,20 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
369
429
  const layoutsJson = JSON.stringify(layoutRelPaths);
370
430
  const loadingFile = findProdLoadingFile(route.file, distDir) ?? undefined;
371
431
 
372
- // Inject CSRF token into the HTML head so client JS can read it.
373
- const headExtra = `<meta name="csrf-token" content="${csrfToken.replace(/"/g, "&quot;")}" />`;
432
+ // ── Cache-control + CSRF ──────────────────────────────────────────────
433
+ // Pages that export `cdnCache` are public, edge-cacheable pages.
434
+ // They get CDN headers instead of `no-store`, and CSRF tokens are
435
+ // omitted — a shared cache would deliver the same token to every
436
+ // visitor, defeating CSRF protection.
437
+ let headExtra = "";
438
+ if (mod.cdnCache) {
439
+ applyCdnHeaders(res, mod.cdnCache);
440
+ } else {
441
+ // Private page: must not be cached by intermediaries.
442
+ res.setHeader("cache-control", "no-store");
443
+ const csrfToken = setCsrfCookie(event);
444
+ headExtra = `<meta name="csrf-token" content="${csrfToken.replace(/"/g, "&quot;")}" />`;
445
+ }
374
446
 
375
447
  try {
376
448
  renderToResponse(res, {
@@ -384,6 +456,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
384
456
  loadingFile,
385
457
  ssr: ssrEnabled,
386
458
  headExtra,
459
+ ...(buildId ? { buildId } : {}),
387
460
  });
388
461
  } catch (err) {
389
462
  // ── error.tsx fallback ────────────────────────────────────────────
@@ -0,0 +1,187 @@
1
+ /**
2
+ * CDN cache header utilities for Alab.
3
+ *
4
+ * Pages that export `const cdnCache: CdnCache = { ... }` are opt-in public,
5
+ * edge-cached pages. Alab sets response headers so any CDN or shared proxy
6
+ * (Cloudflare, Fastly, Varnish, Nginx, AWS CloudFront) can cache them without
7
+ * Vercel.
8
+ *
9
+ * ## Configuration
10
+ *
11
+ * Set `ALAB_CDN` in your environment to enable vendor-specific headers:
12
+ *
13
+ * | Value | Extra headers set |
14
+ * |--------------|-------------------------------------------------|
15
+ * | `cloudflare` | `CDN-Cache-Control`, `Cache-Tag` |
16
+ * | `fastly` | `Surrogate-Control`, `Surrogate-Key` |
17
+ * | (unset) | Universal `Cache-Control: public, s-maxage=N` only |
18
+ *
19
+ * ## Tag-based purge credentials
20
+ *
21
+ * - Cloudflare: `CF_ZONE_ID` + `CF_API_TOKEN`
22
+ * - Fastly: `FASTLY_SERVICE_ID` + `FASTLY_API_KEY`
23
+ *
24
+ * ## Important
25
+ *
26
+ * CDN-cached pages are **public pages** — they must not contain user-specific
27
+ * state. Alab automatically skips CSRF token injection for pages that export
28
+ * `cdnCache` because a shared cache would hand the same token to every visitor,
29
+ * which defeats CSRF protection.
30
+ */
31
+
32
+ import type { ServerResponse } from "node:http";
33
+
34
+ // ─── Public type ──────────────────────────────────────────────────────────────
35
+
36
+ export interface CdnCache {
37
+ /** Seconds the CDN / shared proxy may cache this response. */
38
+ maxAge: number;
39
+ /**
40
+ * Seconds the CDN may continue serving a stale response while it
41
+ * revalidates the entry in the background (stale-while-revalidate).
42
+ * Defaults to `maxAge` when omitted.
43
+ */
44
+ swr?: number;
45
+ /**
46
+ * Cache tags for targeted invalidation via `POST /_alabjs/revalidate`.
47
+ *
48
+ * - Cloudflare: emitted as `Cache-Tag: tag1,tag2`
49
+ * - Fastly / Varnish: emitted as `Surrogate-Key: tag1 tag2`
50
+ *
51
+ * @example
52
+ * export const cdnCache: CdnCache = {
53
+ * maxAge: 60,
54
+ * tags: ["posts", "post:42"],
55
+ * };
56
+ */
57
+ tags?: readonly string[];
58
+ }
59
+
60
+ // ─── Internal ─────────────────────────────────────────────────────────────────
61
+
62
+ type CdnProvider = "cloudflare" | "fastly" | "none";
63
+
64
+ function detectProvider(): CdnProvider {
65
+ switch (process.env["ALAB_CDN"]?.toLowerCase()) {
66
+ case "cloudflare": return "cloudflare";
67
+ case "fastly": return "fastly";
68
+ default: return "none";
69
+ }
70
+ }
71
+
72
+ // ─── Header helpers ───────────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Set CDN-appropriate response headers for a page that opts in to edge caching
76
+ * via `export const cdnCache = { ... }`.
77
+ *
78
+ * Always emits the universal `Cache-Control: public, s-maxage=N,
79
+ * stale-while-revalidate=M` header. Vendor-specific headers are added when
80
+ * `ALAB_CDN` is set.
81
+ */
82
+ export function applyCdnHeaders(res: ServerResponse, cdnCache: CdnCache): void {
83
+ const { maxAge, swr = maxAge, tags = [] } = cdnCache;
84
+
85
+ // Universal — honoured by every shared proxy, CDN, and browser.
86
+ res.setHeader(
87
+ "cache-control",
88
+ `public, s-maxage=${maxAge}, stale-while-revalidate=${swr}`,
89
+ );
90
+
91
+ const provider = detectProvider();
92
+
93
+ if (provider === "cloudflare") {
94
+ // Cloudflare reads CDN-Cache-Control with higher priority than Cache-Control,
95
+ // allowing different TTLs at the edge vs. the browser.
96
+ res.setHeader("cdn-cache-control", `max-age=${maxAge}`);
97
+ if (tags.length > 0) {
98
+ // Cache-Tag enables Cloudflare tag-based purge via their API.
99
+ res.setHeader("cache-tag", [...tags].join(","));
100
+ }
101
+ } else if (provider === "fastly") {
102
+ // Surrogate-Control is stripped by Fastly before forwarding to the browser,
103
+ // so it can hold a much larger TTL than Cache-Control safely.
104
+ res.setHeader("surrogate-control", `max-age=${maxAge}`);
105
+ if (tags.length > 0) {
106
+ // Surrogate-Key is Fastly's mechanism for instant surrogate-key purge.
107
+ res.setHeader("surrogate-key", [...tags].join(" "));
108
+ }
109
+ }
110
+ }
111
+
112
+ // ─── CDN purge ────────────────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Purge CDN cache entries by tag.
116
+ *
117
+ * Called from `/_alabjs/revalidate` **after** the in-process cache has been
118
+ * cleared. Silently no-ops when `ALAB_CDN` is not configured or the required
119
+ * credentials are absent — the TTL will expire the CDN entry naturally.
120
+ */
121
+ export async function purgeCdnByTags(tags: readonly string[]): Promise<void> {
122
+ if (tags.length === 0) return;
123
+
124
+ switch (detectProvider()) {
125
+ case "cloudflare": await purgeCloudflare(tags); break;
126
+ case "fastly": await purgeFastly(tags); break;
127
+ // "none": no CDN purge needed — in-process cache already cleared.
128
+ }
129
+ }
130
+
131
+ async function purgeCloudflare(tags: readonly string[]): Promise<void> {
132
+ const zoneId = process.env["CF_ZONE_ID"];
133
+ const apiToken = process.env["CF_API_TOKEN"];
134
+
135
+ if (!zoneId || !apiToken) {
136
+ console.warn(
137
+ "[alabjs] CDN purge: ALAB_CDN=cloudflare but CF_ZONE_ID or CF_API_TOKEN is not set — skipping.",
138
+ );
139
+ return;
140
+ }
141
+
142
+ const res = await fetch(
143
+ `https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
144
+ {
145
+ method: "POST",
146
+ headers: {
147
+ authorization: `Bearer ${apiToken}`,
148
+ "content-type": "application/json",
149
+ },
150
+ body: JSON.stringify({ tags: [...tags] }),
151
+ },
152
+ );
153
+
154
+ if (!res.ok) {
155
+ console.error(`[alabjs] Cloudflare cache purge failed (${res.status}): ${await res.text()}`);
156
+ }
157
+ }
158
+
159
+ async function purgeFastly(tags: readonly string[]): Promise<void> {
160
+ const serviceId = process.env["FASTLY_SERVICE_ID"];
161
+ const apiKey = process.env["FASTLY_API_KEY"];
162
+
163
+ if (!serviceId || !apiKey) {
164
+ console.warn(
165
+ "[alabjs] CDN purge: ALAB_CDN=fastly but FASTLY_SERVICE_ID or FASTLY_API_KEY is not set — skipping.",
166
+ );
167
+ return;
168
+ }
169
+
170
+ // Fastly instant purge by surrogate key (POST /service/{id}/purge with
171
+ // Surrogate-Key header containing space-separated tags).
172
+ const res = await fetch(
173
+ `https://api.fastly.com/service/${serviceId}/purge`,
174
+ {
175
+ method: "POST",
176
+ headers: {
177
+ "fastly-key": apiKey,
178
+ "surrogate-key": [...tags].join(" "),
179
+ accept: "application/json",
180
+ },
181
+ },
182
+ );
183
+
184
+ if (!res.ok) {
185
+ console.error(`[alabjs] Fastly cache purge failed (${res.status}): ${await res.text()}`);
186
+ }
187
+ }
@@ -25,6 +25,7 @@
25
25
  */
26
26
 
27
27
  import { revalidatePath, revalidatePathPrefix, revalidateTag } from "./cache.js";
28
+ import { purgeCdnByTags } from "./cdn.js";
28
29
 
29
30
  export interface RevalidateBody {
30
31
  /** Purge a single cached page path. */
@@ -64,7 +65,12 @@ export function applyRevalidate(
64
65
 
65
66
  if (path) revalidatePath(path);
66
67
  if (prefix) revalidatePathPrefix(prefix);
67
- if (tags?.length) revalidateTag({ tags });
68
+ if (tags?.length) {
69
+ revalidateTag({ tags });
70
+ // Fire-and-forget: CDN purge is best-effort. In-process cache is already
71
+ // cleared above, so a CDN miss will just hit the origin and re-warm the edge.
72
+ void purgeCdnByTags(tags);
73
+ }
68
74
 
69
75
  return {
70
76
  revalidated: true,
package/src/ssr/html.ts CHANGED
@@ -18,6 +18,13 @@ export interface HtmlShellOptions {
18
18
  headExtra?: string | undefined;
19
19
  /** Nonce for CSP inline scripts (optional). */
20
20
  nonce?: string | undefined;
21
+ /**
22
+ * Build ID for skew protection.
23
+ * Injected as `<meta name="alabjs-build-id">` so the client SPA router can
24
+ * detect a deployment change mid-session and trigger a hard reload instead
25
+ * of swapping components in-place with mismatched JS chunks.
26
+ */
27
+ buildId?: string | undefined;
21
28
  }
22
29
 
23
30
  /** Build the opening HTML fragment — everything up to and including `<div id="alabjs-root">`. */
@@ -31,6 +38,7 @@ export function htmlShellBefore(opts: HtmlShellOptions): string {
31
38
  layoutsJson,
32
39
  loadingFile,
33
40
  headExtra = "",
41
+ buildId,
34
42
  } = opts;
35
43
 
36
44
  const titleTag = metadata.title
@@ -76,6 +84,7 @@ export function htmlShellBefore(opts: HtmlShellOptions): string {
76
84
  <meta name="alabjs-search-params" content="${escAttr(searchParamsJson)}" />
77
85
  ${layoutsJson ? `<meta name="alabjs-layouts" content="${escAttr(layoutsJson)}" />` : ""}
78
86
  ${loadingFile ? `<meta name="alabjs-loading" content="${escAttr(loadingFile)}" />` : ""}
87
+ ${buildId ? `<meta name="alabjs-build-id" content="${escAttr(buildId)}" />` : ""}
79
88
  <link rel="stylesheet" href="/app/globals.css" />
80
89
  ${headExtra}
81
90
  </head>