alabjs 0.2.3 → 0.2.5

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.
@@ -8,7 +8,7 @@ import {
8
8
  findLayoutFiles, findErrorFile, findLoadingFile,
9
9
  scanDevApiRoutes, matchDevApiRoute,
10
10
  } from "../ssr/router-dev.js";
11
- import { htmlShellBefore, htmlShellAfter } from "../ssr/html.js";
11
+ import { htmlShellBefore, htmlShellAfter, injectIntoFullDocument } from "../ssr/html.js";
12
12
  import { generateSitemap } from "../server/sitemap.js";
13
13
  import { handleImageRequest } from "../server/image.js";
14
14
  import type { MiddlewareModule } from "../server/middleware.js";
@@ -181,7 +181,7 @@ export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions)
181
181
  found = true;
182
182
  const url = new URL(rawUrl, `http://${host}:${port}`);
183
183
  const params = Object.fromEntries(url.searchParams.entries());
184
- const input = req.method === "POST" ? await readJsonBody(req) : undefined;
184
+ const input = req.method === "POST" ? await readJsonBody(req) : (Object.keys(params).length ? params : undefined);
185
185
  const ctx = {
186
186
  params,
187
187
  query: params,
@@ -205,7 +205,8 @@ export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions)
205
205
  res.statusCode = 500;
206
206
  res.setHeader("content-type", "application/json");
207
207
  const msg = err instanceof Error ? err.message : String(err);
208
- res.end(JSON.stringify({ error: msg }));
208
+ console.error(`[alabjs] server fn "${fnName}" threw:`, err);
209
+ res.end(JSON.stringify({ error: msg }));
209
210
  }
210
211
  }
211
212
  break;
@@ -219,6 +220,32 @@ export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions)
219
220
  return;
220
221
  }
221
222
 
223
+ // ── /_alabjs/vitals — Core Web Vitals beacon from <Analytics> component ───
224
+ if (pathname === "/_alabjs/vitals") {
225
+ if (req.method === "OPTIONS") {
226
+ res.setHeader("access-control-allow-origin", "*");
227
+ res.setHeader("access-control-allow-methods", "POST, OPTIONS");
228
+ res.setHeader("access-control-allow-headers", "content-type");
229
+ res.statusCode = 204;
230
+ res.end();
231
+ return;
232
+ }
233
+ if (req.method !== "POST") {
234
+ res.statusCode = 405;
235
+ res.setHeader("allow", "POST");
236
+ res.end();
237
+ return;
238
+ }
239
+ // Silently accept the beacon — dev mode doesn't aggregate metrics.
240
+ const vitalsBody = await readJsonBody(req);
241
+ console.debug("[alabjs] vitals beacon:", vitalsBody);
242
+ res.setHeader("access-control-allow-origin", "*");
243
+ res.setHeader("content-type", "application/json");
244
+ res.statusCode = 200;
245
+ res.end(JSON.stringify({ ok: true }));
246
+ return;
247
+ }
248
+
222
249
  // ── /_alabjs/image — Rust-powered image optimisation ───────────────────────
223
250
  if (pathname === "/_alabjs/image") {
224
251
  const publicDir = resolve(cwd, "public");
@@ -276,8 +303,12 @@ export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions)
276
303
  }
277
304
 
278
305
  // ── API routes (route.ts) ─────────────────────────────────────────────────
306
+ // Browser navigations (Accept: text/html) skip API routing so a path can
307
+ // serve both a React page AND an API endpoint (e.g. /activity as a page
308
+ // and GET /activity as an SSE stream).
309
+ const wantsHtml = (req.headers["accept"] ?? "").includes("text/html");
279
310
  const apiRoutes = scanDevApiRoutes(appDir);
280
- const matchedApi = matchDevApiRoute(apiRoutes, pathname);
311
+ const matchedApi = !wantsHtml ? matchDevApiRoute(apiRoutes, pathname) : null;
281
312
  if (matchedApi) {
282
313
  const apiMod = await vite.ssrLoadModule(matchedApi.route.file) as Record<string, unknown>;
283
314
  const method = (req.method ?? "GET").toUpperCase();
@@ -333,7 +364,6 @@ export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions)
333
364
 
334
365
  // ── Not-found page ────────────────────────────────────────────────────────
