@useavalon/avalon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +54 -0
  2. package/mod.ts +301 -0
  3. package/package.json +85 -0
  4. package/src/build/README.md +310 -0
  5. package/src/build/integration-bundler-plugin.ts +116 -0
  6. package/src/build/integration-config.ts +168 -0
  7. package/src/build/integration-detection-plugin.ts +117 -0
  8. package/src/build/integration-resolver-plugin.ts +90 -0
  9. package/src/build/island-manifest.ts +269 -0
  10. package/src/build/island-types-generator.ts +476 -0
  11. package/src/build/mdx-island-transform.ts +464 -0
  12. package/src/build/mdx-plugin.ts +98 -0
  13. package/src/build/page-island-transform.ts +598 -0
  14. package/src/build/prop-extractors/index.ts +21 -0
  15. package/src/build/prop-extractors/lit.ts +140 -0
  16. package/src/build/prop-extractors/qwik.ts +16 -0
  17. package/src/build/prop-extractors/solid.ts +125 -0
  18. package/src/build/prop-extractors/svelte.ts +194 -0
  19. package/src/build/prop-extractors/vue.ts +111 -0
  20. package/src/build/sidecar-file-manager.ts +104 -0
  21. package/src/build/sidecar-renderer.ts +30 -0
  22. package/src/client/adapters/index.ts +13 -0
  23. package/src/client/adapters/lit-adapter.ts +654 -0
  24. package/src/client/adapters/preact-adapter.ts +331 -0
  25. package/src/client/adapters/qwik-adapter.ts +345 -0
  26. package/src/client/adapters/react-adapter.ts +353 -0
  27. package/src/client/adapters/solid-adapter.ts +451 -0
  28. package/src/client/adapters/svelte-adapter.ts +524 -0
  29. package/src/client/adapters/vue-adapter.ts +467 -0
  30. package/src/client/components.ts +35 -0
  31. package/src/client/css-hmr-handler.ts +344 -0
  32. package/src/client/framework-adapter.ts +462 -0
  33. package/src/client/hmr-coordinator.ts +396 -0
  34. package/src/client/hmr-error-overlay.js +533 -0
  35. package/src/client/main.js +816 -0
  36. package/src/client/tests/css-hmr-handler.test.ts +360 -0
  37. package/src/client/tests/framework-adapter.test.ts +519 -0
  38. package/src/client/tests/hmr-coordinator.test.ts +176 -0
  39. package/src/client/tests/hydration-option-parsing.test.ts +107 -0
  40. package/src/client/tests/lit-adapter.test.ts +427 -0
  41. package/src/client/tests/preact-adapter.test.ts +353 -0
  42. package/src/client/tests/qwik-adapter.test.ts +343 -0
  43. package/src/client/tests/react-adapter.test.ts +317 -0
  44. package/src/client/tests/solid-adapter.test.ts +396 -0
  45. package/src/client/tests/svelte-adapter.test.ts +387 -0
  46. package/src/client/tests/vue-adapter.test.ts +407 -0
  47. package/src/client/types/framework-runtime.d.ts +68 -0
  48. package/src/client/types/vite-hmr.d.ts +46 -0
  49. package/src/client/types/vite-virtual-modules.d.ts +60 -0
  50. package/src/components/Image.tsx +123 -0
  51. package/src/components/IslandErrorBoundary.tsx +145 -0
  52. package/src/components/LayoutDataErrorBoundary.tsx +141 -0
  53. package/src/components/LayoutErrorBoundary.tsx +127 -0
  54. package/src/components/PersistentIsland.tsx +52 -0
  55. package/src/components/StreamingErrorBoundary.tsx +233 -0
  56. package/src/components/StreamingLayout.tsx +538 -0
  57. package/src/components/tests/component-analyzer.test.ts +96 -0
  58. package/src/components/tests/component-detection.test.ts +347 -0
  59. package/src/components/tests/persistent-islands.test.ts +398 -0
  60. package/src/core/components/component-analyzer.ts +192 -0
  61. package/src/core/components/component-detection.ts +508 -0
  62. package/src/core/components/enhanced-framework-detector.ts +500 -0
  63. package/src/core/components/framework-registry.ts +563 -0
  64. package/src/core/components/tests/enhanced-framework-detector.test.ts +577 -0
  65. package/src/core/components/tests/framework-registry.test.ts +465 -0
  66. package/src/core/content/mdx-processor.ts +46 -0
  67. package/src/core/integrations/README.md +282 -0
  68. package/src/core/integrations/index.ts +19 -0
  69. package/src/core/integrations/loader.ts +125 -0
  70. package/src/core/integrations/registry.ts +195 -0
  71. package/src/core/islands/island-persistence.ts +325 -0
  72. package/src/core/islands/island-state-serializer.ts +258 -0
  73. package/src/core/islands/persistent-island-context.tsx +80 -0
  74. package/src/core/islands/use-persistent-state.ts +68 -0
  75. package/src/core/layout/enhanced-layout-resolver.ts +322 -0
  76. package/src/core/layout/layout-cache-manager.ts +485 -0
  77. package/src/core/layout/layout-composer.ts +357 -0
  78. package/src/core/layout/layout-data-loader.ts +516 -0
  79. package/src/core/layout/layout-discovery.ts +243 -0
  80. package/src/core/layout/layout-matcher.ts +299 -0
  81. package/src/core/layout/layout-types.ts +110 -0
  82. package/src/core/layout/tests/enhanced-layout-resolver.test.ts +477 -0
  83. package/src/core/layout/tests/layout-cache-optimization.test.ts +149 -0
  84. package/src/core/layout/tests/layout-composer.test.ts +486 -0
  85. package/src/core/layout/tests/layout-data-loader.test.ts +443 -0
  86. package/src/core/layout/tests/layout-discovery.test.ts +253 -0
  87. package/src/core/layout/tests/layout-matcher.test.ts +480 -0
  88. package/src/core/modules/framework-module-resolver.ts +273 -0
  89. package/src/core/modules/tests/framework-module-resolver.test.ts +263 -0
  90. package/src/core/modules/tests/module-resolution-integration.test.ts +117 -0
  91. package/src/islands/component-analysis.ts +213 -0
  92. package/src/islands/css-utils.ts +565 -0
  93. package/src/islands/discovery/index.ts +80 -0
  94. package/src/islands/discovery/registry.ts +340 -0
  95. package/src/islands/discovery/resolver.ts +477 -0
  96. package/src/islands/discovery/scanner.ts +386 -0
  97. package/src/islands/discovery/tests/island-discovery.test.ts +881 -0
  98. package/src/islands/discovery/types.ts +117 -0
  99. package/src/islands/discovery/validator.ts +544 -0
  100. package/src/islands/discovery/watcher.ts +368 -0
  101. package/src/islands/framework-detection.ts +428 -0
  102. package/src/islands/integration-loader.ts +490 -0
  103. package/src/islands/island.tsx +565 -0
  104. package/src/islands/render-cache.ts +550 -0
  105. package/src/islands/types.ts +80 -0
  106. package/src/islands/universal-css-collector.ts +157 -0
  107. package/src/islands/universal-head-collector.ts +137 -0
  108. package/src/layout-system.d.ts +592 -0
  109. package/src/layout-system.ts +218 -0
  110. package/src/middleware/__tests__/discovery.test.ts +107 -0
  111. package/src/middleware/discovery.ts +268 -0
  112. package/src/middleware/executor.ts +315 -0
  113. package/src/middleware/index.ts +76 -0
  114. package/src/middleware/types.ts +99 -0
  115. package/src/nitro/build-config.ts +576 -0
  116. package/src/nitro/config.ts +483 -0
  117. package/src/nitro/error-handler.ts +636 -0
  118. package/src/nitro/index.ts +173 -0
  119. package/src/nitro/island-manifest.ts +584 -0
  120. package/src/nitro/middleware-adapter.ts +260 -0
  121. package/src/nitro/renderer.ts +1458 -0
  122. package/src/nitro/route-discovery.ts +439 -0
  123. package/src/nitro/types.ts +321 -0
  124. package/src/render/collect-css.ts +198 -0
  125. package/src/render/error-pages.ts +79 -0
  126. package/src/render/isolated-ssr-renderer.ts +654 -0
  127. package/src/render/ssr.ts +1030 -0
  128. package/src/schemas/api.ts +30 -0
  129. package/src/schemas/core.ts +64 -0
  130. package/src/schemas/index.ts +212 -0
  131. package/src/schemas/layout.ts +279 -0
  132. package/src/schemas/routing/index.ts +38 -0
  133. package/src/schemas/routing.ts +376 -0
  134. package/src/types/as-island.ts +20 -0
  135. package/src/types/image.d.ts +106 -0
  136. package/src/types/index.d.ts +22 -0
  137. package/src/types/island-jsx.d.ts +33 -0
  138. package/src/types/island-prop.d.ts +20 -0
  139. package/src/types/layout.ts +285 -0
  140. package/src/types/mdx.d.ts +6 -0
  141. package/src/types/routing.ts +555 -0
  142. package/src/types/tests/layout-types.test.ts +197 -0
  143. package/src/types/types.ts +5 -0
  144. package/src/types/urlpattern.d.ts +49 -0
  145. package/src/types/vite-env.d.ts +11 -0
  146. package/src/utils/dev-logger.ts +299 -0
  147. package/src/utils/fs.ts +151 -0
  148. package/src/vite-plugin/auto-discover.ts +551 -0
  149. package/src/vite-plugin/config.ts +266 -0
  150. package/src/vite-plugin/errors.ts +127 -0
  151. package/src/vite-plugin/image-optimization.ts +151 -0
  152. package/src/vite-plugin/integration-activator.ts +126 -0
  153. package/src/vite-plugin/island-sidecar-plugin.ts +176 -0
  154. package/src/vite-plugin/module-discovery.ts +189 -0
  155. package/src/vite-plugin/nitro-integration.ts +1334 -0
  156. package/src/vite-plugin/plugin.ts +329 -0
  157. package/src/vite-plugin/tests/image-optimization.test.ts +54 -0
  158. package/src/vite-plugin/types.ts +327 -0
  159. package/src/vite-plugin/validation.ts +228 -0
