@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.
package/src/index.js CHANGED
@@ -1,11 +1,10 @@
1
1
  /** @import {PackageJson} from 'type-fest' */
2
2
  /** @import {Plugin, ResolvedConfig, ViteDevServer} from 'vite' */
3
- /** @import {RipplePluginOptions, RippleConfigOptions, Route, Middleware, RenderRoute} from '@ripple-ts/vite-plugin' */
3
+ /** @import {RipplePluginOptions, RippleConfigOptions, ResolvedRippleConfig, Route, Middleware, RenderRoute} from '@ripple-ts/vite-plugin' */
4
4
 
5
5
  /// <reference types="ripple/compiler/internal/rpc" />
6
6
 
7
7
  import { compile } from 'ripple/compiler';
8
- import { AsyncLocalStorage } from 'node:async_hooks';
9
8
  import fs from 'node:fs';
10
9
  import path from 'node:path';
11
10
  import { createRequire } from 'node:module';
@@ -15,6 +14,16 @@ import { createRouter } from './server/router.js';
15
14
  import { createContext, runMiddlewareChain } from './server/middleware.js';
16
15
  import { handleRenderRoute } from './server/render-route.js';
17
16
  import { handleServerRoute } from './server/server-route.js';
17
+ import { generateServerEntry } from './server/virtual-entry.js';
18
+ import {
19
+ getRippleConfigPath,
20
+ loadRippleConfig,
21
+ resolveRippleConfig,
22
+ rippleConfigExists,
23
+ } from './load-config.js';
24
+ import { ENTRY_FILENAME } from './constants.js';
25
+
26
+ import { patch_global_fetch, is_rpc_request, handle_rpc_request } from '@ripple-ts/adapter/rpc';
18
27
 
19
28
  // Re-export route classes
20
29
  export { RenderRoute, ServerRoute } from './routes.js';
@@ -22,57 +31,41 @@ export { RenderRoute, ServerRoute } from './routes.js';
22
31
  const VITE_FS_PREFIX = '/@fs/';
23
32
  const IS_WINDOWS = process.platform === 'win32';
24
33
 
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;
34
+ // Dev server always runs in Node — use node:async_hooks as default runtime
35
+ // If the user provides adapter.runtime in their config, that will be used instead.
36
+ import { AsyncLocalStorage } from 'node:async_hooks';
30
37
 
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
- }
38
+ /** @type {import('@ripple-ts/adapter/rpc').AsyncContext | null} */
39
+ let devAsyncContext = null;
39
40
 
40
41
  /**
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>}
42
+ * Get (or lazily create) the dev server's async context.
43
+ * Uses adapter.runtime.createAsyncContext() if available, otherwise
44
+ * falls back to Node.js AsyncLocalStorage (always available in dev).
45
+ *
46
+ * @param {RippleConfigOptions | null} config
47
+ * @returns {import('@ripple-ts/adapter/rpc').AsyncContext}
49
48
  */
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
- }
49
+ function getDevAsyncContext(config) {
50
+ if (devAsyncContext) return devAsyncContext;
51
+
52
+ const adapterRuntime = config?.adapter?.runtime;
53
+ if (adapterRuntime?.createAsyncContext) {
54
+ devAsyncContext = adapterRuntime.createAsyncContext();
55
+ } else {
56
+ // Fallback: dev always runs in Node
57
+ const als = new AsyncLocalStorage();
58
+ devAsyncContext = {
59
+ run: (store, fn) => als.run(store, fn),
60
+ getStore: () => als.getStore(),
61
+ };
72
62
  }
73
63
 
74
- return originalFetch(input, init);
75
- };
64
+ // Patch fetch once using the async context
65
+ patch_global_fetch(devAsyncContext);
66
+
67
+ return devAsyncContext;
68
+ }
76
69
 
77
70
  /**
78
71
  * @param {string} filename
@@ -317,11 +310,23 @@ export function ripple(inlineOptions = {}) {
317
310
  const ripplePackages = new Set();
318
311
  const cssCache = new Map();
319
312
 
320
- /** @type {RippleConfigOptions | null} */
313
+ /** @type {ResolvedRippleConfig | null} */
321
314
  let rippleConfig = null;
