@tyndall/build 0.0.1 → 0.0.2

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 CHANGED
@@ -1,8 +1,8 @@
1
1
  import { copyFile, mkdir, rm, stat, writeFile } from "node:fs/promises";
2
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
2
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
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";
4
+ import { createRouteGraph, loadConfig, resolveLayoutRouteId, routeDataKeyForLayout, routeDataKeyForPage, ROUTE_DATA_ERROR_KEY, ROUTE_DATA_INIT_KEY, SPECIAL_ROUTE_IDS, collectRouteLayouts, renderClientRouterBootstrap, resolvePageModule, resolveUIAdapter, runBeforeBundleHooks, runGetRouteData, runInitServer, serializeProps, } from "@tyndall/core";
5
+ import { buildModuleGraphSnapshot, computeGraphKey, computeCacheRootKey, DEFAULT_IMPORT_EXTENSIONS, getBunVersion, getOsArch, hash, normalizePath, readFileSafe, readModuleGraphCache, resolveCacheRoot, stableStringify, writeModuleGraphCache, } from "@tyndall/shared";
6
6
  import { bundleClient, bundleServer, } from "./bundler.js";
7
7
  import { build as esbuild } from "esbuild";
8
8
  import { transform } from "@swc/core";
@@ -61,6 +61,16 @@ const resolveHyperVersion = async (rootDir) => {
61
61
  return "unknown";
62
62
  }
63
63
  };
64
+ const STYLE_MODULE_EXTENSIONS = new Set([".css", ".scss", ".sass"]);
65
+ const STYLE_MODULE_SUFFIXES = [".css.ts", ".css.tsx"];
66
+ const isStyleModule = (filePath) => {
67
+ const lower = filePath.toLowerCase();
68
+ if (STYLE_MODULE_SUFFIXES.some((suffix) => lower.endsWith(suffix))) {
69
+ return true;
70
+ }
71
+ return STYLE_MODULE_EXTENSIONS.has(extname(lower));
72
+ };
73
+ const MODULE_GRAPH_EXTENSIONS = [...DEFAULT_IMPORT_EXTENSIONS, ".css", ".scss", ".sass"];
64
74
  const isStaticRoute = (route) => route.segments.every((segment) => segment.type === "static");
65
75
  const isDynamicRoute = (route) => route.segments.some((segment) => segment.type === "dynamic" || segment.type === "catchAll");