@@ -0,0 +1,1334 @@
1
+ /**
2
+ * Nitro Integration Module for Avalon Vite Plugin
3
+ *
4
+ * Provides coordination between Avalon's Vite plugin and Nitro:
5
+ * - API routes: Auto-discovered by Nitro from `api/` directory
6
+ * - Page routes: Virtual module for SSR page component discovery
7
+ * - Middleware: Auto-discovered by Nitro from `middleware/` directory
8
+ */
9
+
10
+ import type { Plugin, ViteDevServer } from 'vite';
11
+ import { nitro as nitroVitePlugin } from 'nitro/vite';
12
+ import { stat as fsStat } from 'node:fs/promises';
13
+ import type { ResolvedAvalonConfig } from './types.ts';
14
+ import { createNitroConfig, type AvalonNitroConfig, type NitroConfigOutput } from '../nitro/config.ts';
15
+ import type { PageModule } from '../nitro/types.ts';
16
+ import {
17
+ createNitroBuildPlugin,
18
+ createIslandManifestPlugin,
19
+ createSourceMapPlugin,
20
+ createSourceMapConfig,
21
+ } from '../nitro/index.ts';
22
+ import { discoverScopedMiddleware, executeScopedMiddleware, clearMiddlewareCache } from '../middleware/index.ts';
23
+ import type { MiddlewareRoute } from '../middleware/types.ts';
24
+ import type { H3Event } from 'h3';
25
+ import { generateErrorPage, generateFallback404 } from '../render/error-pages.ts';
26
+ import { collectCssFromModuleGraph, injectSsrCss } from '../render/collect-css.ts';
27
+ import { getUniversalCSSForHead } from '../islands/universal-css-collector.ts';
28
+ import { getUniversalHeadForInjection } from '../islands/universal-head-collector.ts';
29
+
30
+ export const VIRTUAL_MODULE_IDS = {
31
+ PAGE_ROUTES: 'virtual:avalon/page-routes',
32
+ ISLAND_MANIFEST: 'virtual:avalon/island-manifest',
33
+ RUNTIME_CONFIG: 'virtual:avalon/runtime-config',
34
+ CONFIG: 'virtual:avalon/config',
35
+ } as const;
36
+
37
+ export const RESOLVED_VIRTUAL_IDS = {
38
+ PAGE_ROUTES: '\0' + VIRTUAL_MODULE_IDS.PAGE_ROUTES,
39
+ ISLAND_MANIFEST: '\0' + VIRTUAL_MODULE_IDS.ISLAND_MANIFEST,
40
+ RUNTIME_CONFIG: '\0' + VIRTUAL_MODULE_IDS.RUNTIME_CONFIG,
41
+ CONFIG: '\0' + VIRTUAL_MODULE_IDS.CONFIG,
42
+ } as const;
43
+
44
+ export interface NitroIntegrationResult {
45
+ nitroOptions: NitroConfigOutput;
46
+ plugins: Plugin[];
47
+ }
48
+
49
+ export interface NitroCoordinationPluginOptions {
50
+ avalonConfig: ResolvedAvalonConfig;
51
+ nitroConfig: AvalonNitroConfig;
52
+ verbose?: boolean;
53
+ }
54
+
55
+ /**
56
+ * Creates the Nitro integration for Avalon — configuration, virtual modules,
57
+ * build plugins, and SSR coordination.
58
+ *
59
+ * Uses the Nitro v3 Vite plugin from `nitro/vite` for server route discovery,
60
+ * SSR rendering pipeline, and Rolldown-optimized bundling.
61
+ */
62
+ export function createNitroIntegration(
63
+ avalonConfig: ResolvedAvalonConfig,
64
+ nitroConfig: AvalonNitroConfig = {},
65
+ ): NitroIntegrationResult {
66
+ const nitroOptions = createNitroConfig(nitroConfig, avalonConfig);
67
+
68
+ // Nitro v3 Vite plugin — only pass keys that Nitro actually accepts.
69
+ // Spreading the full nitroOptions leaks Avalon-specific keys (staticAssets,
70
+ // publicAssets, etc.) which Nitro forwards to Rolldown, causing
71
+ // "Invalid input options" warnings (e.g. "jsx" key errors).
72
+ const nitroVitePluginOptions: Record<string, unknown> = {
73
+ preset: nitroOptions.preset,
74
+ serverDir: nitroConfig.serverDir ?? nitroOptions.serverDir ?? './server',
75
+ routeRules: nitroOptions.routeRules,
76
+ runtimeConfig: nitroOptions.runtimeConfig,
77
+ renderer: nitroConfig.renderer === false ? false : nitroOptions.renderer,
78
+ compatibilityDate: nitroOptions.compatibilityDate,
79
+ // Tell Nitro to scan the project root so it discovers routes/ and middleware/
80
+ // alongside the serverDir (./server) which contains the catch-all renderer.
81
+ scanDirs: ['.'],
82
+ };
83
+
84
+ // Only include optional keys if they're defined
85
+ if (nitroOptions.publicRuntimeConfig) {
86
+ nitroVitePluginOptions.publicRuntimeConfig = nitroOptions.publicRuntimeConfig;
87
+ }
88
+ if (nitroOptions.publicAssets) {
89
+ nitroVitePluginOptions.publicAssets = nitroOptions.publicAssets;
90
+ }
91
+ if (nitroOptions.compressPublicAssets) {
92
+ nitroVitePluginOptions.compressPublicAssets = nitroOptions.compressPublicAssets;
93
+ }
94
+ if (nitroOptions.serverEntry) {
95
+ nitroVitePluginOptions.serverEntry = nitroOptions.serverEntry;
96
+ }
97
+
98
+ const nitroPlugin = nitroVitePlugin(nitroVitePluginOptions);
99
+
100
+ const coordinationPlugin = createNitroCoordinationPlugin({
101
+ avalonConfig,
102
+ nitroConfig,
103
+ verbose: avalonConfig.verbose,
104
+ });
105
+
106
+ const virtualModulesPlugin = createVirtualModulesPlugin({
107
+ avalonConfig,
108
+ nitroConfig,
109
+ verbose: avalonConfig.verbose,
110
+ });
111
+
112
+ const buildPlugin = createNitroBuildPlugin(avalonConfig, nitroConfig);
113
+
114
+ const manifestPlugin = createIslandManifestPlugin(avalonConfig, {
115
+ verbose: avalonConfig.verbose,
116
+ generatePreloadHints: true,
117
+ });
118
+
119
+ const sourceMapConfig = createSourceMapConfig(nitroConfig.preset ?? 'node_server', avalonConfig.isDev);
120
+ const sourceMapPlugin = createSourceMapPlugin(sourceMapConfig);
121
+
122
+ return {
123
+ nitroOptions,
124
+ plugins: [
125
+ ...(Array.isArray(nitroPlugin) ? nitroPlugin : [nitroPlugin]),
126
+ coordinationPlugin,
127
+ virtualModulesPlugin,
128
+ buildPlugin,
129
+ manifestPlugin,
130
+ sourceMapPlugin,
131
+ ],
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Coordination plugin: stores config/server refs, sets up SSR middleware and HMR,
137
+ * and prewarms core infrastructure modules (fire-and-forget).
138
+ */
139
+ export function createNitroCoordinationPlugin(options: NitroCoordinationPluginOptions): Plugin {
140
+ const { avalonConfig, verbose } = options;
141
+
142
+ return {
143
+ name: 'avalon:nitro-coordination',
144
+ enforce: 'pre',
145
+
146
+ configResolved(_config) {
147
+ globalThis.__avalonConfig = avalonConfig;
148
+ },
149
+
150
+ configureServer(server: ViteDevServer) {
151
+ globalThis.__viteDevServer = server;
152
+
153
+ // Scoped middleware — discovered once, cached until invalidated by HMR
154
+ let scopedMiddlewareRoutes: MiddlewareRoute[] | null = null;
155
+
156
+ async function getScopedMiddleware(): Promise<MiddlewareRoute[]> {
157
+ if (!scopedMiddlewareRoutes) {
158
+ const viteRoot = server.config.root || process.cwd();
159
+ scopedMiddlewareRoutes = await discoverScopedMiddleware({
160
+ baseDir: `${viteRoot}/src`,
161
+ devMode: false,
162
+ });
163
+ }
164
+ return scopedMiddlewareRoutes;
165
+ }
166
+
167
+ function clearScopedMiddlewareRoutes(): void {
168
+ scopedMiddlewareRoutes = null;
169
+ }
170
+
171
+ setupHMRCoordination(server, avalonConfig, verbose, clearScopedMiddlewareRoutes);
172
+
173
+ // Pre-discover middleware (non-blocking)
174
+ getScopedMiddleware().catch(err => {
175
+ console.warn('[middleware] Failed to discover middleware:', err);
176
+ });
177
+
178
+ // Fire-and-forget: prewarm only core infrastructure modules.
179
+ // Pages, islands, and per-route middleware are loaded on-demand.
180
+ prewarmCoreModules(server, verbose).catch(err => {
181
+ console.error('[prewarm] Core modules pre-warm failed:', err);
182
+ });
183
+
184
+ // SSR middleware — runs before Vite's SPA fallback
185
+ server.middlewares.use(async (req, res, next) => {
186
+ const originalUrl = req.url || '/';
187
+ let url = originalUrl;
188
+
189
+ if (url.endsWith('.html')) url = url.slice(0, -5) || '/';
190
+ if (url === '/index') url = '/';
191
+
192
+ // Skip static files, HMR, and Vite internals
193
+ if (
194
+ url.startsWith('/@') ||
195
+ url.startsWith('/__') ||
196
+ url.startsWith('/node_modules/') ||
197
+ url.startsWith('/src/client/') ||
198
+ url.startsWith('/packages/') ||
199
+ (url.includes('.') && !url.endsWith('/'))
200
+ ) {
201
+ return next();
202
+ }
203
+
204
+ if (url.startsWith('/api/')) {
205
+ return next();
206
+ }
207
+
208
+ try {
209
+ const middlewareHandled = await handleScopedMiddleware(server, url, req, res, getScopedMiddleware, verbose);
210
+ if (middlewareHandled) return;
211
+
212
+ // Try streaming SSR first (streams shell before page data resolves)
213
+ const streamed = await handleStreamingSSRRequest(server, url, avalonConfig, res);
214
+ if (streamed) return;
215
+
216
+ // Fallback to buffered SSR for non-modular pages
217
+ const html = await handleSSRRequest(server, url, avalonConfig);
218
+ if (html) {
219
+ res.statusCode = 200;
220
+ res.setHeader('Content-Type', 'text/html');
221
+ res.end(html);
222
+ return;
223
+ }
224
+
225
+ await handle404(server, url, res, avalonConfig);
226
+ } catch (error) {
227
+ console.error('[SSR Error]', error);
228
+ res.statusCode = 500;
229
+ res.setHeader('Content-Type', 'text/html');
230
+ res.end(generateErrorPage(error as Error));
231
+ }
232
+ });
233
+ },
234
+
235
+ buildStart() {
236
+ // no-op in production — coordination happens via other plugins
237
+ },
238
+ };
239
+ }
240
+
241
+ // ─── Dev Server Middleware Helpers ───────────────────────────────────────────
242
+
243
+ import type { ServerResponse, IncomingMessage } from 'node:http';
244
+
245
+ async function handleScopedMiddleware(
246
+ server: ViteDevServer,
247
+ url: string,
248
+ req: IncomingMessage,
249
+ res: ServerResponse,
250
+ getScopedMiddleware: () => Promise<MiddlewareRoute[]>,
251
+ verbose?: boolean,
252
+ ): Promise<boolean> {
253
+ const middlewareStart = performance.now();
254
+ const middlewareRoutes = await getScopedMiddleware();
255
+ if (middlewareRoutes.length === 0) return false;
256
+
257
+ const headers: Record<string, string> = {};
258
+ for (const [key, value] of Object.entries(req.headers)) {
259
+ if (typeof value === 'string') headers[key] = value;
260
+ else if (Array.isArray(value)) headers[key] = value.join(', ');
261
+ }
262
+
263
+ const fullUrl = `http://${req.headers.host || 'localhost'}${url}`;
264
+ const h3Event = {
265
+ url: fullUrl,
266
+ method: req.method || 'GET',
267
+ path: url,
268
+ node: { req, res },
269
+ req: new Request(fullUrl, {
270
+ method: req.method || 'GET',
271
+ headers,
272
+ }),
273
+ context: {} as Record<string, unknown>,
274
+ } as unknown as H3Event;
275
+
276
+ const middlewareResponse = await executeScopedMiddleware(h3Event, middlewareRoutes, { devMode: false });
277
+
278
+ const middlewareTime = performance.now() - middlewareStart;
279
+ if (middlewareTime > 100) {
280
+ console.warn(`⚠️ Slow middleware: ${middlewareTime.toFixed(0)}ms for ${url}`);
281
+ }
282
+
283
+ if (middlewareResponse) {
284
+ res.statusCode = middlewareResponse.status;
285
+ middlewareResponse.headers.forEach((value, key) => {
286
+ res.setHeader(key, value);
287
+ });
288
+ res.end(await middlewareResponse.text());
289
+ return true;
290
+ }
291
+ return false;
292
+ }
293
+
294
+ async function handle404(
295
+ server: ViteDevServer,
296
+ url: string,
297
+ res: ServerResponse,
298
+ config: ResolvedAvalonConfig,
299
+ ): Promise<void> {
300
+ try {
301
+ const { discoverErrorPages, getErrorPageModule, generateDefaultErrorPage } =
302
+ await import('../nitro/error-handler.ts');
303
+ const errorPages = await discoverErrorPages({
304
+ isDev: config.isDev,
305
+ pagesDir: config.pagesDir,
306
+ loadPageModule: async (filePath: string): Promise<PageModule> => {
307
+ return (await server.ssrLoadModule(filePath)) as PageModule;
308
+ },
309
+ });
310
+ const errorPageModule = getErrorPageModule(404, errorPages);
311
+
312
+ if (errorPageModule?.default && typeof errorPageModule.default === 'function') {
313
+ const { renderToHtml } = await import('../render/ssr.ts');
314
+ const ErrorPageComponent = errorPageModule.default;
315
+ const errorHtml = await renderToHtml(
316
+ { component: () => ErrorPageComponent({ statusCode: 404, message: `Page not found: ${url}`, url }) },
317
+ {},
318
+ );
319
+ res.statusCode = 404;
320
+ res.setHeader('Content-Type', 'text/html');
321
+ res.end(errorHtml);
322
+ return;
323
+ }
324
+
325
+ const fallbackHtml = generateDefaultErrorPage(404, `Page not found: ${url}`, config.isDev);
326
+ res.statusCode = 404;
327
+ res.setHeader('Content-Type', 'text/html');
328
+ res.end(fallbackHtml);
329
+ } catch {
330
+ res.statusCode = 404;
331
+ res.setHeader('Content-Type', 'text/html');
332
+ res.end(generateFallback404(url));
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Prewarms core infrastructure modules (fire-and-forget).
338
+ * Loads SSR infrastructure and framework renderers so the first page render
339
+ * doesn't pay the full module-load cost. Island components are loaded on-demand
340
+ * to avoid penalizing startup with unused modules.
341
+ */
342
+ async function prewarmCoreModules(server: ViteDevServer, verbose?: boolean): Promise<void> {
343
+ const prewarmStart = performance.now();
344
+
345
+ const coreModules = [
346
+ // SSR infrastructure
347
+ { path: '../packages/avalon/src/render/ssr.ts', assignTo: 'ssr' },
348
+ { path: '../packages/avalon/src/core/layout/enhanced-layout-resolver.ts', assignTo: 'layout' },
349
+ { path: '../packages/avalon/src/middleware/index.ts', assignTo: null },
350
+ // Framework renderers — prewarm so first island render is fast
351
+ { path: '../packages/integrations/react/server/renderer.ts', assignTo: null },
352
+ { path: '../packages/integrations/vue/server/renderer.ts', assignTo: null },
353
+ { path: '../packages/integrations/solid/server/renderer.ts', assignTo: null },
354
+ { path: '../packages/integrations/svelte/server/renderer.ts', assignTo: null },
355
+ { path: '../packages/integrations/lit/server/renderer.ts', assignTo: null },
356
+ { path: '../packages/integrations/preact/server/renderer.ts', assignTo: null },
357
+ ];
358
+
359
+ const results = await Promise.allSettled(
360
+ coreModules.map(async ({ path, assignTo }) => {
361
+ const mod = await server.ssrLoadModule(path);
362
+ if (assignTo === 'ssr') cachedSSRModule = mod;
363
+ if (assignTo === 'layout') cachedLayoutModule = mod;
364
+ }),
365
+ );
366
+
367
+ const succeeded = results.filter(r => r.status === 'fulfilled').length;
368
+ const totalTime = performance.now() - prewarmStart;
369
+
370
+ if (verbose && succeeded > 0) {
371
+ console.log(`🔥 SSR ready in ${totalTime.toFixed(0)}ms (${succeeded}/${coreModules.length} core modules)`);
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Virtual modules plugin — page routes, island manifest, runtime config.
377
+ */
378
+ export function createVirtualModulesPlugin(options: NitroCoordinationPluginOptions): Plugin {
379
+ const { avalonConfig, nitroConfig, verbose } = options;
380
+
381
+ return {
382
+ name: 'avalon:nitro-virtual-modules',
383
+ enforce: 'pre',
384
+
385
+ resolveId(id: string) {
386
+ if (id === VIRTUAL_MODULE_IDS.PAGE_ROUTES) return RESOLVED_VIRTUAL_IDS.PAGE_ROUTES;
387
+ if (id === VIRTUAL_MODULE_IDS.ISLAND_MANIFEST) return RESOLVED_VIRTUAL_IDS.ISLAND_MANIFEST;
388
+ if (id === VIRTUAL_MODULE_IDS.RUNTIME_CONFIG) return RESOLVED_VIRTUAL_IDS.RUNTIME_CONFIG;
389
+ if (id === VIRTUAL_MODULE_IDS.CONFIG) return RESOLVED_VIRTUAL_IDS.CONFIG;
390
+ return null;
391
+ },
392
+
393
+ async load(id: string) {
394
+ if (id === RESOLVED_VIRTUAL_IDS.PAGE_ROUTES) return await generatePageRoutesModule(avalonConfig, verbose);
395
+ if (id === RESOLVED_VIRTUAL_IDS.ISLAND_MANIFEST) return generateIslandManifestModule();
396
+ if (id === RESOLVED_VIRTUAL_IDS.RUNTIME_CONFIG) return generateRuntimeConfigModule(avalonConfig, nitroConfig);
397
+ if (id === RESOLVED_VIRTUAL_IDS.CONFIG) return generateConfigModule(avalonConfig, nitroConfig);
398
+ return null;
399
+ },
400
+
401
+ handleHotUpdate({ file, server }) {
402
+ if (file.includes(avalonConfig.pagesDir)) {
403
+ const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_IDS.PAGE_ROUTES);
404
+ if (mod) server.moduleGraph.invalidateModule(mod);
405
+ }
406
+ // Invalidate virtual:avalon/config when config-related files change
407
+ if (file.includes('vite.config') || file.includes('avalon.config') || file.includes('nitro.config')) {
408
+ const configMod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_IDS.CONFIG);
409
+ if (configMod) server.moduleGraph.invalidateModule(configMod);
410
+ }
411
+ return undefined;
412
+ },
413
+ };
414
+ }
415
+
416
+ // ─── HMR Coordination ───────────────────────────────────────────────────────
417
+
418
+ function setupHMRCoordination(
419
+ server: ViteDevServer,
420
+ _config: ResolvedAvalonConfig,
421
+ _verbose?: boolean,
422
+ clearScopedMiddlewareRoutes?: () => void,
423
+ ): void {
424
+ server.watcher.on('change', file => {
425
+ if (file.includes('_middleware')) {
426
+ clearMiddlewareCache();
427
+ clearScopedMiddlewareRoutes?.();
428
+ }
429
+ if (file.includes('/render/') || file.includes('/layout/') || file.includes('/islands/')) {
430
+ cachedSSRModule = null;
431
+ cachedLayoutModule = null;
432
+ }
433
+
434
+ if (file.includes('/layouts/') || file.includes('_layout')) {
435
+ const resolver = globalThis.__avalonLayoutResolver as { clearCache?: () => void } | undefined;
436
+ resolver?.clearCache?.();
437
+ }
438
+ });
439
+
440
+ server.watcher.on('add', file => {
441
+ if (file.includes('_middleware')) {
442
+ clearMiddlewareCache();
443
+ clearScopedMiddlewareRoutes?.();
444
+ }
445
+ });
446
+
447
+ server.watcher.on('unlink', file => {
448
+ if (file.includes('_middleware')) {
449
+ clearMiddlewareCache();
450
+ clearScopedMiddlewareRoutes?.();
451
+ }
452
+ });
453
+ }
454
+
455
+ // ─── Virtual Module Generators ───────────────────────────────────────────────
456
+
457
+ async function generatePageRoutesModule(config: ResolvedAvalonConfig, _verbose?: boolean): Promise<string> {
458
+ try {
459
+ const { getAllPageDirs } = await import('./module-discovery.ts');
460
+ const { discoverPageRoutesFromMultipleDirs } = await import('../nitro/route-discovery.ts');
461
+
462
+ // Get all page directories (traditional + modular)
463
+ const pageDirs = await getAllPageDirs(
464
+ config.pagesDir,
465
+ config.modules,
466
+ process.cwd()
467
+ );
468
+
469
+ const routes = await discoverPageRoutesFromMultipleDirs(pageDirs, {
470
+ developmentMode: config.isDev,
471
+ });
472
+
473
+ const routesJson = JSON.stringify(routes, null, 2);
474
+ return `export const pageRoutes = ${routesJson};\nexport default pageRoutes;\n`;
475
+ } catch {
476
+ return `export const pageRoutes = [];\nexport default pageRoutes;\n`;
477
+ }
478
+ }
479
+
480
+ function generateIslandManifestModule(): string {
481
+ return `export const islandManifest = { islands: {}, clientEntry: "", css: [] };\nexport default islandManifest;\n`;
482
+ }
483
+
484
+ function generateRuntimeConfigModule(avalonConfig: ResolvedAvalonConfig, nitroConfig: AvalonNitroConfig): string {
485
+ const runtimeConfig = {
486
+ avalon: {
487
+ streaming: nitroConfig.streaming ?? true,
488
+ pagesDir: avalonConfig.pagesDir,
489
+ layoutsDir: avalonConfig.layoutsDir,
490
+ isDev: avalonConfig.isDev,
491
+ },
492
+ ...nitroConfig.runtimeConfig,
493
+ };
494
+ return `export const runtimeConfig = ${JSON.stringify(runtimeConfig, null, 2)};\nexport function useRuntimeConfig() { return runtimeConfig; }\nexport default runtimeConfig;\n`;
495
+ }
496
+
497
+ export function generateConfigModule(avalonConfig: ResolvedAvalonConfig, nitroConfig: AvalonNitroConfig): string {
498
+ const config = {
499
+ streaming: nitroConfig.streaming ?? true,
500
+ pagesDir: avalonConfig.pagesDir,
501
+ layoutsDir: avalonConfig.layoutsDir,
502
+ isDev: avalonConfig.isDev,
503
+ ...nitroConfig.runtimeConfig,
504
+ };
505
+ return `const config = ${JSON.stringify(config, null, 2)};\nexport function useAvalonConfig() { return config; }\nexport default config;\n`;
506
+ }
507
+
508
+ // ─── Public Accessors ────────────────────────────────────────────────────────
509
+
510
+ export function getViteDevServer(): ViteDevServer | undefined {
511
+ return globalThis.__viteDevServer;
512
+ }
513
+
514
+ export function getAvalonConfig(): ResolvedAvalonConfig | undefined {
515
+ return globalThis.__avalonConfig;
516
+ }
517
+
518
+ export function isDevelopmentMode(): boolean {
519
+ return globalThis.__avalonConfig?.isDev ?? true;
520
+ }
521
+
522
+ // ─── SSR Request Handling ────────────────────────────────────────────────────
523
+
524
+ const STREAM_MARKER = '<!--AVALON_STREAM_BOUNDARY-->';
525
+
526
+ let cachedSSRModule: unknown = null;
527
+
528
+ let cachedLayoutModule: unknown = null;
529
+
530
+ /**
531
+ * Streaming SSR handler — flushes the layout shell to the browser before
532
+ * the page component's async data fetching resolves.
533
+ *
534
+ * Flow:
535
+ * 1. Load page module + layout modules, collect CSS
536
+ * 2. Render shell layout with a marker placeholder as children
537
+ * 3. Split HTML on the marker → shellBefore / shellAfter
538
+ * 4. res.write(shellBefore) — browser starts parsing <html><head>... immediately
539
+ * 5. Render page content (awaits data fetches)
540
+ * 6. Render wrapper layouts around page content
541
+ * 7. res.write(wrappedContent + shellAfter)
542
+ * 8. res.end()
543
+ *
544
+ * Falls back to null (caller uses buffered path) when:
545
+ * - No modular layouts configured
546
+ * - Page not found
547
+ * - No shell layout detected
548
+ * - Page provides its own complete HTML document
549
+ */
550
+ async function handleStreamingSSRRequest(
551
+ server: ViteDevServer,
552
+ url: string,
553
+ config: ResolvedAvalonConfig,
554
+ res: ServerResponse,
555
+ ): Promise<boolean> {
556
+ // Streaming only works with modular layouts (need shell + wrapper separation)
557
+ if (!config.modules) return false;
558
+
559
+ const pathname = url.split('?')[0];
560
+ const pageFile = await findPageFile(pathname, config, server);
561
+ if (!pageFile) return false;
562
+
563
+ try {
564
+ const pageModule = await server.ssrLoadModule(pageFile);
565
+ const PageComponent = pageModule.default;
566
+ if (!PageComponent) return false;
567
+
568
+ // Check if page wants to skip layouts entirely (provides own HTML)
569
+ const layoutConfig = pageModule.layoutConfig as { skipLayouts?: string[] } | undefined;
570
+
571
+ // Collect CSS
572
+ const cssContents = await collectCssFromModuleGraph(server, pageFile);
573
+ const layoutFiles = await discoverLayoutFiles(pathname, server);
574
+
575
+ const layoutModules: Array<{ file: string; module: Record<string, unknown> }> = [];
576
+ for (const layoutFile of layoutFiles) {
577
+ const layoutModule = await server.ssrLoadModule(layoutFile);
578
+ layoutModules.push({ file: layoutFile, module: layoutModule });
579
+ }
580
+ for (const layoutFile of layoutFiles) {
581
+ const layoutCss = await collectCssFromModuleGraph(server, layoutFile);
582
+ cssContents.push(...layoutCss);
583
+ }
584
+
585
+ if (layoutModules.length === 0) return false;
586
+
587
+ const { render: preactRender } = await server.ssrLoadModule('preact-render-to-string');
588
+ const { h } = await server.ssrLoadModule('preact');
589
+
590
+ const skipLayouts = layoutConfig?.skipLayouts || [];
591
+ const activeLayouts = layoutModules.filter(({ file }) => {
592
+ const layoutName = file.split('/').pop()?.replace(/\.[^.]+$/, '') || '';
593
+ return !skipLayouts.includes(layoutName);
594
+ });
595
+
596
+ const frontmatter = pageModule.frontmatter as Record<string, unknown> | undefined;
597
+ const metadata = pageModule.metadata as Record<string, unknown> | undefined;
598
+ const mergedFrontmatter = { ...frontmatter, ...metadata, currentPath: pathname };
599
+ const layoutProps = {
600
+ children: null as unknown,
601
+ frontmatter: mergedFrontmatter,
602
+ params: {},
603
+ url: pathname,
604
+ };
605
+
606
+ // Categorize layouts into shell vs wrapper
607
+ const shellLayouts: Array<{ module: Record<string, unknown> }> = [];
608
+ const wrapperLayouts: Array<{ module: Record<string, unknown> }> = [];
609
+
610
+ for (const layout of activeLayouts) {
611
+ const LayoutComponent = layout.module.default;
612
+ if (!LayoutComponent || typeof LayoutComponent !== 'function') continue;
613
+ try {
614
+ const testProps = { ...layoutProps, children: h('div', null, 'test') };
615
+ const testResult = (LayoutComponent as (props: unknown) => unknown)(testProps);
616
+ const resolvedTest = testResult instanceof Promise ? await testResult : testResult;
617
+ const testHtml = preactRender(resolvedTest);
618
+ if (testHtml.trim().startsWith('<html') || testHtml.includes('<!DOCTYPE')) {
619
+ shellLayouts.push(layout);
620
+ } else {
621
+ wrapperLayouts.push(layout);
622
+ }
623
+ } catch {
624
+ wrapperLayouts.push(layout);
625
+ }
626
+ }
627
+
628
+ // Need a shell layout to stream
629
+ if (shellLayouts.length === 0) return false;
630
+
631
+ // Render shell layout with stream marker as children
632
+ const { module: shellModule } = shellLayouts[shellLayouts.length - 1];
633
+ const ShellComponent = shellModule.default;
634
+ if (!ShellComponent || typeof ShellComponent !== 'function') return false;
635
+
636
+ let shellHtml: string;
637
+ try {
638
+ const shellProps = {
639
+ ...layoutProps,
640
+ children: h('div', { dangerouslySetInnerHTML: { __html: STREAM_MARKER } }),
641
+ };
642
+ const shellResult = (ShellComponent as (props: unknown) => unknown)(shellProps);
643
+ const resolvedShell = shellResult instanceof Promise ? await shellResult : shellResult;
644
+ shellHtml = preactRender(resolvedShell);
645
+ } catch {
646
+ return false;
647
+ }
648
+
649
+ // Split on marker
650
+ const markerIndex = shellHtml.indexOf(STREAM_MARKER);
651
+ if (markerIndex === -1) return false;
652
+
653
+ let shellBefore = shellHtml.slice(0, markerIndex);
654
+ const shellAfter = shellHtml.slice(markerIndex + STREAM_MARKER.length);
655
+
656
+ // Inject CSS into the shell's <head>
657
+ let shellBeforeWithCss = shellBefore;
658
+ if (cssContents.length > 0) {
659
+ const cssTag = `<style data-avalon-ssr-css>${cssContents.join('\n')}</style>`;
660
+ if (shellBefore.includes('</head>')) {
661
+ shellBeforeWithCss = shellBefore.replace('</head>', `${cssTag}\n</head>`);
662
+ } else {
663
+ shellBeforeWithCss = shellBefore + cssTag;
664
+ }
665
+ }
666
+
667
+ // Ensure DOCTYPE
668
+ if (!shellBeforeWithCss.trim().toLowerCase().startsWith('<!doctype')) {
669
+ shellBeforeWithCss = '<!DOCTYPE html>\n' + shellBeforeWithCss;
670
+ }
671
+
672
+ // Inject universal CSS and head content
673
+ const universalCSS = getUniversalCSSForHead(true);
674
+ if (universalCSS && shellBeforeWithCss.includes('</head>')) {
675
+ shellBeforeWithCss = shellBeforeWithCss.replace('</head>', `${universalCSS}\n</head>`);
676
+ }
677
+ const universalHead = getUniversalHeadForInjection(true);
678
+ if (universalHead && shellBeforeWithCss.includes('</head>')) {
679
+ shellBeforeWithCss = shellBeforeWithCss.replace('</head>', `${universalHead}\n</head>`);
680
+ }
681
+
682
+ // ── FLUSH SHELL ──
683
+ res.statusCode = 200;
684
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
685
+ res.setHeader('Transfer-Encoding', 'chunked');
686
+ res.setHeader('X-Avalon-Streaming', '1');
687
+ res.flushHeaders();
688
+ res.write(shellBeforeWithCss);
689
+
690
+ // ── RENDER PAGE CONTENT (this is where data fetching happens) ──
691
+ let pageContent: string;
692
+ try {
693
+ const pageResult = typeof PageComponent === 'function'
694
+ ? (PageComponent as () => unknown)()
695
+ : PageComponent;
696
+ const resolvedPage = pageResult instanceof Promise ? await pageResult : pageResult;
697
+ pageContent = preactRender(resolvedPage);
698
+ } catch (error) {
699
+ console.error('[SSR Streaming] Error rendering page component:', error);
700
+ pageContent = `<div>Error rendering page</div>`;
701
+ }
702
+
703
+ // Check if page returned a complete HTML doc (shouldn't happen with layouts, but safety check)
704
+ const isCompleteDoc = pageContent.trim().startsWith('<!DOCTYPE html>') ||
705
+ pageContent.trim().startsWith('<html');
706
+ if (isCompleteDoc) {
707
+ // Can't stream this — just send it and close
708
+ res.end(pageContent);
709
+ return true;
710
+ }
711
+
712
+ // Apply wrapper layouts around page content
713
+ let content = pageContent;
714
+ for (const { module: layoutModule } of wrapperLayouts) {
715
+ const LayoutComponent = layoutModule.default;
716
+ if (!LayoutComponent || typeof LayoutComponent !== 'function') continue;
717
+ try {
718
+ const props = {
719
+ ...layoutProps,
720
+ children: h('div', { dangerouslySetInnerHTML: { __html: content } }),
721
+ };
722
+ const layoutResult = (LayoutComponent as (props: unknown) => unknown)(props);
723
+ const resolvedLayout = layoutResult instanceof Promise ? await layoutResult : layoutResult;
724
+ content = preactRender(resolvedLayout);
725
+ } catch (error) {
726
+ console.error('[SSR Streaming] Error rendering wrapper layout:', error);
727
+ }
728
+ }
729
+
730
+ // ── FLUSH PAGE CONTENT + SHELL TAIL ──
731
+ // Inject client scripts before closing </body>
732
+ let tail = content + shellAfter;
733
+ if (!tail.includes('/src/client/main.js') && !tail.includes('/@vite/client')) {
734
+ const bodyCloseIndex = tail.lastIndexOf('</body>');
735
+ if (bodyCloseIndex !== -1) {
736
+ tail = tail.slice(0, bodyCloseIndex) +
737
+ '\n<script type="module" src="/@vite/client"></script>\n' +
738
+ '<script type="module" src="/src/client/main.js"></script>\n' +
739
+ tail.slice(bodyCloseIndex);
740
+ }
741
+ }
742
+
743
+ res.end(tail);
744
+ return true;
745
+ } catch (error) {
746
+ // If we already started writing, we can't change status code
747
+ if (res.headersSent) {
748
+ res.end(`<div>Streaming SSR error: ${(error as Error).message}</div></body></html>`);
749
+ return true;
750
+ }
751
+ return false;
752
+ }
753
+ }
754
+
755
+
756
+ async function handleSSRRequest(
757
+ server: ViteDevServer,
758
+ url: string,
759
+ config: ResolvedAvalonConfig,
760
+ ): Promise<string | null> {
761
+ const pathname = url.split('?')[0];
762
+ const pageFile = await findPageFile(pathname, config, server);
763
+
764
+ if (!pageFile) return null;
765
+
766
+ try {
767
+ const pageModule = await server.ssrLoadModule(pageFile);
768
+ const PageComponent = pageModule.default;
769
+
770
+ if (!PageComponent) {
771
+ console.warn(`[SSR] Page ${pageFile} has no default export`);
772
+ return null;
773
+ }
774
+
775
+ // Collect CSS from the module graph after loading the page module.
776
+ // This captures CSS modules, plain CSS imports, and any transitive CSS deps.
777
+ const cssContents = await collectCssFromModuleGraph(server, pageFile);
778
+
779
+ // Pre-load layout files via ssrLoadModule so their CSS modules enter
780
+ // Vite's module graph *before* we collect CSS from them.
781
+ const layoutFiles = await discoverLayoutFiles(pathname, server);
782
+
783
+ // Load all layout modules
784
+ const layoutModules: Array<{ file: string; module: Record<string, unknown> }> = [];
785
+ for (const layoutFile of layoutFiles) {
786
+ const layoutModule = await server.ssrLoadModule(layoutFile);
787
+ layoutModules.push({ file: layoutFile, module: layoutModule });
788
+ }
789
+
790
+ // Collect CSS from layout files and merge with page CSS
791
+ for (const layoutFile of layoutFiles) {
792
+ const layoutCss = await collectCssFromModuleGraph(server, layoutFile);
793
+ cssContents.push(...layoutCss);
794
+ }
795
+
796
+ let html: string;
797
+
798
+ // If we have modular layouts, use manual layout composition
799
+ if (config.modules && layoutModules.length > 0) {
800
+ html = await renderPageWithManualLayouts(
801
+ PageComponent,
802
+ pageModule,
803
+ layoutModules,
804
+ pathname,
805
+ config,
806
+ server
807
+ );
808
+ } else {
809
+ html = await renderPageToHtml(PageComponent, pageModule, pathname, config, server);
810
+ }
811
+
812
+ // Inject collected CSS into the HTML so styles are present on first paint
813
+ if (cssContents.length > 0) {
814
+ html = injectSsrCss(html, cssContents);
815
+ }
816
+
817
+ return html;
818
+ } catch (error) {
819
+ console.error(`[SSR] Error rendering ${pageFile}:`, error);
820
+ throw error;
821
+ }
822
+ }
823
+
824
+ /**
825
+ * Render a page with manually composed layouts (for modular architecture)
826
+ *
827
+ * Layout composition order:
828
+ * 1. Page content is rendered first
829
+ * 2. Module-specific layouts (e.g., docs/_layout.tsx) wrap the page content
830
+ * 3. Root/shell layout (shared/_layout.tsx) wraps everything last
831
+ *
832
+ * This ensures that layouts returning `<div>` wrappers are applied before
833
+ * layouts returning complete `<html>` documents.
834
+ */
835
+ async function renderPageWithManualLayouts(
836
+ PageComponent: unknown,
837
+ pageModule: Record<string, unknown>,
838
+ layoutModules: Array<{ file: string; module: Record<string, unknown> }>,
839
+ pathname: string,
840
+ config: ResolvedAvalonConfig,
841
+ server: ViteDevServer,
842
+ ): Promise<string> {
843
+ const { render: preactRender } = await server.ssrLoadModule('preact-render-to-string');
844
+ const { h } = await server.ssrLoadModule('preact');
845
+
846
+ // Check if page wants to skip certain layouts
847
+ const layoutConfig = pageModule.layoutConfig as { skipLayouts?: string[] } | undefined;
848
+ const skipLayouts = layoutConfig?.skipLayouts || [];
849
+
850
+ // Filter out skipped layouts
851
+ const activeLayouts = layoutModules.filter(({ file }) => {
852
+ const layoutName = file.split('/').pop()?.replace(/\.[^.]+$/, '') || '';
853
+ return !skipLayouts.includes(layoutName);
854
+ });
855
+
856
+ // Render page content first
857
+ let pageContent: string;
858
+ try {
859
+ const pageResult = typeof PageComponent === 'function'
860
+ ? (PageComponent as () => unknown)()
861
+ : PageComponent;
862
+ const resolvedPage = pageResult instanceof Promise ? await pageResult : pageResult;
863
+ pageContent = preactRender(resolvedPage);
864
+ } catch (error) {
865
+ console.error('[SSR] Error rendering page component:', error);
866
+ pageContent = `<div>Error rendering page</div>`;
867
+ }
868
+
869
+ // Check if page content is a complete HTML document
870
+ const isCompleteDoc = pageContent.trim().startsWith('<!DOCTYPE html>') ||
871
+ pageContent.trim().startsWith('<html');
872
+
873
+ if (isCompleteDoc) {
874
+ // Page provides its own HTML structure, inject client script and return
875
+ return injectClientScript(pageContent);
876
+ }
877
+
878
+ // Separate layouts into shell (returns <html>) and wrapper (returns <div>) layouts
879
+ // We need to render each layout to determine its type, then apply in correct order
880
+ // Merge frontmatter and metadata - metadata takes precedence for page-specific values
881
+ const frontmatter = pageModule.frontmatter as Record<string, unknown> | undefined;
882
+ const metadata = pageModule.metadata as Record<string, unknown> | undefined;
883
+ const mergedFrontmatter = { ...frontmatter, ...metadata, currentPath: pathname };
884
+
885
+ const layoutProps = {
886
+ children: null as unknown, // Will be set per-layout
887
+ frontmatter: mergedFrontmatter,
888
+ params: {},
889
+ url: pathname,
890
+ };
891
+
892
+ // Categorize layouts by rendering them with placeholder content
893
+ const shellLayouts: Array<{ module: Record<string, unknown> }> = [];
894
+ const wrapperLayouts: Array<{ module: Record<string, unknown> }> = [];
895
+
896
+ for (const layout of activeLayouts) {
897
+ const LayoutComponent = layout.module.default;
898
+ if (!LayoutComponent || typeof LayoutComponent !== 'function') continue;
899
+
900
+ try {
901
+ // Render with placeholder to detect if it returns HTML shell
902
+ const testProps = {
903
+ ...layoutProps,
904
+ children: h('div', null, 'test'),
905
+ };
906
+ const testResult = (LayoutComponent as (props: unknown) => unknown)(testProps);
907
+ const resolvedTest = testResult instanceof Promise ? await testResult : testResult;
908
+ const testHtml = preactRender(resolvedTest);
909
+
910
+ if (testHtml.trim().startsWith('<html') || testHtml.includes('<!DOCTYPE')) {
911
+ shellLayouts.push(layout);
912
+ } else {
913
+ wrapperLayouts.push(layout);
914
+ }
915
+ } catch {
916
+ // If we can't determine, treat as wrapper
917
+ wrapperLayouts.push(layout);
918
+ }
919
+ }
920
+
921
+ // Apply wrapper layouts first (innermost to outermost)
922
+ // These are module-specific layouts that return <div> wrappers
923
+ let content = pageContent;
924
+
925
+ for (const { module: layoutModule } of wrapperLayouts) {
926
+ const LayoutComponent = layoutModule.default;
927
+ if (!LayoutComponent || typeof LayoutComponent !== 'function') continue;
928
+
929
+ try {
930
+ const props = {
931
+ ...layoutProps,
932
+ children: h('div', { dangerouslySetInnerHTML: { __html: content } }),
933
+ };
934
+
935
+ const layoutResult = (LayoutComponent as (props: unknown) => unknown)(props);
936
+ const resolvedLayout = layoutResult instanceof Promise ? await layoutResult : layoutResult;
937
+ content = preactRender(resolvedLayout);
938
+ } catch (error) {
939
+ console.error('[SSR] Error rendering wrapper layout:', error);
940
+ }
941
+ }
942
+
943
+ // Apply shell layout last (the one that provides <html>)
944
+ // If there are multiple shell layouts, prefer the module-specific one (last in array)
945
+ // since layouts are discovered in order: shared -> module-specific
946
+ if (shellLayouts.length > 0) {
947
+ // Use the last shell layout (module-specific takes precedence over shared)
948
+ const { module: shellModule } = shellLayouts[shellLayouts.length - 1];
949
+ const ShellComponent = shellModule.default;
950
+
951
+ if (ShellComponent && typeof ShellComponent === 'function') {
952
+ try {
953
+ const props = {
954
+ ...layoutProps,
955
+ children: h('div', { dangerouslySetInnerHTML: { __html: content } }),
956
+ };
957
+
958
+ const shellResult = (ShellComponent as (props: unknown) => unknown)(props);
959
+ const resolvedShell = shellResult instanceof Promise ? await shellResult : shellResult;
960
+ content = preactRender(resolvedShell);
961
+ } catch (error) {
962
+ console.error('[SSR] Error rendering shell layout:', error);
963
+ }
964
+ }
965
+ }
966
+
967
+ // Check if final content is a complete HTML document
968
+ const isFinalCompleteDoc = content.trim().startsWith('<!DOCTYPE html>') ||
969
+ content.trim().startsWith('<html');
970
+
971
+ if (isFinalCompleteDoc) {
972
+ return injectClientScript(content);
973
+ }
974
+
975
+ // Wrap in basic HTML structure (fallback if no shell layout)
976
+ const fallbackMetadata = (pageModule.metadata || {}) as { title?: string; description?: string };
977
+ const title = fallbackMetadata.title || 'Avalon App';
978
+ const description = fallbackMetadata.description || '';
979
+
980
+ return `<!DOCTYPE html>
981
+ <html lang="en">
982
+ <head>
983
+ <meta charset="utf-8">
984
+ <meta name="viewport" content="width=device-width, initial-scale=1">
985
+ <title>${escapeHtml(title)}</title>
986
+ ${description ? `<meta name="description" content="${escapeHtml(description)}">` : ''}
987
+ <script type="module" src="/@vite/client"></script>
988
+ </head>
989
+ <body>
990
+ ${content}
991
+ <script type="module" src="/src/client/main.js"></script>
992
+ </body>
993
+ </html>`;
994
+ }
995
+
996
+ /**
997
+ * Inject client script into HTML if not already present.
998
+ * Also ensures DOCTYPE is present for valid HTML5.
999
+ */
1000
+ function injectClientScript(html: string): string {
1001
+ let result = html;
1002
+
1003
+ // Ensure DOCTYPE is present
1004
+ if (!result.trim().toLowerCase().startsWith('<!doctype')) {
1005
+ result = '<!DOCTYPE html>\n' + result;
1006
+ }
1007
+
1008
+ // Inject universal CSS from island framework renderers (Svelte scoped, Vue scoped, Solid CSS, etc.)
1009
+ if (!result.includes('data-universal-ssr="true"')) {
1010
+ const universalCSS = getUniversalCSSForHead(true);
1011
+ if (universalCSS && result.includes('</head>')) {
1012
+ result = result.replace('</head>', `${universalCSS}\n</head>`);
1013
+ }
1014
+ }
1015
+
1016
+ // Inject universal head content (hydration scripts from frameworks like Solid)
1017
+ const universalHead = getUniversalHeadForInjection(true);
1018
+ if (universalHead && result.includes('</head>')) {
1019
+ result = result.replace('</head>', `${universalHead}\n</head>`);
1020
+ }
1021
+
1022
+ // Skip if scripts already present
1023
+ if (result.includes('/src/client/main.js') || result.includes('/@vite/client')) {
1024
+ return result;
1025
+ }
1026
+
1027
+ // Inject before </body> or at the end
1028
+ const bodyCloseIndex = result.lastIndexOf('</body>');
1029
+ if (bodyCloseIndex !== -1) {
1030
+ return result.slice(0, bodyCloseIndex) +
1031
+ '\n<script type="module" src="/@vite/client"></script>\n' +
1032
+ '<script type="module" src="/src/client/main.js"></script>\n' +
1033
+ result.slice(bodyCloseIndex);
1034
+ }
1035
+
1036
+ return result + '\n<script type="module" src="/@vite/client"></script>\n<script type="module" src="/src/client/main.js"></script>';
1037
+ }
1038
+
1039
+ /**
1040
+ * Discover layout files that apply to a given route path.
1041
+ *
1042
+ * Supports both traditional layouts (src/layouts/) and modular layouts (app/modules/[module]/layouts/).
1043
+ * For route "/blog/post", checks shared layouts, module layouts, and traditional layouts.
1044
+ */
1045
+ async function discoverLayoutFiles(pathname: string, server: ViteDevServer): Promise<string[]> {
1046
+ const viteRoot = server.config.root || process.cwd();
1047
+ const config = globalThis.__avalonConfig;
1048
+ const layoutFileName = '_layout.tsx';
1049
+ const layoutFiles: string[] = [];
1050
+
1051
+ // Build path hierarchy: "/" → [''], "/blog/post" → ['', '/blog', '/blog/post']
1052
+ const segments = pathname.split('/').filter(Boolean);
1053
+ const paths = [''];
1054
+ for (let i = 0; i < segments.length; i++) {
1055
+ paths.push('/' + segments.slice(0, i + 1).join('/'));
1056
+ }
1057
+
1058
+ // Helper to check and add layout file
1059
+ async function tryAddLayout(fullPath: string): Promise<void> {
1060
+ try {
1061
+ const stat = await fsStat(fullPath);
1062
+ if (stat.isFile()) {
1063
+ const relativePath = fullPath.slice(viteRoot.length);
1064
+ if (!layoutFiles.includes(relativePath)) {
1065
+ layoutFiles.push(relativePath);
1066
+ }
1067
+ }
1068
+ } catch {
1069
+ // Layout file doesn't exist — that's fine
1070
+ }
1071
+ }
1072
+
1073
+ // 1. Check shared layouts directory (root layout)
1074
+ if (config?.layoutsDir) {
1075
+ const sharedLayoutsDir = `${viteRoot}/${config.layoutsDir}`;
1076
+ await tryAddLayout(`${sharedLayoutsDir}/${layoutFileName}`);
1077
+ }
1078
+
1079
+ // 2. Check modular layouts (app/modules/*/layouts/)
1080
+ if (config?.modules) {
1081
+ const modulesDir = `${viteRoot}/${config.modules.dir}`;
1082
+ const layoutsDirName = config.modules.layoutsDirName;
1083
+
1084
+ // Determine which module this route belongs to
1085
+ const firstSegment = segments[0] || '';
1086
+ const rootModules = ['home', 'root', 'main', 'index'];
1087
+
1088
+ // For root routes, check the home/root/main/index module
1089
+ if (!firstSegment || rootModules.includes(firstSegment.toLowerCase())) {
1090
+ for (const moduleName of rootModules) {
1091
+ await tryAddLayout(`${modulesDir}/${moduleName}/${layoutsDirName}/${layoutFileName}`);
1092
+ }
1093
+ } else {
1094
+ // For other routes, check the module matching the first segment
1095
+ await tryAddLayout(`${modulesDir}/${firstSegment}/${layoutsDirName}/${layoutFileName}`);
1096
+ }
1097
+ }
1098
+
1099
+ // 3. Check traditional layouts directory (src/layouts/)
1100
+ const traditionalLayoutsDir = `${viteRoot}/src/layouts`;
1101
+ for (const pathSegment of paths) {
1102
+ const fullPath = pathSegment === ''
1103
+ ? `${traditionalLayoutsDir}/${layoutFileName}`
1104
+ : `${traditionalLayoutsDir}${pathSegment}/${layoutFileName}`;
1105
+ await tryAddLayout(fullPath);
1106
+ }
1107
+
1108
+ return layoutFiles;
1109
+ }
1110
+
1111
+ async function findPageFile(pathname: string, config: ResolvedAvalonConfig, server: ViteDevServer): Promise<string | null> {
1112
+ let normalizedPath = pathname;
1113
+ if (normalizedPath.endsWith('/') && normalizedPath !== '/') {
1114
+ normalizedPath = normalizedPath.slice(0, -1);
1115
+ }
1116
+ if (normalizedPath === '/') {
1117
+ normalizedPath = '/index';
1118
+ }
1119
+
1120
+ const extensions = ['.tsx', '.ts', '.jsx', '.js', '.mdx', '.md'];
1121
+ const viteRoot = server.config.root || process.cwd();
1122
+
1123
+ // Helper to check if a file exists
1124
+ async function tryFile(relativePath: string): Promise<string | null> {
1125
+ try {
1126
+ const fullPath = `${viteRoot}/${relativePath}`;
1127
+ const stat = await fsStat(fullPath);
1128
+ if (stat.isFile()) return `/${relativePath}`;
1129
+ } catch {
1130
+ // File doesn't exist
1131
+ }
1132
+ return null;
1133
+ }
1134
+
1135
+ // 1. Check modular page directories first
1136
+ if (config.modules) {
1137
+ const modulesDir = config.modules.dir;
1138
+ const pagesDirName = config.modules.pagesDirName;
1139
+ const segments = pathname.split('/').filter(Boolean);
1140
+ const firstSegment = segments[0] || '';
1141
+ const rootModules = ['home', 'root', 'main', 'index'];
1142
+
1143
+ // Determine which module and what the relative path within that module is
1144
+ let moduleName: string;
1145
+ let moduleRelativePath: string;
1146
+
1147
+ if (!firstSegment || rootModules.includes(firstSegment.toLowerCase())) {
1148
+ // Root route - check home module
1149
+ moduleName = 'home';
1150
+ moduleRelativePath = normalizedPath;
1151
+ } else {
1152
+ // Check if first segment matches a module
1153
+ moduleName = firstSegment;
1154
+ // Remove the module prefix from the path
1155
+ const remainingSegments = segments.slice(1);
1156
+ moduleRelativePath = remainingSegments.length > 0
1157
+ ? '/' + remainingSegments.join('/')
1158
+ : '/index';
1159
+ }
1160
+
1161
+ // Try to find the page in the module
1162
+ for (const ext of extensions) {
1163
+ const result = await tryFile(`${modulesDir}/${moduleName}/${pagesDirName}${moduleRelativePath}${ext}`);
1164
+ if (result) return result;
1165
+ }
1166
+ if (!moduleRelativePath.endsWith('/index')) {
1167
+ for (const ext of extensions) {
1168
+ const result = await tryFile(`${modulesDir}/${moduleName}/${pagesDirName}${moduleRelativePath}/index${ext}`);
1169
+ if (result) return result;
1170
+ }
1171
+ }
1172
+ }
1173
+
1174
+ // 2. Check traditional pages directory
1175
+ const pagesDir = config.pagesDir;
1176
+ for (const ext of extensions) {
1177
+ const result = await tryFile(`${pagesDir}${normalizedPath}${ext}`);
1178
+ if (result) return result;
1179
+ }
1180
+ if (!normalizedPath.endsWith('/index')) {
1181
+ for (const ext of extensions) {
1182
+ const result = await tryFile(`${pagesDir}${normalizedPath}/index${ext}`);
1183
+ if (result) return result;
1184
+ }
1185
+ }
1186
+
1187
+ return null;
1188
+ }
1189
+
1190
+ async function renderPageToHtml(
1191
+ PageComponent: unknown,
1192
+ pageModule: Record<string, unknown>,
1193
+ pathname: string,
1194
+ config: ResolvedAvalonConfig,
1195
+ server: ViteDevServer,
1196
+ ): Promise<string> {
1197
+ const metadata = (pageModule.metadata || {}) as { title?: string; description?: string };
1198
+
1199
+ try {
1200
+ if (!cachedSSRModule) {
1201
+ cachedSSRModule = await server.ssrLoadModule('../packages/avalon/src/render/ssr.ts');
1202
+ }
1203
+ // cachedSSRModule is the dynamically-loaded render/ssr.ts module;
1204
+ // expected exports: renderToHtml, renderToHtmlWithLayouts
1205
+ const ssrModule = cachedSSRModule as Record<string, unknown>;
1206
+
1207
+ if (!cachedLayoutModule) {
1208
+ cachedLayoutModule = await server.ssrLoadModule('../packages/avalon/src/core/layout/enhanced-layout-resolver.ts');
1209
+ }
1210
+ // cachedLayoutModule is the dynamically-loaded enhanced-layout-resolver.ts module;
1211
+ // expected exports: EnhancedLayoutResolver, EnhancedLayoutResolverUtils
1212
+ const layoutModule = cachedLayoutModule as Record<string, unknown>;
1213
+
1214
+ const routeConfig = {
1215
+ component: () => (typeof PageComponent === 'function' ? (PageComponent as () => unknown)() : PageComponent),
1216
+ options: { title: metadata.title || 'Avalon App' },
1217
+ frontmatter: pageModule.frontmatter as Record<string, unknown> | undefined,
1218
+ };
1219
+
1220
+ // Try layout-aware rendering first
1221
+ if (
1222
+ ssrModule.renderToHtmlWithLayouts &&
1223
+ layoutModule.EnhancedLayoutResolver &&
1224
+ layoutModule.EnhancedLayoutResolverUtils
1225
+ ) {
1226
+ try {
1227
+ const viteRoot = server.config.root || process.cwd();
1228
+
1229
+ if (!globalThis.__avalonLayoutResolver) {
1230
+ const EnhancedLayoutResolver = layoutModule.EnhancedLayoutResolver as new (
1231
+ opts: Record<string, unknown>,
1232
+ ) => unknown;
1233
+
1234
+ // Use the shared layouts directory as the base
1235
+ // The resolver will also check modular layouts via the layout composer
1236
+ const layoutsDir = config.layoutsDir || 'src/layouts';
1237
+
1238
+ globalThis.__avalonLayoutResolver = new EnhancedLayoutResolver({
1239
+ baseDirectory: `${viteRoot}/${layoutsDir}`,
1240
+ filePattern: '_layout.tsx',
1241
+ excludeDirectories: ['node_modules', '.git', 'dist', 'build'],
1242
+ enableWatching: true,
1243
+ developmentMode: false,
1244
+ enableCaching: true,
1245
+ cacheTTL: 60 * 1000,
1246
+ maxCacheSize: 100,
1247
+ enableStreaming: true,
1248
+ enableErrorBoundaries: true,
1249
+ enableMetrics: false,
1250
+ enableDebugInfo: false,
1251
+ // Pass modules config for modular layout discovery
1252
+ modulesDir: config.modules ? `${viteRoot}/${config.modules.dir}` : undefined,
1253
+ modulesLayoutsDirName: config.modules?.layoutsDirName,
1254
+ });
1255
+ }
1256
+
1257
+ const fullUrl = `http://localhost${pathname}`;
1258
+ const layoutContext = {
1259
+ params: {},
1260
+ query: {},
1261
+ url: fullUrl,
1262
+ request: { method: 'GET', url: fullUrl, headers: new Headers() },
1263
+ };
1264
+
1265
+ return await (ssrModule.renderToHtmlWithLayouts as Function)(
1266
+ routeConfig,
1267
+ globalThis.__avalonLayoutResolver,
1268
+ layoutContext,
1269
+ pathname,
1270
+ { title: metadata.title || 'Avalon App' },
1271
+ undefined,
1272
+ { suppressWarnings: true },
1273
+ );
1274
+ } catch {
1275
+ // Layout rendering failed, fall back to basic rendering
1276
+ }
1277
+ }
1278
+
1279
+ if (ssrModule.renderToHtml) {
1280
+ return await (ssrModule.renderToHtml as Function)(
1281
+ routeConfig,
1282
+ { title: metadata.title || 'Avalon App' },
1283
+ undefined,
1284
+ { suppressWarnings: true },
1285
+ );
1286
+ }
1287
+ } catch {
1288
+ // SSR module not available, fall back to basic rendering
1289
+ }
1290
+
1291
+ // Fallback: basic HTML template
1292
+ const title = metadata.title || 'Avalon App';
1293
+ const description = metadata.description || '';
1294
+ let content = '';
1295
+ try {
1296
+ const preactRenderModule = await server.ssrLoadModule('preact-render-to-string');
1297
+ if (preactRenderModule.render && typeof PageComponent === 'function') {
1298
+ content = preactRenderModule.render((PageComponent as () => unknown)());
1299
+ }
1300
+ } catch {
1301
+ content = `<p>Loading page: ${escapeHtml(pathname)}</p>`;
1302
+ }
1303
+
1304
+ return `<!DOCTYPE html>
1305
+ <html lang="en">
1306
+ <head>
1307
+ <meta charset="utf-8">
1308
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1309
+ <title>${escapeHtml(title)}</title>
1310
+ ${description ? `<meta name="description" content="${escapeHtml(description)}">` : ''}
1311
+ <script type="module" src="/@vite/client"></script>
1312
+ </head>
1313
+ <body>
1314
+ <div id="app">${content}</div>
1315
+ <script type="module" src="/src/client/main.js"></script>
1316
+ </body>
1317
+ </html>`;
1318
+ }
1319
+
1320
+ function escapeHtml(str: string): string {
1321
+ return str
1322
+ .replaceAll('&', '&amp;')
1323
+ .replaceAll('<', '&lt;')
1324
+ .replaceAll('>', '&gt;')
1325
+ .replaceAll('"', '&quot;')
1326
+ .replaceAll("'", '&#039;');
1327
+ }
1328
+
1329
+ // ─── Global Type Declarations ────────────────────────────────────────────────
1330
+
1331
+ declare global {
1332
+ // deno-lint-ignore no-var
1333
+ var __avalonLayoutResolver: unknown;
1334
+ }