@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/CHANGELOG.md +19 -0
- package/package.json +14 -3
- package/src/bin/preview.js +43 -0
- package/src/constants.js +2 -0
- package/src/index.js +542 -193
- package/src/load-config.js +172 -0
- package/src/server/production.js +127 -94
- package/src/server/render-route.js +1 -1
- package/src/server/virtual-entry.js +215 -0
- package/tsconfig.json +8 -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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* @
|
|
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,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
|
-
|
|
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
|
|
578
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
}
|
|
603
|
-
|
|
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:
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
1155
|
+
const moduleInfo = rpcModules.get(hash);
|
|
1156
|
+
if (!moduleInfo) return null;
|
|
795
1157
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|