@ripple-ts/vite-plugin 0.3.34 → 0.3.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # @ripple-ts/vite-plugin
2
2
 
3
+ ## 0.3.35
4
+
5
+ ### Patch Changes
6
+
7
+ - [#966](https://github.com/Ripple-TS/ripple/pull/966)
8
+ [`caf83e3`](https://github.com/Ripple-TS/ripple/commit/caf83e386faa9133df70460f266fc27ab323082b)
9
+ Thanks [@RazinShafayet2007](https://github.com/RazinShafayet2007)! - fix:
10
+ register SSR/API middleware as a pre-hook so it runs before Vite's HTML fallback
11
+ middleware
12
+
13
+ The dev server's `configureServer` hook previously returned a function
14
+ (post-hook), which registered SSR/API middleware after Vite's internal
15
+ middleware stack. Vite's HTML fallback middleware would intercept all non-file
16
+ GET requests first, preventing SSR rendering and API routes from ever executing.
17
+
18
+ Switched to a pre-hook (no return value) so middleware is registered before Vite
19
+ internals. Config loading is deferred to the first request via
20
+ `ensureConfigLoaded()`, which retries on missing config and surfaces load errors
21
+ as dev-server 500 pages instead of silently falling through.
22
+
23
+ - Updated dependencies []:
24
+ - @tsrx/ripple@0.0.17
25
+ - @ripple-ts/adapter@0.3.35
26
+
3
27
  ## 0.3.34
4
28
 
5
29
  ### Patch Changes
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.3.34",
6
+ "version": "0.3.35",
7
7
  "type": "module",
8
8
  "module": "src/index.js",
9
9
  "main": "src/index.js",
@@ -32,14 +32,14 @@
32
32
  "url": "https://github.com/Ripple-TS/ripple/issues"
33
33
  },
34
34
  "dependencies": {
35
- "@ripple-ts/adapter": "0.3.34",
36
- "@tsrx/ripple": "0.0.16"
35
+ "@ripple-ts/adapter": "0.3.35",
36
+ "@tsrx/ripple": "0.0.17"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/node": "^24.3.0",
40
40
  "type-fest": "^5.6.0",
41
41
  "vite": "^8.0.0",
42
- "ripple": "0.3.34"
42
+ "ripple": "0.3.35"
43
43
  },
44
44
  "publishConfig": {
45
45
  "access": "public"
package/src/index.js CHANGED
@@ -574,138 +574,225 @@ export function ripple(inlineOptions = {}) {
574
574
  },
575
575
 
576
576
  /**
577
- * Configure the dev server with SSR middleware
577
+ * Configure the dev server with SSR middleware.
578
+ *
579
+ * Uses a pre-hook (no return value) so that Ripple's SSR/API
580
+ * middleware is registered BEFORE Vite's internal middlewares.
581
+ * Route-owning middleware must run before Vite's HTML fallback
582
+ * middleware, which otherwise intercepts non-file GET requests
583
+ * and serves index.html.
584
+ *
585
+ * Config loading is deferred until the first incoming request so
586
+ * that `vite.ssrLoadModule` is guaranteed to be fully initialised.
587
+ *
578
588
  * @param {ViteDevServer} vite
579
589
  */
580
590
  configureServer(vite) {
581
- // Return a function to be called after Vite's internal middlewares
582
- return async () => {
583
- if (!rippleConfigExists(root)) return;
591
+ // Deferred config initialisation resolved on first request
592
+ // that finds a ripple.config.ts. The promise is cleared after
593
+ // every attempt so that "config missing" is never cached
594
+ // permanently (the user may create the file while the dev
595
+ // server is running).
596
+ /** @type {Promise<void> | null} */
597
+ let initPromise = null;
598
+ /** @type {number} */
599
+ let lastConfigErrorMtimeMs = 0;
584
600
 
585
- try {
586
- rippleConfig = await loadRippleConfig(root, { vite });
601
+ /**
602
+ * Ensure ripple.config.ts has been loaded and the router is
603
+ * ready. Safe to call on every request — a successful load
604
+ * (even with no routes) is short-circuited, a missing config
605
+ * file is retried on the next request, and load errors are
606
+ * only retried when the file has been modified.
607
+ */
608
+ async function ensureConfigLoaded() {
609
+ // Config and router are already loaded.
610
+ if (rippleConfig && router) return;
587
611
 
588
- if (!has_route_config(rippleConfig)) {
612
+ if (initPromise) {
613
+ await initPromise;
614
+ return;
615
+ }
616
+
617
+ const configPath = getRippleConfigPath(root);
618
+
619
+ // Config file doesn't exist (yet). Don't cache this — the
620
+ // user may create it while the dev server is running.
621
+ if (!rippleConfigExists(root)) return;
622
+
623
+ // After a load error, only retry if the file has been
624
+ // modified since the last failure. This avoids per-request
625
+ // log spam while instantly picking up fixes.
626
+ if (lastConfigErrorMtimeMs) {
627
+ try {
628
+ const stat = fs.statSync(configPath);
629
+ if (stat.mtimeMs <= lastConfigErrorMtimeMs) return;
630
+ } catch {
589
631
  return;
590
632
  }
633
+ }
634
+
635
+ if (!initPromise) {
636
+ // Snapshot mtime before loading into a local variable.
637
+ // Only promoted to lastConfigErrorMtimeMs if the load
638
+ // actually fails — this prevents concurrent requests
639
+ // during a normal first load from seeing a non-zero
640
+ // lastConfigErrorMtimeMs and short-circuiting above.
641
+ let preLoadMtimeMs;
642
+ try {
643
+ preLoadMtimeMs = fs.statSync(configPath).mtimeMs;
644
+ } catch {
645
+ preLoadMtimeMs = Date.now();
646
+ }
591
647
 
592
- // Create router from config
593
- router = createRouter(rippleConfig.router.routes);
594
- console.log(
595
- `[@ripple-ts/vite-plugin] Loaded ${rippleConfig.router.routes.length} routes from ripple.config.ts`,
596
- );
597
- } catch (error) {
598
- console.error('[@ripple-ts/vite-plugin] Failed to load ripple.config.ts:', error);
599
- return;
648
+ initPromise = (async () => {
649
+ const nextConfig = await loadRippleConfig(root, { vite });
650
+
651
+ let nextRouter = null;
652
+ if (has_route_config(nextConfig)) {
653
+ nextRouter = createRouter(nextConfig.router.routes);
654
+ }
655
+
656
+ rippleConfig = nextConfig;
657
+ router = nextRouter;
658
+
659
+ if (nextRouter) {
660
+ console.log(
661
+ `[@ripple-ts/vite-plugin] Loaded ${nextConfig.router.routes.length} routes from ripple.config.ts`,
662
+ );
663
+ }
664
+ })()
665
+ .catch((error) => {
666
+ // Record pre-load mtime so retries only happen
667
+ // when the file has been modified.
668
+ lastConfigErrorMtimeMs = preLoadMtimeMs;
669
+ throw error;
670
+ })
671
+ .finally(() => {
672
+ initPromise = null;
673
+ });
600
674
  }
601
675
 
602
- // Add SSR middleware
603
- vite.middlewares.use((req, res, next) => {
604
- // Handle async logic in an IIFE
605
- (async () => {
606
- // Skip if no router
607
- if (!router || !rippleConfig) {
608
- next();
609
- return;
676
+ await initPromise;
677
+ }
678
+
679
+ // Pre-hook: register middleware directly without returning a
680
+ // function, so it is inserted BEFORE Vite's built-in stack.
681
+ vite.middlewares.use(function rippleDevMiddleware(req, res, next) {
682
+ // Handle async logic in an IIFE
683
+ (async () => {
684
+ // Lazy-load ripple.config.ts. This is deferred to the
685
+ // first request because vite.ssrLoadModule may not be
686
+ // fully initialised when configureServer runs.
687
+ try {
688
+ await ensureConfigLoaded();
689
+ } catch (error) {
690
+ // Log but do NOT return a 500 — falling through to
691
+ // next() lets Vite continue serving its own internal
692
+ // requests (HMR, CSS, JS modules, etc.). A broken
693
+ // ripple.config.ts should not kill the entire dev
694
+ // server. The error is retried on the next request
695
+ // because ensureConfigLoaded clears initPromise.
696
+ vite.ssrFixStacktrace(/** @type {Error} */ (error));
697
+ console.error('[@ripple-ts/vite-plugin] Failed to load ripple.config.ts:', error);
698
+ next();
699
+ return;
700
+ }
701
+
702
+ // Skip if no router
703
+ if (!router || !rippleConfig) {
704
+ next();
705
+ return;
706
+ }
707
+
708
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
709
+ const method = req.method || 'GET';
710
+
711
+ // Handle RPC requests for #server blocks
712
+ if (is_rpc_request(url.pathname)) {
713
+ await handleRpcRequest(req, res, vite, rippleConfig.server.trustProxy, rippleConfig);
714
+ return;
715
+ }
716
+
717
+ // Match route
718
+ const match = router.match(method, url.pathname);
719
+
720
+ if (!match) {
721
+ next();
722
+ return;
723
+ }
724
+
725
+ try {
726
+ // Reload config to get fresh routes (for HMR)
727
+ const previousRoutes = rippleConfig.router.routes;
728
+ const freshConfig = await loadRippleConfig(root, { vite });
729
+ if (freshConfig) {
730
+ rippleConfig = freshConfig;
610
731
  }
611
732
 
612
- const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
613
- const method = req.method || 'GET';
614
-
615
- // Handle RPC requests for #server blocks
616
- if (is_rpc_request(url.pathname)) {
617
- await handleRpcRequest(
618
- req,
619
- res,
620
- vite,
621
- rippleConfig.server.trustProxy,
622
- rippleConfig,
733
+ // Check if routes have changed
734
+ if (JSON.stringify(previousRoutes) !== JSON.stringify(rippleConfig.router.routes)) {
735
+ console.log(
736
+ `[@ripple-ts/vite-plugin] Detected route changes. Re-loading ${rippleConfig.router.routes.length} routes from ripple.config.ts`,
623
737
  );
624
- return;
625
738
  }
626
739
 
627
- // Match route
628
- const match = router.match(method, url.pathname);
740
+ router = createRouter(rippleConfig.router.routes);
629
741
 
630
- if (!match) {
742
+ // Re-match with fresh router
743
+ const freshMatch = router.match(method, url.pathname);
744
+ if (!freshMatch) {
631
745
  next();
632
746
  return;
633
747
  }
634
748
 
635
- try {
636
- // Reload config to get fresh routes (for HMR)
637
- const previousRoutes = rippleConfig.router.routes;
638
- const freshConfig = await loadRippleConfig(root, { vite });
639
- if (freshConfig) {
640
- rippleConfig = freshConfig;
641
- }
642
-
643
- // Check if routes have changed
644
- if (JSON.stringify(previousRoutes) !== JSON.stringify(rippleConfig.router.routes)) {
645
- console.log(
646
- `[@ripple-ts/vite-plugin] Detected route changes. Re-loading ${rippleConfig.router.routes.length} routes from ripple.config.ts`,
647
- );
648
- }
649
-
650
- router = createRouter(rippleConfig.router.routes);
651
-
652
- // Re-match with fresh router
653
- const freshMatch = router.match(method, url.pathname);
654
- if (!freshMatch) {
655
- next();
656
- return;
657
- }
658
-
659
- // Create context
660
- const request = nodeRequestToWebRequest(req);
661
- const context = createContext(request, freshMatch.params);
662
-
663
- const globalMiddlewares = rippleConfig.middlewares;
664
-
665
- let response;
666
-
667
- if (freshMatch.route.type === 'render') {
668
- // Handle RenderRoute with global middlewares
669
- response = await runMiddlewareChain(
670
- context,
671
- globalMiddlewares,
672
- freshMatch.route.before || [],
673
- async () =>
674
- handleRenderRoute(
675
- /** @type {RenderRoute} */ (freshMatch.route),
676
- context,
677
- vite,
678
- ),
679
- [],
680
- );
681
- } else {
682
- // Handle ServerRoute
683
- response = await handleServerRoute(freshMatch.route, context, globalMiddlewares);
684
- }
685
-
686
- // Send response
687
- await sendWebResponse(res, response);
688
- } catch (error) {
689
- console.error('[@ripple-ts/vite-plugin] Request error:', error);
690
- vite.ssrFixStacktrace(/** @type {Error} */ (error));
691
-
692
- res.statusCode = 500;
693
- res.setHeader('Content-Type', 'text/html');
694
- res.end(
695
- `<pre style="color: red; background: #1a1a1a; padding: 2rem; margin: 0;">${escapeHtml(
696
- error instanceof Error ? error.stack || error.message : String(error),
697
- )}</pre>`,
749
+ // Create context
750
+ const request = nodeRequestToWebRequest(req);
751
+ const context = createContext(request, freshMatch.params);
752
+
753
+ const globalMiddlewares = rippleConfig.middlewares;
754
+
755
+ let response;
756
+
757
+ if (freshMatch.route.type === 'render') {
758
+ // Handle RenderRoute with global middlewares
759
+ response = await runMiddlewareChain(
760
+ context,
761
+ globalMiddlewares,
762
+ freshMatch.route.before || [],
763
+ async () =>
764
+ handleRenderRoute(/** @type {RenderRoute} */ (freshMatch.route), context, vite),
765
+ [],
698
766
  );
767
+ } else {
768
+ // Handle ServerRoute
769
+ response = await handleServerRoute(freshMatch.route, context, globalMiddlewares);
699
770
  }
700
- })().catch((err) => {
701
- console.error('[@ripple-ts/vite-plugin] Unhandled middleware error:', err);
702
- if (!res.headersSent) {
703
- res.statusCode = 500;
704
- res.end('Internal Server Error');
705
- }
706
- });
771
+
772
+ // Send response
773
+ await sendWebResponse(res, response);
774
+ } catch (error) {
775
+ console.error('[@ripple-ts/vite-plugin] Request error:', error);
776
+ vite.ssrFixStacktrace(/** @type {Error} */ (error));
777
+
778
+ res.statusCode = 500;
779
+ res.setHeader('Content-Type', 'text/html');
780
+ res.end(
781
+ `<pre style="color: red; background: #1a1a1a; padding: 2rem; margin: 0;">${escapeHtml(
782
+ error instanceof Error ? error.stack || error.message : String(error),
783
+ )}</pre>`,
784
+ );
785
+ }
786
+ })().catch((err) => {
787
+ console.error('[@ripple-ts/vite-plugin] Unhandled middleware error:', err);
788
+ if (!res.headersSent) {
789
+ res.statusCode = 500;
790
+ res.end('Internal Server Error');
791
+ }
707
792
  });
708
- };
793
+ });
794
+ // No return — pre-hook ensures middleware runs before
795
+ // viteHtmlFallbackMiddleware
709
796
  },
710
797
 
711
798
  /**
@@ -0,0 +1,288 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { ripple } from '@ripple-ts/vite-plugin';
3
+ import { createServer } from 'vite';
4
+ import path from 'node:path';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+
8
+ /**
9
+ * Tests for configureServer middleware ordering.
10
+ *
11
+ * Ripple's SSR/API middleware is route-owning middleware. It must be
12
+ * registered as a pre-hook (no return value from configureServer) so
13
+ * it runs before Vite's HTML fallback middleware, which otherwise
14
+ * intercepts non-file GET requests and serves index.html.
15
+ */
16
+ describe('configureServer middleware ordering', () => {
17
+ /**
18
+ * Get the main ripple plugin from the plugin array.
19
+ * @returns {{ plugin: import('vite').Plugin, plugins: import('vite').Plugin[] }}
20
+ */
21
+ function getPlugins() {
22
+ const plugins = ripple({ excludeRippleExternalModules: true });
23
+ const plugin = plugins.find((p) => p.name === 'vite-plugin-ripple');
24
+ if (!plugin) throw new Error('vite-plugin-ripple not found in plugin array');
25
+ return { plugin, plugins };
26
+ }
27
+
28
+ /**
29
+ * Create a mock ViteDevServer with just enough surface for configureServer.
30
+ */
31
+ function createMockVite() {
32
+ /** @type {Function[]} */
33
+ const registeredMiddlewares = [];
34
+ return {
35
+ middlewares: {
36
+ use: vi.fn((/** @type {Function} */ fn) => {
37
+ registeredMiddlewares.push(fn);
38
+ }),
39
+ },
40
+ registeredMiddlewares,
41
+ ssrLoadModule: vi.fn(),
42
+ ssrFixStacktrace: vi.fn(),
43
+ environments: {},
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Call configResolved on the plugin to set up `root` and `config`.
49
+ * @param {import('vite').Plugin} plugin
50
+ */
51
+ async function initPlugin(plugin) {
52
+ if (typeof plugin.configResolved === 'function') {
53
+ await plugin.configResolved(
54
+ /** @type {any} */ ({
55
+ root: '/nonexistent-test-root',
56
+ command: 'serve',
57
+ }),
58
+ );
59
+ }
60
+ }
61
+
62
+ // --- Unit tests (mocked Vite) ---
63
+
64
+ it('uses a pre-hook — configureServer does NOT return a function', async () => {
65
+ const { plugin } = getPlugins();
66
+ const mockVite = createMockVite();
67
+
68
+ await initPlugin(plugin);
69
+
70
+ const result = plugin.configureServer(/** @type {any} */ (mockVite));
71
+
72
+ // A post-hook returns a function (or async function).
73
+ // A pre-hook returns undefined (void).
74
+ expect(result).toBeUndefined();
75
+ });
76
+
77
+ it('registers middleware synchronously inside configureServer', async () => {
78
+ const { plugin } = getPlugins();
79
+ const mockVite = createMockVite();
80
+
81
+ await initPlugin(plugin);
82
+
83
+ plugin.configureServer(/** @type {any} */ (mockVite));
84
+
85
+ expect(mockVite.middlewares.use).toHaveBeenCalledTimes(1);
86
+ expect(mockVite.registeredMiddlewares).toHaveLength(1);
87
+ expect(typeof mockVite.registeredMiddlewares[0]).toBe('function');
88
+ });
89
+
90
+ it('middleware calls next() when no ripple config exists', async () => {
91
+ const { plugin } = getPlugins();
92
+ const mockVite = createMockVite();
93
+
94
+ await initPlugin(plugin);
95
+
96
+ plugin.configureServer(/** @type {any} */ (mockVite));
97
+
98
+ const middleware = mockVite.registeredMiddlewares[0];
99
+ expect(middleware).toBeDefined();
100
+
101
+ /** @type {ReturnType<typeof vi.fn>} */
102
+ let next;
103
+ const nextCalled = new Promise((resolve) => {
104
+ next = vi.fn(() => resolve(undefined));
105
+
106
+ const req = /** @type {any} */ ({
107
+ url: '/api/test',
108
+ method: 'GET',
109
+ headers: { host: 'localhost' },
110
+ });
111
+
112
+ const res = /** @type {any} */ ({
113
+ statusCode: 200,
114
+ headersSent: false,
115
+ setHeader: vi.fn(),
116
+ end: vi.fn(),
117
+ });
118
+
119
+ middleware(req, res, next);
120
+ });
121
+
122
+ await nextCalled;
123
+
124
+ // @ts-ignore — next is assigned inside the Promise constructor
125
+ expect(next).toHaveBeenCalledTimes(1);
126
+ });
127
+
128
+ it('ripple middleware is registered before a simulated html fallback', async () => {
129
+ const { plugin } = getPlugins();
130
+ /** @type {Function[]} */
131
+ const stack = [];
132
+
133
+ const mockVite = /** @type {any} */ ({
134
+ middlewares: {
135
+ use: vi.fn((/** @type {Function} */ fn) => {
136
+ stack.push(fn);
137
+ }),
138
+ },
139
+ ssrLoadModule: vi.fn(),
140
+ ssrFixStacktrace: vi.fn(),
141
+ environments: {},
142
+ });
143
+
144
+ await initPlugin(plugin);
145
+
146
+ const returnValue = plugin.configureServer(mockVite);
147
+
148
+ // Our middleware should already be registered (pre-hook)
149
+ expect(stack).toHaveLength(1);
150
+
151
+ // Simulate Vite adding its internal html fallback AFTER configureServer
152
+ const htmlFallback = (
153
+ /** @type {any} */ req,
154
+ /** @type {any} */ res,
155
+ /** @type {any} */ _next,
156
+ ) => {
157
+ res.setHeader('Content-Type', 'text/html');
158
+ res.end('<html>fallback</html>');
159
+ };
160
+ stack.push(htmlFallback);
161
+
162
+ // Ripple middleware is at index 0, html fallback is at index 1
163
+ expect(stack[0]).not.toBe(htmlFallback);
164
+ expect(stack[1]).toBe(htmlFallback);
165
+
166
+ if (typeof returnValue === 'function') {
167
+ throw new Error(
168
+ 'configureServer returned a function — this is a post-hook pattern ' +
169
+ 'which places middleware AFTER viteHtmlFallbackMiddleware. ' +
170
+ 'SSR/API routes will be unreachable.',
171
+ );
172
+ }
173
+ });
174
+
175
+ // --- Integration tests (real Vite dev server) ---
176
+
177
+ /** @type {import('vite').ViteDevServer | null} */
178
+ let server = null;
179
+
180
+ afterEach(async () => {
181
+ if (server) {
182
+ await server.close();
183
+ server = null;
184
+ }
185
+ });
186
+
187
+ it('middleware is ordered before html fallback in a real Vite server', async () => {
188
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ripple-vite-test-'));
189
+ fs.writeFileSync(path.join(tmpDir, 'index.html'), '<html><body></body></html>');
190
+
191
+ try {
192
+ server = await createServer({
193
+ root: tmpDir,
194
+ configFile: false,
195
+ plugins: [ripple({ excludeRippleExternalModules: true })],
196
+ server: { middlewareMode: true },
197
+ logLevel: 'silent',
198
+ });
199
+
200
+ const stack = server.middlewares.stack;
201
+
202
+ const rippleIndex = stack.findIndex((/** @type {any} */ layer) => {
203
+ const name = layer.handle?.name || layer.name || '';
204
+ return name === 'rippleDevMiddleware';
205
+ });
206
+
207
+ const htmlFallbackIndex = stack.findIndex((/** @type {any} */ layer) => {
208
+ const name = layer.handle?.name || layer.name || '';
209
+ return name.includes('htmlFallback') || name.includes('HtmlFallback');
210
+ });
211
+
212
+ // Our middleware must exist in the stack
213
+ expect(rippleIndex).toBeGreaterThanOrEqual(0);
214
+
215
+ // If Vite has an HTML fallback, our middleware must come before it
216
+ if (htmlFallbackIndex !== -1) {
217
+ expect(rippleIndex).toBeLessThan(htmlFallbackIndex);
218
+ }
219
+ } finally {
220
+ fs.rmSync(tmpDir, { recursive: true, force: true });
221
+ }
222
+ });
223
+
224
+ it('non-ripple requests pass through to next middleware', async () => {
225
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ripple-vite-test-'));
226
+ fs.writeFileSync(path.join(tmpDir, 'index.html'), '<html><body>hello</body></html>');
227
+
228
+ try {
229
+ server = await createServer({
230
+ root: tmpDir,
231
+ configFile: false,
232
+ plugins: [ripple({ excludeRippleExternalModules: true })],
233
+ server: { middlewareMode: true },
234
+ logLevel: 'silent',
235
+ });
236
+
237
+ const response = await new Promise((resolve) => {
238
+ const req = /** @type {any} */ ({
239
+ url: '/',
240
+ method: 'GET',
241
+ headers: { host: 'localhost', accept: 'text/html' },
242
+ on: () => {},
243
+ removeListener: () => {},
244
+ });
245
+
246
+ const chunks = [];
247
+ const res = /** @type {any} */ ({
248
+ statusCode: 200,
249
+ headersSent: false,
250
+ _headers: {},
251
+ setHeader(key, val) {
252
+ this._headers[key] = val;
253
+ },
254
+ getHeader(key) {
255
+ return this._headers[key];
256
+ },
257
+ writeHead(status, headers) {
258
+ this.statusCode = status;
259
+ if (headers) Object.assign(this._headers, headers);
260
+ },
261
+ write(chunk) {
262
+ chunks.push(chunk);
263
+ },
264
+ end(chunk) {
265
+ if (chunk) chunks.push(chunk);
266
+ resolve({
267
+ statusCode: this.statusCode,
268
+ headers: this._headers,
269
+ body: Buffer.concat(
270
+ chunks.map((c) => (typeof c === 'string' ? Buffer.from(c) : c)),
271
+ ).toString(),
272
+ });
273
+ },
274
+ on: () => {},
275
+ removeListener: () => {},
276
+ });
277
+
278
+ server.middlewares.handle(req, res);
279
+ });
280
+
281
+ // Ripple middleware passed through and Vite handled the request
282
+ expect(response.statusCode).toBe(200);
283
+ expect(response.body).toContain('hello');
284
+ } finally {
285
+ fs.rmSync(tmpDir, { recursive: true, force: true });
286
+ }
287
+ });
288
+ });
package/types/index.d.ts CHANGED
@@ -1,212 +1,210 @@
1
1
  import type { Plugin, BuildEnvironmentOptions, ViteDevServer } from 'vite';
2
2
  import type { RuntimePrimitives } from '@ripple-ts/adapter';
3
3
 
4
- declare module '@ripple-ts/vite-plugin' {
5
- // ============================================================================
6
- // Plugin exports
7
- // ============================================================================
8
-
9
- export function ripple(options?: RipplePluginOptions): Plugin[];
10
- export function defineConfig(options: RippleConfigOptions): RippleConfigOptions;
11
- export function resolveRippleConfig(
12
- raw: RippleConfigOptions,
13
- options?: { requireAdapter?: boolean },
14
- ): ResolvedRippleConfig;
15
- export function getRippleConfigPath(projectRoot: string): string;
16
- export function rippleConfigExists(projectRoot: string): boolean;
17
- export function loadRippleConfig(
18
- projectRoot: string,
19
- options?: { vite?: ViteDevServer; requireAdapter?: boolean },
20
- ): Promise<ResolvedRippleConfig>;
21
-
22
- // ============================================================================
23
- // Route classes
24
- // ============================================================================
25
-
26
- export class RenderRoute {
27
- readonly type: 'render';
28
- path: string;
29
- entry: string;
30
- layout?: string;
31
- before: Middleware[];
32
- constructor(options: RenderRouteOptions);
33
- }
34
-
35
- export class ServerRoute {
36
- readonly type: 'server';
37
- path: string;
38
- methods: string[];
39
- handler: RouteHandler;
40
- before: Middleware[];
41
- after: Middleware[];
42
- constructor(options: ServerRouteOptions);
43
- }
44
-
45
- export type Route = RenderRoute | ServerRoute;
46
-
47
- // ============================================================================
48
- // Route options
49
- // ============================================================================
50
-
51
- export interface RenderRouteOptions {
52
- /** URL path pattern (e.g., '/', '/posts/:id', '/docs/*slug') */
53
- path: string;
54
- /** Path to the Ripple component entry file */
55
- entry: string;
56
- /** Path to the layout component (wraps the entry) */
57
- layout?: string;
58
- /** Middleware to run before rendering */
59
- before?: Middleware[];
60
- }
61
-
62
- export interface ServerRouteOptions {
63
- /** URL path pattern (e.g., '/api/hello', '/api/posts/:id') */
64
- path: string;
65
- /** HTTP methods to handle (default: ['GET']) */
66
- methods?: string[];
67
- /** Request handler that returns a Response */
68
- handler: RouteHandler;
69
- /** Middleware to run before the handler */
70
- before?: Middleware[];
71
- /** Middleware to run after the handler */
72
- after?: Middleware[];
73
- }
74
-
75
- // ============================================================================
76
- // Context and middleware
77
- // ============================================================================
78
-
79
- export interface Context {
80
- /** The incoming Request object */
81
- request: Request;
82
- /** URL parameters extracted from the route pattern */
83
- params: Record<string, string>;
84
- /** Parsed URL object */
85
- url: URL;
86
- /** Shared state for passing data between middlewares */
87
- state: Map<string, unknown>;
88
- }
89
-
90
- export type NextFunction = () => Promise<Response>;
91
- export type Middleware = (context: Context, next: NextFunction) => Response | Promise<Response>;
92
- export type RouteHandler = (context: Context) => Response | Promise<Response>;
93
-
94
- // ============================================================================
95
- // Configuration
96
- // ============================================================================
97
-
98
- export interface RipplePluginOptions {
99
- excludeRippleExternalModules?: boolean;
100
- }
101
-
102
- export interface CompatFactoryConfig {
103
- /** Module specifier that exports the compat factory */
104
- from: string;
105
- /** Named export to call. Omit to use the module's default export. */
106
- factory?: string;
107
- }
108
-
109
- export interface CompatFactory<T = unknown> {
110
- (): T;
111
- __ripple_compat__: CompatFactoryConfig;
112
- }
113
-
114
- export interface CompatEntryValue {
115
- __ripple_compat__: CompatFactoryConfig;
116
- }
117
-
118
- export type CompatConfigEntry = CompatFactoryConfig | CompatFactory | CompatEntryValue;
119
-
120
- export type CompatConfig = Record<string, CompatConfigEntry>;
121
-
122
- export interface RippleConfigOptions {
123
- build?: {
124
- /** Output directory for the production build. @default 'dist' */
125
- outDir?: string;
126
- minify?: boolean;
127
- target?: BuildEnvironmentOptions['target'];
128
- };
129
- adapter?: {
130
- serve: AdapterServeFunction;
131
- /**
132
- * Platform-specific runtime primitives provided by the adapter.
133
- *
134
- * These allow the server runtime to operate without depending
135
- * on Node.js-specific APIs like `node:crypto` or `node:async_hooks`.
136
- *
137
- * Required for production builds. In development, the vite plugin
138
- * falls back to Node.js defaults if not provided.
139
- */
140
- runtime: RuntimePrimitives;
141
- };
142
- router?: {
143
- routes: Route[];
144
- };
145
- /** Global middlewares applied to all routes */
146
- middlewares?: Middleware[];
4
+ // ============================================================================
5
+ // Plugin exports
6
+ // ============================================================================
7
+
8
+ export function ripple(options?: RipplePluginOptions): Plugin[];
9
+ export function defineConfig(options: RippleConfigOptions): RippleConfigOptions;
10
+ export function resolveRippleConfig(
11
+ raw: RippleConfigOptions,
12
+ options?: { requireAdapter?: boolean },
13
+ ): ResolvedRippleConfig;
14
+ export function getRippleConfigPath(projectRoot: string): string;
15
+ export function rippleConfigExists(projectRoot: string): boolean;
16
+ export function loadRippleConfig(
17
+ projectRoot: string,
18
+ options?: { vite?: ViteDevServer; requireAdapter?: boolean },
19
+ ): Promise<ResolvedRippleConfig>;
20
+
21
+ // ============================================================================
22
+ // Route classes
23
+ // ============================================================================
24
+
25
+ export class RenderRoute {
26
+ readonly type: 'render';
27
+ path: string;
28
+ entry: string;
29
+ layout?: string;
30
+ before: Middleware[];
31
+ constructor(options: RenderRouteOptions);
32
+ }
33
+
34
+ export class ServerRoute {
35
+ readonly type: 'server';
36
+ path: string;
37
+ methods: string[];
38
+ handler: RouteHandler;
39
+ before: Middleware[];
40
+ after: Middleware[];
41
+ constructor(options: ServerRouteOptions);
42
+ }
43
+
44
+ export type Route = RenderRoute | ServerRoute;
45
+
46
+ // ============================================================================
47
+ // Route options
48
+ // ============================================================================
49
+
50
+ export interface RenderRouteOptions {
51
+ /** URL path pattern (e.g., '/', '/posts/:id', '/docs/*slug') */
52
+ path: string;
53
+ /** Path to the Ripple component entry file */
54
+ entry: string;
55
+ /** Path to the layout component (wraps the entry) */
56
+ layout?: string;
57
+ /** Middleware to run before rendering */
58
+ before?: Middleware[];
59
+ }
60
+
61
+ export interface ServerRouteOptions {
62
+ /** URL path pattern (e.g., '/api/hello', '/api/posts/:id') */
63
+ path: string;
64
+ /** HTTP methods to handle (default: ['GET']) */
65
+ methods?: string[];
66
+ /** Request handler that returns a Response */
67
+ handler: RouteHandler;
68
+ /** Middleware to run before the handler */
69
+ before?: Middleware[];
70
+ /** Middleware to run after the handler */
71
+ after?: Middleware[];
72
+ }
73
+
74
+ // ============================================================================
75
+ // Context and middleware
76
+ // ============================================================================
77
+
78
+ export interface Context {
79
+ /** The incoming Request object */
80
+ request: Request;
81
+ /** URL parameters extracted from the route pattern */
82
+ params: Record<string, string>;
83
+ /** Parsed URL object */
84
+ url: URL;
85
+ /** Shared state for passing data between middlewares */
86
+ state: Map<string, unknown>;
87
+ }
88
+
89
+ export type NextFunction = () => Promise<Response>;
90
+ export type Middleware = (context: Context, next: NextFunction) => Response | Promise<Response>;
91
+ export type RouteHandler = (context: Context) => Response | Promise<Response>;
92
+
93
+ // ============================================================================
94
+ // Configuration
95
+ // ============================================================================
96
+
97
+ export interface RipplePluginOptions {
98
+ excludeRippleExternalModules?: boolean;
99
+ }
100
+
101
+ export interface CompatFactoryConfig {
102
+ /** Module specifier that exports the compat factory */
103
+ from: string;
104
+ /** Named export to call. Omit to use the module's default export. */
105
+ factory?: string;
106
+ }
107
+
108
+ export interface CompatFactory<T = unknown> {
109
+ (): T;
110
+ __ripple_compat__: CompatFactoryConfig;
111
+ }
112
+
113
+ export interface CompatEntryValue {
114
+ __ripple_compat__: CompatFactoryConfig;
115
+ }
116
+
117
+ export type CompatConfigEntry = CompatFactoryConfig | CompatFactory | CompatEntryValue;
118
+
119
+ export type CompatConfig = Record<string, CompatConfigEntry>;
120
+
121
+ export interface RippleConfigOptions {
122
+ build?: {
123
+ /** Output directory for the production build. @default 'dist' */
124
+ outDir?: string;
125
+ minify?: boolean;
126
+ target?: BuildEnvironmentOptions['target'];
127
+ };
128
+ adapter?: {
129
+ serve: AdapterServeFunction;
147
130
  /**
148
- * Client-side TSX compat integrations keyed by kind, e.g. `react` for `<tsx:react>`.
131
+ * Platform-specific runtime primitives provided by the adapter.
149
132
  *
150
- * You can either pass a descriptor object or import a compat factory directly,
151
- * as long as that factory export carries Ripple compat metadata.
133
+ * These allow the server runtime to operate without depending
134
+ * on Node.js-specific APIs like `node:crypto` or `node:async_hooks`.
152
135
  *
153
- * These are compiled into a browser-side compat registry by the Vite plugin,
154
- * allowing `mount()` / `hydrate()` to pick them up automatically.
136
+ * Required for production builds. In development, the vite plugin
137
+ * falls back to Node.js defaults if not provided.
155
138
  */
156
- compat?: CompatConfig;
157
- platform?: {
158
- env: Record<string, string>;
159
- };
160
- server?: {
161
- /**
162
- * Whether to trust `X-Forwarded-Proto` and `X-Forwarded-Host` headers
163
- * when deriving the request origin (protocol + host).
164
- *
165
- * Enable this only when the application is behind a trusted reverse proxy
166
- * (e.g., nginx, Cloudflare, AWS ALB). When `false` (the default), the
167
- * protocol is inferred from the socket and the host from the `Host` header.
168
- *
169
- * @default false
170
- */
171
- trustProxy?: boolean;
172
- };
173
- }
174
-
139
+ runtime: RuntimePrimitives;
140
+ };
141
+ router?: {
142
+ routes: Route[];
143
+ };
144
+ /** Global middlewares applied to all routes */
145
+ middlewares?: Middleware[];
175
146
  /**
176
- * Resolved configuration with all defaults applied.
177
- * Returned by `resolveRippleConfig` and `loadRippleConfig`.
178
- * Consumers should use this type instead of applying ad-hoc defaults.
147
+ * Client-side TSX compat integrations keyed by kind, e.g. `react` for `<tsx:react>`.
148
+ *
149
+ * You can either pass a descriptor object or import a compat factory directly,
150
+ * as long as that factory export carries Ripple compat metadata.
151
+ *
152
+ * These are compiled into a browser-side compat registry by the Vite plugin,
153
+ * allowing `mount()` / `hydrate()` to pick them up automatically.
179
154
  */
180
- export interface ResolvedRippleConfig {
181
- build: {
182
- /** @default 'dist' */
183
- outDir: string;
184
- minify?: boolean;
185
- target?: BuildEnvironmentOptions['target'];
186
- };
187
- adapter?: {
188
- serve: AdapterServeFunction;
189
- runtime: RuntimePrimitives;
190
- };
191
- router: {
192
- routes: Route[];
193
- };
194
- /** @default [] */
195
- middlewares: Middleware[];
155
+ compat?: CompatConfig;
156
+ platform?: {
157
+ env: Record<string, string>;
158
+ };
159
+ server?: {
160
+ /**
161
+ * Whether to trust `X-Forwarded-Proto` and `X-Forwarded-Host` headers
162
+ * when deriving the request origin (protocol + host).
163
+ *
164
+ * Enable this only when the application is behind a trusted reverse proxy
165
+ * (e.g., nginx, Cloudflare, AWS ALB). When `false` (the default), the
166
+ * protocol is inferred from the socket and the host from the `Host` header.
167
+ *
168
+ * @default false
169
+ */
170
+ trustProxy?: boolean;
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Resolved configuration with all defaults applied.
176
+ * Returned by `resolveRippleConfig` and `loadRippleConfig`.
177
+ * Consumers should use this type instead of applying ad-hoc defaults.
178
+ */
179
+ export interface ResolvedRippleConfig {
180
+ build: {
181
+ /** @default 'dist' */
182
+ outDir: string;
183
+ minify?: boolean;
184
+ target?: BuildEnvironmentOptions['target'];
185
+ };
186
+ adapter?: {
187
+ serve: AdapterServeFunction;
188
+ runtime: RuntimePrimitives;
189
+ };
190
+ router: {
191
+ routes: Route[];
192
+ };
193
+ /** @default [] */
194
+ middlewares: Middleware[];
195
+ /** @default {} */
196
+ compat: Record<string, CompatFactoryConfig>;
197
+ platform: {
196
198
  /** @default {} */
197
- compat: Record<string, CompatFactoryConfig>;
198
- platform: {
199
- /** @default {} */
200
- env: Record<string, string>;
201
- };
202
- server: {
203
- /** @default false */
204
- trustProxy: boolean;
205
- };
206
- }
207
-
208
- export type AdapterServeFunction = (
209
- handler: (request: Request, platform?: unknown) => Response | Promise<Response>,
210
- options?: Record<string, unknown>,
211
- ) => { listen: (port?: number) => unknown; close: () => void };
199
+ env: Record<string, string>;
200
+ };
201
+ server: {
202
+ /** @default false */
203
+ trustProxy: boolean;
204
+ };
212
205
  }
206
+
207
+ export type AdapterServeFunction = (
208
+ handler: (request: Request, platform?: unknown) => Response | Promise<Response>,
209
+ options?: Record<string, unknown>,
210
+ ) => { listen: (port?: number) => unknown; close: () => void };