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.
Files changed (57) hide show
  1. package/dist/analytics/handler.d.ts +5 -1
  2. package/dist/analytics/handler.d.ts.map +1 -1
  3. package/dist/analytics/handler.js +14 -10
  4. package/dist/analytics/handler.js.map +1 -1
  5. package/dist/cli.js +1 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client/hooks.d.ts +9 -1
  8. package/dist/client/hooks.d.ts.map +1 -1
  9. package/dist/client/hooks.js +26 -2
  10. package/dist/client/hooks.js.map +1 -1
  11. package/dist/commands/build.d.ts.map +1 -1
  12. package/dist/commands/build.js +163 -43
  13. package/dist/commands/build.js.map +1 -1
  14. package/dist/commands/start.js +1 -1
  15. package/dist/commands/start.js.map +1 -1
  16. package/dist/components/Image.d.ts +0 -12
  17. package/dist/components/Image.d.ts.map +1 -1
  18. package/dist/components/Image.js +2 -29
  19. package/dist/components/Image.js.map +1 -1
  20. package/dist/components/ImageServer.d.ts +20 -0
  21. package/dist/components/ImageServer.d.ts.map +1 -0
  22. package/dist/components/ImageServer.js +37 -0
  23. package/dist/components/ImageServer.js.map +1 -0
  24. package/dist/components/index.d.ts +1 -1
  25. package/dist/components/index.d.ts.map +1 -1
  26. package/dist/components/index.js +1 -1
  27. package/dist/components/index.js.map +1 -1
  28. package/dist/server/app.d.ts.map +1 -1
  29. package/dist/server/app.js +193 -43
  30. package/dist/server/app.js.map +1 -1
  31. package/dist/server/cache.d.ts.map +1 -1
  32. package/dist/server/cache.js +23 -1
  33. package/dist/server/cache.js.map +1 -1
  34. package/dist/server/csrf.d.ts.map +1 -1
  35. package/dist/server/csrf.js +5 -0
  36. package/dist/server/csrf.js.map +1 -1
  37. package/dist/ssr/html.d.ts.map +1 -1
  38. package/dist/ssr/html.js +15 -0
  39. package/dist/ssr/html.js.map +1 -1
  40. package/dist/ssr/ppr.d.ts.map +1 -1
  41. package/dist/ssr/ppr.js +2 -1
  42. package/dist/ssr/ppr.js.map +1 -1
  43. package/package.json +8 -3
  44. package/src/analytics/handler.ts +15 -10
  45. package/src/cli.ts +3 -1
  46. package/src/client/hooks.ts +30 -2
  47. package/src/commands/build.ts +182 -47
  48. package/src/commands/start.ts +1 -1
  49. package/src/components/Image.tsx +2 -35
  50. package/src/components/ImageServer.ts +43 -0
  51. package/src/components/index.ts +1 -1
  52. package/src/server/app.ts +210 -44
  53. package/src/server/cache.ts +23 -1
  54. package/src/server/csrf.ts +5 -0
  55. package/src/ssr/html.ts +15 -0
  56. package/src/ssr/ppr.ts +2 -1
  57. package/tsconfig.tsbuildinfo +1 -1
@@ -104,38 +104,5 @@ export function Image({
104
104
  });
105
105
  }
106
106
 
107
- /**
108
- * Generate a Base64 blur-up placeholder for an image in `public/`.
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
+ }
@@ -1,4 +1,4 @@
1
- export { Image, generateBlurPlaceholder } from "./Image.js";
1
+ export { Image } from "./Image.js";
2
2
  export type { ImageProps } from "./Image.js";
3
3
  export { Link } from "./Link.js";
4
4
  export type { LinkProps } from "./Link.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, 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 layoutRelPath = `${dir}/layout.tsx`;
34
- if (existsSync(join(distDir, "server", layoutRelPath))) {
35
- layouts.push(layoutRelPath);
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.tsx`;
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 candidate = `${dir}/loading.tsx`;
60
- if (existsSync(join(distDir, "server", candidate))) return candidate;
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.ts) ───
138
- const middlewareModulePath = `${distDir}/server/middleware.ts`;
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
- const mod = await import(middlewareModulePath) as MiddlewareModule;
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
- /** Stream a file with optional brotli/gzip compression and ETag 304 support. */
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
- ): null {
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 null;
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 null; }
238
-
239
- const stream = createReadStream(filePath);
240
- if (useBrotli) {
241
- res.setHeader("content-encoding", "br");
242
- stream.pipe(createBrotliCompress()).pipe(res);
243
- } else if (useGzip) {
244
- res.setHeader("content-encoding", "gzip");
245
- stream.pipe(createGzip()).pipe(res);
246
- } else {
247
- res.setHeader("content-length", fileSize);
248
- stream.pipe(res);
249
- }
250
- return null;
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
- return serveFile(
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
- return serveFile(publicCandidate, stat.size, stat.mtimeMs, "public, max-age=3600", contentType);
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.tsx`;
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
  },
@@ -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
- /** Global in-process LRU-style cache. Shared across all server function calls. */
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
 
@@ -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
- const candidate = `${dir}/layout.tsx`;
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
  }