@ripple-ts/vite-plugin 0.2.208 → 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 ADDED
@@ -0,0 +1,27 @@
1
+ # @ripple-ts/vite-plugin
2
+
3
+ ## 0.2.211
4
+
5
+ ## 0.2.210
6
+
7
+ ## 0.2.209
8
+
9
+ ### Patch Changes
10
+
11
+ - [#682](https://github.com/Ripple-TS/ripple/pull/682)
12
+ [`96a5614`](https://github.com/Ripple-TS/ripple/commit/96a56141de8aa667a64bf53ad06f63292e38b1d9)
13
+ Thanks [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)! - Add
14
+ invalid HTML nesting error detection during SSR in dev mode
15
+
16
+ During SSR, if the HTML is malformed (e.g., `<button>` elements nested inside
17
+ other `<button>` elements), the browser tries to repair the HTML, making
18
+ hydration impossible. This change adds runtime validation of HTML nesting during
19
+ SSR to detect these cases and provide clear error messages.
20
+ - Added `push_element` and `pop_element` functions to the server runtime that
21
+ track the element stack during SSR
22
+ - Added comprehensive HTML nesting validation rules based on the HTML spec
23
+ - The server compiler now emits `push_element`/`pop_element` calls when the
24
+ `dev` option is enabled
25
+ - Added `dev` option to `CompileOptions`
26
+ - The Vite plugin now automatically enables dev mode during `vite dev` (serve
27
+ command)
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Vite plugin for Ripple",
4
4
  "license": "MIT",
5
5
  "author": "Dominic Gannaway",
6
- "version": "0.2.208",
6
+ "version": "0.2.211",
7
7
  "type": "module",
8
8
  "module": "src/index.js",
9
9
  "main": "src/index.js",
@@ -26,6 +26,9 @@
26
26
  "devDependencies": {
27
27
  "type-fest": "^5.1.0",
28
28
  "vite": "^7.1.9",
29
- "ripple": "0.2.208"
29
+ "ripple": "0.2.211"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
30
33
  }
31
34
  }
package/src/index.js CHANGED
@@ -1,10 +1,20 @@
1
1
  /** @import {PackageJson} from 'type-fest' */
2
- /** @import {Plugin, ResolvedConfig} from 'vite' */
3
- /** @import {RipplePluginOptions} from '@ripple-ts/vite-plugin' */
2
+ /** @import {Plugin, ResolvedConfig, ViteDevServer} from 'vite' */
3
+ /** @import {RipplePluginOptions, RippleConfigOptions, Route, Middleware, RenderRoute} from '@ripple-ts/vite-plugin' */
4
4
 
5
5
  import { compile } from 'ripple/compiler';
6
6
  import fs from 'node:fs';
7
+ import path from 'node:path';
7
8
  import { createRequire } from 'node:module';
9
+ import { Readable } from 'node:stream';
10
+
11
+ import { createRouter } from './server/router.js';
12
+ import { createContext, runMiddlewareChain } from './server/middleware.js';
13
+ import { handleRenderRoute } from './server/render-route.js';
14
+ import { handleServerRoute } from './server/server-route.js';
15
+
16
+ // Re-export route classes
17
+ export { RenderRoute, ServerRoute } from './routes.js';
8
18
 
9
19
  const VITE_FS_PREFIX = '/@fs/';
10
20
  const IS_WINDOWS = process.platform === 'win32';
@@ -252,10 +262,15 @@ export function ripple(inlineOptions = {}) {
252
262
  const ripplePackages = new Set();
253
263
  const cssCache = new Map();
254
264
 
265
+ /** @type {RippleConfigOptions | null} */
266
+ let rippleConfig = null;
267
+ /** @type {ReturnType<typeof createRouter> | null} */
268
+ let router = null;
269
+
255
270
  /** @type {Plugin[]} */
256
271
  const plugins = [
257
272
  {
258
- name: 'vite-plugin',
273
+ name: 'vite-plugin-ripple',
259
274
  // make sure our resolver runs before vite internal resolver to resolve ripple field correctly
260
275
  enforce: 'pre',
261
276
  api,
@@ -304,7 +319,155 @@ export function ripple(inlineOptions = {}) {
304
319
  config = resolvedConfig;
305
320
  },
306
321
 
322
+ /**
323
+ * Configure the dev server with SSR middleware
324
+ * @param {ViteDevServer} vite
325
+ */
326
+ configureServer(vite) {
327
+ // Return a function to be called after Vite's internal middlewares
328
+ return async () => {
329
+ // Load ripple.config.ts
330
+ const configPath = path.join(root, 'ripple.config.ts');
331
+ if (!fs.existsSync(configPath)) {
332
+ console.log('[@ripple-ts/vite-plugin] No ripple.config.ts found, skipping SSR setup');
333
+ return;
334
+ }
335
+
336
+ try {
337
+ const configModule = await vite.ssrLoadModule(configPath);
338
+ rippleConfig = configModule.default;
339
+
340
+ if (!rippleConfig?.router?.routes) {
341
+ console.log('[@ripple-ts/vite-plugin] No routes defined in ripple.config.ts');
342
+ return;
343
+ }
344
+
345
+ // Create router from config
346
+ router = createRouter(rippleConfig.router.routes);
347
+ console.log(
348
+ `[@ripple-ts/vite-plugin] Loaded ${rippleConfig.router.routes.length} routes from ripple.config.ts`,
349
+ );
350
+ } catch (error) {
351
+ console.error('[@ripple-ts/vite-plugin] Failed to load ripple.config.ts:', error);
352
+ return;
353
+ }
354
+
355
+ // Add SSR middleware
356
+ vite.middlewares.use((req, res, next) => {
357
+ // Handle async logic in an IIFE
358
+ (async () => {
359
+ // Skip if no router
360
+ if (!router || !rippleConfig) {
361
+ next();
362
+ return;
363
+ }
364
+
365
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
366
+ const method = req.method || 'GET';
367
+
368
+ // Handle RPC requests for #server blocks
369
+ if (url.pathname.startsWith('/_$_ripple_rpc_$_/')) {
370
+ await handleRpcRequest(req, res, url, vite);
371
+ return;
372
+ }
373
+
374
+ // Match route
375
+ const match = router.match(method, url.pathname);
376
+
377
+ if (!match) {
378
+ next();
379
+ return;
380
+ }
381
+
382
+ try {
383
+ // Reload config to get fresh routes (for HMR)
384
+ const freshConfig = await vite.ssrLoadModule(configPath);
385
+ rippleConfig = freshConfig.default;
386
+
387
+ if (!rippleConfig || !rippleConfig.router || !rippleConfig.router.routes) {
388
+ console.log('[@ripple-ts/vite-plugin] No routes defined in ripple.config.ts');
389
+ next();
390
+ return;
391
+ }
392
+
393
+ // Check if routes have changed
394
+ if (
395
+ JSON.stringify(freshConfig.default.router.routes) !==
396
+ JSON.stringify(rippleConfig.router.routes)
397
+ ) {
398
+ console.log(
399
+ `[@ripple-ts/vite-plugin] Detected route changes. Re-loading ${rippleConfig.router.routes.length} routes from ripple.config.ts`,
400
+ );
401
+ }
402
+
403
+ router = createRouter(rippleConfig.router.routes);
404
+
405
+ // Re-match with fresh router
406
+ const freshMatch = router.match(method, url.pathname);
407
+ if (!freshMatch) {
408
+ next();
409
+ return;
410
+ }
411
+
412
+ // Create context
413
+ const request = nodeRequestToWebRequest(req);
414
+ const context = createContext(request, freshMatch.params);
415
+
416
+ // Get global middlewares
417
+ const globalMiddlewares = rippleConfig.middlewares || [];
418
+
419
+ let response;
420
+
421
+ if (freshMatch.route.type === 'render') {
422
+ // Handle RenderRoute with global middlewares
423
+ response = await runMiddlewareChain(
424
+ context,
425
+ globalMiddlewares,
426
+ freshMatch.route.before || [],
427
+ async () =>
428
+ handleRenderRoute(
429
+ /** @type {RenderRoute} */ (freshMatch.route),
430
+ context,
431
+ vite,
432
+ ),
433
+ [],
434
+ );
435
+ } else {
436
+ // Handle ServerRoute
437
+ response = await handleServerRoute(freshMatch.route, context, globalMiddlewares);
438
+ }
439
+
440
+ // Send response
441
+ await sendWebResponse(res, response);
442
+ } catch (/** @type {any} */ error) {
443
+ console.error('[@ripple-ts/vite-plugin] Request error:', error);
444
+ vite.ssrFixStacktrace(error);
445
+
446
+ res.statusCode = 500;
447
+ res.setHeader('Content-Type', 'text/html');
448
+ res.end(
449
+ `<pre style="color: red; background: #1a1a1a; padding: 2rem; margin: 0;">${escapeHtml(
450
+ error instanceof Error ? error.stack || error.message : String(error),
451
+ )}</pre>`,
452
+ );
453
+ }
454
+ })().catch((err) => {
455
+ console.error('[@ripple-ts/vite-plugin] Unhandled middleware error:', err);
456
+ if (!res.headersSent) {
457
+ res.statusCode = 500;
458
+ res.end('Internal Server Error');
459
+ }
460
+ });
461
+ });
462
+ };
463
+ },
464
+
307
465
  async resolveId(id, importer, options) {
466
+ // Handle virtual hydrate module
467
+ if (id === 'virtual:ripple-hydrate') {
468
+ return '\0virtual:ripple-hydrate';
469
+ }
470
+
308
471
  // Skip non-package imports (relative/absolute paths)
309
472
  if (id.startsWith('.') || id.startsWith('/') || id.includes(':')) {
310
473
  return null;
@@ -345,6 +508,42 @@ export function ripple(inlineOptions = {}) {
345
508
  },
346
509
 
347
510
  async load(id, opts) {
511
+ // Handle virtual hydrate module
512
+ if (id === '\0virtual:ripple-hydrate') {
513
+ return `
514
+ import { hydrate, mount } from 'ripple';
515
+
516
+ const data = JSON.parse(document.getElementById('__ripple_data').textContent);
517
+ const target = document.getElementById('root');
518
+
519
+ try {
520
+ const module = await import(/* @vite-ignore */ data.entry);
521
+ const Component =
522
+ module.default ||
523
+ Object.entries(module).find(([key, value]) => typeof value === 'function' && /^[A-Z]/.test(key))?.[1];
524
+
525
+ if (!Component || !target) {
526
+ console.error('[ripple] Unable to hydrate route: missing component export or #root target.');
527
+ } else {
528
+ try {
529
+ hydrate(Component, {
530
+ target,
531
+ props: { params: data.params }
532
+ });
533
+ } catch (error) {
534
+ console.warn('[ripple] Hydration failed, falling back to mount.', error);
535
+ mount(Component, {
536
+ target,
537
+ props: { params: data.params }
538
+ });
539
+ }
540
+ }
541
+ } catch (error) {
542
+ console.error('[ripple] Failed to bootstrap client hydration.', error);
543
+ }
544
+ `;
545
+ }
546
+
348
547
  if (cssCache.has(id)) {
349
548
  return cssCache.get(id);
350
549
  }
@@ -355,10 +554,11 @@ export function ripple(inlineOptions = {}) {
355
554
 
356
555
  async handler(code, id, opts) {
357
556
  const filename = id.replace(root, '');
358
- const ssr = this.environment.config.consumer === 'server';
557
+ const ssr = opts?.ssr === true || this.environment.config.consumer === 'server';
359
558
 
360
559
  const { js, css } = await compile(code, filename, {
361
560
  mode: ssr ? 'server' : 'client',
561
+ dev: config?.command === 'serve',
362
562
  });
363
563
 
364
564
  if (css !== '') {
@@ -375,3 +575,168 @@ export function ripple(inlineOptions = {}) {
375
575
 
376
576
  return plugins;
377
577
  }
578
+
579
+ // This is mainly to enforce types and provide a better DX with types than anything else
580
+ export function defineConfig(/** @type {RipplePluginOptions} */ options) {
581
+ return options;
582
+ }
583
+
584
+ // ============================================================================
585
+ // Helper functions for dev server
586
+ // ============================================================================
587
+
588
+ /**
589
+ * Convert a Node.js IncomingMessage to a Web Request
590
+ * @param {import('node:http').IncomingMessage} nodeRequest
591
+ * @returns {Request}
592
+ */
593
+ function nodeRequestToWebRequest(nodeRequest) {
594
+ const protocol = 'http';
595
+ const host = nodeRequest.headers.host || 'localhost';
596
+ const url = new URL(nodeRequest.url || '/', `${protocol}://${host}`);
597
+
598
+ const headers = new Headers();
599
+ for (const [key, value] of Object.entries(nodeRequest.headers)) {
600
+ if (value == null) continue;
601
+ if (Array.isArray(value)) {
602
+ for (const v of value) headers.append(key, v);
603
+ } else {
604
+ headers.set(key, value);
605
+ }
606
+ }
607
+
608
+ const method = (nodeRequest.method || 'GET').toUpperCase();
609
+ /** @type {RequestInit & { duplex?: 'half' }} */
610
+ const init = { method, headers };
611
+
612
+ // Add body for non-GET/HEAD requests
613
+ if (method !== 'GET' && method !== 'HEAD') {
614
+ init.body = /** @type {any} */ (Readable.toWeb(nodeRequest));
615
+ init.duplex = 'half';
616
+ }
617
+
618
+ return new Request(url, init);
619
+ }
620
+
621
+ /**
622
+ * Send a Web Response to a Node.js ServerResponse
623
+ * @param {import('node:http').ServerResponse} nodeResponse
624
+ * @param {Response} webResponse
625
+ */
626
+ async function sendWebResponse(nodeResponse, webResponse) {
627
+ nodeResponse.statusCode = webResponse.status;
628
+ if (webResponse.statusText) {
629
+ nodeResponse.statusMessage = webResponse.statusText;
630
+ }
631
+
632
+ // Copy headers
633
+ webResponse.headers.forEach((value, key) => {
634
+ nodeResponse.setHeader(key, value);
635
+ });
636
+
637
+ // Send body
638
+ if (webResponse.body) {
639
+ const reader = webResponse.body.getReader();
640
+ try {
641
+ while (true) {
642
+ const { done, value } = await reader.read();
643
+ if (done) break;
644
+ nodeResponse.write(value);
645
+ }
646
+ } finally {
647
+ reader.releaseLock();
648
+ }
649
+ }
650
+
651
+ nodeResponse.end();
652
+ }
653
+
654
+ /**
655
+ * Handle RPC requests for #server blocks
656
+ * @param {import('node:http').IncomingMessage} req
657
+ * @param {import('node:http').ServerResponse} res
658
+ * @param {URL} url
659
+ * @param {import('vite').ViteDevServer} vite
660
+ */
661
+ async function handleRpcRequest(req, res, url, vite) {
662
+ try {
663
+ const hash = url.pathname.slice('/_$_ripple_rpc_$_/'.length);
664
+
665
+ // Get request body
666
+ const body = await getRequestBody(req);
667
+
668
+ // Load the RPC module info from globalThis (set during SSR)
669
+ const rpcModules = /** @type {Map<string, [string, string]>} */ (
670
+ /** @type {any} */ (globalThis).rpc_modules
671
+ );
672
+ if (!rpcModules) {
673
+ res.statusCode = 500;
674
+ res.end('RPC modules not initialized');
675
+ return;
676
+ }
677
+
678
+ const moduleInfo = rpcModules.get(hash);
679
+ if (!moduleInfo) {
680
+ res.statusCode = 404;
681
+ res.end(`RPC function not found: ${hash}`);
682
+ return;
683
+ }
684
+
685
+ const [filePath, funcName] = moduleInfo;
686
+
687
+ // Load the module and execute the function
688
+ const { executeServerFunction } = await vite.ssrLoadModule('ripple/server');
689
+ const module = await vite.ssrLoadModule(filePath);
690
+ const server = module._$_server_$_;
691
+
692
+ if (!server || !server[funcName]) {
693
+ res.statusCode = 404;
694
+ res.end(`Server function not found: ${funcName}`);
695
+ return;
696
+ }
697
+
698
+ const result = await executeServerFunction(server[funcName], body);
699
+
700
+ res.statusCode = 200;
701
+ res.setHeader('Content-Type', 'application/json');
702
+ res.end(result);
703
+ } catch (error) {
704
+ console.error('[@ripple-ts/vite-plugin] RPC error:', error);
705
+ res.statusCode = 500;
706
+ res.setHeader('Content-Type', 'application/json');
707
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'RPC failed' }));
708
+ }
709
+ }
710
+
711
+ /**
712
+ * Get the body of a request as a string
713
+ * @param {import('node:http').IncomingMessage} req
714
+ * @returns {Promise<string>}
715
+ */
716
+ function getRequestBody(req) {
717
+ return new Promise((resolve, reject) => {
718
+ let data = '';
719
+ req.on('data', (chunk) => {
720
+ data += chunk;
721
+ if (data.length > 1e6) {
722
+ req.destroy();
723
+ reject(new Error('Request body too large'));
724
+ }
725
+ });
726
+ req.on('end', () => resolve(data));
727
+ req.on('error', reject);
728
+ });
729
+ }
730
+
731
+ /**
732
+ * Escape HTML entities
733
+ * @param {string} str
734
+ * @returns {string}
735
+ */
736
+ function escapeHtml(str) {
737
+ return str
738
+ .replace(/&/g, '&amp;')
739
+ .replace(/</g, '&lt;')
740
+ .replace(/>/g, '&gt;')
741
+ .replace(/"/g, '&quot;');
742
+ }
package/src/routes.js ADDED
@@ -0,0 +1,70 @@
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').RenderRouteOptions} RenderRouteOptions
5
+ * @typedef {import('@ripple-ts/vite-plugin').ServerRouteOptions} ServerRouteOptions
6
+ */
7
+
8
+ /**
9
+ * Route for rendering Ripple components with SSR
10
+ */
11
+ export class RenderRoute {
12
+ /** @type {'render'} */
13
+ type = 'render';
14
+
15
+ /** @type {string} */
16
+ path;
17
+
18
+ /** @type {string} */
19
+ entry;
20
+
21
+ /** @type {string | undefined} */
22
+ layout;
23
+
24
+ /** @type {Middleware[]} */
25
+ before;
26
+
27
+ /**
28
+ * @param {RenderRouteOptions} options
29
+ */
30
+ constructor(options) {
31
+ this.path = options.path;
32
+ this.entry = options.entry;
33
+ this.layout = options.layout;
34
+ this.before = options.before ?? [];
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Route for API endpoints (returns Response directly)
40
+ */
41
+ export class ServerRoute {
42
+ /** @type {'server'} */
43
+ type = 'server';
44
+
45
+ /** @type {string} */
46
+ path;
47
+
48
+ /** @type {string[]} */
49
+ methods;
50
+
51
+ /** @type {(context: Context) => Response | Promise<Response>} */
52
+ handler;
53
+
54
+ /** @type {Middleware[]} */
55
+ before;
56
+
57
+ /** @type {Middleware[]} */
58
+ after;
59
+
60
+ /**
61
+ * @param {ServerRouteOptions} options
62
+ */
63
+ constructor(options) {
64
+ this.path = options.path;
65
+ this.methods = options.methods ?? ['GET'];
66
+ this.handler = options.handler;
67
+ this.before = options.before ?? [];
68
+ this.after = options.after ?? [];
69
+ }
70
+ }
@@ -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
+ }