alabjs 0.2.5 → 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 (94) 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 +7 -2
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client/federation.d.ts +41 -0
  8. package/dist/client/federation.d.ts.map +1 -0
  9. package/dist/client/federation.js +48 -0
  10. package/dist/client/federation.js.map +1 -0
  11. package/dist/client/hooks.d.ts +9 -1
  12. package/dist/client/hooks.d.ts.map +1 -1
  13. package/dist/client/hooks.js +37 -4
  14. package/dist/client/hooks.js.map +1 -1
  15. package/dist/client/index.d.ts +1 -0
  16. package/dist/client/index.d.ts.map +1 -1
  17. package/dist/client/index.js +1 -0
  18. package/dist/client/index.js.map +1 -1
  19. package/dist/client/offline-sw.js +142 -0
  20. package/dist/commands/build.d.ts.map +1 -1
  21. package/dist/commands/build.js +279 -40
  22. package/dist/commands/build.js.map +1 -1
  23. package/dist/commands/dev.d.ts.map +1 -1
  24. package/dist/commands/dev.js +78 -2
  25. package/dist/commands/dev.js.map +1 -1
  26. package/dist/commands/start.js +1 -1
  27. package/dist/commands/start.js.map +1 -1
  28. package/dist/components/Image.d.ts +0 -12
  29. package/dist/components/Image.d.ts.map +1 -1
  30. package/dist/components/Image.js +2 -29
  31. package/dist/components/Image.js.map +1 -1
  32. package/dist/components/ImageServer.d.ts +20 -0
  33. package/dist/components/ImageServer.d.ts.map +1 -0
  34. package/dist/components/ImageServer.js +37 -0
  35. package/dist/components/ImageServer.js.map +1 -0
  36. package/dist/components/index.d.ts +1 -1
  37. package/dist/components/index.d.ts.map +1 -1
  38. package/dist/components/index.js +1 -1
  39. package/dist/components/index.js.map +1 -1
  40. package/dist/config.d.ts +66 -0
  41. package/dist/config.d.ts.map +1 -0
  42. package/dist/config.js +77 -0
  43. package/dist/config.js.map +1 -0
  44. package/dist/index.d.ts +2 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +1 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/server/app.d.ts.map +1 -1
  49. package/dist/server/app.js +251 -41
  50. package/dist/server/app.js.map +1 -1
  51. package/dist/server/cache.d.ts.map +1 -1
  52. package/dist/server/cache.js +26 -4
  53. package/dist/server/cache.js.map +1 -1
  54. package/dist/server/csrf.d.ts.map +1 -1
  55. package/dist/server/csrf.js +5 -0
  56. package/dist/server/csrf.js.map +1 -1
  57. package/dist/server/revalidate.d.ts.map +1 -1
  58. package/dist/server/revalidate.js +10 -3
  59. package/dist/server/revalidate.js.map +1 -1
  60. package/dist/ssr/html.d.ts +7 -0
  61. package/dist/ssr/html.d.ts.map +1 -1
  62. package/dist/ssr/html.js +24 -4
  63. package/dist/ssr/html.js.map +1 -1
  64. package/dist/ssr/ppr.d.ts.map +1 -1
  65. package/dist/ssr/ppr.js +2 -1
  66. package/dist/ssr/ppr.js.map +1 -1
  67. package/dist/ssr/render.d.ts +5 -0
  68. package/dist/ssr/render.d.ts.map +1 -1
  69. package/dist/ssr/render.js +2 -1
  70. package/dist/ssr/render.js.map +1 -1
  71. package/package.json +9 -4
  72. package/src/analytics/handler.ts +15 -10
  73. package/src/cli.ts +9 -2
  74. package/src/client/federation.ts +55 -0
  75. package/src/client/hooks.ts +42 -4
  76. package/src/client/index.ts +1 -0
  77. package/src/client/offline-sw.ts +7 -2
  78. package/src/commands/build.ts +335 -44
  79. package/src/commands/dev.ts +84 -2
  80. package/src/commands/start.ts +1 -1
  81. package/src/components/Image.tsx +2 -35
  82. package/src/components/ImageServer.ts +43 -0
  83. package/src/components/index.ts +1 -1
  84. package/src/config.ts +143 -0
  85. package/src/index.ts +2 -0
  86. package/src/server/app.ts +289 -35
  87. package/src/server/cache.ts +28 -4
  88. package/src/server/csrf.ts +5 -0
  89. package/src/server/revalidate.ts +14 -2
  90. package/src/ssr/html.ts +31 -3
  91. package/src/ssr/ppr.ts +2 -1
  92. package/src/ssr/render.ts +7 -0
  93. package/tsconfig.sw.json +18 -0
  94. package/tsconfig.tsbuildinfo +1 -1
