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