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.
- package/dist/analytics/handler.d.ts +24 -0
- package/dist/analytics/handler.d.ts.map +1 -0
- package/dist/analytics/handler.js +87 -0
- package/dist/analytics/handler.js.map +1 -0
- package/dist/analytics/store.d.ts +33 -0
- package/dist/analytics/store.d.ts.map +1 -0
- package/dist/analytics/store.js +68 -0
- package/dist/analytics/store.js.map +1 -0
- package/dist/analytics/store.test.d.ts +2 -0
- package/dist/analytics/store.test.d.ts.map +1 -0
- package/dist/analytics/store.test.js +42 -0
- package/dist/analytics/store.test.js.map +1 -0
- package/dist/commands/build.d.ts.map +1 -1
- package/dist/commands/build.js +104 -2
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +6 -0
- package/dist/commands/dev.js.map +1 -1
- package/dist/components/Analytics.d.ts +48 -0
- package/dist/components/Analytics.d.ts.map +1 -0
- package/dist/components/Analytics.js +154 -0
- package/dist/components/Analytics.js.map +1 -0
- package/dist/components/Dynamic.d.ts +88 -0
- package/dist/components/Dynamic.d.ts.map +1 -0
- package/dist/components/Dynamic.js +86 -0
- package/dist/components/Dynamic.js.map +1 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +2 -0
- package/dist/components/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/server/app.d.ts.map +1 -1
- package/dist/server/app.js +72 -8
- package/dist/server/app.js.map +1 -1
- package/dist/server/cdn.d.ts +72 -0
- package/dist/server/cdn.d.ts.map +1 -0
- package/dist/server/cdn.js +132 -0
- package/dist/server/cdn.js.map +1 -0
- package/dist/server/revalidate.d.ts.map +1 -1
- package/dist/server/revalidate.js +6 -1
- package/dist/server/revalidate.js.map +1 -1
- package/dist/ssr/html.d.ts +7 -0
- package/dist/ssr/html.d.ts.map +1 -1
- package/dist/ssr/html.js +2 -1
- package/dist/ssr/html.js.map +1 -1
- package/dist/ssr/ppr.d.ts +69 -0
- package/dist/ssr/ppr.d.ts.map +1 -0
- package/dist/ssr/ppr.js +132 -0
- package/dist/ssr/ppr.js.map +1 -0
- package/dist/ssr/render.d.ts +2 -0
- package/dist/ssr/render.d.ts.map +1 -1
- package/dist/ssr/render.js +2 -1
- package/dist/ssr/render.js.map +1 -1
- package/dist/types/index.d.ts +20 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/analytics/handler.ts +110 -0
- package/src/analytics/store.test.ts +45 -0
- package/src/analytics/store.ts +94 -0
- package/src/commands/build.ts +117 -2
- package/src/commands/dev.ts +7 -0
- package/src/components/Analytics.tsx +164 -0
- package/src/components/Dynamic.tsx +124 -0
- package/src/components/index.ts +4 -0
- package/src/index.ts +1 -0
- package/src/server/app.ts +82 -9
- package/src/server/cdn.ts +187 -0
- package/src/server/revalidate.ts +7 -1
- package/src/ssr/html.ts +9 -0
- package/src/ssr/ppr.ts +167 -0
- package/src/ssr/render.ts +4 -0
- package/src/types/index.ts +23 -0
- 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
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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
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
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
//
|
|
373
|
-
|
|
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, """)}" />`;
|
|
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
|
+
}
|
package/src/server/revalidate.ts
CHANGED
|
@@ -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)
|
|
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>
|