alabjs 0.2.6 → 0.4.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 (93) 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 +279 -65
  13. package/dist/commands/build.js.map +1 -1
  14. package/dist/commands/dev.d.ts.map +1 -1
  15. package/dist/commands/dev.js +225 -2
  16. package/dist/commands/dev.js.map +1 -1
  17. package/dist/commands/start.js +1 -1
  18. package/dist/commands/start.js.map +1 -1
  19. package/dist/components/Image.d.ts +0 -12
  20. package/dist/components/Image.d.ts.map +1 -1
  21. package/dist/components/Image.js +2 -29
  22. package/dist/components/Image.js.map +1 -1
  23. package/dist/components/ImageServer.d.ts +20 -0
  24. package/dist/components/ImageServer.d.ts.map +1 -0
  25. package/dist/components/ImageServer.js +37 -0
  26. package/dist/components/ImageServer.js.map +1 -0
  27. package/dist/components/Link.d.ts +16 -0
  28. package/dist/components/Link.d.ts.map +1 -1
  29. package/dist/components/Link.js +10 -0
  30. package/dist/components/Link.js.map +1 -1
  31. package/dist/components/index.d.ts +3 -3
  32. package/dist/components/index.d.ts.map +1 -1
  33. package/dist/components/index.js +2 -2
  34. package/dist/components/index.js.map +1 -1
  35. package/dist/live/broadcaster.d.ts +64 -0
  36. package/dist/live/broadcaster.d.ts.map +1 -0
  37. package/dist/live/broadcaster.js +78 -0
  38. package/dist/live/broadcaster.js.map +1 -0
  39. package/dist/live/registry.d.ts +34 -0
  40. package/dist/live/registry.d.ts.map +1 -0
  41. package/dist/live/registry.js +33 -0
  42. package/dist/live/registry.js.map +1 -0
  43. package/dist/live/renderer.d.ts +22 -0
  44. package/dist/live/renderer.d.ts.map +1 -0
  45. package/dist/live/renderer.js +45 -0
  46. package/dist/live/renderer.js.map +1 -0
  47. package/dist/router/manifest.d.ts +1 -1
  48. package/dist/router/manifest.d.ts.map +1 -1
  49. package/dist/server/app.d.ts.map +1 -1
  50. package/dist/server/app.js +339 -43
  51. package/dist/server/app.js.map +1 -1
  52. package/dist/server/cache.d.ts.map +1 -1
  53. package/dist/server/cache.js +23 -1
  54. package/dist/server/cache.js.map +1 -1
  55. package/dist/server/csrf.d.ts.map +1 -1
  56. package/dist/server/csrf.js +5 -0
  57. package/dist/server/csrf.js.map +1 -1
  58. package/dist/server/index.d.ts +1 -0
  59. package/dist/server/index.d.ts.map +1 -1
  60. package/dist/server/index.js +1 -0
  61. package/dist/server/index.js.map +1 -1
  62. package/dist/server/revalidate.d.ts.map +1 -1
  63. package/dist/server/revalidate.js +3 -0
  64. package/dist/server/revalidate.js.map +1 -1
  65. package/dist/ssr/html.d.ts.map +1 -1
  66. package/dist/ssr/html.js +15 -0
  67. package/dist/ssr/html.js.map +1 -1
  68. package/dist/ssr/ppr.d.ts.map +1 -1
  69. package/dist/ssr/ppr.js +2 -1
  70. package/dist/ssr/ppr.js.map +1 -1
  71. package/package.json +8 -3
  72. package/src/analytics/handler.ts +15 -10
  73. package/src/cli.ts +3 -1
  74. package/src/client/hooks.ts +30 -2
  75. package/src/commands/build.ts +316 -69
  76. package/src/commands/dev.ts +246 -3
  77. package/src/commands/start.ts +1 -1
  78. package/src/components/Image.tsx +2 -35
  79. package/src/components/ImageServer.ts +43 -0
  80. package/src/components/Link.tsx +20 -0
  81. package/src/components/index.ts +3 -3
  82. package/src/live/broadcaster.ts +83 -0
  83. package/src/live/registry.ts +56 -0
  84. package/src/live/renderer.ts +54 -0
  85. package/src/router/manifest.ts +1 -1
  86. package/src/server/app.ts +369 -44
  87. package/src/server/cache.ts +23 -1
  88. package/src/server/csrf.ts +5 -0
  89. package/src/server/index.ts +1 -0
  90. package/src/server/revalidate.ts +3 -0
  91. package/src/ssr/html.ts +15 -0
  92. package/src/ssr/ppr.ts +2 -1
  93. package/tsconfig.tsbuildinfo +1 -1