package/src/config.ts ADDED
@@ -0,0 +1,143 @@
1
+ import { resolve } from "node:path";
2
+ import { existsSync } from "node:fs";
3
+ import type { ConfigEnv } from "vite";
4
+
5
+ // ─── Types ────────────────────────────────────────────────────────────────────
6
+
7
+ export interface FederationConfig {
8
+ /**
9
+ * This application's name — used as the namespace for its exposed modules.
10
+ * Other apps reference exposed components as `<name>/<ExposedName>`.
11
+ *
12
+ * @example "marketing" // exposed modules served at /_alabjs/remotes/marketing/
13
+ */
14
+ name: string;
15
+
16
+ /**
17
+ * Modules this app exposes to remote hosts.
18
+ *
19
+ * Key: public component name (e.g. `"Button"`).
20
+ * Value: module path relative to the project root (e.g. `"./app/components/Button"`).
21
+ *
22
+ * Each entry is built as a self-contained ESM chunk served at
23
+ * `/_alabjs/remotes/<name>/<key>.js`.
24
+ */
25
+ exposes?: Record<string, string>;
26
+
27
+ /**
28
+ * Remote apps this app consumes.
29
+ *
30
+ * Key: remote app name (matches the remote's `federation.name`).
31
+ * Value: base URL where the remote app is hosted.
32
+ *
33
+ * AlabJS injects a `<script type="importmap">` into every page so that
34
+ * `import("RemoteName/ComponentName")` resolves to the remote's pre-built
35
+ * ESM chunk without any bundler runtime.
36
+ *
37
+ * @example { "RemoteApp": "https://remote.example.com" }
38
+ */
39
+ remotes?: Record<string, string>;
40
+
41
+ /**
42
+ * Extra bare-specifier packages to externalize from exposed modules and
43
+ * provide as shared singletons via the import map.
44
+ *
45
+ * `react`, `react/jsx-runtime`, `react-dom`, and `react-dom/client` are
46
+ * always shared automatically — you do not need to list them.
47
+ */
48
+ shared?: string[];
49
+ }
50
+
51
+ export interface AlabConfig {
52
+ federation?: FederationConfig;
53
+ }
54
+
55
+ // ─── defineConfig ─────────────────────────────────────────────────────────────
56
+
57
+ /** Define your AlabJS configuration with full TypeScript type inference. */
58
+ export function defineConfig(config: AlabConfig): AlabConfig {
59
+ return config;
60
+ }
61
+
62
+ // ─── loadUserConfig ───────────────────────────────────────────────────────────
63
+
64
+ /**
65
+ * Load `alabjs.config.ts` (or `.js` / `.mjs`) from the given project root.
66
+ * Uses Vite's `loadConfigFromFile` so TypeScript configs are supported with
67
+ * zero extra dependencies. Returns `{}` if no config file is found.
68
+ */
69
+ export async function loadUserConfig(cwd: string): Promise<AlabConfig> {
70
+ const candidates = [
71
+ "alabjs.config.ts",
72
+ "alabjs.config.js",
73
+ "alabjs.config.mjs",
74
+ ];
75
+
76
+ for (const name of candidates) {
77
+ const configPath = resolve(cwd, name);
78
+ if (!existsSync(configPath)) continue;
79
+
80
+ try {
81
+ const { loadConfigFromFile } = await import("vite");
82
+ const env: ConfigEnv = { command: "build", mode: "production" };
83
+ const result = await loadConfigFromFile(env, configPath, cwd);
84
+ return (result?.config as AlabConfig | undefined) ?? {};
85
+ } catch (err) {
86
+ console.warn(
87
+ `[alabjs] warning: failed to load ${name}: ${String(err)}`,
88
+ );
89
+ }
90
+ break; // only try the first match
91
+ }
92
+
93
+ return {};
94
+ }
95
+
96
+ // ─── buildImportMap ───────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Generate the `<script type="importmap">` JSON for a federation config.
100
+ *
101
+ * - **Production** (`dev = false`): React singleton is served from the host
102
+ * app's own `/_alabjs/vendor/*.js` files (built by `alab build`).
103
+ * This guarantees a single React instance across host and all remotes.
104
+ *
105
+ * - **Dev** (`dev = true`): Only remote scope mappings are emitted.
106
+ * React is already provided by Vite's module graph — injecting a duplicate
107
+ * entry would create a second instance and break hooks.
108
+ *
109
+ * Returns `null` when the config has no remotes (no import map needed).
110
+ */
111
+ export function buildImportMap(
112
+ federation: FederationConfig,
113
+ dev = false,
114
+ ): string | null {
115
+ const { remotes = {}, shared = [] } = federation;
116
+
117
+ if (Object.keys(remotes).length === 0 && !dev) return null;
118
+ if (Object.keys(remotes).length === 0) return null;
119
+
120
+ const imports: Record<string, string> = {};
121
+
122
+ // Trailing-slash scope: `RemoteApp/Button` → `https://remote.example.com/_alabjs/remotes/RemoteApp/Button.js`
123
+ for (const [remoteName, baseUrl] of Object.entries(remotes)) {
124
+ imports[`${remoteName}/`] = `${baseUrl.replace(/\/$/, "")}/_alabjs/remotes/${remoteName}/`;
125
+ }
126
+
127
+ if (!dev) {
128
+ // Production: shared React singleton from locally-built vendor files.
129
+ imports["react"] = "/_alabjs/vendor/react.js";
130
+ imports["react/jsx-runtime"] = "/_alabjs/vendor/react-jsx-runtime.js";
131
+ imports["react-dom"] = "/_alabjs/vendor/react-dom.js";
132
+ imports["react-dom/client"] = "/_alabjs/vendor/react-dom-client.js";
133
+
134
+ // Extra shared packages declared by the user
135
+ for (const pkg of shared) {
136
+ if (!imports[pkg]) {
137
+ imports[pkg] = `/_alabjs/vendor/${pkg.replace(/\//g, "--")}.js`;
138
+ }
139
+ }
140
+ }
141
+
142
+ return JSON.stringify({ imports });
143
+ }
package/src/index.ts CHANGED
@@ -9,3 +9,5 @@ export type {
9
9
  CdnCache,
10
10
  } from "./types/index.js";
