@ripple-ts/vite-plugin 0.2.213 → 0.2.215

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,172 @@
1
+ /**
2
+ * Shared utility for loading and resolving ripple.config.ts.
3
+ *
4
+ * `resolveRippleConfig` is the single source of truth for all config
5
+ * validation and default values. Every consumer should receive a
6
+ * `ResolvedRippleConfig` rather than applying ad-hoc defaults.
7
+ *
8
+ * `loadRippleConfig` is the single entry point for loading the config
9
+ * file. It accepts an optional Vite dev server — when provided the
10
+ * config is loaded via `ssrLoadModule` (no temp server overhead,
11
+ * HMR-aware). Otherwise a temporary Vite server is spun up, used to
12
+ * transpile the TypeScript config, and immediately shut down.
13
+ *
14
+ * Used by the Vite plugin (during dev + build), the preview CLI script,
15
+ * and the generated production server entry.
16
+ */
17
+
18
+ /** @import { RippleConfigOptions, ResolvedRippleConfig } from '@ripple-ts/vite-plugin' */
19
+
20
+ import path from 'node:path';
21
+ import fs from 'node:fs';
22
+ import { DEFAULT_OUTDIR } from './constants.js';
23
+
24
+ /**
25
+ * Validate a raw ripple config and apply all defaults.
26
+ *
27
+ * After this function returns every optional field carries its default
28
+ * value so callers never need to use `??` / `||` fallbacks.
29
+ *
30
+ * The function is idempotent — passing an already-resolved config
31
+ * through it again is safe and produces the same result.
32
+ *
33
+ * @param {RippleConfigOptions} raw - The user-provided config (from ripple.config.ts)
34
+ * @param {{ requireAdapter?: boolean }} [options]
35
+ * @returns {ResolvedRippleConfig}
36
+ */
37
+ export function resolveRippleConfig(raw, options = {}) {
38
+ const { requireAdapter = false } = options;
39
+
40
+ // ------------------------------------------------------------------
41
+ // Validate
42
+ // ------------------------------------------------------------------
43
+ if (!raw) {
44
+ throw new Error(
45
+ '[@ripple-ts/vite-plugin] ripple.config.ts must export a default config object.',
46
+ );
47
+ }
48
+
49
+ if (!raw.router?.routes) {
50
+ throw new Error('[@ripple-ts/vite-plugin] ripple.config.ts must define `router.routes`.');
51
+ }
52
+
53
+ if (requireAdapter) {
54
+ if (!raw.adapter) {
55
+ throw new Error(
56
+ '[@ripple-ts/vite-plugin] Production builds require an `adapter` in ripple.config.ts. ' +
57
+ 'Install an adapter package (e.g. @ripple-ts/adapter-node) and set the `adapter` property.',
58
+ );
59
+ }
60
+
61
+ if (!raw.adapter.runtime) {
62
+ throw new Error(
63
+ '[@ripple-ts/vite-plugin] The adapter in ripple.config.ts is missing the `runtime` property. ' +
64
+ 'Make sure your adapter exports runtime primitives.',
65
+ );
66
+ }
67
+ }
68
+
69
+ // ------------------------------------------------------------------
70
+ // Apply defaults
71
+ // ------------------------------------------------------------------
72
+ return {
73
+ build: {
74
+ outDir: raw.build?.outDir ?? DEFAULT_OUTDIR,
75
+ minify: raw.build?.minify,
76
+ target: raw.build?.target,
77
+ },
78
+ adapter: raw.adapter,
79
+ router: {
80
+ routes: raw.router.routes,
81
+ },
82
+ middlewares: raw.middlewares ?? [],
83
+ platform: {
84
+ env: raw.platform?.env ?? {},
85
+ },
86
+ server: {
87
+ trustProxy: raw.server?.trustProxy ?? false,
88
+ },
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Return the absolute path to ripple.config.ts for the given project root.
94
+ *
95
+ * This is the single source of truth for the config file name / location.
96
+ *
97
+ * @param {string} projectRoot - Absolute path to the project root
98
+ * @returns {string}
99
+ */
100
+ export function getRippleConfigPath(projectRoot) {
101
+ return path.join(projectRoot, 'ripple.config.ts');
102
+ }
103
+
104
+ /**
105
+ * Check whether a ripple.config.ts file exists in the given root.
106
+ *
107
+ * Use this before calling `loadRippleConfig` when the absence of a
108
+ * config is a valid state (e.g. the Vite plugin running in SPA mode).
109
+ *
110
+ * @param {string} projectRoot - Absolute path to the project root
111
+ * @returns {boolean}
112
+ */
113
+ export function rippleConfigExists(projectRoot) {
114
+ return fs.existsSync(getRippleConfigPath(projectRoot));
115
+ }
116
+
117
+ /**
118
+ * Load ripple.config.ts, validate, and apply defaults via `resolveRippleConfig`.
119
+ *
120
+ * When a Vite dev server is provided via `options.vite`, the config is loaded
121
+ * through its `ssrLoadModule` — avoiding the cost of spinning up a temporary
122
+ * server and enabling HMR-aware reloads.
123
+ *
124
+ * When no dev server is available (build / preview), a temporary Vite server
125
+ * is created in middleware mode, used to transpile the config, then shut down.
126
+ *
127
+ * Throws if the config file does not exist or is invalid.
128
+ *
129
+ * @param {string} projectRoot - Absolute path to the project root
130
+ * @param {{ vite?: import('vite').ViteDevServer, requireAdapter?: boolean }} [options]
131
+ * @returns {Promise<ResolvedRippleConfig>}
132
+ */
133
+ export async function loadRippleConfig(projectRoot, options = {}) {
134
+ const { vite, requireAdapter } = options;
135
+ const configPath = getRippleConfigPath(projectRoot);
136
+
137
+ if (!fs.existsSync(configPath)) {
138
+ throw new Error(`[@ripple-ts/vite-plugin] ripple.config.ts not found in ${projectRoot}`);
139
+ }
140
+
141
+ // When a running Vite dev server is available, use it directly.
142
+ if (vite) {
143
+ const configModule = await vite.ssrLoadModule(configPath);
144
+ return resolveRippleConfig(configModule.default, { requireAdapter });
145
+ }
146
+
147
+ // Otherwise spin up a temporary Vite server (build / preview).
148
+ // The temp server only transpiles ripple.config.ts (plain TypeScript) —
149
+ // no .ripple compilation plugin is needed.
150
+ const { createServer } = await import('vite');
151
+
152
+ const tempVite = await createServer({
153
+ root: projectRoot,
154
+ configFile: false,
155
+ appType: 'custom',
156
+ server: { middlewareMode: true },
157
+ // We don't need to load the ripple plugin for now
158
+ // but if we start using references to components in router.routes
159
+ // then we'll need to add the plugin here to handle the .ripple imports.
160
+ // But this will cause a circular references warning
161
+ // that we should resolve when we implement references to components.
162
+ // plugins: [ripple({ excludeRippleExternalModules: true })],
163
+ logLevel: 'silent',
164
+ });
165
+
166
+ try {
167
+ const configModule = await tempVite.ssrLoadModule(configPath);
168
+ return resolveRippleConfig(configModule.default, { requireAdapter });
169
+ } finally {
170
+ await tempVite.close();
171
+ }
172
+ }
@@ -1,10 +1,24 @@
1
1
  /**
2
- * Production server runtime for Ripple metaframework
3
- * This module is used in production builds to handle SSR + API routes
2
+ * Production server runtime for Ripple metaframework.
3
+ * This module is used in production builds to handle SSR + API routes + RPC.
4
+ *
5
+ * It is designed to be imported by the generated server entry and does NOT
6
+ * depend on Vite at runtime.
7
+ *
8
+ * Platform-agnostic — no Node.js-specific imports. Platform capabilities
9
+ * (hashing, async context) are provided via `manifest.runtime` from the adapter.
4
10
  */
5
11
 
6
12
  import { createRouter } from './router.js';
7
13
  import { createContext, runMiddlewareChain } from './middleware.js';
14
+ import {
15
+ patch_global_fetch,
16
+ build_rpc_lookup,
17
+ is_rpc_request,
18
+ handle_rpc_request,
19
+ } from '@ripple-ts/adapter/rpc';
20
+
21
+ export { resolveRippleConfig } from '../load-config.js';
8
22
 
9
23
  /**
10
24
  * @typedef {import('@ripple-ts/vite-plugin').Route} Route
@@ -14,39 +28,62 @@ import { createContext, runMiddlewareChain } from './middleware.js';
14
28
  */
15
29
 
16
30
  /**
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
31
+ @import {
32
+ ServerManifest,
33
+ RenderResult,
34
+ HandlerOptions,
35
+ ClientAssetEntry,
36
+ } from '@ripple-ts/vite-plugin/production';
29
37
  */
30
38
 
31
39
  /**
32
- * Create a production request handler from a manifest
40
+ * Create a production request handler from a manifest.
41
+ *
42
+ * The returned function is a standard Web `fetch`-style handler:
43
+ * `(request: Request) => Promise<Response>`
33
44
  *
34
45
  * @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
46
+ * @param {HandlerOptions} options
39
47
  * @returns {(request: Request) => Promise<Response>}
40
48
  */
41
49
  export function createHandler(manifest, options) {
42
- const { render, getCss, clientBase = '/' } = options;
50
+ const { render, getCss, htmlTemplate, executeServerFunction } = options;
43
51
  const router = createRouter(manifest.routes);
44
- const globalMiddlewares = manifest.middlewares || [];
52
+ const globalMiddlewares = manifest.middlewares;
53
+ const trustProxy = manifest.trustProxy ?? false;
54
+ const clientAssets = manifest.clientAssets || {};
55
+
56
+ // Use adapter's runtime primitives for platform-agnostic operation
57
+ const runtime = manifest.runtime;
45
58
 
46
- return async function handler(request) {
59
+ // Build the RPC lookup table using the adapter's hash function
60
+ const rpcLookup = manifest.rpcModules
61
+ ? build_rpc_lookup(manifest.rpcModules, runtime.hash)
62
+ : new Map();
63
+
64
+ // Create async context and patch fetch for relative URL resolution in #server blocks
65
+ const asyncContext = runtime.createAsyncContext();
66
+ const fetchHandle = patch_global_fetch(asyncContext);
67
+
68
+ const handler = async function handler(/** @type {Request} */ request) {
47
69
  const url = new URL(request.url);
48
70
  const method = request.method;
49
71
 
72
+ // Handle RPC requests for #server blocks
73
+ if (is_rpc_request(url.pathname)) {
74
+ return handle_rpc_request(request, {
75
+ resolveFunction(hash) {
76
+ const entry = rpcLookup.get(hash);
77
+ if (!entry) return null;
78
+ const fn = entry.serverObj[entry.funcName];
79
+ return typeof fn === 'function' ? fn : null;
80
+ },
81
+ executeServerFunction,
82
+ asyncContext,
83
+ trustProxy,
84
+ });
85
+ }
86
+
50
87
  // Match route
51
88
  const match = router.match(method, url.pathname);
52
89
 
@@ -66,7 +103,8 @@ export function createHandler(manifest, options) {
66
103
  globalMiddlewares,
67
104
  render,
68
105
  getCss,
69
- clientBase,
106
+ htmlTemplate,
107
+ clientAssets,
70
108
  );
71
109
  } else {
72
110
  return await handleServerRoute(match.route, context, globalMiddlewares);
@@ -76,8 +114,19 @@ export function createHandler(manifest, options) {
76
114
  return new Response('Internal Server Error', { status: 500 });
77
115
  }
78
116
  };
117
+
118
+ // Enable same-origin fetch short-circuit: server-side fetch() calls that
119
+ // resolve to the same origin are routed directly through this handler
120
+ // in-process, instead of making a real network request.
121
+ fetchHandle.set_handler(handler);
122
+
123
+ return handler;
79
124
  }
80
125
 
126
+ // ============================================================================
127
+ // Render routes
128
+ // ============================================================================
129
+
81
130
  /**
82
131
  * Handle a RenderRoute in production
83
132
  *
@@ -87,7 +136,8 @@ export function createHandler(manifest, options) {
87
136
  * @param {Middleware[]} globalMiddlewares
88
137
  * @param {(component: Function) => Promise<RenderResult>} render
89
138
  * @param {(css: Set<string>) => string} getCss
90
- * @param {string} clientBase
139
+ * @param {string} htmlTemplate
140
+ * @param {Record<string, ClientAssetEntry>} clientAssets
91
141
  * @returns {Promise<Response>}
92
142
  */
93
143
  async function handleRenderRoute(
@@ -97,7 +147,8 @@ async function handleRenderRoute(
97
147
  globalMiddlewares,
98
148
  render,
99
149
  getCss,
100
- clientBase,
150
+ htmlTemplate,
151
+ clientAssets,
101
152
  ) {
102
153
  const renderHandler = async () => {
103
154
  // Get the page component
@@ -120,7 +171,7 @@ async function handleRenderRoute(
120
171
  // Render to HTML
121
172
  const { head, body, css } = await render(RootComponent);
122
173
 
123
- // Generate CSS tags
174
+ // Generate inline scoped CSS (from SSR-rendered component hashes)
124
175
  let cssContent = '';
125
176
  if (css.size > 0) {
126
177
  const cssString = getCss(css);
@@ -129,14 +180,46 @@ async function handleRenderRoute(
129
180
  }
130
181
  }
131
182
 
132
- // Generate the full HTML document
133
- const html = generateHtml({
134
- head: head + cssContent,
135
- body,
136
- route,
137
- context,
138
- clientBase,
183
+ // Build asset preload tags from the client manifest.
184
+ // These ensure the browser starts downloading page-specific JS/CSS
185
+ // immediately, before the hydration script executes.
186
+ /** @type {string[]} */
187
+ const preloadTags = [];
188
+ const entryAssets = clientAssets[route.entry];
189
+
190
+ if (entryAssets?.css) {
191
+ for (const cssFile of entryAssets.css) {
192
+ preloadTags.push(`<link rel="stylesheet" href="/${cssFile}">`);
193
+ }
194
+ }
195
+ if (entryAssets?.js) {
196
+ preloadTags.push(`<link rel="modulepreload" href="/${entryAssets.js}">`);
197
+ }
198
+
199
+ // Preload the hydrate runtime so it starts downloading in parallel
200
+ const hydrateAsset = clientAssets.__hydrate_js;
201
+ if (hydrateAsset?.js) {
202
+ preloadTags.push(`<link rel="modulepreload" href="/${hydrateAsset.js}">`);
203
+ }
204
+
205
+ // Build head content with hydration data
206
+ const routeData = JSON.stringify({
207
+ entry: route.entry,
208
+ params: context.params,
139
209
  });
210
+ const headContent = [
211
+ head,
212
+ cssContent,
213
+ ...preloadTags,
214
+ `<script id="__ripple_data" type="application/json">${escapeScript(routeData)}</script>`,
215
+ ]
216
+ .filter(Boolean)
217
+ .join('\n');
218
+
219
+ // Inject into the HTML template
220
+ const html = htmlTemplate
221
+ .replace('<!--ssr-head-->', headContent)
222
+ .replace('<!--ssr-body-->', body);
140
223
 
141
224
  return new Response(html, {
142
225
  status: 200,
@@ -147,6 +230,10 @@ async function handleRenderRoute(
147
230
  return runMiddlewareChain(context, globalMiddlewares, route.before || [], renderHandler, []);
148
231
  }
149
232
 
233
+ // ============================================================================
234
+ // Server routes
235
+ // ============================================================================
236
+
150
237
  /**
151
238
  * Handle a ServerRoute in production
152
239
  *
@@ -166,6 +253,10 @@ async function handleServerRoute(route, context, globalMiddlewares) {
166
253
  );
167
254
  }
168
255
 
256
+ // ============================================================================
257
+ // Component wrappers
258
+ // ============================================================================
259
+
169
260
  /**
170
261
  * Create a wrapper component that injects props
171
262
  * @param {Function} Component
@@ -194,67 +285,9 @@ function createLayoutWrapper(Layout, Page, pageProps) {
194
285
  };
195
286
  }
196
287
 
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
- }
288
+ // ============================================================================
289
+ // Utilities
290
+ // ============================================================================
258
291
 
259
292
  /**
260
293
  * Escape script content to prevent XSS
@@ -90,7 +90,7 @@ export async function handleRenderRoute(route, context, vite) {
90
90
  .join('\n');
91
91
 
92
92
  // Load and process index.html template
93
- const templatePath = join(vite.config.root, 'public', 'index.html');
93
+ const templatePath = join(vite.config.root, 'index.html');
94
94
  let template = await readFile(templatePath, 'utf-8');
95
95
 
96
96
  // Apply Vite's HTML transforms (HMR client, module resolution, etc.)