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
package/src/server/app.ts CHANGED
@@ -18,6 +18,9 @@ import { getPPRShell, injectBuildIdIntoPPRShell, PPR_CACHE_SUBDIR } from "../ssr
18
18
  import { handleVitalsBeacon, handleAnalyticsDashboard } from "../analytics/handler.js";
19
19
  import { buildImportMap } from "../config.js";
20
20
  import type { FederationConfig } from "../config.js";
21
+ import { registerLiveComponent } from "../live/registry.js";
22
+ import { renderLiveFragment, hashFragment } from "../live/renderer.js";
23
+ import { subscribeToTag } from "../live/broadcaster.js";
21
24
 
22
25
  /** Walk dist/server recursively and collect all *.server.js paths (compiled server functions). */
23
26
  function findDistServerFiles(distDir: string): string[] {
@@ -39,6 +42,47 @@ function findDistServerFiles(distDir: string): string[] {
39
42
  return results;
40
43
  }
41
44
 
45
+ /** Walk dist/server recursively and register all *.live.js modules. */
46
+ async function registerAllLiveComponents(distDir: string): Promise<void> {
47
+ const serverDir = join(distDir, "server");
48
+ const files: string[] = [];
49
+
50
+ function walk(dir: string) {
51
+ try {
52
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
53
+ const fullPath = join(dir, entry.name);
54
+ if (entry.isDirectory()) {
55
+ walk(fullPath);
56
+ } else if (entry.isFile() && entry.name.endsWith(".live.js")) {
57
+ files.push(fullPath);
58
+ }
59
+ }
60
+ } catch { /* not readable */ }
61
+ }
62
+ walk(serverDir);
63
+
64
+ for (const filePath of files) {
65
+ try {
66
+ const mod = await import(filePath) as {
67
+ liveId?: string;
68
+ liveInterval?: number;
69
+ liveTags?: (props: unknown) => string[];
70
+ };
71
+ // liveId is stamped by the Vite plugin (hash of source path).
72
+ // Fall back to a hash of the file path itself.
73
+ const id = mod.liveId ?? filePath.replace(/[^a-z0-9]/gi, "").slice(-16);
74
+ registerLiveComponent({
75
+ id,
76
+ modulePath: filePath,
77
+ ...(typeof mod.liveInterval === "number" ? { liveInterval: mod.liveInterval } : {}),
78
+ ...(typeof mod.liveTags === "function" ? { liveTags: mod.liveTags } : {}),
79
+ });
80
+ } catch (err) {
81
+ console.warn(`[alabjs] live: failed to register ${filePath}:`, err);
82
+ }
83
+ }
84
+ }
85
+
42
86
  /**
43
87
  * Find layout file paths (relative to cwd root) for a given route.file, ordered outermost→innermost.
44
88
  * Checks the compiled dist directory for the existence of each layout.
@@ -146,6 +190,11 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
146
190
  // Absolute path to the PPR shell cache directory.
147
191
  const pprCacheDir = resolve(distDir, "../../", PPR_CACHE_SUBDIR);
148
192
 
193
+ // Register live components at startup (non-blocking — failures are warned, not thrown).
194
+ registerAllLiveComponents(distDir).catch((err) => {
195
+ console.warn("[alabjs] live: component registration failed:", err);
196
+ });
197
+
149
198
  // ─── Global middleware ───────────────────────────────────────────────────────
150
199
  app.use(
151
200
  defineEventHandler((event) => {
@@ -165,6 +214,30 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
165
214
  // every <script> tag via renderToResponse's headExtra option. The CSRF
166
215
  // double-submit pattern relies on XSS prevention — using 'unsafe-inline'
167
216
  // without a nonce makes the CSRF token readable by injected scripts.
217
+ //
218
+ // NOTE: 'upgrade-insecure-requests' is intentionally omitted from the
219
+ // default CSP. That directive tells browsers to silently rewrite http://
220
+ // sub-resource URLs to https://, which breaks any app served over plain
221
+ // HTTP (local dev, internal tooling, HTTP-only staging servers) because
222
+ // the browser will refuse to load scripts and stylesheets redirected from
223
+ // the virtual /@alabjs/client path.
224
+ //
225
+ // Add it in your own middleware when you are certain every environment
226
+ // runs behind HTTPS:
227
+ //
228
+ // // middleware.ts
229
+ // export async function middleware(req: Request) {
230
+ // const isHttps = req.headers.get("x-forwarded-proto") === "https"
231
+ // || new URL(req.url).protocol === "https:";
232
+ // if (isHttps) {
233
+ // // Append the directive to whatever CSP the framework already set.
234
+ // const existing = res.headers.get("content-security-policy") ?? "";
235
+ // res.headers.set(
236
+ // "content-security-policy",
237
+ // existing + "; upgrade-insecure-requests",
238
+ // );
239
+ // }
240
+ // }
168
241
  res.setHeader(
169
242
  "content-security-policy",
170
243
  [
@@ -428,6 +501,92 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
428
501
  }),
429
502
  );
430
503
 
504
+ // ─── Live component SSE endpoint ────────────────────────────────────────────
505
+ // GET /_alabjs/live/:id?props=<base64-json>
506
+ //
507
+ // Opens a persistent SSE stream for a live component. On connect:
508
+ // 1. Renders the component immediately and sends the first fragment.
509
+ // 2. Sets up an interval (if liveInterval is set) to re-render and push.
510
+ // 3. Subscribes to tag broadcasts (if liveTags is set).
511
+ // 4. On client disconnect: clears interval + unsubscribes tags.
512
+ router.get(
513
+ "/_alabjs/live/:id",
514
+ defineEventHandler(async (event) => {
515
+ const req = event.node.req;
516
+ const res = event.node.res;
517
+
518
+ const id = (event.context.params?.["id"] ?? "") as string;
519
+ const { getLiveComponent } = await import("../live/registry.js");
520
+ const entry = getLiveComponent(id);
521
+
522
+ if (!entry) {
523
+ res.statusCode = 404;
524
+ res.setHeader("content-type", "application/json");
525
+ res.end(JSON.stringify({ error: `[alabjs] live component not found: ${id}` }));
526
+ return;
527
+ }
528
+
529
+ // Parse props from base64-encoded JSON query param.
530
+ let props: unknown = {};
531
+ try {
532
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
533
+ const raw = url.searchParams.get("props");
534
+ if (raw) props = JSON.parse(Buffer.from(raw, "base64").toString("utf8"));
535
+ } catch { /* malformed props — use empty object */ }
536
+
537
+ // SSE headers.
538
+ res.statusCode = 200;
539
+ res.setHeader("content-type", "text/event-stream; charset=utf-8");
540
+ res.setHeader("cache-control", "no-cache, no-transform");
541
+ res.setHeader("connection", "keep-alive");
542
+ res.setHeader("x-accel-buffering", "no"); // disable nginx buffering
543
+
544
+ let lastHash = "";
545
+ let closed = false;
546
+
547
+ async function pushFragment(): Promise<void> {
548
+ if (closed) return;
549
+ try {
550
+ const html = await renderLiveFragment(entry!.modulePath, props);
551
+ const hash = hashFragment(html);
552
+ if (hash === lastHash) return; // no-op — output unchanged
553
+ lastHash = hash;
554
+ res.write(`data: ${html}\n\n`);
555
+ } catch (err) {
556
+ console.error(`[alabjs] live render error (${id}):`, err);
557
+ }
558
+ }
559
+
560
+ // Send initial fragment immediately.
561
+ await pushFragment();
562
+
563
+ // Interval-based polling.
564
+ let intervalHandle: ReturnType<typeof setInterval> | null = null;
565
+ if (entry.liveInterval && entry.liveInterval > 0) {
566
+ intervalHandle = setInterval(() => { void pushFragment(); }, entry.liveInterval);
567
+ }
568
+
569
+ // Tag-based subscriptions.
570
+ const unsubFns: Array<() => void> = [];
571
+ if (entry.liveTags) {
572
+ const tags = entry.liveTags(props);
573
+ for (const tag of tags) {
574
+ unsubFns.push(subscribeToTag(tag, () => { void pushFragment(); }));
575
+ }
576
+ }
577
+
578
+ // Cleanup on disconnect.
579
+ req.on("close", () => {
580
+ closed = true;
581
+ if (intervalHandle) clearInterval(intervalHandle);
582
+ for (const unsub of unsubFns) unsub();
583
+ });
584
+
585
+ // Return null so h3 does not try to end the response — SSE keeps it open.
586
+ return null;
587
+ }),
588
+ );
589
+
431
590
  // ─── Server function endpoints ──────────────────────────────────────────────