11
11
  export { createApp } from "./server/app.js";
12
+ export { defineConfig } from "./config.js";
13
+ export type { AlabConfig, FederationConfig } from "./config.js";
package/src/server/app.ts CHANGED
@@ -1,7 +1,8 @@
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
+ import { createGzip, createBrotliCompress } from "node:zlib";
5
6
  import { toNodeListener } from "h3";
6
7
  import type { RouteManifest } from "../router/manifest.js";
7
8
  import { renderToResponse } from "../ssr/render.js";
@@ -15,21 +16,51 @@ import { checkRevalidateAuth, applyRevalidate } from "./revalidate.js";
15
16
  import { applyCdnHeaders, type CdnCache } from "./cdn.js";
16
17
  import { getPPRShell, injectBuildIdIntoPPRShell, PPR_CACHE_SUBDIR } from "../ssr/ppr.js";
17
18
  import { handleVitalsBeacon, handleAnalyticsDashboard } from "../analytics/handler.js";
19
+ import { buildImportMap } from "../config.js";
20
+ import type { FederationConfig } from "../config.js";
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
+ }
18
41
 
19
42
  /**
20
43
  * Find layout file paths (relative to cwd root) for a given route.file, ordered outermost→innermost.
21
44
  * Checks the compiled dist directory for the existence of each layout.
22
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
+
23
51
  function findProdLayoutFiles(routeFile: string, distDir: string): string[] {
24
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.
25
56
  const pageDir = dirname(routeFile);
26
57
  const parts = pageDir.split("/");
27
58
  const layouts: string[] = [];
28
59
  for (let i = 1; i <= parts.length; i++) {
29
60
  const dir = parts.slice(0, i).join("/");
30
- const layoutRelPath = `${dir}/layout.tsx`;
31
- if (existsSync(join(distDir, "server", layoutRelPath))) {
32
- layouts.push(layoutRelPath);
61
+ const compiledPath = `${dir}/layout.js`;
62
+ if (existsSync(join(distDir, "server", compiledPath))) {
63
+ layouts.push(`${dir}/layout.tsx`);
33
64
  }
34
65
  }
35
66
  return layouts;
@@ -41,7 +72,7 @@ function findProdLayoutFiles(routeFile: string, distDir: string): string[] {
41
72
  function findProdErrorFile(routeFile: string, distDir: string): string | null {
42
73
  let dir = dirname(routeFile);
43
74
  while (dir.length > 0 && dir !== ".") {
44
- const candidate = `${dir}/error.tsx`;
75
+ const candidate = `${dir}/error.js`;
45
76
  if (existsSync(join(distDir, "server", candidate))) return candidate;
46
77
  const parent = dirname(dir);
47
78
  if (parent === dir) break;
@@ -53,8 +84,8 @@ function findProdErrorFile(routeFile: string, distDir: string): string | null {
53
84
  function findProdLoadingFile(routeFile: string, distDir: string): string | null {
54
85
  let dir = dirname(routeFile);
55
86
  while (dir.length > 0 && dir !== ".") {
56
- const candidate = `${dir}/loading.tsx`;
57
- 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`;
58
89
  const parent = dirname(dir);
59
90
  if (parent === dir) break;
60
91
  dir = parent;
@@ -86,6 +117,32 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
86
117
  buildId = readFileSync(resolve(distDir, "BUILD_ID"), "utf8").trim() || undefined;
87
118
  } catch { /* no BUILD_ID file — skew protection disabled */ }