package/src/server/app.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createApp as createH3App, createRouter, defineEventHandler, getQuery, readBody } from "h3";
2
2
  import { createServer } from "node:http";
3
3
  import { resolve, dirname, join, extname } from "node:path";
4
- import { existsSync, createReadStream, statSync, 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";
@@ -18,21 +18,93 @@ import { getPPRShell, injectBuildIdIntoPPRShell, PPR_CACHE_SUBDIR } from "../ssr
18
18
  import { handleVitalsBeacon, handleAnalyticsDashboard } from "../analytics/handler.js";
19
19
  import { buildImportMap } from "../config.js";
20
20
  import type { FederationConfig } from "../config.js";
21
+ import { registerLiveComponent } from "../live/registry.js";
22
+ import { renderLiveFragment, hashFragment } from "../live/renderer.js";
23
+ import { subscribeToTag } from "../live/broadcaster.js";
24
+
25
+ /** Walk dist/server recursively and collect all *.server.js paths (compiled server functions). */
26
+ function findDistServerFiles(distDir: string): string[] {
27
+ const serverDir = join(distDir, "server");
28
+ const results: string[] = [];
29
+ function walk(dir: string) {
30
+ try {
31
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
32
+ const fullPath = join(dir, entry.name);
33
+ if (entry.isDirectory()) {
34
+ walk(fullPath);
35
+ } else if (entry.isFile() && entry.name.endsWith(".server.js")) {
36
+ results.push(fullPath);
37
+ }
38
+ }
39
+ } catch { /* not readable */ }
40
+ }
41
+ walk(serverDir);
42
+ return results;
43
+ }
44
+
45
+ /** Walk dist/server recursively and register all *.live.js modules. */
46
+ async function registerAllLiveComponents(distDir: string): Promise<void> {
47
+ const serverDir = join(distDir, "server");
48
+ const files: string[] = [];
49
+
50
+ function walk(dir: string) {
51
+ try {
52
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
53
+ const fullPath = join(dir, entry.name);
54
+ if (entry.isDirectory()) {
55
+ walk(fullPath);
56
+ } else if (entry.isFile() && entry.name.endsWith(".live.js")) {
57
+ files.push(fullPath);
58
+ }
59
+ }
60
+ } catch { /* not readable */ }
61
+ }
62
+ walk(serverDir);
63
+
64
+ for (const filePath of files) {
65
+ try {
66
+ const mod = await import(filePath) as {
67
+ liveId?: string;
68
+ liveInterval?: number;
69
+ liveTags?: (props: unknown) => string[];
70
+ };
71
+ // liveId is stamped by the Vite plugin (hash of source path).
72
+ // Fall back to a hash of the file path itself.
73
+ const id = mod.liveId ?? filePath.replace(/[^a-z0-9]/gi, "").slice(-16);
74
+ registerLiveComponent({
75
+ id,
76
+ modulePath: filePath,
77
+ ...(typeof mod.liveInterval === "number" ? { liveInterval: mod.liveInterval } : {}),
78
+ ...(typeof mod.liveTags === "function" ? { liveTags: mod.liveTags } : {}),
79
+ });
80
+ } catch (err) {
81
+ console.warn(`[alabjs] live: failed to register ${filePath}:`, err);
82
+ }
83
+ }
84
+ }
21
85
 
22
86
  /**
23
87
  * Find layout file paths (relative to cwd root) for a given route.file, ordered outermost→innermost.
24
88
  * Checks the compiled dist directory for the existence of each layout.
25
89
  */
