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.
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +57 -17
- package/dist/commands/dev.js.map +1 -1
- package/dist/components/Font.d.ts +8 -3
- package/dist/components/Font.d.ts.map +1 -1
- package/dist/components/Font.js +12 -2
- package/dist/components/Font.js.map +1 -1
- package/dist/components/Script.d.ts +19 -7
- package/dist/components/Script.d.ts.map +1 -1
- package/dist/components/Script.js +28 -1
- package/dist/components/Script.js.map +1 -1
- package/dist/ssr/html.d.ts +14 -0
- package/dist/ssr/html.d.ts.map +1 -1
- package/dist/ssr/html.js +48 -0
- package/dist/ssr/html.js.map +1 -1
- package/package.json +3 -3
- package/src/commands/dev.ts +59 -19
- package/src/components/Font.tsx +12 -2
- package/src/components/Script.tsx +47 -6
- package/src/ssr/html.ts +55 -0
- package/src/types/plugins.d.ts +16 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/commands/dev.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
package/src/components/Font.tsx
CHANGED
|
@@ -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
|
|
86
|
-
*
|
|
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
|
-
|
|
4
|
-
|
|
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 {
|
package/src/types/plugins.d.ts
CHANGED
|
@@ -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
|
+
}
|