88
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
+
135
+ // Load federation config written by `alab build`. Used to:
136
+ // 1. Serve `/_alabjs/federation-manifest.json` (remote discovery)
137
+ // 2. Inject `<script type="importmap">` into every page (host → remote routing)
138
+ let federationConfig: FederationConfig | undefined;
139
+ let importMapJson: string | null = null;
140
+ try {
141
+ const fedJson = readFileSync(resolve(distDir, "federation-config.json"), "utf8");
142
+ federationConfig = JSON.parse(fedJson) as FederationConfig;
143
+ importMapJson = buildImportMap(federationConfig, /* dev= */ false);
144
+ } catch { /* no federation config — federation disabled */ }
145
+
89
146
  // Absolute path to the PPR shell cache directory.
90
147
  const pprCacheDir = resolve(distDir, "../../", PPR_CACHE_SUBDIR);
91
148
 
@@ -98,17 +155,49 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
98
155
  res.setHeader("referrer-policy", "strict-origin-when-cross-origin");
99
156
  res.setHeader("permissions-policy", "camera=(), microphone=(), geolocation=()");
100
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.
168
+ res.setHeader(
169
+ "content-security-policy",
170
+ [
171
+ "default-src 'self'",
172
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
173
+ "style-src 'self' 'unsafe-inline'",
174
+ "img-src 'self' data: blob: https:",
175
+ "font-src 'self' data: https:",
176
+ "connect-src 'self'",
177
+ "media-src 'self'",
178
+ "object-src 'none'",
179
+ "base-uri 'self'",
180
+ "form-action 'self'",
181
+ "frame-ancestors 'self'",
182
+ ].join("; "),
183
+ );
101
184
  // HSTS — only meaningful over HTTPS; set in production only.
102
185
  res.setHeader("strict-transport-security", "max-age=31536000; includeSubDomains");
103
186
  }),
104
187
  );
105
188
 
