@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.
@@ -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, '&amp;')
236
+ .replace(/</g, '&lt;')
237
+ .replace(/>/g, '&gt;')
238
+ .replace(/"/g, '&quot;');
239
+ }