alabjs 0.1.0 → 0.2.0
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/README.md +45 -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/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 +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -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 +62 -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 +15 -2
- package/src/commands/build.ts +117 -2
- package/src/commands/dev.ts +7 -0
- package/src/components/Dynamic.tsx +124 -0
- package/src/components/index.ts +2 -0
- package/src/index.ts +1 -0
- package/src/server/app.ts +64 -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
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,8 @@ 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";
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Find layout file paths (relative to cwd root) for a given route.file, ordered outermost→innermost.
|
|
@@ -75,6 +77,17 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
75
77
|
const router = createRouter();
|
|
76
78
|
const publicDir = resolve(distDir, "../../public");
|
|
77
79
|
|
|
80
|
+
// Load the build ID written by `alab build` for skew protection.
|
|
81
|
+
// If the file is absent (first-run / non-standard setup) skew detection
|
|
82
|
+
// is silently disabled — existing behaviour is unchanged.
|
|
83
|
+
let buildId: string | undefined;
|
|
84
|
+
try {
|
|
85
|
+
buildId = readFileSync(resolve(distDir, "BUILD_ID"), "utf8").trim() || undefined;
|
|
86
|
+
} catch { /* no BUILD_ID file — skew protection disabled */ }
|
|
87
|
+
|
|
88
|
+
// Absolute path to the PPR shell cache directory.
|
|
89
|
+
const pprCacheDir = resolve(distDir, "../../", PPR_CACHE_SUBDIR);
|
|
90
|
+
|
|
78
91
|
// ─── Global middleware ───────────────────────────────────────────────────────
|
|
79
92
|
app.use(
|
|
80
93
|
defineEventHandler((event) => {
|
|
@@ -319,12 +332,14 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
319
332
|
defineEventHandler(async (event) => {
|
|
320
333
|
const res = event.node.res;
|
|
321
334
|
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
335
|
+
// Skew protection: tell the client which build this server is running.
|
|
336
|
+
if (buildId) {
|
|
337
|
+
res.setHeader("x-alab-build-id", buildId);
|
|
338
|
+
const clientBuildId = event.node.req.headers["x-alab-build-id"];
|
|
339
|
+
if (clientBuildId && clientBuildId !== buildId) {
|
|
340
|
+
res.setHeader("x-alab-revalidate", "1");
|
|
341
|
+
}
|
|
342
|
+
}
|
|
328
343
|
|
|
329
344
|
const rawParams = (event.context.params ?? {}) as Record<string, string>;
|
|
330
345
|
const params = rawParams;
|
|
@@ -342,6 +357,8 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
342
357
|
metadata?: PageMetadata;
|
|
343
358
|
generateMetadata?: (params: Record<string, string>) => PageMetadata | Promise<PageMetadata>;
|
|
344
359
|
ssr?: boolean;
|
|
360
|
+
cdnCache?: CdnCache;
|
|
361
|
+
ppr?: boolean;
|
|
345
362
|
};
|
|
346
363
|
|
|
347
364
|
const Page = mod.default;
|
|
@@ -351,6 +368,31 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
351
368
|
return;
|
|
352
369
|
}
|
|
353
370
|
|
|
371
|
+
// ── PPR: serve pre-rendered static shell ──────────────────────────────
|
|
372
|
+
// Pages with `export const ppr = true` get a static HTML shell built
|
|
373
|
+
// at `alab build` time. Serve it instantly with a long CDN TTL so the
|
|
374
|
+
// static portion is edge-cached. Dynamic sections (`<Dynamic>`) render
|
|
375
|
+
// their fallback in the shell and are filled in client-side via React
|
|
376
|
+
// hydration, or server-side via Suspense streaming on direct hits.
|
|
377
|
+
if (mod.ppr === true) {
|
|
378
|
+
let shell = getPPRShell(route.path, pprCacheDir);
|
|
379
|
+
if (shell !== null) {
|
|
380
|
+
// Inject the per-build skew-protection tag into the pre-rendered HTML.
|
|
381
|
+
if (buildId) shell = injectBuildIdIntoPPRShell(shell, buildId);
|
|
382
|
+
|
|
383
|
+
res.statusCode = 200;
|
|
384
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
385
|
+
// Long CDN TTL: static shell doesn't change until the next build.
|
|
386
|
+
res.setHeader("cache-control", "public, s-maxage=3600, stale-while-revalidate=86400");
|
|
387
|
+
res.setHeader("x-alab-ppr", "shell");
|
|
388
|
+
if (buildId) res.setHeader("x-alab-build-id", buildId);
|
|
389
|
+
res.end(shell);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
// Shell not found — fall through to normal SSR and warn once.
|
|
393
|
+
console.warn(`[alabjs] ppr: no pre-rendered shell for ${route.path} — run \`alab build\` to generate it. Falling back to SSR.`);
|
|
394
|
+
}
|
|
395
|
+
|
|
354
396
|
// Support both static metadata and dynamic generateMetadata (production fix)
|
|
355
397
|
const metadata: PageMetadata =
|
|
356
398
|
typeof mod.generateMetadata === "function"
|
|
@@ -369,8 +411,20 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
369
411
|
const layoutsJson = JSON.stringify(layoutRelPaths);
|
|
370
412
|
const loadingFile = findProdLoadingFile(route.file, distDir) ?? undefined;
|
|
371
413
|
|
|
372
|
-
//
|
|
373
|
-
|
|
414
|
+
// ── Cache-control + CSRF ──────────────────────────────────────────────
|
|
415
|
+
// Pages that export `cdnCache` are public, edge-cacheable pages.
|
|
416
|
+
// They get CDN headers instead of `no-store`, and CSRF tokens are
|
|
417
|
+
// omitted — a shared cache would deliver the same token to every
|
|
418
|
+
// visitor, defeating CSRF protection.
|
|
419
|
+
let headExtra = "";
|
|
420
|
+
if (mod.cdnCache) {
|
|
421
|
+
applyCdnHeaders(res, mod.cdnCache);
|
|
422
|
+
} else {
|
|
423
|
+
// Private page: must not be cached by intermediaries.
|
|
424
|
+
res.setHeader("cache-control", "no-store");
|
|
425
|
+
const csrfToken = setCsrfCookie(event);
|
|
426
|
+
headExtra = `<meta name="csrf-token" content="${csrfToken.replace(/"/g, """)}" />`;
|
|
427
|
+
}
|
|
374
428
|
|
|
375
429
|
try {
|
|
376
430
|
renderToResponse(res, {
|
|
@@ -384,6 +438,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
384
438
|
loadingFile,
|
|
385
439
|
ssr: ssrEnabled,
|
|
386
440
|
headExtra,
|
|
441
|
+
...(buildId ? { buildId } : {}),
|
|
387
442
|
});
|
|
388
443
|
} catch (err) {
|
|
389
444
|
// ── 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>
|
package/src/ssr/ppr.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alab PPR — build-time static shell pre-renderer and runtime shell reader.
|
|
3
|
+
*
|
|
4
|
+
* ## Build-time
|
|
5
|
+
* `preRenderPPRShell()` renders a page with `PPRShellProvider` so that every
|
|
6
|
+
* `<Dynamic>` emits a `data-ppr-hole` placeholder instead of its children.
|
|
7
|
+
* The resulting HTML is saved to `.alabjs/ppr-cache/<slug>.html`.
|
|
8
|
+
*
|
|
9
|
+
* ## Runtime
|
|
10
|
+
* `getPPRShell()` reads the pre-rendered HTML for a given route path, or
|
|
11
|
+
* returns `null` if the file doesn't exist (triggers SSR fallback).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createElement, Suspense, type ComponentType } from "react";
|
|
15
|
+
import { renderToPipeableStream } from "react-dom/server";
|
|
16
|
+
import { Writable } from "node:stream";
|
|
17
|
+
import { writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs";
|
|
18
|
+
import { join, dirname } from "node:path";
|
|
19
|
+
import { PPRShellProvider } from "../components/Dynamic.js";
|
|
20
|
+
import { htmlShellBefore, htmlShellAfter } from "./html.js";
|
|
21
|
+
import type { HtmlShellOptions } from "./html.js";
|
|
22
|
+
|
|
23
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/** Relative path (from cwd) where pre-rendered shells are written. */
|
|
26
|
+
export const PPR_CACHE_SUBDIR = ".alabjs/ppr-cache";
|
|
27
|
+
|
|
28
|
+
// ─── Filename helpers ─────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Convert a route path to a safe filesystem filename (no leading slash, no
|
|
32
|
+
* dynamic segment brackets, slashes replaced with `__`).
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* routeToFilename("/") → "index"
|
|
36
|
+
* routeToFilename("/posts") → "posts"
|
|
37
|
+
* routeToFilename("/posts/[id]") → "posts___id_"
|
|
38
|
+
* routeToFilename("/a/[b]/c/[d]") → "a___b___c___d_"
|
|
39
|
+
*/
|
|
40
|
+
export function routeToFilename(routePath: string): string {
|
|
41
|
+
const slug = routePath
|
|
42
|
+
.replace(/^\//, "") // strip leading /
|
|
43
|
+
.replace(/\[([^\]]+)\]/g, "__$1_") // [param] → __param_
|
|
44
|
+
.replace(/\//g, "__"); // / → __
|
|
45
|
+
return (slug || "index") + ".html";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Build-time ───────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
export interface PPRPreRenderOptions {
|
|
51
|
+
/** Default component to render (page default export). */
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
53
|
+
Page: ComponentType<any>;
|
|
54
|
+
/** Layout components, outermost → innermost. */
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
56
|
+
layouts: ComponentType<any>[];
|
|
57
|
+
/** Options forwarded to `htmlShellBefore` (minus `headExtra`). */
|
|
58
|
+
shellOpts: Omit<HtmlShellOptions, "headExtra">;
|
|
59
|
+
/** Absolute path to the PPR cache directory. */
|
|
60
|
+
pprCacheDir: string;
|
|
61
|
+
/** Alab route path, e.g. `"/posts/[id]"`. */
|
|
62
|
+
routePath: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Pre-render the static HTML shell for a PPR page and persist it to disk.
|
|
67
|
+
*
|
|
68
|
+
* The render uses `PPRShellProvider` so every `<Dynamic>` in the tree emits
|
|
69
|
+
* a `data-ppr-hole` placeholder — children (per-request logic) are omitted.
|
|
70
|
+
*
|
|
71
|
+
* Called from `alab build` after the Vite bundle step completes.
|
|
72
|
+
*/
|
|
73
|
+
export async function preRenderPPRShell({
|
|
74
|
+
Page,
|
|
75
|
+
layouts,
|
|
76
|
+
shellOpts,
|
|
77
|
+
pprCacheDir,
|
|
78
|
+
routePath,
|
|
79
|
+
}: PPRPreRenderOptions): Promise<void> {
|
|
80
|
+
// Build the element tree: layouts wrapping Page, all inside PPRShellProvider.
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
82
|
+
let pageEl: any = createElement(Page, { params: {}, searchParams: {} });
|
|
83
|
+
// Suspense wrapper guards against any accidental top-level suspension.
|
|
84
|
+
pageEl = createElement(Suspense, { fallback: null }, pageEl);
|
|
85
|
+
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
86
|
+
const Layout = layouts[i];
|
|
87
|
+
if (Layout) pageEl = createElement(Layout, {}, pageEl);
|
|
88
|
+
}
|
|
89
|
+
// PPRShellProvider switches Dynamic into placeholder mode.
|
|
90
|
+
// Pass children via props to satisfy strict TS prop checking.
|
|
91
|
+
const tree = createElement(PPRShellProvider, { children: pageEl });
|
|
92
|
+
|
|
93
|
+
// Wait for the full render (allReady) — we need a complete snapshot, not a
|
|
94
|
+
// partially streamed response, because the file is served as-is from disk.
|
|
95
|
+
const reactHtml = await new Promise<string>((resolve, reject) => {
|
|
96
|
+
let result = "";
|
|
97
|
+
const sink = new Writable({
|
|
98
|
+
write(chunk: Buffer, _enc, cb) { result += chunk.toString(); cb(); },
|
|
99
|
+
final(cb) { resolve(result); cb(); },
|
|
100
|
+
});
|
|
101
|
+
const { pipe } = renderToPipeableStream(tree, {
|
|
102
|
+
onAllReady() { pipe(sink); },
|
|
103
|
+
onError(err) { reject(err instanceof Error ? err : new Error(String(err))); },
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const before = htmlShellBefore({ ...shellOpts, headExtra: "" });
|
|
108
|
+
const after = htmlShellAfter({});
|
|
109
|
+
const fullHtml = `${before}${reactHtml}${after}`;
|
|
110
|
+
|
|
111
|
+
mkdirSync(pprCacheDir, { recursive: true });
|
|
112
|
+
writeFileSync(join(pprCacheDir, routeToFilename(routePath)), fullHtml, "utf8");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Runtime ──────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Return the pre-rendered static HTML shell for `routePath`, or `null` if the
|
|
119
|
+
* cache file doesn't exist.
|
|
120
|
+
*
|
|
121
|
+
* Callers should fall back to normal SSR when `null` is returned.
|
|
122
|
+
*/
|
|
123
|
+
export function getPPRShell(routePath: string, pprCacheDir: string): string | null {
|
|
124
|
+
const filePath = join(pprCacheDir, routeToFilename(routePath));
|
|
125
|
+
try {
|
|
126
|
+
return existsSync(filePath) ? readFileSync(filePath, "utf8") : null;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Inject a `<meta name="alabjs-build-id">` tag into a pre-rendered PPR shell.
|
|
134
|
+
*
|
|
135
|
+
* The shell is pre-built and therefore doesn't include the per-build ID. We
|
|
136
|
+
* splice it in at serve time so skew protection still works for PPR pages.
|
|
137
|
+
*/
|
|
138
|
+
export function injectBuildIdIntoPPRShell(html: string, buildId: string): string {
|
|
139
|
+
const tag = `<meta name="alabjs-build-id" content="${buildId.replace(/"/g, """)}" />`;
|
|
140
|
+
// Insert after <head> if present; otherwise prepend to <html>.
|
|
141
|
+
const headClose = html.indexOf("</head>");
|
|
142
|
+
if (headClose !== -1) {
|
|
143
|
+
return html.slice(0, headClose) + ` ${tag}\n` + html.slice(headClose);
|
|
144
|
+
}
|
|
145
|
+
return tag + html;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Layout discovery (prod) ──────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Find layout file paths for a given page route file, ordered
|
|
152
|
+
* outermost → innermost. Mirrors the logic in `app.ts` but scoped to the
|
|
153
|
+
* build-time dist directory.
|
|
154
|
+
*/
|
|
155
|
+
export function findBuildLayoutFiles(routeFile: string, distDir: string): string[] {
|
|
156
|
+
const pageDir = dirname(routeFile);
|
|
157
|
+
const parts = pageDir.split("/");
|
|
158
|
+
const layouts: string[] = [];
|
|
159
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
160
|
+
const dir = parts.slice(0, i).join("/");
|
|
161
|
+
const candidate = `${dir}/layout.tsx`;
|
|
162
|
+
if (existsSync(join(distDir, "server", candidate))) {
|
|
163
|
+
layouts.push(candidate);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return layouts;
|
|
167
|
+
}
|
package/src/ssr/render.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface RenderOptions {
|
|
|
29
29
|
headExtra?: string;
|
|
30
30
|
/** CSP nonce (optional). */
|
|
31
31
|
nonce?: string;
|
|
32
|
+
/** Build ID for skew protection (see html.ts). */
|
|
33
|
+
buildId?: string;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
/**
|
|
@@ -50,6 +52,7 @@ export function renderToResponse(res: ServerResponse, opts: RenderOptions): void
|
|
|
50
52
|
ssr,
|
|
51
53
|
headExtra,
|
|
52
54
|
nonce,
|
|
55
|
+
buildId,
|
|
53
56
|
} = opts;
|
|
54
57
|
|
|
55
58
|
const shellOpts: HtmlShellOptions = {
|
|
@@ -62,6 +65,7 @@ export function renderToResponse(res: ServerResponse, opts: RenderOptions): void
|
|
|
62
65
|
ssr,
|
|
63
66
|
headExtra,
|
|
64
67
|
nonce,
|
|
68
|
+
buildId,
|
|
65
69
|
};
|
|
66
70
|
|
|
67
71
|
const before = htmlShellBefore(shellOpts);
|
package/src/types/index.ts
CHANGED
|
@@ -145,3 +145,26 @@ export type GenerateMetadata<Path extends string = string> = (props: {
|
|
|
145
145
|
readonly params: RouteParams<Path>;
|
|
146
146
|
readonly searchParams: Readonly<Record<string, string | readonly string[]>>;
|
|
147
147
|
}) => Promise<PageMetadata> | PageMetadata;
|
|
148
|
+
|
|
149
|
+
// ─── CDN cache ─────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Export `cdnCache` from a page to let any CDN or shared proxy cache it at
|
|
153
|
+
* the edge — no Vercel required.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* // app/posts/[id]/page.tsx
|
|
157
|
+
* import type { CdnCache } from "alabjs";
|
|
158
|
+
*
|
|
159
|
+
* export const cdnCache: CdnCache = {
|
|
160
|
+
* maxAge: 60, // CDN keeps the page for 60 s
|
|
161
|
+
* swr: 30, // serve stale for 30 s while revalidating
|
|
162
|
+
* tags: ["posts", "post:42"], // invalidate via /_alabjs/revalidate
|
|
163
|
+
* };
|
|
164
|
+
*
|
|
165
|
+
* @remarks
|
|
166
|
+
* CDN-cached pages are **public pages** — Alab skips CSRF token injection for
|
|
167
|
+
* them because a shared cache would hand the same token to every visitor.
|
|
168
|
+
* Do not use `cdnCache` on pages that contain user-specific state.
|
|
169
|
+
*/
|
|
170
|
+
export type { CdnCache } from "../server/cdn.js";
|