alabjs 0.1.1 → 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.
Files changed (55) hide show
  1. package/dist/commands/build.d.ts.map +1 -1
  2. package/dist/commands/build.js +104 -2
  3. package/dist/commands/build.js.map +1 -1
  4. package/dist/commands/dev.d.ts.map +1 -1
  5. package/dist/commands/dev.js +6 -0
  6. package/dist/commands/dev.js.map +1 -1
  7. package/dist/components/Dynamic.d.ts +88 -0
  8. package/dist/components/Dynamic.d.ts.map +1 -0
  9. package/dist/components/Dynamic.js +86 -0
  10. package/dist/components/Dynamic.js.map +1 -0
  11. package/dist/components/index.d.ts +2 -0
  12. package/dist/components/index.d.ts.map +1 -1
  13. package/dist/components/index.js +1 -0
  14. package/dist/components/index.js.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/server/app.d.ts.map +1 -1
  19. package/dist/server/app.js +62 -8
  20. package/dist/server/app.js.map +1 -1
  21. package/dist/server/cdn.d.ts +72 -0
  22. package/dist/server/cdn.d.ts.map +1 -0
  23. package/dist/server/cdn.js +132 -0
  24. package/dist/server/cdn.js.map +1 -0
  25. package/dist/server/revalidate.d.ts.map +1 -1
  26. package/dist/server/revalidate.js +6 -1
  27. package/dist/server/revalidate.js.map +1 -1
  28. package/dist/ssr/html.d.ts +7 -0
  29. package/dist/ssr/html.d.ts.map +1 -1
  30. package/dist/ssr/html.js +2 -1
  31. package/dist/ssr/html.js.map +1 -1
  32. package/dist/ssr/ppr.d.ts +69 -0
  33. package/dist/ssr/ppr.d.ts.map +1 -0
  34. package/dist/ssr/ppr.js +132 -0
  35. package/dist/ssr/ppr.js.map +1 -0
  36. package/dist/ssr/render.d.ts +2 -0
  37. package/dist/ssr/render.d.ts.map +1 -1
  38. package/dist/ssr/render.js +2 -1
  39. package/dist/ssr/render.js.map +1 -1
  40. package/dist/types/index.d.ts +20 -1
  41. package/dist/types/index.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/commands/build.ts +117 -2
  44. package/src/commands/dev.ts +7 -0
  45. package/src/components/Dynamic.tsx +124 -0
  46. package/src/components/index.ts +2 -0
  47. package/src/index.ts +1 -0
  48. package/src/server/app.ts +64 -9
  49. package/src/server/cdn.ts +187 -0
  50. package/src/server/revalidate.ts +7 -1
  51. package/src/ssr/html.ts +9 -0
  52. package/src/ssr/ppr.ts +167 -0
  53. package/src/ssr/render.ts +4 -0
  54. package/src/types/index.ts +23 -0
  55. package/tsconfig.tsbuildinfo +1 -1
@@ -9,7 +9,7 @@ import { htmlShellBefore, htmlShellAfter } from "./html.js";
9
9
  * the closing fragment is appended when the stream finishes.
10
10
  */
