alabjs 0.3.0-alpha.1 → 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 (51) hide show
  1. package/dist/commands/build.d.ts.map +1 -1
  2. package/dist/commands/build.js +122 -28
  3. package/dist/commands/build.js.map +1 -1
  4. package/dist/commands/dev.d.ts.map +1 -1
  5. package/dist/commands/dev.js +225 -2
  6. package/dist/commands/dev.js.map +1 -1
  7. package/dist/components/Link.d.ts +16 -0
  8. package/dist/components/Link.d.ts.map +1 -1
  9. package/dist/components/Link.js +10 -0
  10. package/dist/components/Link.js.map +1 -1
  11. package/dist/components/index.d.ts +2 -2
  12. package/dist/components/index.d.ts.map +1 -1
  13. package/dist/components/index.js +1 -1
  14. package/dist/components/index.js.map +1 -1
  15. package/dist/live/broadcaster.d.ts +64 -0
  16. package/dist/live/broadcaster.d.ts.map +1 -0
  17. package/dist/live/broadcaster.js +78 -0
  18. package/dist/live/broadcaster.js.map +1 -0
  19. package/dist/live/registry.d.ts +34 -0
  20. package/dist/live/registry.d.ts.map +1 -0
  21. package/dist/live/registry.js +33 -0
  22. package/dist/live/registry.js.map +1 -0
  23. package/dist/live/renderer.d.ts +22 -0
  24. package/dist/live/renderer.d.ts.map +1 -0
  25. package/dist/live/renderer.js +45 -0
  26. package/dist/live/renderer.js.map +1 -0
  27. package/dist/router/manifest.d.ts +1 -1
  28. package/dist/router/manifest.d.ts.map +1 -1
  29. package/dist/server/app.d.ts.map +1 -1
  30. package/dist/server/app.js +146 -0
  31. package/dist/server/app.js.map +1 -1
  32. package/dist/server/index.d.ts +1 -0
  33. package/dist/server/index.d.ts.map +1 -1
  34. package/dist/server/index.js +1 -0
  35. package/dist/server/index.js.map +1 -1
  36. package/dist/server/revalidate.d.ts.map +1 -1
  37. package/dist/server/revalidate.js +3 -0
  38. package/dist/server/revalidate.js.map +1 -1
  39. package/package.json +3 -3
  40. package/src/commands/build.ts +142 -30
  41. package/src/commands/dev.ts +246 -3
  42. package/src/components/Link.tsx +20 -0
  43. package/src/components/index.ts +2 -2
  44. package/src/live/broadcaster.ts +83 -0
  45. package/src/live/registry.ts +56 -0
  46. package/src/live/renderer.ts +54 -0
  47. package/src/router/manifest.ts +1 -1
  48. package/src/server/app.ts +159 -0
  49. package/src/server/index.ts +1 -0
  50. package/src/server/revalidate.ts +3 -0
  51. package/tsconfig.tsbuildinfo +1 -1
@@ -1,6 +1,6 @@
1
1
  import { createServer } from "vite";
2
- import { resolve, join } from "node:path";
3
- import { readdirSync, existsSync as fsExistsSync, readFileSync as fsReadFileSync } from "node:fs";
2
+ import { resolve, join, relative } from "node:path";
3
+ import { readdirSync, existsSync as fsExistsSync, readFileSync as fsReadFileSync, writeFileSync, mkdirSync, openSync, readSync, closeSync } from "node:fs";
4
4
  import { loadUserConfig, buildImportMap } from "../config.js";
5
5
  import { Writable } from "node:stream";
6
6
  import type { IncomingMessage } from "node:http";
@@ -19,7 +19,129 @@ import {
19
19
  } from "../server/cache.js";
20
20
  import { checkRevalidateAuth, applyRevalidate } from "../server/revalidate.js";
21
21
  import type { PageMetadata } from "../types/index.js";