106
- // ─── User middleware (middleware.ts compiled to dist/server/middleware.ts) ───
107
- const middlewareModulePath = `${distDir}/server/middleware.ts`;
189
+ // ─── User middleware (middleware.ts compiled to dist/server/middleware.js) ───
190
+ const middlewareModulePath = `${distDir}/server/middleware.js`;
108
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;
109
195
  app.use(
110
196
  defineEventHandler(async (event) => {
111
- const mod = await import(middlewareModulePath) as MiddlewareModule;
197
+ if (!_middlewareCache) {
198
+ _middlewareCache = await import(middlewareModulePath) as MiddlewareModule;
199
+ }
200
+ const mod = _middlewareCache;
112
201
  if (typeof mod.middleware !== "function") return;
113
202
  const req = event.node.req;
114
203
  const res = event.node.res;
@@ -162,7 +251,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
162
251
  };
163
252
 
164
253
  app.use(
165
- defineEventHandler((event) => {
254
+ defineEventHandler(async (event) => {
166
255
  const req = event.node.req;
167
256
  const res = event.node.res;
168
257
  if (req.method !== "GET" && req.method !== "HEAD") return;
@@ -173,25 +262,89 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
173
262
  try { relPath = decodeURIComponent(rawPath); } catch { return; }
174
263
  if (relPath.includes("..")) return;
175
264
 
265
+ const acceptEncoding = (req.headers["accept-encoding"] ?? "") as string;
266
+ const useBrotli = acceptEncoding.includes("br");
267
+ const useGzip = !useBrotli && acceptEncoding.includes("gzip");
268
+
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
+
176
283
  const ext = extname(relPath).toLowerCase();
177
284
  const contentType = MIME_TYPES[ext];
178
285
  if (!contentType) return; // skip extensionless paths (page routes)
179
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
+ */
294
+ function serveFile(
295
+ filePath: string,
296
+ fileSize: number,
297
+ mtimeMs: number,
298
+ cacheControl: string,
299
+ mime: string,
300
+ ): Promise<void> {
301
+ // ETag from file size + mtime — both already known from the caller's stat().
302
+ const etag = `"${fileSize.toString(36)}-${mtimeMs.toString(36)}"`;
303
+ res.setHeader("etag", etag);
304
+ res.setHeader("vary", "Accept-Encoding");
305
+
306
+ if (req.headers["if-none-match"] === etag) {
307
+ res.statusCode = 304;
308
+ res.end();
309
+ return Promise.resolve();
310
+ }
311
+
312
+ res.setHeader("content-type", mime);
313
+ res.setHeader("cache-control", cacheControl);
314
+
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
+ });
332
+ }
333
+
180
334
  // 1. Built client assets (JS chunks, CSS, source maps)
181
335
  const clientCandidate = join(clientDir, relPath);
182
336
  if (existsSync(clientCandidate)) {
183
337
  const stat = statSync(clientCandidate);
184
338
  if (stat.isFile()) {
185
- res.setHeader("content-type", contentType);
186
- res.setHeader("content-length", stat.size);
187
- // Immutable cache for hashed assets; short TTL for others
188
339
  const isHashed = /\.[a-f0-9]{8,}\.[a-z]+$/.test(relPath);
189
- res.setHeader("cache-control", isHashed
190
- ? "public, max-age=31536000, immutable"
191
- : "public, max-age=3600");
192
- if (req.method === "HEAD") { res.end(); return null; }
193
- createReadStream(clientCandidate).pipe(res);
194
- return null;
340
+ await serveFile(
341
+ clientCandidate,
342
+ stat.size,
343
+ stat.mtimeMs,
344
+ isHashed ? "public, max-age=31536000, immutable" : "public, max-age=3600",
345
+ contentType,
346
+ );
347
+ return;
195
348
  }
196
349
  }
197
350
 
@@ -200,20 +353,34 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
200
353
  if (existsSync(publicCandidate)) {
201
354
  const stat = statSync(publicCandidate);
202
355
  if (stat.isFile()) {
203
- res.setHeader("content-type", contentType);
204
- res.setHeader("content-length", stat.size);
205
- res.setHeader("cache-control", "public, max-age=3600");
206
- if (req.method === "HEAD") { res.end(); return null; }
207
- createReadStream(publicCandidate).pipe(res);
208
- return null;
356
+ await serveFile(publicCandidate, stat.size, stat.mtimeMs, "public, max-age=3600", contentType);
357
+ return;
209
358
  }
210
359
  }
211
- return undefined;
212
360
  }),
213
361
  );
214
362
 
215
363
  // ─── Built-in routes ────────────────────────────────────────────────────────
216
364
 
365
+ // Federation manifest — remote apps can advertise what they expose
366
+ router.get(
367
+ "/_alabjs/federation-manifest.json",
368
+ defineEventHandler((event) => {
369
+ const res = event.node.res;
370
+ res.setHeader("content-type", "application/json; charset=utf-8");
371
+ res.setHeader("access-control-allow-origin", "*");
372
+ res.setHeader("cache-control", "no-store");
373
+
374
+ const manifestPath = resolve(distDir, "client/_alabjs/federation-manifest.json");
375
+ if (!existsSync(manifestPath)) {
376
+ res.statusCode = 404;
377
+ return JSON.stringify({ error: "No federation exposes configured." });
378
+ }
379
+ res.statusCode = 200;
380
+ return readFileSync(manifestPath, "utf8");
381
+ }),
382
+ );
383
+
217
384
  // Rust-powered image optimisation — resize + JPEG encode via `alab-napi`
218
385
  router.get(
219
386
  "/_alabjs/image",
@@ -261,6 +428,87 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
261
428
  }),
262
429
  );
263
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
+
264
512
  // Auto sitemap.xml from route manifest