432
591
  // GET /_alabjs/data/:fn — used by useServerData (query params as input)
433
592
  // POST /_alabjs/fn/:fn — used by useMutation (JSON body as input)
@@ -142,3 +142,4 @@ export function defineServerFn(...args: any[]): ServerFn<any, any> {
142
142
 
143
143
  export { defineSSEHandler } from "./sse.js";
144
144
  export type { SSEEvent } from "./sse.js";
145
+ export { invalidateLive } from "../live/broadcaster.js";
@@ -27,6 +27,7 @@
27
27
  import { timingSafeEqual } from "node:crypto";
28
28
  import { revalidatePath, revalidatePathPrefix, revalidateTag } from "./cache.js";
29
29
  import { purgeCdnByTags } from "./cdn.js";
30
+ import { invalidateLive } from "../live/broadcaster.js";
30
31
 
31
32
  export interface RevalidateBody {
32
33
  /** Purge a single cached page path. */
@@ -82,6 +83,8 @@ export function applyRevalidate(
82
83
  // Fire-and-forget: CDN purge is best-effort. In-process cache is already
83
84
  // cleared above, so a CDN miss will just hit the origin and re-warm the edge.
84
85
  void purgeCdnByTags(tags);
86
+ // Notify live SSE connections subscribed to any of these tags.
87
+ void invalidateLive({ tags });
85
88
  }
86
89
 
87
90
  return {