22
- import type { Route } from "../router/manifest.js";
22
+ import type { Route, RouteManifest } from "../router/manifest.js";
23
+ import { subscribeToTag } from "../live/broadcaster.js";
24
+
25
+ // ─── FNV-1a hash (mirrors the Rust napi hashBuildId) ─────────────────────────
26
+ // Must produce the same output as the Vite plugin so `/_alabjs/live/:id`
27
+ // endpoints match the IDs embedded in the client bundle.
28
+ function fnv1aHex(str: string): string {
29
+ let hash = 0xcbf29ce484222325n;
30
+ for (const byte of Buffer.from(str, "utf-8")) {
31
+ hash ^= BigInt(byte);
32
+ hash = BigInt.asUintN(64, hash * 0x100000001b3n);
33
+ }
34
+ return hash.toString(16).padStart(16, "0");
35
+ }
36
+
37
+ // ─── Live component discovery ─────────────────────────────────────────────────
38
+ // Scans app/ for live component files and builds a hash → abs-path map so the
39
+ // dev SSE endpoint can locate components by the ID the client bundle embeds.
40
+ //
41
+ // Detection order (mirrors the Vite plugin transform hook):
42
+ // 1. *.live.tsx / *.live.ts filename convention — O(1), no file read needed
43
+ // 2. "use live" directive as first statement — reads first 200 bytes only
44
+ function buildLiveComponentMap(appDir: string): Map<string, string> {
45
+ const map = new Map<string, string>();
46
+
47
+ function hasUseLiveDirective(filePath: string): boolean {
48
+ try {
49
+ // Read only the first 200 bytes to avoid loading large files.
50
+ const fd = openSync(filePath, "r");
51
+ const buf = Buffer.alloc(200);
52
+ const bytesRead = readSync(fd, buf, 0, 200, 0);
53
+ closeSync(fd);
54
+ return buf.subarray(0, bytesRead).toString("utf-8").includes('"use live"');
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ function scan(dir: string) {
61
+ try {
62
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
63
+ const full = join(dir, entry.name);
64
+ if (entry.isDirectory()) { scan(full); continue; }
65
+ if (!/\.(tsx|ts)$/.test(entry.name)) continue;
66
+ // Convention-based detection (no file read).
67
+ if (/\.live\.(tsx|ts)$/.test(entry.name)) {
68
+ map.set(fnv1aHex(full), full);
69
+ continue;
70
+ }
71
+ // Directive-based detection (cheap partial read).
72
+ if (hasUseLiveDirective(full)) {
73
+ map.set(fnv1aHex(full), full);
74
+ }
75
+ }
76
+ } catch { /* ignore ENOENT */ }
77
+ }
78
+
79
+ scan(appDir);
80
+ return map;
81
+ }
82
+
83
+ // ─── Dev-time route type generation ──────────────────────────────────────────
84
+ async function emitDevRouteTypes(cwd: string, appDir: string): Promise<void> {
85
+ try {
86
+ type NapiRoutes = { buildRoutes(d: string): string; checkRouteRefs(d: string, m: string): string };
87
+ const mod = await import("@alabjs/compiler") as unknown as { default?: NapiRoutes } & NapiRoutes;
88
+ const napi = (mod.default ?? mod) as NapiRoutes;
89
+ if (typeof napi.buildRoutes !== "function") return;
90
+
91
+ const json = napi.buildRoutes(appDir);
92
+ const manifest = JSON.parse(json) as RouteManifest;
93
+
94
+ // Normalise absolute paths → cwd-relative
95
+ for (const r of manifest.routes) {
96
+ if (r.file.startsWith(cwd)) r.file = relative(cwd, r.file);
97
+ }
98
+
99
+ const pageRoutes = manifest.routes.filter((r) => r.kind === "page");
100
+ const routeTypes = pageRoutes.map((r) => {
101
+ const tsPath = r.path.replace(/\[([^\]]+)\]/g, "${string}");
102
+ return tsPath.includes("${") ? `\`${tsPath}\`` : JSON.stringify(tsPath);
103
+ });
104
+ const unionType = routeTypes.length > 0 ? routeTypes.join(" | ") : "string";
105
+
106
+ const content = [
107
+ "// AUTO-GENERATED by `alab dev` — do not edit manually.",
108
+ `export type AlabRoutes = ${unionType};`,
109
+ "declare module \"alabjs/router\" {",
110
+ " export function navigate(path: AlabRoutes, opts?: { replace?: boolean }): void;",
111
+ "}",
112
+ "declare module \"alabjs/components\" {",
113
+ " import type { ComponentProps } from \"react\";",
114
+ " interface RouteLinkProps extends Omit<ComponentProps<\"a\">, \"href\"> {",
115
+ " to: AlabRoutes;",
116
+ " replace?: boolean;",
117
+ " }",
118
+ " export function RouteLink(props: RouteLinkProps): JSX.Element;",
119
+ " export function Link(props: RouteLinkProps): JSX.Element;",
120
+ "}",
121
+ "",
122
+ ].join("\n");
123
+
124
+ const distDir = resolve(cwd, ".alabjs");
125
+ mkdirSync(distDir, { recursive: true });
126
+ writeFileSync(resolve(distDir, "routes.d.ts"), content, "utf8");
127
+
128
+ // Warn about route violations (non-fatal in dev)
129
+ if (typeof napi.checkRouteRefs === "function") {
130
+ const violations = JSON.parse(napi.checkRouteRefs(appDir, json)) as Array<{
131
+ file: string; kind: string; path: string; suggestion?: string;
132
+ }>;
133
+ if (violations.length > 0) {
134
+ console.warn(`\n alab ⚠ ${violations.length} route violation(s):`);
135
+ for (const v of violations) {
136
+ const rel = relative(cwd, v.file);
137
+ console.warn(` ✗ ${rel} "${v.path}" (${v.kind.replace("_", " ")})`);
138
+ if (v.suggestion) console.warn(` → ${v.suggestion}`);
139
+ }
140
+ console.warn("");
141
+ }
142
+ }
143
+ } catch { /* napi binary not available — skip silently */ }
144
+ }
23
145
 
