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
@@ -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 { renderToResponse } from "../ssr/render.js";
@@ -14,20 +14,88 @@ import { applyCdnHeaders } from "./cdn.js";
14
14
  import { getPPRShell, injectBuildIdIntoPPRShell, PPR_CACHE_SUBDIR } from "../ssr/ppr.js";
15
15
  import { handleVitalsBeacon, handleAnalyticsDashboard } from "../analytics/handler.js";
16
16
  import { buildImportMap } from "../config.js";
17
+ import { registerLiveComponent } from "../live/registry.js";
18
+ import { renderLiveFragment, hashFragment } from "../live/renderer.js";
19
+ import { subscribeToTag } from "../live/broadcaster.js";
20
+ /** Walk dist/server recursively and collect all *.server.js paths (compiled server functions). */
21
+ function findDistServerFiles(distDir) {
22
+ const serverDir = join(distDir, "server");
23
+ const results = [];
24
+ function walk(dir) {
25
+ try {
26
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
27
+ const fullPath = join(dir, entry.name);
28
+ if (entry.isDirectory()) {
29
+ walk(fullPath);
30
+ }
31
+ else if (entry.isFile() && entry.name.endsWith(".server.js")) {
32
+ results.push(fullPath);
33
+ }
34
+ }
35
+ }
36
+ catch { /* not readable */ }
37
+ }
38
+ walk(serverDir);
39
+ return results;
40
+ }
41
+ /** Walk dist/server recursively and register all *.live.js modules. */
42
+ async function registerAllLiveComponents(distDir) {
43
+ const serverDir = join(distDir, "server");
44
+ const files = [];
45
+ function walk(dir) {
46
+ try {
47
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
48
+ const fullPath = join(dir, entry.name);
49
+ if (entry.isDirectory()) {
50
+ walk(fullPath);
51
+ }
52
+ else if (entry.isFile() && entry.name.endsWith(".live.js")) {
53
+ files.push(fullPath);
54
+ }
55
+ }
56
+ }
57
+ catch { /* not readable */ }
58
+ }
59
+ walk(serverDir);
60
+ for (const filePath of files) {
61
+ try {
62
+ const mod = await import(filePath);
63
+ // liveId is stamped by the Vite plugin (hash of source path).
64
+ // Fall back to a hash of the file path itself.
65
+ const id = mod.liveId ?? filePath.replace(/[^a-z0-9]/gi, "").slice(-16);
66
+ registerLiveComponent({
67
+ id,
68
+ modulePath: filePath,
69
+ ...(typeof mod.liveInterval === "number" ? { liveInterval: mod.liveInterval } : {}),
70
+ ...(typeof mod.liveTags === "function" ? { liveTags: mod.liveTags } : {}),
71
+ });
72
+ }
73
+ catch (err) {
74
+ console.warn(`[alabjs] live: failed to register ${filePath}:`, err);
75
+ }
76
+ }
77
+ }
17
78
  /**
18
79
  * Find layout file paths (relative to cwd root) for a given route.file, ordered outermost→innermost.
19
80
  * Checks the compiled dist directory for the existence of each layout.
20
81
  */