335
366
  if (!matched) {
336
- const wantsHtml = (req.headers["accept"] ?? "").includes("text/html");
337
367
  if (!wantsHtml) return next();
338
368
 
339
369
  const notFoundFile = resolve(appDir, "not-found.tsx");
@@ -374,7 +404,7 @@ export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions)
374
404
  const mod = await vite.ssrLoadModule(route.file) as {
375
405
  default?: unknown;
376
406
  metadata?: PageMetadata;
377
- generateMetadata?: (params: Record<string, string>) => PageMetadata | Promise<PageMetadata>;
407
+ generateMetadata?: (ctx: { params: Record<string, string> }) => PageMetadata | Promise<PageMetadata>;
378
408
  ssr?: boolean;
379
409
  /** ISR: seconds before a cached page is considered stale. Omit to disable caching. */
380
410
  revalidate?: number;
@@ -389,7 +419,7 @@ export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions)
389
419
  // Support both static `export const metadata` and dynamic `export async function generateMetadata`.
390
420
  const metadata: PageMetadata =
391
421
  typeof mod.generateMetadata === "function"
392
- ? await mod.generateMetadata(params)
422
+ ? await mod.generateMetadata({ params })
393
423
  : (mod.metadata ?? {});
394
424
 
395
425
  // Make the server's base URL available to useServerData during SSR,
@@ -492,19 +522,29 @@ export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions)
492
522
 
493
523
  // ── Render helper (used for both fresh render + background revalidation) ─
494
524
  const revalidateSecs = typeof mod.revalidate === "number" ? mod.revalidate : null;
525
+ // Detect whether the SSR output is a full document (layout returns <html>).
526
+ // In that case we inject the alab meta tags and client script directly into
527
+ // the user-controlled <head>/<body> rather than wrapping in the shell div,
528
+ // which would produce invalid "html cannot be child of div" nesting.
529
+ const isFullDocument = ssrEnabled && ssrContent.trimStart().toLowerCase().startsWith("<html");
530
+ const shellOpts = {
531
+ paramsJson: JSON.stringify(params),
532
+ searchParamsJson: JSON.stringify(searchParams),
533
+ routeFile,
534
+ layoutsJson,
535
+ loadingFile,
536
+ ssr: ssrEnabled,
537
+ buildId: devBuildId,
538
+ };
495
539
  const renderPageHtml = async (): Promise<string> => {
496
- const shellBefore = htmlShellBefore({
497
- metadata,
498
- paramsJson: JSON.stringify(params),
499
- searchParamsJson: JSON.stringify(searchParams),
500
- routeFile,
501
- layoutsJson,
502
- loadingFile,
503
- ssr: ssrEnabled,
504
- buildId: devBuildId,
505
- });
506
- const shellAfter = htmlShellAfter({});
507
- const rawHtml = `${shellBefore}${ssrContent}${shellAfter}`;
540
+ let rawHtml: string;
541
+ if (isFullDocument) {
542
+ rawHtml = injectIntoFullDocument(ssrContent, shellOpts);
543
+ } else {
544
+ const shellBefore = htmlShellBefore({ metadata, headExtra: undefined, ...shellOpts });
545
+ const shellAfter = htmlShellAfter({});
546
+ rawHtml = `${shellBefore}${ssrContent}${shellAfter}`;
547
+ }
508
548
  return vite.transformIndexHtml(pathname, rawHtml);
509
549
  };
510
550
 
