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
@@ -1,7 +1,10 @@
1
1
  import { build as viteBuild, type PluginOption } from "vite";
2
- import { resolve } from "node:path";
2
+ import { resolve, relative, isAbsolute } from "node:path";
3
+ import { pathToFileURL } from "node:url";
3
4
  import { spawn, execSync } from "node:child_process";
4
- import { existsSync, writeFileSync, readFileSync } from "node:fs";
5
+ import { existsSync, writeFileSync, readFileSync, mkdirSync } from "node:fs";
6
+ import { loadUserConfig } from "../config.js";
7
+ import type { FederationConfig } from "../config.js";
5
8
  import { preRenderPPRShell, findBuildLayoutFiles, PPR_CACHE_SUBDIR } from "../ssr/ppr.js";
6
9
  import type { RouteManifest } from "../router/manifest.js";
7
10
 
@@ -149,7 +152,10 @@ export async function build({ cwd, skipTypecheck = false, mode = "ssr", analyze
149
152
  ...(visualizerPlugin ? [visualizerPlugin] : []),
150
153
  ],
151
154
  build: {
152
- outDir: resolve(cwd, ".alabjs/dist"),
155
+ // Output client assets to .alabjs/dist/client/ so the production server's
156
+ // static handler (which serves from distDir/client/) can find them.
157
+ outDir: resolve(cwd, ".alabjs/dist/client"),
158
+ manifest: true,
153
159
  ssrManifest: true,
154
160
  rolldownOptions: {
155
161
  // In SSR mode, we don't use an index.html as the entry point.
@@ -167,9 +173,19 @@ export async function build({ cwd, skipTypecheck = false, mode = "ssr", analyze
167
173
 
168
174
  await Promise.all(tasks);
169
175
 
170
- // Write a stable build ID for skew protection (must run after Vite so the
171
- // route-manifest.json is in place for the content-hash fallback path).
172
176
  const distDir = resolve(cwd, ".alabjs/dist");
177
+
178
+ // Scan the app/ directory with the Rust router, normalize paths, and write
179
+ // route-manifest.json. Must run before writeBuildId (hash) and buildPPRShells.
180
+ const manifest = await buildRouteManifest(cwd, distDir);
181
+
182
+ // Compile all app pages, layouts, and server functions to .alabjs/dist/server/.
183
+ // Must run after buildRouteManifest so we have the entry list, and before
184
+ // buildPPRShells which imports the compiled modules.
185
+ await buildSsrBundle(cwd, distDir, manifest);
186
+
187
+ // Write a stable build ID for skew protection (must run after the route
188
+ // manifest is in place for the content-hash fallback path).
173
189
  await writeBuildId(distDir, cwd);
174
190
  await buildPPRShells(distDir, cwd);
175
191
 
@@ -177,6 +193,12 @@ export async function build({ cwd, skipTypecheck = false, mode = "ssr", analyze
177
193
  // Output: .alabjs/dist/client/_alabjs/offline-sw.js (served at /_alabjs/offline-sw.js)
178
194
  await buildOfflineSw(cwd);
179
195
 
196
+ // Build federation vendor + exposed modules if configured.
197
+ const userConfig = await loadUserConfig(cwd);
198
+ if (userConfig.federation) {
199
+ await buildFederation(cwd, userConfig.federation);
200
+ }
201
+
180
202
  console.log("\n alab build complete → .alabjs/dist");
181
203
  }
182
204
 
@@ -234,57 +256,323 @@ async function buildPPRShells(distDir: string, cwd: string): Promise<void> {
234
256
  const pprCacheDir = resolve(cwd, PPR_CACHE_SUBDIR);
235
257
  let count = 0;
236
258
 
237
- for (const route of pageRoutes) {
238
- const modulePath = resolve(distDir, "server", route.file);
239
- if (!existsSync(modulePath)) continue;
259
+ // Signal to useServerData that it must not make network calls — return
260
+ // empty placeholders instead so components can render their static shell.
261
+ process.env["ALAB_PPR_PRERENDER"] = "1";
240
262
 
241
- // Dynamic import — module is compiled ESM, importable by Node directly.
242
- const mod = await import(modulePath) as {
243
- default?: unknown;
244
- ppr?: unknown;
245
- metadata?: Record<string, unknown>;
246
- };
263
+ try {
264
+ for (const route of pageRoutes) {
265
+ // esbuild compiles .tsx/.ts → .js; use the compiled path.
266
+ const modulePath = resolve(distDir, "server", route.file.replace(/\.(tsx?)$/, ".js"));
267
+ if (!existsSync(modulePath)) continue;
268
+
269
+ // Dynamic import — on Windows absolute paths need a file:// URL.
270
+ const mod = await import(pathToFileURL(modulePath).href) as {
271
+ default?: unknown;
272
+ ppr?: unknown;
273
+ metadata?: Record<string, unknown>;
274
+ };
275
+
276
+ if (mod.ppr !== true) continue;
277
+ if (typeof mod.default !== "function") {
278
+ console.warn(` alab ppr: ${route.file} has no default export — skipping.`);
279
+ continue;
280
+ }
247
281
 
248
- if (mod.ppr !== true) continue;
249
- if (typeof mod.default !== "function") {
250
- console.warn(` alab ppr: ${route.file} has no default export — skipping.`);
251
- continue;
282
+ // Load layout modules (outermost innermost).
283
+ const layoutPaths = findBuildLayoutFiles(route.file, distDir);
284
+ const layoutMods = await Promise.all(
285
+ layoutPaths.map((p) => import(pathToFileURL(resolve(distDir, "server", p)).href)),
286
+ );
287
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
288
+ const layouts = layoutMods.map((m: any) => m.default).filter((c: unknown) => typeof c === "function");
289
+
290
+ try {
291
+ await preRenderPPRShell({
292
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
293
+ Page: mod.default as any,
294
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
295
+ layouts: layouts as any[],
296
+ shellOpts: {
297
+ metadata: (mod.metadata as never) ?? {},
298
+ paramsJson: "{}",
299
+ searchParamsJson: "{}",
300
+ routeFile: route.file,
301
+ // PPR shells are static snapshots — client mounts via CSR (createRoot)
302
+ // rather than hydration to avoid mismatches with the pre-rendered HTML.
303
+ ssr: false,
304
+ layoutsJson: JSON.stringify(layoutPaths.map(p => p.replace(/\.js$/, ".tsx"))),
305
+ },
306
+ pprCacheDir,
307
+ routePath: route.path,
308
+ });
309
+ count++;
310
+ } catch (err) {
311
+ const msg = err instanceof Error ? err.message : String(err);
312
+ console.warn(` alab ppr: failed to pre-render ${route.path}: ${msg}`);
313
+ }
252
314
  }
315
+ } finally {
316
+ delete process.env["ALAB_PPR_PRERENDER"];
317
+ }
253
318
 
254
- // Load layout modules (outermost innermost).
255
- const layoutPaths = findBuildLayoutFiles(route.file, distDir);
256
- const layoutMods = await Promise.all(
257
- layoutPaths.map((p) => import(resolve(distDir, "server", p))),
258
- );
259
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
260
- const layouts = layoutMods.map((m: any) => m.default).filter((c: unknown) => typeof c === "function");
319
+ if (count > 0) {
320
+ console.log(` alab ppr → ${count} shell${count === 1 ? "" : "s"} written to ${PPR_CACHE_SUBDIR}`);
321
+ }
322
+ }
323
+
324
+ // ─── Federation build ─────────────────────────────────────────────────────────
325
+
326
+ /**
327
+ * Build all federation artefacts after the main SSR bundle:
328
+ * 1. Vendor ESM chunks for shared React singletons (`/_alabjs/vendor/`)
329
+ * 2. Exposed component modules (`/_alabjs/remotes/<name>/<ExposedName>.js`)
330
+ * 3. `federation-config.json` in the dist root (read by `alab start`)
331
+ * 4. `federation-manifest.json` in the client dir (served at runtime)
332
+ */
333
+ async function buildFederation(cwd: string, federation: FederationConfig): Promise<void> {
334
+ const { name, exposes = {}, remotes = {} } = federation;
335
+ const distClientAlab = resolve(cwd, ".alabjs/dist/client/_alabjs");
336
+
337
+ const hasExposes = Object.keys(exposes).length > 0;
338
+ const hasRemotes = Object.keys(remotes).length > 0;
339
+
340
+ if (hasRemotes || hasExposes) {
341
+ await buildFederationVendors(cwd, distClientAlab, federation.shared ?? []);
342
+ }
343
+
344
+ if (hasExposes) {
345
+ await buildFederationExposes(cwd, distClientAlab, name, exposes, federation.shared ?? []);
346
+ }
261
347
 
348
+ // Write federation config for the production server (import map generation).
349
+ writeFileSync(
350
+ resolve(cwd, ".alabjs/dist/federation-config.json"),
351
+ JSON.stringify(federation, null, 2),
352
+ "utf8",
353
+ );
354
+
355
+ console.log(
356
+ ` alab federation → ${Object.keys(exposes).length} exposed, ${Object.keys(remotes).length} remote(s)`,
357
+ );
358
+ }
359
+
360
+ /** Build React + react-dom (and any user `shared` packages) as standalone ESM vendor files. */
361
+ async function buildFederationVendors(
362
+ cwd: string,
363
+ distClientAlab: string,
364
+ extraShared: string[] = [],
365
+ ): Promise<void> {
366
+ const vendorDir = resolve(distClientAlab, "vendor");
367
+ mkdirSync(vendorDir, { recursive: true });
368
+
369
+ // Always vendor the React singleton packages.
370
+ const coreVendors: Array<{ specifier: string; output: string }> = [
371
+ { specifier: "react", output: "react.js" },
372
+ { specifier: "react/jsx-runtime", output: "react-jsx-runtime.js" },
373
+ { specifier: "react-dom", output: "react-dom.js" },
374
+ { specifier: "react-dom/client", output: "react-dom-client.js" },
375
+ ];
376
+ // User-declared shared packages (e.g. "date-fns", "zustand").
377
+ const extraVendors: Array<{ specifier: string; output: string }> = extraShared.map((pkg) => ({
378
+ specifier: pkg,
379
+ output: `${pkg.replace(/\//g, "--")}.js`,
380
+ }));
381
+ const vendors = [...coreVendors, ...extraVendors];
382
+
383
+ for (const { specifier, output } of vendors) {
384
+ const virtualId = `\0alabjs-vendor:${specifier}`;
262
385
  try {
263
- await preRenderPPRShell({
264
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
265
- Page: mod.default as any,
266
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
267
- layouts: layouts as any[],
268
- shellOpts: {
269
- metadata: (mod.metadata as never) ?? {},
270
- paramsJson: "{}",
271
- searchParamsJson: "{}",
272
- routeFile: route.file,
273
- ssr: true,
386
+ await viteBuild({
387
+ root: cwd,
388
+ configFile: false,
389
+ plugins: [{
390
+ name: "alabjs-federation-vendor",
391
+ resolveId: (id: string) => id === virtualId ? id : null,
392
+ load: (id: string) =>
393
+ id === virtualId
394
+ ? `export * from "${specifier}"; export { default } from "${specifier}";`
395
+ : null,
396
+ }],
397
+ build: {
398
+ outDir: vendorDir,
399
+ emptyOutDir: false,
400
+ lib: {
401
+ entry: virtualId,
402
+ formats: ["es"],
403
+ fileName: () => output,
404
+ },
405
+ minify: true,
274
406
  },
275
- pprCacheDir,
276
- routePath: route.path,
407
+ logLevel: "warn",
277
408
  });
278
- count++;
279
409
  } catch (err) {
280
- const msg = err instanceof Error ? err.message : String(err);
281
- console.warn(` alab ppr: failed to pre-render ${route.path}: ${msg}`);
410
+ console.warn(` alab federation: failed to build vendor ${specifier}: ${String(err)}`);
282
411
  }
283
412
  }
413
+ }
284
414
 
285
- if (count > 0) {
286
- console.log(` alab ppr → ${count} shell${count === 1 ? "" : "s"} written to ${PPR_CACHE_SUBDIR}`);
415
+ /** Build each exposed module as an externalized ESM chunk for remote consumption. */
416
+ async function buildFederationExposes(
417
+ cwd: string,
418
+ distClientAlab: string,
419
+ appName: string,
420
+ exposes: Record<string, string>,
421
+ shared: string[],
422
+ ): Promise<void> {
423
+ const remotesDir = resolve(distClientAlab, `remotes/${appName}`);
424
+ mkdirSync(remotesDir, { recursive: true });
425
+
426
+ const external = [
427
+ "react",
428
+ "react/jsx-runtime",
429
+ "react-dom",
430
+ "react-dom/client",
431
+ ...shared,
432
+ ];
433
+
434
+ for (const [exposedName, entryRelPath] of Object.entries(exposes)) {
435
+ const entryAbs = resolve(cwd, entryRelPath.replace(/^\.\//, ""));
436
+ try {
437
+ await viteBuild({
438
+ root: cwd,
439
+ configFile: false,
440
+ build: {
441
+ outDir: remotesDir,
442
+ emptyOutDir: false,
443
+ lib: {
444
+ entry: entryAbs,
445
+ formats: ["es"],
446
+ fileName: () => `${exposedName}.js`,
447
+ },
448
+ minify: true,
449
+ rolldownOptions: { external },
450
+ },
451
+ logLevel: "warn",
452
+ });
453
+ } catch (err) {
454
+ console.warn(` alab federation: failed to build exposed "${exposedName}": ${String(err)}`);
455
+ }
456
+ }
457
+
458
+ // Manifest consumed by host apps discovering what this remote exposes.
459
+ const manifest = {
460
+ name: appName,
461
+ exposes: Object.fromEntries(
462
+ Object.keys(exposes).map((k) => [k, `/_alabjs/remotes/${appName}/${k}.js`]),
463
+ ),
464
+ };
465
+ writeFileSync(
466
+ resolve(distClientAlab, "federation-manifest.json"),
467
+ JSON.stringify(manifest, null, 2),
468
+ "utf8",
469
+ );
470
+ }
471
+
472
+ /**
473
+ * Scan `app/` with the Rust router napi, normalize absolute file paths to
474
+ * cwd-relative, and write `route-manifest.json` to `distDir`.
475
+ *
476
+ * Returns the in-memory manifest so callers can use it immediately without
477
+ * reading the file back from disk.
478
+ */
479
+ async function buildRouteManifest(cwd: string, distDir: string): Promise<RouteManifest> {
480
+ const appDir = resolve(cwd, "app");
481
+ let manifest: RouteManifest = { routes: [] };
482
+
483
+ try {
484
+ type NapiRoutes = { buildRoutes(appDir: string): string };
485
+ const mod = await import("@alabjs/compiler") as unknown as { default?: NapiRoutes } & NapiRoutes;
486
+ const napi = (mod.default ?? mod) as NapiRoutes;
487
+ const json = napi.buildRoutes(appDir);
488
+ manifest = JSON.parse(json) as RouteManifest;
489
+
490
+ // The Rust scanner stores absolute paths; normalize to cwd-relative so the
491
+ // production server can construct `distDir/server/<file>` paths correctly.
492
+ for (const route of manifest.routes) {
493
+ if (isAbsolute(route.file)) {
494
+ route.file = relative(cwd, route.file);
495
+ }
496
+ }
497
+ } catch {
498
+ console.warn(
499
+ " alab warning: Rust compiler unavailable — route manifest will be empty.\n" +
500
+ " Run `cargo build --release -p alab-napi && bash scripts/copy-napi-binary.sh`.",
501
+ );
287
502
  }
503
+
504
+ mkdirSync(distDir, { recursive: true });
505
+ writeFileSync(resolve(distDir, "route-manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
506
+
507
+ const pages = manifest.routes.filter((r) => r.kind === "page").length;
508
+ const apis = manifest.routes.filter((r) => r.kind === "api").length;
509
+ console.log(` alab routes → ${pages} page(s), ${apis} api route(s)`);
510
+
511
+ return manifest;
512
+ }
513
+
514
+ /**
515
+ * Compile all SSR route files to `distDir/server/` using esbuild's per-file
516
+ * transpilation mode.
517
+ *
518
+ * We use esbuild directly (bundled with Vite) rather than a second viteBuild
519
+ * call because:
520
+ * 1. Preserves directory structure via `outbase` without needing
521
+ * `preserveModules` (which hangs with some Rolldown versions).
522
+ * 2. `packages: "external"` externalizes all npm specifiers while inlining
523
+ * local relative imports — avoids Node ESM extensionless-import failures.
524
+ * 3. Much faster: no second Vite startup overhead.
525
+ */
526
+ async function buildSsrBundle(cwd: string, distDir: string, manifest: RouteManifest): Promise<void> {
527
+ const entryFiles = manifest.routes.map((r) => resolve(cwd, r.file));
528
+
529
+ // Include top-level middleware.ts if present.
530
+ const middlewarePath = resolve(cwd, "middleware.ts");
531
+ if (existsSync(middlewarePath)) entryFiles.push(middlewarePath);
532
+
533
+ if (entryFiles.length === 0) return;
534
+
535
+ // Build the import.meta.env replacement object for esbuild.
536
+ // Node.js never defines import.meta.env (it's a Vite-only concept), so if
537
+ // we leave it undefined the compiled server modules throw at runtime on any
538
+ // reference to import.meta.env.*. We mirror exactly what Vite inlines for
539
+ // the client build: standard constants + ALAB_PUBLIC_* / VITE_* vars.
540
+ const publicEnv: Record<string, string> = {};
541
+ for (const [key, val] of Object.entries(process.env)) {
542
+ if (key.startsWith("ALAB_PUBLIC_") || key.startsWith("VITE_")) {
543
+ publicEnv[key] = val ?? "";
544
+ }
545
+ }
546
+ const metaEnv = {
547
+ PROD: true,
548
+ DEV: false,
549
+ SSR: true,
550
+ MODE: "production",
551
+ BASE_URL: "/",
552
+ ...publicEnv,
553
+ };
554
+
555
+ const { build: esbuild } = await import("esbuild");
556
+ await esbuild({
557
+ entryPoints: entryFiles,
558
+ outbase: cwd, // preserve directory structure: app/page.tsx → server/app/page.js
559
+ outdir: resolve(distDir, "server"),
560
+ format: "esm",
561
+ platform: "node",
562
+ target: "node22",
563
+ bundle: true, // bundle local imports (resolves extensionless paths)
564
+ packages: "external", // keep all npm specifiers (react, alabjs/*…) as-is
565
+ jsx: "automatic",
566
+ jsxImportSource: "react",
567
+ logLevel: "warning",
568
+ define: {
569
+ // Replace the entire import.meta.env expression so property accesses,
570
+ // destructuring, and optional-chaining all resolve correctly at runtime.
571
+ "import.meta.env": JSON.stringify(metaEnv),
572
+ },
573
+ });
574
+
575
+ console.log(" alab SSR bundle → .alabjs/dist/server");
288
576
  }
289
577
 
290
578
  /** Compile the offline service worker to a standalone iife bundle. */
@@ -305,7 +593,10 @@ async function buildOfflineSw(cwd: string): Promise<void> {
305
593
  fileName: () => "offline-sw.js",
306
594
  },
307
595
  minify: true,
308
- rolldownOptions: { output: { inlineDynamicImports: true } },
596
+ // Note: do NOT set rolldownOptions.output.inlineDynamicImports here.
597
+ // iife format sets codeSplitting:false which already implies
598
+ // inlineDynamicImports:true in Rolldown. Setting it explicitly
599
+ // produces a warning that can cause the build to stall in Rolldown/Vite 8+.
309
600
  },
310
601
  });
311
602
  } catch (err) {
@@ -1,6 +1,7 @@
1
1
  import { createServer } from "vite";
2
2
  import { resolve, join } from "node:path";
3
- import { readdirSync } from "node:fs";
3
+ import { readdirSync, existsSync as fsExistsSync, readFileSync as fsReadFileSync } from "node:fs";
4
+ import { loadUserConfig, buildImportMap } from "../config.js";
4
5
  import { Writable } from "node:stream";
5
6
  import type { IncomingMessage } from "node:http";
6
7
  import {
@@ -63,6 +64,46 @@ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
63
64
  export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions) {
64
65
  console.log(" alab starting dev server...\n");
65
66
 
67
+ // Load optional alabjs.config.ts for federation and other user config.
68
+ const userConfig = await loadUserConfig(cwd);
69
+ // In dev: only inject remote scope mappings (react is already in Vite's module graph).
70
+ const importMapJson = userConfig.federation
71
+ ? buildImportMap(userConfig.federation, /* dev= */ true)
72
+ : null;
73
+
74
+ // If this app exposes federation modules, build them once at dev startup so
75
+ // the `/_alabjs/remotes/` endpoint can serve them immediately.
76
+ // No hot-reloading of exposed modules in dev — restart the dev server after changes.
77
+ if (userConfig.federation?.exposes && Object.keys(userConfig.federation.exposes).length > 0) {
78
+ const { name, exposes, shared = [] } = userConfig.federation;
79
+ const devRemotesDir = resolve(cwd, `.alabjs/dev-remotes/${name}`);
80
+ try {
81
+ const { mkdirSync } = await import("node:fs");
82
+ const { build: viteBuildDev } = await import("vite");
83
+ mkdirSync(devRemotesDir, { recursive: true });
84
+
85
+ const external = ["react", "react/jsx-runtime", "react-dom", "react-dom/client", ...shared];
86
+ for (const [exposedName, entryRelPath] of Object.entries(exposes)) {
87
+ const entryAbs = resolve(cwd, entryRelPath.replace(/^\.\//, ""));
88
+ await viteBuildDev({
89
+ root: cwd,
90
+ configFile: false,
91
+ build: {
92
+ outDir: devRemotesDir,
93
+ emptyOutDir: false,
94
+ lib: { entry: entryAbs, formats: ["es"], fileName: () => `${exposedName}.js` },
95
+ minify: false,
96
+ rolldownOptions: { external },
97
+ },
98
+ logLevel: "warn",
99
+ });
100
+ }
101
+ console.log(` alab federation dev → ${Object.keys(exposes).length} exposed module(s) built`);
102
+ } catch (err) {
103
+ console.warn(` alab federation: dev build of exposed modules failed: ${String(err)}`);
104
+ }
105
+ }
106
+
66
107
  // Per-session build ID for skew protection in dev.
67
108
  // A new ID is generated each time the dev server starts so that a browser
68
109
  // tab left open across a restart will hard-reload on the next navigation
@@ -130,6 +171,46 @@ export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions)
130
171
  }
131
172
  }
132
173
 
174
+ // ── /_alabjs/federation-manifest.json — expose what this app exposes ────────
175
+ if (pathname === "/_alabjs/federation-manifest.json") {
176
+ const fed = userConfig.federation;
177
+ if (!fed?.exposes || Object.keys(fed.exposes).length === 0) {
178
+ res.statusCode = 404;
179
+ res.setHeader("content-type", "application/json");
180
+ res.end(JSON.stringify({ error: "No federation exposes configured." }));
181
+ return;
182
+ }
183
+ const manifest = {
184
+ name: fed.name,
185
+ exposes: Object.fromEntries(
186
+ Object.keys(fed.exposes).map((k) => [k, `/_alabjs/remotes/${fed.name}/${k}.js`]),
187
+ ),
188
+ };
189
+ res.statusCode = 200;
190
+ res.setHeader("content-type", "application/json; charset=utf-8");
191
+ res.setHeader("access-control-allow-origin", "*");
192
+ res.setHeader("cache-control", "no-store");
193
+ res.end(JSON.stringify(manifest, null, 2));
194
+ return;
195
+ }
196
+
197
+ // ── /_alabjs/remotes/:name/:module.js — serve pre-built exposed modules ─────
198
+ if (pathname.startsWith("/_alabjs/remotes/")) {
199
+ const relPath = pathname.slice("/_alabjs/remotes/".length);
200
+ const filePath = resolve(cwd, ".alabjs/dev-remotes", relPath);
201
+ if (fsExistsSync(filePath)) {
202
+ res.statusCode = 200;
203
+ res.setHeader("content-type", "application/javascript; charset=utf-8");
204
+ res.setHeader("access-control-allow-origin", "*");
205
+ res.setHeader("cache-control", "no-store");
206
+ res.end(fsReadFileSync(filePath));
207
+ return;
208
+ }
209
+ res.statusCode = 404;
210
+ res.end("Not found");
211
+ return;
212
+ }
213
+
133
214
  // ── /_alabjs/__devtools — debug dump of routes + server functions ───────────
134
215
  if (pathname === "/_alabjs/__devtools") {
135
216
  const devRoutes = scanDevRoutes(appDir);
@@ -387,7 +468,7 @@ export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions)
387
468
  onError(e) { fail(e); },
388
469
  });
389
470
  });
390
- const shell = htmlShellBefore({ metadata: { title: "404 — Not Found" }, paramsJson: "{}", searchParamsJson: "{}", routeFile: "app/not-found.tsx", ssr: true });
471
+ const shell = htmlShellBefore({ metadata: { title: "404 — Not Found" }, paramsJson: "{}", searchParamsJson: "{}", routeFile: "app/not-found.tsx", ssr: true, ...(importMapJson ? { importMapJson } : {}) });
391
472
  const rawHtml = `${shell}${nfContent}${htmlShellAfter({})}`;
392
473
  const html = await vite.transformIndexHtml(pathname, rawHtml);
393
474
  res.statusCode = 404;
@@ -535,6 +616,7 @@ export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions)
535
616
  loadingFile,
536
617
  ssr: ssrEnabled,
537
618
  buildId: devBuildId,
619
+ ...(importMapJson ? { importMapJson } : {}),
538
620
  };
539
621
  const renderPageHtml = async (): Promise<string> => {
540
622
  let rawHtml: string;
@@ -8,7 +8,7 @@ interface StartOptions {
8
8
  }
9
9
 
10
10
  export async function start({ cwd, port = 3000 }: StartOptions) {
11
- const manifestPath = resolve(cwd, ".alabjs/route-manifest.json");
11
+ const manifestPath = resolve(cwd, ".alabjs/dist/route-manifest.json");
12
12
 
13
13
  let manifest: RouteManifest;
14
14
  try {
@@ -104,38 +104,5 @@ export function Image({
104
104
  });
105
105
  }
106
106
 
107
- /**
108
- * Generate a Base64 blur-up placeholder for an image in `public/`.
109
- *
110
- * Calls the Rust napi binding to resize the image to 8px wide and encode it
111
- * as a tiny WebP, then Base64-encodes it into a data URL ready for `blurDataURL`.
112
- *
113
- * Run this in a server function — it reads from disk and must not run in the browser.
114
- *
115
- * @param src - Path relative to `public/` (e.g. `"/hero.jpg"`)
116
- * @param publicDir - Absolute path to the `public/` directory
117
- */
118
- export async function generateBlurPlaceholder(
119
- src: string,
120
- publicDir: string,
121
- ): Promise<string> {
122
- const { readFile } = await import("node:fs/promises");
123
- const { resolve } = await import("node:path");
124
-
125
- const safeSrc = src.replace(/\.\./g, "").replace(/^\/+/, "");
126
- const filePath = resolve(publicDir, safeSrc);
127
-
128
- const input = await readFile(filePath);
129
-
130
- let napi: { optimizeImage: (b: Buffer, q: number | null, w: number | null, h: null, fmt: string) => Promise<Buffer> };
131
- try {
132
- napi = (await import("@alabjs/compiler")) as typeof napi;
133
- } catch {
134
- // napi not built — return empty string (image still loads, just no blur effect)
135
- return "";
136
- }
137
-
138
- const tiny = await napi.optimizeImage(input, 40, 8, null, "webp");
139
- const b64 = Buffer.from(tiny).toString("base64");
140
- return `data:image/webp;base64,${b64}`;
141
- }
107
+ // generateBlurPlaceholder is server-only (uses node:fs/promises).
108
+ // Import it from "alabjs/components/server" in your server functions.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Server-only image utilities.
3
+ *
4
+ * Import from "alabjs/components/server" — do NOT import from "alabjs/components"
5
+ * because this file uses Node.js built-ins (fs, path) and must never be bundled
6
+ * for the browser.
7
+ */
8
+
9
+ /**
10
+ * Generate a Base64 blur-up placeholder for an image in `public/`.
11
+ *
12
+ * Calls the Rust napi binding to resize the image to 8px wide and encode it
13
+ * as a tiny WebP, then Base64-encodes it into a data URL ready for `blurDataURL`.
14
+ *
15
+ * Run this in a server function — it reads from disk and must not run in the browser.
16
+ *
17
+ * @param src - Path relative to `public/` (e.g. `"/hero.jpg"`)
18
+ * @param publicDir - Absolute path to the `public/` directory
19
+ */
20
+ export async function generateBlurPlaceholder(
21
+ src: string,
22
+ publicDir: string,
23
+ ): Promise<string> {
24
+ const { readFile } = await import("node:fs/promises");
25
+ const { resolve } = await import("node:path");
26
+
27
+ const safeSrc = src.replace(/\.\./g, "").replace(/^\/+/, "");
28
+ const filePath = resolve(publicDir, safeSrc);
29
+
30
+ const input = await readFile(filePath);
31
+
32
+ let napi: { optimizeImage: (b: Buffer, q: number | null, w: number | null, h: null, fmt: string) => Promise<Buffer> };
33
+ try {
34
+ napi = (await import("@alabjs/compiler")) as typeof napi;
35
+ } catch {
36
+ // napi not built — return empty string (image still loads, just no blur effect)
37
+ return "";
38
+ }
39
+
40
+ const tiny = await napi.optimizeImage(input, 40, 8, null, "webp");
41
+ const b64 = Buffer.from(tiny).toString("base64");
42
+ return `data:image/webp;base64,${b64}`;
43
+ }
@@ -1,4 +1,4 @@
1
- export { Image, generateBlurPlaceholder } from "./Image.js";
1
+ export { Image } from "./Image.js";
2
2
  export type { ImageProps } from "./Image.js";
3
3
  export { Link } from "./Link.js";
4
4
  export type { LinkProps } from "./Link.js";