82
+ /** Convert a TypeScript source path to its compiled .js equivalent. */
83
+ function toJsPath(p) {
84
+ return p.replace(/\.(tsx?)$/, ".js");
85
+ }
21
86
  function findProdLayoutFiles(routeFile, distDir) {
22
87
  // routeFile is like "app/users/[id]/page.tsx"
88
+ // Returns source paths (e.g. "app/layout.tsx") so the client bootstrap can look
89
+ // them up in LAYOUT_MODS by their original source key. The import() call in
90
+ // app.ts uses toJsPath() to convert back to the compiled .js path.
23
91
  const pageDir = dirname(routeFile);
24
92
  const parts = pageDir.split("/");
25
93
  const layouts = [];
26
94
  for (let i = 1; i <= parts.length; i++) {
27
95
  const dir = parts.slice(0, i).join("/");
28
- const layoutRelPath = `${dir}/layout.tsx`;
29
- if (existsSync(join(distDir, "server", layoutRelPath))) {
30
- layouts.push(layoutRelPath);
96
+ const compiledPath = `${dir}/layout.js`;
97
+ if (existsSync(join(distDir, "server", compiledPath))) {
98
+ layouts.push(`${dir}/layout.tsx`);
31
99
  }
32
100
  }
33
101
  return layouts;
@@ -38,7 +106,7 @@ function findProdLayoutFiles(routeFile, distDir) {
38
106
  function findProdErrorFile(routeFile, distDir) {
39
107
  let dir = dirname(routeFile);
40
108
  while (dir.length > 0 && dir !== ".") {
41
- const candidate = `${dir}/error.tsx`;
109
+ const candidate = `${dir}/error.js`;
42
110
  if (existsSync(join(distDir, "server", candidate)))
43
111
  return candidate;
44
112
  const parent = dirname(dir);
@@ -51,9 +119,9 @@ function findProdErrorFile(routeFile, distDir) {
51
119
  function findProdLoadingFile(routeFile, distDir) {
52
120
  let dir = dirname(routeFile);
53
121
  while (dir.length > 0 && dir !== ".") {
54
- const candidate = `${dir}/loading.tsx`;
55
- if (existsSync(join(distDir, "server", candidate)))
56
- return candidate;
122
+ const compiled = `${dir}/loading.js`;
123
+ if (existsSync(join(distDir, "server", compiled)))
124
+ return `${dir}/loading.tsx`;
57
125
  const parent = dirname(dir);
58
126
  if (parent === dir)
59
127
  break;
@@ -80,6 +148,18 @@ export function createApp(manifest, distDir) {
80
148
  buildId = readFileSync(resolve(distDir, "BUILD_ID"), "utf8").trim() || undefined;
81
149
  }
82
150
  catch { /* no BUILD_ID file — skew protection disabled */ }
151
+ // Resolve the compiled client entry file path by reading the Vite manifest.
152
+ // /@alabjs/client is a virtual module at build time; at runtime the server
153
+ // must redirect requests for it to the hashed asset file.
154
+ // The manifest key is a relative path ending in "@alabjs/client" (not "/@alabjs/client").
155
+ let clientEntryPath = "";
156
+ try {
157
+ const viteManifest = JSON.parse(readFileSync(resolve(distDir, "client/.vite/manifest.json"), "utf8"));
158
+ const entry = Object.values(viteManifest).find((e) => e.isEntry && e.src?.endsWith("@alabjs/client"));
159
+ if (entry?.file)
160
+ clientEntryPath = "/" + entry.file;
161
+ }
162
+ catch { /* manifest absent — /@alabjs/client will 404 */ }
83
163
  // Load federation config written by `alab build`. Used to:
84
164
  // 1. Serve `/_alabjs/federation-manifest.json` (remote discovery)
85
165
  // 2. Inject `<script type="importmap">` into every page (host → remote routing)
@@ -93,6 +173,10 @@ export function createApp(manifest, distDir) {
93
173
  catch { /* no federation config — federation disabled */ }
94
174
  // Absolute path to the PPR shell cache directory.
95
175
  const pprCacheDir = resolve(distDir, "../../", PPR_CACHE_SUBDIR);
176
+ // Register live components at startup (non-blocking — failures are warned, not thrown).
177
+ registerAllLiveComponents(distDir).catch((err) => {
178
+ console.warn("[alabjs] live: component registration failed:", err);
179
+ });
96
180
  // ─── Global middleware ───────────────────────────────────────────────────────
97
181
  app.use(defineEventHandler((event) => {
98
182
  const res = event.node.res;
@@ -101,6 +185,40 @@ export function createApp(manifest, distDir) {
101
185
  res.setHeader("referrer-policy", "strict-origin-when-cross-origin");
102
186
  res.setHeader("permissions-policy", "camera=(), microphone=(), geolocation=()");
103
187
  res.setHeader("x-permitted-cross-domain-policies", "none");
188
+ // NOTE: 'unsafe-inline' is required by React's inline event delegation and
189
+ // Tailwind's runtime style injection. 'unsafe-eval' is required by some
190
+ // React dev-mode internals and dynamic import().
191
+ //
192
+ // ⚠️ Security implication: these directives weaken XSS protection.
193
+ // In production, override this header in your middleware with a nonce-based
194
+ // CSP: `script-src 'self' 'nonce-<random>'` and inject the same nonce into
195
+ // every <script> tag via renderToResponse's headExtra option. The CSRF
196
+ // double-submit pattern relies on XSS prevention — using 'unsafe-inline'
197
+ // without a nonce makes the CSRF token readable by injected scripts.
198
+ //
199
+ // NOTE: 'upgrade-insecure-requests' is intentionally omitted from the
200
+ // default CSP. That directive tells browsers to silently rewrite http://
201
+ // sub-resource URLs to https://, which breaks any app served over plain
202
+ // HTTP (local dev, internal tooling, HTTP-only staging servers) because
203
+ // the browser will refuse to load scripts and stylesheets redirected from
204
+ // the virtual /@alabjs/client path.
205
+ //
206
+ // Add it in your own middleware when you are certain every environment
207
+ // runs behind HTTPS:
208
+ //
209
+ // // middleware.ts
210
+ // export async function middleware(req: Request) {
211
+ // const isHttps = req.headers.get("x-forwarded-proto") === "https"
212
+ // || new URL(req.url).protocol === "https:";
213
+ // if (isHttps) {
214
+ // // Append the directive to whatever CSP the framework already set.
215
+ // const existing = res.headers.get("content-security-policy") ?? "";
216
+ // res.headers.set(
217
+ // "content-security-policy",
218
+ // existing + "; upgrade-insecure-requests",
219
+ // );
220
+ // }
221
+ // }
104
222
  res.setHeader("content-security-policy", [
105
223
  "default-src 'self'",
106
224
  "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
@@ -113,16 +231,21 @@ export function createApp(manifest, distDir) {
113
231
  "base-uri 'self'",
114
232
  "form-action 'self'",
115
233
  "frame-ancestors 'self'",
116
- "upgrade-insecure-requests",
117
234
  ].join("; "));
118
235
  // HSTS — only meaningful over HTTPS; set in production only.
119
236
  res.setHeader("strict-transport-security", "max-age=31536000; includeSubDomains");
120
237
  }));
121
- // ─── User middleware (middleware.ts compiled to dist/server/middleware.ts) ───
122
- const middlewareModulePath = `${distDir}/server/middleware.ts`;
238
+ // ─── User middleware (middleware.ts compiled to dist/server/middleware.js) ───
239
+ const middlewareModulePath = `${distDir}/server/middleware.js`;
123
240
  if (existsSync(middlewareModulePath)) {
241
+ // Cache the module after first import — avoids redundant dynamic import()
242
+ // overhead on every request (each import() call re-resolves the module graph).
243
+ let _middlewareCache = null;
124
244
  app.use(defineEventHandler(async (event) => {
125
- const mod = await import(middlewareModulePath);
245
+ if (!_middlewareCache) {
246
+ _middlewareCache = await import(middlewareModulePath);
247
+ }
248
+ const mod = _middlewareCache;
126
249
  if (typeof mod.middleware !== "function")
127
250
  return;
128
251
  const req = event.node.req;
@@ -169,7 +292,7 @@ export function createApp(manifest, distDir) {
169
292
  ".xml": "application/xml; charset=utf-8",
170
293
  ".map": "application/json; charset=utf-8",
171
294
  };
172
- app.use(defineEventHandler((event) => {
295
+ app.use(defineEventHandler(async (event) => {
173
296
  const req = event.node.req;
174
297
  const res = event.node.res;
175
298
  if (req.method !== "GET" && req.method !== "HEAD")
@@ -185,14 +308,34 @@ export function createApp(manifest, distDir) {
185
308
  }
186
309
  if (relPath.includes(".."))
187
310
  return;
311
+ const acceptEncoding = (req.headers["accept-encoding"] ?? "");
312
+ const useBrotli = acceptEncoding.includes("br");
313
+ const useGzip = !useBrotli && acceptEncoding.includes("gzip");
314
+ // Virtual client entry — redirect to the hashed asset file resolved at startup.
315
+ // A 302 redirect (not direct serve) is critical: relative imports in the bundle
316
+ // (e.g. "./components-HASH.js") must resolve against the real asset URL
317
+ // (/assets/client-HASH.js), not the virtual path (/@alabjs/client).
318
+ if (relPath === "/@alabjs/client") {
319
+ if (clientEntryPath) {
320
+ res.writeHead(302, { Location: clientEntryPath });
321
+ }
322
+ else {
323
+ res.statusCode = 404;
324
+ }
325
+ res.end();
326
+ return;
327
+ }
188
328
  const ext = extname(relPath).toLowerCase();
189
329
  const contentType = MIME_TYPES[ext];
190
330
  if (!contentType)
191
331
  return; // skip extensionless paths (page routes)
192
- const acceptEncoding = (req.headers["accept-encoding"] ?? "");
193
- const useBrotli = acceptEncoding.includes("br");
194
- const useGzip = !useBrotli && acceptEncoding.includes("gzip");
195
- /** Stream a file with optional brotli/gzip compression and ETag 304 support. */
332
+ /** Stream a file with optional brotli/gzip compression and ETag 304 support.
333
+ *
334
+ * Returns a Promise that resolves when the response is fully sent. The
335
+ * h3 handler awaits this promise so h3 knows the response is complete
336
+ * before considering the next middleware. This avoids h3 passing the
337
+ * request to the router (which would 404) while the async pipe is running.
338
+ */
196
339
  function serveFile(filePath, fileSize, mtimeMs, cacheControl, mime) {
197
340
  // ETag from file size + mtime — both already known from the caller's stat().
198
341
  const etag = `"${fileSize.toString(36)}-${mtimeMs.toString(36)}"`;
@@ -201,28 +344,31 @@ export function createApp(manifest, distDir) {
201
344
  if (req.headers["if-none-match"] === etag) {
202
345
  res.statusCode = 304;
203
346
  res.end();
204
- return null;
347
+ return Promise.resolve();
205
348
  }
206
349
  res.setHeader("content-type", mime);
207
350
  res.setHeader("cache-control", cacheControl);
208
351
  if (req.method === "HEAD") {
209
352
  res.end();
210
- return null;
211
- }
212
- const stream = createReadStream(filePath);
213
- if (useBrotli) {
214
- res.setHeader("content-encoding", "br");
215
- stream.pipe(createBrotliCompress()).pipe(res);
216
- }
217
- else if (useGzip) {
218
- res.setHeader("content-encoding", "gzip");
219
- stream.pipe(createGzip()).pipe(res);
220
- }
221
- else {
222
- res.setHeader("content-length", fileSize);
223
- stream.pipe(res);
353
+ return Promise.resolve();
224
354
  }
225
- return null;
355
+ return new Promise((resolve, reject) => {
356
+ const fileStream = createReadStream(filePath);
357
+ res.on("finish", resolve);
358
+ res.on("error", reject);
359
+ if (useBrotli) {
360
+ res.setHeader("content-encoding", "br");
361
+ fileStream.pipe(createBrotliCompress()).pipe(res);
362
+ }
363
+ else if (useGzip) {
364
+ res.setHeader("content-encoding", "gzip");
365
+ fileStream.pipe(createGzip()).pipe(res);
366
+ }
367
+ else {
368
+ res.setHeader("content-length", fileSize);
369
+ fileStream.pipe(res);
370
+ }
371
+ });
226
372
  }
227
373
  // 1. Built client assets (JS chunks, CSS, source maps)
228
374
  const clientCandidate = join(clientDir, relPath);
@@ -230,7 +376,8 @@ export function createApp(manifest, distDir) {
230
376
  const stat = statSync(clientCandidate);
231
377
  if (stat.isFile()) {
232
378
  const isHashed = /\.[a-f0-9]{8,}\.[a-z]+$/.test(relPath);
233
- return serveFile(clientCandidate, stat.size, stat.mtimeMs, isHashed ? "public, max-age=31536000, immutable" : "public, max-age=3600", contentType);
379
+ await serveFile(clientCandidate, stat.size, stat.mtimeMs, isHashed ? "public, max-age=31536000, immutable" : "public, max-age=3600", contentType);
380
+ return;
234
381
  }
235
382
  }
236
383
  // 2. Public directory (favicons, fonts, open-graph images, etc.)
@@ -238,10 +385,10 @@ export function createApp(manifest, distDir) {
238
385
  if (existsSync(publicCandidate)) {
239
386
  const stat = statSync(publicCandidate);
240
387
  if (stat.isFile()) {
241
- return serveFile(publicCandidate, stat.size, stat.mtimeMs, "public, max-age=3600", contentType);
388
+ await serveFile(publicCandidate, stat.size, stat.mtimeMs, "public, max-age=3600", contentType);
389
+ return;
242
390
  }
243
391
  }
244
- return undefined;
245
392
  }));
246
393
  // ─── Built-in routes ────────────────────────────────────────────────────────
247
394
  // Federation manifest — remote apps can advertise what they expose
@@ -287,6 +434,152 @@ export function createApp(manifest, distDir) {
287
434
  handleAnalyticsDashboard(event.node.req, event.node.res);
288
435
  return null;
289
436
  }));
437
+ // ─── Live component SSE endpoint ────────────────────────────────────────────
438
+ // GET /_alabjs/live/:id?props=<base64-json>
439
+ //
440
+ // Opens a persistent SSE stream for a live component. On connect:
441
+ // 1. Renders the component immediately and sends the first fragment.
442
+ // 2. Sets up an interval (if liveInterval is set) to re-render and push.
443
+ // 3. Subscribes to tag broadcasts (if liveTags is set).
444
+ // 4. On client disconnect: clears interval + unsubscribes tags.
445
+ router.get("/_alabjs/live/:id", defineEventHandler(async (event) => {
446
+ const req = event.node.req;
447
+ const res = event.node.res;
448
+ const id = (event.context.params?.["id"] ?? "");
449
+ const { getLiveComponent } = await import("../live/registry.js");
450
+ const entry = getLiveComponent(id);
451
+ if (!entry) {
452
+ res.statusCode = 404;
453
+ res.setHeader("content-type", "application/json");
454
+ res.end(JSON.stringify({ error: `[alabjs] live component not found: ${id}` }));
455
+ return;
456
+ }
457
+ // Parse props from base64-encoded JSON query param.
458
+ let props = {};
459
+ try {
460
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
461
+ const raw = url.searchParams.get("props");
462
+ if (raw)
463
+ props = JSON.parse(Buffer.from(raw, "base64").toString("utf8"));
464
+ }
465
+ catch { /* malformed props — use empty object */ }
466
+ // SSE headers.
467
+ res.statusCode = 200;
468
+ res.setHeader("content-type", "text/event-stream; charset=utf-8");
469
+ res.setHeader("cache-control", "no-cache, no-transform");
470
+ res.setHeader("connection", "keep-alive");
471
+ res.setHeader("x-accel-buffering", "no"); // disable nginx buffering
472
+ let lastHash = "";
473
+ let closed = false;
474
+ async function pushFragment() {
475
+ if (closed)
476
+ return;
477
+ try {
478
+ const html = await renderLiveFragment(entry.modulePath, props);
479
+ const hash = hashFragment(html);
480
+ if (hash === lastHash)
481
+ return; // no-op — output unchanged
482
+ lastHash = hash;
483
+ res.write(`data: ${html}\n\n`);
484
+ }
485
+ catch (err) {
486
+ console.error(`[alabjs] live render error (${id}):`, err);
487
+ }
488
+ }
489
+ // Send initial fragment immediately.
490
+ await pushFragment();
491
+ // Interval-based polling.
492
+ let intervalHandle = null;
493
+ if (entry.liveInterval && entry.liveInterval > 0) {
494
+ intervalHandle = setInterval(() => { void pushFragment(); }, entry.liveInterval);
495
+ }
496
+ // Tag-based subscriptions.
497
+ const unsubFns = [];
498
+ if (entry.liveTags) {
499
+ const tags = entry.liveTags(props);
500
+ for (const tag of tags) {
501
+ unsubFns.push(subscribeToTag(tag, () => { void pushFragment(); }));
502
+ }
503
+ }
504
+ // Cleanup on disconnect.
505
+ req.on("close", () => {
506
+ closed = true;
507
+ if (intervalHandle)
508
+ clearInterval(intervalHandle);
509
+ for (const unsub of unsubFns)
510
+ unsub();
511
+ });
512
+ // Return null so h3 does not try to end the response — SSE keeps it open.
513
+ return null;
514
+ }));
515
+ // ─── Server function endpoints ──────────────────────────────────────────────
516
+ // GET /_alabjs/data/:fn — used by useServerData (query params as input)
517
+ // POST /_alabjs/fn/:fn — used by useMutation (JSON body as input)
518
+ //
519
+ // Both scan all *.server.js files in dist/server for the named export.
520
+ // Module results are NOT cached — the module cache is Node's own require cache.
521
+ async function callServerFn(fnName, ctx, input, res) {
522
+ const serverFiles = findDistServerFiles(distDir);
523
+ for (const file of serverFiles) {
524
+ const mod = await import(file);
525
+ if (typeof mod[fnName] === "function") {
526
+ try {
527
+ const result = await mod[fnName](ctx, input);
528
+ res.statusCode = 200;
529
+ res.setHeader("content-type", "application/json");
530
+ res.end(JSON.stringify(result));
531
+ }
532
+ catch (err) {
533
+ const zodError = err?.["zodError"];
534
+ if (zodError) {
535
+ res.statusCode = 422;
536
+ res.setHeader("content-type", "application/json");
537
+ res.end(JSON.stringify({ zodError }));
538
+ }
539
+ else {
540
+ const msg = err instanceof Error ? err.message : String(err);
541
+ console.error(`[alabjs] server fn "${fnName}" threw:`, err);
542
+ res.statusCode = 500;
543
+ res.setHeader("content-type", "application/json");
544
+ res.end(JSON.stringify({ error: msg }));
545
+ }
546
+ }
547
+ return;
548
+ }
549
+ }
550
+ res.statusCode = 404;
551
+ res.setHeader("content-type", "application/json");
552
+ res.end(JSON.stringify({ error: `[alabjs] server function not found: ${fnName}` }));
553
+ }
554
+ router.get("/_alabjs/data/:fn", defineEventHandler(async (event) => {
555
+ const fnName = event.context.params?.["fn"] ?? "";
556
+ const req = event.node.req;
557
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
558
+ const query = Object.fromEntries(url.searchParams.entries());
559
+ const ctx = {
560
+ params: query,
561
+ query,
562
+ headers: req.headers,
563
+ method: "GET",
564
+ url: req.url ?? "/",
565
+ };
566
+ await callServerFn(fnName, ctx, Object.keys(query).length ? query : undefined, event.node.res);
567
+ }));
568
+ router.post("/_alabjs/fn/:fn", defineEventHandler(async (event) => {
569
+ const fnName = event.context.params?.["fn"] ?? "";
570
+ const req = event.node.req;
571
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
572
+ const query = Object.fromEntries(url.searchParams.entries());
573
+ const body = await readBody(event);
574
+ const ctx = {
575
+ params: query,
576
+ query,
577
+ headers: req.headers,
578
+ method: "POST",
579
+ url: req.url ?? "/",
580
+ };
581
+ await callServerFn(fnName, ctx, body, event.node.res);
582
+ }));
290
583
  // Auto sitemap.xml from route manifest
291
584
  router.get("/sitemap.xml", defineEventHandler((event) => {
292
585
  const baseUrl = process.env["PUBLIC_URL"] ??
@@ -301,7 +594,7 @@ export function createApp(manifest, distDir) {
301
594
  if (route.kind !== "api")
302
595
  continue;
303
596
  const h3ApiPath = route.path.replace(/\[([^\]]+)\]/g, ":$1");
304
- const apiModulePath = `${distDir}/server/${route.file}`;
597
+ const apiModulePath = `${distDir}/server/${toJsPath(route.file)}`;
305
598
  for (const method of ["get", "post", "put", "patch", "delete", "head"]) {
306
599
  router[method](h3ApiPath, defineEventHandler(async (event) => {
307
600
  const apiMod = await import(apiModulePath);
@@ -377,7 +670,7 @@ export function createApp(manifest, distDir) {
377
670
  searchParams[k] = Array.isArray(v) ? v[0] ?? "" : v;
378
671
  }
379
672
  // Dynamically import the compiled page module from the dist directory.
380
- const pageModulePath = `${distDir}/server/${route.file}`;
673
+ const pageModulePath = `${distDir}/server/${toJsPath(route.file)}`;
381
674
  const mod = await import(pageModulePath);
382
675
  const Page = mod.default;
383
676
  if (typeof Page !== "function") {
@@ -412,12 +705,12 @@ export function createApp(manifest, distDir) {
412
705
  }
413
706
  // Support both static metadata and dynamic generateMetadata (production fix)
414
707
  const metadata = typeof mod.generateMetadata === "function"
415
- ? await mod.generateMetadata(params)
708
+ ? await mod.generateMetadata({ params, searchParams })
416
709
  : (mod.metadata ?? {});
417
710
  const ssrEnabled = mod.ssr === true;
418
711
  // ── Layouts ──────────────────────────────────────────────────────────
419
712
  const layoutRelPaths = findProdLayoutFiles(route.file, distDir);
420
- const layoutMods = await Promise.all(layoutRelPaths.map((p) => import(`${distDir}/server/${p}`)));
713
+ const layoutMods = await Promise.all(layoutRelPaths.map((p) => import(`${distDir}/server/${toJsPath(p)}`)));
421
714
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
422
715
  const layouts = layoutMods.map((m) => m.default).filter((c) => typeof c === "function");
423
716
  const layoutsJson = JSON.stringify(layoutRelPaths);
@@ -459,7 +752,7 @@ export function createApp(manifest, distDir) {
459
752
  if (errorRelPath) {
460
753
  try {
461
754
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
462
- const errorMod = await import(`${distDir}/server/${errorRelPath}`);
755
+ const errorMod = await import(`${distDir}/server/${toJsPath(errorRelPath)}`);
463
756
  const ErrorPage = errorMod.default;
464
757
  if (typeof ErrorPage === "function") {
465
758
  renderToResponse(res, {
@@ -489,7 +782,7 @@ export function createApp(manifest, distDir) {
489
782
  }
490
783
  app.use(router);
491
784
  // ─── 404 / not-found fallback ────────────────────────────────────────────────
492
- const notFoundPath = `${distDir}/server/app/not-found.tsx`;
785
+ const notFoundPath = `${distDir}/server/app/not-found.js`;
493
786
  app.use(defineEventHandler(async (event) => {
494
787
  const res = event.node.res;
495
788
  res.statusCode = 404;
@@ -521,8 +814,11 @@ export function createApp(manifest, distDir) {
521
814
  }));
522
815
  return {
523
816
  listen(port = 3000) {
817
+ // Make the server's base URL available to useServerData during SSR so it
818
+ // can construct an absolute URL for its internal loop-back fetch.
819
+ process.env["ALAB_ORIGIN"] = `http://127.0.0.1:${port}`;
524
820
  const server = createServer(toNodeListener(app));
525
- server.listen(port, () => {
821
+ server.listen(port, "0.0.0.0", () => {
526
822
  console.log(` alab ready at http://localhost:${port}`);
527
823
  });
528
824
  },