90
+ /** Convert a TypeScript source path to its compiled .js equivalent. */
91
+ function toJsPath(p: string): string {
92
+ return p.replace(/\.(tsx?)$/, ".js");
93
+ }
94
+
26
95
  function findProdLayoutFiles(routeFile: string, distDir: string): string[] {
27
96
  // routeFile is like "app/users/[id]/page.tsx"
97
+ // Returns source paths (e.g. "app/layout.tsx") so the client bootstrap can look
98
+ // them up in LAYOUT_MODS by their original source key. The import() call in
99
+ // app.ts uses toJsPath() to convert back to the compiled .js path.
28
100
  const pageDir = dirname(routeFile);
29
101
  const parts = pageDir.split("/");
30
102
  const layouts: string[] = [];
31
103
  for (let i = 1; i <= parts.length; i++) {
32
104
  const dir = parts.slice(0, i).join("/");
33
- const layoutRelPath = `${dir}/layout.tsx`;
34
- if (existsSync(join(distDir, "server", layoutRelPath))) {
35
- layouts.push(layoutRelPath);
105
+ const compiledPath = `${dir}/layout.js`;
106
+ if (existsSync(join(distDir, "server", compiledPath))) {
107
+ layouts.push(`${dir}/layout.tsx`);
36
108
  }
37
109
  }
38
110
  return layouts;
@@ -44,7 +116,7 @@ function findProdLayoutFiles(routeFile: string, distDir: string): string[] {
44
116
  function findProdErrorFile(routeFile: string, distDir: string): string | null {
45
117
  let dir = dirname(routeFile);
46
118
  while (dir.length > 0 && dir !== ".") {
47
- const candidate = `${dir}/error.tsx`;
119
+ const candidate = `${dir}/error.js`;
48
120
  if (existsSync(join(distDir, "server", candidate))) return candidate;
49
121
  const parent = dirname(dir);
50
122
  if (parent === dir) break;
@@ -56,8 +128,8 @@ function findProdErrorFile(routeFile: string, distDir: string): string | null {
56
128
  function findProdLoadingFile(routeFile: string, distDir: string): string | null {
57
129
  let dir = dirname(routeFile);
58
130
  while (dir.length > 0 && dir !== ".") {
59
- const candidate = `${dir}/loading.tsx`;
60
- if (existsSync(join(distDir, "server", candidate))) return candidate;
131
+ const compiled = `${dir}/loading.js`;
132
+ if (existsSync(join(distDir, "server", compiled))) return `${dir}/loading.tsx`;
61
133
  const parent = dirname(dir);
62
134
  if (parent === dir) break;
63
135
  dir = parent;
@@ -89,6 +161,21 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
89
161
  buildId = readFileSync(resolve(distDir, "BUILD_ID"), "utf8").trim() || undefined;
90
162
  } catch { /* no BUILD_ID file — skew protection disabled */ }
91
163
 
164
+ // Resolve the compiled client entry file path by reading the Vite manifest.
165
+ // /@alabjs/client is a virtual module at build time; at runtime the server
166
+ // must redirect requests for it to the hashed asset file.
167
+ // The manifest key is a relative path ending in "@alabjs/client" (not "/@alabjs/client").
168
+ let clientEntryPath = "";
169
+ try {
170
+ const viteManifest = JSON.parse(
171
+ readFileSync(resolve(distDir, "client/.vite/manifest.json"), "utf8"),
172
+ ) as Record<string, { file?: string; isEntry?: boolean; src?: string }>;
173
+ const entry = Object.values(viteManifest).find(
174
+ (e) => e.isEntry && e.src?.endsWith("@alabjs/client"),
175
+ );
176
+ if (entry?.file) clientEntryPath = "/" + entry.file;
177
+ } catch { /* manifest absent — /@alabjs/client will 404 */ }
178
+
92
179
  // Load federation config written by `alab build`. Used to:
93
180
  // 1. Serve `/_alabjs/federation-manifest.json` (remote discovery)
94
181
  // 2. Inject `<script type="importmap">` into every page (host → remote routing)
@@ -103,6 +190,11 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
103
190
  // Absolute path to the PPR shell cache directory.
104
191
  const pprCacheDir = resolve(distDir, "../../", PPR_CACHE_SUBDIR);
105
192
 
193
+ // Register live components at startup (non-blocking — failures are warned, not thrown).
194
+ registerAllLiveComponents(distDir).catch((err) => {
195
+ console.warn("[alabjs] live: component registration failed:", err);
196
+ });
197
+
106
198
  // ─── Global middleware ───────────────────────────────────────────────────────
107
199
  app.use(
108
200
  defineEventHandler((event) => {
@@ -112,6 +204,40 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
112
204
  res.setHeader("referrer-policy", "strict-origin-when-cross-origin");
113
205
  res.setHeader("permissions-policy", "camera=(), microphone=(), geolocation=()");
114
206
  res.setHeader("x-permitted-cross-domain-policies", "none");
207
+ // NOTE: 'unsafe-inline' is required by React's inline event delegation and
208
+ // Tailwind's runtime style injection. 'unsafe-eval' is required by some
209
+ // React dev-mode internals and dynamic import().
210
+ //
211
+ // ⚠️ Security implication: these directives weaken XSS protection.
212
+ // In production, override this header in your middleware with a nonce-based
213
+ // CSP: `script-src 'self' 'nonce-<random>'` and inject the same nonce into
214
+ // every <script> tag via renderToResponse's headExtra option. The CSRF
215
+ // double-submit pattern relies on XSS prevention — using 'unsafe-inline'
216
+ // without a nonce makes the CSRF token readable by injected scripts.
217
+ //
218
+ // NOTE: 'upgrade-insecure-requests' is intentionally omitted from the
219
+ // default CSP. That directive tells browsers to silently rewrite http://
220
+ // sub-resource URLs to https://, which breaks any app served over plain
221
+ // HTTP (local dev, internal tooling, HTTP-only staging servers) because
222
+ // the browser will refuse to load scripts and stylesheets redirected from
223
+ // the virtual /@alabjs/client path.
224
+ //
225
+ // Add it in your own middleware when you are certain every environment
226
+ // runs behind HTTPS:
227
+ //
228
+ // // middleware.ts
229
+ // export async function middleware(req: Request) {
230
+ // const isHttps = req.headers.get("x-forwarded-proto") === "https"
231
+ // || new URL(req.url).protocol === "https:";
232
+ // if (isHttps) {
233
+ // // Append the directive to whatever CSP the framework already set.
234
+ // const existing = res.headers.get("content-security-policy") ?? "";
235
+ // res.headers.set(
236
+ // "content-security-policy",
237
+ // existing + "; upgrade-insecure-requests",
238
+ // );
239
+ // }
240
+ // }
115
241
  res.setHeader(
116
242
  "content-security-policy",
117
243
  [
@@ -126,7 +252,6 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
126
252
  "base-uri 'self'",
127
253
  "form-action 'self'",
128
254
  "frame-ancestors 'self'",
129
- "upgrade-insecure-requests",
130
255
  ].join("; "),
131
256
  );
132
257
  // HSTS — only meaningful over HTTPS; set in production only.
@@ -134,12 +259,18 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
134
259
  }),
135
260
  );
136
261
 
137
- // ─── User middleware (middleware.ts compiled to dist/server/middleware.ts) ───
138
- const middlewareModulePath = `${distDir}/server/middleware.ts`;
262
+ // ─── User middleware (middleware.ts compiled to dist/server/middleware.js) ───
263
+ const middlewareModulePath = `${distDir}/server/middleware.js`;
139
264
  if (existsSync(middlewareModulePath)) {
265
+ // Cache the module after first import — avoids redundant dynamic import()
266
+ // overhead on every request (each import() call re-resolves the module graph).
267
+ let _middlewareCache: MiddlewareModule | null = null;
140
268
  app.use(
141
269
  defineEventHandler(async (event) => {
142
- const mod = await import(middlewareModulePath) as MiddlewareModule;
270
+ if (!_middlewareCache) {
271
+ _middlewareCache = await import(middlewareModulePath) as MiddlewareModule;
272
+ }
273
+ const mod = _middlewareCache;
143
274
  if (typeof mod.middleware !== "function") return;
144
275
  const req = event.node.req;
145
276
  const res = event.node.res;
@@ -193,7 +324,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
193
324
  };
194
325
 
195
326
  app.use(
196
- defineEventHandler((event) => {
327
+ defineEventHandler(async (event) => {
197
328
  const req = event.node.req;
198
329
  const res = event.node.res;
199
330
  if (req.method !== "GET" && req.method !== "HEAD") return;
@@ -204,22 +335,42 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
204
335
  try { relPath = decodeURIComponent(rawPath); } catch { return; }
205
336
  if (relPath.includes("..")) return;
206
337
 
207
- const ext = extname(relPath).toLowerCase();
208
- const contentType = MIME_TYPES[ext];
209
- if (!contentType) return; // skip extensionless paths (page routes)
210
-
211
338
  const acceptEncoding = (req.headers["accept-encoding"] ?? "") as string;
212
339
  const useBrotli = acceptEncoding.includes("br");
213
340
  const useGzip = !useBrotli && acceptEncoding.includes("gzip");
214
341
 
215
- /** Stream a file with optional brotli/gzip compression and ETag 304 support. */
342
+ // Virtual client entry redirect to the hashed asset file resolved at startup.
343
+ // A 302 redirect (not direct serve) is critical: relative imports in the bundle
344
+ // (e.g. "./components-HASH.js") must resolve against the real asset URL
345
+ // (/assets/client-HASH.js), not the virtual path (/@alabjs/client).
346
+ if (relPath === "/@alabjs/client") {
347
+ if (clientEntryPath) {
348
+ res.writeHead(302, { Location: clientEntryPath });
349
+ } else {
350
+ res.statusCode = 404;
351
+ }
352
+ res.end();
353
+ return;
354
+ }
355
+
356
+ const ext = extname(relPath).toLowerCase();
357
+ const contentType = MIME_TYPES[ext];
358
+ if (!contentType) return; // skip extensionless paths (page routes)
359
+
360
+ /** Stream a file with optional brotli/gzip compression and ETag 304 support.
361
+ *
362
+ * Returns a Promise that resolves when the response is fully sent. The
363
+ * h3 handler awaits this promise so h3 knows the response is complete
364
+ * before considering the next middleware. This avoids h3 passing the
365
+ * request to the router (which would 404) while the async pipe is running.
366
+ */
216
367
  function serveFile(
217
368
  filePath: string,
218
369
  fileSize: number,
219
370
  mtimeMs: number,
220
371
  cacheControl: string,
221
372
  mime: string,
222
- ): null {
373
+ ): Promise<void> {
223
374
  // ETag from file size + mtime — both already known from the caller's stat().
224
375
  const etag = `"${fileSize.toString(36)}-${mtimeMs.toString(36)}"`;
225
376
  res.setHeader("etag", etag);
@@ -228,26 +379,29 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
228
379
  if (req.headers["if-none-match"] === etag) {
229
380
  res.statusCode = 304;
230
381
  res.end();
231
- return null;
382
+ return Promise.resolve();
232
383
  }
233
384
 
234
385
  res.setHeader("content-type", mime);
235
386
  res.setHeader("cache-control", cacheControl);
236
387
 
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;
388
+ if (req.method === "HEAD") { res.end(); return Promise.resolve(); }
389
+
390
+ return new Promise<void>((resolve, reject) => {
391
+ const fileStream = createReadStream(filePath);
392
+ res.on("finish", resolve);
393
+ res.on("error", reject);
394
+ if (useBrotli) {
395
+ res.setHeader("content-encoding", "br");
396
+ fileStream.pipe(createBrotliCompress()).pipe(res);
397
+ } else if (useGzip) {
398
+ res.setHeader("content-encoding", "gzip");
399
+ fileStream.pipe(createGzip()).pipe(res);
400
+ } else {
401
+ res.setHeader("content-length", fileSize);
402
+ fileStream.pipe(res);
403
+ }
404
+ });
251
405
  }
252
406
 
253
407
  // 1. Built client assets (JS chunks, CSS, source maps)
@@ -256,13 +410,14 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
256
410
  const stat = statSync(clientCandidate);
257
411
  if (stat.isFile()) {
258
412
  const isHashed = /\.[a-f0-9]{8,}\.[a-z]+$/.test(relPath);
259
- return serveFile(
413
+ await serveFile(
260
414
  clientCandidate,
261
415
  stat.size,
262
416
  stat.mtimeMs,
263
417
  isHashed ? "public, max-age=31536000, immutable" : "public, max-age=3600",
264
418
  contentType,
265
419
  );
420
+ return;
266
421
  }
267
422
  }
268
423
 
@@ -271,10 +426,10 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
271
426
  if (existsSync(publicCandidate)) {
272
427
  const stat = statSync(publicCandidate);
273
428
  if (stat.isFile()) {
274
- return serveFile(publicCandidate, stat.size, stat.mtimeMs, "public, max-age=3600", contentType);
429
+ await serveFile(publicCandidate, stat.size, stat.mtimeMs, "public, max-age=3600", contentType);
430
+ return;
275
431
  }
276
432
  }
277
- return undefined;
278
433
  }),
279
434
  );
280
435
 
@@ -346,6 +501,173 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
346
501
  }),
347
502
  );
348
503
 
504
+ // ─── Live component SSE endpoint ────────────────────────────────────────────
505
+ // GET /_alabjs/live/:id?props=<base64-json>
506
+ //
507
+ // Opens a persistent SSE stream for a live component. On connect:
508
+ // 1. Renders the component immediately and sends the first fragment.
509
+ // 2. Sets up an interval (if liveInterval is set) to re-render and push.
510
+ // 3. Subscribes to tag broadcasts (if liveTags is set).
511
+ // 4. On client disconnect: clears interval + unsubscribes tags.
512
+ router.get(
513
+ "/_alabjs/live/:id",
514
+ defineEventHandler(async (event) => {
515
+ const req = event.node.req;
516
+ const res = event.node.res;
517
+
518
+ const id = (event.context.params?.["id"] ?? "") as string;
519
+ const { getLiveComponent } = await import("../live/registry.js");
520
+ const entry = getLiveComponent(id);
521
+
522
+ if (!entry) {
523
+ res.statusCode = 404;
524
+ res.setHeader("content-type", "application/json");
525
+ res.end(JSON.stringify({ error: `[alabjs] live component not found: ${id}` }));
526
+ return;
527
+ }
528
+
529
+ // Parse props from base64-encoded JSON query param.
530
+ let props: unknown = {};
531
+ try {
532
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
533
+ const raw = url.searchParams.get("props");
534
+ if (raw) props = JSON.parse(Buffer.from(raw, "base64").toString("utf8"));
535
+ } catch { /* malformed props — use empty object */ }
536
+
537
+ // SSE headers.
538
+ res.statusCode = 200;
539
+ res.setHeader("content-type", "text/event-stream; charset=utf-8");
540
+ res.setHeader("cache-control", "no-cache, no-transform");
541
+ res.setHeader("connection", "keep-alive");
542
+ res.setHeader("x-accel-buffering", "no"); // disable nginx buffering
543
+
544
+ let lastHash = "";
545
+ let closed = false;
546
+
547
+ async function pushFragment(): Promise<void> {
548
+ if (closed) return;
549
+ try {
550
+ const html = await renderLiveFragment(entry!.modulePath, props);
551
+ const hash = hashFragment(html);
552
+ if (hash === lastHash) return; // no-op — output unchanged
553
+ lastHash = hash;
554
+ res.write(`data: ${html}\n\n`);
555
+ } catch (err) {
556
+ console.error(`[alabjs] live render error (${id}):`, err);
557
+ }
558
+ }
559
+
560
+ // Send initial fragment immediately.
561
+ await pushFragment();
562
+
563
+ // Interval-based polling.
564
+ let intervalHandle: ReturnType<typeof setInterval> | null = null;
565
+ if (entry.liveInterval && entry.liveInterval > 0) {
566
+ intervalHandle = setInterval(() => { void pushFragment(); }, entry.liveInterval);
567
+ }
568
+
569
+ // Tag-based subscriptions.
570
+ const unsubFns: Array<() => void> = [];
571
+ if (entry.liveTags) {
572
+ const tags = entry.liveTags(props);
573
+ for (const tag of tags) {
574
+ unsubFns.push(subscribeToTag(tag, () => { void pushFragment(); }));
575
+ }
576
+ }
577
+
578
+ // Cleanup on disconnect.
579
+ req.on("close", () => {
580
+ closed = true;
581
+ if (intervalHandle) clearInterval(intervalHandle);
582
+ for (const unsub of unsubFns) unsub();
583
+ });
584
+
585
+ // Return null so h3 does not try to end the response — SSE keeps it open.
586
+ return null;
587
+ }),
588
+ );
589
+
590
+ // ─── Server function endpoints ──────────────────────────────────────────────
591
+ // GET /_alabjs/data/:fn — used by useServerData (query params as input)
592
+ // POST /_alabjs/fn/:fn — used by useMutation (JSON body as input)
593
+ //
594
+ // Both scan all *.server.js files in dist/server for the named export.
595
+ // Module results are NOT cached — the module cache is Node's own require cache.
596
+
597
+ async function callServerFn(
598
+ fnName: string,
599
+ ctx: { params: Record<string, string>; query: Record<string, string>; headers: Record<string, string | string[] | undefined>; method: string; url: string },
600
+ input: unknown,
601
+ res: import("node:http").ServerResponse,
602
+ ): Promise<void> {
603
+ const serverFiles = findDistServerFiles(distDir);
604
+ for (const file of serverFiles) {
605
+ const mod = await import(file) as Record<string, unknown>;
606
+ if (typeof mod[fnName] === "function") {
607
+ try {
608
+ const result = await (mod[fnName] as (c: unknown, i: unknown) => Promise<unknown>)(ctx, input);
609
+ res.statusCode = 200;
610
+ res.setHeader("content-type", "application/json");
611
+ res.end(JSON.stringify(result));
612
+ } catch (err) {
613
+ const zodError = (err as Record<string, unknown>)?.["zodError"];
614
+ if (zodError) {
615
+ res.statusCode = 422;
616
+ res.setHeader("content-type", "application/json");
617
+ res.end(JSON.stringify({ zodError }));
618
+ } else {
619
+ const msg = err instanceof Error ? err.message : String(err);
620
+ console.error(`[alabjs] server fn "${fnName}" threw:`, err);
621
+ res.statusCode = 500;
622
+ res.setHeader("content-type", "application/json");
623
+ res.end(JSON.stringify({ error: msg }));
624
+ }
625
+ }
626
+ return;
627
+ }
628
+ }
629
+ res.statusCode = 404;
630
+ res.setHeader("content-type", "application/json");
631
+ res.end(JSON.stringify({ error: `[alabjs] server function not found: ${fnName}` }));
632
+ }
633
+
634
+ router.get(
635
+ "/_alabjs/data/:fn",
636
+ defineEventHandler(async (event) => {
637
+ const fnName = event.context.params?.["fn"] ?? "";
638
+ const req = event.node.req;
639
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
640
+ const query = Object.fromEntries(url.searchParams.entries());
641
+ const ctx = {
642
+ params: query,
643
+ query,
644
+ headers: req.headers as Record<string, string>,
645
+ method: "GET",
646
+ url: req.url ?? "/",
647
+ };
648
+ await callServerFn(fnName, ctx, Object.keys(query).length ? query : undefined, event.node.res);
649
+ }),
650
+ );
651
+
652
+ router.post(
653
+ "/_alabjs/fn/:fn",
654
+ defineEventHandler(async (event) => {
655
+ const fnName = event.context.params?.["fn"] ?? "";
656
+ const req = event.node.req;
657
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
658
+ const query = Object.fromEntries(url.searchParams.entries());
659
+ const body = await readBody(event);
660
+ const ctx = {
661
+ params: query,
662
+ query,
663
+ headers: req.headers as Record<string, string>,
664
+ method: "POST",
665
+ url: req.url ?? "/",
666
+ };
667
+ await callServerFn(fnName, ctx, body, event.node.res);
668
+ }),
669
+ );
670
+
349
671
  // Auto sitemap.xml from route manifest
350
672
  router.get(
351
673
  "/sitemap.xml",
@@ -365,7 +687,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
365
687
  if (route.kind !== "api") continue;
366
688
 
367
689
  const h3ApiPath = route.path.replace(/\[([^\]]+)\]/g, ":$1");
368
- const apiModulePath = `${distDir}/server/${route.file}`;
690
+ const apiModulePath = `${distDir}/server/${toJsPath(route.file)}`;
369
691
 
370
692
  for (const method of ["get", "post", "put", "patch", "delete", "head"] as const) {
371
693
  router[method](
@@ -454,11 +776,11 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
454
776
  }
455
777
 
456
778
  // Dynamically import the compiled page module from the dist directory.
457
- const pageModulePath = `${distDir}/server/${route.file}`;
779
+ const pageModulePath = `${distDir}/server/${toJsPath(route.file)}`;
458
780
  const mod = await import(pageModulePath) as {
459
781
  default?: unknown;
460
782
  metadata?: PageMetadata;
461
- generateMetadata?: (params: Record<string, string>) => PageMetadata | Promise<PageMetadata>;
783
+ generateMetadata?: (props: { params: Record<string, string>; searchParams: Record<string, string> }) => PageMetadata | Promise<PageMetadata>;
462
784
  ssr?: boolean;
463
785
  cdnCache?: CdnCache;
464
786
  ppr?: boolean;
@@ -499,7 +821,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
499
821
  // Support both static metadata and dynamic generateMetadata (production fix)
500
822
  const metadata: PageMetadata =
501
823
  typeof mod.generateMetadata === "function"
502
- ? await mod.generateMetadata(params)
824
+ ? await mod.generateMetadata({ params, searchParams })
503
825
  : (mod.metadata ?? {});
504
826
 
505
827
  const ssrEnabled = mod.ssr === true;
@@ -507,7 +829,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
507
829
  // ── Layouts ──────────────────────────────────────────────────────────
508
830
  const layoutRelPaths = findProdLayoutFiles(route.file, distDir);
509
831
  const layoutMods = await Promise.all(
510
- layoutRelPaths.map((p) => import(`${distDir}/server/${p}`)),
832
+ layoutRelPaths.map((p) => import(`${distDir}/server/${toJsPath(p)}`)),
511
833
  );
512
834
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
513
835
  const layouts = layoutMods.map((m: any) => m.default).filter((c: unknown): c is any => typeof c === "function");
@@ -550,7 +872,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
550
872
  if (errorRelPath) {
551
873
  try {
552
874
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
553
- const errorMod = await import(`${distDir}/server/${errorRelPath}`) as any;
875
+ const errorMod = await import(`${distDir}/server/${toJsPath(errorRelPath)}`) as any;
554
876
  const ErrorPage = errorMod.default;
555
877
  if (typeof ErrorPage === "function") {
556
878
  renderToResponse(res, {
@@ -582,7 +904,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
582
904
  app.use(router);
583
905
 
584
906
  // ─── 404 / not-found fallback ────────────────────────────────────────────────
585
- const notFoundPath = `${distDir}/server/app/not-found.tsx`;
907
+ const notFoundPath = `${distDir}/server/app/not-found.js`;
586
908
  app.use(
587
909
  defineEventHandler(async (event) => {
588
910
  const res = event.node.res;
@@ -618,8 +940,11 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
618
940
 
619
941
  return {
620
942
  listen(port = 3000) {
943
+ // Make the server's base URL available to useServerData during SSR so it
944
+ // can construct an absolute URL for its internal loop-back fetch.
945
+ process.env["ALAB_ORIGIN"] = `http://127.0.0.1:${port}`;
621
946
  const server = createServer(toNodeListener(app));
622
- server.listen(port, () => {
947
+ server.listen(port, "0.0.0.0", () => {
623
948
  console.log(` alab ready at http://localhost:${port}`);
624
949
  });
625
950
  },
@@ -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
 
@@ -142,3 +142,4 @@ export function defineServerFn(...args: any[]): ServerFn<any, any> {
142
142
 
143
143
  export { defineSSEHandler } from "./sse.js";
144
144
  export type { SSEEvent } from "./sse.js";
145
+ export { invalidateLive } from "../live/broadcaster.js";