66
76
  const routeIdToPath = (routeId, params) => {
@@ -87,6 +97,183 @@ const writeFileSafe = async (filePath, contents) => {
87
97
  await mkdir(dirname(filePath), { recursive: true });
88
98
  await writeFile(filePath, contents, "utf-8");
89
99
  };
100
+ const resolveSpecialRouteRecords = (routeGraph, routesDir) => {
101
+ const specialFiles = routeGraph.specialFiles;
102
+ const records = {
103
+ notFound: null,
104
+ error: null,
105
+ };
106
+ if (specialFiles?.notFound) {
107
+ const resolution = collectRouteLayouts(specialFiles.notFound, routesDir);
108
+ records.notFound = {
109
+ id: SPECIAL_ROUTE_IDS.notFound,
110
+ filePath: specialFiles.notFound,
111
+ layoutFiles: resolution.layoutFiles,
112
+ };
113
+ }
114
+ if (specialFiles?.error) {
115
+ const resolution = collectRouteLayouts(specialFiles.error, routesDir);
116
+ records.error = {
117
+ id: SPECIAL_ROUTE_IDS.error,
118
+ filePath: specialFiles.error,
119
+ layoutFiles: resolution.layoutFiles,
120
+ };
121
+ }
122
+ return records;
123
+ };
124
+ const resolveInitServerHook = (module) => {
125
+ if (module && typeof module === "object") {
126
+ const record = module;
127
+ if (typeof record.initServer === "function") {
128
+ return record.initServer;
129
+ }
130
+ if (typeof record.default === "function") {
131
+ return record.default;
132
+ }
133
+ }
134
+ return undefined;
135
+ };
136
+ const resolveRouteDataHook = (module) => {
137
+ if (module && typeof module === "object" && typeof module.getRouteData === "function") {
138
+ return module.getRouteData;
139
+ }
140
+ return undefined;
141
+ };
142
+ const normalizeRedirect = (redirect) => {
143
+ if (!redirect) {
144
+ return null;
145
+ }
146
+ if (typeof redirect === "string") {
147
+ return { destination: redirect };
148
+ }
149
+ return {
150
+ destination: redirect.destination,
151
+ status: redirect.status,
152
+ replace: redirect.replace,
153
+ };
154
+ };
155
+ const normalizeError = (error) => {
156
+ if (!error) {
157
+ return null;
158
+ }
159
+ if (typeof error === "string") {
160
+ return { message: error };
161
+ }
162
+ return {
163
+ status: error.status,
164
+ message: error.message,
165
+ };
166
+ };
167
+ const collectRouteDataForRoute = async (input) => {
168
+ const dataMap = {};
169
+ const headers = {};
170
+ let status;
171
+ let redirect;
172
+ let error;
173
+ let initData;
174
+ let parentData;
175
+ if (input.initServerHook) {
176
+ const initResult = await runInitServer(input.initServerHook, {
177
+ routeId: input.routeId,
178
+ params: input.params,
179
+ request: input.request,
180
+ response: input.response,
181
+ });
182
+ if (initResult.data !== undefined) {
183
+ initData = initResult.data;
184
+ dataMap[ROUTE_DATA_INIT_KEY] = initResult.data;
185
+ }
186
+ if (initResult.headers) {
187
+ Object.assign(headers, initResult.headers);
188
+ }
189
+ if (initResult.status !== undefined) {
190
+ status = initResult.status;
191
+ }
192
+ if (initResult.redirect) {
193
+ redirect = initResult.redirect;
194
+ return { data: dataMap, initData, headers, status, redirect, error };
195
+ }
196
+ if (initResult.error) {
197
+ error = initResult.error;
198
+ const normalizedError = normalizeError(error);
199
+ if (normalizedError) {
200
+ dataMap[ROUTE_DATA_ERROR_KEY] = normalizedError;
201
+ }
202
+ return { data: dataMap, initData, headers, status, redirect, error };
203
+ }
204
+ }
205
+ const layoutFiles = input.layoutFiles ?? [];
206
+ for (const layoutFile of layoutFiles) {
207
+ const layoutModule = await input.loadLayoutModule(layoutFile);
208
+ const hook = resolveRouteDataHook(layoutModule);
209
+ if (!hook) {
210
+ continue;
211
+ }
212
+ const layoutId = resolveLayoutRouteId(layoutFile, input.routesDir);
213
+ const result = await runGetRouteData(hook, {
214
+ routeId: layoutId,
215
+ params: input.params,
216
+ request: input.request,
217
+ response: input.response,
218
+ init: initData,
219
+ routeData: dataMap,
220
+ parentData,
221
+ });
222
+ if (result.data !== undefined) {
223
+ dataMap[routeDataKeyForLayout(layoutId)] = result.data;
224
+ parentData = result.data;
225
+ }
226
+ if (result.headers) {
227
+ Object.assign(headers, result.headers);
228
+ }
229
+ if (result.status !== undefined) {
230
+ status = result.status;
231
+ }
232
+ if (result.redirect) {
233
+ redirect = result.redirect;
234
+ return { data: dataMap, initData, headers, status, redirect, error };
235
+ }
236
+ if (result.error) {
237
+ error = result.error;
238
+ const normalizedError = normalizeError(error);
239
+ if (normalizedError) {
240
+ dataMap[ROUTE_DATA_ERROR_KEY] = normalizedError;
241
+ }
242
+ return { data: dataMap, initData, headers, status, redirect, error };
243
+ }
244
+ }
245
+ if (input.pageModule?.getRouteData) {
246
+ const result = await runGetRouteData(input.pageModule.getRouteData, {
247
+ routeId: input.routeId,
248
+ params: input.params,
249
+ request: input.request,
250
+ response: input.response,
251
+ init: initData,
252
+ routeData: dataMap,
253
+ parentData,
254
+ });
255
+ if (result.data !== undefined) {
256
+ dataMap[routeDataKeyForPage(input.routeId)] = result.data;
257
+ }
258
+ if (result.headers) {
259
+ Object.assign(headers, result.headers);
260
+ }
261
+ if (result.status !== undefined) {
262
+ status = result.status;
263
+ }
264
+ if (result.redirect) {
265
+ redirect = result.redirect;
266
+ }
267
+ if (result.error) {
268
+ error = result.error;
269
+ const normalizedError = normalizeError(error);
270
+ if (normalizedError) {
271
+ dataMap[ROUTE_DATA_ERROR_KEY] = normalizedError;
272
+ }
273
+ }
274
+ }
275
+ return { data: dataMap, initData, headers, status, redirect, error };
276
+ };
90
277
  const toPosixPath = (value) => value.split(sep).join("/");
91
278
  const toImportSpecifier = (fromDir, targetPath) => {
92
279
  const relativePath = toPosixPath(relative(fromDir, targetPath));
@@ -114,6 +301,40 @@ const appendBuildVersion = (value, buildVersion) => {
114
301
  const separator = value.includes("?") ? "&" : "?";
115
302
  return `${value}${separator}v=${encodeURIComponent(buildVersion)}`;
116
303
  };
304
+ const escapeHtml = (value) => value
305
+ .replace(/&/g, "&")
306
+ .replace(/</g, "&lt;")
307
+ .replace(/>/g, "&gt;")
308
+ .replace(/\"/g, "&quot;");
309
+ const renderStaticRedirectDocument = (destination) => {
310
+ const safe = escapeHtml(destination);
311
+ return [
312
+ "<!doctype html>",
313
+ "<html>",
314
+ "<head>",
315
+ `<meta http-equiv=\"refresh\" content=\"0;url=${safe}\">`,
316
+ `<script>window.location.replace(${JSON.stringify(destination)});</script>`,
317
+ "</head>",
318
+ "<body>",
319
+ `<a href=\"${safe}\">Redirecting...</a>`,
320
+ "</body>",
321
+ "</html>",
322
+ ].join("");
323
+ };
324
+ const renderStaticErrorDocument = (message) => {
325
+ const safe = escapeHtml(message);
326
+ return [
327
+ "<!doctype html>",
328
+ "<html>",
329
+ "<head>",
330
+ "<title>Route data error</title>",
331
+ "</head>",
332
+ "<body>",
333
+ `<main>${safe}</main>`,
334
+ "</body>",
335
+ "</html>",
336
+ ].join("");
337
+ };
117
338
  const resolveBundleScripts = (bundle, routeId, basePath, assetsDir, buildVersion) => {
118
339
  const entryKey = bundle.entryChunks[routeId];
119
340
  if (!entryKey) {
@@ -138,6 +359,20 @@ const resolveBundleScripts = (bundle, routeId, basePath, assetsDir, buildVersion
138
359
  walk(entryKey);
139
360
  return scripts;
140
361
  };
362
+ const resolveBundleStyleFile = (bundle, routeId) => {
363
+ const entryKey = bundle.entryChunks[routeId];
364
+ if (!entryKey) {
365
+ return null;
366
+ }
367
+ const chunk = bundle.chunks[entryKey];
368
+ if (!chunk) {
369
+ return null;
370
+ }
371
+ if (chunk.file.endsWith(".js")) {
372
+ return chunk.file.replace(/\.js$/, ".css");
373
+ }
374
+ return `${chunk.file}.css`;
375
+ };
141
376
  const LEGACY_AUTO_POLYFILLS_FILE = "hyper-legacy-polyfills.js";
142
377
  const renderLegacyClientBootstrap = () => [
143
378
  "(function () {",
@@ -268,12 +503,20 @@ const resolveHyperCoreBrowserShimSource = async () => {
268
503
  const propsPath = await resolveCoreShimModulePath(entryDir, "props");
269
504
  const renderPolicyPath = await resolveCoreShimModulePath(entryDir, "render-policy");
270
505
  const resolverFallbackPath = await resolveCoreShimModulePath(entryDir, "resolver-fallback");
506
+ const routeDataPath = await resolveCoreShimModulePath(entryDir, "route-data");
271
507
  hyperCoreBrowserShimSource = {
272
508
  source: [
273
509
  `export { mergeHeadDescriptors } from ${JSON.stringify(headPath)};`,
274
510
  `export { serializeProps } from ${JSON.stringify(propsPath)};`,
275
511
  `export { evaluateRenderPolicy } from ${JSON.stringify(renderPolicyPath)};`,
276
512
  `export { shouldForceDynamicFallback } from ${JSON.stringify(resolverFallbackPath)};`,
513
+ `export {`,
514
+ ` ROUTE_DATA_SCRIPT_ID,`,
515
+ ` ROUTE_DATA_INIT_KEY,`,
516
+ ` ROUTE_DATA_ERROR_KEY,`,
517
+ ` routeDataKeyForPage,`,
518
+ ` routeDataKeyForLayout,`,
519
+ `} from ${JSON.stringify(routeDataPath)};`,
277
520
  ].join("\n"),
278
521
  // Required so esbuild resolves filesystem imports from a virtual namespace.
279
522
  resolveDir: entryDir,
@@ -287,6 +530,8 @@ const emitBundleAssets = async (bundle, outDir, assetsDir, chunkSource) => {
287
530
  }
288
531
  };
289
532
  const buildChunkFile = async (entryPath, outputPath, options) => {
533
+ const bundlerPlugins = options.plugins ?? [];
534
+ const shouldEmitCss = Boolean(options.emitCss && options.cssOutputPath);
290
535
  const shimSource = options.shimHyperCore ? await resolveHyperCoreBrowserShimSource() : null;
291
536
  const shimPlugin = shimSource
292
537
  ? {
@@ -309,6 +554,7 @@ const buildChunkFile = async (entryPath, outputPath, options) => {
309
554
  : null;
310
555
  if (options.syntaxTarget === "es5") {
311
556
  try {
557
+ const plugins = shimPlugin ? [shimPlugin, ...bundlerPlugins] : [...bundlerPlugins];
312
558
  const bundled = await esbuild({
313
559
  entryPoints: [entryPath],
314
560
  bundle: true,
@@ -320,10 +566,13 @@ const buildChunkFile = async (entryPath, outputPath, options) => {
320
566
  minify: false,
321
567
  logLevel: "silent",
322
568
  jsx: "automatic",
323
- plugins: shimPlugin ? [shimPlugin] : undefined,
569
+ plugins: plugins.length > 0 ? plugins : undefined,
324
570
  write: false,
325
571
  });
326
- const outputText = bundled.outputFiles?.[0]?.text;
572
+ const outputFiles = bundled.outputFiles ?? [];
573
+ const jsOutput = outputFiles.find((file) => file.path.endsWith(".js") || file.path.endsWith(".mjs")) ??
574
+ outputFiles[0];
575
+ const outputText = jsOutput?.text;
327
576
  if (!outputText) {
328
577
  throw new Error("ES5 bundling produced no output.");
329
578
  }
@@ -338,6 +587,15 @@ const buildChunkFile = async (entryPath, outputPath, options) => {
338
587
  });
339
588
  await mkdir(dirname(outputPath), { recursive: true });
340
589
  await writeFile(outputPath, transformed.code, "utf-8");
590
+ if (shouldEmitCss && options.cssOutputPath) {
591
+ const cssChunks = outputFiles
592
+ .filter((file) => file.path.endsWith(".css"))
593
+ .map((file) => file.text)
594
+ .filter((text) => typeof text === "string" && text.length > 0);
595
+ if (cssChunks.length > 0) {
596
+ await writeFileSafe(options.cssOutputPath, cssChunks.join("\n"));
597
+ }
598
+ }
341
599
  }
342
600
  catch (error) {
343
601
  const messages = typeof error === "object" && error !== null && "errors" in error
@@ -355,6 +613,7 @@ const buildChunkFile = async (entryPath, outputPath, options) => {
355
613
  }
356
614
  const tempOutDir = join(dirname(outputPath), `.@tyndall/build-tmp-${hash(`${outputPath}-${Date.now()}`)}`);
357
615
  await mkdir(tempOutDir, { recursive: true });
616
+ const plugins = shimPlugin ? [shimPlugin, ...bundlerPlugins] : [...bundlerPlugins];
358
617
  const result = await bun.build({
359
618
  entrypoints: [entryPath],
360
619
  outdir: tempOutDir,
@@ -364,7 +623,7 @@ const buildChunkFile = async (entryPath, outputPath, options) => {
364
623
  splitting: false,
365
624
  sourcemap: "none",
366
625
  minify: false,
367
- plugins: shimPlugin ? [shimPlugin] : undefined,
626
+ plugins: plugins.length > 0 ? plugins : undefined,
368
627
  });
369
628
  if (!result.success) {
370
629
  const errors = (result.logs ?? [])
@@ -380,10 +639,59 @@ const buildChunkFile = async (entryPath, outputPath, options) => {
380
639
  await rm(tempOutDir, { recursive: true, force: true }).catch(() => { });
381
640
  throw new Error(`Build chunk output is missing (${entryPath}).`);
382
641
  }
642
+ if (shouldEmitCss && options.cssOutputPath) {
643
+ const cssOutputs = (result.outputs ?? [])
644
+ .map((output) => output.path)
645
+ .filter((path) => typeof path === "string" && path.endsWith(".css"));
646
+ if (cssOutputs.length > 0) {
647
+ const cssChunks = [];
648
+ const ordered = [...cssOutputs].sort((left, right) => left.localeCompare(right));
649
+ for (const cssPath of ordered) {
650
+ const content = await readFileSafe(cssPath, "utf-8");
651
+ if (typeof content === "string" && content.length > 0) {
652
+ cssChunks.push(content);
653
+ }
654
+ else if (content instanceof Uint8Array && content.length > 0) {
655
+ cssChunks.push(Buffer.from(content).toString("utf-8"));
656
+ }
657
+ }
658
+ if (cssChunks.length > 0) {
659
+ await writeFileSafe(options.cssOutputPath, cssChunks.join("\n"));
660
+ }
661
+ }
662
+ }
383
663
  await mkdir(dirname(outputPath), { recursive: true });
384
664
  await copyFile(emittedPath, outputPath);
385
665
  await rm(tempOutDir, { recursive: true, force: true }).catch(() => { });
386
666
  };
667
+ const SERVER_ONLY_MODULE_PATTERN = /\.server\.[cm]?[jt]sx?$/i;
668
+ const collectServerOnlyModules = (modules) => modules.filter((modulePath) => SERVER_ONLY_MODULE_PATTERN.test(modulePath));
669
+ const assertNoServerOnlyClientImports = async (snapshot, appModule) => {
670
+ const offenders = [];
671
+ const routeIds = Object.keys(snapshot.routeToModules).sort((a, b) => a.localeCompare(b));
672
+ for (const routeId of routeIds) {
673
+ const modules = snapshot.routeToModules[routeId] ?? [];
674
+ const hits = collectServerOnlyModules(modules);
675
+ if (hits.length > 0) {
676
+ offenders.push({ scope: `route "${routeId}"`, modules: hits });
677
+ }
678
+ }
679
+ if (appModule) {
680
+ const appSnapshot = buildModuleGraphSnapshot([{ id: "__hyper_app__", filePath: appModule }]);
681
+ const appModules = appSnapshot.routeToModules["__hyper_app__"] ?? [];
682
+ const hits = collectServerOnlyModules(appModules);
683
+ if (hits.length > 0) {
684
+ offenders.push({ scope: "app", modules: hits });
685
+ }
686
+ }
687
+ if (offenders.length === 0) {
688
+ return;
689
+ }
690
+ const details = offenders
691
+ .map((entry) => `${entry.scope}: ${entry.modules.join(", ")}`)
692
+ .join(" | ");
693
+ throw new Error(`Server-only modules (.server.*) cannot be imported into client bundles. Found ${details}.`);
694
+ };
387
695
  const dynamicImportPattern = /import\s*\(/;
388
696
  const findDynamicImportModules = async (modulePaths) => {
389
697
  const offenders = [];
@@ -406,6 +714,7 @@ export const build = async (options) => {
406
714
  const outDir = resolvePath(rootDir, options.outDir ?? "dist");
407
715
  const mode = options.mode ?? "ssg";
408
716
  const config = await loadConfig(rootDir);
717
+ const hyperPlugins = config.plugins;
409
718
  const buildVersion = config.build.version.trim();
410
719
  const adapterRegistry = options.adapterRegistry ?? {};
411
720
  const ssrClientRoutingEnabled = mode === "ssr" && config.routing.ssrClientRouting;
@@ -414,6 +723,7 @@ export const build = async (options) => {
414
723
  navigationMode: resolvedNavigationMode,
415
724
  clientRenderMode: config.routing.clientRender,
416
725
  linkInterceptionMode: ssrClientRoutingEnabled ? "all" : "marked",
726
+ scrollRestoration: config.routing.scrollRestoration,
417
727
  });
418
728
  const legacyBootstrapChunk = renderLegacyClientBootstrap();
419
729
  const legacyEnabled = config.legacy.enabled;
@@ -445,19 +755,36 @@ export const build = async (options) => {
445
755
  ? await resolveGraphKey(rootDir, {
446
756
  client: { format: "esm", target: modernTarget },
447
757
  server: mode === "ssr" ? { format: "cjs", target: serverTarget } : null,
758
+ moduleGraphExtensions: MODULE_GRAPH_EXTENSIONS,
448
759
  })
449
760
  : null;
450
761
  const envHash = cache.enabled ? hash(stableStringify(process.env)) : "disabled";
451
762
  let depsHashByEntry = null;
452
763
  let legacyBundle = null;
764
+ let routeHasStyles = {};
453
765
  const routeModules = new Map();
454
766
  const routeById = new Map();
455
767
  const clientRuntimeEntries = new Map();
456
768
  const serverRuntimeEntries = new Map();
769
+ const layoutModuleCache = new Map();
770
+ let specialRouteFiles = null;
771
+ let specialRouteRecords = {
772
+ notFound: null,
773
+ error: null,
774
+ };
775
+ let initServerHook;
776
+ let documentRenderers = null;
457
777
  const entryWorkspaceRoot = join(cacheRoot, "build-runtime-entries", hash(`${Date.now()}-${Math.random()}`).slice(0, 12));
458
778
  // Important: clear output to avoid stale HTML/chunks when route render policy changes.
459
779
  await rm(outDir, { recursive: true, force: true }).catch(() => { });
460
780
  await mkdir(outDir, { recursive: true });
781
+ const computeRouteStyleMap = (snapshot) => {
782
+ const map = {};
783
+ for (const [routeId, modules] of Object.entries(snapshot.routeToModules)) {
784
+ map[routeId] = modules.some((modulePath) => isStyleModule(modulePath));
785
+ }
786
+ routeHasStyles = map;
787
+ };
461
788
  const assertLegacyChunkingSafe = async (snapshot) => {
462
789
  if (!legacyEnabled || config.legacy.chunking !== "safe") {
463
790
  return;
@@ -489,6 +816,48 @@ export const build = async (options) => {
489
816
  }
490
817
  return result;
491
818
  };
819
+ const loadLayoutModule = async (filePath) => {
820
+ const cached = layoutModuleCache.get(filePath);
821
+ if (cached) {
822
+ return cached;
823
+ }
824
+ const loaded = await import(`${pathToFileURL(filePath).href}?build=${Date.now()}-${hash(filePath).slice(0, 6)}`);
825
+ layoutModuleCache.set(filePath, loaded);
826
+ return loaded;
827
+ };
828
+ const resolveInitServer = async () => {
829
+ if (initServerHook !== undefined) {
830
+ return initServerHook;
831
+ }
832
+ if (!specialRouteFiles?.initServer) {
833
+ initServerHook = undefined;
834
+ return undefined;
835
+ }
836
+ const loaded = await import(`${pathToFileURL(specialRouteFiles.initServer).href}?build=${Date.now()}-${hash("init").slice(0, 6)}`);
837
+ initServerHook = resolveInitServerHook(loaded);
838
+ return initServerHook;
839
+ };
840
+ const resolveDocumentRenderers = async () => {
841
+ if (documentRenderers) {
842
+ return documentRenderers;
843
+ }
844
+ if (!specialRouteFiles?.document) {
845
+ documentRenderers = { renderDocument: undefined, renderDocumentFragments: undefined };
846
+ return documentRenderers;
847
+ }
848
+ const loaded = await import(`${pathToFileURL(specialRouteFiles.document).href}?build=${Date.now()}-${hash("document").slice(0, 6)}`);
849
+ const record = loaded;
850
+ const renderDocument = typeof record.renderDocument === "function"
851
+ ? record.renderDocument
852
+ : typeof record.default === "function"
853
+ ? record.default
854
+ : undefined;
855
+ const renderDocumentFragments = typeof record.renderDocumentFragments === "function"
856
+ ? record.renderDocumentFragments
857
+ : undefined;
858
+ documentRenderers = { renderDocument, renderDocumentFragments };
859
+ return documentRenderers;
860
+ };
492
861
  try {
493
862
  const result = await runBuildPipeline({ rootDir, routesDir, outDir, publicDir, cache }, {
494
863
  scanRoutes: () => createRouteGraph({
@@ -498,14 +867,33 @@ export const build = async (options) => {
498
867
  }),
499
868
  analyzeGraph: async (_ctx, state) => {
500
869
  const routes = state.routeGraph?.routes ?? [];
870
+ specialRouteFiles = state.routeGraph?.specialFiles ?? null;
871
+ specialRouteRecords = state.routeGraph
872
+ ? resolveSpecialRouteRecords(state.routeGraph, routesDir)
873
+ : { notFound: null, error: null };
501
874
  routeById.clear();
502
875
  for (const route of routes) {
503
876
  routeById.set(route.id, route);
504
877
  }
505
878
  const entries = routes.map((route) => ({ id: route.id, filePath: route.filePath }));
879
+ if (specialRouteRecords.notFound) {
880
+ entries.push({
881
+ id: SPECIAL_ROUTE_IDS.notFound,
882
+ filePath: specialRouteRecords.notFound.filePath,
883
+ });
884
+ }
885
+ if (specialRouteRecords.error) {
886
+ entries.push({
887
+ id: SPECIAL_ROUTE_IDS.error,
888
+ filePath: specialRouteRecords.error.filePath,
889
+ });
890
+ }
506
891
  if (!cache.enabled || !graphKey) {
507
- const snapshot = buildModuleGraphSnapshot(entries);
892
+ const snapshot = buildModuleGraphSnapshot(entries, { extensions: MODULE_GRAPH_EXTENSIONS });
893
+ // Guard: client bundles must never include server-only modules.
894
+ await assertNoServerOnlyClientImports(snapshot, specialRouteFiles?.app);
508
895
  await assertLegacyChunkingSafe(snapshot);
896
+ computeRouteStyleMap(snapshot);
509
897
  depsHashByEntry = cache.enabled ? await computeDepsHashByEntry(snapshot) : null;
510
898
  return snapshot;
511
899
  }
@@ -514,12 +902,16 @@ export const build = async (options) => {
514
902
  // Important: cached graph must cover all current routes to be valid.
515
903
  const hasAllRoutes = entries.every((route) => cached.routeToModules[route.id]);
516
904
  if (hasAllRoutes) {
905
+ await assertNoServerOnlyClientImports(cached, specialRouteFiles?.app);
517
906
  await assertLegacyChunkingSafe(cached);
907
+ computeRouteStyleMap(cached);
518
908
  depsHashByEntry = await computeDepsHashByEntry(cached);
519
909
  return cached;
520
910
  }
521
911
  }
522
- const snapshot = buildModuleGraphSnapshot(entries);
912
+ const snapshot = buildModuleGraphSnapshot(entries, { extensions: MODULE_GRAPH_EXTENSIONS });
913
+ await assertNoServerOnlyClientImports(snapshot, specialRouteFiles?.app);
914
+ computeRouteStyleMap(snapshot);
523
915
  await writeModuleGraphCache(cacheRoot, graphKey, snapshot);
524
916
  await assertLegacyChunkingSafe(snapshot);
525
917
  depsHashByEntry = await computeDepsHashByEntry(snapshot);
@@ -535,11 +927,15 @@ export const build = async (options) => {
535
927
  navigationMode: resolvedNavigationMode,
536
928
  clientRenderMode: config.routing.clientRender,
537
929
  }, adapterRegistry);
538
- if (typeof entryAdapter.createClientEntry !== "function") {
930
+ const createClientEntry = entryAdapter.createClientEntry;
931
+ if (typeof createClientEntry !== "function") {
539
932
  throw new Error(`UI adapter \"${entryAdapter.name}\" does not support createClientEntry.`);
540
933
  }
541
934
  const clientEntryDir = join(entryWorkspaceRoot, "client");
542
935
  await mkdir(clientEntryDir, { recursive: true });
936
+ const appModule = specialRouteFiles?.app
937
+ ? toImportSpecifier(clientEntryDir, specialRouteFiles.app)
938
+ : undefined;
543
939
  const buildClientRouteModules = routeGraph.routes.reduce((acc, route) => {
544
940
  acc[route.id] = toImportSpecifier(clientEntryDir, route.filePath);
545
941
  return acc;
@@ -549,25 +945,30 @@ export const build = async (options) => {
549
945
  for (const route of routeGraph.routes) {
550
946
  const entryFilePath = join(clientEntryDir, `entry-${hash(route.id).slice(0, 12)}.tsx`);
551
947
  const pageModule = toImportSpecifier(clientEntryDir, route.filePath);
552
- const entrySource = entryAdapter.createClientEntry({
948
+ const entrySource = createClientEntry({
553
949
  routeId: route.id,
554
950
  routeGraph,
555
951
  basePath: config.basePath,
556
952
  rootDir,
557
953
  entryDir: clientEntryDir,
954
+ entryLayoutFiles: route.layoutFiles,
558
955
  uiOptions: {
559
956
  ...config.ui.options,
560
957
  pageModule,
958
+ appModule,
561
959
  navigationMode: resolvedNavigationMode,
562
960
  clientRenderMode: config.routing.clientRender,
961
+ scrollRestoration: config.routing.scrollRestoration,
563
962
  clientRouteModules: buildClientRouteModules,
564
963
  nestedLayouts: config.routing.nestedLayouts,
565
964
  },
566
965
  adapterOptions: {
567
966
  ...config.ui.options,
568
967
  pageModule,
968
+ appModule,
569
969
  navigationMode: resolvedNavigationMode,
570
970
  clientRenderMode: config.routing.clientRender,
971
+ scrollRestoration: config.routing.scrollRestoration,
571
972
  clientRouteModules: buildClientRouteModules,
572
973
  nestedLayouts: config.routing.nestedLayouts,
573
974
  },
@@ -579,6 +980,49 @@ export const build = async (options) => {
579
980
  clientRuntimeEntries.set(route.id, entryFilePath);
580
981
  entries.push({ id: route.id, input: entryFilePath });
581
982
  }
983
+ const addSpecialClientEntry = async (record) => {
984
+ if (!record) {
985
+ return;
986
+ }
987
+ const entryFilePath = join(clientEntryDir, `entry-${hash(record.id).slice(0, 12)}.tsx`);
988
+ const pageModule = toImportSpecifier(clientEntryDir, record.filePath);
989
+ const entrySource = createClientEntry({
990
+ routeId: record.id,
991
+ routeGraph,
992
+ basePath: config.basePath,
993
+ rootDir,
994
+ entryDir: clientEntryDir,
995
+ entryLayoutFiles: record.layoutFiles,
996
+ uiOptions: {
997
+ ...config.ui.options,
998
+ pageModule,
999
+ appModule,
1000
+ navigationMode: resolvedNavigationMode,
1001
+ clientRenderMode: config.routing.clientRender,
1002
+ scrollRestoration: config.routing.scrollRestoration,
1003
+ clientRouteModules: buildClientRouteModules,
1004
+ nestedLayouts: config.routing.nestedLayouts,
1005
+ },
1006
+ adapterOptions: {
1007
+ ...config.ui.options,
1008
+ pageModule,
1009
+ appModule,
1010
+ navigationMode: resolvedNavigationMode,
1011
+ clientRenderMode: config.routing.clientRender,
1012
+ scrollRestoration: config.routing.scrollRestoration,
1013
+ clientRouteModules: buildClientRouteModules,
1014
+ nestedLayouts: config.routing.nestedLayouts,
1015
+ },
1016
+ });
1017
+ if (typeof entrySource !== "string" || entrySource.trim().length === 0) {
1018
+ throw new Error(`Client entry generation failed for route ${record.id}`);
1019
+ }
1020
+ await writeFileSafe(entryFilePath, `${entrySource}\n\n${clientBootstrapChunk}\n`);
1021
+ clientRuntimeEntries.set(record.id, entryFilePath);
1022
+ entries.push({ id: record.id, input: entryFilePath });
1023
+ };
1024
+ await addSpecialClientEntry(specialRouteRecords.notFound);
1025
+ await addSpecialClientEntry(specialRouteRecords.error);
582
1026
  const clientFormat = isEs5Target ? "iife" : "esm";
583
1027
  const modernBundle = await bundleWithCache("client", entries, clientFormat, bundleClient, modernTarget);
584
1028
  if (legacyEnabled && !isEs5Target) {
@@ -597,18 +1041,28 @@ export const build = async (options) => {
597
1041
  navigationMode: resolvedNavigationMode,
598
1042
  clientRenderMode: config.routing.clientRender,
599
1043
  }, adapterRegistry);
600
- if (typeof entryAdapter.createServerEntry !== "function") {
1044
+ const createServerEntry = entryAdapter.createServerEntry;
1045
+ if (typeof createServerEntry !== "function") {
601
1046
  throw new Error(`UI adapter \"${entryAdapter.name}\" does not support createServerEntry.`);
602
1047
  }
603
1048
  const serverEntryDir = join(entryWorkspaceRoot, "server");
604
1049
  await mkdir(serverEntryDir, { recursive: true });
605
1050
  const entries = [];
606
1051
  serverRuntimeEntries.clear();
1052
+ const appModule = specialRouteFiles?.app
1053
+ ? toImportSpecifier(serverEntryDir, specialRouteFiles.app)
1054
+ : undefined;
1055
+ const documentModule = specialRouteFiles?.document
1056
+ ? toImportSpecifier(serverEntryDir, specialRouteFiles.document)
1057
+ : undefined;
1058
+ const initServerModule = specialRouteFiles?.initServer
1059
+ ? toImportSpecifier(serverEntryDir, specialRouteFiles.initServer)
1060
+ : undefined;
607
1061
  for (const route of routeGraph.routes) {
608
1062
  const pageModule = toImportSpecifier(serverEntryDir, route.filePath);
609
1063
  const rawEntryFilePath = join(serverEntryDir, `entry-${hash(route.id).slice(0, 12)}.raw.tsx`);
610
1064
  const wrappedEntryFilePath = join(serverEntryDir, `entry-${hash(route.id).slice(0, 12)}.ts`);
611
- const serverEntrySource = entryAdapter.createServerEntry({
1065
+ const serverEntrySource = createServerEntry({
612
1066
  routeId: route.id,
613
1067
  routeGraph,
614
1068
  basePath: config.basePath,
@@ -617,6 +1071,7 @@ export const build = async (options) => {
617
1071
  uiOptions: {
618
1072
  ...config.ui.options,
619
1073
  pageModule,
1074
+ appModule,
620
1075
  navigationMode: resolvedNavigationMode,
621
1076
  clientRenderMode: config.routing.clientRender,
622
1077
  nestedLayouts: config.routing.nestedLayouts,
@@ -624,6 +1079,7 @@ export const build = async (options) => {
624
1079
  adapterOptions: {
625
1080
  ...config.ui.options,
626
1081
  pageModule,
1082
+ appModule,
627
1083
  navigationMode: resolvedNavigationMode,
628
1084
  clientRenderMode: config.routing.clientRender,
629
1085
  nestedLayouts: config.routing.nestedLayouts,
@@ -634,9 +1090,27 @@ export const build = async (options) => {
634
1090
  }
635
1091
  await writeFileSafe(rawEntryFilePath, serverEntrySource);
636
1092
  const rawSpecifier = toImportSpecifier(serverEntryDir, rawEntryFilePath);
1093
+ const layoutFiles = route.layoutFiles ?? [];
1094
+ const layoutImports = layoutFiles.map((layoutFile, index) => {
1095
+ const specifier = toImportSpecifier(serverEntryDir, layoutFile);
1096
+ return `import * as layoutModule${index} from ${JSON.stringify(specifier)};`;
1097
+ });
1098
+ const layoutEntries = layoutFiles.map((layoutFile, index) => {
1099
+ const layoutId = resolveLayoutRouteId(layoutFile, routesDir);
1100
+ return ` { key: ${JSON.stringify(routeDataKeyForLayout(layoutId))}, routeId: ${JSON.stringify(layoutId)}, hook: typeof layoutModule${index}.getRouteData === \"function\" ? layoutModule${index}.getRouteData : undefined },`;
1101
+ });
1102
+ const initServerImport = initServerModule
1103
+ ? `import * as initServerModule from ${JSON.stringify(initServerModule)};`
1104
+ : "";
1105
+ const documentImport = documentModule
1106
+ ? `import * as documentModule from ${JSON.stringify(documentModule)};`
1107
+ : "";
637
1108
  const wrapperSource = [
638
1109
  `import * as pageModule from ${JSON.stringify(pageModule)};`,
639
1110
  `import * as serverEntryModule from ${JSON.stringify(rawSpecifier)};`,
1111
+ initServerImport,
1112
+ documentImport,
1113
+ ...layoutImports,
640
1114
  "export const renderToHtml = serverEntryModule.renderToHtml;",
641
1115
  "export const renderToStream = serverEntryModule.renderToStream;",
642
1116
  "export const hydration =",
@@ -647,12 +1121,118 @@ export const build = async (options) => {
647
1121
  " typeof pageModule.getServerProps === \"function\" ? pageModule.getServerProps : undefined;",
648
1122
  "export const getServerSideProps =",
649
1123
  " typeof pageModule.getServerSideProps === \"function\" ? pageModule.getServerSideProps : undefined;",
1124
+ "export const getRouteData =",
1125
+ " typeof pageModule.getRouteData === \"function\" ? pageModule.getRouteData : undefined;",
1126
+ initServerModule
1127
+ ? "export const initServer = typeof initServerModule.initServer === \"function\" ? initServerModule.initServer : (typeof initServerModule.default === \"function\" ? initServerModule.default : undefined);"
1128
+ : "export const initServer = undefined;",
1129
+ documentModule
1130
+ ? "export const renderDocument = typeof documentModule.renderDocument === \"function\" ? documentModule.renderDocument : (typeof documentModule.default === \"function\" ? documentModule.default : undefined);"
1131
+ : "export const renderDocument = undefined;",
1132
+ documentModule
1133
+ ? "export const renderDocumentFragments = typeof documentModule.renderDocumentFragments === \"function\" ? documentModule.renderDocumentFragments : undefined;"
1134
+ : "export const renderDocumentFragments = undefined;",
1135
+ "export const routeDataEntries = [",
1136
+ ...layoutEntries,
1137
+ ` { key: ${JSON.stringify(routeDataKeyForPage(route.id))}, routeId: ${JSON.stringify(route.id)}, hook: typeof pageModule.getRouteData === \"function\" ? pageModule.getRouteData : undefined },`,
1138
+ "];",
650
1139
  "",
651
1140
  ].join("\n");
652
1141
  await writeFileSafe(wrappedEntryFilePath, wrapperSource);
653
1142
  serverRuntimeEntries.set(route.id, wrappedEntryFilePath);
654
1143
  entries.push({ id: route.id, input: wrappedEntryFilePath });
655
1144
  }
1145
+ const addSpecialServerEntry = async (record) => {
1146
+ if (!record) {
1147
+ return;
1148
+ }
1149
+ const pageModule = toImportSpecifier(serverEntryDir, record.filePath);
1150
+ const rawEntryFilePath = join(serverEntryDir, `entry-${hash(record.id).slice(0, 12)}.raw.tsx`);
1151
+ const wrappedEntryFilePath = join(serverEntryDir, `entry-${hash(record.id).slice(0, 12)}.ts`);
1152
+ const serverEntrySource = createServerEntry({
1153
+ routeId: record.id,
1154
+ routeGraph,
1155
+ basePath: config.basePath,
1156
+ rootDir,
1157
+ entryDir: serverEntryDir,
1158
+ uiOptions: {
1159
+ ...config.ui.options,
1160
+ pageModule,
1161
+ appModule,
1162
+ entryLayoutFiles: record.layoutFiles,
1163
+ navigationMode: resolvedNavigationMode,
1164
+ clientRenderMode: config.routing.clientRender,
1165
+ nestedLayouts: config.routing.nestedLayouts,
1166
+ },
1167
+ adapterOptions: {
1168
+ ...config.ui.options,
1169
+ pageModule,
1170
+ appModule,
1171
+ entryLayoutFiles: record.layoutFiles,
1172
+ navigationMode: resolvedNavigationMode,
1173
+ clientRenderMode: config.routing.clientRender,
1174
+ nestedLayouts: config.routing.nestedLayouts,
1175
+ },
1176
+ });
1177
+ if (typeof serverEntrySource !== "string" || serverEntrySource.trim().length === 0) {
1178
+ throw new Error(`Server entry generation failed for route ${record.id}`);
1179
+ }
1180
+ await writeFileSafe(rawEntryFilePath, serverEntrySource);
1181
+ const rawSpecifier = toImportSpecifier(serverEntryDir, rawEntryFilePath);
1182
+ const layoutFiles = record.layoutFiles ?? [];
1183
+ const layoutImports = layoutFiles.map((layoutFile, index) => {
1184
+ const specifier = toImportSpecifier(serverEntryDir, layoutFile);
1185
+ return `import * as layoutModule${index} from ${JSON.stringify(specifier)};`;
1186
+ });
1187
+ const layoutEntries = layoutFiles.map((layoutFile, index) => {
1188
+ const layoutId = resolveLayoutRouteId(layoutFile, routesDir);
1189
+ return ` { key: ${JSON.stringify(routeDataKeyForLayout(layoutId))}, routeId: ${JSON.stringify(layoutId)}, hook: typeof layoutModule${index}.getRouteData === \"function\" ? layoutModule${index}.getRouteData : undefined },`;
1190
+ });
1191
+ const initServerImport = initServerModule
1192
+ ? `import * as initServerModule from ${JSON.stringify(initServerModule)};`
1193
+ : "";
1194
+ const documentImport = documentModule
1195
+ ? `import * as documentModule from ${JSON.stringify(documentModule)};`
1196
+ : "";
1197
+ const wrapperSource = [
1198
+ `import * as pageModule from ${JSON.stringify(pageModule)};`,
1199
+ `import * as serverEntryModule from ${JSON.stringify(rawSpecifier)};`,
1200
+ initServerImport,
1201
+ documentImport,
1202
+ ...layoutImports,
1203
+ "export const renderToHtml = serverEntryModule.renderToHtml;",
1204
+ "export const renderToStream = serverEntryModule.renderToStream;",
1205
+ "export const hydration =",
1206
+ " serverEntryModule.hydration === \"full\" || serverEntryModule.hydration === \"islands\"",
1207
+ " ? serverEntryModule.hydration",
1208
+ " : \"islands\";",
1209
+ "export const getServerProps =",
1210
+ " typeof pageModule.getServerProps === \"function\" ? pageModule.getServerProps : undefined;",
1211
+ "export const getServerSideProps =",
1212
+ " typeof pageModule.getServerSideProps === \"function\" ? pageModule.getServerSideProps : undefined;",
1213
+ "export const getRouteData =",
1214
+ " typeof pageModule.getRouteData === \"function\" ? pageModule.getRouteData : undefined;",
1215
+ initServerModule
1216
+ ? "export const initServer = typeof initServerModule.initServer === \"function\" ? initServerModule.initServer : (typeof initServerModule.default === \"function\" ? initServerModule.default : undefined);"
1217
+ : "export const initServer = undefined;",
1218
+ documentModule
1219
+ ? "export const renderDocument = typeof documentModule.renderDocument === \"function\" ? documentModule.renderDocument : (typeof documentModule.default === \"function\" ? documentModule.default : undefined);"
1220
+ : "export const renderDocument = undefined;",
1221
+ documentModule
1222
+ ? "export const renderDocumentFragments = typeof documentModule.renderDocumentFragments === \"function\" ? documentModule.renderDocumentFragments : undefined;"
1223
+ : "export const renderDocumentFragments = undefined;",
1224
+ "export const routeDataEntries = [",
1225
+ ...layoutEntries,
1226
+ ` { key: ${JSON.stringify(routeDataKeyForPage(record.id))}, routeId: ${JSON.stringify(record.id)}, hook: typeof pageModule.getRouteData === \"function\" ? pageModule.getRouteData : undefined },`,
1227
+ "];",
1228
+ "",
1229
+ ].join("\n");
1230
+ await writeFileSafe(wrappedEntryFilePath, wrapperSource);
1231
+ serverRuntimeEntries.set(record.id, wrappedEntryFilePath);
1232
+ entries.push({ id: record.id, input: wrappedEntryFilePath });
1233
+ };
1234
+ await addSpecialServerEntry(specialRouteRecords.notFound);
1235
+ await addSpecialServerEntry(specialRouteRecords.error);
656
1236
  return bundleWithCache("server", entries, "cjs", bundleServer, serverTarget);
657
1237
  }
658
1238
  : undefined,
@@ -741,37 +1321,160 @@ export const build = async (options) => {
741
1321
  return {};
742
1322
  }
743
1323
  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);
1324
+ const initServer = await resolveInitServer();
1325
+ const errorRecord = specialRouteRecords.error;
1326
+ const notFoundRecord = specialRouteRecords.notFound;
1327
+ let errorPageModule = null;
1328
+ if (errorRecord) {
1329
+ const loaded = await import(`${pathToFileURL(errorRecord.filePath).href}?build=${Date.now()}-${hash("error").slice(0, 6)}`);
1330
+ errorPageModule = resolvePageModule(loaded);
1331
+ }
1332
+ let notFoundPageModule = null;
1333
+ if (notFoundRecord) {
1334
+ const loaded = await import(`${pathToFileURL(notFoundRecord.filePath).href}?build=${Date.now()}-${hash("not-found").slice(0, 6)}`);
1335
+ notFoundPageModule = resolvePageModule(loaded);
1336
+ }
1337
+ const resolveAssetsForRoute = (routeId) => {
1338
+ const scripts = resolveBundleScripts(clientBundle, routeId, config.basePath, assetsDir, buildVersion);
1339
+ const styleFile = routeHasStyles[routeId]
1340
+ ? resolveBundleStyleFile(clientBundle, routeId)
1341
+ : null;
1342
+ const styles = styleFile
1343
+ ? [appendBuildVersion(resolveAssetUrl(config.basePath, assetsDir, styleFile), buildVersion)]
1344
+ : [];
753
1345
  const legacyPolyfillScripts = legacyEnabled
754
1346
  ? legacyPolyfillPlan.scripts.map((script) => appendBuildVersion(script, buildVersion))
755
1347
  : [];
756
1348
  const legacyBundleScripts = legacyEnabled && legacyBundle
757
- ? resolveBundleScripts(legacyBundle, entry.routeId, config.basePath, assetsDir, buildVersion)
1349
+ ? resolveBundleScripts(legacyBundle, routeId, config.basePath, assetsDir, buildVersion)
758
1350
  : [];
759
1351
  const legacyScripts = legacyEnabled && !isEs5Target
760
1352
  ? [...legacyPolyfillScripts, ...legacyBundleScripts]
761
1353
  : [];
762
- const assets = {
763
- scripts: isEs5Target ? [...legacyPolyfillScripts, ...scripts] : scripts,
764
- legacyScripts,
765
- scriptType: clientScriptType,
766
- buildVersion,
1354
+ return {
1355
+ scripts,
1356
+ assets: {
1357
+ scripts: isEs5Target ? [...legacyPolyfillScripts, ...scripts] : scripts,
1358
+ legacyScripts,
1359
+ styles,
1360
+ scriptType: clientScriptType,
1361
+ buildVersion,
1362
+ },
767
1363
  };
768
- const templateHash = computeTemplateHash(defaultHtmlTemplate, assets);
1364
+ };
1365
+ const documentRenderers = await resolveDocumentRenderers();
1366
+ const documentTemplate = documentRenderers.renderDocument || documentRenderers.renderDocumentFragments
1367
+ ? (ctx) => {
1368
+ if (documentRenderers.renderDocument) {
1369
+ try {
1370
+ const custom = documentRenderers.renderDocument(ctx);
1371
+ if (typeof custom === "string") {
1372
+ return custom;
1373
+ }
1374
+ }
1375
+ catch {
1376
+ // Fall back to default template if custom document fails.
1377
+ }
1378
+ }
1379
+ if (documentRenderers.renderDocumentFragments) {
1380
+ try {
1381
+ const fragments = documentRenderers.renderDocumentFragments(ctx);
1382
+ if (fragments &&
1383
+ typeof fragments.prefix === "string" &&
1384
+ typeof fragments.suffix === "string") {
1385
+ return `${fragments.prefix}${ctx.html}${fragments.suffix}`;
1386
+ }
1387
+ }
1388
+ catch {
1389
+ // Fall back to default template if fragments fail.
1390
+ }
1391
+ }
1392
+ return defaultHtmlTemplate(ctx);
1393
+ }
1394
+ : defaultHtmlTemplate;
1395
+ for (const entry of entries) {
1396
+ const route = routeById.get(entry.routeId);
1397
+ const pageModule = routeModules.get(entry.routeId);
1398
+ if (!route || !pageModule) {
1399
+ continue;
1400
+ }
1401
+ const routeDataResult = await collectRouteDataForRoute({
1402
+ routeId: entry.routeId,
1403
+ routesDir,
1404
+ params: entry.params,
1405
+ layoutFiles: route.layoutFiles,
1406
+ pageModule,
1407
+ initServerHook: initServer,
1408
+ loadLayoutModule,
1409
+ });
1410
+ const redirect = normalizeRedirect(routeDataResult.redirect);
1411
+ if (redirect) {
1412
+ const redirectHtml = renderStaticRedirectDocument(redirect.destination);
1413
+ const routePath = routeIdToPath(entry.routeId, entry.params);
1414
+ const htmlPath = routePath
1415
+ ? join(outDir, routePath, "index.html")
1416
+ : join(outDir, "index.html");
1417
+ await writeFileSafe(htmlPath, redirectHtml);
1418
+ outputs[routePath || "/"] = redirectHtml;
1419
+ continue;
1420
+ }
1421
+ if (routeDataResult.error) {
1422
+ const normalizedError = normalizeError(routeDataResult.error);
1423
+ if (normalizedError && errorRecord && errorPageModule) {
1424
+ const errorAdapter = resolveUIAdapter(config.ui.adapter, {
1425
+ ...config.ui.options,
1426
+ navigationMode: resolvedNavigationMode,
1427
+ clientRenderMode: config.routing.clientRender,
1428
+ nestedLayouts: config.routing.nestedLayouts,
1429
+ routeGraph,
1430
+ rootDir,
1431
+ appModule: specialRouteFiles?.app,
1432
+ routeRender: (props) => errorPageModule.default(props),
1433
+ routeHead: errorPageModule.head
1434
+ ? (props) => errorPageModule.head(props)
1435
+ : undefined,
1436
+ }, adapterRegistry);
1437
+ const { assets } = resolveAssetsForRoute(errorRecord.id);
1438
+ const errorRendered = await renderHtml({
1439
+ renderToHtml: (ctx) => errorAdapter.renderToHtml(ctx),
1440
+ serializeProps: errorAdapter.serializeProps ?? serializeProps,
1441
+ }, {
1442
+ routeId: errorRecord.id,
1443
+ props: {
1444
+ error: normalizedError,
1445
+ routeId: entry.routeId,
1446
+ },
1447
+ routeData: routeDataResult.data,
1448
+ }, documentTemplate, assets);
1449
+ const routePath = routeIdToPath(entry.routeId, entry.params);
1450
+ const htmlPath = routePath
1451
+ ? join(outDir, routePath, "index.html")
1452
+ : join(outDir, "index.html");
1453
+ await writeFileSafe(htmlPath, errorRendered.finalHtml);
1454
+ outputs[routePath || "/"] = errorRendered.finalHtml;
1455
+ continue;
1456
+ }
1457
+ const fallbackHtml = renderStaticErrorDocument(normalizedError?.message ?? "Route data error");
1458
+ const routePath = routeIdToPath(entry.routeId, entry.params);
1459
+ const htmlPath = routePath
1460
+ ? join(outDir, routePath, "index.html")
1461
+ : join(outDir, "index.html");
1462
+ await writeFileSafe(htmlPath, fallbackHtml);
1463
+ outputs[routePath || "/"] = fallbackHtml;
1464
+ continue;
1465
+ }
1466
+ const depsHash = depsHashByEntry?.[entry.routeId] ?? "missing";
1467
+ const propsHash = computePropsHash(entry.props);
1468
+ const { assets } = resolveAssetsForRoute(entry.routeId);
1469
+ const templateHash = computeTemplateHash(documentTemplate, assets);
1470
+ const routeDataHash = computePropsHash(routeDataResult.data);
769
1471
  const renderKey = computeRenderKey({
770
1472
  routeId: entry.routeId,
771
1473
  paramsKey: entry.paramsKey,
772
1474
  templateHash,
773
1475
  depsHash,
774
1476
  propsHash,
1477
+ routeDataHash,
775
1478
  });
776
1479
  if (renderCacheEnabled) {
777
1480
  const cached = await readRenderCache(cacheRoot, {
@@ -780,6 +1483,7 @@ export const build = async (options) => {
780
1483
  templateHash,
781
1484
  depsHash,
782
1485
  propsHash,
1486
+ routeDataHash,
783
1487
  });
784
1488
  if (cached) {
785
1489
  const routePath = routeIdToPath(entry.routeId, entry.params);
@@ -798,6 +1502,7 @@ export const build = async (options) => {
798
1502
  nestedLayouts: config.routing.nestedLayouts,
799
1503
  routeGraph,
800
1504
  rootDir,
1505
+ appModule: specialRouteFiles?.app,
801
1506
  routeRender: (props) => pageModule.default(props),
802
1507
  routeHead: pageModule.head
803
1508
  ? (props) => pageModule.head(props)
@@ -809,7 +1514,12 @@ export const build = async (options) => {
809
1514
  const rendered = await renderHtml({
810
1515
  renderToHtml: (ctx) => routeAdapter.renderToHtml(ctx),
811
1516
  serializeProps: routeAdapter.serializeProps ?? serializeProps,
812
- }, { routeId: entry.routeId, params: entry.params, props: entry.props }, defaultHtmlTemplate, assets);
1517
+ }, {
1518
+ routeId: entry.routeId,
1519
+ params: entry.params,
1520
+ props: entry.props,
1521
+ routeData: routeDataResult.data,
1522
+ }, documentTemplate, assets);
813
1523
  const routePath = routeIdToPath(entry.routeId, entry.params);
814
1524
  const htmlPath = routePath
815
1525
  ? join(outDir, routePath, "index.html")
@@ -823,22 +1533,124 @@ export const build = async (options) => {
823
1533
  renderKey,
824
1534
  templateHash: rendered.templateHash,
825
1535
  propsHash,
1536
+ routeDataHash,
826
1537
  depsHash,
827
1538
  generatedAt: Date.now(),
828
1539
  head: rendered.head,
829
1540
  propsPayload: rendered.propsPayload,
1541
+ routeDataPayload: rendered.routeDataPayload,
830
1542
  status: rendered.status,
831
1543
  headers: rendered.headers,
832
1544
  finalHtml: rendered.finalHtml,
833
1545
  });
834
1546
  }
835
1547
  }
1548
+ if (notFoundRecord && notFoundPageModule) {
1549
+ const notFoundData = await collectRouteDataForRoute({
1550
+ routeId: notFoundRecord.id,
1551
+ routesDir,
1552
+ layoutFiles: notFoundRecord.layoutFiles,
1553
+ pageModule: notFoundPageModule,
1554
+ initServerHook: initServer,
1555
+ loadLayoutModule,
1556
+ });
1557
+ const notFoundAdapter = resolveUIAdapter(config.ui.adapter, {
1558
+ ...config.ui.options,
1559
+ navigationMode: resolvedNavigationMode,
1560
+ clientRenderMode: config.routing.clientRender,
1561
+ nestedLayouts: config.routing.nestedLayouts,
1562
+ routeGraph,
1563
+ rootDir,
1564
+ appModule: specialRouteFiles?.app,
1565
+ routeRender: (props) => notFoundPageModule.default(props),
1566
+ routeHead: notFoundPageModule.head
1567
+ ? (props) => notFoundPageModule.head(props)
1568
+ : undefined,
1569
+ }, adapterRegistry);
1570
+ const { assets } = resolveAssetsForRoute(notFoundRecord.id);
1571
+ const rendered = await renderHtml({
1572
+ renderToHtml: (ctx) => notFoundAdapter.renderToHtml(ctx),
1573
+ serializeProps: notFoundAdapter.serializeProps ?? serializeProps,
1574
+ }, {
1575
+ routeId: notFoundRecord.id,
1576
+ props: {},
1577
+ routeData: notFoundData.data,
1578
+ }, documentTemplate, assets);
1579
+ await writeFileSafe(join(outDir, "404.html"), rendered.finalHtml);
1580
+ outputs["/404"] = rendered.finalHtml;
1581
+ }
836
1582
  return outputs;
837
1583
  },
838
1584
  emitManifest: async (_ctx, state) => {
839
1585
  const routeGraph = state.routeGraph;
840
1586
  const clientBundle = state.clientBundle;
841
1587
  const serverBundle = state.serverBundle;
1588
+ const collectBundlerPlugins = async (input) => {
1589
+ if (!hyperPlugins.length) {
1590
+ return [];
1591
+ }
1592
+ const plugins = [];
1593
+ await runBeforeBundleHooks(hyperPlugins, {
1594
+ phase: "build",
1595
+ entryId: input.entryId,
1596
+ entryPath: input.entryPath,
1597
+ target: input.target,
1598
+ format: input.format,
1599
+ syntaxTarget: input.syntaxTarget,
1600
+ bundler: input.syntaxTarget === "es5" ? "esbuild" : "bun",
1601
+ bundleType: input.bundleType,
1602
+ bundlerPlugins: plugins,
1603
+ addBundlerPlugin: (plugin) => {
1604
+ plugins.push(plugin);
1605
+ },
1606
+ });
1607
+ return plugins;
1608
+ };
1609
+ const special = {};
1610
+ if (specialRouteRecords.notFound) {
1611
+ const clientEntry = clientBundle.entryChunks[specialRouteRecords.notFound.id];
1612
+ if (!clientEntry) {
1613
+ throw new Error("Missing client entry for special notFound route.");
1614
+ }
1615
+ special.notFound = {
1616
+ clientEntry,
1617
+ serverEntry: serverBundle?.entryChunks[specialRouteRecords.notFound.id],
1618
+ html: "404.html",
1619
+ };
1620
+ }
1621
+ if (specialRouteRecords.error) {
1622
+ const clientEntry = clientBundle.entryChunks[specialRouteRecords.error.id];
1623
+ if (!clientEntry) {
1624
+ throw new Error("Missing client entry for special error route.");
1625
+ }
1626
+ special.error = {
1627
+ clientEntry,
1628
+ serverEntry: serverBundle?.entryChunks[specialRouteRecords.error.id],
1629
+ };
1630
+ }
1631
+ const routeStyles = {};
1632
+ for (const route of routeGraph.routes) {
1633
+ if (!routeHasStyles[route.id]) {
1634
+ continue;
1635
+ }
1636
+ const styleFile = resolveBundleStyleFile(clientBundle, route.id);
1637
+ if (styleFile) {
1638
+ routeStyles[route.id] = [styleFile];
1639
+ }
1640
+ }
1641
+ const specialStyles = {};
1642
+ if (specialRouteRecords.notFound && routeHasStyles[specialRouteRecords.notFound.id]) {
1643
+ const styleFile = resolveBundleStyleFile(clientBundle, specialRouteRecords.notFound.id);
1644
+ if (styleFile) {
1645
+ specialStyles.notFound = [styleFile];
1646
+ }
1647
+ }
1648
+ if (specialRouteRecords.error && routeHasStyles[specialRouteRecords.error.id]) {
1649
+ const styleFile = resolveBundleStyleFile(clientBundle, specialRouteRecords.error.id);
1650
+ if (styleFile) {
1651
+ specialStyles.error = [styleFile];
1652
+ }
1653
+ }
842
1654
  const manifest = generateManifest({
843
1655
  version: buildVersion,
844
1656
  basePath: config.basePath,
@@ -849,6 +1661,9 @@ export const build = async (options) => {
849
1661
  assetsDir,
850
1662
  scriptType: clientScriptType,
851
1663
  },
1664
+ routeStyles: Object.keys(routeStyles).length > 0 ? routeStyles : undefined,
1665
+ specialStyles: Object.keys(specialStyles).length > 0 ? specialStyles : undefined,
1666
+ special: Object.keys(special).length > 0 ? special : undefined,
852
1667
  });
853
1668
  await mkdir(join(outDir, assetsDir), { recursive: true });
854
1669
  for (const route of routeGraph.routes) {
@@ -861,13 +1676,84 @@ export const build = async (options) => {
861
1676
  if (!clientChunk || !clientEntryPath) {
862
1677
  throw new Error(`Missing client runtime metadata for route ${route.id}`);
863
1678
  }
1679
+ const styleFile = routeHasStyles[route.id]
1680
+ ? resolveBundleStyleFile(clientBundle, route.id)
1681
+ : null;
1682
+ const cssOutputPath = styleFile ? join(outDir, assetsDir, styleFile) : undefined;
1683
+ const bundlerPlugins = await collectBundlerPlugins({
1684
+ entryId: route.id,
1685
+ entryPath: clientEntryPath,
1686
+ target: "browser",
1687
+ format: clientScriptType === "classic" ? "iife" : "esm",
1688
+ syntaxTarget: isEs5Target ? "es5" : "modern",
1689
+ bundleType: "client",
1690
+ });
864
1691
  await buildChunkFile(clientEntryPath, join(outDir, assetsDir, clientChunk.file), {
865
1692
  target: "browser",
866
1693
  format: clientScriptType === "classic" ? "iife" : "esm",
867
1694
  shimHyperCore: true,
868
1695
  syntaxTarget: isEs5Target ? "es5" : "modern",
1696
+ plugins: bundlerPlugins,
1697
+ emitCss: Boolean(cssOutputPath),
1698
+ cssOutputPath,
869
1699
  });
870
1700
  }
1701
+ if (specialRouteRecords.notFound) {
1702
+ const clientEntryKey = clientBundle.entryChunks[specialRouteRecords.notFound.id];
1703
+ const clientChunk = clientEntryKey ? clientBundle.chunks[clientEntryKey] : null;
1704
+ const clientEntryPath = clientRuntimeEntries.get(specialRouteRecords.notFound.id);
1705
+ if (clientChunk && clientEntryPath) {
1706
+ const styleFile = routeHasStyles[specialRouteRecords.notFound.id]
1707
+ ? resolveBundleStyleFile(clientBundle, specialRouteRecords.notFound.id)
1708
+ : null;
1709
+ const cssOutputPath = styleFile ? join(outDir, assetsDir, styleFile) : undefined;
1710
+ const bundlerPlugins = await collectBundlerPlugins({
1711
+ entryId: specialRouteRecords.notFound.id,
1712
+ entryPath: clientEntryPath,
1713
+ target: "browser",
1714
+ format: clientScriptType === "classic" ? "iife" : "esm",
1715
+ syntaxTarget: isEs5Target ? "es5" : "modern",
1716
+ bundleType: "client",
1717
+ });
1718
+ await buildChunkFile(clientEntryPath, join(outDir, assetsDir, clientChunk.file), {
1719
+ target: "browser",
1720
+ format: clientScriptType === "classic" ? "iife" : "esm",
1721
+ shimHyperCore: true,
1722
+ syntaxTarget: isEs5Target ? "es5" : "modern",
1723
+ plugins: bundlerPlugins,
1724
+ emitCss: Boolean(cssOutputPath),
1725
+ cssOutputPath,
1726
+ });
1727
+ }
1728
+ }
1729
+ if (specialRouteRecords.error) {
1730
+ const clientEntryKey = clientBundle.entryChunks[specialRouteRecords.error.id];
1731
+ const clientChunk = clientEntryKey ? clientBundle.chunks[clientEntryKey] : null;
1732
+ const clientEntryPath = clientRuntimeEntries.get(specialRouteRecords.error.id);
1733
+ if (clientChunk && clientEntryPath) {
1734
+ const styleFile = routeHasStyles[specialRouteRecords.error.id]
1735
+ ? resolveBundleStyleFile(clientBundle, specialRouteRecords.error.id)
1736
+ : null;
1737
+ const cssOutputPath = styleFile ? join(outDir, assetsDir, styleFile) : undefined;
1738
+ const bundlerPlugins = await collectBundlerPlugins({
1739
+ entryId: specialRouteRecords.error.id,
1740
+ entryPath: clientEntryPath,
1741
+ target: "browser",
1742
+ format: clientScriptType === "classic" ? "iife" : "esm",
1743
+ syntaxTarget: isEs5Target ? "es5" : "modern",
1744
+ bundleType: "client",
1745
+ });
1746
+ await buildChunkFile(clientEntryPath, join(outDir, assetsDir, clientChunk.file), {
1747
+ target: "browser",
1748
+ format: clientScriptType === "classic" ? "iife" : "esm",
1749
+ shimHyperCore: true,
1750
+ syntaxTarget: isEs5Target ? "es5" : "modern",
1751
+ plugins: bundlerPlugins,
1752
+ emitCss: Boolean(cssOutputPath),
1753
+ cssOutputPath,
1754
+ });
1755
+ }
1756
+ }
871
1757
  if (serverBundle) {
872
1758
  for (const route of routeGraph.routes) {
873
1759
  const serverEntryKey = serverBundle.entryChunks[route.id];
@@ -879,13 +1765,66 @@ export const build = async (options) => {
879
1765
  if (!serverChunk || !serverEntryPath) {
880
1766
  throw new Error(`Missing server runtime metadata for route ${route.id}`);
881
1767
  }
1768
+ const bundlerPlugins = await collectBundlerPlugins({
1769
+ entryId: route.id,
1770
+ entryPath: serverEntryPath,
1771
+ target: "node",
1772
+ format: "cjs",
1773
+ syntaxTarget: isEs5Target ? "es5" : "modern",
1774
+ bundleType: "server",
1775
+ });
882
1776
  await buildChunkFile(serverEntryPath, join(outDir, assetsDir, serverChunk.file), {
883
1777
  target: "node",
884
1778
  format: "cjs",
885
1779
  shimHyperCore: false,
886
1780
  syntaxTarget: isEs5Target ? "es5" : "modern",
1781
+ plugins: bundlerPlugins,
887
1782
  });
888
1783
  }
1784
+ if (specialRouteRecords.notFound) {
1785
+ const serverEntryKey = serverBundle.entryChunks[specialRouteRecords.notFound.id];
1786
+ const serverChunk = serverEntryKey ? serverBundle.chunks[serverEntryKey] : null;
1787
+ const serverEntryPath = serverRuntimeEntries.get(specialRouteRecords.notFound.id);
1788
+ if (serverChunk && serverEntryPath) {
1789
+ const bundlerPlugins = await collectBundlerPlugins({
1790
+ entryId: specialRouteRecords.notFound.id,
1791
+ entryPath: serverEntryPath,
1792
+ target: "node",
1793
+ format: "cjs",
1794
+ syntaxTarget: isEs5Target ? "es5" : "modern",
1795
+ bundleType: "server",
1796
+ });
1797
+ await buildChunkFile(serverEntryPath, join(outDir, assetsDir, serverChunk.file), {
1798
+ target: "node",
1799
+ format: "cjs",
1800
+ shimHyperCore: false,
1801
+ syntaxTarget: isEs5Target ? "es5" : "modern",
1802
+ plugins: bundlerPlugins,
1803
+ });
1804
+ }
1805
+ }
1806
+ if (specialRouteRecords.error) {
1807
+ const serverEntryKey = serverBundle.entryChunks[specialRouteRecords.error.id];
1808
+ const serverChunk = serverEntryKey ? serverBundle.chunks[serverEntryKey] : null;
1809
+ const serverEntryPath = serverRuntimeEntries.get(specialRouteRecords.error.id);
1810
+ if (serverChunk && serverEntryPath) {
1811
+ const bundlerPlugins = await collectBundlerPlugins({
1812
+ entryId: specialRouteRecords.error.id,
1813
+ entryPath: serverEntryPath,
1814
+ target: "node",
1815
+ format: "cjs",
1816
+ syntaxTarget: isEs5Target ? "es5" : "modern",
1817
+ bundleType: "server",
1818
+ });
1819
+ await buildChunkFile(serverEntryPath, join(outDir, assetsDir, serverChunk.file), {
1820
+ target: "node",
1821
+ format: "cjs",
1822
+ shimHyperCore: false,
1823
+ syntaxTarget: isEs5Target ? "es5" : "modern",
1824
+ plugins: bundlerPlugins,
1825
+ });
1826
+ }
1827
+ }
889
1828
  }
890
1829
  await writeFileSafe(join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
891
1830
  if (legacyEnabled && legacyBundle) {