11
11
  export function renderToResponse(res, opts) {
12
- const { Page, layouts = [], params, searchParams, metadata = {}, routeFile, layoutsJson, loadingFile, ssr, headExtra, nonce, } = opts;
12
+ const { Page, layouts = [], params, searchParams, metadata = {}, routeFile, layoutsJson, loadingFile, ssr, headExtra, nonce, buildId, } = opts;
13
13
  const shellOpts = {
14
14
  metadata,
15
15
  paramsJson: JSON.stringify(params),
@@ -20,6 +20,7 @@ export function renderToResponse(res, opts) {
20
20
  ssr,
21
21
  headExtra,
22
22
  nonce,
23
+ buildId,
23
24
  };
24
25
  const before = htmlShellBefore(shellOpts);
25
26
  const after = htmlShellAfter({ nonce });
@@ -1 +1 @@
1
- {"version":3,"file":"render.js","sourceRoot":"","sources":["../../src/ssr/render.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"render.js","sourceRoot":"","sources":["../../src/ssr/render.ts"],"names":[],"mappings":"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"}
@@ -113,5 +113,24 @@ export type GenerateMetadata<Path extends string = string> = (props: {
113
113
  readonly params: RouteParams<Path>;
114
114
  readonly searchParams: Readonly<Record<string, string | readonly string[]>>;
115
115
  }) => Promise<PageMetadata> | PageMetadata;
116
- export {};
116
+ /**
117
+ * Export `cdnCache` from a page to let any CDN or shared proxy cache it at
118
+ * the edge — no Vercel required.
119
+ *
120
+ * @example
121
+ * // app/posts/[id]/page.tsx
122
+ * import type { CdnCache } from "alabjs";
123
+ *
124
+ * export const cdnCache: CdnCache = {
125
+ * maxAge: 60, // CDN keeps the page for 60 s
126
+ * swr: 30, // serve stale for 30 s while revalidating
127
+ * tags: ["posts", "post:42"], // invalidate via /_alabjs/revalidate
128
+ * };
129
+ *
130
+ * @remarks
131
+ * CDN-cached pages are **public pages** — Alab skips CSRF token injection for
132
+ * them because a shared cache would hand the same token to every visitor.
133
+ * Do not use `cdnCache` on pages that contain user-specific state.
134
+ */
135
+ export type { CdnCache } from "../server/cdn.js";
117
136
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AAI1C;;;;;GAKG;AACH,KAAK,aAAa,CAAC,IAAI,SAAS,MAAM,IACpC,IAAI,SAAS,GAAG,MAAM,IAAI,MAAM,KAAK,IAAI,MAAM,IAAI,EAAE,GACjD,KAAK,GAAG,aAAa,CAAC,IAAI,CAAC,GAC3B,KAAK,CAAC;AAEZ;;;;;GAKG;AACH,MAAM,MAAM,WAAW,CAAC,IAAI,SAAS,MAAM,GAAG,MAAM,IAClD;IAAC,aAAa,CAAC,IAAI,CAAC;CAAC,SAAS,CAAC,KAAK,CAAC,GACjC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GACtB;IAAE,QAAQ,EAAE,CAAC,IAAI,aAAa,CAAC,IAAI,CAAC,GAAG,MAAM;CAAE,CAAC;AAItD;;;GAGG;AACH,MAAM,WAAW,eAAe,CAAC,IAAI,SAAS,MAAM,GAAG,MAAM;IAC3D,gEAAgE;IAChE,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,IAAI,CAAC,CAAC;IACnC,sCAAsC;IACtC,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;IACrE,4CAA4C;IAC5C,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;IACvE,sCAAsC;IACtC,QAAQ,CAAC,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;IACtE,oDAAoD;IACpD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAID,OAAO,CAAC,MAAM,eAAe,EAAE,OAAO,MAAM,CAAC;AAE7C;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,QAAQ,CAClB,KAAK,GAAG,SAAS,EACjB,MAAM,GAAG,OAAO,EAChB,IAAI,SAAS,MAAM,GAAG,MAAM,IAC1B;IACF,qFAAqF;IACrF,QAAQ,CAAC,CAAC,eAAe,CAAC,EAAE,IAAI,CAAC;IACjC,CAAC,GAAG,EAAE,eAAe,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CAC7D,CAAC;AAEF,sDAAsD;AACtD,MAAM,MAAM,iBAAiB,CAAC,CAAC,SAAS,QAAQ,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,IAC7D,CAAC,SAAS,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,EAAE,GAAG,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC;AAE7D,4CAA4C;AAC5C,MAAM,MAAM,gBAAgB,CAAC,CAAC,SAAS,QAAQ,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,IAC5D,CAAC,SAAS,QAAQ,CAAC,MAAM,CAAC,EAAE,GAAG,EAAE,GAAG,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;AAEpD,iDAAiD;AACjD,MAAM,MAAM,eAAe,CAAC,CAAC,SAAS,QAAQ,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,IAC3D,CAAC,SAAS,QAAQ,CAAC,GAAG,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;AAIpD;;;;;;;;;GASG;AACH,MAAM,MAAM,QAAQ,CAAC,IAAI,SAAS,MAAM,GAAG,MAAM,IAAI,CAAC,KAAK,EAAE;IAC3D,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,IAAI,CAAC,CAAC;IACnC,QAAQ,CAAC,YAAY,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;CAC7E,KAAK,YAAY,GAAG,IAAI,CAAC;AAI1B,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,SAAS,CAAC;IAClD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,GAAG,qBAAqB,GAAG,KAAK,GAAG,QAAQ,CAAC;IACrE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,EAAE,CAAC,EAAE,iBAAiB,CAAC;IAChC,QAAQ,CAAC,OAAO,CAAC,EAAE,eAAe,CAAC;IACnC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,wCAAwC;IACxC,QAAQ,CAAC,KAAK,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;CAClE;AAED;;GAEG;AACH,MAAM,MAAM,gBAAgB,CAAC,IAAI,SAAS,MAAM,GAAG,MAAM,IAAI,CAAC,KAAK,EAAE;IACnE,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,IAAI,CAAC,CAAC;IACnC,QAAQ,CAAC,YAAY,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;CAC7E,KAAK,OAAO,CAAC,YAAY,CAAC,GAAG,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alabjs",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "AlabJS — full-stack React framework powered by a Rust compiler",
5
5
  "keywords": [
6
6
  "react",
@@ -1,7 +1,9 @@
1
1
  import { build as viteBuild, type PluginOption } from "vite";
2
2
  import { resolve } from "node:path";
3
- import { spawn } from "node:child_process";
4
- import { existsSync, writeFileSync } from "node:fs";
3
+ import { spawn, execSync } from "node:child_process";
4
+ import { existsSync, writeFileSync, readFileSync } from "node:fs";
5
+ import { preRenderPPRShell, findBuildLayoutFiles, PPR_CACHE_SUBDIR } from "../ssr/ppr.js";
6
+ import type { RouteManifest } from "../router/manifest.js";
5
7
 
6
8
  interface BuildOptions {
7
9
  cwd: string;
@@ -165,6 +167,12 @@ export async function build({ cwd, skipTypecheck = false, mode = "ssr", analyze
165
167
 
166
168
  await Promise.all(tasks);
167
169
 
170
+ // Write a stable build ID for skew protection (must run after Vite so the
171
+ // route-manifest.json is in place for the content-hash fallback path).
172
+ const distDir = resolve(cwd, ".alabjs/dist");
173
+ await writeBuildId(distDir, cwd);
174
+ await buildPPRShells(distDir, cwd);
175
+
168
176
  // Bundle the offline service worker as a separate iife chunk.
169
177
  // Output: .alabjs/dist/client/_alabjs/offline-sw.js (served at /_alabjs/offline-sw.js)
170
178
  await buildOfflineSw(cwd);
@@ -172,6 +180,113 @@ export async function build({ cwd, skipTypecheck = false, mode = "ssr", analyze
172
180
  console.log("\n alab build complete → .alabjs/dist");
173
181
  }
174
182
 
183
+ /**
184
+ * Generate a stable build ID and write it to `.alabjs/dist/BUILD_ID`.
185
+ *
186
+ * Strategy (in priority order):
187
+ * 1. Git short SHA — deterministic, human-readable, zero CPU cost.
188
+ * 2. Rust FNV-1a hash of the route-manifest JSON via `@alabjs/compiler`
189
+ * (napi binary) — content-addressed, no git required.
190
+ * 3. Base-36 millisecond timestamp — last resort when both git and napi
191
+ * are unavailable (e.g. first-time contributor without Rust toolchain).
192
+ */
193
+ async function writeBuildId(distDir: string, cwd: string): Promise<void> {
194
+ let buildId: string;
195
+
196
+ // 1. Git SHA (preferred — zero cost, guaranteed unique per commit)
197
+ try {
198
+ buildId = execSync("git rev-parse --short HEAD", { cwd, encoding: "utf8" }).trim();
199
+ } catch {
200
+ // 2. Rust FNV-1a hash of the route manifest (content-addressed)
201
+ try {
202
+ const manifestPath = resolve(distDir, "route-manifest.json");
203
+ const manifestContent = readFileSync(manifestPath, "utf8");
204
+ type NapiWithHash = { hashBuildId(s: string): string };
205
+ const mod = await import("@alabjs/compiler") as unknown as { default?: NapiWithHash } & NapiWithHash;
206
+ const napi: NapiWithHash = (mod.default ?? mod) as NapiWithHash;
207
+ if (typeof napi.hashBuildId === "function") {
208
+ buildId = napi.hashBuildId(manifestContent);
209
+ } else {
210
+ throw new Error("hashBuildId not available");
211
+ }
212
+ } catch {
213
+ // 3. Timestamp fallback
214
+ buildId = Date.now().toString(36);
215
+ }
216
+ }
217
+
218
+ writeFileSync(resolve(distDir, "BUILD_ID"), buildId, "utf8");
219
+ console.log(` alab build ID → ${buildId}`);
220
+ }
221
+
222
+ /**
223
+ * Pre-render static HTML shells for every page that exports `ppr = true`.
224
+ *
225
+ * Runs AFTER the Vite SSR bundle so compiled page modules are available in
226
+ * `.alabjs/dist/server/`. Each shell is saved to `.alabjs/ppr-cache/`.
227
+ */
228
+ async function buildPPRShells(distDir: string, cwd: string): Promise<void> {
229
+ const manifestPath = resolve(distDir, "route-manifest.json");
230
+ if (!existsSync(manifestPath)) return;
231
+
232
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as RouteManifest;
233
+ const pageRoutes = manifest.routes.filter((r) => r.kind === "page");
234
+ const pprCacheDir = resolve(cwd, PPR_CACHE_SUBDIR);
235
+ let count = 0;
236
+
237
+ for (const route of pageRoutes) {
238
+ const modulePath = resolve(distDir, "server", route.file);
239
+ if (!existsSync(modulePath)) continue;
240
+
241
+ // Dynamic import — module is compiled ESM, importable by Node directly.
242
+ const mod = await import(modulePath) as {
243
+ default?: unknown;
244
+ ppr?: unknown;
245
+ metadata?: Record<string, unknown>;
246
+ };
247
+
248
+ if (mod.ppr !== true) continue;
249
+ if (typeof mod.default !== "function") {
250
+ console.warn(` alab ppr: ${route.file} has no default export — skipping.`);
251
+ continue;
252
+ }
253
+
254
+ // Load layout modules (outermost → innermost).
255
+ const layoutPaths = findBuildLayoutFiles(route.file, distDir);
256
+ const layoutMods = await Promise.all(
257
+ layoutPaths.map((p) => import(resolve(distDir, "server", p))),
258
+ );
259
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
260
+ const layouts = layoutMods.map((m: any) => m.default).filter((c: unknown) => typeof c === "function");
261
+
262
+ try {
263
+ await preRenderPPRShell({
264
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
265
+ Page: mod.default as any,
266
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
267
+ layouts: layouts as any[],
268
+ shellOpts: {
269
+ metadata: (mod.metadata as never) ?? {},
270
+ paramsJson: "{}",
271
+ searchParamsJson: "{}",
272
+ routeFile: route.file,
273
+ ssr: true,
274
+ },
275
+ pprCacheDir,
276
+ routePath: route.path,
277
+ });
278
+ count++;
279
+ } catch (err) {
280
+ const msg = err instanceof Error ? err.message : String(err);
281
+ console.warn(` alab ppr: failed to pre-render ${route.path}: ${msg}`);
282
+ }
283
+ }
284
+
285
+ if (count > 0) {
286
+ console.log(` alab ppr → ${count} shell${count === 1 ? "" : "s"} written to ${PPR_CACHE_SUBDIR}`);
287
+ }
288
+ }
289
+
175
290
  /** Compile the offline service worker to a standalone iife bundle. */
176
291
  async function buildOfflineSw(cwd: string): Promise<void> {
177
292
  const swEntry = new URL("../client/offline-sw.js", import.meta.url).pathname;
@@ -63,6 +63,12 @@ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
63
63
  export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions) {
64
64
  console.log(" alab starting dev server...\n");
65
65
 
66
+ // Per-session build ID for skew protection in dev.
67
+ // A new ID is generated each time the dev server starts so that a browser
68
+ // tab left open across a restart will hard-reload on the next navigation
69
+ // rather than silently rendering with stale JS.
70
+ const devBuildId = `dev-${Date.now().toString(36)}`;
71
+
66
72
  const appDir = resolve(cwd, "app");
67
73
 
68
74
  const vite = await createServer({
@@ -459,6 +465,7 @@ export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions)
459
465
  layoutsJson,
460
466
  loadingFile,
461
467
  ssr: ssrEnabled,
468
+ buildId: devBuildId,
462
469
  });
463
470
  const shellAfter = htmlShellAfter({});
464
471
  const rawHtml = `${shellBefore}${ssrContent}${shellAfter}`;
@@ -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,5 @@ 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";
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,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
- // 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);
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
- // 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;")}" />`;
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, "&quot;")}" />`;
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 ────────────────────────────────────────────