@opentf/web-cli 1.0.0 → 1.1.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.
package/src/serve.js ADDED
@@ -0,0 +1,234 @@
1
+ // `otfw serve` — the per-request SSR server (Phase 1).
2
+ //
3
+ // Server-side rendering in OTF Web is *JavaScript at request time*: the Rust
4
+ // compiler/toolchain runs only at build time and emits JS render functions; this
5
+ // server executes them per request. It reuses the exact path SSG already uses
6
+ // (ARCHITECTURE.md §6: "SSR — per-request HTML from the IR … shares the SSG
7
+ // path") — `buildServerBundle()` compiles the app with the SSG backend, and each
8
+ // request calls the same `renderRoute()` the pre-render loop calls.
9
+ //
10
+ // Startup:
11
+ // 1. produce the client `dist/` (interactive bundle + HTML shell) via `otfw build`
12
+ // 2. build the server render bundle once (held live for the process lifetime)
13
+ //
14
+ // Per request:
15
+ // • a path with a file extension → served from `dist/` (assets, css, public/…)
16
+ // • anything else → SSR: render the route's markup + <head>, inject into the
17
+ // shell, return text/html.
18
+ //
19
+ // Hydration (Phase 2.0): the client build is the hydrate target and the shell's
20
+ // `#app` carries the `data-otfw-hydrate` sentinel, so the client *adopts* the
21
+ // server-rendered DOM on first paint (no rebuild/flash) for leaf routes that the
22
+ // hydrate backend can emit an adopt factory for; anything it can't (layout chains,
23
+ // child components, lists/conditionals — see docs/HYDRATION.md §4) falls back to a
24
+ // clean CSR mount. The per-request HTML is real either way (first paint, dynamic
25
+ // content, SEO).
26
+
27
+ import { existsSync, readFileSync, statSync } from "node:fs";
28
+ import { join } from "node:path";
29
+
30
+ import { runBuild } from "./build.js";
31
+ import {
32
+ MIME,
33
+ buildServerBundle,
34
+ discoverPages,
35
+ injectHead,
36
+ injectMarkup,
37
+ loadConfig,
38
+ loadDocsPlugins,
39
+ loadProject,
40
+ withHtmlLang,
41
+ } from "./shared.js";
42
+
43
+ // Resolve the start port from `--port <n>` / `-p <n>` / `--port=<n>` (args after
44
+ // `serve`). Explicit ports are tried once (fail fast if busy); otherwise we
45
+ // default to 3000 and scan upward for a free port — mirroring `otfw dev`.
46
+ function resolvePort() {
47
+ const argv = process.argv.slice(3);
48
+ for (let i = 0; i < argv.length; i++) {
49
+ const a = argv[i];
50
+ if ((a === "--port" || a === "-p") && argv[i + 1]) return { port: Number(argv[i + 1]), explicit: true };
51
+ if (a.startsWith("--port=")) return { port: Number(a.slice("--port=".length)), explicit: true };
52
+ }
53
+ return { port: 3000, explicit: false };
54
+ }
55
+
56
+ // Site origin for absolute canonical / OG URLs in the rendered <head>.
57
+ function resolveBaseUrl(config) {
58
+ const flag = process.argv.find((a) => a.startsWith("--base-url="));
59
+ if (flag) return flag.slice("--base-url=".length).replace(/\/+$/, "");
60
+ if (config?.site?.url) return String(config.site.url).replace(/\/+$/, "");
61
+ return "";
62
+ }
63
+
64
+ // Start `Bun.serve`, scanning upward for a free port unless one was given explicitly.
65
+ function serve(start, explicit, options) {
66
+ const end = explicit ? start : start + 99;
67
+ for (let port = start; port <= end; port++) {
68
+ try {
69
+ return Bun.serve({ ...options, port });
70
+ } catch (e) {
71
+ if (e?.code === "EADDRINUSE") {
72
+ if (explicit) {
73
+ console.error(`✗ port ${port} is already in use (pass a different --port)`);
74
+ process.exit(1);
75
+ }
76
+ continue;
77
+ }
78
+ throw e;
79
+ }
80
+ }
81
+ console.error(`✗ no free port found in ${start}–${end}`);
82
+ process.exit(1);
83
+ }
84
+
85
+ export async function runServe() {
86
+ const bootStart = Date.now();
87
+ const { port: startPort, explicit: explicitPort } = resolvePort();
88
+ if (!Number.isInteger(startPort) || startPort < 1 || startPort > 65535) {
89
+ console.error(`✗ invalid --port value: ${startPort}`);
90
+ process.exit(1);
91
+ }
92
+
93
+ // 1. Client build → dist/ (interactive bundle + composed HTML shell). Reusing the
94
+ // production build keeps the client assets, CSS hashing, and shell identical to
95
+ // `otfw build`; the SSR server fills the shell's `#app` per request. `hydrate`
96
+ // builds the dual-module client bundle and stamps the `#app` sentinel, so the
97
+ // server markup is adopted on first paint instead of rebuilt.
98
+ await runBuild({ hydrate: true });
99
+
100
+ // 2. Server render bundle (held live for the process lifetime).
101
+ const { root, appDir, webEntry, otfwc, exclude } = loadProject();
102
+ const distDir = join(root, "dist");
103
+ const shellPath = join(distDir, "index.html");
104
+ if (!existsSync(shellPath)) {
105
+ console.error(`✗ no dist/index.html — the client build did not produce a shell`);
106
+ process.exit(1);
107
+ }
108
+ const shell = readFileSync(shellPath, "utf8");
109
+
110
+ const pages = discoverPages(appDir, exclude);
111
+ const config = await loadConfig(root);
112
+ const docsPlugins = await loadDocsPlugins(root, appDir, config, exclude);
113
+ const baseUrl = resolveBaseUrl(config);
114
+
115
+ // i18n (docs/I18N.md §6): URL path-prefix locale routing. The server pins the
116
+ // locale from the prefix (renderRoute does this via resolveLocale), emits per-locale
117
+ // <html lang> + hreflang, and detect-redirects bare paths for non-default visitors.
118
+ const i18n = config?.i18n;
119
+ const i18nOn = !!(i18n && Array.isArray(i18n.locales) && i18n.locales.length);
120
+ const nonDefault = i18nOn ? new Set(i18n.locales.filter((l) => l !== i18n.defaultLocale)) : null;
121
+
122
+ const localeOf = (pathname) =>
123
+ i18nOn && nonDefault.has(pathname.split("/")[1]) ? pathname.split("/")[1] : i18n?.defaultLocale ?? null;
124
+ const stripLocale = (pathname) => {
125
+ if (!i18nOn || !nonDefault.has(pathname.split("/")[1])) return pathname;
126
+ const rest = "/" + pathname.split("/").slice(2).join("/");
127
+ return rest === "/" ? "/" : rest.replace(/\/$/, "");
128
+ };
129
+ const localizeFor = (path, locale) =>
130
+ !locale || locale === i18n.defaultLocale ? path : path === "/" ? `/${locale}` : `/${locale}${path}`;
131
+ const alternatesFor = (path) => [
132
+ ...i18n.locales.map((l) => ({ rel: "alternate", hreflang: l, href: localizeFor(path, l) })),
133
+ { rel: "alternate", hreflang: "x-default", href: localizeFor(path, i18n.defaultLocale) },
134
+ ];
135
+ // Pick a preferred locale from a Cookie override or Accept-Language, else null.
136
+ const detectLocale = (req) => {
137
+ if (!i18nOn) return null;
138
+ const cookie = /(?:^|;\s*)otfw_locale=([^;]+)/.exec(req.headers.get("cookie") || "")?.[1];
139
+ if (cookie && i18n.locales.includes(cookie)) return cookie;
140
+ const accept = req.headers.get("accept-language");
141
+ if (!accept) return null;
142
+ for (const part of accept.split(",")) {
143
+ const tag = part.split(";")[0].trim().toLowerCase();
144
+ const hit = i18n.locales.find((l) => l.toLowerCase() === tag || l.toLowerCase() === tag.split("-")[0]);
145
+ if (hit) return hit;
146
+ }
147
+ return null;
148
+ };
149
+
150
+ const { mod, cleanup } = await buildServerBundle({ root, pages, webEntry, otfwc, docsPlugins, i18n });
151
+
152
+ const shutdown = () => {
153
+ try {
154
+ cleanup();
155
+ } catch {}
156
+ process.exit(0);
157
+ };
158
+ process.once("SIGINT", shutdown);
159
+ process.once("SIGTERM", shutdown);
160
+ process.once("exit", () => {
161
+ try {
162
+ cleanup();
163
+ } catch {}
164
+ });
165
+
166
+ // Serve a built asset from dist/ (only regular files; a directory path falls
167
+ // through to SSR). `dist/` already contains hashed assets, copied public/ files,
168
+ // and any stylesheet output.
169
+ function serveStatic(pathname) {
170
+ const file = join(distDir, pathname);
171
+ if (!file.startsWith(distDir)) return null; // path-traversal guard
172
+ if (!existsSync(file) || !statSync(file).isFile()) return null;
173
+ const ext = pathname.split(".").pop();
174
+ return new Response(readFileSync(file), {
175
+ headers: { "content-type": MIME[ext] ?? "application/octet-stream" },
176
+ });
177
+ }
178
+
179
+ // SSR one navigation: render the route's markup + <head>, inject into the shell.
180
+ async function render(url) {
181
+ const result = await mod.renderRoute(url.pathname, null, url.search);
182
+ if (!result) {
183
+ const notFound = join(distDir, "404.html");
184
+ const body = existsSync(notFound) ? readFileSync(notFound) : "<h1>404 — Not Found</h1>";
185
+ return new Response(body, { status: 404, headers: { "content-type": "text/html" } });
186
+ }
187
+ const meta = i18nOn
188
+ ? { ...result.metadata, links: [...(result.metadata.links || []), ...alternatesFor(stripLocale(url.pathname))] }
189
+ : result.metadata;
190
+ const head = mod.renderHead(meta, { path: url.pathname, baseUrl });
191
+ const localizedShell = i18nOn ? withHtmlLang(shell, localeOf(url.pathname)) : shell;
192
+ const html = injectMarkup(injectHead(localizedShell, head), result.html);
193
+ // 200 for a real route; 404 when the path fell back to the registered 404 page.
194
+ return new Response(html, {
195
+ status: result.status ?? 200,
196
+ headers: { "content-type": "text/html" },
197
+ });
198
+ }
199
+
200
+ const server = serve(startPort, explicitPort, {
201
+ async fetch(req) {
202
+ const url = new URL(req.url);
203
+ // A path with a file extension is an asset request; serve it from dist/. A
204
+ // miss on a real asset is a 404 (don't fall through to the SSR shell).
205
+ if (/\.[a-z0-9]+$/i.test(url.pathname) && url.pathname !== "/") {
206
+ const asset = serveStatic(url.pathname);
207
+ return asset ?? new Response("not found", { status: 404 });
208
+ }
209
+ // Locale detection: a bare path (no locale prefix) whose visitor prefers a
210
+ // non-default locale is redirected to the prefixed URL. The default locale
211
+ // serves bare, so an `en` visitor is never redirected (no loop).
212
+ if (i18nOn && localeOf(url.pathname) === i18n.defaultLocale) {
213
+ const pref = detectLocale(req);
214
+ if (pref && pref !== i18n.defaultLocale) {
215
+ const target = localizeFor(stripLocale(url.pathname), pref) + url.search;
216
+ return new Response(null, { status: 302, headers: { location: target } });
217
+ }
218
+ }
219
+ try {
220
+ return await render(url);
221
+ } catch (e) {
222
+ console.error(`✗ SSR failed for ${url.pathname}: ${e?.message ?? e}`);
223
+ return new Response(`<pre>SSR error: ${e?.message ?? e}</pre>`, {
224
+ status: 500,
225
+ headers: { "content-type": "text/html" },
226
+ });
227
+ }
228
+ },
229
+ });
230
+
231
+ console.log(`\n OTF Web SSR server`);
232
+ console.log(` → http://localhost:${server.port} (${pages.length} routes, server-rendered)`);
233
+ console.log(` ✓ ready in ${Date.now() - bootStart}ms\n`);
234
+ }