@tyndall/build 0.0.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/dist/build.js ADDED
@@ -0,0 +1,925 @@
1
+ import { copyFile, mkdir, rm, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { createRouteGraph, loadConfig, renderClientRouterBootstrap, resolvePageModule, resolveUIAdapter, serializeProps, } from "@tyndall/core";
5
+ import { buildModuleGraphSnapshot, computeGraphKey, computeCacheRootKey, getBunVersion, getOsArch, hash, normalizePath, readFileSafe, readModuleGraphCache, resolveCacheRoot, stableStringify, writeModuleGraphCache, } from "@tyndall/shared";
6
+ import { bundleClient, bundleServer, } from "./bundler.js";
7
+ import { build as esbuild } from "esbuild";
8
+ import { transform } from "@swc/core";
9
+ import { computeDepsHashByEntry, readChunkCache, writeChunkCache, } from "./chunk-cache.js";
10
+ import { computePropsHash, computePropsKey, computeRenderKey, readPropsCache, readRenderCache, writePropsCache, writeRenderCache, } from "./ssg-cache.js";
11
+ import { updateCacheIndex } from "./cache-gc.js";
12
+ import { collectStaticData } from "./ssg-data.js";
13
+ import { computeTemplateHash, defaultHtmlTemplate, renderHtml } from "./renderer.js";
14
+ import { generateManifest } from "./manifest.js";
15
+ import { copyPublicDir } from "./emit.js";
16
+ import { runBuildPipeline } from "./pipeline.js";
17
+ const resolvePath = (root, value) => (isAbsolute(value) ? value : join(root, value));
18
+ const normalizeCacheOptions = (rootDir, cache, configCache) => {
19
+ const enabled = cache?.enabled ?? configCache?.enabled ?? true;
20
+ const dir = resolvePath(rootDir, cache?.dir ?? configCache?.dir ?? join(".hyper", "cache"));
21
+ return {
22
+ enabled,
23
+ dir,
24
+ maxSizeMB: cache?.maxSizeMB ?? configCache?.maxSizeMB,
25
+ maxAgeDays: cache?.maxAgeDays ?? configCache?.maxAgeDays,
26
+ propsTTLSeconds: cache?.propsTTLSeconds ?? configCache?.propsTTLSeconds ?? false,
27
+ renderCache: cache?.renderCache ?? configCache?.renderCache ?? true,
28
+ chunkCache: cache?.chunkCache ?? configCache?.chunkCache ?? true,
29
+ };
30
+ };
31
+ const readFileHash = async (filePath) => {
32
+ const content = await readFileSafe(filePath);
33
+ if (!content) {
34
+ return null;
35
+ }
36
+ return hash(content instanceof Uint8Array ? content : content);
37
+ };
38
+ const resolveGraphKey = async (rootDir, bundlerOptions) => {
39
+ const lockfileHash = (await readFileHash(join(rootDir, "bun.lock"))) ??
40
+ (await readFileHash(join(rootDir, "bun.lockb"))) ??
41
+ (await readFileHash(join(rootDir, "package-lock.json"))) ??
42
+ "missing";
43
+ const tsconfigHash = (await readFileHash(join(rootDir, "tsconfig.json"))) ??
44
+ (await readFileHash(join(rootDir, "tsconfig.base.json"))) ??
45
+ "missing";
46
+ const bundlerOptionsHash = hash(stableStringify(bundlerOptions));
47
+ return computeGraphKey({ lockfileHash, tsconfigHash, bundlerOptionsHash });
48
+ };
49
+ const resolveHyperVersion = async (rootDir) => {
50
+ const packagePath = join(rootDir, "packages", "tyndall-core", "package.json");
51
+ const raw = await readFileSafe(packagePath, "utf-8");
52
+ const content = raw ? raw.toString() : null;
53
+ if (!content) {
54
+ return "unknown";
55
+ }
56
+ try {
57
+ const parsed = JSON.parse(content);
58
+ return parsed.version ?? "unknown";
59
+ }
60
+ catch {
61
+ return "unknown";
62
+ }
63
+ };
64
+ const isStaticRoute = (route) => route.segments.every((segment) => segment.type === "static");
65
+ const isDynamicRoute = (route) => route.segments.some((segment) => segment.type === "dynamic" || segment.type === "catchAll");
66
+ const routeIdToPath = (routeId, params) => {
67
+ if (routeId === "/") {
68
+ return "";
69
+ }
70
+ const segments = routeId
71
+ .split("/")
72
+ .filter(Boolean)
73
+ .map((segment) => {
74
+ if (segment.startsWith(":")) {
75
+ const value = params?.[segment.slice(1)];
76
+ return Array.isArray(value) ? value.join("/") : String(value ?? "");
77
+ }
78
+ if (segment.startsWith("*")) {
79
+ const value = params?.[segment.slice(1)];
80
+ return Array.isArray(value) ? value.join("/") : String(value ?? "");
81
+ }
82
+ return segment;
83
+ });
84
+ return segments.join("/");
85
+ };
86
+ const writeFileSafe = async (filePath, contents) => {
87
+ await mkdir(dirname(filePath), { recursive: true });
88
+ await writeFile(filePath, contents, "utf-8");
89
+ };
90
+ const toPosixPath = (value) => value.split(sep).join("/");
91
+ const toImportSpecifier = (fromDir, targetPath) => {
92
+ const relativePath = toPosixPath(relative(fromDir, targetPath));
93
+ if (relativePath.startsWith("../") || relativePath === "..") {
94
+ return relativePath;
95
+ }
96
+ return relativePath.startsWith("./") ? relativePath : `./${relativePath}`;
97
+ };
98
+ const normalizePathSegment = (value) => value.replace(/^\/+|\/+$/g, "");
99
+ const resolveAssetUrl = (basePath, assetsDir, file) => {
100
+ const base = normalizePathSegment(basePath);
101
+ const assets = normalizePathSegment(assetsDir);
102
+ const prefix = [base, assets].filter(Boolean).join("/");
103
+ const fileName = file.replace(/^\/+/, "");
104
+ return `/${prefix ? `${prefix}/` : ""}${fileName}`;
105
+ };
106
+ const isExternalUrl = (value) => value.startsWith("http://") || value.startsWith("https://") || value.startsWith("//");
107
+ const appendBuildVersion = (value, buildVersion) => {
108
+ if (!buildVersion) {
109
+ return value;
110
+ }
111
+ if (isExternalUrl(value)) {
112
+ return value;
113
+ }
114
+ const separator = value.includes("?") ? "&" : "?";
115
+ return `${value}${separator}v=${encodeURIComponent(buildVersion)}`;
116
+ };
117
+ const resolveBundleScripts = (bundle, routeId, basePath, assetsDir, buildVersion) => {
118
+ const entryKey = bundle.entryChunks[routeId];
119
+ if (!entryKey) {
120
+ return [];
121
+ }
122
+ const scripts = [];
123
+ const visited = new Set();
124
+ const walk = (key) => {
125
+ if (visited.has(key)) {
126
+ return;
127
+ }
128
+ visited.add(key);
129
+ const chunk = bundle.chunks[key];
130
+ if (!chunk) {
131
+ return;
132
+ }
133
+ for (const dep of chunk.imports ?? []) {
134
+ walk(dep);
135
+ }
136
+ scripts.push(appendBuildVersion(resolveAssetUrl(basePath, assetsDir, chunk.file), buildVersion));
137
+ };
138
+ walk(entryKey);
139
+ return scripts;
140
+ };
141
+ const LEGACY_AUTO_POLYFILLS_FILE = "hyper-legacy-polyfills.js";
142
+ const renderLegacyClientBootstrap = () => [
143
+ "(function () {",
144
+ " if (typeof window === \"undefined\") { return; }",
145
+ " if (window.__HYPER_LEGACY_BOOTSTRAP__) { return; }",
146
+ " window.__HYPER_LEGACY_BOOTSTRAP__ = true;",
147
+ " var doc = window.document;",
148
+ " if (!doc || !doc.addEventListener) { return; }",
149
+ " var findAnchor = function (node) {",
150
+ " while (node && node.nodeType === 1) {",
151
+ " if (node.tagName && node.tagName.toLowerCase() === \"a\") {",
152
+ " return node;",
153
+ " }",
154
+ " node = node.parentNode;",
155
+ " }",
156
+ " return null;",
157
+ " };",
158
+ " doc.addEventListener(\"click\", function (event) {",
159
+ " event = event || window.event;",
160
+ " var anchor = findAnchor(event.target || event.srcElement);",
161
+ " if (!anchor) { return; }",
162
+ " if (anchor.getAttribute(\"data-hyper-link\") === \"false\") { return; }",
163
+ " if (!anchor.getAttribute(\"data-hyper-link\")) { return; }",
164
+ " if (anchor.getAttribute(\"target\") || anchor.getAttribute(\"download\")) { return; }",
165
+ " var href = anchor.getAttribute(\"href\");",
166
+ " if (!href || href.indexOf(\"#\") === 0) { return; }",
167
+ " if (event.preventDefault) {",
168
+ " event.preventDefault();",
169
+ " } else {",
170
+ " event.returnValue = false;",
171
+ " }",
172
+ " window.location.href = href;",
173
+ " }, true);",
174
+ "})();",
175
+ "",
176
+ ].join("\n");
177
+ const renderLegacyAutoPolyfillsChunk = () => [
178
+ "(function () {",
179
+ " if (!Array.isArray) {",
180
+ " Array.isArray = function (arg) {",
181
+ " return Object.prototype.toString.call(arg) === \"[object Array]\";",
182
+ " };",
183
+ " }",
184
+ " if (!Object.assign) {",
185
+ " Object.assign = function (target) {",
186
+ " if (target === null || target === undefined) {",
187
+ " throw new TypeError(\"Cannot convert undefined or null to object\");",
188
+ " }",
189
+ " var to = Object(target);",
190
+ " for (var index = 1; index < arguments.length; index += 1) {",
191
+ " var nextSource = arguments[index];",
192
+ " if (nextSource !== null && nextSource !== undefined) {",
193
+ " for (var key in nextSource) {",
194
+ " if (Object.prototype.hasOwnProperty.call(nextSource, key)) {",
195
+ " to[key] = nextSource[key];",
196
+ " }",
197
+ " }",
198
+ " }",
199
+ " }",
200
+ " return to;",
201
+ " };",
202
+ " }",
203
+ "})();",
204
+ "",
205
+ ].join("\n");
206
+ const resolveConfiguredScriptUrl = (basePath, script) => {
207
+ if (isExternalUrl(script)) {
208
+ return script;
209
+ }
210
+ const base = normalizePathSegment(basePath);
211
+ const cleaned = script.replace(/^\/+/, "");
212
+ return `/${base ? `${base}/` : ""}${cleaned}`;
213
+ };
214
+ const resolveLegacyPolyfillPlan = (basePath, assetsDir, polyfills) => {
215
+ if (polyfills === "auto") {
216
+ return {
217
+ scripts: [resolveAssetUrl(basePath, assetsDir, LEGACY_AUTO_POLYFILLS_FILE)],
218
+ autoPolyfillsFile: LEGACY_AUTO_POLYFILLS_FILE,
219
+ autoPolyfillsChunk: renderLegacyAutoPolyfillsChunk(),
220
+ };
221
+ }
222
+ const scripts = polyfills
223
+ .filter((entry) => entry.trim().length > 0)
224
+ .map((entry) => resolveConfiguredScriptUrl(basePath, entry));
225
+ return { scripts };
226
+ };
227
+ const getBunBuildApi = () => {
228
+ const runtime = globalThis;
229
+ if (!runtime.Bun || typeof runtime.Bun.build !== "function") {
230
+ return null;
231
+ }
232
+ return { build: runtime.Bun.build };
233
+ };
234
+ let hyperCoreBrowserShimSource = null;
235
+ const resolveCoreShimModulePath = async (entryDir, moduleName) => {
236
+ const candidates = [
237
+ `${moduleName}.js`,
238
+ `${moduleName}.mjs`,
239
+ `${moduleName}.cjs`,
240
+ `${moduleName}.ts`,
241
+ `${moduleName}.tsx`,
242
+ ];
243
+ for (const candidate of candidates) {
244
+ const candidatePath = join(entryDir, candidate);
245
+ const exists = await stat(candidatePath)
246
+ .then((result) => result.isFile())
247
+ .catch(() => false);
248
+ if (exists) {
249
+ return candidatePath;
250
+ }
251
+ }
252
+ throw new Error(`Missing core shim module "${moduleName}" in ${entryDir}.`);
253
+ };
254
+ const resolveHyperCoreBrowserShimSource = async () => {
255
+ if (hyperCoreBrowserShimSource) {
256
+ return hyperCoreBrowserShimSource;
257
+ }
258
+ const resolver = import.meta;
259
+ if (typeof resolver.resolve !== "function") {
260
+ throw new Error("import.meta.resolve is unavailable while building browser entries.");
261
+ }
262
+ const resolvedEntry = await resolver.resolve("@tyndall/core");
263
+ const entryPath = resolvedEntry.startsWith("file://")
264
+ ? fileURLToPath(resolvedEntry)
265
+ : resolvedEntry;
266
+ const entryDir = dirname(entryPath);
267
+ const headPath = await resolveCoreShimModulePath(entryDir, "head");
268
+ const propsPath = await resolveCoreShimModulePath(entryDir, "props");
269
+ const renderPolicyPath = await resolveCoreShimModulePath(entryDir, "render-policy");
270
+ const resolverFallbackPath = await resolveCoreShimModulePath(entryDir, "resolver-fallback");
271
+ hyperCoreBrowserShimSource = {
272
+ source: [
273
+ `export { mergeHeadDescriptors } from ${JSON.stringify(headPath)};`,
274
+ `export { serializeProps } from ${JSON.stringify(propsPath)};`,
275
+ `export { evaluateRenderPolicy } from ${JSON.stringify(renderPolicyPath)};`,
276
+ `export { shouldForceDynamicFallback } from ${JSON.stringify(resolverFallbackPath)};`,
277
+ ].join("\n"),
278
+ // Required so esbuild resolves filesystem imports from a virtual namespace.
279
+ resolveDir: entryDir,
280
+ };
281
+ return hyperCoreBrowserShimSource;
282
+ };
283
+ const emitBundleAssets = async (bundle, outDir, assetsDir, chunkSource) => {
284
+ for (const chunk of Object.values(bundle.chunks)) {
285
+ const filePath = join(outDir, assetsDir, chunk.file);
286
+ await writeFileSafe(filePath, chunkSource);
287
+ }
288
+ };
289
+ const buildChunkFile = async (entryPath, outputPath, options) => {
290
+ const shimSource = options.shimHyperCore ? await resolveHyperCoreBrowserShimSource() : null;
291
+ const shimPlugin = shimSource
292
+ ? {
293
+ name: "@tyndall/core-browser-shim",
294
+ setup(build) {
295
+ build.onResolve({ filter: /^@tyndall\/core$/ }, () => ({
296
+ path: "@tyndall/core-browser-shim",
297
+ namespace: "@tyndall/core-browser-shim",
298
+ }));
299
+ build.onLoad({
300
+ filter: /^@tyndall\/core-browser-shim$/,
301
+ namespace: "@tyndall/core-browser-shim",
302
+ }, () => ({
303
+ contents: shimSource.source,
304
+ loader: "js",
305
+ resolveDir: shimSource.resolveDir,
306
+ }));
307
+ },
308
+ }
309
+ : null;
310
+ if (options.syntaxTarget === "es5") {
311
+ try {
312
+ const bundled = await esbuild({
313
+ entryPoints: [entryPath],
314
+ bundle: true,
315
+ platform: options.target === "node" ? "node" : "browser",
316
+ format: options.format,
317
+ target: "es2015",
318
+ splitting: false,
319
+ sourcemap: false,
320
+ minify: false,
321
+ logLevel: "silent",
322
+ jsx: "automatic",
323
+ plugins: shimPlugin ? [shimPlugin] : undefined,
324
+ write: false,
325
+ });
326
+ const outputText = bundled.outputFiles?.[0]?.text;
327
+ if (!outputText) {
328
+ throw new Error("ES5 bundling produced no output.");
329
+ }
330
+ const transformed = await transform(outputText, {
331
+ jsc: {
332
+ target: "es5",
333
+ parser: {
334
+ syntax: "ecmascript",
335
+ },
336
+ },
337
+ sourceMaps: false,
338
+ });
339
+ await mkdir(dirname(outputPath), { recursive: true });
340
+ await writeFile(outputPath, transformed.code, "utf-8");
341
+ }
342
+ catch (error) {
343
+ const messages = typeof error === "object" && error !== null && "errors" in error
344
+ ? error.errors
345
+ ?.map((entry) => entry.text)
346
+ .filter((text) => typeof text === "string" && text.length > 0) ?? []
347
+ : [];
348
+ throw new Error(`Failed to emit ES5 bundle chunk (${entryPath} -> ${outputPath})${messages.length > 0 ? `: ${messages.join(" | ")}` : "."}`);
349
+ }
350
+ return;
351
+ }
352
+ const bun = getBunBuildApi();
353
+ if (!bun) {
354
+ throw new Error("Bun.build API is unavailable while emitting build chunks.");
355
+ }
356
+ const tempOutDir = join(dirname(outputPath), `.@tyndall/build-tmp-${hash(`${outputPath}-${Date.now()}`)}`);
357
+ await mkdir(tempOutDir, { recursive: true });
358
+ const result = await bun.build({
359
+ entrypoints: [entryPath],
360
+ outdir: tempOutDir,
361
+ target: options.target,
362
+ format: options.format,
363
+ write: true,
364
+ splitting: false,
365
+ sourcemap: "none",
366
+ minify: false,
367
+ plugins: shimPlugin ? [shimPlugin] : undefined,
368
+ });
369
+ if (!result.success) {
370
+ const errors = (result.logs ?? [])
371
+ .map((log) => log.message)
372
+ .filter((message) => typeof message === "string" && message.length > 0);
373
+ await rm(tempOutDir, { recursive: true, force: true }).catch(() => { });
374
+ throw new Error(`Failed to emit bundle chunk (${entryPath} -> ${outputPath})${errors.length > 0 ? `: ${errors.join(" | ")}` : "."}`);
375
+ }
376
+ const emittedPath = result.outputs
377
+ ?.map((output) => output.path)
378
+ .find((path) => typeof path === "string" && path.length > 0);
379
+ if (!emittedPath) {
380
+ await rm(tempOutDir, { recursive: true, force: true }).catch(() => { });
381
+ throw new Error(`Build chunk output is missing (${entryPath}).`);
382
+ }
383
+ await mkdir(dirname(outputPath), { recursive: true });
384
+ await copyFile(emittedPath, outputPath);
385
+ await rm(tempOutDir, { recursive: true, force: true }).catch(() => { });
386
+ };
387
+ const dynamicImportPattern = /import\s*\(/;
388
+ const findDynamicImportModules = async (modulePaths) => {
389
+ const offenders = [];
390
+ for (const modulePath of modulePaths) {
391
+ const content = await readFileSafe(modulePath);
392
+ if (!content) {
393
+ continue;
394
+ }
395
+ const source = typeof content === "string" ? content : content.toString("utf-8");
396
+ if (dynamicImportPattern.test(source)) {
397
+ offenders.push(modulePath);
398
+ }
399
+ }
400
+ return offenders;
401
+ };
402
+ export const build = async (options) => {
403
+ const rootDir = options.rootDir;
404
+ const routesDir = resolvePath(rootDir, options.routesDir ?? join("src", "pages"));
405
+ const publicDir = resolvePath(rootDir, options.publicDir ?? "public");
406
+ const outDir = resolvePath(rootDir, options.outDir ?? "dist");
407
+ const mode = options.mode ?? "ssg";
408
+ const config = await loadConfig(rootDir);
409
+ const buildVersion = config.build.version.trim();
410
+ const adapterRegistry = options.adapterRegistry ?? {};
411
+ const ssrClientRoutingEnabled = mode === "ssr" && config.routing.ssrClientRouting;
412
+ const resolvedNavigationMode = ssrClientRoutingEnabled ? "client" : config.routing.navigation;
413
+ const clientBootstrapChunk = renderClientRouterBootstrap({
414
+ navigationMode: resolvedNavigationMode,
415
+ clientRenderMode: config.routing.clientRender,
416
+ linkInterceptionMode: ssrClientRoutingEnabled ? "all" : "marked",
417
+ });
418
+ const legacyBootstrapChunk = renderLegacyClientBootstrap();
419
+ const legacyEnabled = config.legacy.enabled;
420
+ const modernTarget = config.target;
421
+ const isEs5Target = modernTarget === "es5";
422
+ const clientScriptType = isEs5Target ? "classic" : "module";
423
+ const serverTarget = "es2018";
424
+ const assetsDir = config.assetsDir;
425
+ const legacyPolyfillPlan = legacyEnabled
426
+ ? resolveLegacyPolyfillPlan(config.basePath, assetsDir, config.legacy.polyfills)
427
+ : { scripts: [] };
428
+ const cache = normalizeCacheOptions(rootDir, options.cache, config.cache);
429
+ const uiAdapter = typeof config.ui.adapter === "string"
430
+ ? config.ui.adapter
431
+ : config.ui.adapter?.name ?? "custom";
432
+ const hyperVersion = cache.enabled ? await resolveHyperVersion(rootDir) : "disabled";
433
+ const rootKey = cache.enabled
434
+ ? computeCacheRootKey({
435
+ projectId: normalizePath(resolve(rootDir)),
436
+ hyperVersion,
437
+ bunVersion: getBunVersion(),
438
+ uiAdapter,
439
+ osArch: getOsArch(),
440
+ })
441
+ : "disabled";
442
+ // Important: rootKey isolates caches across projects/toolchains/adapters.
443
+ const cacheRoot = cache.enabled ? resolveCacheRoot(cache.dir, rootKey) : cache.dir;
444
+ const graphKey = cache.enabled
445
+ ? await resolveGraphKey(rootDir, {
446
+ client: { format: "esm", target: modernTarget },
447
+ server: mode === "ssr" ? { format: "cjs", target: serverTarget } : null,
448
+ })
449
+ : null;
450
+ const envHash = cache.enabled ? hash(stableStringify(process.env)) : "disabled";
451
+ let depsHashByEntry = null;
452
+ let legacyBundle = null;
453
+ const routeModules = new Map();
454
+ const routeById = new Map();
455
+ const clientRuntimeEntries = new Map();
456
+ const serverRuntimeEntries = new Map();
457
+ const entryWorkspaceRoot = join(cacheRoot, "build-runtime-entries", hash(`${Date.now()}-${Math.random()}`).slice(0, 12));
458
+ // Important: clear output to avoid stale HTML/chunks when route render policy changes.
459
+ await rm(outDir, { recursive: true, force: true }).catch(() => { });
460
+ await mkdir(outDir, { recursive: true });
461
+ const assertLegacyChunkingSafe = async (snapshot) => {
462
+ if (!legacyEnabled || config.legacy.chunking !== "safe") {
463
+ return;
464
+ }
465
+ const offenders = await findDynamicImportModules(Object.keys(snapshot.moduleGraph));
466
+ if (offenders.length > 0) {
467
+ throw new Error(`Legacy chunking (safe) forbids dynamic import in: ${offenders.slice(0, 3).join(", ")}`);
468
+ }
469
+ };
470
+ const bundleWithCache = async (kind, entries, format, bundler, target) => {
471
+ const depsHash = depsHashByEntry ?? undefined;
472
+ const cacheActive = cache.enabled && cache.chunkCache;
473
+ const cacheOptions = {
474
+ format,
475
+ target,
476
+ define: undefined,
477
+ depsHashByEntry: depsHash,
478
+ };
479
+ if (cacheActive) {
480
+ const cached = await readChunkCache(cacheRoot, entries, cacheOptions, depsHash);
481
+ if (cached) {
482
+ console.info(`[@tyndall/build] chunk cache hit (${kind}, ${entries.length} entries)`);
483
+ return cached;
484
+ }
485
+ }
486
+ const result = bundler({ entries, target, depsHashByEntry: depsHash, format });
487
+ if (cacheActive) {
488
+ await writeChunkCache(cacheRoot, entries, result, depsHash);
489
+ }
490
+ return result;
491
+ };
492
+ try {
493
+ const result = await runBuildPipeline({ rootDir, routesDir, outDir, publicDir, cache }, {
494
+ scanRoutes: () => createRouteGraph({
495
+ routesDir,
496
+ routeMeta: config.routing.routeMeta,
497
+ nestedLayouts: config.routing.nestedLayouts,
498
+ }),
499
+ analyzeGraph: async (_ctx, state) => {
500
+ const routes = state.routeGraph?.routes ?? [];
501
+ routeById.clear();
502
+ for (const route of routes) {
503
+ routeById.set(route.id, route);
504
+ }
505
+ const entries = routes.map((route) => ({ id: route.id, filePath: route.filePath }));
506
+ if (!cache.enabled || !graphKey) {
507
+ const snapshot = buildModuleGraphSnapshot(entries);
508
+ await assertLegacyChunkingSafe(snapshot);
509
+ depsHashByEntry = cache.enabled ? await computeDepsHashByEntry(snapshot) : null;
510
+ return snapshot;
511
+ }
512
+ const cached = await readModuleGraphCache(cacheRoot, graphKey);
513
+ if (cached) {
514
+ // Important: cached graph must cover all current routes to be valid.
515
+ const hasAllRoutes = entries.every((route) => cached.routeToModules[route.id]);
516
+ if (hasAllRoutes) {
517
+ await assertLegacyChunkingSafe(cached);
518
+ depsHashByEntry = await computeDepsHashByEntry(cached);
519
+ return cached;
520
+ }
521
+ }
522
+ const snapshot = buildModuleGraphSnapshot(entries);
523
+ await writeModuleGraphCache(cacheRoot, graphKey, snapshot);
524
+ await assertLegacyChunkingSafe(snapshot);
525
+ depsHashByEntry = await computeDepsHashByEntry(snapshot);
526
+ return snapshot;
527
+ },
528
+ bundleClient: async (_ctx, state) => {
529
+ const routeGraph = state.routeGraph;
530
+ if (!routeGraph) {
531
+ return bundleClient({ entries: [] });
532
+ }
533
+ const entryAdapter = resolveUIAdapter(config.ui.adapter, {
534
+ ...config.ui.options,
535
+ navigationMode: resolvedNavigationMode,
536
+ clientRenderMode: config.routing.clientRender,
537
+ }, adapterRegistry);
538
+ if (typeof entryAdapter.createClientEntry !== "function") {
539
+ throw new Error(`UI adapter \"${entryAdapter.name}\" does not support createClientEntry.`);
540
+ }
541
+ const clientEntryDir = join(entryWorkspaceRoot, "client");
542
+ await mkdir(clientEntryDir, { recursive: true });
543
+ const buildClientRouteModules = routeGraph.routes.reduce((acc, route) => {
544
+ acc[route.id] = toImportSpecifier(clientEntryDir, route.filePath);
545
+ return acc;
546
+ }, {});
547
+ const entries = [];
548
+ clientRuntimeEntries.clear();
549
+ for (const route of routeGraph.routes) {
550
+ const entryFilePath = join(clientEntryDir, `entry-${hash(route.id).slice(0, 12)}.tsx`);
551
+ const pageModule = toImportSpecifier(clientEntryDir, route.filePath);
552
+ const entrySource = entryAdapter.createClientEntry({
553
+ routeId: route.id,
554
+ routeGraph,
555
+ basePath: config.basePath,
556
+ rootDir,
557
+ entryDir: clientEntryDir,
558
+ uiOptions: {
559
+ ...config.ui.options,
560
+ pageModule,
561
+ navigationMode: resolvedNavigationMode,
562
+ clientRenderMode: config.routing.clientRender,
563
+ clientRouteModules: buildClientRouteModules,
564
+ nestedLayouts: config.routing.nestedLayouts,
565
+ },
566
+ adapterOptions: {
567
+ ...config.ui.options,
568
+ pageModule,
569
+ navigationMode: resolvedNavigationMode,
570
+ clientRenderMode: config.routing.clientRender,
571
+ clientRouteModules: buildClientRouteModules,
572
+ nestedLayouts: config.routing.nestedLayouts,
573
+ },
574
+ });
575
+ if (typeof entrySource !== "string" || entrySource.trim().length === 0) {
576
+ throw new Error(`Client entry generation failed for route ${route.id}`);
577
+ }
578
+ await writeFileSafe(entryFilePath, `${entrySource}\n\n${clientBootstrapChunk}\n`);
579
+ clientRuntimeEntries.set(route.id, entryFilePath);
580
+ entries.push({ id: route.id, input: entryFilePath });
581
+ }
582
+ const clientFormat = isEs5Target ? "iife" : "esm";
583
+ const modernBundle = await bundleWithCache("client", entries, clientFormat, bundleClient, modernTarget);
584
+ if (legacyEnabled && !isEs5Target) {
585
+ legacyBundle = await bundleWithCache("client-legacy", entries, "iife", bundleClient, "es5");
586
+ }
587
+ return modernBundle;
588
+ },
589
+ bundleServer: mode === "ssr"
590
+ ? async (_ctx, state) => {
591
+ const routeGraph = state.routeGraph;
592
+ if (!routeGraph) {
593
+ return bundleServer({ entries: [] });
594
+ }
595
+ const entryAdapter = resolveUIAdapter(config.ui.adapter, {
596
+ ...config.ui.options,
597
+ navigationMode: resolvedNavigationMode,
598
+ clientRenderMode: config.routing.clientRender,
599
+ }, adapterRegistry);
600
+ if (typeof entryAdapter.createServerEntry !== "function") {
601
+ throw new Error(`UI adapter \"${entryAdapter.name}\" does not support createServerEntry.`);
602
+ }
603
+ const serverEntryDir = join(entryWorkspaceRoot, "server");
604
+ await mkdir(serverEntryDir, { recursive: true });
605
+ const entries = [];
606
+ serverRuntimeEntries.clear();
607
+ for (const route of routeGraph.routes) {
608
+ const pageModule = toImportSpecifier(serverEntryDir, route.filePath);
609
+ const rawEntryFilePath = join(serverEntryDir, `entry-${hash(route.id).slice(0, 12)}.raw.tsx`);
610
+ const wrappedEntryFilePath = join(serverEntryDir, `entry-${hash(route.id).slice(0, 12)}.ts`);
611
+ const serverEntrySource = entryAdapter.createServerEntry({
612
+ routeId: route.id,
613
+ routeGraph,
614
+ basePath: config.basePath,
615
+ rootDir,
616
+ entryDir: serverEntryDir,
617
+ uiOptions: {
618
+ ...config.ui.options,
619
+ pageModule,
620
+ navigationMode: resolvedNavigationMode,
621
+ clientRenderMode: config.routing.clientRender,
622
+ nestedLayouts: config.routing.nestedLayouts,
623
+ },
624
+ adapterOptions: {
625
+ ...config.ui.options,
626
+ pageModule,
627
+ navigationMode: resolvedNavigationMode,
628
+ clientRenderMode: config.routing.clientRender,
629
+ nestedLayouts: config.routing.nestedLayouts,
630
+ },
631
+ });
632
+ if (typeof serverEntrySource !== "string" || serverEntrySource.trim().length === 0) {
633
+ throw new Error(`Server entry generation failed for route ${route.id}`);
634
+ }
635
+ await writeFileSafe(rawEntryFilePath, serverEntrySource);
636
+ const rawSpecifier = toImportSpecifier(serverEntryDir, rawEntryFilePath);
637
+ const wrapperSource = [
638
+ `import * as pageModule from ${JSON.stringify(pageModule)};`,
639
+ `import * as serverEntryModule from ${JSON.stringify(rawSpecifier)};`,
640
+ "export const renderToHtml = serverEntryModule.renderToHtml;",
641
+ "export const renderToStream = serverEntryModule.renderToStream;",
642
+ "export const hydration =",
643
+ " serverEntryModule.hydration === \"full\" || serverEntryModule.hydration === \"islands\"",
644
+ " ? serverEntryModule.hydration",
645
+ " : \"islands\";",
646
+ "export const getServerProps =",
647
+ " typeof pageModule.getServerProps === \"function\" ? pageModule.getServerProps : undefined;",
648
+ "export const getServerSideProps =",
649
+ " typeof pageModule.getServerSideProps === \"function\" ? pageModule.getServerSideProps : undefined;",
650
+ "",
651
+ ].join("\n");
652
+ await writeFileSafe(wrappedEntryFilePath, wrapperSource);
653
+ serverRuntimeEntries.set(route.id, wrappedEntryFilePath);
654
+ entries.push({ id: route.id, input: wrappedEntryFilePath });
655
+ }
656
+ return bundleWithCache("server", entries, "cjs", bundleServer, serverTarget);
657
+ }
658
+ : undefined,
659
+ runStaticData: async (_ctx, state) => {
660
+ const routeGraph = state.routeGraph;
661
+ if (!routeGraph) {
662
+ return { entries: [] };
663
+ }
664
+ routeModules.clear();
665
+ for (const route of routeGraph.routes) {
666
+ const loaded = await import(`${pathToFileURL(route.filePath).href}?build=${Date.now()}-${hash(route.id).slice(0, 6)}`);
667
+ routeModules.set(route.id, resolvePageModule(loaded));
668
+ }
669
+ const resolveDepsHash = (routeId) => depsHashByEntry?.[routeId] ?? "missing";
670
+ const propsCacheEnabled = cache.enabled;
671
+ const staticDataCache = propsCacheEnabled
672
+ ? {
673
+ read: async (routeId, params, paramsKeyValue) => {
674
+ const cached = await readPropsCache(cacheRoot, {
675
+ routeId,
676
+ paramsKey: paramsKeyValue,
677
+ depsHash: resolveDepsHash(routeId),
678
+ envHash,
679
+ }, cache.propsTTLSeconds);
680
+ if (!cached) {
681
+ return null;
682
+ }
683
+ return {
684
+ routeId,
685
+ params,
686
+ paramsKey: paramsKeyValue,
687
+ props: cached.props,
688
+ revalidate: cached.revalidate,
689
+ };
690
+ },
691
+ write: async (entry) => {
692
+ const depsHash = resolveDepsHash(entry.routeId);
693
+ const propsKey = computePropsKey({
694
+ routeId: entry.routeId,
695
+ paramsKey: entry.paramsKey,
696
+ depsHash,
697
+ envHash,
698
+ });
699
+ await writePropsCache(cacheRoot, {
700
+ routeId: entry.routeId,
701
+ paramsKey: entry.paramsKey,
702
+ props: entry.props,
703
+ revalidate: entry.revalidate,
704
+ propsKey,
705
+ propsHash: computePropsHash(entry.props),
706
+ depsHash,
707
+ envHash,
708
+ generatedAt: Date.now(),
709
+ });
710
+ },
711
+ }
712
+ : undefined;
713
+ const pageModules = {};
714
+ for (const route of routeGraph.routes) {
715
+ const pageModule = routeModules.get(route.id);
716
+ if (!pageModule) {
717
+ continue;
718
+ }
719
+ if (mode === "ssr" && typeof pageModule.getServerProps === "function") {
720
+ continue;
721
+ }
722
+ if (isStaticRoute(route)) {
723
+ pageModules[route.id] = pageModule;
724
+ continue;
725
+ }
726
+ if (mode === "ssg" || (isDynamicRoute(route) && typeof pageModule.getStaticPaths === "function")) {
727
+ pageModules[route.id] = pageModule;
728
+ }
729
+ }
730
+ return collectStaticData(routeGraph, pageModules, staticDataCache);
731
+ },
732
+ renderHtml: async (_ctx, state) => {
733
+ if (!state.staticData || typeof state.staticData !== "object") {
734
+ return {};
735
+ }
736
+ const entries = state.staticData.entries;
737
+ const renderCacheEnabled = cache.enabled && cache.renderCache;
738
+ const clientBundle = state.clientBundle;
739
+ const routeGraph = state.routeGraph;
740
+ if (!routeGraph) {
741
+ return {};
742
+ }
743
+ const outputs = {};
744
+ for (const entry of entries) {
745
+ const route = routeById.get(entry.routeId);
746
+ const pageModule = routeModules.get(entry.routeId);
747
+ if (!route || !pageModule) {
748
+ continue;
749
+ }
750
+ const depsHash = depsHashByEntry?.[entry.routeId] ?? "missing";
751
+ const propsHash = computePropsHash(entry.props);
752
+ const scripts = resolveBundleScripts(clientBundle, entry.routeId, config.basePath, assetsDir, buildVersion);
753
+ const legacyPolyfillScripts = legacyEnabled
754
+ ? legacyPolyfillPlan.scripts.map((script) => appendBuildVersion(script, buildVersion))
755
+ : [];
756
+ const legacyBundleScripts = legacyEnabled && legacyBundle
757
+ ? resolveBundleScripts(legacyBundle, entry.routeId, config.basePath, assetsDir, buildVersion)
758
+ : [];
759
+ const legacyScripts = legacyEnabled && !isEs5Target
760
+ ? [...legacyPolyfillScripts, ...legacyBundleScripts]
761
+ : [];
762
+ const assets = {
763
+ scripts: isEs5Target ? [...legacyPolyfillScripts, ...scripts] : scripts,
764
+ legacyScripts,
765
+ scriptType: clientScriptType,
766
+ buildVersion,
767
+ };
768
+ const templateHash = computeTemplateHash(defaultHtmlTemplate, assets);
769
+ const renderKey = computeRenderKey({
770
+ routeId: entry.routeId,
771
+ paramsKey: entry.paramsKey,
772
+ templateHash,
773
+ depsHash,
774
+ propsHash,
775
+ });
776
+ if (renderCacheEnabled) {
777
+ const cached = await readRenderCache(cacheRoot, {
778
+ routeId: entry.routeId,
779
+ paramsKey: entry.paramsKey,
780
+ templateHash,
781
+ depsHash,
782
+ propsHash,
783
+ });
784
+ if (cached) {
785
+ const routePath = routeIdToPath(entry.routeId, entry.params);
786
+ const htmlPath = routePath
787
+ ? join(outDir, routePath, "index.html")
788
+ : join(outDir, "index.html");
789
+ await writeFileSafe(htmlPath, cached.finalHtml);
790
+ outputs[routePath || "/"] = cached.finalHtml;
791
+ continue;
792
+ }
793
+ }
794
+ const routeAdapter = resolveUIAdapter(config.ui.adapter, {
795
+ ...config.ui.options,
796
+ navigationMode: resolvedNavigationMode,
797
+ clientRenderMode: config.routing.clientRender,
798
+ nestedLayouts: config.routing.nestedLayouts,
799
+ routeGraph,
800
+ rootDir,
801
+ routeRender: (props) => pageModule.default(props),
802
+ routeHead: pageModule.head
803
+ ? (props) => pageModule.head(props)
804
+ : undefined,
805
+ }, adapterRegistry);
806
+ if (typeof routeAdapter.renderToHtml !== "function") {
807
+ throw new Error(`UI adapter \"${routeAdapter.name}\" does not support renderToHtml.`);
808
+ }
809
+ const rendered = await renderHtml({
810
+ renderToHtml: (ctx) => routeAdapter.renderToHtml(ctx),
811
+ serializeProps: routeAdapter.serializeProps ?? serializeProps,
812
+ }, { routeId: entry.routeId, params: entry.params, props: entry.props }, defaultHtmlTemplate, assets);
813
+ const routePath = routeIdToPath(entry.routeId, entry.params);
814
+ const htmlPath = routePath
815
+ ? join(outDir, routePath, "index.html")
816
+ : join(outDir, "index.html");
817
+ await writeFileSafe(htmlPath, rendered.finalHtml);
818
+ outputs[routePath || "/"] = rendered.finalHtml;
819
+ if (renderCacheEnabled) {
820
+ await writeRenderCache(cacheRoot, {
821
+ routeId: entry.routeId,
822
+ paramsKey: entry.paramsKey,
823
+ renderKey,
824
+ templateHash: rendered.templateHash,
825
+ propsHash,
826
+ depsHash,
827
+ generatedAt: Date.now(),
828
+ head: rendered.head,
829
+ propsPayload: rendered.propsPayload,
830
+ status: rendered.status,
831
+ headers: rendered.headers,
832
+ finalHtml: rendered.finalHtml,
833
+ });
834
+ }
835
+ }
836
+ return outputs;
837
+ },
838
+ emitManifest: async (_ctx, state) => {
839
+ const routeGraph = state.routeGraph;
840
+ const clientBundle = state.clientBundle;
841
+ const serverBundle = state.serverBundle;
842
+ const manifest = generateManifest({
843
+ version: buildVersion,
844
+ basePath: config.basePath,
845
+ routeGraph,
846
+ clientBundle,
847
+ serverBundle,
848
+ assets: {
849
+ assetsDir,
850
+ scriptType: clientScriptType,
851
+ },
852
+ });
853
+ await mkdir(join(outDir, assetsDir), { recursive: true });
854
+ for (const route of routeGraph.routes) {
855
+ const clientEntryKey = clientBundle.entryChunks[route.id];
856
+ if (!clientEntryKey) {
857
+ continue;
858
+ }
859
+ const clientChunk = clientBundle.chunks[clientEntryKey];
860
+ const clientEntryPath = clientRuntimeEntries.get(route.id);
861
+ if (!clientChunk || !clientEntryPath) {
862
+ throw new Error(`Missing client runtime metadata for route ${route.id}`);
863
+ }
864
+ await buildChunkFile(clientEntryPath, join(outDir, assetsDir, clientChunk.file), {
865
+ target: "browser",
866
+ format: clientScriptType === "classic" ? "iife" : "esm",
867
+ shimHyperCore: true,
868
+ syntaxTarget: isEs5Target ? "es5" : "modern",
869
+ });
870
+ }
871
+ if (serverBundle) {
872
+ for (const route of routeGraph.routes) {
873
+ const serverEntryKey = serverBundle.entryChunks[route.id];
874
+ if (!serverEntryKey) {
875
+ continue;
876
+ }
877
+ const serverChunk = serverBundle.chunks[serverEntryKey];
878
+ const serverEntryPath = serverRuntimeEntries.get(route.id);
879
+ if (!serverChunk || !serverEntryPath) {
880
+ throw new Error(`Missing server runtime metadata for route ${route.id}`);
881
+ }
882
+ await buildChunkFile(serverEntryPath, join(outDir, assetsDir, serverChunk.file), {
883
+ target: "node",
884
+ format: "cjs",
885
+ shimHyperCore: false,
886
+ syntaxTarget: isEs5Target ? "es5" : "modern",
887
+ });
888
+ }
889
+ }
890
+ await writeFileSafe(join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
891
+ if (legacyEnabled && legacyBundle) {
892
+ await emitBundleAssets(legacyBundle, outDir, assetsDir, legacyBootstrapChunk);
893
+ }
894
+ if (legacyEnabled &&
895
+ legacyPolyfillPlan.autoPolyfillsFile &&
896
+ legacyPolyfillPlan.autoPolyfillsChunk) {
897
+ await writeFileSafe(join(outDir, assetsDir, legacyPolyfillPlan.autoPolyfillsFile), legacyPolyfillPlan.autoPolyfillsChunk);
898
+ }
899
+ return manifest;
900
+ },
901
+ copyPublic: async () => {
902
+ await copyPublicDir(publicDir, outDir);
903
+ },
904
+ }, { ssr: mode === "ssr" });
905
+ if (!result.state.routeGraph || !result.state.manifest) {
906
+ throw new Error("Build did not produce route graph or manifest.");
907
+ }
908
+ if (cache.enabled) {
909
+ await updateCacheIndex(cache.dir, rootKey, cacheRoot, {
910
+ maxSizeMB: cache.maxSizeMB,
911
+ maxAgeDays: cache.maxAgeDays,
912
+ });
913
+ }
914
+ return {
915
+ outDir,
916
+ routeGraph: result.state.routeGraph,
917
+ manifest: result.state.manifest,
918
+ timings: result.timings,
919
+ cache,
920
+ };
921
+ }
922
+ finally {
923
+ await rm(entryWorkspaceRoot, { recursive: true, force: true }).catch(() => { });
924
+ }
925
+ };