@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,1458 @@
1
+ /**
2
+ * Nitro SSR Renderer Handler for Avalon
3
+ *
4
+ * This module provides the main SSR renderer for Nitro integration.
5
+ * It handles page rendering using Avalon's existing SSR pipeline while
6
+ * integrating with Nitro's h3 event handling system.
7
+ *
8
+ * The renderer acts as a catch-all handler for Nitro - it receives requests
9
+ * that don't match any API routes or static files, and renders the appropriate
10
+ * page using Avalon's SSR pipeline.
11
+ *
12
+ * Key design principle: This renderer relies on Nitro's built-in file-system
13
+ * routing for route matching. Custom route matching logic has been removed
14
+ * in favor of Nitro's native capabilities.
15
+ *
16
+ * Middleware Integration:
17
+ * - Global middleware runs first (handled by Nitro's middleware/ directory)
18
+ * - Route-scoped middleware runs after global middleware, before page rendering
19
+ * - If global middleware terminates, route-scoped middleware does not run
20
+ *
21
+ * Custom Error Pages:
22
+ * - Supports custom 404 page (src/pages/404.tsx)
23
+ * - Supports custom 500 page (src/pages/500.tsx)
24
+ * - Supports generic error page (src/pages/_error.tsx)
25
+ *
26
+ * Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 5.1, 5.3, 9.1, 9.2, 9.3, 9.4, 10.5
27
+ */
28
+
29
+ import type {
30
+ NitroRenderContext,
31
+ SSRRenderOptions,
32
+ SSRRenderResult,
33
+ PageModule,
34
+ AvalonRuntimeConfig,
35
+ HttpError,
36
+ } from './types.ts';
37
+ import type { H3Event } from 'h3';
38
+ import { getRequestURL as h3GetRequestURL } from 'h3';
39
+ import { createNotFoundError, isHttpError } from './types.ts';
40
+ import type { MiddlewareRoute } from '../middleware/types.ts';
41
+ import { discoverScopedMiddleware, executeScopedMiddleware } from '../middleware/index.ts';
42
+ import {
43
+ handleRenderError as handleRenderErrorWithCustomPages,
44
+ discoverErrorPages,
45
+ type ErrorHandlerOptions,
46
+ } from './error-handler.ts';
47
+
48
+ /**
49
+ * Resolved page route information
50
+ */
51
+ export interface ResolvedPageRoute {
52
+ /** File path to the page module */
53
+ filePath: string;
54
+ /** Route pattern that matched */
55
+ pattern: string;
56
+ /** Extracted route parameters */
57
+ params: Record<string, string>;
58
+ /** Layout files to apply (outermost first) */
59
+ layouts?: string[];
60
+ }
61
+
62
+ /**
63
+ * Render handler options
64
+ *
65
+ * Simplified for Nitro's catch-all pattern - route resolution is now
66
+ * handled by Nitro's file-system routing, so custom resolvers are optional
67
+ * and primarily used for development/testing scenarios.
68
+ */
69
+ export interface RenderHandlerOptions {
70
+ /** Avalon runtime configuration */
71
+ avalonConfig: AvalonRuntimeConfig;
72
+ /** Whether running in development mode */
73
+ isDev?: boolean;
74
+ /** Vite dev server URL for development */
75
+ viteServerUrl?: string;
76
+ /**
77
+ * Custom page resolver function (optional)
78
+ * In production, Nitro handles route resolution via file-system routing.
79
+ * This is primarily used for development with Vite's SSR module loading.
80
+ */
81
+ resolvePageRoute?: (pathname: string, pagesDir: string) => Promise<ResolvedPageRoute | null>;
82
+ /**
83
+ * Custom page module loader (optional)
84
+ * In production, modules are loaded from the build output.
85
+ * In development, Vite's ssrLoadModule is used.
86
+ */
87
+ loadPageModule?: (filePath: string) => Promise<PageModule>;
88
+ /** Custom layout resolver */
89
+ resolveLayouts?: (routePath: string, config: AvalonRuntimeConfig) => Promise<string[]>;
90
+ /**
91
+ * Enable custom error pages (404.tsx, 500.tsx, _error.tsx)
92
+ * When enabled, the renderer will look for custom error pages in the pages directory
93
+ * Requirements: 10.5
94
+ */
95
+ enableCustomErrorPages?: boolean;
96
+ }
97
+
98
+ /**
99
+ * Creates a render context from an H3 event
100
+ *
101
+ * @param event - The H3 event from Nitro
102
+ * @param params - Route parameters extracted from the URL
103
+ * @returns NitroRenderContext for use in rendering
104
+ */
105
+ export function createRenderContext(event: H3Event, params: Record<string, string> = {}): NitroRenderContext {
106
+ const url = getRequestURL(event);
107
+
108
+ return {
109
+ url,
110
+ params,
111
+ query: Object.fromEntries(url.searchParams),
112
+ request: toRequest(event),
113
+ event,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Gets the request URL from an H3 event
119
+ */
120
+ export function getRequestURL(event: H3Event): URL {
121
+ // Use h3's getRequestURL for h3 v2 compatibility
122
+ const protocol = 'http';
123
+ const host = 'localhost';
124
+ return new URL(h3GetRequestURL(event).pathname, `${protocol}://${host}`);
125
+ }
126
+
127
+ /**
128
+ * Converts an H3 event to a standard Request object
129
+ */
130
+ export function toRequest(event: H3Event): Request {
131
+ const url = getRequestURL(event);
132
+ return new Request(url, {
133
+ method: event.req.method,
134
+ headers: getRequestHeaders(event),
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Gets request headers from an H3 event
140
+ */
141
+ export function getRequestHeaders(event: H3Event): Headers {
142
+ const headers = new Headers();
143
+ // In a real Nitro environment, headers would come from event.node.req.headers
144
+ // This is a placeholder implementation
145
+ return headers;
146
+ }
147
+
148
+ /**
149
+ * Sets a response header on an H3 event
150
+ */
151
+ export function setResponseHeader(event: H3Event, name: string, value: string): void {
152
+ // In a real Nitro environment, this would use h3's setResponseHeader
153
+ // Store in event context for now
154
+ if (!event.context.responseHeaders) {
155
+ event.context.responseHeaders = {};
156
+ }
157
+ (event.context.responseHeaders as Record<string, string>)[name] = value;
158
+ }
159
+
160
+ /**
161
+ * Creates an error response
162
+ */
163
+ export function createErrorResponse(error: Error | HttpError, isDev: boolean): Response {
164
+ const statusCode = isHttpError(error) ? error.statusCode : 500;
165
+
166
+ if (isDev) {
167
+ // Development: include full error details
168
+ const errorHtml = generateDevErrorPage(error, statusCode);
169
+ return new Response(errorHtml, {
170
+ status: statusCode,
171
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
172
+ });
173
+ }
174
+
175
+ // Production: generic error page
176
+ const errorHtml = generateProdErrorPage(statusCode);
177
+ return new Response(errorHtml, {
178
+ status: statusCode,
179
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Generates a development error page with full details
185
+ */
186
+ function generateDevErrorPage(error: Error, statusCode: number): string {
187
+ return `<!DOCTYPE html>
188
+ <html lang="en">
189
+ <head>
190
+ <meta charset="utf-8">
191
+ <meta name="viewport" content="width=device-width, initial-scale=1">
192
+ <title>Error ${statusCode}</title>
193
+ <style>
194
+ body {
195
+ font-family: system-ui, -apple-system, sans-serif;
196
+ margin: 0;
197
+ padding: 40px;
198
+ background: #1a1a1a;
199
+ color: #fff;
200
+ }
201
+ .error-container {
202
+ max-width: 800px;
203
+ margin: 0 auto;
204
+ background: #2d2d2d;
205
+ padding: 40px;
206
+ border-radius: 8px;
207
+ border-left: 4px solid #ff6b6b;
208
+ }
209
+ h1 {
210
+ color: #ff6b6b;
211
+ margin-top: 0;
212
+ font-size: 24px;
213
+ }
214
+ .status-code {
215
+ font-size: 48px;
216
+ font-weight: bold;
217
+ color: #ff6b6b;
218
+ margin-bottom: 10px;
219
+ }
220
+ .message {
221
+ font-size: 18px;
222
+ color: #ccc;
223
+ margin-bottom: 20px;
224
+ }
225
+ pre {
226
+ background: #1a1a1a;
227
+ padding: 20px;
228
+ border-radius: 4px;
229
+ overflow-x: auto;
230
+ font-size: 14px;
231
+ line-height: 1.5;
232
+ color: #e0e0e0;
233
+ }
234
+ .stack-title {
235
+ color: #888;
236
+ font-size: 12px;
237
+ text-transform: uppercase;
238
+ margin-bottom: 10px;
239
+ }
240
+ </style>
241
+ </head>
242
+ <body>
243
+ <div class="error-container">
244
+ <div class="status-code">${statusCode}</div>
245
+ <h1>${getStatusText(statusCode)}</h1>
246
+ <p class="message">${escapeHtml(error.message)}</p>
247
+ ${
248
+ error.stack
249
+ ? `
250
+ <div class="stack-title">Stack Trace</div>
251
+ <pre>${escapeHtml(error.stack)}</pre>
252
+ `
253
+ : ''
254
+ }
255
+ </div>
256
+ </body>
257
+ </html>`;
258
+ }
259
+
260
+ /**
261
+ * Generates a production error page without sensitive details
262
+ */
263
+ function generateProdErrorPage(statusCode: number): string {
264
+ return `<!DOCTYPE html>
265
+ <html lang="en">
266
+ <head>
267
+ <meta charset="utf-8">
268
+ <meta name="viewport" content="width=device-width, initial-scale=1">
269
+ <title>Error ${statusCode}</title>
270
+ <style>
271
+ body {
272
+ font-family: system-ui, -apple-system, sans-serif;
273
+ margin: 0;
274
+ padding: 40px;
275
+ background: #f5f5f5;
276
+ display: flex;
277
+ align-items: center;
278
+ justify-content: center;
279
+ min-height: 100vh;
280
+ box-sizing: border-box;
281
+ }
282
+ .error-container {
283
+ text-align: center;
284
+ max-width: 400px;
285
+ }
286
+ .status-code {
287
+ font-size: 72px;
288
+ font-weight: bold;
289
+ color: #333;
290
+ margin-bottom: 10px;
291
+ }
292
+ h1 {
293
+ color: #666;
294
+ font-size: 24px;
295
+ margin: 0 0 20px 0;
296
+ }
297
+ p {
298
+ color: #888;
299
+ margin: 0;
300
+ }
301
+ a {
302
+ color: #0066cc;
303
+ text-decoration: none;
304
+ }
305
+ a:hover {
306
+ text-decoration: underline;
307
+ }
308
+ </style>
309
+ </head>
310
+ <body>
311
+ <div class="error-container">
312
+ <div class="status-code">${statusCode}</div>
313
+ <h1>${getStatusText(statusCode)}</h1>
314
+ <p><a href="/">Return to home</a></p>
315
+ </div>
316
+ </body>
317
+ </html>`;
318
+ }
319
+
320
+ /**
321
+ * Gets the status text for an HTTP status code
322
+ */
323
+ function getStatusText(statusCode: number): string {
324
+ const statusTexts: Record<number, string> = {
325
+ 400: 'Bad Request',
326
+ 401: 'Unauthorized',
327
+ 403: 'Forbidden',
328
+ 404: 'Page Not Found',
329
+ 405: 'Method Not Allowed',
330
+ 500: 'Internal Server Error',
331
+ 502: 'Bad Gateway',
332
+ 503: 'Service Unavailable',
333
+ };
334
+ return statusTexts[statusCode] || 'Error';
335
+ }
336
+
337
+ /**
338
+ * Escapes HTML special characters
339
+ */
340
+ function escapeHtml(str: string): string {
341
+ return str
342
+ .replaceAll('&', '&amp;')
343
+ .replaceAll('<', '&lt;')
344
+ .replaceAll('>', '&gt;')
345
+ .replaceAll('"', '&quot;')
346
+ .replaceAll("'", '&#039;');
347
+ }
348
+
349
+ /**
350
+ * Island hydration marker information
351
+ */
352
+ export interface IslandMarker {
353
+ /** Framework identifier (react, vue, svelte, etc.) */
354
+ framework: string;
355
+ /** Source path to the island module */
356
+ src: string;
357
+ /** Serialized props for the island */
358
+ props?: string;
359
+ /** Hydration strategy (load, idle, visible, media) */
360
+ hydrate?: string;
361
+ }
362
+
363
+ /**
364
+ * Extracts island markers from HTML
365
+ * Requirements: 9.1, 9.2, 9.3
366
+ *
367
+ * @param html - The rendered HTML string
368
+ * @returns Array of island markers found in the HTML
369
+ */
370
+ export function extractIslandMarkers(html: string): IslandMarker[] {
371
+ const markers: IslandMarker[] = [];
372
+
373
+ // Match island elements with data-framework attribute
374
+ const islandRegex = /<[^>]*data-framework="([^"]+)"[^>]*>/g;
375
+ let match;
376
+
377
+ while ((match = islandRegex.exec(html)) !== null) {
378
+ const fullMatch = match[0];
379
+ const framework = match[1];
380
+
381
+ // Extract data-src
382
+ const srcMatch = /data-src="([^"]+)"/.exec(fullMatch);
383
+ const src = srcMatch ? srcMatch[1] : '';
384
+
385
+ // Extract data-props
386
+ const propsMatch = /data-props="([^"]*)"/.exec(fullMatch);
387
+ const props = propsMatch ? propsMatch[1] : undefined;
388
+
389
+ // Extract data-hydrate (hydration strategy)
390
+ const hydrateMatch = /data-hydrate="([^"]+)"/.exec(fullMatch);
391
+ const hydrate = hydrateMatch ? hydrateMatch[1] : undefined;
392
+
393
+ markers.push({
394
+ framework,
395
+ src,
396
+ props,
397
+ hydrate,
398
+ });
399
+ }
400
+
401
+ return markers;
402
+ }
403
+
404
+ /**
405
+ * Ensures all required hydration markers are present on an island element
406
+ * Requirements: 9.1, 9.2, 9.3
407
+ *
408
+ * @param element - The island element HTML string
409
+ * @param marker - The island marker data to ensure
410
+ * @returns The element with all required markers
411
+ */
412
+ export function ensureHydrationMarkers(element: string, marker: Partial<IslandMarker>): string {
413
+ let result = element;
414
+
415
+ // Ensure data-framework is present
416
+ if (marker.framework && !result.includes('data-framework=')) {
417
+ result = result.replace(/>/, ` data-framework="${marker.framework}">`);
418
+ }
419
+
420
+ // Ensure data-src is present
421
+ if (marker.src && !result.includes('data-src=')) {
422
+ result = result.replace(/>/, ` data-src="${marker.src}">`);
423
+ }
424
+
425
+ // Ensure data-props is present (even if empty)
426
+ if (marker.props !== undefined && !result.includes('data-props=')) {
427
+ result = result.replace(/>/, ` data-props="${marker.props}">`);
428
+ }
429
+
430
+ return result;
431
+ }
432
+
433
+ /**
434
+ * Injects the client hydration script into HTML
435
+ * Requirements: 2.6, 9.4
436
+ *
437
+ * This function:
438
+ * 1. Checks if there are islands that need hydration
439
+ * 2. Injects the client script before </body> if not already present
440
+ * 3. Supports both development and production script paths
441
+ *
442
+ * @param html - The rendered HTML string
443
+ * @param isDev - Whether running in development mode
444
+ * @param options - Additional injection options
445
+ * @returns HTML with hydration script injected
446
+ */
447
+ export function injectHydrationScript(
448
+ html: string,
449
+ isDev: boolean,
450
+ options: {
451
+ /** Custom script path override */
452
+ scriptPath?: string;
453
+ /** Additional scripts to inject */
454
+ additionalScripts?: string[];
455
+ /** Whether to force injection even without islands */
456
+ forceInject?: boolean;
457
+ } = {},
458
+ ): string {
459
+ // Check if there are any islands that need hydration
460
+ const hasIslands = html.includes('data-framework=') || html.includes('data-src=');
461
+
462
+ if (!hasIslands && !options.forceInject) {
463
+ // No islands found, no need to inject hydration script
464
+ return html;
465
+ }
466
+
467
+ // Check if the client script is already included
468
+ const existingScripts = ['/src/client/main.js', '/dist/client.js', 'client/main.js'];
469
+
470
+ if (existingScripts.some(script => html.includes(script))) {
471
+ return html;
472
+ }
473
+
474
+ // Determine the script path based on environment or override
475
+ const scriptPath = options.scriptPath || (isDev ? '/src/client/main.js' : '/dist/client.js');
476
+
477
+ // Build the script tags
478
+ const scripts: string[] = [];
479
+
480
+ // Main hydration script
481
+ scripts.push(`<script type="module" src="${scriptPath}"></script>`);
482
+
483
+ // Additional scripts if provided
484
+ if (options.additionalScripts) {
485
+ scripts.push(...options.additionalScripts);
486
+ }
487
+
488
+ const scriptBlock = scripts.join('\n');
489
+
490
+ // Inject before closing </body> tag
491
+ if (html.includes('</body>')) {
492
+ return html.replace('</body>', `${scriptBlock}\n</body>`);
493
+ }
494
+
495
+ // Fallback: append to the end
496
+ return html + scriptBlock;
497
+ }
498
+
499
+ /**
500
+ * Validates that hydration markers are present in the HTML
501
+ * Requirements: 2.3, 9.1, 9.2, 9.3
502
+ *
503
+ * @param html - The rendered HTML string
504
+ * @returns Object with validation results
505
+ */
506
+ export function validateHydrationMarkers(html: string): {
507
+ hasFrameworkAttr: boolean;
508
+ hasSrcAttr: boolean;
509
+ hasPropsAttr: boolean;
510
+ islandCount: number;
511
+ islands: IslandMarker[];
512
+ hasClientScript: boolean;
513
+ isValid: boolean;
514
+ } {
515
+ // Extract all island markers
516
+ const islands = extractIslandMarkers(html);
517
+
518
+ // Count islands with each attribute type
519
+ const frameworkMatches = html.match(/data-framework="[^"]+"/g) || [];
520
+ const srcMatches = html.match(/data-src="[^"]+"/g) || [];
521
+ const propsMatches = html.match(/data-props="[^"]*"/g) || [];
522
+
523
+ // Check for client script
524
+ const hasClientScript =
525
+ html.includes('/src/client/main.js') || html.includes('/dist/client.js') || html.includes('client/main.js');
526
+
527
+ // Validation: all islands should have framework and src attributes
528
+ const allIslandsValid = islands.every(island => island.framework && island.src);
529
+
530
+ // Overall validity: if there are islands, they should be valid and have client script
531
+ const isValid = islands.length === 0 || (allIslandsValid && hasClientScript);
532
+
533
+ return {
534
+ hasFrameworkAttr: frameworkMatches.length > 0,
535
+ hasSrcAttr: srcMatches.length > 0,
536
+ hasPropsAttr: propsMatches.length > 0,
537
+ islandCount: frameworkMatches.length,
538
+ islands,
539
+ hasClientScript,
540
+ isValid,
541
+ };
542
+ }
543
+
544
+ /**
545
+ * Processes HTML to ensure all hydration requirements are met
546
+ * Requirements: 2.3, 2.6, 9.1, 9.2, 9.3, 9.4
547
+ *
548
+ * This is a convenience function that:
549
+ * 1. Validates existing hydration markers
550
+ * 2. Injects the client script if needed
551
+ * 3. Returns the processed HTML
552
+ *
553
+ * @param html - The rendered HTML string
554
+ * @param isDev - Whether running in development mode
555
+ * @returns Processed HTML with all hydration requirements met
556
+ */
557
+ export function processHydrationRequirements(
558
+ html: string,
559
+ isDev: boolean,
560
+ ): {
561
+ html: string;
562
+ validation: ReturnType<typeof validateHydrationMarkers>;
563
+ } {
564
+ // First, inject the hydration script
565
+ const processedHtml = injectHydrationScript(html, isDev);
566
+
567
+ // Then validate the result
568
+ const validation = validateHydrationMarkers(processedHtml);
569
+
570
+ return {
571
+ html: processedHtml,
572
+ validation,
573
+ };
574
+ }
575
+
576
+ /**
577
+ * Renders a page to HTML string (non-streaming)
578
+ * Requirements: 2.1, 2.2
579
+ *
580
+ * @param pageModule - The page module to render
581
+ * @param context - The render context
582
+ * @param options - Render options
583
+ * @returns SSR render result
584
+ */
585
+ export async function renderPage(
586
+ pageModule: PageModule,
587
+ context: NitroRenderContext,
588
+ options: SSRRenderOptions = {},
589
+ ): Promise<SSRRenderResult> {
590
+ try {
591
+ // Get page props if getServerSideProps is defined
592
+ let pageProps: Record<string, unknown> = {};
593
+ if (pageModule.getServerSideProps) {
594
+ pageProps = await pageModule.getServerSideProps(context);
595
+ }
596
+
597
+ // The actual rendering would integrate with Avalon's existing renderToHtml
598
+ // For now, we return a placeholder that shows the structure
599
+ const html = await renderPageComponent(pageModule, pageProps, context, options);
600
+
601
+ return {
602
+ html,
603
+ statusCode: 200,
604
+ headers: {
605
+ 'Content-Type': 'text/html; charset=utf-8',
606
+ },
607
+ };
608
+ } catch (error) {
609
+ console.error('[SSR Error]', error);
610
+
611
+ if (options.onError && error instanceof Error) {
612
+ options.onError(error);
613
+ }
614
+
615
+ throw error;
616
+ }
617
+ }
618
+
619
+ /**
620
+ * Renders a page component to HTML
621
+ * This is a placeholder that would integrate with Avalon's existing SSR pipeline
622
+ */
623
+ async function renderPageComponent(
624
+ pageModule: PageModule,
625
+ pageProps: Record<string, unknown>,
626
+ context: NitroRenderContext,
627
+ _options: SSRRenderOptions,
628
+ ): Promise<string> {
629
+ // This would integrate with the existing renderToHtml function
630
+ // For now, return a basic structure showing the integration point
631
+
632
+ // In the real implementation, this would:
633
+ // 1. Import and use renderToHtml from '../render/ssr.ts'
634
+ // 2. Create a RouteConfig from the pageModule
635
+ // 3. Apply layouts using the layout resolver
636
+ // 4. Return the fully rendered HTML
637
+
638
+ const componentName = (pageModule.default as { name?: string })?.name || 'Page';
639
+ const metadata = pageModule.metadata || {};
640
+
641
+ return `<!DOCTYPE html>
642
+ <html lang="en">
643
+ <head>
644
+ <meta charset="utf-8">
645
+ <meta name="viewport" content="width=device-width, initial-scale=1">
646
+ <title>${escapeHtml(String(metadata.title || 'Avalon App'))}</title>
647
+ ${metadata.description ? `<meta name="description" content="${escapeHtml(String(metadata.description))}">` : ''}
648
+ </head>
649
+ <body>
650
+ <div id="app" data-page="${escapeHtml(String(componentName))}" data-props='${escapeHtml(JSON.stringify(pageProps))}'>
651
+ <!-- Page content rendered by Avalon SSR pipeline -->
652
+ </div>
653
+ </body>
654
+ </html>`;
655
+ }
656
+
657
+ /**
658
+ * Streaming render state for tracking progress
659
+ */
660
+ interface StreamingRenderState {
661
+ shellSent: boolean;
662
+ contentSent: boolean;
663
+ closed: boolean;
664
+ error: Error | null;
665
+ }
666
+
667
+ /**
668
+ * Extended streaming options with additional callbacks
669
+ */
670
+ export interface StreamingSSROptions extends SSRRenderOptions {
671
+ /** Callback when shell rendering fails before streaming starts */
672
+ onShellError?: (error: Error) => void;
673
+ /** Timeout for shell ready in milliseconds */
674
+ shellReadyTimeout?: number;
675
+ /** Timeout for all content ready in milliseconds */
676
+ allReadyTimeout?: number;
677
+ }
678
+
679
+ /**
680
+ * Renders a page to a streaming response
681
+ * Requirements: 2.4
682
+ *
683
+ * This function implements streaming SSR with proper shell/content separation:
684
+ * 1. Shell (DOCTYPE, html, head, body opening) is sent first
685
+ * 2. onShellReady callback is invoked when shell is ready
686
+ * 3. Page content is streamed progressively
687
+ * 4. onAllReady callback is invoked when all content is complete
688
+ *
689
+ * @param pageModule - The page module to render
690
+ * @param context - The render context
691
+ * @param options - Render options including streaming callbacks
692
+ * @returns ReadableStream of HTML chunks
693
+ */
694
+ export async function renderPageStream(
695
+ pageModule: PageModule,
696
+ context: NitroRenderContext,
697
+ options: StreamingSSROptions = {},
698
+ ): Promise<ReadableStream<Uint8Array>> {
699
+ const encoder = new TextEncoder();
700
+ let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
701
+
702
+ const state: StreamingRenderState = {
703
+ shellSent: false,
704
+ contentSent: false,
705
+ closed: false,
706
+ error: null,
707
+ };
708
+
709
+ // Set up timeouts if specified
710
+ const shellTimeout = options.shellReadyTimeout;
711
+ const allReadyTimeout = options.allReadyTimeout;
712
+ let shellTimeoutId: ReturnType<typeof setTimeout> | null = null;
713
+ let allReadyTimeoutId: ReturnType<typeof setTimeout> | null = null;
714
+
715
+ const clearTimeouts = () => {
716
+ if (shellTimeoutId) {
717
+ clearTimeout(shellTimeoutId);
718
+ shellTimeoutId = null;
719
+ }
720
+ if (allReadyTimeoutId) {
721
+ clearTimeout(allReadyTimeoutId);
722
+ allReadyTimeoutId = null;
723
+ }
724
+ };
725
+
726
+ function handleStreamError(
727
+ err: Error,
728
+ isShellError: boolean,
729
+ state: StreamingRenderState,
730
+ ctrl: ReadableStreamDefaultController<Uint8Array> | null,
731
+ encoder: TextEncoder,
732
+ clearFn: () => void,
733
+ opts: StreamingSSROptions,
734
+ ) {
735
+ state.error = err;
736
+ clearFn();
737
+
738
+ console.error('[Streaming Error]', {
739
+ message: err.message,
740
+ stack: err.stack,
741
+ shellSent: state.shellSent,
742
+ isShellError,
743
+ timestamp: new Date().toISOString(),
744
+ });
745
+
746
+ // Call appropriate error callback
747
+ if (isShellError && opts.onShellError) {
748
+ opts.onShellError(err);
749
+ }
750
+ if (opts.onError) {
751
+ opts.onError(err);
752
+ }
753
+
754
+ if (!state.closed && ctrl) {
755
+ if (state.shellSent) {
756
+ // Inject error boundary into the stream
757
+ const errorBoundary = generateStreamingErrorBoundary(err);
758
+ ctrl.enqueue(encoder.encode(errorBoundary));
759
+
760
+ // Close the HTML document gracefully
761
+ const footer = generateStreamingFooter();
762
+ ctrl.enqueue(encoder.encode(footer));
763
+ } else {
764
+ // Send complete error page if shell hasn't been sent
765
+ const errorHtml = generateDevErrorPage(err, 500);
766
+ ctrl.enqueue(encoder.encode(errorHtml));
767
+ }
768
+
769
+ state.closed = true;
770
+ ctrl.close();
771
+ }
772
+ }
773
+
774
+ async function executeStreamingRender(ctrl: ReadableStreamDefaultController<Uint8Array>) {
775
+ controller = ctrl;
776
+
777
+ // Get page props if getServerSideProps is defined
778
+ let pageProps: Record<string, unknown> = {};
779
+ if (pageModule.getServerSideProps) {
780
+ pageProps = await pageModule.getServerSideProps(context);
781
+ }
782
+
783
+ const metadata = pageModule.metadata || {};
784
+
785
+ // Generate the shell (DOCTYPE, html, head, body opening)
786
+ const shell = generateStreamingShell(metadata, context);
787
+
788
+ // Send the shell
789
+ if (!state.closed) {
790
+ ctrl.enqueue(encoder.encode(shell));
791
+ state.shellSent = true;
792
+
793
+ // Clear shell timeout
794
+ if (shellTimeoutId) {
795
+ clearTimeout(shellTimeoutId);
796
+ shellTimeoutId = null;
797
+ }
798
+
799
+ // Notify that shell is ready
800
+ if (options.onShellReady) {
801
+ options.onShellReady();
802
+ }
803
+ }
804
+
805
+ // Set up all ready timeout
806
+ if (allReadyTimeout && allReadyTimeout > 0) {
807
+ allReadyTimeoutId = setTimeout(() => {
808
+ if (!state.contentSent && !state.closed) {
809
+ const timeoutError = new Error(`All ready timeout after ${allReadyTimeout}ms`);
810
+ handleStreamError(timeoutError, false, state, controller, encoder, clearTimeouts, options);
811
+ }
812
+ }, allReadyTimeout);
813
+ }
814
+
815
+ // Send the page content
816
+ if (!state.closed) {
817
+ const content = generateStreamingContent(pageModule, pageProps);
818
+ ctrl.enqueue(encoder.encode(content));
819
+ state.contentSent = true;
820
+ }
821
+
822
+ // Send the footer (closing body and html tags)
823
+ if (!state.closed) {
824
+ const footer = generateStreamingFooter();
825
+ ctrl.enqueue(encoder.encode(footer));
826
+ }
827
+
828
+ // Clear all ready timeout
829
+ clearTimeouts();
830
+
831
+ // Notify that all content is ready
832
+ if (options.onAllReady && !state.closed) {
833
+ options.onAllReady();
834
+ }
835
+
836
+ if (!state.closed) {
837
+ state.closed = true;
838
+ ctrl.close();
839
+ }
840
+ }
841
+
842
+ const stream = new ReadableStream<Uint8Array>({
843
+ async start(ctrl) {
844
+ controller = ctrl;
845
+
846
+ // Set up shell timeout
847
+ if (shellTimeout && shellTimeout > 0) {
848
+ shellTimeoutId = setTimeout(() => {
849
+ if (!state.shellSent && !state.closed) {
850
+ const timeoutError = new Error(`Shell ready timeout after ${shellTimeout}ms`);
851
+ handleStreamError(timeoutError, true, state, controller, encoder, clearTimeouts, options);
852
+ }
853
+ }, shellTimeout);
854
+ }
855
+
856
+ try {
857
+ await executeStreamingRender(ctrl);
858
+ } catch (error) {
859
+ handleStreamError(
860
+ error instanceof Error ? error : new Error(String(error)),
861
+ !state.shellSent,
862
+ state,
863
+ controller,
864
+ encoder,
865
+ clearTimeouts,
866
+ options,
867
+ );
868
+ }
869
+ },
870
+
871
+ cancel() {
872
+ clearTimeouts();
873
+ if (!state.closed && controller) {
874
+ state.closed = true;
875
+ try {
876
+ controller.close();
877
+ } catch {
878
+ // Already closed
879
+ }
880
+ }
881
+ },
882
+ });
883
+
884
+ return stream;
885
+ }
886
+
887
+ /**
888
+ * Generates the streaming shell (DOCTYPE, html, head, body opening)
889
+ */
890
+ function generateStreamingShell(
891
+ metadata: { title?: string; description?: string },
892
+ _context: NitroRenderContext,
893
+ ): string {
894
+ return `<!DOCTYPE html>
895
+ <html lang="en">
896
+ <head>
897
+ <meta charset="utf-8">
898
+ <meta name="viewport" content="width=device-width, initial-scale=1">
899
+ <title>${escapeHtml(String(metadata.title || 'Avalon App'))}</title>
900
+ ${metadata.description ? `<meta name="description" content="${escapeHtml(String(metadata.description))}">` : ''}
901
+ </head>
902
+ <body>
903
+ `;
904
+ }
905
+
906
+ /**
907
+ * Generates the streaming content
908
+ */
909
+ function generateStreamingContent(pageModule: PageModule, pageProps: Record<string, unknown>): string {
910
+ const componentName = (pageModule.default as { name?: string })?.name || 'Page';
911
+ return ` <div id="app" data-page="${escapeHtml(String(componentName))}" data-props='${escapeHtml(JSON.stringify(pageProps))}'>
912
+ <!-- Page content rendered by Avalon SSR pipeline -->
913
+ </div>
914
+ `;
915
+ }
916
+
917
+ /**
918
+ * Generates the streaming footer (closing body and html tags)
919
+ */
920
+ function generateStreamingFooter(): string {
921
+ return ` </body>
922
+ </html>`;
923
+ }
924
+
925
+ /**
926
+ * Generates an error boundary for mid-stream errors
927
+ */
928
+ function generateStreamingErrorBoundary(error: Error): string {
929
+ const isDev = process.env.NODE_ENV !== 'production';
930
+
931
+ const stackHtml = error.stack
932
+ ? `<pre style="
933
+ background: #f5f5f5;
934
+ padding: 10px;
935
+ border-radius: 4px;
936
+ overflow-x: auto;
937
+ font-size: 12px;
938
+ margin-top: 10px;
939
+ ">${escapeHtml(error.stack)}</pre>`
940
+ : '';
941
+
942
+ return `
943
+ <div class="streaming-error-boundary" data-error-boundary="true" style="
944
+ background: #fff3cd;
945
+ border: 2px solid #ffc107;
946
+ border-radius: 8px;
947
+ padding: 20px;
948
+ margin: 20px 0;
949
+ font-family: system-ui, -apple-system, sans-serif;
950
+ ">
951
+ <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
952
+ <span style="font-size: 24px;">⚠️</span>
953
+ <h3 style="margin: 0; color: #856404;">Streaming Error</h3>
954
+ </div>
955
+ <p style="margin: 10px 0; color: #856404;">
956
+ An error occurred while streaming this page.
957
+ </p>
958
+ ${
959
+ isDev
960
+ ? `
961
+ <details style="margin-top: 15px;">
962
+ <summary style="cursor: pointer; color: #856404; font-weight: bold;">
963
+ Error Details (Development Mode)
964
+ </summary>
965
+ <div style="margin-top: 10px;">
966
+ <p><strong>Error:</strong> ${escapeHtml(error.message)}</p>
967
+ ${stackHtml}
968
+ </div>
969
+ </details>
970
+ `
971
+ : ''
972
+ }
973
+ </div>
974
+ `;
975
+ }
976
+
977
+ /**
978
+ * Creates a streaming response with proper headers
979
+ * Requirements: 2.4
980
+ *
981
+ * @param stream - The ReadableStream to wrap
982
+ * @param options - Additional response options
983
+ * @returns Response object with streaming body
984
+ */
985
+ export function createStreamingResponse(
986
+ stream: ReadableStream<Uint8Array>,
987
+ options: {
988
+ status?: number;
989
+ headers?: Record<string, string>;
990
+ } = {},
991
+ ): Response {
992
+ const headers = new Headers({
993
+ 'Content-Type': 'text/html; charset=utf-8',
994
+ 'Transfer-Encoding': 'chunked',
995
+ ...options.headers,
996
+ });
997
+
998
+ return new Response(stream, {
999
+ status: options.status || 200,
1000
+ headers,
1001
+ });
1002
+ }
1003
+
1004
+ /**
1005
+ * Creates a scoped middleware getter that discovers and caches middleware routes.
1006
+ * Shared between createNitroRenderer and createNitroCatchAllRenderer.
1007
+ */
1008
+ function createScopedMiddlewareGetter(
1009
+ routesRef: { value: MiddlewareRoute[] | null },
1010
+ srcDir: string,
1011
+ isDev: boolean,
1012
+ ): () => Promise<MiddlewareRoute[]> {
1013
+ return async () => {
1014
+ routesRef.value ??= await discoverScopedMiddleware({
1015
+ baseDir: srcDir,
1016
+ devMode: isDev,
1017
+ });
1018
+ return routesRef.value;
1019
+ };
1020
+ }
1021
+
1022
+ /**
1023
+ * Creates an error handler with custom error page support.
1024
+ * Shared between createNitroRenderer and createNitroCatchAllRenderer.
1025
+ */
1026
+ function createErrorHandler(
1027
+ enableCustomErrorPages: boolean,
1028
+ errorHandlerOptions: ErrorHandlerOptions,
1029
+ isDev: boolean,
1030
+ ): (error: Error | HttpError, event: H3Event) => Promise<Response> {
1031
+ return async (error, event) => {
1032
+ if (enableCustomErrorPages) {
1033
+ return handleRenderErrorWithCustomPages(error, event, errorHandlerOptions);
1034
+ }
1035
+ return createErrorResponse(error, isDev);
1036
+ };
1037
+ }
1038
+
1039
+ /**
1040
+ * Creates the main Nitro renderer handler
1041
+ *
1042
+ * This is the catch-all handler for Nitro that renders pages not matched
1043
+ * by API routes or static files. It integrates with Nitro's routing system:
1044
+ *
1045
+ * 1. Nitro's file-system routing handles API routes (api/ directory)
1046
+ * 2. Nitro's static asset handling serves files from public/
1047
+ * 3. This renderer catches all remaining requests for SSR page rendering
1048
+ *
1049
+ * Middleware execution order:
1050
+ * 1. Global middleware (from middleware/ directory) - handled by Nitro
1051
+ * 2. Route-scoped middleware (from _middleware.ts files) - handled here
1052
+ * 3. Page rendering
1053
+ *
1054
+ * If global middleware terminates the chain, this handler is not called.
1055
+ * If route-scoped middleware terminates, page rendering is skipped.
1056
+ *
1057
+ * The renderer relies on Nitro's event context for route information when
1058
+ * available, falling back to pathname-based resolution for development.
1059
+ *
1060
+ * Requirements: 2.1, 2.2, 2.4, 5.1, 5.3, 10.5
1061
+ *
1062
+ * @param options - Render handler options
1063
+ * @returns Handler function for Nitro
1064
+ */
1065
+ export function createNitroRenderer(options: RenderHandlerOptions) {
1066
+ const { avalonConfig, isDev = false, enableCustomErrorPages = true } = options;
1067
+
1068
+ // Middleware routes cache - discovered once at startup
1069
+ let scopedMiddlewareRoutes: MiddlewareRoute[] | null = null;
1070
+
1071
+ // Error handler options for custom error pages
1072
+ const errorHandlerOptions: ErrorHandlerOptions = {
1073
+ isDev,
1074
+ avalonConfig,
1075
+ loadPageModule: options.loadPageModule,
1076
+ pagesDir: avalonConfig.pagesDir,
1077
+ };
1078
+
1079
+ // Pre-discover error pages if custom error pages are enabled
1080
+ if (enableCustomErrorPages) {
1081
+ discoverErrorPages(errorHandlerOptions).catch(err => {
1082
+ console.warn('[renderer] Failed to discover error pages:', err);
1083
+ });
1084
+ }
1085
+
1086
+ /**
1087
+ * Gets scoped middleware routes, discovering them on first call
1088
+ * Routes are cached for performance in production
1089
+ */
1090
+ const middlewareRef = { value: scopedMiddlewareRoutes };
1091
+ const getScopedMiddleware = createScopedMiddlewareGetter(middlewareRef, avalonConfig.srcDir || 'src', isDev);
1092
+
1093
+ /**
1094
+ * Handles errors with custom error page support
1095
+ */
1096
+ const handleError = createErrorHandler(enableCustomErrorPages, errorHandlerOptions, isDev);
1097
+
1098
+ return async function nitroRendererHandler(event: H3Event): Promise<Response> {
1099
+ const url = getRequestURL(event);
1100
+ const pathname = url.pathname;
1101
+
1102
+ try {
1103
+ // Execute route-scoped middleware before page rendering
1104
+ // Global middleware has already run (handled by Nitro's middleware/ directory)
1105
+ // Requirements: 5.1, 5.3
1106
+ const middlewareRoutes = await getScopedMiddleware();
1107
+ const middlewareResponse = await executeScopedMiddleware(event, middlewareRoutes, {
1108
+ devMode: isDev,
1109
+ });
1110
+
1111
+ // If middleware returned a response, use it and skip page rendering
1112
+ if (middlewareResponse) {
1113
+ if (isDev) {
1114
+ console.log(`[renderer] Middleware terminated request for ${pathname}`);
1115
+ }
1116
+ return middlewareResponse;
1117
+ }
1118
+
1119
+ // Check if Nitro has already resolved route information in the event context
1120
+ // This happens when Nitro's file-system routing has matched a route
1121
+ const nitroRouteContext = event.context.route as ResolvedPageRoute | undefined;
1122
+
1123
+ let route: ResolvedPageRoute | null = null;
1124
+
1125
+ if (nitroRouteContext) {
1126
+ // Use Nitro's resolved route information
1127
+ route = nitroRouteContext;
1128
+ } else {
1129
+ // Fall back to custom resolution (primarily for development)
1130
+ // In production with Nitro, this path is rarely taken as Nitro
1131
+ // handles route resolution before reaching the catch-all renderer
1132
+ route = options.resolvePageRoute
1133
+ ? await options.resolvePageRoute(pathname, avalonConfig.pagesDir)
1134
+ : await defaultResolvePageRoute(pathname, avalonConfig.pagesDir);
1135
+ }
1136
+
1137
+ if (!route) {
1138
+ // No page found, return 404 with custom error page support
1139
+ const error = createNotFoundError(`Page not found: ${pathname}`);
1140
+ return handleError(error, event);
1141
+ }
1142
+
1143
+ // Load the page module
1144
+ const pageModule = options.loadPageModule
1145
+ ? await options.loadPageModule(route.filePath)
1146
+ : await defaultLoadPageModule(route.filePath);
1147
+
1148
+ // Create render context with route params from Nitro or custom resolution
1149
+ // Nitro provides params via event.context.params when using its routing
1150
+ const routeParams = (event.context.params as Record<string, string>) || route.params;
1151
+ const renderContext = createRenderContext(event, routeParams);
1152
+
1153
+ // Resolve layouts if available
1154
+ if (options.resolveLayouts) {
1155
+ const layouts = await options.resolveLayouts(pathname, avalonConfig);
1156
+ renderContext.layoutContext = { layouts };
1157
+ }
1158
+
1159
+ // Render the page
1160
+ if (avalonConfig.streaming) {
1161
+ // Streaming SSR
1162
+ const stream = await renderPageStream(pageModule, renderContext, {
1163
+ onShellReady: () => {
1164
+ setResponseHeader(event, 'Content-Type', 'text/html; charset=utf-8');
1165
+ },
1166
+ });
1167
+
1168
+ return new Response(stream, {
1169
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
1170
+ });
1171
+ } else {
1172
+ // Non-streaming SSR
1173
+ const result = await renderPage(pageModule, renderContext);
1174
+
1175
+ // Inject hydration script
1176
+ const html = injectHydrationScript(result.html as string, isDev);
1177
+
1178
+ return new Response(html, {
1179
+ status: result.statusCode,
1180
+ headers: result.headers,
1181
+ });
1182
+ }
1183
+ } catch (error) {
1184
+ console.error('[Nitro Renderer Error]', error);
1185
+
1186
+ const err = error instanceof Error ? error : new Error(String(error));
1187
+ return handleError(err, event);
1188
+ }
1189
+ };
1190
+ }
1191
+
1192
+ /**
1193
+ * Default page route resolver
1194
+ *
1195
+ * This is a fallback resolver used primarily in development when Nitro's
1196
+ * file-system routing hasn't resolved the route. In production with Nitro,
1197
+ * route resolution is handled by Nitro's native routing system.
1198
+ *
1199
+ * The resolver converts URL pathnames to potential file paths in the pages
1200
+ * directory. It's intentionally simple as the heavy lifting of route matching
1201
+ * is delegated to Nitro's routing system.
1202
+ *
1203
+ * @param pathname - URL pathname to resolve
1204
+ * @param _pagesDir - Pages directory (unused, kept for interface compatibility)
1205
+ * @returns Resolved page route or null if not found
1206
+ */
1207
+ async function defaultResolvePageRoute(pathname: string, _pagesDir: string): Promise<ResolvedPageRoute | null> {
1208
+ // Handle root path
1209
+ if (pathname === '/' || pathname === '') {
1210
+ return {
1211
+ filePath: 'src/pages/index.tsx',
1212
+ pattern: '/',
1213
+ params: {},
1214
+ };
1215
+ }
1216
+
1217
+ // Convert pathname to potential file path
1218
+ // This is a simple conversion - Nitro's routing handles complex patterns
1219
+ const cleanPath = pathname.replace(/^\//, '').replace(/\/$/, '');
1220
+ const filePath = `src/pages/${cleanPath}.tsx`;
1221
+
1222
+ return {
1223
+ filePath,
1224
+ pattern: pathname,
1225
+ params: {},
1226
+ };
1227
+ }
1228
+
1229
+ /**
1230
+ * Default page module loader
1231
+ *
1232
+ * This is a placeholder implementation that returns a minimal page module.
1233
+ * In actual usage:
1234
+ * - Development: Vite's ssrLoadModule is used via the loadPageModule option
1235
+ * - Production: Modules are imported from the build output
1236
+ *
1237
+ * The actual module loading is handled by the integration layer (nitro-integration.ts)
1238
+ * which provides the appropriate loader based on the environment.
1239
+ *
1240
+ * @param _filePath - File path to load (unused in placeholder)
1241
+ * @returns Minimal page module
1242
+ */
1243
+ async function defaultLoadPageModule(_filePath: string): Promise<PageModule> {
1244
+ // This is a placeholder - actual loading is done by:
1245
+ // - Vite's ssrLoadModule in development
1246
+ // - Direct imports from build output in production
1247
+
1248
+ return {
1249
+ default: () => null,
1250
+ metadata: {
1251
+ title: 'Avalon Page',
1252
+ },
1253
+ };
1254
+ }
1255
+
1256
+ /**
1257
+ * Options for the Nitro catch-all renderer
1258
+ */
1259
+ export interface NitroCatchAllOptions {
1260
+ /** Avalon runtime configuration */
1261
+ avalonConfig: AvalonRuntimeConfig;
1262
+ /** Whether running in development mode */
1263
+ isDev?: boolean;
1264
+ /**
1265
+ * Page module loader function
1266
+ * In development, this should use Vite's ssrLoadModule
1267
+ * In production, this imports from the build output
1268
+ */
1269
+ loadPageModule: (filePath: string) => Promise<PageModule>;
1270
+ /** Optional layout resolver */
1271
+ resolveLayouts?: (routePath: string, config: AvalonRuntimeConfig) => Promise<string[]>;
1272
+ /**
1273
+ * Enable custom error pages (404.tsx, 500.tsx, _error.tsx)
1274
+ * When enabled, the renderer will look for custom error pages in the pages directory
1275
+ * Requirements: 10.5
1276
+ */
1277
+ enableCustomErrorPages?: boolean;
1278
+ }
1279
+
1280
+ /**
1281
+ * Creates a Nitro catch-all renderer handler
1282
+ *
1283
+ * This is the recommended way to create a renderer for Nitro's catch-all pattern.
1284
+ * It's designed to work with Nitro's file-system routing where:
1285
+ *
1286
+ * 1. API routes are handled by files in the api/ directory
1287
+ * 2. Static assets are served from public/
1288
+ * 3. This catch-all handles all remaining requests for SSR
1289
+ *
1290
+ * Middleware execution order:
1291
+ * 1. Global middleware (from middleware/ directory) - handled by Nitro
1292
+ * 2. Route-scoped middleware (from _middleware.ts files) - handled here
1293
+ * 3. Page rendering
1294
+ *
1295
+ * The handler expects Nitro to provide route information via event.context:
1296
+ * - event.context.params: Route parameters from dynamic segments
1297
+ * - event.context.route: Optional resolved route information
1298
+ *
1299
+ * Usage in Nitro routes/[...slug].ts:
1300
+ * ```ts
1301
+ * import { createNitroCatchAllRenderer } from '@useavalon/nitro/renderer';
1302
+ *
1303
+ * export default createNitroCatchAllRenderer({
1304
+ * avalonConfig: useRuntimeConfig().avalon,
1305
+ * isDev: import.meta.dev,
1306
+ * loadPageModule: async (filePath) => {
1307
+ * return await import(filePath);
1308
+ * }
1309
+ * });
1310
+ * ```
1311
+ *
1312
+ * Requirements: 2.1, 2.2, 2.6, 5.1, 5.3, 10.5
1313
+ *
1314
+ * @param options - Catch-all renderer options
1315
+ * @returns Nitro event handler function
1316
+ */
1317
+ export function createNitroCatchAllRenderer(options: NitroCatchAllOptions) {
1318
+ const { avalonConfig, isDev = false, loadPageModule, resolveLayouts, enableCustomErrorPages = true } = options;
1319
+
1320
+ // Middleware routes cache - discovered once at startup
1321
+ let scopedMiddlewareRoutes: MiddlewareRoute[] | null = null;
1322
+
1323
+ // Error handler options for custom error pages
1324
+ const errorHandlerOptions: ErrorHandlerOptions = {
1325
+ isDev,
1326
+ avalonConfig,
1327
+ loadPageModule,
1328
+ pagesDir: avalonConfig.pagesDir,
1329
+ };
1330
+
1331
+ // Pre-discover error pages if custom error pages are enabled
1332
+ if (enableCustomErrorPages) {
1333
+ discoverErrorPages(errorHandlerOptions).catch(err => {
1334
+ console.warn('[renderer] Failed to discover error pages:', err);
1335
+ });
1336
+ }
1337
+
1338
+ /**
1339
+ * Gets scoped middleware routes, discovering them on first call
1340
+ * Routes are cached for performance in production
1341
+ */
1342
+ const middlewareRef = { value: scopedMiddlewareRoutes };
1343
+ const getScopedMiddleware = createScopedMiddlewareGetter(middlewareRef, avalonConfig.srcDir || 'src', isDev);
1344
+
1345
+ /**
1346
+ * Handles errors with custom error page support
1347
+ */
1348
+ const handleError = createErrorHandler(enableCustomErrorPages, errorHandlerOptions, isDev);
1349
+
1350
+ return async function nitroCatchAllHandler(event: H3Event): Promise<Response> {
1351
+ const url = getRequestURL(event);
1352
+ const pathname = url.pathname;
1353
+
1354
+ try {
1355
+ // Execute route-scoped middleware before page rendering
1356
+ // Global middleware has already run (handled by Nitro's middleware/ directory)
1357
+ // Requirements: 5.1, 5.3
1358
+ const middlewareRoutes = await getScopedMiddleware();
1359
+ const middlewareResponse = await executeScopedMiddleware(event, middlewareRoutes, {
1360
+ devMode: isDev,
1361
+ });
1362
+
1363
+ // If middleware returned a response, use it and skip page rendering
1364
+ if (middlewareResponse) {
1365
+ if (isDev) {
1366
+ console.log(`[renderer] Middleware terminated request for ${pathname}`);
1367
+ }
1368
+ return middlewareResponse;
1369
+ }
1370
+
1371
+ // Get route params from Nitro's routing (e.g., from [...slug].ts)
1372
+ const params = (event.context.params as Record<string, string>) || {};
1373
+
1374
+ // Reconstruct the page file path from the pathname
1375
+ // Nitro's catch-all provides the slug, we map it to the pages directory
1376
+ const slug = params.slug || pathname.replace(/^\//, '') || 'index';
1377
+ const filePath = `${avalonConfig.pagesDir}/${slug}.tsx`;
1378
+
1379
+ // Try to load the page module
1380
+ let pageModule: PageModule;
1381
+ try {
1382
+ pageModule = await loadPageModule(filePath);
1383
+ } catch (loadError) {
1384
+ // Direct path failed — try index file in directory
1385
+ try {
1386
+ const indexPath = `${avalonConfig.pagesDir}/${slug}/index.tsx`;
1387
+ pageModule = await loadPageModule(indexPath);
1388
+ } catch (indexLoadError) {
1389
+ // Neither direct path nor index path found
1390
+ if (isDev) {
1391
+ console.debug(`[renderer] Page not found: ${filePath}`, loadError);
1392
+ console.debug(
1393
+ `[renderer] Index fallback not found: ${avalonConfig.pagesDir}/${slug}/index.tsx`,
1394
+ indexLoadError,
1395
+ );
1396
+ }
1397
+ const error = createNotFoundError(`Page not found: ${pathname}`);
1398
+ return handleError(error, event);
1399
+ }
1400
+ }
1401
+
1402
+ // Create render context
1403
+ const renderContext = createRenderContext(event, params);
1404
+
1405
+ // Resolve layouts if available
1406
+ if (resolveLayouts) {
1407
+ const layouts = await resolveLayouts(pathname, avalonConfig);
1408
+ renderContext.layoutContext = { layouts };
1409
+ }
1410
+
1411
+ // Render the page
1412
+ if (avalonConfig.streaming) {
1413
+ // Streaming SSR
1414
+ const stream = await renderPageStream(pageModule, renderContext, {
1415
+ onShellReady: () => {
1416
+ setResponseHeader(event, 'Content-Type', 'text/html; charset=utf-8');
1417
+ },
1418
+ });
1419
+
1420
+ return new Response(stream, {
1421
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
1422
+ });
1423
+ } else {
1424
+ // Non-streaming SSR
1425
+ const result = await renderPage(pageModule, renderContext);
1426
+
1427
+ // Inject hydration script - ensures client-side hydration works
1428
+ const html = injectHydrationScript(result.html as string, isDev);
1429
+
1430
+ return new Response(html, {
1431
+ status: result.statusCode,
1432
+ headers: result.headers,
1433
+ });
1434
+ }
1435
+ } catch (error) {
1436
+ console.error('[Nitro Catch-All Renderer Error]', error);
1437
+
1438
+ const err = error instanceof Error ? error : new Error(String(error));
1439
+ return handleError(err, event);
1440
+ }
1441
+ };
1442
+ }
1443
+
1444
+ /**
1445
+ * Re-export middleware cache clearing for hot reload support
1446
+ *
1447
+ * Call this function when middleware files change during development
1448
+ * to ensure the latest version is loaded on the next request.
1449
+ *
1450
+ * @example
1451
+ * ```ts
1452
+ * // In your HMR handler
1453
+ * if (file.endsWith('_middleware.ts')) {
1454
+ * clearRendererMiddlewareCache();
1455
+ * }
1456
+ * ```
1457
+ */
1458
+ export { clearMiddlewareCache as clearRendererMiddlewareCache } from '../middleware/index.ts';