265
513
  router.get(
266
514
  "/sitemap.xml",
@@ -280,7 +528,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
280
528
  if (route.kind !== "api") continue;
281
529
 
282
530
  const h3ApiPath = route.path.replace(/\[([^\]]+)\]/g, ":$1");
283
- const apiModulePath = `${distDir}/server/${route.file}`;
531
+ const apiModulePath = `${distDir}/server/${toJsPath(route.file)}`;
284
532
 
285
533
  for (const method of ["get", "post", "put", "patch", "delete", "head"] as const) {
286
534
  router[method](
@@ -369,11 +617,11 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
369
617
  }
370
618
 
371
619
  // Dynamically import the compiled page module from the dist directory.
372
- const pageModulePath = `${distDir}/server/${route.file}`;
620
+ const pageModulePath = `${distDir}/server/${toJsPath(route.file)}`;
373
621
  const mod = await import(pageModulePath) as {
374
622
  default?: unknown;
375
623
  metadata?: PageMetadata;
376
- generateMetadata?: (params: Record<string, string>) => PageMetadata | Promise<PageMetadata>;
624
+ generateMetadata?: (props: { params: Record<string, string>; searchParams: Record<string, string> }) => PageMetadata | Promise<PageMetadata>;
377
625
  ssr?: boolean;
378
626
  cdnCache?: CdnCache;
379
627
  ppr?: boolean;
@@ -414,7 +662,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
414
662
  // Support both static metadata and dynamic generateMetadata (production fix)
415
663
  const metadata: PageMetadata =
416
664
  typeof mod.generateMetadata === "function"
417
- ? await mod.generateMetadata(params)
665
+ ? await mod.generateMetadata({ params, searchParams })
418
666
  : (mod.metadata ?? {});
419
667
 
420
668
  const ssrEnabled = mod.ssr === true;
@@ -422,7 +670,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
422
670
  // ── Layouts ──────────────────────────────────────────────────────────
423
671
  const layoutRelPaths = findProdLayoutFiles(route.file, distDir);
424
672
  const layoutMods = await Promise.all(
425
- layoutRelPaths.map((p) => import(`${distDir}/server/${p}`)),
673
+ layoutRelPaths.map((p) => import(`${distDir}/server/${toJsPath(p)}`)),
426
674
  );
427
675
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
428
676
  const layouts = layoutMods.map((m: any) => m.default).filter((c: unknown): c is any => typeof c === "function");
@@ -457,6 +705,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
457
705
  ssr: ssrEnabled,
458
706
  headExtra,
459
707
  ...(buildId ? { buildId } : {}),
708
+ ...(importMapJson ? { importMapJson } : {}),
460
709
  });
461
710
  } catch (err) {
462
711
  // ── error.tsx fallback ────────────────────────────────────────────
@@ -464,7 +713,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
464
713
  if (errorRelPath) {
465
714
  try {
466
715
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
467
- const errorMod = await import(`${distDir}/server/${errorRelPath}`) as any;
716
+ const errorMod = await import(`${distDir}/server/${toJsPath(errorRelPath)}`) as any;
468
717
  const ErrorPage = errorMod.default;
469
718
  if (typeof ErrorPage === "function") {
470
719
  renderToResponse(res, {
@@ -475,6 +724,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
475
724
  routeFile: errorRelPath,
476
725
  ssr: true,
477
726
  headExtra,
727
+ ...(importMapJson ? { importMapJson } : {}),
478
728
  });
479
729
  return;
480
730
  }
@@ -495,7 +745,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
495
745
  app.use(router);
496
746
 
497
747
  // ─── 404 / not-found fallback ────────────────────────────────────────────────
498
- const notFoundPath = `${distDir}/server/app/not-found.tsx`;
748
+ const notFoundPath = `${distDir}/server/app/not-found.js`;
499
749
  app.use(
500
750
  defineEventHandler(async (event) => {
501
751
  const res = event.node.res;
@@ -514,6 +764,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
514
764
  metadata: { title: "404 — Not Found" },
515
765
  routeFile: "app/not-found.tsx",
516
766
  ssr: true,
767
+ ...(importMapJson ? { importMapJson } : {}),
517
768
  });
518
769
  return;
519
770
  }
@@ -530,8 +781,11 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
530
781
 
531
782
  return {
532
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}`;
533
787
  const server = createServer(toNodeListener(app));
534
- server.listen(port, () => {
788
+ server.listen(port, "0.0.0.0", () => {
535
789
  console.log(` alab ready at http://localhost:${port}`);
536
790
  });
537
791
  },