@ripple-ts/vite-plugin 0.2.213 → 0.2.214
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 +12 -0
- package/package.json +14 -3
- package/src/bin/preview.js +43 -0
- package/src/constants.js +2 -0
- package/src/index.js +493 -192
- package/src/load-config.js +172 -0
- package/src/server/production.js +119 -93
- package/src/server/render-route.js +1 -1
- package/src/server/virtual-entry.js +215 -0
- package/types/index.d.ts +117 -1
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
|
-
//
|
|
26
|
-
|
|
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';
|
|
27
37
|
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
/** @type {import('@ripple-ts/adapter/rpc').AsyncContext | null} */
|
|
39
|
+
let devAsyncContext = null;
|
|
30
40
|
|
|
31
41
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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>}
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
478
|
-
const globalMiddlewares = rippleConfig.middlewares || [];
|
|
562
|
+
const globalMiddlewares = rippleConfig.middlewares;
|
|
479
563
|
|
|
480
564
|
let response;
|
|
481
565
|
|
|
@@ -523,6 +607,221 @@ export function ripple(inlineOptions = {}) {
|
|
|
523
607
|
};
|
|
524
608
|
},
|
|
525
609
|
|
|
610
|
+
/**
|
|
611
|
+
* Inject the hydration script into the HTML template during build.
|
|
612
|
+
* In dev mode, this is handled by render-route.js instead.
|
|
613
|
+
*/
|
|
614
|
+
transformIndexHtml: {
|
|
615
|
+
order: 'pre',
|
|
616
|
+
handler(html) {
|
|
617
|
+
if (!isBuild || isSSRBuild) return html;
|
|
618
|
+
|
|
619
|
+
// Inject the hydration client entry script before </body>
|
|
620
|
+
const hydrationScript = `<script type="module" src="virtual:ripple-hydrate"></script>`;
|
|
621
|
+
return html.replace('</body>', `${hydrationScript}\n</body>`);
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* After the client build completes, trigger the SSR server build.
|
|
627
|
+
* This only runs for the primary (non-SSR) build.
|
|
628
|
+
*/
|
|
629
|
+
async closeBundle() {
|
|
630
|
+
if (!isBuild || isSSRBuild) return;
|
|
631
|
+
|
|
632
|
+
// Reuse config loaded in buildStart, or load it now as fallback
|
|
633
|
+
if (!loadedRippleConfig) {
|
|
634
|
+
if (!rippleConfigExists(root)) return;
|
|
635
|
+
loadedRippleConfig = await loadRippleConfig(root);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
console.log('[@ripple-ts/vite-plugin] Client build done. Starting server build...');
|
|
639
|
+
|
|
640
|
+
// Re-resolve with adapter validation for production builds.
|
|
641
|
+
// loadRippleConfig already resolved the config, but the adapter
|
|
642
|
+
// is only required for production server builds.
|
|
643
|
+
loadedRippleConfig = resolveRippleConfig(loadedRippleConfig, { requireAdapter: true });
|
|
644
|
+
|
|
645
|
+
const outDir = loadedRippleConfig.build.outDir;
|
|
646
|
+
|
|
647
|
+
// ------------------------------------------------------------------
|
|
648
|
+
// Read Vite's client manifest and build a per-route asset map.
|
|
649
|
+
// This lets the production server emit <link rel="stylesheet"> and
|
|
650
|
+
// <link rel="modulepreload"> tags for every CSS/JS file a page
|
|
651
|
+
// needs (including transitive dependencies).
|
|
652
|
+
// ------------------------------------------------------------------
|
|
653
|
+
const clientOutDir = path.join(root, outDir, 'client');
|
|
654
|
+
const manifestPath = path.join(clientOutDir, '.vite', 'manifest.json');
|
|
655
|
+
|
|
656
|
+
/** @type {Record<string, { file: string, css?: string[], imports?: string[], name?: string }>} */
|
|
657
|
+
let clientManifest = {};
|
|
658
|
+
if (fs.existsSync(manifestPath)) {
|
|
659
|
+
clientManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
660
|
+
} else {
|
|
661
|
+
console.warn(
|
|
662
|
+
'[@ripple-ts/vite-plugin] Client manifest not found at',
|
|
663
|
+
manifestPath,
|
|
664
|
+
'— asset preloading will be unavailable',
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Recursively collect all CSS files from a manifest entry and its
|
|
670
|
+
* imports, avoiding cycles via a visited set.
|
|
671
|
+
* @param {string} key - Manifest key (source-relative path)
|
|
672
|
+
* @param {Set<string>} [visited] - Already visited keys
|
|
673
|
+
* @returns {string[]}
|
|
674
|
+
*/
|
|
675
|
+
const collectCss = (key, visited = new Set()) => {
|
|
676
|
+
if (visited.has(key)) return [];
|
|
677
|
+
visited.add(key);
|
|
678
|
+
const entry = clientManifest[key];
|
|
679
|
+
if (!entry) return [];
|
|
680
|
+
/** @type {string[]} */
|
|
681
|
+
const css = [...(entry.css || [])];
|
|
682
|
+
for (const imp of entry.imports || []) {
|
|
683
|
+
css.push(...collectCss(imp, visited));
|
|
684
|
+
}
|
|
685
|
+
return css;
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
// Build a map of route entry → { js, css } from the manifest
|
|
689
|
+
/** @type {Record<string, { js: string, css: string[] }>} */
|
|
690
|
+
const clientAssetMap = {};
|
|
691
|
+
|
|
692
|
+
const renderRoutes = loadedRippleConfig.router.routes.filter(
|
|
693
|
+
(/** @type {Route} */ r) => r.type === 'render',
|
|
694
|
+
);
|
|
695
|
+
const uniqueEntries = [
|
|
696
|
+
...new Set(renderRoutes.map((/** @type {RenderRoute} */ r) => r.entry)),
|
|
697
|
+
];
|
|
698
|
+
|
|
699
|
+
for (const entry of uniqueEntries) {
|
|
700
|
+
const manifestKey = entry.startsWith('/') ? entry.slice(1) : entry;
|
|
701
|
+
const manifestEntry = clientManifest[manifestKey];
|
|
702
|
+
if (manifestEntry) {
|
|
703
|
+
clientAssetMap[entry] = {
|
|
704
|
+
js: manifestEntry.file,
|
|
705
|
+
css: [...new Set(collectCss(manifestKey))],
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Find the hydrate runtime entry in the manifest
|
|
711
|
+
let hydrateJsAsset = '';
|
|
712
|
+
for (const [key, value] of Object.entries(clientManifest)) {
|
|
713
|
+
if (key.includes('virtual:ripple-hydrate') || value.name === '__ripple_hydrate') {
|
|
714
|
+
hydrateJsAsset = value.file;
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (hydrateJsAsset) {
|
|
720
|
+
// Store as a special key so the server can modulepreload it
|
|
721
|
+
clientAssetMap.__hydrate_js = { js: hydrateJsAsset, css: [] };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
console.log(
|
|
725
|
+
`[@ripple-ts/vite-plugin] Built client asset map for ${Object.keys(clientAssetMap).length} entries`,
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
// Remove the .vite folder from the client build output.
|
|
729
|
+
// The manifest was only needed at build time to construct the
|
|
730
|
+
// clientAssetMap above. Leaving it in dist/client would expose
|
|
731
|
+
// source file paths publicly via the static file server.
|
|
732
|
+
const viteMetaDir = path.join(clientOutDir, '.vite');
|
|
733
|
+
try {
|
|
734
|
+
fs.rmSync(viteMetaDir, { recursive: true, force: true });
|
|
735
|
+
console.log('[@ripple-ts/vite-plugin] Removed .vite metadata from client output');
|
|
736
|
+
} catch {
|
|
737
|
+
// Non-fatal — warn but continue
|
|
738
|
+
console.warn('[@ripple-ts/vite-plugin] Could not remove .vite folder from client output');
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Generate the virtual server entry
|
|
742
|
+
const serverEntryCode = generateServerEntry({
|
|
743
|
+
routes: loadedRippleConfig.router.routes,
|
|
744
|
+
rippleConfigPath: getRippleConfigPath(root),
|
|
745
|
+
htmlTemplatePath: './index.html',
|
|
746
|
+
rpcModulePaths: [...serverBlockModules],
|
|
747
|
+
clientAssetMap,
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
const VIRTUAL_SERVER_ENTRY_ID = 'virtual:ripple-server-entry';
|
|
751
|
+
const RESOLVED_VIRTUAL_SERVER_ENTRY_ID = '\0' + VIRTUAL_SERVER_ENTRY_ID;
|
|
752
|
+
|
|
753
|
+
/** @type {Plugin} */
|
|
754
|
+
const virtualEntryPlugin = {
|
|
755
|
+
name: 'ripple-virtual-server-entry',
|
|
756
|
+
resolveId(id) {
|
|
757
|
+
if (id === VIRTUAL_SERVER_ENTRY_ID) return RESOLVED_VIRTUAL_SERVER_ENTRY_ID;
|
|
758
|
+
},
|
|
759
|
+
load(id) {
|
|
760
|
+
if (id === RESOLVED_VIRTUAL_SERVER_ENTRY_ID) return serverEntryCode;
|
|
761
|
+
},
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
const serverOutDir = path.join(root, outDir, 'server');
|
|
765
|
+
|
|
766
|
+
// Do NOT add ripple() here — the user's vite.config.ts (loaded automatically
|
|
767
|
+
// from `root`) already includes it. Adding another instance causes double
|
|
768
|
+
// compilation of .ripple files.
|
|
769
|
+
const { build: viteBuild } = await import('vite');
|
|
770
|
+
try {
|
|
771
|
+
await viteBuild({
|
|
772
|
+
root,
|
|
773
|
+
appType: 'custom',
|
|
774
|
+
plugins: [virtualEntryPlugin],
|
|
775
|
+
build: {
|
|
776
|
+
outDir: serverOutDir,
|
|
777
|
+
emptyOutDir: true,
|
|
778
|
+
ssr: true,
|
|
779
|
+
target: loadedRippleConfig?.build?.target,
|
|
780
|
+
minify: loadedRippleConfig?.build?.minify ?? false,
|
|
781
|
+
rollupOptions: {
|
|
782
|
+
input: VIRTUAL_SERVER_ENTRY_ID,
|
|
783
|
+
output: {
|
|
784
|
+
entryFileNames: ENTRY_FILENAME,
|
|
785
|
+
format: 'esm',
|
|
786
|
+
},
|
|
787
|
+
},
|
|
788
|
+
},
|
|
789
|
+
ssr: {
|
|
790
|
+
external: [
|
|
791
|
+
'@ripple-ts/adapter',
|
|
792
|
+
'@ripple-ts/adapter-node',
|
|
793
|
+
'@ripple-ts/adapter-bun',
|
|
794
|
+
'@ripple-ts/adapter-vercel',
|
|
795
|
+
],
|
|
796
|
+
noExternal: [],
|
|
797
|
+
},
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
// Copy the HTML template into the server output so the server
|
|
801
|
+
// entry is self-contained and doesn't depend on dist/client/.
|
|
802
|
+
// This is critical for platforms like Vercel where dist/client/
|
|
803
|
+
// is served as static files and index.html would be returned as-is
|
|
804
|
+
// (with unresolved SSR placeholders) instead of going through SSR.
|
|
805
|
+
const clientHtml = path.join(clientOutDir, 'index.html');
|
|
806
|
+
const serverHtml = path.join(serverOutDir, 'index.html');
|
|
807
|
+
if (fs.existsSync(clientHtml)) {
|
|
808
|
+
fs.copyFileSync(clientHtml, serverHtml);
|
|
809
|
+
console.log('[@ripple-ts/vite-plugin] Copied HTML template to server output');
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
console.log('[@ripple-ts/vite-plugin] Server build complete.');
|
|
813
|
+
console.log(`[@ripple-ts/vite-plugin] Output: ${path.join(root, outDir)}`);
|
|
814
|
+
console.log(
|
|
815
|
+
`[@ripple-ts/vite-plugin] Start with: node ${outDir}/server/${ENTRY_FILENAME}`,
|
|
816
|
+
);
|
|
817
|
+
} catch (/** @type {any} */ error) {
|
|
818
|
+
console.error('[@ripple-ts/vite-plugin] Server build failed:', error);
|
|
819
|
+
throw new Error(
|
|
820
|
+
`Server build failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
},
|
|
824
|
+
|
|
526
825
|
async resolveId(id, importer, options) {
|
|
527
826
|
// Handle virtual hydrate module
|
|
528
827
|
if (id === 'virtual:ripple-hydrate') {
|
|
@@ -571,21 +870,45 @@ export function ripple(inlineOptions = {}) {
|
|
|
571
870
|
async load(id, opts) {
|
|
572
871
|
// Handle virtual hydrate module
|
|
573
872
|
if (id === '\0virtual:ripple-hydrate') {
|
|
574
|
-
|
|
873
|
+
if (isBuild && renderRouteEntries.length > 0) {
|
|
874
|
+
// Production: generate static import map so Vite bundles page components
|
|
875
|
+
const importMapLines = renderRouteEntries
|
|
876
|
+
.map((entry) => ` ${JSON.stringify(entry)}: () => import(${JSON.stringify(entry)}),`)
|
|
877
|
+
.join('\n');
|
|
878
|
+
|
|
879
|
+
// IMPORTANT: Use async IIFE instead of top-level await.
|
|
880
|
+
// The page modules statically import from the main bundle (which contains
|
|
881
|
+
// the runtime). If we used top-level await here, it would deadlock:
|
|
882
|
+
// main bundle awaits page module import → page module awaits main bundle's
|
|
883
|
+
// TLA to complete → circular wait.
|
|
884
|
+
return `
|
|
575
885
|
import { hydrate, mount } from 'ripple';
|
|
576
886
|
|
|
577
|
-
const
|
|
578
|
-
|
|
887
|
+
const routeModules = {
|
|
888
|
+
${importMapLines}
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
(async () => {
|
|
892
|
+
try {
|
|
893
|
+
const data = JSON.parse(document.getElementById('__ripple_data').textContent);
|
|
894
|
+
const target = document.getElementById('root');
|
|
895
|
+
const loadModule = routeModules[data.entry];
|
|
579
896
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
897
|
+
if (!loadModule) {
|
|
898
|
+
console.error('[ripple] No client module for route:', data.entry);
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const module = await loadModule();
|
|
903
|
+
const Component =
|
|
904
|
+
module.default ||
|
|
905
|
+
Object.entries(module).find(([key, value]) => typeof value === 'function' && /^[A-Z]/.test(key))?.[1];
|
|
906
|
+
|
|
907
|
+
if (!Component || !target) {
|
|
908
|
+
console.error('[ripple] Unable to hydrate route: missing component export or #root target.');
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
585
911
|
|
|
586
|
-
if (!Component || !target) {
|
|
587
|
-
console.error('[ripple] Unable to hydrate route: missing component export or #root target.');
|
|
588
|
-
} else {
|
|
589
912
|
try {
|
|
590
913
|
hydrate(Component, {
|
|
591
914
|
target,
|
|
@@ -598,10 +921,48 @@ try {
|
|
|
598
921
|
props: { params: data.params }
|
|
599
922
|
});
|
|
600
923
|
}
|
|
924
|
+
} catch (error) {
|
|
925
|
+
console.error('[ripple] Failed to bootstrap client hydration.', error);
|
|
601
926
|
}
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
}
|
|
927
|
+
})();
|
|
928
|
+
`;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Dev mode: use async IIFE to avoid top-level await deadlock
|
|
932
|
+
// (same reason as production — page modules import from the main bundle)
|
|
933
|
+
return `
|
|
934
|
+
import { hydrate, mount } from 'ripple';
|
|
935
|
+
|
|
936
|
+
(async () => {
|
|
937
|
+
try {
|
|
938
|
+
const data = JSON.parse(document.getElementById('__ripple_data').textContent);
|
|
939
|
+
const target = document.getElementById('root');
|
|
940
|
+
const module = await import(/* @vite-ignore */ data.entry);
|
|
941
|
+
const Component =
|
|
942
|
+
module.default ||
|
|
943
|
+
Object.entries(module).find(([key, value]) => typeof value === 'function' && /^[A-Z]/.test(key))?.[1];
|
|
944
|
+
|
|
945
|
+
if (!Component || !target) {
|
|
946
|
+
console.error('[ripple] Unable to hydrate route: missing component export or #root target.');
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
try {
|
|
951
|
+
hydrate(Component, {
|
|
952
|
+
target,
|
|
953
|
+
props: { params: data.params }
|
|
954
|
+
});
|
|
955
|
+
} catch (error) {
|
|
956
|
+
console.warn('[ripple] Hydration failed, falling back to mount.', error);
|
|
957
|
+
mount(Component, {
|
|
958
|
+
target,
|
|
959
|
+
props: { params: data.params }
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
} catch (error) {
|
|
963
|
+
console.error('[ripple] Failed to bootstrap client hydration.', error);
|
|
964
|
+
}
|
|
965
|
+
})();
|
|
605
966
|
`;
|
|
606
967
|
}
|
|
607
968
|
|
|
@@ -622,6 +983,11 @@ try {
|
|
|
622
983
|
dev: config?.command === 'serve',
|
|
623
984
|
});
|
|
624
985
|
|
|
986
|
+
// Track modules with #server blocks for RPC (client build only)
|
|
987
|
+
if (isBuild && !ssr && js.code.includes('_$_.rpc(')) {
|
|
988
|
+
serverBlockModules.add(filename);
|
|
989
|
+
}
|
|
990
|
+
|
|
625
991
|
if (css !== '') {
|
|
626
992
|
const cssId = createVirtualImportId(filename, root, 'style');
|
|
627
993
|
cssCache.set(cssId, css);
|
|
@@ -638,7 +1004,7 @@ try {
|
|
|
638
1004
|
}
|
|
639
1005
|
|
|
640
1006
|
// This is mainly to enforce types and provide a better DX with types than anything else
|
|
641
|
-
export function defineConfig(/** @type {
|
|
1007
|
+
export function defineConfig(/** @type {RippleConfigOptions} */ options) {
|
|
642
1008
|
return options;
|
|
643
1009
|
}
|
|
644
1010
|
|
|
@@ -646,39 +1012,6 @@ export function defineConfig(/** @type {RipplePluginOptions} */ options) {
|
|
|
646
1012
|
// Helper functions for dev server
|
|
647
1013
|
// ============================================================================
|
|
648
1014
|
|
|
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
1015
|
/**
|
|
683
1016
|
* Convert a Node.js IncomingMessage to a Web Request
|
|
684
1017
|
* @param {import('node:http').IncomingMessage} nodeRequest
|
|
@@ -746,62 +1079,50 @@ async function sendWebResponse(nodeResponse, webResponse) {
|
|
|
746
1079
|
}
|
|
747
1080
|
|
|
748
1081
|
/**
|
|
749
|
-
* Handle RPC requests for #server blocks
|
|
1082
|
+
* Handle RPC requests for #server blocks in dev mode.
|
|
1083
|
+
*
|
|
1084
|
+
* Delegates to the shared `handle_rpc_request` from `@ripple-ts/adapter/rpc`,
|
|
1085
|
+
* providing a dev-specific `resolveFunction` that uses Vite's `ssrLoadModule`
|
|
1086
|
+
* and `globalThis.rpc_modules` (populated by the compiler during SSR).
|
|
1087
|
+
*
|
|
750
1088
|
* @param {import('node:http').IncomingMessage} req
|
|
751
1089
|
* @param {import('node:http').ServerResponse} res
|
|
752
|
-
* @param {URL} url
|
|
753
1090
|
* @param {import('vite').ViteDevServer} vite
|
|
754
1091
|
* @param {boolean} trustProxy
|
|
1092
|
+
* @param {RippleConfigOptions | null} config
|
|
755
1093
|
*/
|
|
756
|
-
async function handleRpcRequest(req, res,
|
|
757
|
-
// we don't really need trustProxy in vite but leaving it as a model for production adapters
|
|
1094
|
+
async function handleRpcRequest(req, res, vite, trustProxy, config) {
|
|
758
1095
|
try {
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
}
|
|
1096
|
+
// Convert Node request to Web Request for the shared handler
|
|
1097
|
+
const webRequest = nodeRequestToWebRequest(req);
|
|
1098
|
+
const asyncContext = getDevAsyncContext(config);
|
|
1099
|
+
|
|
1100
|
+
const response = await handle_rpc_request(webRequest, {
|
|
1101
|
+
async resolveFunction(hash) {
|
|
1102
|
+
const rpcModules = /** @type {Map<string, [string, string]>} */ (
|
|
1103
|
+
/** @type {any} */ (globalThis).rpc_modules
|
|
1104
|
+
);
|
|
1105
|
+
if (!rpcModules) return null;
|
|
793
1106
|
|
|
794
|
-
|
|
1107
|
+
const moduleInfo = rpcModules.get(hash);
|
|
1108
|
+
if (!moduleInfo) return null;
|
|
795
1109
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
const result = await executeServerFunction(server[funcName], body);
|
|
1110
|
+
const [filePath, funcName] = moduleInfo;
|
|
1111
|
+
const module = await vite.ssrLoadModule(filePath);
|
|
1112
|
+
const server = module._$_server_$_;
|
|
800
1113
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
1114
|
+
if (!server || !server[funcName]) return null;
|
|
1115
|
+
return server[funcName];
|
|
1116
|
+
},
|
|
1117
|
+
async executeServerFunction(fn, body) {
|
|
1118
|
+
const { executeServerFunction } = await vite.ssrLoadModule('ripple/server');
|
|
1119
|
+
return executeServerFunction(fn, body);
|
|
1120
|
+
},
|
|
1121
|
+
asyncContext,
|
|
1122
|
+
trustProxy,
|
|
804
1123
|
});
|
|
1124
|
+
|
|
1125
|
+
await sendWebResponse(res, response);
|
|
805
1126
|
} catch (error) {
|
|
806
1127
|
console.error('[@ripple-ts/vite-plugin] RPC error:', error);
|
|
807
1128
|
res.statusCode = 500;
|
|
@@ -810,26 +1131,6 @@ async function handleRpcRequest(req, res, url, vite, trustProxy) {
|
|
|
810
1131
|
}
|
|
811
1132
|
}
|
|
812
1133
|
|
|
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
1134
|
/**
|
|
834
1135
|
* Escape HTML entities
|
|
835
1136
|
* @param {string} str
|