alabjs 0.2.6 → 0.3.0-alpha.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 +5 -1
- package/dist/analytics/handler.d.ts.map +1 -1
- package/dist/analytics/handler.js +14 -10
- package/dist/analytics/handler.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/client/hooks.d.ts +9 -1
- package/dist/client/hooks.d.ts.map +1 -1
- package/dist/client/hooks.js +26 -2
- package/dist/client/hooks.js.map +1 -1
- package/dist/commands/build.d.ts.map +1 -1
- package/dist/commands/build.js +163 -43
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/start.js +1 -1
- package/dist/commands/start.js.map +1 -1
- package/dist/components/Image.d.ts +0 -12
- package/dist/components/Image.d.ts.map +1 -1
- package/dist/components/Image.js +2 -29
- package/dist/components/Image.js.map +1 -1
- package/dist/components/ImageServer.d.ts +20 -0
- package/dist/components/ImageServer.d.ts.map +1 -0
- package/dist/components/ImageServer.js +37 -0
- package/dist/components/ImageServer.js.map +1 -0
- package/dist/components/index.d.ts +1 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/index.js.map +1 -1
- package/dist/server/app.d.ts.map +1 -1
- package/dist/server/app.js +193 -43
- package/dist/server/app.js.map +1 -1
- package/dist/server/cache.d.ts.map +1 -1
- package/dist/server/cache.js +23 -1
- package/dist/server/cache.js.map +1 -1
- package/dist/server/csrf.d.ts.map +1 -1
- package/dist/server/csrf.js +5 -0
- package/dist/server/csrf.js.map +1 -1
- package/dist/ssr/html.d.ts.map +1 -1
- package/dist/ssr/html.js +15 -0
- package/dist/ssr/html.js.map +1 -1
- package/dist/ssr/ppr.d.ts.map +1 -1
- package/dist/ssr/ppr.js +2 -1
- package/dist/ssr/ppr.js.map +1 -1
- package/package.json +8 -3
- package/src/analytics/handler.ts +15 -10
- package/src/cli.ts +3 -1
- package/src/client/hooks.ts +30 -2
- package/src/commands/build.ts +182 -47
- package/src/commands/start.ts +1 -1
- package/src/components/Image.tsx +2 -35
- package/src/components/ImageServer.ts +43 -0
- package/src/components/index.ts +1 -1
- package/src/server/app.ts +210 -44
- package/src/server/cache.ts +23 -1
- package/src/server/csrf.ts +5 -0
- package/src/ssr/html.ts +15 -0
- package/src/ssr/ppr.ts +2 -1
- package/tsconfig.tsbuildinfo +1 -1
package/src/components/Image.tsx
CHANGED
|
@@ -104,38 +104,5 @@ export function Image({
|
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
*
|
|
110
|
-
* Calls the Rust napi binding to resize the image to 8px wide and encode it
|
|
111
|
-
* as a tiny WebP, then Base64-encodes it into a data URL ready for `blurDataURL`.
|
|
112
|
-
*
|
|
113
|
-
* Run this in a server function — it reads from disk and must not run in the browser.
|
|
114
|
-
*
|
|
115
|
-
* @param src - Path relative to `public/` (e.g. `"/hero.jpg"`)
|
|
116
|
-
* @param publicDir - Absolute path to the `public/` directory
|
|
117
|
-
*/
|
|
118
|
-
export async function generateBlurPlaceholder(
|
|
119
|
-
src: string,
|
|
120
|
-
publicDir: string,
|
|
121
|
-
): Promise<string> {
|
|
122
|
-
const { readFile } = await import("node:fs/promises");
|
|
123
|
-
const { resolve } = await import("node:path");
|
|
124
|
-
|
|
125
|
-
const safeSrc = src.replace(/\.\./g, "").replace(/^\/+/, "");
|
|
126
|
-
const filePath = resolve(publicDir, safeSrc);
|
|
127
|
-
|
|
128
|
-
const input = await readFile(filePath);
|
|
129
|
-
|
|
130
|
-
let napi: { optimizeImage: (b: Buffer, q: number | null, w: number | null, h: null, fmt: string) => Promise<Buffer> };
|
|
131
|
-
try {
|
|
132
|
-
napi = (await import("@alabjs/compiler")) as typeof napi;
|
|
133
|
-
} catch {
|
|
134
|
-
// napi not built — return empty string (image still loads, just no blur effect)
|
|
135
|
-
return "";
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const tiny = await napi.optimizeImage(input, 40, 8, null, "webp");
|
|
139
|
-
const b64 = Buffer.from(tiny).toString("base64");
|
|
140
|
-
return `data:image/webp;base64,${b64}`;
|
|
141
|
-
}
|
|
107
|
+
// generateBlurPlaceholder is server-only (uses node:fs/promises).
|
|
108
|
+
// Import it from "alabjs/components/server" in your server functions.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-only image utilities.
|
|
3
|
+
*
|
|
4
|
+
* Import from "alabjs/components/server" — do NOT import from "alabjs/components"
|
|
5
|
+
* because this file uses Node.js built-ins (fs, path) and must never be bundled
|
|
6
|
+
* for the browser.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate a Base64 blur-up placeholder for an image in `public/`.
|
|
11
|
+
*
|
|
12
|
+
* Calls the Rust napi binding to resize the image to 8px wide and encode it
|
|
13
|
+
* as a tiny WebP, then Base64-encodes it into a data URL ready for `blurDataURL`.
|
|
14
|
+
*
|
|
15
|
+
* Run this in a server function — it reads from disk and must not run in the browser.
|
|
16
|
+
*
|
|
17
|
+
* @param src - Path relative to `public/` (e.g. `"/hero.jpg"`)
|
|
18
|
+
* @param publicDir - Absolute path to the `public/` directory
|
|
19
|
+
*/
|
|
20
|
+
export async function generateBlurPlaceholder(
|
|
21
|
+
src: string,
|
|
22
|
+
publicDir: string,
|
|
23
|
+
): Promise<string> {
|
|
24
|
+
const { readFile } = await import("node:fs/promises");
|
|
25
|
+
const { resolve } = await import("node:path");
|
|
26
|
+
|
|
27
|
+
const safeSrc = src.replace(/\.\./g, "").replace(/^\/+/, "");
|
|
28
|
+
const filePath = resolve(publicDir, safeSrc);
|
|
29
|
+
|
|
30
|
+
const input = await readFile(filePath);
|
|
31
|
+
|
|
32
|
+
let napi: { optimizeImage: (b: Buffer, q: number | null, w: number | null, h: null, fmt: string) => Promise<Buffer> };
|
|
33
|
+
try {
|
|
34
|
+
napi = (await import("@alabjs/compiler")) as typeof napi;
|
|
35
|
+
} catch {
|
|
36
|
+
// napi not built — return empty string (image still loads, just no blur effect)
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const tiny = await napi.optimizeImage(input, 40, 8, null, "webp");
|
|
41
|
+
const b64 = Buffer.from(tiny).toString("base64");
|
|
42
|
+
return `data:image/webp;base64,${b64}`;
|
|
43
|
+
}
|
package/src/components/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, readFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, createReadStream, statSync, readFileSync, readdirSync } from "node:fs";
|
|
5
5
|
import { createGzip, createBrotliCompress } from "node:zlib";
|
|
6
6
|
import { toNodeListener } from "h3";
|
|
7
7
|
import type { RouteManifest } from "../router/manifest.js";
|
|
@@ -19,20 +19,48 @@ import { handleVitalsBeacon, handleAnalyticsDashboard } from "../analytics/handl
|
|
|
19
19
|
import { buildImportMap } from "../config.js";
|
|
20
20
|
import type { FederationConfig } from "../config.js";
|
|
21
21
|
|
|
22
|
+
/** Walk dist/server recursively and collect all *.server.js paths (compiled server functions). */
|
|
23
|
+
function findDistServerFiles(distDir: string): string[] {
|
|
24
|
+
const serverDir = join(distDir, "server");
|
|
25
|
+
const results: string[] = [];
|
|
26
|
+
function walk(dir: string) {
|
|
27
|
+
try {
|
|
28
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
29
|
+
const fullPath = join(dir, entry.name);
|
|
30
|
+
if (entry.isDirectory()) {
|
|
31
|
+
walk(fullPath);
|
|
32
|
+
} else if (entry.isFile() && entry.name.endsWith(".server.js")) {
|
|
33
|
+
results.push(fullPath);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch { /* not readable */ }
|
|
37
|
+
}
|
|
38
|
+
walk(serverDir);
|
|
39
|
+
return results;
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
/**
|
|
23
43
|
* Find layout file paths (relative to cwd root) for a given route.file, ordered outermost→innermost.
|
|
24
44
|
* Checks the compiled dist directory for the existence of each layout.
|
|
25
45
|
*/
|
|
46
|
+
/** Convert a TypeScript source path to its compiled .js equivalent. */
|
|
47
|
+
function toJsPath(p: string): string {
|
|
48
|
+
return p.replace(/\.(tsx?)$/, ".js");
|
|
49
|
+
}
|
|
50
|
+
|
|
26
51
|
function findProdLayoutFiles(routeFile: string, distDir: string): string[] {
|
|
27
52
|
// routeFile is like "app/users/[id]/page.tsx"
|
|
53
|
+
// Returns source paths (e.g. "app/layout.tsx") so the client bootstrap can look
|
|
54
|
+
// them up in LAYOUT_MODS by their original source key. The import() call in
|
|
55
|
+
// app.ts uses toJsPath() to convert back to the compiled .js path.
|
|
28
56
|
const pageDir = dirname(routeFile);
|
|
29
57
|
const parts = pageDir.split("/");
|
|
30
58
|
const layouts: string[] = [];
|
|
31
59
|
for (let i = 1; i <= parts.length; i++) {
|
|
32
60
|
const dir = parts.slice(0, i).join("/");
|
|
33
|
-
const
|
|
34
|
-
if (existsSync(join(distDir, "server",
|
|
35
|
-
layouts.push(
|
|
61
|
+
const compiledPath = `${dir}/layout.js`;
|
|
62
|
+
if (existsSync(join(distDir, "server", compiledPath))) {
|
|
63
|
+
layouts.push(`${dir}/layout.tsx`);
|
|
36
64
|
}
|
|
37
65
|
}
|
|
38
66
|
return layouts;
|
|
@@ -44,7 +72,7 @@ function findProdLayoutFiles(routeFile: string, distDir: string): string[] {
|
|
|
44
72
|
function findProdErrorFile(routeFile: string, distDir: string): string | null {
|
|
45
73
|
let dir = dirname(routeFile);
|
|
46
74
|
while (dir.length > 0 && dir !== ".") {
|
|
47
|
-
const candidate = `${dir}/error.
|
|
75
|
+
const candidate = `${dir}/error.js`;
|
|
48
76
|
if (existsSync(join(distDir, "server", candidate))) return candidate;
|
|
49
77
|
const parent = dirname(dir);
|
|
50
78
|
if (parent === dir) break;
|
|
@@ -56,8 +84,8 @@ function findProdErrorFile(routeFile: string, distDir: string): string | null {
|
|
|
56
84
|
function findProdLoadingFile(routeFile: string, distDir: string): string | null {
|
|
57
85
|
let dir = dirname(routeFile);
|
|
58
86
|
while (dir.length > 0 && dir !== ".") {
|
|
59
|
-
const
|
|
60
|
-
if (existsSync(join(distDir, "server",
|
|
87
|
+
const compiled = `${dir}/loading.js`;
|
|
88
|
+
if (existsSync(join(distDir, "server", compiled))) return `${dir}/loading.tsx`;
|
|
61
89
|
const parent = dirname(dir);
|
|
62
90
|
if (parent === dir) break;
|
|
63
91
|
dir = parent;
|
|
@@ -89,6 +117,21 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
89
117
|
buildId = readFileSync(resolve(distDir, "BUILD_ID"), "utf8").trim() || undefined;
|
|
90
118
|
} catch { /* no BUILD_ID file — skew protection disabled */ }
|
|
91
119
|
|
|
120
|
+
// Resolve the compiled client entry file path by reading the Vite manifest.
|
|
121
|
+
// /@alabjs/client is a virtual module at build time; at runtime the server
|
|
122
|
+
// must redirect requests for it to the hashed asset file.
|
|
123
|
+
// The manifest key is a relative path ending in "@alabjs/client" (not "/@alabjs/client").
|
|
124
|
+
let clientEntryPath = "";
|
|
125
|
+
try {
|
|
126
|
+
const viteManifest = JSON.parse(
|
|
127
|
+
readFileSync(resolve(distDir, "client/.vite/manifest.json"), "utf8"),
|
|
128
|
+
) as Record<string, { file?: string; isEntry?: boolean; src?: string }>;
|
|
129
|
+
const entry = Object.values(viteManifest).find(
|
|
130
|
+
(e) => e.isEntry && e.src?.endsWith("@alabjs/client"),
|
|
131
|
+
);
|
|
132
|
+
if (entry?.file) clientEntryPath = "/" + entry.file;
|
|
133
|
+
} catch { /* manifest absent — /@alabjs/client will 404 */ }
|
|
134
|
+
|
|
92
135
|
// Load federation config written by `alab build`. Used to:
|
|
93
136
|
// 1. Serve `/_alabjs/federation-manifest.json` (remote discovery)
|
|
94
137
|
// 2. Inject `<script type="importmap">` into every page (host → remote routing)
|
|
@@ -112,6 +155,16 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
112
155
|
res.setHeader("referrer-policy", "strict-origin-when-cross-origin");
|
|
113
156
|
res.setHeader("permissions-policy", "camera=(), microphone=(), geolocation=()");
|
|
114
157
|
res.setHeader("x-permitted-cross-domain-policies", "none");
|
|
158
|
+
// NOTE: 'unsafe-inline' is required by React's inline event delegation and
|
|
159
|
+
// Tailwind's runtime style injection. 'unsafe-eval' is required by some
|
|
160
|
+
// React dev-mode internals and dynamic import().
|
|
161
|
+
//
|
|
162
|
+
// ⚠️ Security implication: these directives weaken XSS protection.
|
|
163
|
+
// In production, override this header in your middleware with a nonce-based
|
|
164
|
+
// CSP: `script-src 'self' 'nonce-<random>'` and inject the same nonce into
|
|
165
|
+
// every <script> tag via renderToResponse's headExtra option. The CSRF
|
|
166
|
+
// double-submit pattern relies on XSS prevention — using 'unsafe-inline'
|
|
167
|
+
// without a nonce makes the CSRF token readable by injected scripts.
|
|
115
168
|
res.setHeader(
|
|
116
169
|
"content-security-policy",
|
|
117
170
|
[
|
|
@@ -126,7 +179,6 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
126
179
|
"base-uri 'self'",
|
|
127
180
|
"form-action 'self'",
|
|
128
181
|
"frame-ancestors 'self'",
|
|
129
|
-
"upgrade-insecure-requests",
|
|
130
182
|
].join("; "),
|
|
131
183
|
);
|
|
132
184
|
// HSTS — only meaningful over HTTPS; set in production only.
|
|
@@ -134,12 +186,18 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
134
186
|
}),
|
|
135
187
|
);
|
|
136
188
|
|
|
137
|
-
// ─── User middleware (middleware.ts compiled to dist/server/middleware.
|
|
138
|
-
const middlewareModulePath = `${distDir}/server/middleware.
|
|
189
|
+
// ─── User middleware (middleware.ts compiled to dist/server/middleware.js) ───
|
|
190
|
+
const middlewareModulePath = `${distDir}/server/middleware.js`;
|
|
139
191
|
if (existsSync(middlewareModulePath)) {
|
|
192
|
+
// Cache the module after first import — avoids redundant dynamic import()
|
|
193
|
+
// overhead on every request (each import() call re-resolves the module graph).
|
|
194
|
+
let _middlewareCache: MiddlewareModule | null = null;
|
|
140
195
|
app.use(
|
|
141
196
|
defineEventHandler(async (event) => {
|
|
142
|
-
|
|
197
|
+
if (!_middlewareCache) {
|
|
198
|
+
_middlewareCache = await import(middlewareModulePath) as MiddlewareModule;
|
|
199
|
+
}
|
|
200
|
+
const mod = _middlewareCache;
|
|
143
201
|
if (typeof mod.middleware !== "function") return;
|
|
144
202
|
const req = event.node.req;
|
|
145
203
|
const res = event.node.res;
|
|
@@ -193,7 +251,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
193
251
|
};
|
|
194
252
|
|
|
195
253
|
app.use(
|
|
196
|
-
defineEventHandler((event) => {
|
|
254
|
+
defineEventHandler(async (event) => {
|
|
197
255
|
const req = event.node.req;
|
|
198
256
|
const res = event.node.res;
|
|
199
257
|
if (req.method !== "GET" && req.method !== "HEAD") return;
|
|
@@ -204,22 +262,42 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
204
262
|
try { relPath = decodeURIComponent(rawPath); } catch { return; }
|
|
205
263
|
if (relPath.includes("..")) return;
|
|
206
264
|
|
|
207
|
-
const ext = extname(relPath).toLowerCase();
|
|
208
|
-
const contentType = MIME_TYPES[ext];
|
|
209
|
-
if (!contentType) return; // skip extensionless paths (page routes)
|
|
210
|
-
|
|
211
265
|
const acceptEncoding = (req.headers["accept-encoding"] ?? "") as string;
|
|
212
266
|
const useBrotli = acceptEncoding.includes("br");
|
|
213
267
|
const useGzip = !useBrotli && acceptEncoding.includes("gzip");
|
|
214
268
|
|
|
215
|
-
|
|
269
|
+
// Virtual client entry — redirect to the hashed asset file resolved at startup.
|
|
270
|
+
// A 302 redirect (not direct serve) is critical: relative imports in the bundle
|
|
271
|
+
// (e.g. "./components-HASH.js") must resolve against the real asset URL
|
|
272
|
+
// (/assets/client-HASH.js), not the virtual path (/@alabjs/client).
|
|
273
|
+
if (relPath === "/@alabjs/client") {
|
|
274
|
+
if (clientEntryPath) {
|
|
275
|
+
res.writeHead(302, { Location: clientEntryPath });
|
|
276
|
+
} else {
|
|
277
|
+
res.statusCode = 404;
|
|
278
|
+
}
|
|
279
|
+
res.end();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const ext = extname(relPath).toLowerCase();
|
|
284
|
+
const contentType = MIME_TYPES[ext];
|
|
285
|
+
if (!contentType) return; // skip extensionless paths (page routes)
|
|
286
|
+
|
|
287
|
+
/** Stream a file with optional brotli/gzip compression and ETag 304 support.
|
|
288
|
+
*
|
|
289
|
+
* Returns a Promise that resolves when the response is fully sent. The
|
|
290
|
+
* h3 handler awaits this promise so h3 knows the response is complete
|
|
291
|
+
* before considering the next middleware. This avoids h3 passing the
|
|
292
|
+
* request to the router (which would 404) while the async pipe is running.
|
|
293
|
+
*/
|
|
216
294
|
function serveFile(
|
|
217
295
|
filePath: string,
|
|
218
296
|
fileSize: number,
|
|
219
297
|
mtimeMs: number,
|
|
220
298
|
cacheControl: string,
|
|
221
299
|
mime: string,
|
|
222
|
-
):
|
|
300
|
+
): Promise<void> {
|
|
223
301
|
// ETag from file size + mtime — both already known from the caller's stat().
|
|
224
302
|
const etag = `"${fileSize.toString(36)}-${mtimeMs.toString(36)}"`;
|
|
225
303
|
res.setHeader("etag", etag);
|
|
@@ -228,26 +306,29 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
228
306
|
if (req.headers["if-none-match"] === etag) {
|
|
229
307
|
res.statusCode = 304;
|
|
230
308
|
res.end();
|
|
231
|
-
return
|
|
309
|
+
return Promise.resolve();
|
|
232
310
|
}
|
|
233
311
|
|
|
234
312
|
res.setHeader("content-type", mime);
|
|
235
313
|
res.setHeader("cache-control", cacheControl);
|
|
236
314
|
|
|
237
|
-
if (req.method === "HEAD") { res.end(); return
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
res.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
315
|
+
if (req.method === "HEAD") { res.end(); return Promise.resolve(); }
|
|
316
|
+
|
|
317
|
+
return new Promise<void>((resolve, reject) => {
|
|
318
|
+
const fileStream = createReadStream(filePath);
|
|
319
|
+
res.on("finish", resolve);
|
|
320
|
+
res.on("error", reject);
|
|
321
|
+
if (useBrotli) {
|
|
322
|
+
res.setHeader("content-encoding", "br");
|
|
323
|
+
fileStream.pipe(createBrotliCompress()).pipe(res);
|
|
324
|
+
} else if (useGzip) {
|
|
325
|
+
res.setHeader("content-encoding", "gzip");
|
|
326
|
+
fileStream.pipe(createGzip()).pipe(res);
|
|
327
|
+
} else {
|
|
328
|
+
res.setHeader("content-length", fileSize);
|
|
329
|
+
fileStream.pipe(res);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
251
332
|
}
|
|
252
333
|
|
|
253
334
|
// 1. Built client assets (JS chunks, CSS, source maps)
|
|
@@ -256,13 +337,14 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
256
337
|
const stat = statSync(clientCandidate);
|
|
257
338
|
if (stat.isFile()) {
|
|
258
339
|
const isHashed = /\.[a-f0-9]{8,}\.[a-z]+$/.test(relPath);
|
|
259
|
-
|
|
340
|
+
await serveFile(
|
|
260
341
|
clientCandidate,
|
|
261
342
|
stat.size,
|
|
262
343
|
stat.mtimeMs,
|
|
263
344
|
isHashed ? "public, max-age=31536000, immutable" : "public, max-age=3600",
|
|
264
345
|
contentType,
|
|
265
346
|
);
|
|
347
|
+
return;
|
|
266
348
|
}
|
|
267
349
|
}
|
|
268
350
|
|
|
@@ -271,10 +353,10 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
271
353
|
if (existsSync(publicCandidate)) {
|
|
272
354
|
const stat = statSync(publicCandidate);
|
|
273
355
|
if (stat.isFile()) {
|
|
274
|
-
|
|
356
|
+
await serveFile(publicCandidate, stat.size, stat.mtimeMs, "public, max-age=3600", contentType);
|
|
357
|
+
return;
|
|
275
358
|
}
|
|
276
359
|
}
|
|
277
|
-
return undefined;
|
|
278
360
|
}),
|
|
279
361
|
);
|
|
280
362
|
|
|
@@ -346,6 +428,87 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
346
428
|
}),
|
|
347
429
|
);
|
|
348
430
|
|
|
431
|
+
// ─── Server function endpoints ──────────────────────────────────────────────
|
|
432
|
+
// GET /_alabjs/data/:fn — used by useServerData (query params as input)
|
|
433
|
+
// POST /_alabjs/fn/:fn — used by useMutation (JSON body as input)
|
|
434
|
+
//
|
|
435
|
+
// Both scan all *.server.js files in dist/server for the named export.
|
|
436
|
+
// Module results are NOT cached — the module cache is Node's own require cache.
|
|
437
|
+
|
|
438
|
+
async function callServerFn(
|
|
439
|
+
fnName: string,
|
|
440
|
+
ctx: { params: Record<string, string>; query: Record<string, string>; headers: Record<string, string | string[] | undefined>; method: string; url: string },
|
|
441
|
+
input: unknown,
|
|
442
|
+
res: import("node:http").ServerResponse,
|
|
443
|
+
): Promise<void> {
|
|
444
|
+
const serverFiles = findDistServerFiles(distDir);
|
|
445
|
+
for (const file of serverFiles) {
|
|
446
|
+
const mod = await import(file) as Record<string, unknown>;
|
|
447
|
+
if (typeof mod[fnName] === "function") {
|
|
448
|
+
try {
|
|
449
|
+
const result = await (mod[fnName] as (c: unknown, i: unknown) => Promise<unknown>)(ctx, input);
|
|
450
|
+
res.statusCode = 200;
|
|
451
|
+
res.setHeader("content-type", "application/json");
|
|
452
|
+
res.end(JSON.stringify(result));
|
|
453
|
+
} catch (err) {
|
|
454
|
+
const zodError = (err as Record<string, unknown>)?.["zodError"];
|
|
455
|
+
if (zodError) {
|
|
456
|
+
res.statusCode = 422;
|
|
457
|
+
res.setHeader("content-type", "application/json");
|
|
458
|
+
res.end(JSON.stringify({ zodError }));
|
|
459
|
+
} else {
|
|
460
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
461
|
+
console.error(`[alabjs] server fn "${fnName}" threw:`, err);
|
|
462
|
+
res.statusCode = 500;
|
|
463
|
+
res.setHeader("content-type", "application/json");
|
|
464
|
+
res.end(JSON.stringify({ error: msg }));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
res.statusCode = 404;
|
|
471
|
+
res.setHeader("content-type", "application/json");
|
|
472
|
+
res.end(JSON.stringify({ error: `[alabjs] server function not found: ${fnName}` }));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
router.get(
|
|
476
|
+
"/_alabjs/data/:fn",
|
|
477
|
+
defineEventHandler(async (event) => {
|
|
478
|
+
const fnName = event.context.params?.["fn"] ?? "";
|
|
479
|
+
const req = event.node.req;
|
|
480
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
481
|
+
const query = Object.fromEntries(url.searchParams.entries());
|
|
482
|
+
const ctx = {
|
|
483
|
+
params: query,
|
|
484
|
+
query,
|
|
485
|
+
headers: req.headers as Record<string, string>,
|
|
486
|
+
method: "GET",
|
|
487
|
+
url: req.url ?? "/",
|
|
488
|
+
};
|
|
489
|
+
await callServerFn(fnName, ctx, Object.keys(query).length ? query : undefined, event.node.res);
|
|
490
|
+
}),
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
router.post(
|
|
494
|
+
"/_alabjs/fn/:fn",
|
|
495
|
+
defineEventHandler(async (event) => {
|
|
496
|
+
const fnName = event.context.params?.["fn"] ?? "";
|
|
497
|
+
const req = event.node.req;
|
|
498
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
499
|
+
const query = Object.fromEntries(url.searchParams.entries());
|
|
500
|
+
const body = await readBody(event);
|
|
501
|
+
const ctx = {
|
|
502
|
+
params: query,
|
|
503
|
+
query,
|
|
504
|
+
headers: req.headers as Record<string, string>,
|
|
505
|
+
method: "POST",
|
|
506
|
+
url: req.url ?? "/",
|
|
507
|
+
};
|
|
508
|
+
await callServerFn(fnName, ctx, body, event.node.res);
|
|
509
|
+
}),
|
|
510
|
+
);
|
|
511
|
+
|
|
349
512
|
// Auto sitemap.xml from route manifest
|
|
350
513
|
router.get(
|
|
351
514
|
"/sitemap.xml",
|
|
@@ -365,7 +528,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
365
528
|
if (route.kind !== "api") continue;
|
|
366
529
|
|
|
367
530
|
const h3ApiPath = route.path.replace(/\[([^\]]+)\]/g, ":$1");
|
|
368
|
-
const apiModulePath = `${distDir}/server/${route.file}`;
|
|
531
|
+
const apiModulePath = `${distDir}/server/${toJsPath(route.file)}`;
|
|
369
532
|
|
|
370
533
|
for (const method of ["get", "post", "put", "patch", "delete", "head"] as const) {
|
|
371
534
|
router[method](
|
|
@@ -454,11 +617,11 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
454
617
|
}
|
|
455
618
|
|
|
456
619
|
// Dynamically import the compiled page module from the dist directory.
|
|
457
|
-
const pageModulePath = `${distDir}/server/${route.file}`;
|
|
620
|
+
const pageModulePath = `${distDir}/server/${toJsPath(route.file)}`;
|
|
458
621
|
const mod = await import(pageModulePath) as {
|
|
459
622
|
default?: unknown;
|
|
460
623
|
metadata?: PageMetadata;
|
|
461
|
-
generateMetadata?: (params: Record<string, string>) => PageMetadata | Promise<PageMetadata>;
|
|
624
|
+
generateMetadata?: (props: { params: Record<string, string>; searchParams: Record<string, string> }) => PageMetadata | Promise<PageMetadata>;
|
|
462
625
|
ssr?: boolean;
|
|
463
626
|
cdnCache?: CdnCache;
|
|
464
627
|
ppr?: boolean;
|
|
@@ -499,7 +662,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
499
662
|
// Support both static metadata and dynamic generateMetadata (production fix)
|
|
500
663
|
const metadata: PageMetadata =
|
|
501
664
|
typeof mod.generateMetadata === "function"
|
|
502
|
-
? await mod.generateMetadata(params)
|
|
665
|
+
? await mod.generateMetadata({ params, searchParams })
|
|
503
666
|
: (mod.metadata ?? {});
|
|
504
667
|
|
|
505
668
|
const ssrEnabled = mod.ssr === true;
|
|
@@ -507,7 +670,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
507
670
|
// ── Layouts ──────────────────────────────────────────────────────────
|
|
508
671
|
const layoutRelPaths = findProdLayoutFiles(route.file, distDir);
|
|
509
672
|
const layoutMods = await Promise.all(
|
|
510
|
-
layoutRelPaths.map((p) => import(`${distDir}/server/${p}`)),
|
|
673
|
+
layoutRelPaths.map((p) => import(`${distDir}/server/${toJsPath(p)}`)),
|
|
511
674
|
);
|
|
512
675
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
513
676
|
const layouts = layoutMods.map((m: any) => m.default).filter((c: unknown): c is any => typeof c === "function");
|
|
@@ -550,7 +713,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
550
713
|
if (errorRelPath) {
|
|
551
714
|
try {
|
|
552
715
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
553
|
-
const errorMod = await import(`${distDir}/server/${errorRelPath}`) as any;
|
|
716
|
+
const errorMod = await import(`${distDir}/server/${toJsPath(errorRelPath)}`) as any;
|
|
554
717
|
const ErrorPage = errorMod.default;
|
|
555
718
|
if (typeof ErrorPage === "function") {
|
|
556
719
|
renderToResponse(res, {
|
|
@@ -582,7 +745,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
582
745
|
app.use(router);
|
|
583
746
|
|
|
584
747
|
// ─── 404 / not-found fallback ────────────────────────────────────────────────
|
|
585
|
-
const notFoundPath = `${distDir}/server/app/not-found.
|
|
748
|
+
const notFoundPath = `${distDir}/server/app/not-found.js`;
|
|
586
749
|
app.use(
|
|
587
750
|
defineEventHandler(async (event) => {
|
|
588
751
|
const res = event.node.res;
|
|
@@ -618,8 +781,11 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
618
781
|
|
|
619
782
|
return {
|
|
620
783
|
listen(port = 3000) {
|
|
784
|
+
// Make the server's base URL available to useServerData during SSR so it
|
|
785
|
+
// can construct an absolute URL for its internal loop-back fetch.
|
|
786
|
+
process.env["ALAB_ORIGIN"] = `http://127.0.0.1:${port}`;
|
|
621
787
|
const server = createServer(toNodeListener(app));
|
|
622
|
-
server.listen(port, () => {
|
|
788
|
+
server.listen(port, "0.0.0.0", () => {
|
|
623
789
|
console.log(` alab ready at http://localhost:${port}`);
|
|
624
790
|
});
|
|
625
791
|
},
|
package/src/server/cache.ts
CHANGED
|
@@ -28,7 +28,18 @@ interface CacheEntry {
|
|
|
28
28
|
/** Sentinel value returned when a cache key has no valid entry. */
|
|
29
29
|
const CACHE_MISS: unique symbol = Symbol("alab:cache_miss");
|
|
30
30
|
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Global in-process LRU cache. Shared across all server function calls.
|
|
33
|
+
*
|
|
34
|
+
* Max size is capped to prevent unbounded memory growth in long-running servers.
|
|
35
|
+
* When the cap is reached, the oldest entry (by insertion order) is evicted.
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ Multi-tenant note: this cache is process-wide and shared across all
|
|
38
|
+
* requests. In multi-tenant deployments, cache keys MUST include a tenant
|
|
39
|
+
* identifier (e.g. `tenant:${tenantId}:posts`) to prevent data leakage
|
|
40
|
+
* between tenants.
|
|
41
|
+
*/
|
|
42
|
+
const _STORE_MAX = 2048;
|
|
32
43
|
const _store = new Map<string, CacheEntry>();
|
|
33
44
|
|
|
34
45
|
export { CACHE_MISS };
|
|
@@ -41,6 +52,9 @@ export function getCached(key: string): unknown | typeof CACHE_MISS {
|
|
|
41
52
|
_store.delete(key);
|
|
42
53
|
return CACHE_MISS;
|
|
43
54
|
}
|
|
55
|
+
// Move to end (LRU: mark as recently used)
|
|
56
|
+
_store.delete(key);
|
|
57
|
+
_store.set(key, entry);
|
|
44
58
|
return entry.data;
|
|
45
59
|
}
|
|
46
60
|
|
|
@@ -50,6 +64,10 @@ export function setCache(
|
|
|
50
64
|
data: unknown,
|
|
51
65
|
opts: { ttl: number; tags?: string[] },
|
|
52
66
|
): void {
|
|
67
|
+
// Evict oldest entry when at capacity
|
|
68
|
+
if (_store.size >= _STORE_MAX && !_store.has(key)) {
|
|
69
|
+
_store.delete(_store.keys().next().value!);
|
|
70
|
+
}
|
|
53
71
|
_store.set(key, {
|
|
54
72
|
data,
|
|
55
73
|
expires: Date.now() + opts.ttl * 1_000,
|
|
@@ -95,6 +113,7 @@ interface PageCacheEntry {
|
|
|
95
113
|
tags: string[];
|
|
96
114
|
}
|
|
97
115
|
|
|
116
|
+
const _PAGE_STORE_MAX = 1024;
|
|
98
117
|
const _pageStore = new Map<string, PageCacheEntry>();
|
|
99
118
|
|
|
100
119
|
/**
|
|
@@ -118,6 +137,9 @@ export function getCachedPage(pathname: string): { html: string; stale: boolean
|
|
|
118
137
|
|
|
119
138
|
/** Store a rendered HTML page with a TTL (seconds). */
|
|
120
139
|
export function setCachedPage(pathname: string, html: string, ttl: number, tags: string[] = []): void {
|
|
140
|
+
if (_pageStore.size >= _PAGE_STORE_MAX && !_pageStore.has(pathname)) {
|
|
141
|
+
_pageStore.delete(_pageStore.keys().next().value!);
|
|
142
|
+
}
|
|
121
143
|
_pageStore.set(pathname, { html, expires: Date.now() + ttl * 1_000, ttl, revalidating: false, tags });
|
|
122
144
|
}
|
|
123
145
|
|
package/src/server/csrf.ts
CHANGED
|
@@ -31,6 +31,11 @@ export function csrfMiddleware() {
|
|
|
31
31
|
const method = event.method.toUpperCase();
|
|
32
32
|
if (SAFE_METHODS.has(method)) return;
|
|
33
33
|
|
|
34
|
+
// Internal endpoints that use their own auth (Bearer token, no cookie session)
|
|
35
|
+
// don't need CSRF protection.
|
|
36
|
+
const path = (event.node.req.url ?? "").split("?")[0] ?? "";
|
|
37
|
+
if (path === "/_alabjs/revalidate" || path === "/_alabjs/vitals") return;
|
|
38
|
+
|
|
34
39
|
const cookieToken = getCookie(event, CSRF_COOKIE);
|
|
35
40
|
const headerToken = getHeader(event, CSRF_HEADER);
|
|
36
41
|
|
package/src/ssr/html.ts
CHANGED
|
@@ -104,7 +104,22 @@ export function htmlShellBefore(opts: HtmlShellOptions): string {
|
|
|
104
104
|
/** Build the closing HTML fragment — everything after the SSR content. */
|
|
105
105
|
export function htmlShellAfter(opts: { nonce?: string | undefined }): string {
|
|
106
106
|
const nonceAttr = opts.nonce ? ` nonce="${escAttr(opts.nonce)}"` : "";
|
|
107
|
+
// The Rust compiler (oxc_transformer::enable_all) always emits $RefreshReg$ /
|
|
108
|
+
// $RefreshSig$ calls into TSX files, even in production builds. These are
|
|
109
|
+
// React Fast Refresh globals that only exist when the dev preamble is injected.
|
|
110
|
+
// In production there is no preamble, so the calls throw ReferenceError which
|
|
111
|
+
// silently aborts module loading and prevents React from mounting.
|
|
112
|
+
//
|
|
113
|
+
// A classic (non-module) <script> executes synchronously during HTML parsing,
|
|
114
|
+
// before any <script type="module"> is evaluated (modules are always deferred).
|
|
115
|
+
// Defining no-op shims here guarantees they exist before any page chunk runs.
|
|
116
|
+
const refreshShim = `<script${nonceAttr}>` +
|
|
117
|
+
`if(typeof $RefreshReg$==="undefined"){` +
|
|
118
|
+
`window.$RefreshReg$=function(){};` +
|
|
119
|
+
`window.$RefreshSig$=function(){return function(x){return x};}` +
|
|
120
|
+
`}</script>`;
|
|
107
121
|
return `</div>
|
|
122
|
+
${refreshShim}
|
|
108
123
|
<script type="module" src="/@alabjs/client"${nonceAttr}></script>
|
|
109
124
|
</body>
|
|
110
125
|
</html>`;
|
package/src/ssr/ppr.ts
CHANGED
|
@@ -158,7 +158,8 @@ export function findBuildLayoutFiles(routeFile: string, distDir: string): string
|
|
|
158
158
|
const layouts: string[] = [];
|
|
159
159
|
for (let i = 1; i <= parts.length; i++) {
|
|
160
160
|
const dir = parts.slice(0, i).join("/");
|
|
161
|
-
|
|
161
|
+
// esbuild compiles layout.tsx → layout.js in the dist/server tree.
|
|
162
|
+
const candidate = `${dir}/layout.js`;
|
|
162
163
|
if (existsSync(join(distDir, "server", candidate))) {
|
|
163
164
|
layouts.push(candidate);
|
|
164
165
|
}
|