@ripple-ts/vite-plugin 0.2.210 → 0.2.211
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 +2 -0
- package/package.json +2 -2
- package/src/index.js +368 -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 +232 -0
- package/src/server/router.js +122 -0
- package/src/server/server-route.js +46 -0
- package/types/index.d.ts +103 -0
|
@@ -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,232 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {import('@ripple-ts/vite-plugin').Context} Context
|
|
6
|
+
* @typedef {import('@ripple-ts/vite-plugin').RenderRoute} RenderRoute
|
|
7
|
+
* @typedef {import('vite').ViteDevServer} ViteDevServer
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} RenderResult
|
|
12
|
+
* @property {string} head
|
|
13
|
+
* @property {string} body
|
|
14
|
+
* @property {Set<string>} css
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Handle SSR rendering for a RenderRoute
|
|
19
|
+
*
|
|
20
|
+
* @param {RenderRoute} route
|
|
21
|
+
* @param {Context} context
|
|
22
|
+
* @param {ViteDevServer} vite
|
|
23
|
+
* @returns {Promise<Response>}
|
|
24
|
+
*/
|
|
25
|
+
export async function handleRenderRoute(route, context, vite) {
|
|
26
|
+
try {
|
|
27
|
+
// Load ripple server utilities
|
|
28
|
+
const { render, get_css_for_hashes } = await vite.ssrLoadModule('ripple/server');
|
|
29
|
+
|
|
30
|
+
// Load the page component
|
|
31
|
+
const pageModule = await vite.ssrLoadModule(route.entry);
|
|
32
|
+
const PageComponent = getDefaultExport(pageModule);
|
|
33
|
+
|
|
34
|
+
if (!PageComponent) {
|
|
35
|
+
throw new Error(`No default export found in ${route.entry}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Build the component tree (with optional layout)
|
|
39
|
+
let RootComponent;
|
|
40
|
+
const pageProps = { params: context.params };
|
|
41
|
+
|
|
42
|
+
if (route.layout) {
|
|
43
|
+
// Load layout component
|
|
44
|
+
const layoutModule = await vite.ssrLoadModule(route.layout);
|
|
45
|
+
const LayoutComponent = getDefaultExport(layoutModule);
|
|
46
|
+
|
|
47
|
+
if (!LayoutComponent) {
|
|
48
|
+
throw new Error(`No default export found in ${route.layout}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Create a wrapper that composes layout + page
|
|
52
|
+
// Layout receives children as a component prop
|
|
53
|
+
RootComponent = createLayoutWrapper(LayoutComponent, PageComponent, pageProps);
|
|
54
|
+
} else {
|
|
55
|
+
// No layout - render page directly with props
|
|
56
|
+
RootComponent = createPropsWrapper(PageComponent, pageProps);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Render to HTML
|
|
60
|
+
/** @type {RenderResult} */
|
|
61
|
+
const { head, body, css } = await render(RootComponent);
|
|
62
|
+
|
|
63
|
+
// Generate CSS tags
|
|
64
|
+
let cssContent = '';
|
|
65
|
+
if (css.size > 0) {
|
|
66
|
+
const cssString = get_css_for_hashes(css);
|
|
67
|
+
if (cssString) {
|
|
68
|
+
cssContent = `<style data-ripple-ssr>${cssString}</style>`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Build head content with hydration data
|
|
73
|
+
const routeData = JSON.stringify({
|
|
74
|
+
entry: route.entry,
|
|
75
|
+
params: context.params,
|
|
76
|
+
});
|
|
77
|
+
const headContent = [
|
|
78
|
+
head,
|
|
79
|
+
cssContent,
|
|
80
|
+
`<script id="__ripple_data" type="application/json">${escapeScript(routeData)}</script>`,
|
|
81
|
+
]
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.join('\n');
|
|
84
|
+
|
|
85
|
+
// Load and process index.html template
|
|
86
|
+
const templatePath = join(vite.config.root, 'public', 'index.html');
|
|
87
|
+
let template = await readFile(templatePath, 'utf-8');
|
|
88
|
+
|
|
89
|
+
// Apply Vite's HTML transforms (HMR client, module resolution, etc.)
|
|
90
|
+
template = await vite.transformIndexHtml(context.url.pathname, template);
|
|
91
|
+
|
|
92
|
+
// Replace placeholders
|
|
93
|
+
let html = template.replace('<!--ssr-head-->', headContent).replace('<!--ssr-body-->', body);
|
|
94
|
+
|
|
95
|
+
// Inject hydration script before </body>
|
|
96
|
+
const hydrationScript = `<script type="module" src="/@id/virtual:ripple-hydrate"></script>`;
|
|
97
|
+
html = html.replace('</body>', `${hydrationScript}\n</body>`);
|
|
98
|
+
|
|
99
|
+
return new Response(html, {
|
|
100
|
+
status: 200,
|
|
101
|
+
headers: {
|
|
102
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error('[ripple] SSR render error:', error);
|
|
107
|
+
|
|
108
|
+
const errorHtml = generateErrorHtml(error, route);
|
|
109
|
+
return new Response(errorHtml, {
|
|
110
|
+
status: 500,
|
|
111
|
+
headers: {
|
|
112
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get the default export from a module
|
|
120
|
+
* Handles both `export default` and `export { X as default }`
|
|
121
|
+
*
|
|
122
|
+
* @param {Record<string, unknown>} module
|
|
123
|
+
* @returns {Function | null}
|
|
124
|
+
*/
|
|
125
|
+
function getDefaultExport(module) {
|
|
126
|
+
if (typeof module.default === 'function') {
|
|
127
|
+
return module.default;
|
|
128
|
+
}
|
|
129
|
+
// Look for a component-like export (capitalized function)
|
|
130
|
+
for (const [key, value] of Object.entries(module)) {
|
|
131
|
+
if (typeof value === 'function' && /^[A-Z]/.test(key)) {
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create a wrapper component that injects props
|
|
140
|
+
*
|
|
141
|
+
* @param {Function} Component
|
|
142
|
+
* @param {Record<string, unknown>} props
|
|
143
|
+
* @returns {Function}
|
|
144
|
+
*/
|
|
145
|
+
function createPropsWrapper(Component, props) {
|
|
146
|
+
/**
|
|
147
|
+
* @param {unknown} output
|
|
148
|
+
* @param {Record<string, unknown>} additionalProps
|
|
149
|
+
*/
|
|
150
|
+
return function WrappedComponent(output, additionalProps = {}) {
|
|
151
|
+
return Component(output, { ...additionalProps, ...props });
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Create a wrapper that composes a layout with a page component
|
|
157
|
+
* The layout receives the page as its children prop
|
|
158
|
+
*
|
|
159
|
+
* @param {Function} Layout
|
|
160
|
+
* @param {Function} Page
|
|
161
|
+
* @param {Record<string, unknown>} pageProps
|
|
162
|
+
* @returns {Function}
|
|
163
|
+
*/
|
|
164
|
+
function createLayoutWrapper(Layout, Page, pageProps) {
|
|
165
|
+
/**
|
|
166
|
+
* @param {unknown} output
|
|
167
|
+
* @param {Record<string, unknown>} additionalProps
|
|
168
|
+
*/
|
|
169
|
+
return function LayoutWrapper(output, additionalProps = {}) {
|
|
170
|
+
// Children is a component function that renders the page
|
|
171
|
+
const children = (/** @type {unknown} */ childOutput) => {
|
|
172
|
+
return Page(childOutput, { ...additionalProps, ...pageProps });
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return Layout(output, { ...additionalProps, children });
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Escape script content to prevent XSS
|
|
181
|
+
* @param {string} str
|
|
182
|
+
* @returns {string}
|
|
183
|
+
*/
|
|
184
|
+
function escapeScript(str) {
|
|
185
|
+
return str.replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Generate an error HTML page for development
|
|
190
|
+
*
|
|
191
|
+
* @param {unknown} error
|
|
192
|
+
* @param {RenderRoute} route
|
|
193
|
+
* @returns {string}
|
|
194
|
+
*/
|
|
195
|
+
function generateErrorHtml(error, route) {
|
|
196
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
197
|
+
const stack = error instanceof Error ? error.stack : '';
|
|
198
|
+
|
|
199
|
+
return `<!DOCTYPE html>
|
|
200
|
+
<html lang="en">
|
|
201
|
+
<head>
|
|
202
|
+
<meta charset="UTF-8">
|
|
203
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
204
|
+
<title>SSR Error</title>
|
|
205
|
+
<style>
|
|
206
|
+
body { font-family: system-ui, sans-serif; padding: 2rem; background: #1a1a1a; color: #fff; }
|
|
207
|
+
h1 { color: #ff6b6b; }
|
|
208
|
+
pre { background: #2d2d2d; padding: 1rem; border-radius: 4px; overflow-x: auto; }
|
|
209
|
+
.route { color: #888; }
|
|
210
|
+
</style>
|
|
211
|
+
</head>
|
|
212
|
+
<body>
|
|
213
|
+
<h1>SSR Render Error</h1>
|
|
214
|
+
<p class="route">Route: ${route.path} → ${route.entry}</p>
|
|
215
|
+
<pre>${escapeHtml(message)}</pre>
|
|
216
|
+
${stack ? `<pre>${escapeHtml(stack)}</pre>` : ''}
|
|
217
|
+
</body>
|
|
218
|
+
</html>`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Escape HTML entities
|
|
223
|
+
* @param {string} str
|
|
224
|
+
* @returns {string}
|
|
225
|
+
*/
|
|
226
|
+
function escapeHtml(str) {
|
|
227
|
+
return str
|
|
228
|
+
.replace(/&/g, '&')
|
|
229
|
+
.replace(/</g, '<')
|
|
230
|
+
.replace(/>/g, '>')
|
|
231
|
+
.replace(/"/g, '"');
|
|
232
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {import('@ripple-ts/vite-plugin').Route} Route
|
|
3
|
+
* @typedef {import('@ripple-ts/vite-plugin').RenderRoute} RenderRoute
|
|
4
|
+
* @typedef {import('@ripple-ts/vite-plugin').ServerRoute} ServerRoute
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {Object} RouteMatch
|
|
9
|
+
* @property {Route} route
|
|
10
|
+
* @property {Record<string, string>} params
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} CompiledRoute
|
|
15
|
+
* @property {Route} route
|
|
16
|
+
* @property {RegExp} pattern
|
|
17
|
+
* @property {string[]} paramNames
|
|
18
|
+
* @property {number} specificity - Higher = more specific (static > param > catch-all)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Convert a route path pattern to a RegExp
|
|
23
|
+
* Supports:
|
|
24
|
+
* - Static segments: /about, /api/hello
|
|
25
|
+
* - Named params: /posts/:id, /users/:userId/posts/:postId
|
|
26
|
+
* - Catch-all: /docs/*slug
|
|
27
|
+
*
|
|
28
|
+
* @param {string} path
|
|
29
|
+
* @returns {{ pattern: RegExp, paramNames: string[], specificity: number }}
|
|
30
|
+
*/
|
|
31
|
+
function compilePath(path) {
|
|
32
|
+
/** @type {string[]} */
|
|
33
|
+
const paramNames = [];
|
|
34
|
+
let specificity = 0;
|
|
35
|
+
|
|
36
|
+
// Escape special regex characters except our param syntax
|
|
37
|
+
const regexString = path
|
|
38
|
+
.split('/')
|
|
39
|
+
.map((segment) => {
|
|
40
|
+
if (!segment) return '';
|
|
41
|
+
|
|
42
|
+
// Catch-all param: *slug
|
|
43
|
+
if (segment.startsWith('*')) {
|
|
44
|
+
const paramName = segment.slice(1);
|
|
45
|
+
paramNames.push(paramName);
|
|
46
|
+
specificity += 1; // Lowest specificity
|
|
47
|
+
return '(.+)';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Named param: :id
|
|
51
|
+
if (segment.startsWith(':')) {
|
|
52
|
+
const paramName = segment.slice(1);
|
|
53
|
+
paramNames.push(paramName);
|
|
54
|
+
specificity += 10; // Medium specificity
|
|
55
|
+
return '([^/]+)';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Static segment
|
|
59
|
+
specificity += 100; // Highest specificity
|
|
60
|
+
return escapeRegex(segment);
|
|
61
|
+
})
|
|
62
|
+
.join('/');
|
|
63
|
+
|
|
64
|
+
const pattern = new RegExp(`^${regexString || '/'}$`);
|
|
65
|
+
return { pattern, paramNames, specificity };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Escape special regex characters
|
|
70
|
+
* @param {string} str
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
function escapeRegex(str) {
|
|
74
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Create a router from a list of routes
|
|
79
|
+
* @param {Route[]} routes
|
|
80
|
+
* @returns {{ match: (method: string, pathname: string) => RouteMatch | null }}
|
|
81
|
+
*/
|
|
82
|
+
export function createRouter(routes) {
|
|
83
|
+
/** @type {CompiledRoute[]} */
|
|
84
|
+
const compiledRoutes = routes.map((route) => {
|
|
85
|
+
const { pattern, paramNames, specificity } = compilePath(route.path);
|
|
86
|
+
return { route, pattern, paramNames, specificity };
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Sort by specificity (higher first) for correct matching order
|
|
90
|
+
compiledRoutes.sort((a, b) => b.specificity - a.specificity);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
/**
|
|
94
|
+
* Match a request to a route
|
|
95
|
+
* @param {string} method
|
|
96
|
+
* @param {string} pathname
|
|
97
|
+
* @returns {RouteMatch | null}
|
|
98
|
+
*/
|
|
99
|
+
match(method, pathname) {
|
|
100
|
+
for (const { route, pattern, paramNames } of compiledRoutes) {
|
|
101
|
+
// Check method for ServerRoute
|
|
102
|
+
if (route.type === 'server') {
|
|
103
|
+
const methods = /** @type {ServerRoute} */ (route).methods;
|
|
104
|
+
if (!methods.includes(method.toUpperCase())) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const match = pathname.match(pattern);
|
|
110
|
+
if (match) {
|
|
111
|
+
/** @type {Record<string, string>} */
|
|
112
|
+
const params = {};
|
|
113
|
+
for (let i = 0; i < paramNames.length; i++) {
|
|
114
|
+
params[paramNames[i]] = decodeURIComponent(match[i + 1]);
|
|
115
|
+
}
|
|
116
|
+
return { route, params };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {import('@ripple-ts/vite-plugin').Context} Context
|
|
3
|
+
* @typedef {import('@ripple-ts/vite-plugin').ServerRoute} ServerRoute
|
|
4
|
+
* @typedef {import('@ripple-ts/vite-plugin').Middleware} Middleware
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { runMiddlewareChain } from './middleware.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Handle a ServerRoute (API endpoint)
|
|
11
|
+
*
|
|
12
|
+
* @param {ServerRoute} route
|
|
13
|
+
* @param {Context} context
|
|
14
|
+
* @param {Middleware[]} globalMiddlewares
|
|
15
|
+
* @returns {Promise<Response>}
|
|
16
|
+
*/
|
|
17
|
+
export async function handleServerRoute(route, context, globalMiddlewares) {
|
|
18
|
+
try {
|
|
19
|
+
// The handler wrapped as a function returning Promise<Response>
|
|
20
|
+
const handler = async () => {
|
|
21
|
+
return route.handler(context);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Run the middleware chain: global → before → handler → after
|
|
25
|
+
const response = await runMiddlewareChain(
|
|
26
|
+
context,
|
|
27
|
+
globalMiddlewares,
|
|
28
|
+
route.before,
|
|
29
|
+
handler,
|
|
30
|
+
route.after,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
return response;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error('[ripple] API route error:', error);
|
|
36
|
+
|
|
37
|
+
// Return error response
|
|
38
|
+
const message = error instanceof Error ? error.message : 'Internal Server Error';
|
|
39
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
40
|
+
status: 500,
|
|
41
|
+
headers: {
|
|
42
|
+
'Content-Type': 'application/json',
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|