24
146
  interface DevOptions {
25
147
  cwd: string;
@@ -112,6 +234,14 @@ export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions)
112
234
 
113
235
  const appDir = resolve(cwd, "app");
114
236
 
237
+ // Generate .alabjs/routes.d.ts and warn about route violations (non-fatal in dev).
238
+ void emitDevRouteTypes(cwd, appDir);
239
+
240
+ // Build live component map: hash → absolute file path.
241
+ // Re-built lazily on each request so new *.live.tsx files are picked up
242
+ // without restarting the dev server.
243
+ let liveMap = buildLiveComponentMap(appDir);
244
+
115
245
  const vite = await createServer({
116
246
  root: cwd,
117
247
  appType: "custom",
@@ -383,6 +513,100 @@ export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions)
383
513
  return;
384
514
  }
385
515
 
516
+ // ── /_alabjs/live/:id — SSE stream for "use live" components ─────────────
517
+ if (pathname.startsWith("/_alabjs/live/")) {
518
+ const liveId = pathname.slice("/_alabjs/live/".length).split("?")[0] ?? "";
519
+
520
+ // Rebuild map on each request so new *.live.tsx files appear without restart.
521
+ liveMap = buildLiveComponentMap(appDir);
522
+ const liveFile = liveMap.get(liveId);
523
+ if (!liveFile) {
524
+ res.statusCode = 404;
525
+ res.setHeader("content-type", "application/json");
526
+ res.end(JSON.stringify({ error: `[alabjs] live component not found: ${liveId}` }));
527
+ return;
528
+ }
529
+
530
+ // Decode props from query param: ?props=<base64-json>
531
+ const url = new URL(rawUrl, `http://${host}:${port}`);
532
+ let props: Record<string, unknown> = {};
533
+ try {
534
+ const propsParam = url.searchParams.get("props");
535
+ if (propsParam) props = JSON.parse(Buffer.from(decodeURIComponent(propsParam), "base64").toString("utf-8")) as Record<string, unknown>;
536
+ } catch { /* malformed — use empty props */ }
537
+
538
+ res.statusCode = 200;
539
+ res.setHeader("content-type", "text/event-stream; charset=utf-8");
540
+ res.setHeader("cache-control", "no-store");
541
+ res.setHeader("connection", "keep-alive");
542
+ res.setHeader("x-accel-buffering", "no");
543
+
544
+ let dead = false;
545
+ let lastHash = "";
546
+
547
+ const send = async () => {
548
+ if (dead || res.destroyed) return;
549
+ try {
550
+ const mod = await vite.ssrLoadModule(liveFile) as { default?: unknown };
551
+ const Component = mod.default;
552
+ if (typeof Component !== "function") return;
553
+
554
+ const { renderToStaticMarkup } = await import("react-dom/server") as {
555
+ renderToStaticMarkup: (el: unknown) => string;
556
+ };
557
+ const { createElement } = await import("react") as {
558
+ createElement: (type: unknown, props: unknown) => unknown;
559
+ };
560
+
561
+ const html = renderToStaticMarkup(createElement(Component, props));
562
+
563
+ // FNV-1a no-op suppression: skip push when content unchanged.
564
+ const hash = fnv1aHex(html);
565
+ if (hash === lastHash) return;
566
+ lastHash = hash;
567
+
568
+ res.write(`data: ${html}\n\n`);
569
+ } catch (err) {
570
+ const msg = err instanceof Error ? err.message : String(err);
571
+ res.write(`event: error\ndata: ${JSON.stringify(msg)}\n\n`);
572
+ }
573
+ };
574
+
575
+ // Initial render.
576
+ await send();
577
+
578
+ // Interval-based polling (read liveInterval from module).
579
+ let intervalMs = 5000;
580
+ try {
581
+ const mod = await vite.ssrLoadModule(liveFile) as { liveInterval?: unknown };
582
+ if (typeof mod.liveInterval === "number" && mod.liveInterval > 0) {
583
+ intervalMs = mod.liveInterval * 1000;
584
+ }
585
+ } catch { /* use default */ }
586
+
587
+ const timer = setInterval(() => void send(), intervalMs);
588
+
589
+ // Tag-based invalidation.
590
+ let unsubTags: (() => void) | null = null;
591
+ try {
592
+ const mod = await vite.ssrLoadModule(liveFile) as { liveTags?: unknown };
593
+ if (Array.isArray(mod.liveTags) && mod.liveTags.length > 0) {
594
+ const unsubs = (mod.liveTags as string[]).map((tag) =>
595
+ subscribeToTag(tag, () => void send()),
596
+ );
597
+ unsubTags = () => unsubs.forEach((u) => u());
598
+ }
599
+ } catch { /* no tags */ }
600
+
601
+ req.on("close", () => {
602
+ dead = true;
603
+ clearInterval(timer);
604
+ unsubTags?.();
605
+ });
606
+
607
+ return;
608
+ }
609
+
386
610
  // ── API routes (route.ts) ─────────────────────────────────────────────────
