@ripple-ts/vite-plugin 0.2.210 → 0.2.212
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/CHANGELOG.md +4 -0
- package/package.json +2 -2
- package/src/index.js +470 -4
- package/src/routes.js +70 -0
- package/src/server/middleware.js +126 -0
- package/src/server/production.js +266 -0
- package/src/server/render-route.js +239 -0
- package/src/server/router.js +122 -0
- package/src/server/server-route.js +46 -0
- package/types/index.d.ts +116 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {import('@ripple-ts/vite-plugin').Context} Context
|
|
3
|
+
* @typedef {import('@ripple-ts/vite-plugin').Middleware} Middleware
|
|
4
|
+
* @typedef {import('@ripple-ts/vite-plugin').NextFunction} NextFunction
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Compose multiple middlewares into a single middleware
|
|
9
|
+
* Follows Koa-style execution: request flows down, response flows back up
|
|
10
|
+
*
|
|
11
|
+
* @param {Middleware[]} middlewares
|
|
12
|
+
* @returns {(context: Context, finalHandler: () => Promise<Response>) => Promise<Response>}
|
|
13
|
+
*/
|
|
14
|
+
export function compose(middlewares) {
|
|
15
|
+
return function composed(context, finalHandler) {
|
|
16
|
+
let index = -1;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {number} i
|
|
20
|
+
* @returns {Promise<Response>}
|
|
21
|
+
*/
|
|
22
|
+
function dispatch(i) {
|
|
23
|
+
if (i <= index) {
|
|
24
|
+
return Promise.reject(new Error('next() called multiple times'));
|
|
25
|
+
}
|
|
26
|
+
index = i;
|
|
27
|
+
|
|
28
|
+
/** @type {Middleware | (() => Promise<Response>) | undefined} */
|
|
29
|
+
let fn;
|
|
30
|
+
|
|
31
|
+
if (i < middlewares.length) {
|
|
32
|
+
fn = middlewares[i];
|
|
33
|
+
} else if (i === middlewares.length) {
|
|
34
|
+
fn = finalHandler;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!fn) {
|
|
38
|
+
return Promise.reject(new Error('No handler provided'));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
// For the final handler, we don't pass next
|
|
43
|
+
if (i === middlewares.length) {
|
|
44
|
+
return Promise.resolve(/** @type {() => Promise<Response>} */ (fn)());
|
|
45
|
+
}
|
|
46
|
+
// For middlewares, pass context and next
|
|
47
|
+
return Promise.resolve(/** @type {Middleware} */ (fn)(context, () => dispatch(i + 1)));
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return Promise.reject(err);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return dispatch(0);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create a context object for the request
|
|
59
|
+
* @param {Request} request
|
|
60
|
+
* @param {Record<string, string>} params
|
|
61
|
+
* @returns {Context}
|
|
62
|
+
*/
|
|
63
|
+
export function createContext(request, params) {
|
|
64
|
+
return {
|
|
65
|
+
request,
|
|
66
|
+
params,
|
|
67
|
+
url: new URL(request.url),
|
|
68
|
+
state: new Map(),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run middlewares with a final handler
|
|
74
|
+
* Combines global middlewares, route-level before/after, and the handler
|
|
75
|
+
*
|
|
76
|
+
* @param {Context} context
|
|
77
|
+
* @param {Middleware[]} globalMiddlewares
|
|
78
|
+
* @param {Middleware[]} beforeMiddlewares
|
|
79
|
+
* @param {() => Promise<Response>} handler
|
|
80
|
+
* @param {Middleware[]} afterMiddlewares
|
|
81
|
+
* @returns {Promise<Response>}
|
|
82
|
+
*/
|
|
83
|
+
export async function runMiddlewareChain(
|
|
84
|
+
context,
|
|
85
|
+
globalMiddlewares,
|
|
86
|
+
beforeMiddlewares,
|
|
87
|
+
handler,
|
|
88
|
+
afterMiddlewares = [],
|
|
89
|
+
) {
|
|
90
|
+
// Combine global + before middlewares
|
|
91
|
+
const allMiddlewares = [...globalMiddlewares, ...beforeMiddlewares];
|
|
92
|
+
|
|
93
|
+
// If there are after middlewares, wrap the handler to run them
|
|
94
|
+
const wrappedHandler =
|
|
95
|
+
afterMiddlewares.length > 0
|
|
96
|
+
? async () => {
|
|
97
|
+
const response = await handler();
|
|
98
|
+
// After middlewares can inspect/modify the response
|
|
99
|
+
// but have limited ability to change it in our model
|
|
100
|
+
// We run them for side-effects (logging, etc.)
|
|
101
|
+
return runAfterMiddlewares(context, afterMiddlewares, response);
|
|
102
|
+
}
|
|
103
|
+
: handler;
|
|
104
|
+
|
|
105
|
+
const composed = compose(allMiddlewares);
|
|
106
|
+
return composed(context, wrappedHandler);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Run after middlewares with the response
|
|
111
|
+
* After middlewares run in order and can intercept/modify the response
|
|
112
|
+
*
|
|
113
|
+
* @param {Context} context
|
|
114
|
+
* @param {Middleware[]} middlewares
|
|
115
|
+
* @param {Response} response
|
|
116
|
+
* @returns {Promise<Response>}
|
|
117
|
+
*/
|
|
118
|
+
async function runAfterMiddlewares(context, middlewares, response) {
|
|
119
|
+
let currentResponse = response;
|
|
120
|
+
|
|
121
|
+
for (const middleware of middlewares) {
|
|
122
|
+
currentResponse = await middleware(context, async () => currentResponse);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return currentResponse;
|
|
126
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Production server runtime for Ripple metaframework
|
|
3
|
+
* This module is used in production builds to handle SSR + API routes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createRouter } from './router.js';
|
|
7
|
+
import { createContext, runMiddlewareChain } from './middleware.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {import('@ripple-ts/vite-plugin').Route} Route
|
|
11
|
+
* @typedef {import('@ripple-ts/vite-plugin').Middleware} Middleware
|
|
12
|
+
* @typedef {import('@ripple-ts/vite-plugin').RenderRoute} RenderRoute
|
|
13
|
+
* @typedef {import('@ripple-ts/vite-plugin').ServerRoute} ServerRoute
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} ServerManifest
|
|
18
|
+
* @property {Route[]} routes - Array of route definitions
|
|
19
|
+
* @property {Record<string, Function>} components - Map of entry path to component function
|
|
20
|
+
* @property {Record<string, Function>} layouts - Map of layout path to layout function
|
|
21
|
+
* @property {Middleware[]} middlewares - Global middlewares
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} RenderResult
|
|
26
|
+
* @property {string} head
|
|
27
|
+
* @property {string} body
|
|
28
|
+
* @property {Set<string>} css
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create a production request handler from a manifest
|
|
33
|
+
*
|
|
34
|
+
* @param {ServerManifest} manifest
|
|
35
|
+
* @param {Object} options
|
|
36
|
+
* @param {(component: Function) => Promise<RenderResult>} options.render - SSR render function
|
|
37
|
+
* @param {(css: Set<string>) => string} options.getCss - Get CSS for hashes
|
|
38
|
+
* @param {string} options.clientBase - Base path for client assets
|
|
39
|
+
* @returns {(request: Request) => Promise<Response>}
|
|
40
|
+
*/
|
|
41
|
+
export function createHandler(manifest, options) {
|
|
42
|
+
const { render, getCss, clientBase = '/' } = options;
|
|
43
|
+
const router = createRouter(manifest.routes);
|
|
44
|
+
const globalMiddlewares = manifest.middlewares || [];
|
|
45
|
+
|
|
46
|
+
return async function handler(request) {
|
|
47
|
+
const url = new URL(request.url);
|
|
48
|
+
const method = request.method;
|
|
49
|
+
|
|
50
|
+
// Match route
|
|
51
|
+
const match = router.match(method, url.pathname);
|
|
52
|
+
|
|
53
|
+
if (!match) {
|
|
54
|
+
return new Response('Not Found', { status: 404 });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Create context
|
|
58
|
+
const context = createContext(request, match.params);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
if (match.route.type === 'render') {
|
|
62
|
+
return await handleRenderRoute(
|
|
63
|
+
match.route,
|
|
64
|
+
context,
|
|
65
|
+
manifest,
|
|
66
|
+
globalMiddlewares,
|
|
67
|
+
render,
|
|
68
|
+
getCss,
|
|
69
|
+
clientBase,
|
|
70
|
+
);
|
|
71
|
+
} else {
|
|
72
|
+
return await handleServerRoute(match.route, context, globalMiddlewares);
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error('[ripple] Request error:', error);
|
|
76
|
+
return new Response('Internal Server Error', { status: 500 });
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Handle a RenderRoute in production
|
|
83
|
+
*
|
|
84
|
+
* @param {RenderRoute} route
|
|
85
|
+
* @param {import('@ripple-ts/vite-plugin').Context} context
|
|
86
|
+
* @param {ServerManifest} manifest
|
|
87
|
+
* @param {Middleware[]} globalMiddlewares
|
|
88
|
+
* @param {(component: Function) => Promise<RenderResult>} render
|
|
89
|
+
* @param {(css: Set<string>) => string} getCss
|
|
90
|
+
* @param {string} clientBase
|
|
91
|
+
* @returns {Promise<Response>}
|
|
92
|
+
*/
|
|
93
|
+
async function handleRenderRoute(
|
|
94
|
+
route,
|
|
95
|
+
context,
|
|
96
|
+
manifest,
|
|
97
|
+
globalMiddlewares,
|
|
98
|
+
render,
|
|
99
|
+
getCss,
|
|
100
|
+
clientBase,
|
|
101
|
+
) {
|
|
102
|
+
const renderHandler = async () => {
|
|
103
|
+
// Get the page component
|
|
104
|
+
const PageComponent = manifest.components[route.entry];
|
|
105
|
+
if (!PageComponent) {
|
|
106
|
+
throw new Error(`Component not found: ${route.entry}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Get layout if specified
|
|
110
|
+
let RootComponent;
|
|
111
|
+
const pageProps = { params: context.params };
|
|
112
|
+
|
|
113
|
+
if (route.layout && manifest.layouts[route.layout]) {
|
|
114
|
+
const LayoutComponent = manifest.layouts[route.layout];
|
|
115
|
+
RootComponent = createLayoutWrapper(LayoutComponent, PageComponent, pageProps);
|
|
116
|
+
} else {
|
|
117
|
+
RootComponent = createPropsWrapper(PageComponent, pageProps);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Render to HTML
|
|
121
|
+
const { head, body, css } = await render(RootComponent);
|
|
122
|
+
|
|
123
|
+
// Generate CSS tags
|
|
124
|
+
let cssContent = '';
|
|
125
|
+
if (css.size > 0) {
|
|
126
|
+
const cssString = getCss(css);
|
|
127
|
+
if (cssString) {
|
|
128
|
+
cssContent = `<style data-ripple-ssr>${cssString}</style>`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Generate the full HTML document
|
|
133
|
+
const html = generateHtml({
|
|
134
|
+
head: head + cssContent,
|
|
135
|
+
body,
|
|
136
|
+
route,
|
|
137
|
+
context,
|
|
138
|
+
clientBase,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return new Response(html, {
|
|
142
|
+
status: 200,
|
|
143
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return runMiddlewareChain(context, globalMiddlewares, route.before || [], renderHandler, []);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Handle a ServerRoute in production
|
|
152
|
+
*
|
|
153
|
+
* @param {ServerRoute} route
|
|
154
|
+
* @param {import('@ripple-ts/vite-plugin').Context} context
|
|
155
|
+
* @param {Middleware[]} globalMiddlewares
|
|
156
|
+
* @returns {Promise<Response>}
|
|
157
|
+
*/
|
|
158
|
+
async function handleServerRoute(route, context, globalMiddlewares) {
|
|
159
|
+
const handler = async () => route.handler(context);
|
|
160
|
+
return runMiddlewareChain(
|
|
161
|
+
context,
|
|
162
|
+
globalMiddlewares,
|
|
163
|
+
route.before || [],
|
|
164
|
+
handler,
|
|
165
|
+
route.after || [],
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Create a wrapper component that injects props
|
|
171
|
+
* @param {Function} Component
|
|
172
|
+
* @param {Record<string, unknown>} props
|
|
173
|
+
* @returns {Function}
|
|
174
|
+
*/
|
|
175
|
+
function createPropsWrapper(Component, props) {
|
|
176
|
+
return function WrappedComponent(/** @type {unknown} */ output, additionalProps = {}) {
|
|
177
|
+
return Component(output, { ...additionalProps, ...props });
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Create a wrapper that composes a layout with a page component
|
|
183
|
+
* @param {Function} Layout
|
|
184
|
+
* @param {Function} Page
|
|
185
|
+
* @param {Record<string, unknown>} pageProps
|
|
186
|
+
* @returns {Function}
|
|
187
|
+
*/
|
|
188
|
+
function createLayoutWrapper(Layout, Page, pageProps) {
|
|
189
|
+
return function LayoutWrapper(/** @type {unknown} */ output, additionalProps = {}) {
|
|
190
|
+
const children = (/** @type {unknown} */ childOutput) => {
|
|
191
|
+
return Page(childOutput, { ...additionalProps, ...pageProps });
|
|
192
|
+
};
|
|
193
|
+
return Layout(output, { ...additionalProps, children });
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Generate the full HTML document for production
|
|
199
|
+
* @param {Object} options
|
|
200
|
+
* @param {string} options.head
|
|
201
|
+
* @param {string} options.body
|
|
202
|
+
* @param {RenderRoute} options.route
|
|
203
|
+
* @param {import('@ripple-ts/vite-plugin').Context} options.context
|
|
204
|
+
* @param {string} options.clientBase
|
|
205
|
+
* @returns {string}
|
|
206
|
+
*/
|
|
207
|
+
function generateHtml({ head, body, route, context, clientBase }) {
|
|
208
|
+
const routeData = JSON.stringify({
|
|
209
|
+
entry: route.entry,
|
|
210
|
+
params: context.params,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return `<!DOCTYPE html>
|
|
214
|
+
<html lang="en">
|
|
215
|
+
<head>
|
|
216
|
+
<meta charset="UTF-8">
|
|
217
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
218
|
+
${head}
|
|
219
|
+
</head>
|
|
220
|
+
<body>
|
|
221
|
+
<div id="app">${body}</div>
|
|
222
|
+
<script id="__ripple_data" type="application/json">${escapeScript(routeData)}</script>
|
|
223
|
+
<script type="module">
|
|
224
|
+
import { hydrate, mount } from '${clientBase}ripple.js';
|
|
225
|
+
|
|
226
|
+
const data = JSON.parse(document.getElementById('__ripple_data').textContent);
|
|
227
|
+
const target = document.getElementById('app');
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const module = await import('${clientBase}' + data.entry.replace(/^\\//, '').replace(/\\.ripple$/, '.js'));
|
|
231
|
+
const Component =
|
|
232
|
+
module.default ||
|
|
233
|
+
Object.entries(module).find(([key, value]) => typeof value === 'function' && /^[A-Z]/.test(key))?.[1];
|
|
234
|
+
|
|
235
|
+
if (!Component || !target) {
|
|
236
|
+
console.error('[ripple] Unable to hydrate route: missing component export or #app target.');
|
|
237
|
+
} else {
|
|
238
|
+
try {
|
|
239
|
+
hydrate(Component, {
|
|
240
|
+
target,
|
|
241
|
+
props: { params: data.params }
|
|
242
|
+
});
|
|
243
|
+
} catch (error) {
|
|
244
|
+
console.warn('[ripple] Hydration failed, falling back to mount.', error);
|
|
245
|
+
mount(Component, {
|
|
246
|
+
target,
|
|
247
|
+
props: { params: data.params }
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} catch (error) {
|
|
252
|
+
console.error('[ripple] Failed to bootstrap client hydration.', error);
|
|
253
|
+
}
|
|
254
|
+
</script>
|
|
255
|
+
</body>
|
|
256
|
+
</html>`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Escape script content to prevent XSS
|
|
261
|
+
* @param {string} str
|
|
262
|
+
* @returns {string}
|
|
263
|
+
*/
|
|
264
|
+
function escapeScript(str) {
|
|
265
|
+
return str.replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
|
|
266
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/// <reference types="ripple/compiler/internal/rpc" />
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {import('@ripple-ts/vite-plugin').Context} Context
|
|
7
|
+
* @typedef {import('@ripple-ts/vite-plugin').RenderRoute} RenderRoute
|
|
8
|
+
* @typedef {import('vite').ViteDevServer} ViteDevServer
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} RenderResult
|
|
13
|
+
* @property {string} head
|
|
14
|
+
* @property {string} body
|
|
15
|
+
* @property {Set<string>} css
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Handle SSR rendering for a RenderRoute
|
|
20
|
+
*
|
|
21
|
+
* @param {RenderRoute} route
|
|
22
|
+
* @param {Context} context
|
|
23
|
+
* @param {ViteDevServer} vite
|
|
24
|
+
* @returns {Promise<Response>}
|
|
25
|
+
*/
|
|
26
|
+
export async function handleRenderRoute(route, context, vite) {
|
|
27
|
+
try {
|
|
28
|
+
// Initialize so the server can register
|
|
29
|
+
// RPC functions from #server blocks during SSR module loading
|
|
30
|
+
if (!globalThis.rpc_modules) {
|
|
31
|
+
globalThis.rpc_modules = new Map();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Load ripple server utilities
|
|
35
|
+
const { render, get_css_for_hashes } = await vite.ssrLoadModule('ripple/server');
|
|
36
|
+
|
|
37
|
+
// Load the page component
|
|
38
|
+
const pageModule = await vite.ssrLoadModule(route.entry);
|
|
39
|
+
const PageComponent = getDefaultExport(pageModule);
|
|
40
|
+
|
|
41
|
+
if (!PageComponent) {
|
|
42
|
+
throw new Error(`No default export found in ${route.entry}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Build the component tree (with optional layout)
|
|
46
|
+
let RootComponent;
|
|
47
|
+
const pageProps = { params: context.params };
|
|
48
|
+
|
|
49
|
+
if (route.layout) {
|
|
50
|
+
// Load layout component
|
|
51
|
+
const layoutModule = await vite.ssrLoadModule(route.layout);
|
|
52
|
+
const LayoutComponent = getDefaultExport(layoutModule);
|
|
53
|
+
|
|
54
|
+
if (!LayoutComponent) {
|
|
55
|
+
throw new Error(`No default export found in ${route.layout}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Create a wrapper that composes layout + page
|
|
59
|
+
// Layout receives children as a component prop
|
|
60
|
+
RootComponent = createLayoutWrapper(LayoutComponent, PageComponent, pageProps);
|
|
61
|
+
} else {
|
|
62
|
+
// No layout - render page directly with props
|
|
63
|
+
RootComponent = createPropsWrapper(PageComponent, pageProps);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Render to HTML
|
|
67
|
+
/** @type {RenderResult} */
|
|
68
|
+
const { head, body, css } = await render(RootComponent);
|
|
69
|
+
|
|
70
|
+
// Generate CSS tags
|
|
71
|
+
let cssContent = '';
|
|
72
|
+
if (css.size > 0) {
|
|
73
|
+
const cssString = get_css_for_hashes(css);
|
|
74
|
+
if (cssString) {
|
|
75
|
+
cssContent = `<style data-ripple-ssr>${cssString}</style>`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Build head content with hydration data
|
|
80
|
+
const routeData = JSON.stringify({
|
|
81
|
+
entry: route.entry,
|
|
82
|
+
params: context.params,
|
|
83
|
+
});
|
|
84
|
+
const headContent = [
|
|
85
|
+
head,
|
|
86
|
+
cssContent,
|
|
87
|
+
`<script id="__ripple_data" type="application/json">${escapeScript(routeData)}</script>`,
|
|
88
|
+
]
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.join('\n');
|
|
91
|
+
|
|
92
|
+
// Load and process index.html template
|
|
93
|
+
const templatePath = join(vite.config.root, 'public', 'index.html');
|
|
94
|
+
let template = await readFile(templatePath, 'utf-8');
|
|
95
|
+
|
|
96
|
+
// Apply Vite's HTML transforms (HMR client, module resolution, etc.)
|
|
97
|
+
template = await vite.transformIndexHtml(context.url.pathname, template);
|
|
98
|
+
|
|
99
|
+
// Replace placeholders
|
|
100
|
+
let html = template.replace('<!--ssr-head-->', headContent).replace('<!--ssr-body-->', body);
|
|
101
|
+
|
|
102
|
+
// Inject hydration script before </body>
|
|
103
|
+
const hydrationScript = `<script type="module" src="/@id/virtual:ripple-hydrate"></script>`;
|
|
104
|
+
html = html.replace('</body>', `${hydrationScript}\n</body>`);
|
|
105
|
+
|
|
106
|
+
return new Response(html, {
|
|
107
|
+
status: 200,
|
|
108
|
+
headers: {
|
|
109
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error('[ripple] SSR render error:', error);
|
|
114
|
+
|
|
115
|
+
const errorHtml = generateErrorHtml(error, route);
|
|
116
|
+
return new Response(errorHtml, {
|
|
117
|
+
status: 500,
|
|
118
|
+
headers: {
|
|
119
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get the default export from a module
|
|
127
|
+
* Handles both `export default` and `export { X as default }`
|
|
128
|
+
*
|
|
129
|
+
* @param {Record<string, unknown>} module
|
|
130
|
+
* @returns {Function | null}
|
|
131
|
+
*/
|
|
132
|
+
function getDefaultExport(module) {
|
|
133
|
+
if (typeof module.default === 'function') {
|
|
134
|
+
return module.default;
|
|
135
|
+
}
|
|
136
|
+
// Look for a component-like export (capitalized function)
|
|
137
|
+
for (const [key, value] of Object.entries(module)) {
|
|
138
|
+
if (typeof value === 'function' && /^[A-Z]/.test(key)) {
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Create a wrapper component that injects props
|
|
147
|
+
*
|
|
148
|
+
* @param {Function} Component
|
|
149
|
+
* @param {Record<string, unknown>} props
|
|
150
|
+
* @returns {Function}
|
|
151
|
+
*/
|
|
152
|
+
function createPropsWrapper(Component, props) {
|
|
153
|
+
/**
|
|
154
|
+
* @param {unknown} output
|
|
155
|
+
* @param {Record<string, unknown>} additionalProps
|
|
156
|
+
*/
|
|
157
|
+
return function WrappedComponent(output, additionalProps = {}) {
|
|
158
|
+
return Component(output, { ...additionalProps, ...props });
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Create a wrapper that composes a layout with a page component
|
|
164
|
+
* The layout receives the page as its children prop
|
|
165
|
+
*
|
|
166
|
+
* @param {Function} Layout
|
|
167
|
+
* @param {Function} Page
|
|
168
|
+
* @param {Record<string, unknown>} pageProps
|
|
169
|
+
* @returns {Function}
|
|
170
|
+
*/
|
|
171
|
+
function createLayoutWrapper(Layout, Page, pageProps) {
|
|
172
|
+
/**
|
|
173
|
+
* @param {unknown} output
|
|
174
|
+
* @param {Record<string, unknown>} additionalProps
|
|
175
|
+
*/
|
|
176
|
+
return function LayoutWrapper(output, additionalProps = {}) {
|
|
177
|
+
// Children is a component function that renders the page
|
|
178
|
+
const children = (/** @type {unknown} */ childOutput) => {
|
|
179
|
+
return Page(childOutput, { ...additionalProps, ...pageProps });
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return Layout(output, { ...additionalProps, children });
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Escape script content to prevent XSS
|
|
188
|
+
* @param {string} str
|
|
189
|
+
* @returns {string}
|
|
190
|
+
*/
|
|
191
|
+
function escapeScript(str) {
|
|
192
|
+
return str.replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Generate an error HTML page for development
|
|
197
|
+
*
|
|
198
|
+
* @param {unknown} error
|
|
199
|
+
* @param {RenderRoute} route
|
|
200
|
+
* @returns {string}
|
|
201
|
+
*/
|
|
202
|
+
function generateErrorHtml(error, route) {
|
|
203
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
204
|
+
const stack = error instanceof Error ? error.stack : '';
|
|
205
|
+
|
|
206
|
+
return `<!DOCTYPE html>
|
|
207
|
+
<html lang="en">
|
|
208
|
+
<head>
|
|
209
|
+
<meta charset="UTF-8">
|
|
210
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
211
|
+
<title>SSR Error</title>
|
|
212
|
+
<style>
|
|
213
|
+
body { font-family: system-ui, sans-serif; padding: 2rem; background: #1a1a1a; color: #fff; }
|
|
214
|
+
h1 { color: #ff6b6b; }
|
|
215
|
+
pre { background: #2d2d2d; padding: 1rem; border-radius: 4px; overflow-x: auto; }
|
|
216
|
+
.route { color: #888; }
|
|
217
|
+
</style>
|
|
218
|
+
</head>
|
|
219
|
+
<body>
|
|
220
|
+
<h1>SSR Render Error</h1>
|
|
221
|
+
<p class="route">Route: ${route.path} → ${route.entry}</p>
|
|
222
|
+
<pre>${escapeHtml(message)}</pre>
|
|
223
|
+
${stack ? `<pre>${escapeHtml(stack)}</pre>` : ''}
|
|
224
|
+
</body>
|
|
225
|
+
</html>`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Escape HTML entities
|
|
230
|
+
* @param {string} str
|
|
231
|
+
* @returns {string}
|
|
232
|
+
*/
|
|
233
|
+
function escapeHtml(str) {
|
|
234
|
+
return str
|
|
235
|
+
.replace(/&/g, '&')
|
|
236
|
+
.replace(/</g, '<')
|
|
237
|
+
.replace(/>/g, '>')
|
|
238
|
+
.replace(/"/g, '"');
|
|
239
|
+
}
|