@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.
@@ -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, '&amp;')
229
+ .replace(/</g, '&lt;')
230
+ .replace(/>/g, '&gt;')
231
+ .replace(/"/g, '&quot;');
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
+ }