322
315
  /** @type {ReturnType<typeof createRouter> | null} */
323
316
  let router = null;
324
317
 
318
+ /** @type {boolean} */
319
+ let isBuild = false;
320
+ /** @type {boolean} */
321
+ let isSSRBuild = false;
322
+
323
+ /** @type {string[]} Render route entry paths for client hydration import map */
324
+ let renderRouteEntries = [];
325
+ /** @type {ResolvedRippleConfig | null} Cached config from buildStart (reused in closeBundle) */
326
+ let loadedRippleConfig = null;
327
+ /** @type {Set<string>} File paths (relative to root) of .ripple modules with #server blocks */
328
+ const serverBlockModules = new Set();
329
+
325
330
  /** @type {Plugin[]} */
326
331
  const plugins = [
327
332
  {
@@ -330,7 +335,78 @@ export function ripple(inlineOptions = {}) {
330
335
  enforce: 'pre',
331
336
  api,
332
337
 
333
- async config(userConfig) {
338
+ async config(userConfig, { command }) {
339
+ isBuild = command === 'build';
340
+ isSSRBuild = !!userConfig.build?.ssr;
341
+
342
+ // In build mode (client build, not the SSR sub-build), configure for production
343
+ if (isBuild && !isSSRBuild) {
344
+ const projectRoot = userConfig.root || process.cwd();
345
+
346
+ if (rippleConfigExists(projectRoot)) {
347
+ const htmlInput = path.join(projectRoot, 'index.html');
348
+ if (!fs.existsSync(htmlInput)) {
349
+ throw new Error(
350
+ '[@ripple-ts/vite-plugin] index.html not found. ' +
351
+ 'Required for SSR builds with ripple.config.ts.',
352
+ );
353
+ }
354
+
355
+ console.log(
356
+ '[@ripple-ts/vite-plugin] Detected ripple.config.ts — configuring client build',
357
+ );
358
+
359
+ // Load ripple.config.ts early so build options (e.g. minify) can
360
+ // influence the client build config returned from this hook.
361
+ // The loaded config is cached and reused by
362
+ // buildStart and closeBundle.
363
+ loadedRippleConfig = await loadRippleConfig(projectRoot);
364
+
365
+ const outDir = loadedRippleConfig.build.outDir;
366
+
367
+ // Build Rollup inputs: HTML template + each page entry as a
368
+ // separate input. This gives Vite proper per-page code splitting
369
+ // and produces manifest entries for each page chunk.
370
+ /** @type {Record<string, string>} */
371
+ const rollupInput = { main: htmlInput };
372
+
373
+ const renderRoutes = loadedRippleConfig.router.routes.filter(
374
+ (/** @type {Route} */ r) => r.type === 'render',
375
+ );
376
+ const uniqueEntries = [
377
+ ...new Set(renderRoutes.map((/** @type {RenderRoute} */ r) => r.entry)),
378
+ ];
379
+ for (const entry of uniqueEntries) {
380
+ const sourcePath = entry.startsWith('/') ? entry.slice(1) : entry;
381
+ rollupInput[sourcePath] = path.join(projectRoot, sourcePath);
382
+ }
383
+ console.log(
384
+ `[@ripple-ts/vite-plugin] Adding ${uniqueEntries.length} page entry/entries as Rollup inputs`,
385
+ );
386
+
387
+ /** @type {import('vite').UserConfig['build']} */
388
+ const buildConfig = {
389
+ outDir: `${outDir}/client`,
390
+ emptyOutDir: true,
391
+ manifest: true,
392
+ rollupOptions: {
393
+ input: rollupInput,
394
+ },
395
+ };
396
+
397
+ // Only override minify when explicitly set in ripple.config.ts;
398
+ // otherwise let Vite's default (esbuild) apply.
399
+ if (loadedRippleConfig.build.minify !== undefined) {
400
+ buildConfig.minify = loadedRippleConfig.build.minify;
401
+ }
402
+
403
+ return {
404
+ appType: 'custom',
405
+ build: buildConfig,
406
+ };
407
+ }
408
+ }
409
+
334
410
  if (excludeRippleExternalModules) {
335
411
  return {
336
412
  optimizeDeps: {
@@ -374,6 +450,32 @@ export function ripple(inlineOptions = {}) {
374
450
  config = resolvedConfig;
375
451
  },
376
452
 
453
+ /**
454
+ * Load render route entries before the client build so virtual:ripple-hydrate
455
+ * can generate static import() calls that Vite will bundle.
456
+ */
457
+ async buildStart() {
458
+ if (!isBuild || isSSRBuild) return;
459
+
460
+ // Reuse config loaded in the config hook if available;
461
+ // otherwise load it now as a fallback.
462
+ if (!loadedRippleConfig) {
463
+ if (!rippleConfigExists(root)) return;
464
+ loadedRippleConfig = await loadRippleConfig(root);
465
+ }
466
+
467
+ renderRouteEntries = loadedRippleConfig.router.routes
468
+ .filter((/** @type {Route} */ r) => r.type === 'render')
469
+ .map((/** @type {RenderRoute} */ r) => r.entry);
470
+
471
+ // Deduplicate entries (multiple routes can share the same component)
472
+ renderRouteEntries = [...new Set(renderRouteEntries)];
473
+
474
+ console.log(
475
+ `[@ripple-ts/vite-plugin] Found ${renderRouteEntries.length} render route(s) for client hydration`,
476
+ );
477
+ },
478
+
377
479
  /**
378
480
  * Configure the dev server with SSR middleware
379
481
  * @param {ViteDevServer} vite
@@ -381,21 +483,10 @@ export function ripple(inlineOptions = {}) {
381
483
  configureServer(vite) {
382
484
  // Return a function to be called after Vite's internal middlewares
383
485
  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
- }
486
+ if (!rippleConfigExists(root)) return;
390
487
 
391
488
  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
- }
489
+ rippleConfig = await loadRippleConfig(root, { vite });
399
490
 
400
491
  // Create router from config
401
492
  router = createRouter(rippleConfig.router.routes);
@@ -421,13 +512,13 @@ export function ripple(inlineOptions = {}) {
421
512
  const method = req.method || 'GET';
422
513
 
423
514
  // Handle RPC requests for #server blocks
424
- if (url.pathname.startsWith('/_$_ripple_rpc_$_/')) {
515
+ if (is_rpc_request(url.pathname)) {
425
516
  await handleRpcRequest(
426
517
  req,
427
518
  res,
428
- url,
429
519
  vite,
430
- rippleConfig.server?.trustProxy ?? false,
520
+ rippleConfig.server.trustProxy,
521
+ rippleConfig,
431
522
  );
432
523
  return;
433
524
  }
@@ -442,20 +533,14 @@ export function ripple(inlineOptions = {}) {
442
533
 
443
534
  try {
444
535
  // 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;
536
+ const previousRoutes = rippleConfig.router.routes;
537
+ const freshConfig = await loadRippleConfig(root, { vite });
538
+ if (freshConfig) {
539
+ rippleConfig = freshConfig;
452
540
  }
453
541
 
454
542
  // Check if routes have changed
455
- if (
456
- JSON.stringify(freshConfig.default.router.routes) !==
457
- JSON.stringify(rippleConfig.router.routes)
458
- ) {
543
+ if (JSON.stringify(previousRoutes) !== JSON.stringify(rippleConfig.router.routes)) {
459
544
  console.log(
460
545
  `[@ripple-ts/vite-plugin] Detected route changes. Re-loading ${rippleConfig.router.routes.length} routes from ripple.config.ts`,
461
546
  );
@@ -474,8 +559,7 @@ export function ripple(inlineOptions = {}) {
474
559
  const request = nodeRequestToWebRequest(req);
475
560
  const context = createContext(request, freshMatch.params);
476
561
 
477
- // Get global middlewares
478
- const globalMiddlewares = rippleConfig.middlewares || [];
562
+ const globalMiddlewares = rippleConfig.middlewares;
479
563
 
480
564
  let response;
481
565
 
@@ -523,6 +607,266 @@ export function ripple(inlineOptions = {}) {
523
607
  };
524
608
  },
525
609
 
610
+ /**
611
+ * Handle HMR for files that Vite's default module HMR can't
612
+ * properly refresh.
613
+ *
614
+ * .ripple components self-accept HMR and swap their component
615
+ * function. For changes to their OWN code this works perfectly.
616
+ * But for changes to DEPENDENCIES (e.g. .md files imported via
617
+ * import.meta.glob), the self-accept handler can't refresh
618
+ * static imports cached in the browser's ESM module registry.
619
+ *
620
+ * Strategy:
621
+ * - If all changed modules self-accept → they can hot-replace
622
+ * themselves (e.g. .ripple components, CSS). Let Vite handle.
623
+ * - Otherwise, check the SSR module graph. If the file is there,
624
+ * invalidate SSR cache and trigger a full page reload.
625
+ */
626
+ hotUpdate({ file, modules, server }) {
627
+ if (this.environment.name !== 'client') return;
628
+
629
+ // If all changed modules self-accept, they can hot-replace
630
+ // themselves (.ripple components, CSS modules). Let Vite
631
+ // handle without intervention.
632
+ if (modules.length > 0 && modules.every((m) => m.isSelfAccepting)) {
633
+ return;
634
+ }
635
+
636
+ // Check if this file is part of the SSR module graph.
637
+ const ssr = server.environments.ssr;
638
+ if (!ssr) return;
639
+
640
+ const ssr_modules = ssr.moduleGraph.getModulesByFile(file);
641
+ if (!ssr_modules || ssr_modules.size === 0) return;
642
+
643
+ // Invalidate SSR modules so the server re-reads the file
644
+ // on next request instead of serving stale cached content.
645
+ for (const mod of ssr_modules) {
646
+ ssr.moduleGraph.invalidateModule(mod);
647
+ }
648
+
649
+ // Full reload — the only reliable way to pick up the change
650
+ // for files that don't self-accept but are consumed by the app.
651
+ this.environment.hot.send({ type: 'full-reload' });
652
+ return [];
653
+ },
654
+
655
+ /**
656
+ * Inject the hydration script into the HTML template during build.
657
+ * In dev mode, this is handled by render-route.js instead.
658
+ */
659
+ transformIndexHtml: {
660
+ order: 'pre',
661
+ handler(html) {
662
+ if (!isBuild || isSSRBuild) return html;
663
+
664
+ // Inject the hydration client entry script before </body>
665
+ const hydrationScript = `<script type="module" src="virtual:ripple-hydrate"></script>`;
666
+ return html.replace('</body>', `${hydrationScript}\n</body>`);
667
+ },
668
+ },
669
+
670
+ /**
671
+ * After the client build completes, trigger the SSR server build.
672
+ * This only runs for the primary (non-SSR) build.
673
+ */
674
+ async closeBundle() {
675
+ if (!isBuild || isSSRBuild) return;
676
+
677
+ // Reuse config loaded in buildStart, or load it now as fallback
678
+ if (!loadedRippleConfig) {
679
+ if (!rippleConfigExists(root)) return;
680
+ loadedRippleConfig = await loadRippleConfig(root);
681
+ }
682
+
683
+ console.log('[@ripple-ts/vite-plugin] Client build done. Starting server build...');
684
+
685
+ // Re-resolve with adapter validation for production builds.
686
+ // loadRippleConfig already resolved the config, but the adapter
687
+ // is only required for production server builds.
688
+ loadedRippleConfig = resolveRippleConfig(loadedRippleConfig, { requireAdapter: true });
689
+
690
+ const outDir = loadedRippleConfig.build.outDir;
691
+
692
+ // ------------------------------------------------------------------
693
+ // Read Vite's client manifest and build a per-route asset map.
694
+ // This lets the production server emit <link rel="stylesheet"> and
695
+ // <link rel="modulepreload"> tags for every CSS/JS file a page
696
+ // needs (including transitive dependencies).
697
+ // ------------------------------------------------------------------
698
+ const clientOutDir = path.join(root, outDir, 'client');
699
+ const manifestPath = path.join(clientOutDir, '.vite', 'manifest.json');
700
+
701
+ /** @type {Record<string, { file: string, css?: string[], imports?: string[], name?: string }>} */
702
+ let clientManifest = {};
703
+ if (fs.existsSync(manifestPath)) {
704
+ clientManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
705
+ } else {
706
+ console.warn(
707
+ '[@ripple-ts/vite-plugin] Client manifest not found at',
708
+ manifestPath,
709
+ '— asset preloading will be unavailable',
710
+ );
711
+ }
712
+
713
+ /**
714
+ * Recursively collect all CSS files from a manifest entry and its
715
+ * imports, avoiding cycles via a visited set.
716
+ * @param {string} key - Manifest key (source-relative path)
717
+ * @param {Set<string>} [visited] - Already visited keys
718
+ * @returns {string[]}
719
+ */
720
+ const collectCss = (key, visited = new Set()) => {
721
+ if (visited.has(key)) return [];
722
+ visited.add(key);
723
+ const entry = clientManifest[key];
724
+ if (!entry) return [];
725
+ /** @type {string[]} */
726
+ const css = [...(entry.css || [])];
727
+ for (const imp of entry.imports || []) {
728
+ css.push(...collectCss(imp, visited));
729
+ }
730
+ return css;
731
+ };
732
+
733
+ // Build a map of route entry → { js, css } from the manifest
734
+ /** @type {Record<string, { js: string, css: string[] }>} */
735
+ const clientAssetMap = {};
736
+
737
+ const renderRoutes = loadedRippleConfig.router.routes.filter(
738
+ (/** @type {Route} */ r) => r.type === 'render',
739
+ );
740
+ const uniqueEntries = [
741
+ ...new Set(renderRoutes.map((/** @type {RenderRoute} */ r) => r.entry)),
742
+ ];
743
+
744
+ for (const entry of uniqueEntries) {
745
+ const manifestKey = entry.startsWith('/') ? entry.slice(1) : entry;
746
+ const manifestEntry = clientManifest[manifestKey];
747
+ if (manifestEntry) {
748
+ clientAssetMap[entry] = {
749
+ js: manifestEntry.file,
750
+ css: [...new Set(collectCss(manifestKey))],
751
+ };
752
+ }
753
+ }
754
+
755
+ // Find the hydrate runtime entry in the manifest
756
+ let hydrateJsAsset = '';
757
+ for (const [key, value] of Object.entries(clientManifest)) {
758
+ if (key.includes('virtual:ripple-hydrate') || value.name === '__ripple_hydrate') {
759
+ hydrateJsAsset = value.file;
760
+ break;
761
+ }
762
+ }
763
+
764
+ if (hydrateJsAsset) {
765
+ // Store as a special key so the server can modulepreload it
766
+ clientAssetMap.__hydrate_js = { js: hydrateJsAsset, css: [] };
767
+ }
768
+
769
+ console.log(
770
+ `[@ripple-ts/vite-plugin] Built client asset map for ${Object.keys(clientAssetMap).length} entries`,
771
+ );
772
+
773
+ // Remove the .vite folder from the client build output.
774
+ // The manifest was only needed at build time to construct the
775
+ // clientAssetMap above. Leaving it in dist/client would expose
776
+ // source file paths publicly via the static file server.
777
+ const viteMetaDir = path.join(clientOutDir, '.vite');
778
+ try {
779
+ fs.rmSync(viteMetaDir, { recursive: true, force: true });
780
+ console.log('[@ripple-ts/vite-plugin] Removed .vite metadata from client output');
781
+ } catch {
782
+ // Non-fatal — warn but continue
783
+ console.warn('[@ripple-ts/vite-plugin] Could not remove .vite folder from client output');
784
+ }
785
+
786
+ // Generate the virtual server entry
787
+ const serverEntryCode = generateServerEntry({
788
+ routes: loadedRippleConfig.router.routes,
789
+ rippleConfigPath: getRippleConfigPath(root),
790
+ htmlTemplatePath: './index.html',
791
+ rpcModulePaths: [...serverBlockModules],
792
+ clientAssetMap,
793
+ });
794
+
795
+ const VIRTUAL_SERVER_ENTRY_ID = 'virtual:ripple-server-entry';
796
+ const RESOLVED_VIRTUAL_SERVER_ENTRY_ID = '\0' + VIRTUAL_SERVER_ENTRY_ID;
797
+
798
+ /** @type {Plugin} */
799
+ const virtualEntryPlugin = {
800
+ name: 'ripple-virtual-server-entry',
801
+ resolveId(id) {
802
+ if (id === VIRTUAL_SERVER_ENTRY_ID) return RESOLVED_VIRTUAL_SERVER_ENTRY_ID;
803
+ },
804
+ load(id) {
805
+ if (id === RESOLVED_VIRTUAL_SERVER_ENTRY_ID) return serverEntryCode;
806
+ },
807
+ };
808
+
809
+ const serverOutDir = path.join(root, outDir, 'server');
810
+
811
+ // Do NOT add ripple() here — the user's vite.config.ts (loaded automatically
812
+ // from `root`) already includes it. Adding another instance causes double
813
+ // compilation of .ripple files.
814
+ const { build: viteBuild } = await import('vite');
815
+ try {
816
+ await viteBuild({
817
+ root,
818
+ appType: 'custom',
819
+ plugins: [virtualEntryPlugin],
820
+ build: {
821
+ outDir: serverOutDir,
822
+ emptyOutDir: true,
823
+ ssr: true,
824
+ target: loadedRippleConfig?.build?.target,
825
+ minify: loadedRippleConfig?.build?.minify ?? false,
826
+ rollupOptions: {
827
+ input: VIRTUAL_SERVER_ENTRY_ID,
828
+ output: {
829
+ entryFileNames: ENTRY_FILENAME,
830
+ format: 'esm',
831
+ },
832
+ },
833
+ },
834
+ ssr: {
835
+ external: [
836
+ '@ripple-ts/adapter',
837
+ '@ripple-ts/adapter-node',
838
+ '@ripple-ts/adapter-bun',
839
+ '@ripple-ts/adapter-vercel',
840
+ ],
841
+ noExternal: [],
842
+ },
843
+ });
844
+
845
+ // Copy the HTML template into the server output so the server
846
+ // entry is self-contained and doesn't depend on dist/client/.
847
+ // This is critical for platforms like Vercel where dist/client/
848
+ // is served as static files and index.html would be returned as-is
849
+ // (with unresolved SSR placeholders) instead of going through SSR.
850
+ const clientHtml = path.join(clientOutDir, 'index.html');
851
+ const serverHtml = path.join(serverOutDir, 'index.html');
852
+ if (fs.existsSync(clientHtml)) {
853
+ fs.copyFileSync(clientHtml, serverHtml);
854
+ console.log('[@ripple-ts/vite-plugin] Copied HTML template to server output');
855
+ }
856
+
857
+ console.log('[@ripple-ts/vite-plugin] Server build complete.');
858
+ console.log(`[@ripple-ts/vite-plugin] Output: ${path.join(root, outDir)}`);
859
+ console.log(
860
+ `[@ripple-ts/vite-plugin] Start with: node ${outDir}/server/${ENTRY_FILENAME}`,
861
+ );
862
+ } catch (/** @type {any} */ error) {
863
+ console.error('[@ripple-ts/vite-plugin] Server build failed:', error);
864
+ throw new Error(
865
+ `Server build failed: ${error instanceof Error ? error.message : String(error)}`,
866
+ );
867
+ }
868
+ },
869
+
526
870
  async resolveId(id, importer, options) {
527
871
  // Handle virtual hydrate module
528
872
  if (id === 'virtual:ripple-hydrate') {
@@ -571,21 +915,45 @@ export function ripple(inlineOptions = {}) {
571
915
  async load(id, opts) {
572
916
  // Handle virtual hydrate module
573
917
  if (id === '\0virtual:ripple-hydrate') {
574
- return `
918
+ if (isBuild && renderRouteEntries.length > 0) {
919
+ // Production: generate static import map so Vite bundles page components
920
+ const importMapLines = renderRouteEntries
921
+ .map((entry) => ` ${JSON.stringify(entry)}: () => import(${JSON.stringify(entry)}),`)
922
+ .join('\n');
923
+
924
+ // IMPORTANT: Use async IIFE instead of top-level await.
925
+ // The page modules statically import from the main bundle (which contains
926
+ // the runtime). If we used top-level await here, it would deadlock:
927
+ // main bundle awaits page module import → page module awaits main bundle's
928
+ // TLA to complete → circular wait.
929
+ return `
575
930
  import { hydrate, mount } from 'ripple';
576
931
 
577
- const data = JSON.parse(document.getElementById('__ripple_data').textContent);
578
- const target = document.getElementById('root');
932
+ const routeModules = {
933
+ ${importMapLines}
934
+ };
935
+
936
+ (async () => {
937
+ try {
938
+ const data = JSON.parse(document.getElementById('__ripple_data').textContent);
939
+ const target = document.getElementById('root');
940
+ const loadModule = routeModules[data.entry];
579
941
 
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];
942
+ if (!loadModule) {
943
+ console.error('[ripple] No client module for route:', data.entry);
944
+ return;
945
+ }
946
+
947
+ const module = await loadModule();
948
+ const Component =
949
+ module.default ||
950
+ Object.entries(module).find(([key, value]) => typeof value === 'function' && /^[A-Z]/.test(key))?.[1];
951
+
952
+ if (!Component || !target) {
953
+ console.error('[ripple] Unable to hydrate route: missing component export or #root target.');
954
+ return;
955
+ }
585
956
 
586
- if (!Component || !target) {
587
- console.error('[ripple] Unable to hydrate route: missing component export or #root target.');
588
- } else {
589
957
  try {
590
958
  hydrate(Component, {
591
959
  target,
@@ -598,10 +966,48 @@ try {
598
966
  props: { params: data.params }
599
967
  });
600
968
  }
969
+ } catch (error) {
970
+ console.error('[ripple] Failed to bootstrap client hydration.', error);
601
971
  }
602
- } catch (error) {
603
- console.error('[ripple] Failed to bootstrap client hydration.', error);
604
- }
972
+ })();
973
+ `;
974
+ }
975
+
976
+ // Dev mode: use async IIFE to avoid top-level await deadlock
977
+ // (same reason as production — page modules import from the main bundle)
978
+ return `
979
+ import { hydrate, mount } from 'ripple';
980
+
981
+ (async () => {
982
+ try {
983
+ const data = JSON.parse(document.getElementById('__ripple_data').textContent);
984
+ const target = document.getElementById('root');
985
+ const module = await import(/* @vite-ignore */ data.entry);
986
+ const Component =
987
+ module.default ||
988
+ Object.entries(module).find(([key, value]) => typeof value === 'function' && /^[A-Z]/.test(key))?.[1];
989
+
990
+ if (!Component || !target) {
991
+ console.error('[ripple] Unable to hydrate route: missing component export or #root target.');
992
+ return;
993
+ }
994
+
995
+ try {
996
+ hydrate(Component, {
997
+ target,
998
+ props: { params: data.params }
999
+ });
1000
+ } catch (error) {
1001
+ console.warn('[ripple] Hydration failed, falling back to mount.', error);
1002
+ mount(Component, {
1003
+ target,
1004
+ props: { params: data.params }
1005
+ });
1006
+ }
1007
+ } catch (error) {
1008
+ console.error('[ripple] Failed to bootstrap client hydration.', error);
1009
+ }
1010
+ })();
605
1011
  `;
606
1012
  }
607
1013
 
@@ -617,11 +1023,19 @@ try {
617
1023
  const filename = id.replace(root, '');
618
1024
  const ssr = opts?.ssr === true || this.environment.config.consumer === 'server';
619
1025
 
1026
+ const is_dev = config?.command === 'serve';
1027
+
620
1028
  const { js, css } = await compile(code, filename, {
621
1029
  mode: ssr ? 'server' : 'client',
622
- dev: config?.command === 'serve',
1030
+ dev: is_dev,
1031
+ hmr: is_dev && !ssr,
623
1032
  });
624
1033
 
1034
+ // Track modules with #server blocks for RPC (client build only)
1035
+ if (isBuild && !ssr && js.code.includes('_$_.rpc(')) {
1036
+ serverBlockModules.add(filename);
1037
+ }
1038
+
625
1039
  if (css !== '') {
626
1040
  const cssId = createVirtualImportId(filename, root, 'style');
627
1041
  cssCache.set(cssId, css);
@@ -638,7 +1052,7 @@ try {
638
1052
  }
639
1053
 
640
1054
  // This is mainly to enforce types and provide a better DX with types than anything else
641
- export function defineConfig(/** @type {RipplePluginOptions} */ options) {
1055
+ export function defineConfig(/** @type {RippleConfigOptions} */ options) {
642
1056
  return options;
643
1057
  }
644
1058
 
@@ -646,39 +1060,6 @@ export function defineConfig(/** @type {RipplePluginOptions} */ options) {
646
1060
  // Helper functions for dev server
647
1061
  // ============================================================================
648
1062
 
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
1063
  /**
683
1064
  * Convert a Node.js IncomingMessage to a Web Request
684
1065
  * @param {import('node:http').IncomingMessage} nodeRequest
@@ -746,62 +1127,50 @@ async function sendWebResponse(nodeResponse, webResponse) {
746
1127
  }
747
1128
 
748
1129
  /**
749
- * Handle RPC requests for #server blocks
1130
+ * Handle RPC requests for #server blocks in dev mode.
1131
+ *
1132
+ * Delegates to the shared `handle_rpc_request` from `@ripple-ts/adapter/rpc`,
1133
+ * providing a dev-specific `resolveFunction` that uses Vite's `ssrLoadModule`
1134
+ * and `globalThis.rpc_modules` (populated by the compiler during SSR).
1135
+ *
750
1136
  * @param {import('node:http').IncomingMessage} req
751
1137
  * @param {import('node:http').ServerResponse} res
752
- * @param {URL} url
753
1138
  * @param {import('vite').ViteDevServer} vite
754
1139
  * @param {boolean} trustProxy
1140
+ * @param {RippleConfigOptions | null} config
755
1141
  */
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
1142
+ async function handleRpcRequest(req, res, vite, trustProxy, config) {
758
1143
  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
- }
1144
+ // Convert Node request to Web Request for the shared handler
1145
+ const webRequest = nodeRequestToWebRequest(req);
1146
+ const asyncContext = getDevAsyncContext(config);
1147
+
1148
+ const response = await handle_rpc_request(webRequest, {
1149
+ async resolveFunction(hash) {
1150
+ const rpcModules = /** @type {Map<string, [string, string]>} */ (
1151
+ /** @type {any} */ (globalThis).rpc_modules
1152
+ );
1153
+ if (!rpcModules) return null;
793
1154
 
794
- const origin = deriveOrigin(req, trustProxy);
1155
+ const moduleInfo = rpcModules.get(hash);
1156
+ if (!moduleInfo) return null;
795
1157
 
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);
1158
+ const [filePath, funcName] = moduleInfo;
1159
+ const module = await vite.ssrLoadModule(filePath);
1160
+ const server = module._$_server_$_;
800
1161
 
801
- res.statusCode = 200;
802
- res.setHeader('Content-Type', 'application/json');
803
- res.end(result);
1162
+ if (!server || !server[funcName]) return null;
1163
+ return server[funcName];
1164
+ },
1165
+ async executeServerFunction(fn, body) {
1166
+ const { executeServerFunction } = await vite.ssrLoadModule('ripple/server');
1167
+ return executeServerFunction(fn, body);
1168
+ },
1169
+ asyncContext,
1170
+ trustProxy,
804
1171
  });
1172
+
1173
+ await sendWebResponse(res, response);
805
1174
  } catch (error) {
806
1175
  console.error('[@ripple-ts/vite-plugin] RPC error:', error);
807
1176
  res.statusCode = 500;
@@ -810,26 +1179,6 @@ async function handleRpcRequest(req, res, url, vite, trustProxy) {
810
1179
  }
811
1180
  }
812
1181
 
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
1182
  /**
834
1183
  * Escape HTML entities
835
1184
  * @param {string} str