387
611
  // Browser navigations (Accept: text/html) skip API routing so a path can
388
612
  // serve both a React page AND an API endpoint (e.g. /activity as a page
@@ -671,4 +895,23 @@ export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions)
671
895
 
672
896
  await vite.listen();
673
897
  vite.printUrls();
898
+
899
+ // ── Watch app/ for route file additions/deletions ──────────────────────────
900
+ // Re-emit .alabjs/routes.d.ts so TypeScript picks up new routes immediately
901
+ // without needing a dev server restart. Also rebuilds the live component map.
902
+ const ROUTE_FILE_RE = /\.(page|server|live|layout)\.(tsx|ts)$|^(page|layout|error|loading|route)\.(tsx|ts)$/;
903
+
904
+ vite.watcher.on("add", (filePath) => {
905
+ if (!filePath.startsWith(appDir)) return;
906
+ if (!ROUTE_FILE_RE.test(filePath)) return;
907
+ void emitDevRouteTypes(cwd, appDir);
908
+ liveMap = buildLiveComponentMap(appDir);
909
+ });
910
+
911
+ vite.watcher.on("unlink", (filePath) => {
912
+ if (!filePath.startsWith(appDir)) return;
913
+ if (!ROUTE_FILE_RE.test(filePath)) return;
914
+ void emitDevRouteTypes(cwd, appDir);
915
+ liveMap = buildLiveComponentMap(appDir);
916
+ });
674
917
  }
