@opentf/web-cli 1.2.0 → 1.3.1
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/package.json +3 -3
- package/src/build.js +35 -1
- package/src/dev.js +141 -3
- package/src/prerender.js +31 -6
- package/src/serve.js +87 -25
- package/src/shared.js +341 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opentf/web-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "OTF Web dev toolchain — the Rolldown-driven CSR dev server, production build, and SSG.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
13
|
"test": "bun test tests/",
|
|
14
|
-
"test:e2e": "bun tests/e2e/serve.mjs && bun tests/e2e/hydrate-browser.mjs"
|
|
14
|
+
"test:e2e": "bun tests/e2e/serve.mjs && bun tests/e2e/build-data.mjs && bun tests/e2e/hydrate-browser.mjs"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"src"
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"bun": ">=1.0.0"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@opentf/web-compiler": "0.
|
|
23
|
+
"@opentf/web-compiler": "0.3.0",
|
|
24
24
|
"@tailwindcss/node": "4.2.2",
|
|
25
25
|
"@tailwindcss/oxide": "4.2.2",
|
|
26
26
|
"rolldown": "1.0.0-rc.17",
|
package/src/build.js
CHANGED
|
@@ -14,13 +14,19 @@ import {
|
|
|
14
14
|
writeFileSync,
|
|
15
15
|
} from "node:fs";
|
|
16
16
|
import { basename, join } from "node:path";
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
17
18
|
|
|
18
19
|
import { compileCss, usesTailwind } from "./tailwind.js";
|
|
19
20
|
import {
|
|
20
21
|
EXTENSIONS,
|
|
22
|
+
assertNoRouteConflicts,
|
|
21
23
|
cssPlugin,
|
|
24
|
+
discoverLoaders,
|
|
22
25
|
discoverPages,
|
|
26
|
+
emitApiBundle,
|
|
27
|
+
emitLoaderBundle,
|
|
23
28
|
entrySource,
|
|
29
|
+
loaderRoutePath,
|
|
24
30
|
injectBeforeBody,
|
|
25
31
|
loadConfig,
|
|
26
32
|
loadDocsPlugins,
|
|
@@ -76,6 +82,7 @@ export async function runBuild(options = {}) {
|
|
|
76
82
|
console.error(`✗ no page.jsx files found under ${appDir}`);
|
|
77
83
|
process.exit(1);
|
|
78
84
|
}
|
|
85
|
+
assertNoRouteConflicts(appDir, exclude);
|
|
79
86
|
|
|
80
87
|
// Build the client for hydration when there will be server markup to adopt — the
|
|
81
88
|
// SSR server (`runBuild({ hydrate: true })`) and `--ssg` pre-rendered pages. A
|
|
@@ -94,7 +101,9 @@ export async function runBuild(options = {}) {
|
|
|
94
101
|
const tmp = join(root, ".otfw");
|
|
95
102
|
mkdirSync(tmp, { recursive: true });
|
|
96
103
|
const entry = join(tmp, "entry.js");
|
|
97
|
-
|
|
104
|
+
const loaderFiles = discoverLoaders(appDir, exclude);
|
|
105
|
+
const loaderRoutes = loaderFiles.map((f) => loaderRoutePath(f, appDir));
|
|
106
|
+
writeFileSync(entry, entrySource(pages, appDir, undefined, config?.i18n, config?.nav, loaderRoutes));
|
|
98
107
|
|
|
99
108
|
console.log("\n OTF Web — production build\n");
|
|
100
109
|
|
|
@@ -161,6 +170,24 @@ export async function runBuild(options = {}) {
|
|
|
161
170
|
const chunks = result.output.filter((o) => o.type === "chunk").length;
|
|
162
171
|
buildStep.done(`Compiled ${pages.length} routes · bundled ${chunks} chunks`);
|
|
163
172
|
|
|
173
|
+
// Route loaders (docs/DATA.md): emit the server bundle to dist/server/loaders.js
|
|
174
|
+
// — `otfw serve` imports it per request, and the SSG pre-render below runs it at
|
|
175
|
+
// build time (so this must precede the SSG step).
|
|
176
|
+
let loaders = null;
|
|
177
|
+
if (loaderFiles.length > 0) {
|
|
178
|
+
const loaderStep = step("Bundling route loaders");
|
|
179
|
+
await emitLoaderBundle({
|
|
180
|
+
root,
|
|
181
|
+
appDir,
|
|
182
|
+
webEntry,
|
|
183
|
+
exclude,
|
|
184
|
+
i18n: config?.i18n,
|
|
185
|
+
outDir: join(outDir, "server"),
|
|
186
|
+
});
|
|
187
|
+
loaders = (await import(pathToFileURL(join(outDir, "server", "loaders.js")).href)).loaders;
|
|
188
|
+
loaderStep.done(`Route loaders — ${loaderFiles.length} → dist/server/loaders.js`);
|
|
189
|
+
}
|
|
190
|
+
|
|
164
191
|
// SSG: pre-render each route into static HTML using the shell we just composed
|
|
165
192
|
// (so per-route files carry the same bundle + stylesheet links).
|
|
166
193
|
let ssg = null;
|
|
@@ -181,6 +208,7 @@ export async function runBuild(options = {}) {
|
|
|
181
208
|
docsPlugins,
|
|
182
209
|
lastUpdated,
|
|
183
210
|
i18n: config?.i18n,
|
|
211
|
+
loaders,
|
|
184
212
|
onCompile: (id) => ssgStep.update(`compiling ${basename(id)} (${++ssgCompiled})`),
|
|
185
213
|
onRender: (done, total) => ssgStep.update(`rendering ${done}/${total}`),
|
|
186
214
|
});
|
|
@@ -190,6 +218,12 @@ export async function runBuild(options = {}) {
|
|
|
190
218
|
);
|
|
191
219
|
}
|
|
192
220
|
|
|
221
|
+
// API routes (SPEC §11/§13): emit the server handler bundle to dist/server/api.js
|
|
222
|
+
// so a deploy adapter (Bun/Node/CF Workers) can serve /api/* in production.
|
|
223
|
+
const apiStep = step("Bundling API routes");
|
|
224
|
+
const api = await emitApiBundle({ root, appDir, webEntry, exclude, outDir: join(outDir, "server") });
|
|
225
|
+
apiStep.done(api ? `API routes — ${api.routes.length} → dist/server/api.js` : "API routes — none");
|
|
226
|
+
|
|
193
227
|
// Copy the public/ directory (static assets served at the root), if present.
|
|
194
228
|
const publicDir = join(root, "public");
|
|
195
229
|
if (existsSync(publicDir)) cpSync(publicDir, outDir, { recursive: true });
|
package/src/dev.js
CHANGED
|
@@ -18,16 +18,23 @@
|
|
|
18
18
|
|
|
19
19
|
import { rolldown } from "rolldown";
|
|
20
20
|
import { existsSync, mkdirSync, readFileSync, statSync, watch, writeFileSync } from "node:fs";
|
|
21
|
-
import { join } from "node:path";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { pathToFileURL } from "node:url";
|
|
22
23
|
|
|
23
24
|
import { compileCss, usesTailwind } from "./tailwind.js";
|
|
24
25
|
import { overlayClient } from "./overlay.js";
|
|
25
26
|
import {
|
|
26
27
|
EXTENSIONS,
|
|
27
28
|
MIME,
|
|
29
|
+
assertNoRouteConflicts,
|
|
30
|
+
buildApiBundle,
|
|
31
|
+
buildLoaderBundle,
|
|
28
32
|
cssPlugin,
|
|
33
|
+
discoverApiRoutes,
|
|
34
|
+
discoverLoaders,
|
|
29
35
|
discoverPages,
|
|
30
36
|
entrySource,
|
|
37
|
+
loaderRoutePath,
|
|
31
38
|
injectBeforeBody,
|
|
32
39
|
loadConfig,
|
|
33
40
|
loadDocsPlugins,
|
|
@@ -97,6 +104,7 @@ export async function runDev() {
|
|
|
97
104
|
console.error(`✗ no page.jsx files found under ${appDir}`);
|
|
98
105
|
process.exit(1);
|
|
99
106
|
}
|
|
107
|
+
assertNoRouteConflicts(appDir, exclude);
|
|
100
108
|
|
|
101
109
|
const config = await loadConfig(root);
|
|
102
110
|
const docsPlugins = await loadDocsPlugins(root, appDir, config, exclude);
|
|
@@ -104,7 +112,10 @@ export async function runDev() {
|
|
|
104
112
|
const devDir = join(root, ".dev");
|
|
105
113
|
mkdirSync(devDir, { recursive: true });
|
|
106
114
|
const entryFile = join(devDir, "entry.js");
|
|
107
|
-
|
|
115
|
+
const loaderRoutes = () => discoverLoaders(appDir, exclude).map((f) => loaderRoutePath(f, appDir));
|
|
116
|
+
const writeEntry = () =>
|
|
117
|
+
writeFileSync(entryFile, entrySource(pages, appDir, toRouteUrl, config?.i18n, config?.nav, loaderRoutes()));
|
|
118
|
+
writeEntry();
|
|
108
119
|
|
|
109
120
|
let server;
|
|
110
121
|
const publish = (msg) => server?.publish("hmr", JSON.stringify(msg));
|
|
@@ -184,6 +195,88 @@ export async function runDev() {
|
|
|
184
195
|
return routeCache.get(file);
|
|
185
196
|
}
|
|
186
197
|
|
|
198
|
+
// API routes (SPEC §11) are plain server modules under app/api/. In dev they're
|
|
199
|
+
// built lazily on the first /api/* request and rebuilt after an edit (the watcher
|
|
200
|
+
// clears the cache). `bust` busts the ESM import cache so edits take effect.
|
|
201
|
+
let apiBundle = null;
|
|
202
|
+
let apiBuilt = false;
|
|
203
|
+
let apiVersion = 0;
|
|
204
|
+
async function getApi() {
|
|
205
|
+
if (!apiBuilt) {
|
|
206
|
+
try {
|
|
207
|
+
apiBundle = await buildApiBundle({ root, appDir, webEntry, exclude, bust: ++apiVersion });
|
|
208
|
+
} catch (e) {
|
|
209
|
+
console.error(`✗ API build failed: ${e?.message ?? e}`);
|
|
210
|
+
apiBundle = await brokenApiStub(e);
|
|
211
|
+
}
|
|
212
|
+
apiBuilt = true;
|
|
213
|
+
}
|
|
214
|
+
return apiBundle;
|
|
215
|
+
}
|
|
216
|
+
// A failed API build must not silently serve the SPA shell for endpoint URLs:
|
|
217
|
+
// stand in with a handler that 500s exactly the discovered endpoints (real
|
|
218
|
+
// matching semantics via createApiHandler) until the next successful rebuild.
|
|
219
|
+
async function brokenApiStub(err) {
|
|
220
|
+
try {
|
|
221
|
+
const serverApi = pathToFileURL(join(dirname(webEntry), "server", "index.js")).href;
|
|
222
|
+
const { createApiHandler } = await import(serverApi);
|
|
223
|
+
// Strip ANSI color codes — the bundler's terminal diagnostic goes into JSON here.
|
|
224
|
+
const msg = String(err?.message ?? err).replace(/\u001b\[[0-9;]*m/g, "");
|
|
225
|
+
const fail = () => Response.json({ error: `API routes failed to build: ${msg}` }, { status: 500 });
|
|
226
|
+
const stub = Object.fromEntries(
|
|
227
|
+
["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].map((m) => [m, fail]),
|
|
228
|
+
);
|
|
229
|
+
const { routes } = discoverApiRoutes(appDir, exclude);
|
|
230
|
+
return {
|
|
231
|
+
handler: createApiHandler(Object.fromEntries(routes.map((f) => [f, stub])), {}, { appDir }),
|
|
232
|
+
routes,
|
|
233
|
+
cleanup: () => {},
|
|
234
|
+
};
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function invalidateApi() {
|
|
240
|
+
try {
|
|
241
|
+
apiBundle?.cleanup();
|
|
242
|
+
} catch {}
|
|
243
|
+
apiBundle = null;
|
|
244
|
+
apiBuilt = false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Route loaders (docs/DATA.md) are plain server modules like API routes, and get
|
|
248
|
+
// the same treatment: built lazily on the first `__data.json` request, rebuilt
|
|
249
|
+
// after an edit (the watcher clears the cache), ESM cache busted per rebuild.
|
|
250
|
+
let loaderBundle = null;
|
|
251
|
+
let loadersBuilt = false;
|
|
252
|
+
let loaderVersion = 0;
|
|
253
|
+
async function getLoaders() {
|
|
254
|
+
if (!loadersBuilt) {
|
|
255
|
+
try {
|
|
256
|
+
loaderBundle = await buildLoaderBundle({
|
|
257
|
+
root,
|
|
258
|
+
appDir,
|
|
259
|
+
webEntry,
|
|
260
|
+
exclude,
|
|
261
|
+
i18n: config?.i18n,
|
|
262
|
+
bust: ++loaderVersion,
|
|
263
|
+
});
|
|
264
|
+
} catch (e) {
|
|
265
|
+
console.error(`✗ loader build failed: ${e?.message ?? e}`);
|
|
266
|
+
loaderBundle = null;
|
|
267
|
+
}
|
|
268
|
+
loadersBuilt = true;
|
|
269
|
+
}
|
|
270
|
+
return loaderBundle;
|
|
271
|
+
}
|
|
272
|
+
function invalidateLoaders() {
|
|
273
|
+
try {
|
|
274
|
+
loaderBundle?.cleanup();
|
|
275
|
+
} catch {}
|
|
276
|
+
loaderBundle = null;
|
|
277
|
+
loadersBuilt = false;
|
|
278
|
+
}
|
|
279
|
+
|
|
187
280
|
// The module graph powers precise invalidation; build it in the background so it
|
|
188
281
|
// never blocks startup. Until it's ready, a change clears every cache (safe).
|
|
189
282
|
let graph = { has: () => false, affected: () => new Set() };
|
|
@@ -217,12 +310,30 @@ export async function runDev() {
|
|
|
217
310
|
const watcher = watch(appDir, { recursive: true }, (_evt, name) => {
|
|
218
311
|
if (!name) return;
|
|
219
312
|
const file = join(appDir, name);
|
|
313
|
+
// An API endpoint/middleware edit (any `route.*` / `_middleware.*` file) rebuilds
|
|
314
|
+
// the API bundle on the next request and triggers a reload.
|
|
315
|
+
if (/^(route|_middleware)\.(jsx?|tsx?)$/.test(name.split("/").pop())) {
|
|
316
|
+
invalidateApi();
|
|
317
|
+
publish({ type: "reload" });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
// A loader edit rebuilds the loader bundle on the next data request; a new or
|
|
321
|
+
// deleted loader.* also changes the route set baked into the entry, so the
|
|
322
|
+
// entry is rewritten and its cached chunk dropped (a stale set would make SPA
|
|
323
|
+
// nav skip the fetch — or fetch a 404).
|
|
324
|
+
if (/^loader\.(js|ts)$/.test(name.split("/").pop())) {
|
|
325
|
+
invalidateLoaders();
|
|
326
|
+
writeEntry();
|
|
327
|
+
entryCode = null;
|
|
328
|
+
publish({ type: "reload" });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
220
331
|
if (/\.(mdx|md|[jt]sx|css)$/.test(name)) {
|
|
221
332
|
if (/^(page|layout|404)\.(mdx|md|[jt]sx)$/.test(name.split("/").pop()) && !pages.includes(file)) {
|
|
222
333
|
const fresh = discoverPages(appDir, exclude);
|
|
223
334
|
pages.length = 0;
|
|
224
335
|
pages.push(...fresh);
|
|
225
|
-
|
|
336
|
+
writeEntry();
|
|
226
337
|
}
|
|
227
338
|
onChange(file);
|
|
228
339
|
}
|
|
@@ -234,6 +345,8 @@ export async function runDev() {
|
|
|
234
345
|
try {
|
|
235
346
|
watcher.close();
|
|
236
347
|
} catch {}
|
|
348
|
+
invalidateApi();
|
|
349
|
+
invalidateLoaders();
|
|
237
350
|
process.exit(0);
|
|
238
351
|
};
|
|
239
352
|
process.once("SIGINT", shutdown);
|
|
@@ -242,6 +355,8 @@ export async function runDev() {
|
|
|
242
355
|
try {
|
|
243
356
|
watcher.close();
|
|
244
357
|
} catch {}
|
|
358
|
+
invalidateApi();
|
|
359
|
+
invalidateLoaders();
|
|
245
360
|
});
|
|
246
361
|
|
|
247
362
|
// Import map (so `@opentf/web` resolves to the shared runtime chunk) + entry + the
|
|
@@ -303,6 +418,29 @@ export async function runDev() {
|
|
|
303
418
|
if (pathname.startsWith(ROUTE_PREFIX) && pathname.endsWith(".js")) {
|
|
304
419
|
return js(await serveRoute(fromRouteUrl(pathname)));
|
|
305
420
|
}
|
|
421
|
+
// API endpoints (route.* files, at any path — SPEC §11): a matched handler's
|
|
422
|
+
// Response wins; a miss falls through to static assets / the SPA shell, so pages
|
|
423
|
+
// and endpoints coexist. `getApi()` is O(1) after the first build (or a no-op
|
|
424
|
+
// when the app has no route.* files).
|
|
425
|
+
{
|
|
426
|
+
const api = await getApi();
|
|
427
|
+
const res = api ? await api.handler(req) : null;
|
|
428
|
+
if (res) return res;
|
|
429
|
+
}
|
|
430
|
+
// The route-loader data endpoint (docs/DATA.md): `<path>/__data.json` is a
|
|
431
|
+
// reserved suffix — answered here (SPA navigation fetches it) and never
|
|
432
|
+
// allowed to fall through to assets or the SPA shell, so a miss is a 404.
|
|
433
|
+
if (pathname === "/__data.json" || pathname.endsWith("/__data.json")) {
|
|
434
|
+
try {
|
|
435
|
+
const bundle = await getLoaders();
|
|
436
|
+
const res = bundle ? await bundle.loaders.handle(req) : null;
|
|
437
|
+
if (res) return res;
|
|
438
|
+
} catch (e) {
|
|
439
|
+
console.error(`✗ loader failed for ${pathname}: ${e?.message ?? e}`);
|
|
440
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
441
|
+
}
|
|
442
|
+
return Response.json(null, { status: 404 });
|
|
443
|
+
}
|
|
306
444
|
if (pathname !== "/") {
|
|
307
445
|
const asset = await serveStatic(pathname);
|
|
308
446
|
if (asset) return asset;
|
package/src/prerender.js
CHANGED
|
@@ -7,10 +7,12 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
|
7
7
|
import { dirname, join } from "node:path";
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
+
DATA_FILE,
|
|
10
11
|
buildServerBundle,
|
|
11
12
|
injectHead,
|
|
12
13
|
injectHydrationData,
|
|
13
14
|
injectMarkup,
|
|
15
|
+
injectRouteData,
|
|
14
16
|
withHtmlLang,
|
|
15
17
|
} from "./shared.js";
|
|
16
18
|
|
|
@@ -81,7 +83,7 @@ function escapeXml(s) {
|
|
|
81
83
|
* Pre-render the app to static HTML files under `outDir`. Returns
|
|
82
84
|
* `{ count, skipped, failed }`.
|
|
83
85
|
*/
|
|
84
|
-
export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, outDir, baseUrl = "", docsPlugins = [], lastUpdated = {}, i18n = null, onCompile, onRender }) {
|
|
86
|
+
export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, outDir, baseUrl = "", docsPlugins = [], lastUpdated = {}, i18n = null, loaders = null, onCompile, onRender }) {
|
|
85
87
|
const { mod, cleanup } = await buildServerBundle({ root, pages, webEntry, otfwc, docsPlugins, i18n, onCompile });
|
|
86
88
|
|
|
87
89
|
// i18n (docs/I18N.md §6): pre-render each route once per locale. The default
|
|
@@ -101,10 +103,20 @@ export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, ou
|
|
|
101
103
|
for (const locale of locales) {
|
|
102
104
|
const urlPath = localizeFor(path, locale, defaultLocale);
|
|
103
105
|
try {
|
|
106
|
+
// Route loader (docs/DATA.md): run it at build time with no `request` and
|
|
107
|
+
// an empty query (a query-dependent loader needs `otfw serve`). The result
|
|
108
|
+
// is threaded into the render (`router.data`), inlined into the HTML, and
|
|
109
|
+
// written as a sibling `__data.json` — per locale, since a localized
|
|
110
|
+
// loader can return locale-dependent data.
|
|
111
|
+
const m = loaders?.match(urlPath);
|
|
112
|
+
let data;
|
|
113
|
+
let dataJson = "";
|
|
114
|
+
if (m) ({ data, json: dataJson } = await loaders.loadSerialized(m, { query: {} }));
|
|
115
|
+
|
|
104
116
|
// Passing the localized URL pins router.locale (resolveLocale) and still
|
|
105
117
|
// matches the locale-agnostic route table, so `t()` renders in `locale`.
|
|
106
118
|
const { html, metadata, hydration } =
|
|
107
|
-
(await mod.renderRoute(urlPath, params)) ?? { html: "", metadata: {}, hydration: "" };
|
|
119
|
+
(await mod.renderRoute(urlPath, params, "", { data })) ?? { html: "", metadata: {}, hydration: "" };
|
|
108
120
|
const meta = i18nOn
|
|
109
121
|
? { ...metadata, links: [...(metadata.links || []), ...alternatesFor(path, locales, defaultLocale)] }
|
|
110
122
|
: metadata;
|
|
@@ -117,15 +129,28 @@ export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, ou
|
|
|
117
129
|
mkdirSync(dirname(file), { recursive: true });
|
|
118
130
|
writeFileSync(
|
|
119
131
|
file,
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
132
|
+
injectRouteData(
|
|
133
|
+
injectHydrationData(
|
|
134
|
+
injectMarkup(injectHead(withHtmlLang(shellHtml, locale), head), html),
|
|
135
|
+
hydration,
|
|
136
|
+
),
|
|
137
|
+
dataJson,
|
|
123
138
|
),
|
|
124
139
|
);
|
|
140
|
+
if (m) {
|
|
141
|
+
// The static data file SPA navigation fetches on a plain static host —
|
|
142
|
+
// byte-identical to the inline payload ("null" when the loader returned
|
|
143
|
+
// undefined, so a 200 still parses as JSON).
|
|
144
|
+
const dataFile =
|
|
145
|
+
urlPath === "/" ? join(outDir, DATA_FILE) : join(outDir, urlPath, DATA_FILE);
|
|
146
|
+
mkdirSync(dirname(dataFile), { recursive: true });
|
|
147
|
+
writeFileSync(dataFile, dataJson || "null");
|
|
148
|
+
}
|
|
125
149
|
rendered.push(urlPath);
|
|
126
150
|
} catch (e) {
|
|
127
151
|
failed.push(urlPath);
|
|
128
|
-
|
|
152
|
+
const why = e?.otfwNotFound ? "loader called notFound()" : (e?.message ?? e);
|
|
153
|
+
console.error(`✗ pre-render failed for ${urlPath}: ${why}`);
|
|
129
154
|
}
|
|
130
155
|
}
|
|
131
156
|
}
|
package/src/serve.js
CHANGED
|
@@ -16,25 +16,28 @@
|
|
|
16
16
|
// • anything else → SSR: render the route's markup + <head>, inject into the
|
|
17
17
|
// shell, return text/html.
|
|
18
18
|
//
|
|
19
|
-
// Hydration (Phase 2.
|
|
20
|
-
// `#app` carries the `data-otfw-hydrate` sentinel,
|
|
21
|
-
// server-rendered DOM on first paint (no rebuild/flash)
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// clean CSR mount. The per-request HTML is real either way (first
|
|
25
|
-
// content, SEO).
|
|
19
|
+
// Hydration (Phase 2, complete — docs/HYDRATION.md §4): the client build is the
|
|
20
|
+
// hydrate target and the shell's `#app` carries the `data-otfw-hydrate` sentinel,
|
|
21
|
+
// so the client *adopts* the server-rendered DOM on first paint (no rebuild/flash)
|
|
22
|
+
// — pages, layout chains, keyed lists, conditionals, and component islands with
|
|
23
|
+
// rich props (2.0/2.5/2.1a–d). A route the adopt walk can't handle falls back to
|
|
24
|
+
// a clean CSR mount per component. The per-request HTML is real either way (first
|
|
25
|
+
// paint, dynamic content, SEO).
|
|
26
26
|
|
|
27
27
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
28
28
|
import { join } from "node:path";
|
|
29
|
+
import { pathToFileURL } from "node:url";
|
|
29
30
|
|
|
30
31
|
import { runBuild } from "./build.js";
|
|
31
32
|
import {
|
|
32
33
|
MIME,
|
|
33
34
|
buildServerBundle,
|
|
35
|
+
discoverApiRoutes,
|
|
34
36
|
discoverPages,
|
|
35
37
|
injectHead,
|
|
36
38
|
injectHydrationData,
|
|
37
39
|
injectMarkup,
|
|
40
|
+
injectRouteData,
|
|
38
41
|
loadConfig,
|
|
39
42
|
loadDocsPlugins,
|
|
40
43
|
loadProject,
|
|
@@ -150,19 +153,37 @@ export async function runServe() {
|
|
|
150
153
|
|
|
151
154
|
const { mod, cleanup } = await buildServerBundle({ root, pages, webEntry, otfwc, docsPlugins, i18n });
|
|
152
155
|
|
|
153
|
-
|
|
156
|
+
// API routes (SPEC §11): the client build already emitted the handler bundle to
|
|
157
|
+
// dist/server/api.js (build.js `emitApiBundle`). Import it and hold it live;
|
|
158
|
+
// `apiHandler(req)` returns a Response or null (no route matched).
|
|
159
|
+
const apiFile = join(distDir, "server", "api.js");
|
|
160
|
+
let api = null;
|
|
161
|
+
if (existsSync(apiFile)) {
|
|
162
|
+
const apiMod = await import(pathToFileURL(apiFile).href);
|
|
163
|
+
api = { handler: apiMod.apiHandler, routes: discoverApiRoutes(appDir, exclude).routes };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Route loaders (docs/DATA.md): the client build emitted the registry bundle to
|
|
167
|
+
// dist/server/loaders.js (build.js `emitLoaderBundle`). Import it and hold it
|
|
168
|
+
// live; per request it runs the matched page's loader (threaded into the render
|
|
169
|
+
// as `router.data`) and answers the `<path>/__data.json` endpoint.
|
|
170
|
+
const loadersFile = join(distDir, "server", "loaders.js");
|
|
171
|
+
const loaders = existsSync(loadersFile)
|
|
172
|
+
? (await import(pathToFileURL(loadersFile).href)).loaders
|
|
173
|
+
: null;
|
|
174
|
+
|
|
175
|
+
const cleanupAll = () => {
|
|
154
176
|
try {
|
|
155
177
|
cleanup();
|
|
156
178
|
} catch {}
|
|
179
|
+
};
|
|
180
|
+
const shutdown = () => {
|
|
181
|
+
cleanupAll();
|
|
157
182
|
process.exit(0);
|
|
158
183
|
};
|
|
159
184
|
process.once("SIGINT", shutdown);
|
|
160
185
|
process.once("SIGTERM", shutdown);
|
|
161
|
-
process.once("exit",
|
|
162
|
-
try {
|
|
163
|
-
cleanup();
|
|
164
|
-
} catch {}
|
|
165
|
-
});
|
|
186
|
+
process.once("exit", cleanupAll);
|
|
166
187
|
|
|
167
188
|
// Serve a built asset from dist/ (only regular files; a directory path falls
|
|
168
189
|
// through to SSR). `dist/` already contains hashed assets, copied public/ files,
|
|
@@ -177,22 +198,45 @@ export async function runServe() {
|
|
|
177
198
|
});
|
|
178
199
|
}
|
|
179
200
|
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
201
|
+
// The 404 response: the pre-built dist/404.html when present, else a bare body.
|
|
202
|
+
function renderNotFound() {
|
|
203
|
+
const notFound = join(distDir, "404.html");
|
|
204
|
+
const body = existsSync(notFound) ? readFileSync(notFound) : "<h1>404 — Not Found</h1>";
|
|
205
|
+
return new Response(body, { status: 404, headers: { "content-type": "text/html" } });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// SSR one navigation: run the route's loader (if any), render the markup +
|
|
209
|
+
// <head>, inject into the shell.
|
|
210
|
+
async function render(url, req) {
|
|
211
|
+
// Loader first — `notFound()` must resolve before anything renders. The check
|
|
212
|
+
// is a property (`otfwNotFound`), not instanceof: the loader bundle is its own
|
|
213
|
+
// module graph. On notFound, re-render as the unmatched sentinel path so the
|
|
214
|
+
// registered 404 page is served with HTTP 404 (same as a route miss). Any
|
|
215
|
+
// other loader throw propagates to the 500 branch in `fetch`.
|
|
216
|
+
let data;
|
|
217
|
+
let dataJson = "";
|
|
218
|
+
const m = loaders?.match(url.pathname);
|
|
219
|
+
if (m) {
|
|
220
|
+
try {
|
|
221
|
+
({ data, json: dataJson } = await loaders.loadSerialized(m, {
|
|
222
|
+
request: req,
|
|
223
|
+
query: Object.fromEntries(url.searchParams),
|
|
224
|
+
}));
|
|
225
|
+
} catch (e) {
|
|
226
|
+
if (e?.otfwNotFound === true) return render(new URL("/__otfw_404__", url), req);
|
|
227
|
+
throw e;
|
|
228
|
+
}
|
|
187
229
|
}
|
|
230
|
+
const result = await mod.renderRoute(url.pathname, null, url.search, { data });
|
|
231
|
+
if (!result) return renderNotFound();
|
|
188
232
|
const meta = i18nOn
|
|
189
233
|
? { ...result.metadata, links: [...(result.metadata.links || []), ...alternatesFor(stripLocale(url.pathname))] }
|
|
190
234
|
: result.metadata;
|
|
191
235
|
const head = mod.renderHead(meta, { path: url.pathname, baseUrl });
|
|
192
236
|
const localizedShell = i18nOn ? withHtmlLang(shell, localeOf(url.pathname)) : shell;
|
|
193
|
-
const html =
|
|
194
|
-
injectMarkup(injectHead(localizedShell, head), result.html),
|
|
195
|
-
|
|
237
|
+
const html = injectRouteData(
|
|
238
|
+
injectHydrationData(injectMarkup(injectHead(localizedShell, head), result.html), result.hydration),
|
|
239
|
+
dataJson,
|
|
196
240
|
);
|
|
197
241
|
// 200 for a real route; 404 when the path fell back to the registered 404 page.
|
|
198
242
|
return new Response(html, {
|
|
@@ -204,6 +248,22 @@ export async function runServe() {
|
|
|
204
248
|
const server = serve(startPort, explicitPort, {
|
|
205
249
|
async fetch(req) {
|
|
206
250
|
const url = new URL(req.url);
|
|
251
|
+
// API endpoints (route.* files, at any path — SPEC §11) are checked first —
|
|
252
|
+
// before the asset branch, matching `otfw dev` — so an endpoint path with a
|
|
253
|
+
// dotted segment (`/api/v1.0`) still resolves. A matched handler's Response
|
|
254
|
+
// wins; a miss falls through to assets / SSR, so pages and endpoints coexist.
|
|
255
|
+
if (api) {
|
|
256
|
+
const res = await api.handler(req);
|
|
257
|
+
if (res) return res;
|
|
258
|
+
}
|
|
259
|
+
// The route-loader data endpoint (docs/DATA.md): `<path>/__data.json` is a
|
|
260
|
+
// reserved suffix, answered before the asset branch below would swallow it
|
|
261
|
+
// (it has a file extension) and never falling through to SSR (a catch-all
|
|
262
|
+
// page would happily match the suffix as a path segment) — a miss is a 404.
|
|
263
|
+
if (url.pathname === "/__data.json" || url.pathname.endsWith("/__data.json")) {
|
|
264
|
+
const res = loaders ? await loaders.handle(req) : null;
|
|
265
|
+
return res ?? Response.json(null, { status: 404 });
|
|
266
|
+
}
|
|
207
267
|
// A path with a file extension is an asset request; serve it from dist/. A
|
|
208
268
|
// miss on a real asset is a 404 (don't fall through to the SSR shell).
|
|
209
269
|
if (/\.[a-z0-9]+$/i.test(url.pathname) && url.pathname !== "/") {
|
|
@@ -221,7 +281,7 @@ export async function runServe() {
|
|
|
221
281
|
}
|
|
222
282
|
}
|
|
223
283
|
try {
|
|
224
|
-
return await render(url);
|
|
284
|
+
return await render(url, req);
|
|
225
285
|
} catch (e) {
|
|
226
286
|
console.error(`✗ SSR failed for ${url.pathname}: ${e?.message ?? e}`);
|
|
227
287
|
return new Response(`<pre>SSR error: ${e?.message ?? e}</pre>`, {
|
|
@@ -233,6 +293,8 @@ export async function runServe() {
|
|
|
233
293
|
});
|
|
234
294
|
|
|
235
295
|
console.log(`\n OTF Web SSR server`);
|
|
236
|
-
|
|
296
|
+
const apiNote = api ? `, ${api.routes.length} API routes` : "";
|
|
297
|
+
const loaderNote = loaders ? `, ${loaders.routes.length} loaders` : "";
|
|
298
|
+
console.log(` → http://localhost:${server.port} (${pages.length} routes, server-rendered${apiNote}${loaderNote})`);
|
|
237
299
|
console.log(` ✓ ready in ${Date.now() - bootStart}ms\n`);
|
|
238
300
|
}
|
package/src/shared.js
CHANGED
|
@@ -73,6 +73,23 @@ export function injectHydrationData(shellHtml, json) {
|
|
|
73
73
|
return injectBeforeBody(shellHtml, `<script type="application/json" id="__otfw_h">${json}</script>`);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/** The reserved per-route data filename/URL suffix (mirrors the runtime's
|
|
77
|
+
* `DATA_FILE` in `@opentf/web` runtime/route-data.js). */
|
|
78
|
+
export const DATA_FILE = "__data.json";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Embed a route loader's data (docs/DATA.md) as a `<script type="application/json"
|
|
82
|
+
* id="__otfw_data">` the client router reads on first paint (instead of fetching
|
|
83
|
+
* `<path>/__data.json`). A separate script from the island payload above — the two
|
|
84
|
+
* channels have independent shapes and readers. `json` comes `<`-escaped from
|
|
85
|
+
* `serializeRouteData`; no-op for an empty payload (route without a loader, or an
|
|
86
|
+
* undefined loader result).
|
|
87
|
+
*/
|
|
88
|
+
export function injectRouteData(shellHtml, json) {
|
|
89
|
+
if (!json) return shellHtml;
|
|
90
|
+
return injectBeforeBody(shellHtml, `<script type="application/json" id="__otfw_data">${json}</script>`);
|
|
91
|
+
}
|
|
92
|
+
|
|
76
93
|
/**
|
|
77
94
|
* Stamp the `data-otfw-hydrate` sentinel onto the shell's `#app` container, telling
|
|
78
95
|
* the client to *adopt* the server-rendered DOM on first paint instead of rebuilding
|
|
@@ -413,6 +430,325 @@ export function discoverPages(dir, exclude) {
|
|
|
413
430
|
return out;
|
|
414
431
|
}
|
|
415
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Discover file-based API routes anywhere under `app/` (SPEC §11). An endpoint is a
|
|
435
|
+
* `route.{js,ts}` file (the API analogue of `page.{jsx,tsx}`); its folder is the URL.
|
|
436
|
+
* Returns `{ routes, middleware }` — absolute paths to `route.*` handler modules and
|
|
437
|
+
* to `_middleware.{js,ts}` files (folder middleware) respectively.
|
|
438
|
+
*/
|
|
439
|
+
export function discoverApiRoutes(appDir, exclude = new Set()) {
|
|
440
|
+
const routes = [];
|
|
441
|
+
const middleware = [];
|
|
442
|
+
const walk = (dir) => {
|
|
443
|
+
if (!existsSync(dir)) return;
|
|
444
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
445
|
+
if (entry.isDirectory()) {
|
|
446
|
+
if (!exclude.has(entry.name)) walk(join(dir, entry.name));
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const name = entry.name;
|
|
450
|
+
const full = join(dir, name);
|
|
451
|
+
if (/^route\.(jsx?|tsx?)$/.test(name)) routes.push(full);
|
|
452
|
+
else if (/^_middleware\.(jsx?|tsx?)$/.test(name)) middleware.push(full);
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
walk(appDir);
|
|
456
|
+
return { routes, middleware };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Strip the app-directory prefix from a route file path. With `appDir` the exact
|
|
461
|
+
* prefix is removed (unambiguous even for `app/app/...`); without it, fall back to
|
|
462
|
+
* the last complete `/app` path segment — the lookahead keeps folders that merely
|
|
463
|
+
* start with "app" (`/appointments`) intact.
|
|
464
|
+
*/
|
|
465
|
+
function stripAppPrefix(filePath, appDir) {
|
|
466
|
+
if (appDir) {
|
|
467
|
+
const base = appDir.replace(/\/+$/, "");
|
|
468
|
+
if (filePath.startsWith(base + "/")) return filePath.slice(base.length);
|
|
469
|
+
}
|
|
470
|
+
return filePath.replace(/^.*\/app(?=\/)/, "");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** The page URL for a `.../app/<...>/page.{mdx,md,jsx,tsx}` file (folder = URL). */
|
|
474
|
+
export function pageRouteFromPath(filePath, appDir) {
|
|
475
|
+
const r = stripAppPrefix(filePath, appDir).replace(/\/page\.(mdx|md|jsx|tsx)$/, "");
|
|
476
|
+
return r === "" ? "/" : r;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** The route URL for a `.../app/<...>/route.{js,ts}` file (folder = URL). */
|
|
480
|
+
function apiRoutePath(filePath, appDir) {
|
|
481
|
+
const r = stripAppPrefix(filePath, appDir).replace(/\/route\.(jsx?|tsx?)$/, "");
|
|
482
|
+
return r === "" ? "/" : r;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Discover route loaders anywhere under `app/` (docs/DATA.md). A loader is a
|
|
487
|
+
* `loader.{js,ts}` file sibling to a `page.*` — the data analogue of a `route.*`
|
|
488
|
+
* endpoint. Strictly `js|ts` (no `x` variants): loaders are plain server modules,
|
|
489
|
+
* never JSX. Returns absolute file paths.
|
|
490
|
+
*/
|
|
491
|
+
export function discoverLoaders(appDir, exclude = new Set()) {
|
|
492
|
+
const out = [];
|
|
493
|
+
const walk = (dir) => {
|
|
494
|
+
if (!existsSync(dir)) return;
|
|
495
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
496
|
+
if (entry.isDirectory()) {
|
|
497
|
+
if (!exclude.has(entry.name)) walk(join(dir, entry.name));
|
|
498
|
+
} else if (/^loader\.(js|ts)$/.test(entry.name)) out.push(join(dir, entry.name));
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
walk(appDir);
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** The page route a `.../app/<...>/loader.{js,ts}` file feeds (folder = URL). */
|
|
506
|
+
export function loaderRoutePath(filePath, appDir) {
|
|
507
|
+
const r = stripAppPrefix(filePath, appDir).replace(/\/loader\.(js|ts)$/, "");
|
|
508
|
+
return r === "" ? "/" : r;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Detect folders that hold both a `page.*` and a `route.*` — they'd resolve to the
|
|
513
|
+
* same URL. Returns the conflicting `{ path, page, route }` entries (empty = none).
|
|
514
|
+
* A page and an endpoint cannot own the same path (as in Next.js's App Router).
|
|
515
|
+
*/
|
|
516
|
+
export function detectRouteConflicts(appDir, exclude = new Set()) {
|
|
517
|
+
const pageByRoute = new Map();
|
|
518
|
+
for (const p of discoverPages(appDir, exclude)) {
|
|
519
|
+
if (/\/page\.(mdx|md|jsx|tsx)$/.test(p)) pageByRoute.set(pageRouteFromPath(p, appDir), p);
|
|
520
|
+
}
|
|
521
|
+
const conflicts = [];
|
|
522
|
+
for (const r of discoverApiRoutes(appDir, exclude).routes) {
|
|
523
|
+
const path = apiRoutePath(r, appDir);
|
|
524
|
+
if (pageByRoute.has(path)) conflicts.push({ path, page: pageByRoute.get(path), route: r });
|
|
525
|
+
}
|
|
526
|
+
return conflicts;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Detect misplaced `loader.*` files (docs/DATA.md): a loader feeds the page in
|
|
531
|
+
* its folder, so one without a sibling `page.*` — including one placed next to a
|
|
532
|
+
* `route.*` endpoint — has nothing to feed. Returns `{ loader, route, reason }`.
|
|
533
|
+
*/
|
|
534
|
+
export function detectLoaderConflicts(appDir, exclude = new Set()) {
|
|
535
|
+
const conflicts = [];
|
|
536
|
+
for (const loader of discoverLoaders(appDir, exclude)) {
|
|
537
|
+
const dir = dirname(loader);
|
|
538
|
+
const hasPage = ["page.jsx", "page.tsx", "page.mdx", "page.md"].some((f) => existsSync(join(dir, f)));
|
|
539
|
+
if (hasPage) continue;
|
|
540
|
+
const hasRoute = ["route.js", "route.ts"].some((f) => existsSync(join(dir, f)));
|
|
541
|
+
conflicts.push({
|
|
542
|
+
loader,
|
|
543
|
+
route: loaderRoutePath(loader, appDir),
|
|
544
|
+
reason: hasRoute
|
|
545
|
+
? "sits next to a route.* API endpoint (endpoints take a Request; loaders feed pages)"
|
|
546
|
+
: "has no sibling page.* to feed",
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
return conflicts;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Print the route/page/loader conflicts and exit — shared by build, dev, and serve. */
|
|
553
|
+
export function assertNoRouteConflicts(appDir, exclude = new Set()) {
|
|
554
|
+
const conflicts = detectRouteConflicts(appDir, exclude);
|
|
555
|
+
if (conflicts.length > 0) {
|
|
556
|
+
const lines = conflicts.map((c) => ` ${c.path}\n page: ${c.page}\n route: ${c.route}`).join("\n");
|
|
557
|
+
fail(`a page and an API route cannot resolve to the same path:\n${lines}\n Move one to a different folder.`);
|
|
558
|
+
}
|
|
559
|
+
const loaderConflicts = detectLoaderConflicts(appDir, exclude);
|
|
560
|
+
if (loaderConflicts.length > 0) {
|
|
561
|
+
const lines = loaderConflicts.map((c) => ` ${c.route}\n loader: ${c.loader}\n ${c.reason}`).join("\n");
|
|
562
|
+
fail(`a loader.* file must sit next to the page.* it feeds:\n${lines}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Entry source for the API handler bundle: statically import every route +
|
|
568
|
+
* middleware module (keyed by absolute path so `createApiHandler` can derive the
|
|
569
|
+
* route from the path), then export the composed `Request → Response | null`
|
|
570
|
+
* handler. Mirrors `serverEntrySource` for the page/SSG bundle.
|
|
571
|
+
*/
|
|
572
|
+
export function apiEntrySource({ routes, middleware }, appDir) {
|
|
573
|
+
const rImports = routes.map((p, i) => `import * as r${i} from ${JSON.stringify(p)};`).join("\n");
|
|
574
|
+
const mImports = middleware.map((p, i) => `import * as m${i} from ${JSON.stringify(p)};`).join("\n");
|
|
575
|
+
const rMap = routes.map((p, i) => ` [${JSON.stringify(p)}]: r${i},`).join("\n");
|
|
576
|
+
const mMap = middleware.map((p, i) => ` [${JSON.stringify(p)}]: m${i},`).join("\n");
|
|
577
|
+
// `appDir` pins the exact prefix to strip from the keys — route derivation stays
|
|
578
|
+
// correct for folders whose name starts with "app" and for nested app/app dirs.
|
|
579
|
+
const opts = appDir ? `\n{ appDir: ${JSON.stringify(appDir)} },\n` : "\n";
|
|
580
|
+
return (
|
|
581
|
+
`import { createApiHandler } from "@opentf/web/server";\n` +
|
|
582
|
+
`${rImports}\n${mImports}\n` +
|
|
583
|
+
`export const apiHandler = createApiHandler(\n{\n${rMap}\n},\n{\n${mMap}\n},${opts});\n`
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Build the API handler bundle and import it. Returns `{ handler, cleanup }`
|
|
589
|
+
* where `handler(request)` resolves to a `Response` or `null` (no API route
|
|
590
|
+
* matched). Route modules are *plain server code* — bundled as ESM without the
|
|
591
|
+
* `otfwc` DOM transform. Bare (npm / node builtin) imports stay external and are
|
|
592
|
+
* resolved at runtime from the project's node_modules, so server deps and native
|
|
593
|
+
* modules aren't dragged into the bundle. Returns `null` when there are no routes.
|
|
594
|
+
*/
|
|
595
|
+
export async function buildApiBundle({ root, appDir, webEntry, exclude, tmpName = ".otfw-api", bust }) {
|
|
596
|
+
const discovered = discoverApiRoutes(appDir, exclude);
|
|
597
|
+
if (discovered.routes.length === 0) return null;
|
|
598
|
+
|
|
599
|
+
const tmp = join(root, tmpName);
|
|
600
|
+
mkdirSync(tmp, { recursive: true });
|
|
601
|
+
const entry = join(tmp, "api-entry.js");
|
|
602
|
+
writeFileSync(entry, apiEntrySource(discovered, appDir));
|
|
603
|
+
|
|
604
|
+
const serverApi = join(dirname(webEntry), "server", "index.js");
|
|
605
|
+
// `bust` versions the emitted *filename* so `otfw dev` picks up handler edits on
|
|
606
|
+
// rebuild — Bun's ESM cache is keyed by file path and ignores a `?v=` query, so
|
|
607
|
+
// only a genuinely new path re-evaluates the module.
|
|
608
|
+
const outName = bust ? `api.${bust}.js` : "api.js";
|
|
609
|
+
await build({
|
|
610
|
+
input: entry,
|
|
611
|
+
platform: "node",
|
|
612
|
+
resolve: {
|
|
613
|
+
alias: { "@opentf/web/server": serverApi, "@opentf/web": webEntry },
|
|
614
|
+
extensions: EXTENSIONS,
|
|
615
|
+
},
|
|
616
|
+
// Keep npm/node builtins external — server handlers resolve them at runtime.
|
|
617
|
+
external: (id) => !id.startsWith(".") && !id.startsWith("/") && !id.startsWith("@opentf/web"),
|
|
618
|
+
output: { dir: join(tmp, "out"), format: "esm", entryFileNames: outName },
|
|
619
|
+
checks: { pluginTimings: false },
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
const mod = await import(pathToFileURL(join(tmp, "out", outName)).href);
|
|
623
|
+
return {
|
|
624
|
+
handler: mod.apiHandler,
|
|
625
|
+
routes: discovered.routes,
|
|
626
|
+
cleanup: () => rmSync(tmp, { recursive: true, force: true }),
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Emit the API handler bundle to `outDir/api.js` for production/deploy (SPEC §13,
|
|
632
|
+
* `dist/server/`). Unlike `buildApiBundle` (which bundles to a temp dir and imports
|
|
633
|
+
* it for `dev`/`serve`), this writes a persistent, self-contained ESM module that
|
|
634
|
+
* exports `apiHandler` — the runtime dispatcher (`createApiHandler`) is bundled in;
|
|
635
|
+
* npm/node deps stay external and resolve at the deploy target. A deploy adapter
|
|
636
|
+
* (`server/adapters/`) imports this file and hands requests to `apiHandler`.
|
|
637
|
+
* Returns `{ routes }`, or `null` when the project has no API routes.
|
|
638
|
+
*/
|
|
639
|
+
export async function emitApiBundle({ root, appDir, webEntry, exclude, outDir }) {
|
|
640
|
+
const discovered = discoverApiRoutes(appDir, exclude);
|
|
641
|
+
if (discovered.routes.length === 0) return null;
|
|
642
|
+
|
|
643
|
+
const tmp = join(root, ".otfw-api-build");
|
|
644
|
+
mkdirSync(tmp, { recursive: true });
|
|
645
|
+
const entry = join(tmp, "api-entry.js");
|
|
646
|
+
writeFileSync(entry, apiEntrySource(discovered, appDir));
|
|
647
|
+
|
|
648
|
+
const serverApi = join(dirname(webEntry), "server", "index.js");
|
|
649
|
+
mkdirSync(outDir, { recursive: true });
|
|
650
|
+
await build({
|
|
651
|
+
input: entry,
|
|
652
|
+
platform: "node",
|
|
653
|
+
resolve: { alias: { "@opentf/web/server": serverApi, "@opentf/web": webEntry }, extensions: EXTENSIONS },
|
|
654
|
+
external: (id) => !id.startsWith(".") && !id.startsWith("/") && !id.startsWith("@opentf/web"),
|
|
655
|
+
output: { dir: outDir, format: "esm", entryFileNames: "api.js" },
|
|
656
|
+
checks: { pluginTimings: false },
|
|
657
|
+
});
|
|
658
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
659
|
+
return { routes: discovered.routes };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Entry source for the loader bundle (docs/DATA.md): statically import every
|
|
664
|
+
* `loader.{js,ts}` module (keyed by absolute path so the registry derives each
|
|
665
|
+
* route from it) and export the composed registry. Mirrors `apiEntrySource`.
|
|
666
|
+
*/
|
|
667
|
+
export function loaderEntrySource(loaderFiles, appDir, i18n = null) {
|
|
668
|
+
const imports = loaderFiles.map((p, i) => `import * as l${i} from ${JSON.stringify(p)};`).join("\n");
|
|
669
|
+
const map = loaderFiles.map((p, i) => ` [${JSON.stringify(p)}]: l${i},`).join("\n");
|
|
670
|
+
const i18nOpt =
|
|
671
|
+
i18n && Array.isArray(i18n.locales) && i18n.locales.length
|
|
672
|
+
? `, i18n: ${JSON.stringify({ locales: i18n.locales, defaultLocale: i18n.defaultLocale })}`
|
|
673
|
+
: "";
|
|
674
|
+
const opts = `\n{ appDir: ${JSON.stringify(appDir ?? "")}${i18nOpt} },\n`;
|
|
675
|
+
return (
|
|
676
|
+
`import { createLoaderRegistry } from "@opentf/web/server";\n` +
|
|
677
|
+
`${imports}\n` +
|
|
678
|
+
`export const loaders = createLoaderRegistry(\n{\n${map}\n},${opts});\n`
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Build the loader bundle and import it. Returns `{ loaders, files, cleanup }`
|
|
684
|
+
* where `loaders` is the registry (`match`/`load`/`loadSerialized`/`handle` —
|
|
685
|
+
* see `createLoaderRegistry`), or `null` when the app has no loader files.
|
|
686
|
+
* Loaders are *plain server code*, bundled exactly like the API handler bundle:
|
|
687
|
+
* no `otfwc` DOM transform, npm/node builtins external (DB drivers and native
|
|
688
|
+
* modules resolve at runtime from the project's node_modules).
|
|
689
|
+
*/
|
|
690
|
+
export async function buildLoaderBundle({ root, appDir, webEntry, exclude, i18n, tmpName = ".otfw-loaders", bust }) {
|
|
691
|
+
const files = discoverLoaders(appDir, exclude);
|
|
692
|
+
if (files.length === 0) return null;
|
|
693
|
+
|
|
694
|
+
const tmp = join(root, tmpName);
|
|
695
|
+
mkdirSync(tmp, { recursive: true });
|
|
696
|
+
const entry = join(tmp, "loaders-entry.js");
|
|
697
|
+
writeFileSync(entry, loaderEntrySource(files, appDir, i18n));
|
|
698
|
+
|
|
699
|
+
const serverApi = join(dirname(webEntry), "server", "index.js");
|
|
700
|
+
// Versioned filename, not a `?v=` query — Bun's ESM cache ignores the query for
|
|
701
|
+
// file URLs, so only a new path re-evaluates the rebuilt module (see buildApiBundle).
|
|
702
|
+
const outName = bust ? `loaders.${bust}.js` : "loaders.js";
|
|
703
|
+
await build({
|
|
704
|
+
input: entry,
|
|
705
|
+
platform: "node",
|
|
706
|
+
resolve: {
|
|
707
|
+
alias: { "@opentf/web/server": serverApi, "@opentf/web": webEntry },
|
|
708
|
+
extensions: EXTENSIONS,
|
|
709
|
+
},
|
|
710
|
+
external: (id) => !id.startsWith(".") && !id.startsWith("/") && !id.startsWith("@opentf/web"),
|
|
711
|
+
output: { dir: join(tmp, "out"), format: "esm", entryFileNames: outName },
|
|
712
|
+
checks: { pluginTimings: false },
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
const mod = await import(pathToFileURL(join(tmp, "out", outName)).href);
|
|
716
|
+
return {
|
|
717
|
+
loaders: mod.loaders,
|
|
718
|
+
files,
|
|
719
|
+
cleanup: () => rmSync(tmp, { recursive: true, force: true }),
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Emit the loader bundle to `outDir/loaders.js` for production (`dist/server/`,
|
|
725
|
+
* next to `api.js`) — `otfw serve` imports it and runs loaders per request /
|
|
726
|
+
* serves the `<path>/__data.json` endpoint. Returns `{ files }`, or `null` when
|
|
727
|
+
* the app has no loader files.
|
|
728
|
+
*/
|
|
729
|
+
export async function emitLoaderBundle({ root, appDir, webEntry, exclude, i18n, outDir }) {
|
|
730
|
+
const files = discoverLoaders(appDir, exclude);
|
|
731
|
+
if (files.length === 0) return null;
|
|
732
|
+
|
|
733
|
+
const tmp = join(root, ".otfw-loaders-build");
|
|
734
|
+
mkdirSync(tmp, { recursive: true });
|
|
735
|
+
const entry = join(tmp, "loaders-entry.js");
|
|
736
|
+
writeFileSync(entry, loaderEntrySource(files, appDir, i18n));
|
|
737
|
+
|
|
738
|
+
const serverApi = join(dirname(webEntry), "server", "index.js");
|
|
739
|
+
mkdirSync(outDir, { recursive: true });
|
|
740
|
+
await build({
|
|
741
|
+
input: entry,
|
|
742
|
+
platform: "node",
|
|
743
|
+
resolve: { alias: { "@opentf/web/server": serverApi, "@opentf/web": webEntry }, extensions: EXTENSIONS },
|
|
744
|
+
external: (id) => !id.startsWith(".") && !id.startsWith("/") && !id.startsWith("@opentf/web"),
|
|
745
|
+
output: { dir: outDir, format: "esm", entryFileNames: "loaders.js" },
|
|
746
|
+
checks: { pluginTimings: false },
|
|
747
|
+
});
|
|
748
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
749
|
+
return { files };
|
|
750
|
+
}
|
|
751
|
+
|
|
416
752
|
/** The optional `app/routeGuard.{js,ts}` path, or null. */
|
|
417
753
|
export function findGuard(appDir) {
|
|
418
754
|
return [join(appDir, "routeGuard.js"), join(appDir, "routeGuard.ts")].find(
|
|
@@ -428,7 +764,7 @@ export function findGuard(appDir) {
|
|
|
428
764
|
* The production build imports the file directly (Rolldown code-splits it); the dev
|
|
429
765
|
* server passes a `/__route/…` URL so the route compiles on first navigation.
|
|
430
766
|
*/
|
|
431
|
-
export function entrySource(pages, appDir, loaderUrl = (p) => p, i18n = null, nav = null) {
|
|
767
|
+
export function entrySource(pages, appDir, loaderUrl = (p) => p, i18n = null, nav = null, loaderRoutes = []) {
|
|
432
768
|
const map = pages
|
|
433
769
|
.map((p) => ` [${JSON.stringify(p)}]: () => import(${JSON.stringify(loaderUrl(p))}),`)
|
|
434
770
|
.join("\n");
|
|
@@ -442,11 +778,14 @@ export function entrySource(pages, appDir, loaderUrl = (p) => p, i18n = null, na
|
|
|
442
778
|
// Navigation mode (otfw.config `nav`): "mpa" disables client-side link interception
|
|
443
779
|
// (docs/HYDRATION.md §7). Only emitted when explicitly "mpa"; "spa" is the default.
|
|
444
780
|
const navOpt = nav === "mpa" ? `\n nav: "mpa",` : "";
|
|
781
|
+
// Route patterns with a server loader (docs/DATA.md) — the router fetches
|
|
782
|
+
// `<path>/__data.json` for these on navigation.
|
|
783
|
+
const loadersOpt = loaderRoutes.length ? `\n loaders: ${JSON.stringify(loaderRoutes)},` : "";
|
|
445
784
|
return (
|
|
446
785
|
`import { mountApp } from "@opentf/web";\n` +
|
|
447
786
|
(guard ? `import guard from ${JSON.stringify(guard)};\n` : "") +
|
|
448
787
|
`mountApp({\n pages: {\n${map}\n },\n` +
|
|
449
|
-
` target: document.getElementById("app"),${guard ? "\n guard," : ""}${i18nOpt}${navOpt}\n});\n`
|
|
788
|
+
` target: document.getElementById("app"),${guard ? "\n guard," : ""}${i18nOpt}${navOpt}${loadersOpt}\n});\n`
|
|
450
789
|
);
|
|
451
790
|
}
|
|
452
791
|
|