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
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";
|