@@ -6,6 +6,15 @@ export interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
6
6
  prefetch?: boolean;
7
7
  }
8
8
 
9
+ export interface RouteLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
10
+ /** The destination path. Narrows to AlabRoutes when routes.d.ts is included. */
11
+ to: string;
12
+ /** Replace the current history entry instead of pushing a new one. */
13
+ replace?: boolean;
14
+ /** Prefetch the target page on hover (default: true). */
15
+ prefetch?: boolean;
16
+ }
17
+
9
18
  declare global {
10
19
  interface Window {
11
20
  __alabjs_navigate?: (href: string) => Promise<void>;
@@ -62,3 +71,14 @@ export function Link({ href, children, prefetch = true, onClick, ...rest }: Link
62
71
  </a>
63
72
  );
64
73
  }
74
+
75
+ /**
76
+ * Type-safe navigation link.
77
+ *
78
+ * Identical to `<Link>` but uses a `to` prop instead of `href`.
79
+ * When `.alabjs/routes.d.ts` is included in your tsconfig, the `to` prop
80
+ * is narrowed to the `AlabRoutes` union so typos become build errors.
81
+ */
82
+ export function RouteLink({ to, replace: _replace, ...rest }: RouteLinkProps) {
83
+ return <Link href={to} {...rest} />;
84
+ }
@@ -1,7 +1,7 @@
1
1
  export { Image } from "./Image.js";
2
2
  export type { ImageProps } from "./Image.js";
3
- export { Link } from "./Link.js";
4
- export type { LinkProps } from "./Link.js";
3
+ export { Link, RouteLink } from "./Link.js";
4
+ export type { LinkProps, RouteLinkProps } from "./Link.js";
5
5
  export { ErrorBoundary } from "./ErrorBoundary.js";
6
6
  export { Script } from "./Script.js";
7
7
  export type { ScriptProps } from "./Script.js";
@@ -0,0 +1,83 @@
1
+ /**
2
+ * In-process tag-based pub/sub broadcaster for live components.
3
+ *
4
+ * Each SSE connection subscribes to the tags returned by its component's
5
+ * `liveTags(props)` export. When `invalidateLive({ tags })` is called from
6
+ * anywhere on the server (route handler, webhook, cron job), every matching
7
+ * subscriber is notified and re-renders its HTML fragment over SSE.
8
+ *
9
+ * Current implementation: plain Node.js `EventEmitter` — zero dependencies,
10
+ * zero config, works for any single-process deployment (Railway, Fly.io,
11
+ * Render, Heroku — single dyno/instance covers the vast majority of use cases).
12
+ *
13
+ * ─── Redis adapter (ON HOLD) ──────────────────────────────────────────────────
14
+ * Multi-instance deployments (PM2 cluster, multiple replicas behind a load
15
+ * balancer) need cross-process pub/sub: `invalidateLive` called on instance A
16
+ * must wake SSE connections on instances B and C.
17
+ *
18
+ * A Redis adapter is planned but intentionally deferred:
19
+ * - Most alab apps run single-instance and would pay Redis cost for no benefit
20
+ * - Vertical scaling (bigger machine) handles serious load before needing replicas
21
+ * - The interface here (`subscribeToTag` / `broadcastTag`) is already the right
22
+ * abstraction; swapping the backend is a ~20-line change when needed
23
+ *
24
+ * When it ships it will be an optional package:
25
+ *
26
+ * ```ts
27
+ * // alabjs.config.ts
28
+ * import { redisBroadcaster } from "@alabjs/broadcaster-redis";
29
+ *
30
+ * export default defineConfig({
31
+ * live: { broadcaster: redisBroadcaster({ url: process.env.REDIS_URL }) },
32
+ * });
33
+ * ```
34
+ *
35
+ * Tracking issue: https://github.com/alab-framework/alab/issues — search
36
+ * "broadcaster-redis" to follow progress or upvote.
37
+ * ─────────────────────────────────────────────────────────────────────────────
38
+ */
39
+
40
+ import { EventEmitter } from "node:events";
41
+
42
+ // One emitter per process — all SSE handlers share it.
43
+ const _emitter = new EventEmitter();
44
+ // Prevent Node.js MaxListenersExceededWarning on pages with many live components.
45
+ _emitter.setMaxListeners(0);
46
+
47
+ const TAG_EVENT_PREFIX = "live:tag:";
48
+
49
+ /**
50
+ * Subscribe a callback to a specific tag.
51
+ *
52
+ * @returns An unsubscribe function — call it on SSE client disconnect.
53
+ */
54
+ export function subscribeToTag(tag: string, callback: () => void): () => void {
55
+ const event = TAG_EVENT_PREFIX + tag;
56
+ _emitter.on(event, callback);
57
+ return () => _emitter.off(event, callback);
58
+ }
59
+
60
+ /**
61
+ * Broadcast a tag change to all live SSE connections subscribed to it.
62
+ */
63
+ export function broadcastTag(tag: string): void {
64
+ _emitter.emit(TAG_EVENT_PREFIX + tag);
65
+ }
66
+
67
+ /**
68
+ * Invalidate live components by tag.
69
+ *
70
+ * Triggers an immediate re-render push for every SSE connection subscribed
71
+ * to any of the given tags. Called from route handlers, webhooks, cron jobs,
72
+ * or the existing `/_alabjs/revalidate` endpoint.
73
+ *
74
+ * ```ts
75
+ * import { invalidateLive } from "alabjs/server";
76
+ * await invalidateLive({ tags: ["stock:AAPL", "stock:GOOG"] });
77
+ * ```
78
+ */
79
+ export async function invalidateLive(opts: { tags: string[] }): Promise<void> {
80
+ for (const tag of opts.tags) {
81
+ broadcastTag(tag);
82
+ }
83
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Live component registry.
3
+ *
4
+ * At server startup, `createApp` scans `dist/server/**\/*.live.js`, imports
5
+ * each module, reads the `liveInterval` and `liveTags` exports, and calls
6
+ * `registerLiveComponent()`. The SSE endpoint then looks up entries here.
7
+ */
8
+
9
+ export interface LiveComponentEntry {
10
+ /** Stable FNV-1a hash of the original source module path (16 hex chars). */
11
+ id: string;
12
+ /** Absolute path to the compiled `.live.js` file in dist/server. */
13
+ modulePath: string;
14
+ /**
15
+ * Re-render interval in milliseconds.
16
+ * Set via `export const liveInterval = 5000` in the live component file.
17
+ * When undefined, the component only updates on tag invalidation.
18
+ */
19
+ liveInterval?: number;
20
+ /**
21
+ * Function that returns cache tags for a given props object.
22
+ * Set via `export const liveTags = (props) => [\`stock:\${props.ticker}\`]`.
23
+ * When undefined, the component is not subscribed to tag broadcasts.
24
+ */
25
+ liveTags?: (props: unknown) => string[];
26
+ }
27
+
28
+ const _registry = new Map<string, LiveComponentEntry>();
29
+
30
+ export function registerLiveComponent(entry: LiveComponentEntry): void {
31
+ _registry.set(entry.id, entry);
32
+ }
33
+
34
+ export function getLiveComponent(id: string): LiveComponentEntry | undefined {
35
+ return _registry.get(id);
36
+ }
37
+
38
+ /** Return all entries whose `liveTags(props)` includes the given tag. */
39
+ export function getLiveComponentsByTag(tag: string): Array<{ entry: LiveComponentEntry; props: unknown }> {
40
+ // This is called by the broadcaster when a tag is invalidated.
41
+ // Each SSE connection registers itself with the tag broadcaster directly,
42
+ // so this function is used only for discovery — not for fan-out.
43
+ const results: Array<{ entry: LiveComponentEntry; props: unknown }> = [];
44
+ for (const entry of _registry.values()) {
45
+ if (entry.liveTags) {
46
+ // We don't know props here — tag matching is handled per-connection
47
+ // in the SSE handler via subscribeToTag.
48
+ results.push({ entry, props: undefined });
49
+ }
50
+ }
51
+ return results;
52
+ }
53
+
54
+ export function registrySize(): number {
55
+ return _registry.size;
56
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Server-side HTML fragment renderer for live components.
3
+ *
4
+ * Uses `renderToStaticMarkup` (not `renderToPipeableStream`) because:
5
+ * - The SSE update path does a raw DOM swap — no React hydration needed.
6
+ * - Synchronous output fits the push model.
7
+ * - No `data-reactroot` attributes in the fragment (smaller payload).
8
+ */
9
+
10
+ import { createElement } from "react";
11
+ import { renderToStaticMarkup } from "react-dom/server";
12
+
13
+ /**
14
+ * Render a live component to an HTML fragment string.
15
+ *
16
+ * @param modulePath - Absolute path to the compiled `.live.js` module.
17
+ * @param props - Props to pass to the component (from the SSE query string).
18
+ * @returns - Raw HTML string (no wrapping element).
19
+ */
20
+ export async function renderLiveFragment(
21
+ modulePath: string,
22
+ props: unknown,
23
+ ): Promise<string> {
24
+ // Dynamic import — Node's module cache means repeated calls for the same
25
+ // module path are effectively free after the first load.
26
+ const mod = await import(modulePath) as { default?: unknown };
27
+ const Component = mod.default;
28
+
29
+ if (typeof Component !== "function") {
30
+ throw new Error(
31
+ `[alabjs] live component at ${modulePath} has no default export`,
32
+ );
33
+ }
34
+
35
+ // renderToStaticMarkup handles async components (React 19+).
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ const html = renderToStaticMarkup(createElement(Component as any, props as any));
38
+ return html;
39
+ }
40
+
41
+ /**
42
+ * FNV-1a 64-bit content hash of an HTML string.
43
+ * Used to suppress no-op SSE pushes when the rendered output hasn't changed.
44
+ */
45
+ export function hashFragment(html: string): string {
46
+ const FNV_OFFSET = 14_695_981_039_346_656_037n;
47
+ const FNV_PRIME = 1_099_511_628_211n;
48
+ let hash = FNV_OFFSET;
49
+ for (let i = 0; i < html.length; i++) {
50
+ hash ^= BigInt(html.charCodeAt(i));
51
+ hash = BigInt.asUintN(64, hash * FNV_PRIME);
52
+ }
53
+ return hash.toString(16).padStart(16, "0");
54
+ }
@@ -1,4 +1,4 @@
1
- export type RouteKind = "page" | "server" | "layout" | "error" | "loading" | "api";
1
+ export type RouteKind = "page" | "server" | "layout" | "error" | "loading" | "api" | "live";
2
2
 
3
3
  export interface Route {
4
4
  path: string;