@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @ripple-ts/vite-plugin
2
2
 
3
+ ## 0.2.212
4
+
5
+ ## 0.2.211
6
+
3
7
  ## 0.2.210
4
8
 
5
9
  ## 0.2.209
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.210",
6
+ "version": "0.2.212",
7
7
  "type": "module",
8
8
  "module": "src/index.js",
9
9
  "main": "src/index.js",
@@ -26,7 +26,7 @@
26
26
  "devDependencies": {
27
27
  "type-fest": "^5.1.0",
28
28
  "vite": "^7.1.9",
29
- "ripple": "0.2.210"
29
+ "ripple": "0.2.212"
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"
package/src/index.js CHANGED
@@ -1,14 +1,79 @@
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
+
5
+ /// <reference types="ripple/compiler/internal/rpc" />
4
6
 
5
7
  import { compile } from 'ripple/compiler';
8
+ import { AsyncLocalStorage } from 'node:async_hooks';
6
9
  import fs from 'node:fs';
10
+ import path from 'node:path';
7
11
  import { createRequire } from 'node:module';
12
+ import { Readable } from 'node:stream';
13
+
14
+ import { createRouter } from './server/router.js';
15
+ import { createContext, runMiddlewareChain } from './server/middleware.js';
16
+ import { handleRenderRoute } from './server/render-route.js';
17
+ import { handleServerRoute } from './server/server-route.js';
18
+
19
+ // Re-export route classes
20
+ export { RenderRoute, ServerRoute } from './routes.js';
8
21
 
9
22
  const VITE_FS_PREFIX = '/@fs/';
10
23
  const IS_WINDOWS = process.platform === 'win32';
11
24
 
25
+ // AsyncLocalStorage for request-scoped fetch patching
26
+ const rpcContext = new AsyncLocalStorage();
27
+
28
+ // Patch fetch once at module level to support relative URLs in #server blocks
29
+ const originalFetch = globalThis.fetch;
30
+
31
+ /**
32
+ * Quick check whether a string looks like it already has a URL scheme (e.g. "http://", "https://", "data:").
33
+ * @param {string} url
34
+ * @returns {boolean}
35
+ */
36
+ function hasScheme(url) {
37
+ return /^[a-z][a-z0-9+\-.]*:/i.test(url);
38
+ }
39
+
40
+ /**
41
+ * Patch global fetch to resolve relative URLs based on the current request context.
42
+ * This allows server functions in #server blocks to use relative URLs
43
+ * (root-relative like "/api/foo" or path-relative like "api/foo", "./api/foo", "../api/foo")
44
+ * that are resolved against the incoming request's origin.
45
+ * // TODO: a similar logic needs to be ported to the adapters
46
+ * @param {string | Request | URL} input
47
+ * @param {RequestInit} [init]
48
+ * @returns {Promise<Response>}
49
+ */
50
+ globalThis.fetch = function (input, init) {
51
+ const context = rpcContext.getStore();
52
+
53
+ if (context?.origin) {
54
+ // Handle string URLs — resolve any non-absolute URL against the origin
55
+ if (typeof input === 'string' && !hasScheme(input)) {
56
+ input = new URL(input, context.origin).href;
57
+ }
58
+ // Handle Request objects
59
+ else if (input instanceof Request) {
60
+ const url = input.url;
61
+ if (!hasScheme(url)) {
62
+ input = new Request(new URL(url, context.origin).href, input);
63
+ }
64
+ }
65
+ // Handle URL objects constructed with relative paths
66
+ else if (input instanceof URL) {
67
+ if (!input.protocol || input.protocol === '' || input.origin === 'null') {
68
+ const relative = input.pathname + (input.search || '') + (input.hash || '');
69
+ input = new URL(relative, context.origin);
70
+ }
71
+ }
72
+ }
73
+
74
+ return originalFetch(input, init);
75
+ };
76
+
12
77
  /**
13
78
  * @param {string} filename
14
79
  * @param {ResolvedConfig['root']} root
@@ -252,10 +317,15 @@ export function ripple(inlineOptions = {}) {
252
317
  const ripplePackages = new Set();
253
318
  const cssCache = new Map();
254
319
 
320
+ /** @type {RippleConfigOptions | null} */
321
+ let rippleConfig = null;
322
+ /** @type {ReturnType<typeof createRouter> | null} */
323
+ let router = null;
324
+
255
325
  /** @type {Plugin[]} */
256
326
  const plugins = [
257
327
  {
258
- name: 'vite-plugin',
328
+ name: 'vite-plugin-ripple',
259
329
  // make sure our resolver runs before vite internal resolver to resolve ripple field correctly
260
330
  enforce: 'pre',
261
331
  api,
@@ -304,7 +374,161 @@ export function ripple(inlineOptions = {}) {
304
374
  config = resolvedConfig;
305
375
  },
306
376
 
377
+ /**
378
+ * Configure the dev server with SSR middleware
379
+ * @param {ViteDevServer} vite
380
+ */
381
+ configureServer(vite) {
382
+ // Return a function to be called after Vite's internal middlewares
383
+ return async () => {
384
+ // Load ripple.config.ts
385
+ const configPath = path.join(root, 'ripple.config.ts');
386
+ if (!fs.existsSync(configPath)) {
387
+ console.log('[@ripple-ts/vite-plugin] No ripple.config.ts found, skipping SSR setup');
388
+ return;
389
+ }
390
+
391
+ try {
392
+ const configModule = await vite.ssrLoadModule(configPath);
393
+ rippleConfig = configModule.default;
394
+
395
+ if (!rippleConfig?.router?.routes) {
396
+ console.log('[@ripple-ts/vite-plugin] No routes defined in ripple.config.ts');
397
+ return;
398
+ }
399
+
400
+ // Create router from config
401
+ router = createRouter(rippleConfig.router.routes);
402
+ console.log(
403
+ `[@ripple-ts/vite-plugin] Loaded ${rippleConfig.router.routes.length} routes from ripple.config.ts`,
404
+ );
405
+ } catch (error) {
406
+ console.error('[@ripple-ts/vite-plugin] Failed to load ripple.config.ts:', error);
407
+ return;
408
+ }
409
+
410
+ // Add SSR middleware
411
+ vite.middlewares.use((req, res, next) => {
412
+ // Handle async logic in an IIFE
413
+ (async () => {
414
+ // Skip if no router
415
+ if (!router || !rippleConfig) {
416
+ next();
417
+ return;
418
+ }
419
+
420
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
421
+ const method = req.method || 'GET';
422
+
423
+ // Handle RPC requests for #server blocks
424
+ if (url.pathname.startsWith('/_$_ripple_rpc_$_/')) {
425
+ await handleRpcRequest(
426
+ req,
427
+ res,
428
+ url,
429
+ vite,
430
+ rippleConfig.server?.trustProxy ?? false,
431
+ );
432
+ return;
433
+ }
434
+
435
+ // Match route
436
+ const match = router.match(method, url.pathname);
437
+
438
+ if (!match) {
439
+ next();
440
+ return;
441
+ }
442
+
443
+ try {
444
+ // Reload config to get fresh routes (for HMR)
445
+ const freshConfig = await vite.ssrLoadModule(configPath);
446
+ rippleConfig = freshConfig.default;
447
+
448
+ if (!rippleConfig || !rippleConfig.router || !rippleConfig.router.routes) {
449
+ console.log('[@ripple-ts/vite-plugin] No routes defined in ripple.config.ts');
450
+ next();
451
+ return;
452
+ }
453
+
454
+ // Check if routes have changed
455
+ if (
456
+ JSON.stringify(freshConfig.default.router.routes) !==
457
+ JSON.stringify(rippleConfig.router.routes)
458
+ ) {
459
+ console.log(
460
+ `[@ripple-ts/vite-plugin] Detected route changes. Re-loading ${rippleConfig.router.routes.length} routes from ripple.config.ts`,
461
+ );
462
+ }
463
+
464
+ router = createRouter(rippleConfig.router.routes);
465
+
466
+ // Re-match with fresh router
467
+ const freshMatch = router.match(method, url.pathname);
468
+ if (!freshMatch) {
469
+ next();
470
+ return;
471
+ }
472
+
473
+ // Create context
474
+ const request = nodeRequestToWebRequest(req);
475
+ const context = createContext(request, freshMatch.params);
476
+
477
+ // Get global middlewares
478
+ const globalMiddlewares = rippleConfig.middlewares || [];
479
+
480
+ let response;
481
+
482
+ if (freshMatch.route.type === 'render') {
483
+ // Handle RenderRoute with global middlewares
484
+ response = await runMiddlewareChain(
485
+ context,
486
+ globalMiddlewares,
487
+ freshMatch.route.before || [],
488
+ async () =>
489
+ handleRenderRoute(
490
+ /** @type {RenderRoute} */ (freshMatch.route),
491
+ context,
492
+ vite,
493
+ ),
494
+ [],
495
+ );
496
+ } else {
497
+ // Handle ServerRoute
498
+ response = await handleServerRoute(freshMatch.route, context, globalMiddlewares);
499
+ }
500
+
501
+ // Send response
502
+ await sendWebResponse(res, response);
503
+ } catch (/** @type {any} */ error) {
504
+ console.error('[@ripple-ts/vite-plugin] Request error:', error);
505
+ vite.ssrFixStacktrace(error);
506
+
507
+ res.statusCode = 500;
508
+ res.setHeader('Content-Type', 'text/html');
509
+ res.end(
510
+ `<pre style="color: red; background: #1a1a1a; padding: 2rem; margin: 0;">${escapeHtml(
511
+ error instanceof Error ? error.stack || error.message : String(error),
512
+ )}</pre>`,
513
+ );
514
+ }
515
+ })().catch((err) => {
516
+ console.error('[@ripple-ts/vite-plugin] Unhandled middleware error:', err);
517
+ if (!res.headersSent) {
518
+ res.statusCode = 500;
519
+ res.end('Internal Server Error');
520
+ }
521
+ });
522
+ });
523
+ };
524
+ },
525
+
307
526
  async resolveId(id, importer, options) {
527
+ // Handle virtual hydrate module
528
+ if (id === 'virtual:ripple-hydrate') {
529
+ return '\0virtual:ripple-hydrate';
530
+ }
531
+
308
532
  // Skip non-package imports (relative/absolute paths)
309
533
  if (id.startsWith('.') || id.startsWith('/') || id.includes(':')) {
310
534
  return null;
@@ -345,6 +569,42 @@ export function ripple(inlineOptions = {}) {
345
569
  },
346
570
 
347
571
  async load(id, opts) {
572
+ // Handle virtual hydrate module
573
+ if (id === '\0virtual:ripple-hydrate') {
574
+ return `
575
+ import { hydrate, mount } from 'ripple';
576
+
577
+ const data = JSON.parse(document.getElementById('__ripple_data').textContent);
578
+ const target = document.getElementById('root');
579
+
580
+ try {
581
+ const module = await import(/* @vite-ignore */ data.entry);
582
+ const Component =
583
+ module.default ||
584
+ Object.entries(module).find(([key, value]) => typeof value === 'function' && /^[A-Z]/.test(key))?.[1];
585
+
586
+ if (!Component || !target) {
587
+ console.error('[ripple] Unable to hydrate route: missing component export or #root target.');
588
+ } else {
589
+ try {
590
+ hydrate(Component, {
591
+ target,
592
+ props: { params: data.params }
593
+ });
594
+ } catch (error) {
595
+ console.warn('[ripple] Hydration failed, falling back to mount.', error);
596
+ mount(Component, {
597
+ target,
598
+ props: { params: data.params }
599
+ });
600
+ }
601
+ }
602
+ } catch (error) {
603
+ console.error('[ripple] Failed to bootstrap client hydration.', error);
604
+ }
605
+ `;
606
+ }
607
+
348
608
  if (cssCache.has(id)) {
349
609
  return cssCache.get(id);
350
610
  }
@@ -355,7 +615,7 @@ export function ripple(inlineOptions = {}) {
355
615
 
356
616
  async handler(code, id, opts) {
357
617
  const filename = id.replace(root, '');
358
- const ssr = this.environment.config.consumer === 'server';
618
+ const ssr = opts?.ssr === true || this.environment.config.consumer === 'server';
359
619
 
360
620
  const { js, css } = await compile(code, filename, {
361
621
  mode: ssr ? 'server' : 'client',
@@ -376,3 +636,209 @@ export function ripple(inlineOptions = {}) {
376
636
 
377
637
  return plugins;
378
638
  }
639
+
640
+ // This is mainly to enforce types and provide a better DX with types than anything else
641
+ export function defineConfig(/** @type {RipplePluginOptions} */ options) {
642
+ return options;
643
+ }
644
+
645
+ // ============================================================================
646
+ // Helper functions for dev server
647
+ // ============================================================================
648
+
649
+ /**
650
+ * Derive the request origin (protocol + host) from a Node.js request.
651
+ * Only honors `X-Forwarded-Proto` and `X-Forwarded-Host` headers when
652
+ * `trustProxy` is explicitly enabled; otherwise the protocol comes from the
653
+ * socket and the host from the `Host` header.
654
+ *
655
+ * @param {import('node:http').IncomingMessage} req
656
+ * @param {boolean} trustProxy
657
+ * @returns {string}
658
+ */
659
+ function deriveOrigin(req, trustProxy) {
660
+ let protocol = /** @type {import('node:tls').TLSSocket} */ (req.socket).encrypted
661
+ ? 'https'
662
+ : 'http';
663
+ let host = req.headers.host || 'localhost';
664
+
665
+ if (trustProxy) {
666
+ const forwardedProto = req.headers['x-forwarded-proto'];
667
+ const proto = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
668
+ if (proto) {
669
+ protocol = proto.split(',')[0].trim();
670
+ }
671
+
672
+ const forwardedHost = req.headers['x-forwarded-host'];
673
+ const fwdHost = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost;
674
+ if (fwdHost) {
675
+ host = fwdHost.split(',')[0].trim();
676
+ }
677
+ }
678
+
679
+ return `${protocol}://${host}`;
680
+ }
681
+
682
+ /**
683
+ * Convert a Node.js IncomingMessage to a Web Request
684
+ * @param {import('node:http').IncomingMessage} nodeRequest
685
+ * @returns {Request}
686
+ */
687
+ function nodeRequestToWebRequest(nodeRequest) {
688
+ const protocol = 'http';
689
+ const host = nodeRequest.headers.host || 'localhost';
690
+ const url = new URL(nodeRequest.url || '/', `${protocol}://${host}`);
691
+
692
+ const headers = new Headers();
693
+ for (const [key, value] of Object.entries(nodeRequest.headers)) {
694
+ if (value == null) continue;
695
+ if (Array.isArray(value)) {
696
+ for (const v of value) headers.append(key, v);
697
+ } else {
698
+ headers.set(key, value);
699
+ }
700
+ }
701
+
702
+ const method = (nodeRequest.method || 'GET').toUpperCase();
703
+ /** @type {RequestInit & { duplex?: 'half' }} */
704
+ const init = { method, headers };
705
+
706
+ // Add body for non-GET/HEAD requests
707
+ if (method !== 'GET' && method !== 'HEAD') {
708
+ init.body = /** @type {any} */ (Readable.toWeb(nodeRequest));
709
+ init.duplex = 'half';
710
+ }
711
+
712
+ return new Request(url, init);
713
+ }
714
+
715
+ /**
716
+ * Send a Web Response to a Node.js ServerResponse
717
+ * @param {import('node:http').ServerResponse} nodeResponse
718
+ * @param {Response} webResponse
719
+ */
720
+ async function sendWebResponse(nodeResponse, webResponse) {
721
+ nodeResponse.statusCode = webResponse.status;
722
+ if (webResponse.statusText) {
723
+ nodeResponse.statusMessage = webResponse.statusText;
724
+ }
725
+
726
+ // Copy headers
727
+ webResponse.headers.forEach((value, key) => {
728
+ nodeResponse.setHeader(key, value);
729
+ });
730
+
731
+ // Send body
732
+ if (webResponse.body) {
733
+ const reader = webResponse.body.getReader();
734
+ try {
735
+ while (true) {
736
+ const { done, value } = await reader.read();
737
+ if (done) break;
738
+ nodeResponse.write(value);
739
+ }
740
+ } finally {
741
+ reader.releaseLock();
742
+ }
743
+ }
744
+
745
+ nodeResponse.end();
746
+ }
747
+
748
+ /**
749
+ * Handle RPC requests for #server blocks
750
+ * @param {import('node:http').IncomingMessage} req
751
+ * @param {import('node:http').ServerResponse} res
752
+ * @param {URL} url
753
+ * @param {import('vite').ViteDevServer} vite
754
+ * @param {boolean} trustProxy
755
+ */
756
+ async function handleRpcRequest(req, res, url, vite, trustProxy) {
757
+ // we don't really need trustProxy in vite but leaving it as a model for production adapters
758
+ try {
759
+ const hash = url.pathname.slice('/_$_ripple_rpc_$_/'.length);
760
+
761
+ // Get request body
762
+ const body = await getRequestBody(req);
763
+
764
+ // Load the RPC module info from globalThis (set during SSR)
765
+ const rpcModules = /** @type {Map<string, [string, string]>} */ (
766
+ /** @type {any} */ (globalThis).rpc_modules
767
+ );
768
+ if (!rpcModules) {
769
+ res.statusCode = 500;
770
+ res.end('RPC modules not initialized');
771
+ return;
772
+ }
773
+
774
+ const moduleInfo = rpcModules.get(hash);
775
+ if (!moduleInfo) {
776
+ res.statusCode = 404;
777
+ res.end(`RPC function not found: ${hash}`);
778
+ return;
779
+ }
780
+
781
+ const [filePath, funcName] = moduleInfo;
782
+
783
+ // Load the module and execute the function
784
+ const { executeServerFunction } = await vite.ssrLoadModule('ripple/server');
785
+ const module = await vite.ssrLoadModule(filePath);
786
+ const server = module._$_server_$_;
787
+
788
+ if (!server || !server[funcName]) {
789
+ res.statusCode = 404;
790
+ res.end(`Server function not found: ${funcName}`);
791
+ return;
792
+ }
793
+
794
+ const origin = deriveOrigin(req, trustProxy);
795
+
796
+ // Execute server function within async context
797
+ // This allows the patched fetch to access the origin without global state
798
+ await rpcContext.run({ origin }, async () => {
799
+ const result = await executeServerFunction(server[funcName], body);
800
+
801
+ res.statusCode = 200;
802
+ res.setHeader('Content-Type', 'application/json');
803
+ res.end(result);
804
+ });
805
+ } catch (error) {
806
+ console.error('[@ripple-ts/vite-plugin] RPC error:', error);
807
+ res.statusCode = 500;
808
+ res.setHeader('Content-Type', 'application/json');
809
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'RPC failed' }));
810
+ }
811
+ }
812
+
813
+ /**
814
+ * Get the body of a request as a string
815
+ * @param {import('node:http').IncomingMessage} req
816
+ * @returns {Promise<string>}
817
+ */
818
+ function getRequestBody(req) {
819
+ return new Promise((resolve, reject) => {
820
+ let data = '';
821
+ req.on('data', (chunk) => {
822
+ data += chunk;
823
+ if (data.length > 1e6) {
824
+ req.destroy();
825
+ reject(new Error('Request body too large'));
826
+ }
827
+ });
828
+ req.on('end', () => resolve(data));
829
+ req.on('error', reject);
830
+ });
831
+ }
832
+
833
+ /**
834
+ * Escape HTML entities
835
+ * @param {string} str
836
+ * @returns {string}
837
+ */
838
+ function escapeHtml(str) {
839
+ return str
840
+ .replace(/&/g, '&amp;')
841
+ .replace(/</g, '&lt;')
842
+ .replace(/>/g, '&gt;')
843
+ .replace(/"/g, '&quot;');
844
+ }
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
+ }