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