@@ -82,10 +82,20 @@ function buildGoogleFontsUrl(props: FontProps): string {
82
82
  /**
83
83
  * Renders the `<link>` tags needed to load a Google Font.
84
84
  *
85
- * In production (`alab build --self-host-fonts`), the alab Vite plugin
86
- * replaces this with self-hosted CSS so no Google request is made.
85
+ * Only active in development (Vite dev server). In production (`alab build`),
86
+ * the build step self-hosts the font files, so this component renders nothing.
87
+ * Gating on `import.meta.env.DEV` ensures the server and client always agree:
88
+ * both render the links in dev, both render nothing in production — preventing
89
+ * a hydration mismatch that occurs when the server loads the pre-built dist
90
+ * (where `import.meta.env.DEV` is `false`) while the client gets live Vite
91
+ * processing (where it is `true`).
87
92
  */
88
93
  export function Font(props: FontProps) {
94
+ // Return nothing in production — self-hosted fonts are injected by the build.
95
+ // This must be checked before building the URL so the server and client
96
+ // always produce identical output (both null in prod, both links in dev).
97
+ if (!import.meta.env?.DEV) return null;
98
+
89
99
  const href = buildGoogleFontsUrl(props);
90
100
 
91
101
  return (
@@ -1,8 +1,7 @@
1
- import { useEffect, type HTMLAttributes } from "react";
1
+ import { useEffect, type HTMLAttributes, type ReactNode } from "react";
2
2
 
3
- export interface ScriptProps extends Omit<HTMLAttributes<HTMLScriptElement>, "src"> {
4
- /** URL of the external script to load. */
5
- src: string;
3
+ /** Props shared by both external-src and inline-children variants. */
4
+ interface ScriptBaseProps extends Omit<HTMLAttributes<HTMLScriptElement>, "src"> {
6
5
  /**
7
6
  * Loading strategy:
8
7
  * - `"beforeInteractive"` — injected into `<head>` during SSR; blocks page rendering.
@@ -13,12 +12,27 @@ export interface ScriptProps extends Omit<HTMLAttributes<HTMLScriptElement>, "sr
13
12
  * Best for low-priority scripts like A/B testing, heatmaps, social embeds.
14
13
  */
15
14
  strategy?: "beforeInteractive" | "afterInteractive" | "lazyOnload";
16
- /** Called once the script has loaded successfully. */
15
+ /** Called once the script has loaded successfully (external scripts only). */
17
16
  onLoad?: () => void;
18
- /** Called if the script fails to load. */
17
+ /** Called if the script fails to load (external scripts only). */
19
18
  onError?: () => void;
20
19
  }
21
20
 
21
+ /** External script variant — `src` is required and `children` must be absent. */
22
+ interface ExternalScriptProps extends ScriptBaseProps {
23
+ /** URL of the external script to load. */
24
+ src: string;
25
+ children?: never;
26
+ }
27
+
28
+ /** Inline script variant — `children` contains the script body; `src` must be absent. */
29
+ interface InlineScriptProps extends ScriptBaseProps {
30
+ src?: never;
31
+ children: ReactNode;
32
+ }
33
+
34
+ export type ScriptProps = ExternalScriptProps | InlineScriptProps;
35
+
22
36
  /**
23
37
  * Load a third-party script with strategy control.
24
38
  *
@@ -40,8 +54,35 @@ export function Script({
40
54
  strategy = "afterInteractive",
41
55
  onLoad,
42
56
  onError,
57
+ children,
43
58
  ...rest
44
59
  }: ScriptProps) {
60
+ // ── Inline script: render <script>{children}</script> directly ──────────────
61
+ // Inline scripts have no loading strategy — they are always rendered as-is.
62
+ // For SSR (beforeInteractive) they land in the HTML stream; for client
63
+ // renders they are injected once via useEffect.
64
+ if (!src) {
65
+ if (strategy === "beforeInteractive") {
66
+ if (typeof window !== "undefined") return null;
67
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
68
+ return <script {...(rest as any)}>{children}</script>;
69
+ }
70
+ // eslint-disable-next-line react-hooks/rules-of-hooks
71
+ useEffect(() => {
72
+ const el = document.createElement("script");
73
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
+ if (typeof children === "string") el.textContent = children;
75
+ for (const [k, v] of Object.entries(rest)) {
76
+ if (typeof v === "string") el.setAttribute(k, v);
77
+ }
78
+ document.head.appendChild(el);
79
+ // eslint-disable-next-line react-hooks/exhaustive-deps
80
+ }, []);
81
+ return null;
82
+ }
83
+
84
+ // ── External script ─────────────────────────────────────────────────────────
85
+
45
86
  // `beforeInteractive` is handled at SSR time by rendering a real <script> tag.
46
87
  // The component returns null on the client to avoid duplicate injection.
47
88
  if (strategy === "beforeInteractive") {
package/src/ssr/html.ts CHANGED
@@ -101,6 +101,61 @@ export function htmlShellAfter(opts: { nonce?: string | undefined }): string {
101
101
  </html>`;
102
102
  }
103
103
 
104
+ /**
105
+ * Build just the alab `<meta>` tags used for client hydration.
106
+ * Used when the page component renders a full `<html>` document so the tags
107
+ * can be injected into the user-controlled `<head>` rather than the shell.
108
+ */
109
+ export function buildAlabMetaTags(opts: Omit<HtmlShellOptions, "metadata" | "headExtra">): string {
110
+ const { routeFile, ssr, paramsJson, searchParamsJson, layoutsJson, loadingFile, buildId } = opts;
111
+ return [
112
+ `<meta name="alabjs-route" content="${escAttr(routeFile)}" />`,
113
+ `<meta name="alabjs-ssr" content="${ssr ? "true" : "false"}" />`,
114
+ `<meta name="alabjs-params" content="${escAttr(paramsJson)}" />`,
115
+ `<meta name="alabjs-search-params" content="${escAttr(searchParamsJson)}" />`,
116
+ layoutsJson ? `<meta name="alabjs-layouts" content="${escAttr(layoutsJson)}" />` : "",
117
+ loadingFile ? `<meta name="alabjs-loading" content="${escAttr(loadingFile)}" />` : "",
118
+ buildId ? `<meta name="alabjs-build-id" content="${escAttr(buildId)}" />` : "",
119
+ // Signal to the client bootstrap that it must use hydrateRoot(document, …)
120
+ // instead of mounting into <div id="alabjs-root">.
121
+ `<meta name="alabjs-full-document" content="true" />`,
122
+ ].filter(Boolean).join("\n ");
123
+ }
124
+
125
+ /**
126
+ * Inject alab hydration meta tags and the client bootstrap script into a
127
+ * full-document SSR string (i.e. one whose root element is `<html>`).
128
+ *
129
+ * The meta tags are appended before `</head>` and the client script before
130
+ * `</body>`. If neither marker is found the strings are appended verbatim.
131
+ */
132
+ export function injectIntoFullDocument(
133
+ ssrHtml: string,
134
+ opts: Omit<HtmlShellOptions, "metadata" | "headExtra">,
135
+ ): string {
136
+ const metaTags = buildAlabMetaTags(opts);
137
+ const nonceAttr = opts.nonce ? ` nonce="${escAttr(opts.nonce)}"` : "";
138
+ const clientScript = `<script type="module" src="/@alabjs/client"${nonceAttr}></script>`;
139
+
140
+ let result = ssrHtml;
141
+
142
+ // Inject meta tags before </head> (case-insensitive).
143
+ if (/<\/head>/i.test(result)) {
144
+ result = result.replace(/<\/head>/i, ` ${metaTags}\n </head>`);
145
+ } else {
146
+ result = metaTags + "\n" + result;
147
+ }
148
+
149
+ // Inject client bootstrap script before </body>.
150
+ if (/<\/body>/i.test(result)) {
151
+ result = result.replace(/<\/body>/i, ` ${clientScript}\n </body>`);
152
+ } else {
153
+ result = result + "\n" + clientScript;
154
+ }
155
+
156
+ return result;
157
+ }
158
+
104
159
  // ─── Helpers ──────────────────────────────────────────────────────────────────
105
160
 
106
161
  function escHtml(s: string): string {
@@ -1,3 +1,19 @@
1
1
  declare module "alabjs-vite-plugin" {
2
2
  export function alabPlugin(options?: { mode?: "dev" | "build" }): import("vite").Plugin[];
3
3
  }
4
+
5
+ // Augment ImportMeta so `import.meta.env.DEV` (injected by Vite at build time)
6
+ // is recognised by TypeScript when compiling alabjs component source files.
7
+ // Vite replaces `import.meta.env.DEV` with a literal boolean at bundle time;
8
+ // this declaration just provides the compile-time type so TS does not error.
9
+ interface ImportMeta {
10
+ readonly env: {
11
+ /** `true` in Vite dev server, `false` in production builds. */
12
+ readonly DEV: boolean;
13
+ readonly PROD: boolean;
14
+ readonly SSR: boolean;
15
+ readonly MODE: string;
16
+ readonly BASE_URL: string;
17
+ readonly [key: string]: unknown;
18
+ };
19
+ }