@ripple-ts/adapter-node 0.2.208

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/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # @ripple-ts/adapter-node
2
+
3
+ Node.js adapter for Ripple metaframework apps.
4
+
5
+ It bridges Node's `IncomingMessage`/`ServerResponse` API to Web
6
+ `Request`/`Response`, so your server handler can use standard Fetch APIs.
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ pnpm add @ripple-ts/adapter-node
12
+ # or
13
+ npm install @ripple-ts/adapter-node
14
+ # or
15
+ yarn add @ripple-ts/adapter-node
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```js
21
+ import { serve } from '@ripple-ts/adapter-node';
22
+
23
+ const app = serve(async (request) => {
24
+ const url = new URL(request.url);
25
+
26
+ if (url.pathname === '/health') {
27
+ return new Response('ok');
28
+ }
29
+
30
+ return new Response('Hello from Ripple adapter-node!', {
31
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
32
+ });
33
+ });
34
+
35
+ app.listen(3000);
36
+ ```
37
+
38
+ ## API
39
+
40
+ ### `serve(fetch_handler, options?)`
41
+
42
+ Creates an HTTP server adapter.
43
+
44
+ - `fetch_handler`:
45
+ `(request: Request, platform?: any) => Response | Promise<Response>`
46
+ - `options.port` (default: `3000`)
47
+ - `options.hostname` (default: `localhost`)
48
+ - `options.static` (default: `{ dir: 'public' }`): serves static files before
49
+ middleware/handler
50
+ - `options.static.dir` (default: `public`, resolved from `process.cwd()`)
51
+ - `options.static.prefix` (default: `/`)
52
+ - `options.static.maxAge` (default: `86400`)
53
+ - `options.static.immutable` (default: `false`)
54
+ - set `options.static = false` to disable automatic static serving
55
+ - `options.middleware` (optional): Node-style middleware called before
56
+ `fetch_handler`
57
+
58
+ Returns:
59
+
60
+ - `listen(port?)`: starts the server and returns Node `Server`
61
+ - `close()`: closes the server
62
+
63
+ ### `serveStatic(dir, options?)`
64
+
65
+ Creates a Node middleware that serves static assets from `dir`.
66
+
67
+ - `options.prefix` (default: `/`)
68
+ - `options.maxAge` (default: `86400`)
69
+ - `options.immutable` (default: `false`)
70
+
71
+ ## Middleware
72
+
73
+ If middleware sends the response (`res.end()` / `res.headersSent`), the fetch
74
+ handler is skipped.
75
+
76
+ ```js
77
+ import { serve } from '@ripple-ts/adapter-node';
78
+
79
+ const app = serve(async () => new Response('from handler'), {
80
+ middleware(req, res, next) {
81
+ if (req.url === '/legacy') {
82
+ res.statusCode = 200;
83
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
84
+ res.end('from middleware');
85
+ return;
86
+ }
87
+
88
+ next();
89
+ },
90
+ });
91
+
92
+ app.listen(3000);
93
+ ```
94
+
95
+ ## Notes
96
+
97
+ - Static file logic and MIME type mappings are shared from `@ripple-ts/adapter`.
98
+ - `x-forwarded-proto` and `x-forwarded-host` are respected when constructing the
99
+ request URL.
100
+ - Request bodies are streamed for non-`GET`/`HEAD` methods.
101
+ - Multiple `set-cookie` headers are forwarded correctly.
102
+ - Unhandled errors return `500 Internal Server Error`.
103
+
104
+ ## License
105
+
106
+ MIT
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@ripple-ts/adapter-node",
3
+ "description": "Node.js adapter for Ripple metaframework (Web Request/Response bridge)",
4
+ "license": "MIT",
5
+ "author": "Dominic Gannaway",
6
+ "version": "0.2.208",
7
+ "type": "module",
8
+ "module": "src/index.js",
9
+ "main": "src/index.js",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./types/index.d.ts",
13
+ "import": "./src/index.js",
14
+ "default": "./src/index.js"
15
+ }
16
+ },
17
+ "scripts": {
18
+ "test": "pnpm -w test --project adapter-node"
19
+ },
20
+ "dependencies": {
21
+ "@ripple-ts/adapter": "workspace:*"
22
+ },
23
+ "homepage": "https://ripplejs.com",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/Ripple-TS/ripple.git",
27
+ "directory": "packages/adapter-node"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/Ripple-TS/ripple/issues"
31
+ }
32
+ }
package/src/index.js ADDED
@@ -0,0 +1,290 @@
1
+ import { createServer } from 'node:http';
2
+ import { Readable } from 'node:stream';
3
+ import {
4
+ DEFAULT_HOSTNAME,
5
+ DEFAULT_PORT,
6
+ internal_server_error_response,
7
+ run_next_middleware,
8
+ serveStatic as create_static_handler,
9
+ } from '@ripple-ts/adapter';
10
+
11
+ /**
12
+ * @param {string | string[] | undefined} value
13
+ * @returns {string | undefined}
14
+ */
15
+ function first_header_value(value) {
16
+ if (value == null) return undefined;
17
+ if (Array.isArray(value)) return value[0];
18
+ return value;
19
+ }
20
+
21
+ /**
22
+ * @param {string | undefined} value
23
+ * @returns {string | undefined}
24
+ */
25
+ function normalize_forwarded_value(value) {
26
+ if (!value) return undefined;
27
+ return value.split(',')[0].trim();
28
+ }
29
+
30
+ /**
31
+ * @param {import('node:http').IncomingMessage} node_request
32
+ * @param {AbortSignal} signal
33
+ * @returns {Request}
34
+ */
35
+ function node_request_to_web_request(node_request, signal) {
36
+ const forwarded_proto = normalize_forwarded_value(
37
+ first_header_value(node_request.headers['x-forwarded-proto']),
38
+ );
39
+ const forwarded_host = normalize_forwarded_value(
40
+ first_header_value(node_request.headers['x-forwarded-host']),
41
+ );
42
+
43
+ const proto = forwarded_proto ?? 'http';
44
+ const host = forwarded_host ?? first_header_value(node_request.headers.host) ?? 'localhost';
45
+
46
+ const raw_url = node_request.url ?? '/';
47
+ const base = `${proto}://${host}`;
48
+ const url = raw_url === '*' ? new URL(base) : new URL(raw_url, base);
49
+
50
+ const headers = new Headers();
51
+ for (const [key, value] of Object.entries(node_request.headers)) {
52
+ if (value == null) continue;
53
+ if (Array.isArray(value)) {
54
+ for (const v of value) headers.append(key, v);
55
+ } else {
56
+ headers.set(key, value);
57
+ }
58
+ }
59
+
60
+ const method = (node_request.method ?? 'GET').toUpperCase();
61
+ /** @type {RequestInit & { duplex?: 'half' }} */
62
+ const request_init = { method, headers, signal };
63
+
64
+ if (method !== 'GET' && method !== 'HEAD') {
65
+ request_init.body = /** @type {any} */ (Readable.toWeb(node_request));
66
+ request_init.duplex = 'half';
67
+ }
68
+
69
+ return new Request(url, request_init);
70
+ }
71
+
72
+ /**
73
+ * @param {Response} web_response
74
+ * @param {import('node:http').ServerResponse} node_response
75
+ * @param {string} request_method
76
+ * @returns {void}
77
+ */
78
+ function web_response_to_node_response(web_response, node_response, request_method) {
79
+ node_response.statusCode = web_response.status;
80
+ if (web_response.statusText) {
81
+ node_response.statusMessage = web_response.statusText;
82
+ }
83
+
84
+ const get_set_cookie = /** @type {any} */ (web_response.headers).getSetCookie;
85
+ let set_cookie_set = false;
86
+ if (typeof get_set_cookie === 'function') {
87
+ const cookies = get_set_cookie.call(web_response.headers);
88
+ if (cookies.length > 0) {
89
+ node_response.setHeader('set-cookie', cookies);
90
+ set_cookie_set = true;
91
+ }
92
+ }
93
+ if (!set_cookie_set) {
94
+ const cookie = web_response.headers.get('set-cookie');
95
+ if (cookie) {
96
+ node_response.setHeader('set-cookie', cookie);
97
+ }
98
+ }
99
+
100
+ web_response.headers.forEach((value, key) => {
101
+ if (key.toLowerCase() === 'set-cookie') return;
102
+ node_response.setHeader(key, value);
103
+ });
104
+
105
+ if (request_method === 'HEAD' || web_response.body == null) {
106
+ node_response.end();
107
+ return;
108
+ }
109
+
110
+ const node_stream = Readable.fromWeb(/** @type {any} */ (web_response.body));
111
+ node_stream.on('error', (error) => {
112
+ node_response.destroy(error);
113
+ });
114
+ node_stream.pipe(node_response);
115
+ }
116
+
117
+ /** @typedef {import('@ripple-ts/adapter').ServeStaticDirectoryOptions} StaticServeOptions */
118
+
119
+ /**
120
+ * @typedef {{
121
+ * port?: number,
122
+ * hostname?: string,
123
+ * middleware?: ((
124
+ * req: import('node:http').IncomingMessage,
125
+ * res: import('node:http').ServerResponse,
126
+ * next: (error?: any) => void
127
+ * ) => void) | null,
128
+ * static?: StaticServeOptions | false,
129
+ * }} ServeOptions
130
+ */
131
+
132
+ /**
133
+ * @param {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next: (error?: any) => void) => void} middleware
134
+ * @param {import('node:http').IncomingMessage} node_request
135
+ * @param {import('node:http').ServerResponse} node_response
136
+ * @returns {Promise<void>}
137
+ */
138
+ function run_node_middleware(middleware, node_request, node_response) {
139
+ return new Promise((resolve, reject) => {
140
+ if (node_response.writableEnded) {
141
+ resolve(undefined);
142
+ return;
143
+ }
144
+
145
+ const done = (/** @type {Error} */ error) => {
146
+ if (error) {
147
+ reject(error);
148
+ return;
149
+ }
150
+
151
+ resolve(undefined);
152
+ };
153
+
154
+ try {
155
+ middleware(node_request, node_response, done);
156
+ } catch (error) {
157
+ reject(error);
158
+ }
159
+ });
160
+ }
161
+
162
+ /**
163
+ * @param {(request: Request, platform?: any) => Response | Promise<Response>} fetch_handler
164
+ * @param {ServeOptions} [options]
165
+ * @returns {{ listen: (port?: number) => import('node:http').Server, close: () => void }}
166
+ */
167
+ export function serve(fetch_handler, options = {}) {
168
+ const {
169
+ port = DEFAULT_PORT,
170
+ hostname = DEFAULT_HOSTNAME,
171
+ middleware = null,
172
+ static: static_options = {},
173
+ } = options;
174
+
175
+ /** @type {ReturnType<typeof serveStatic> | null} */
176
+ let static_middleware = null;
177
+ if (static_options !== false) {
178
+ const { dir = 'public', ...static_handler_options } = static_options;
179
+ static_middleware = serveStatic(dir, static_handler_options);
180
+ }
181
+
182
+ const server = createServer(async (node_request, node_response) => {
183
+ const abort_controller = new AbortController();
184
+
185
+ node_request.on('aborted', () => {
186
+ abort_controller.abort();
187
+ });
188
+ node_response.on('close', () => {
189
+ abort_controller.abort();
190
+ });
191
+
192
+ try {
193
+ const run_fetch_handler = async () => {
194
+ const request = node_request_to_web_request(node_request, abort_controller.signal);
195
+ return await fetch_handler(request, { node_request, node_response });
196
+ };
197
+
198
+ let response;
199
+ if (static_middleware !== null || middleware !== null) {
200
+ response = await run_next_middleware(
201
+ async (request, middleware_response, next) => {
202
+ if (static_middleware !== null) {
203
+ await run_node_middleware(static_middleware, request, middleware_response);
204
+ if (middleware_response.writableEnded || middleware_response.headersSent) {
205
+ return new Response(null, { status: 204 });
206
+ }
207
+ }
208
+
209
+ if (middleware !== null) {
210
+ await run_node_middleware(middleware, request, middleware_response);
211
+ if (middleware_response.writableEnded || middleware_response.headersSent) {
212
+ return new Response(null, { status: 204 });
213
+ }
214
+ }
215
+
216
+ return await next();
217
+ },
218
+ node_request,
219
+ node_response,
220
+ run_fetch_handler,
221
+ );
222
+ } else {
223
+ response = await run_fetch_handler();
224
+ }
225
+
226
+ if (node_response.writableEnded || node_response.headersSent) {
227
+ return;
228
+ }
229
+
230
+ web_response_to_node_response(
231
+ response,
232
+ node_response,
233
+ (node_request.method ?? 'GET').toUpperCase(),
234
+ );
235
+ } catch {
236
+ if (node_response.headersSent) {
237
+ node_response.end();
238
+ return;
239
+ }
240
+
241
+ web_response_to_node_response(
242
+ internal_server_error_response(),
243
+ node_response,
244
+ (node_request.method ?? 'GET').toUpperCase(),
245
+ );
246
+ }
247
+ });
248
+
249
+ return {
250
+ listen(listen_port = port) {
251
+ server.listen(listen_port, hostname);
252
+ return server;
253
+ },
254
+ close() {
255
+ server.close();
256
+ },
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Create a middleware that serves static files from a directory
262
+ *
263
+ * @param {string} dir - Directory to serve files from (relative to cwd or absolute)
264
+ * @param {import('@ripple-ts/adapter').ServeStaticOptions} [options]
265
+ * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next: (error?: any) => void) => void}
266
+ */
267
+ export function serveStatic(dir, options = {}) {
268
+ const serve_static_request = create_static_handler(dir, options);
269
+
270
+ return function staticMiddleware(req, res, next) {
271
+ try {
272
+ const request_method = (req.method ?? 'GET').toUpperCase();
273
+ if (request_method !== 'GET' && request_method !== 'HEAD') {
274
+ next();
275
+ return;
276
+ }
277
+
278
+ const request = node_request_to_web_request(req, new AbortController().signal);
279
+ const response = serve_static_request(request);
280
+ if (response === null) {
281
+ next();
282
+ return;
283
+ }
284
+
285
+ web_response_to_node_response(response, res, request.method);
286
+ } catch {
287
+ next();
288
+ }
289
+ };
290
+ }
@@ -0,0 +1,377 @@
1
+ import { request as node_http_request } from 'node:http';
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+ import { serve, serveStatic } from '../src/index.js';
7
+
8
+ /**
9
+ * @param {(request: Request, platform?: any) => Response | Promise<Response>} fetch_handler
10
+ * @param {(base_url: string) => Promise<void>} run
11
+ * @param {import('../types/index.d.ts').ServeOptions} [options]
12
+ * @returns {Promise<void>}
13
+ */
14
+ async function with_server(fetch_handler, run, options = {}) {
15
+ const app = serve(fetch_handler, { hostname: '127.0.0.1', ...options });
16
+ const server = app.listen(0);
17
+
18
+ await new Promise((resolve, reject) => {
19
+ server.once('listening', resolve);
20
+ server.once('error', reject);
21
+ });
22
+
23
+ const address = server.address();
24
+ if (address == null || typeof address === 'string') {
25
+ throw new Error('Expected TCP server address');
26
+ }
27
+
28
+ try {
29
+ await run(`http://127.0.0.1:${address.port}`);
30
+ } finally {
31
+ await new Promise((resolve, reject) => {
32
+ server.close((error) => {
33
+ if (error) reject(error);
34
+ else resolve(undefined);
35
+ });
36
+ });
37
+ }
38
+ }
39
+
40
+ /**
41
+ * @param {string} cwd
42
+ * @param {() => Promise<void>} run
43
+ * @returns {Promise<void>}
44
+ */
45
+ async function with_cwd(cwd, run) {
46
+ const previous_cwd = process.cwd();
47
+ process.chdir(cwd);
48
+ try {
49
+ await run();
50
+ } finally {
51
+ process.chdir(previous_cwd);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * @param {string} url
57
+ * @param {{ method?: string, headers?: import('node:http').OutgoingHttpHeaders, body?: string }} [options]
58
+ * @returns {Promise<{
59
+ * status_code: number,
60
+ * headers: import('node:http').IncomingHttpHeaders,
61
+ * body: string
62
+ * }>}
63
+ */
64
+ function send_node_request(url, options = {}) {
65
+ return new Promise((resolve, reject) => {
66
+ const request = node_http_request(
67
+ url,
68
+ {
69
+ method: options.method ?? 'GET',
70
+ headers: options.headers,
71
+ },
72
+ (response) => {
73
+ /** @type {string[]} */
74
+ const chunks = [];
75
+ response.setEncoding('utf8');
76
+ response.on('data', (chunk) => {
77
+ chunks.push(chunk);
78
+ });
79
+ response.on('end', () => {
80
+ resolve({
81
+ status_code: response.statusCode ?? 0,
82
+ headers: response.headers,
83
+ body: chunks.join(''),
84
+ });
85
+ });
86
+ },
87
+ );
88
+
89
+ request.on('error', reject);
90
+ if (options.body) {
91
+ request.write(options.body);
92
+ }
93
+ request.end();
94
+ });
95
+ }
96
+
97
+ describe('@ripple-ts/adapter-node serve()', () => {
98
+ it('maps node request values to a web Request and returns response values', async () => {
99
+ /** @type {{ method: string, url: string, body: string, custom_header: string | null } | null} */
100
+ let captured = null;
101
+
102
+ await with_server(
103
+ async (request) => {
104
+ captured = {
105
+ method: request.method,
106
+ url: request.url,
107
+ body: await request.text(),
108
+ custom_header: request.headers.get('x-custom'),
109
+ };
110
+
111
+ return new Response('created', {
112
+ status: 201,
113
+ headers: {
114
+ 'x-response': 'ok',
115
+ },
116
+ });
117
+ },
118
+ async (base_url) => {
119
+ const response = await fetch(`${base_url}/users?id=1`, {
120
+ method: 'POST',
121
+ headers: {
122
+ 'x-forwarded-proto': 'https, http',
123
+ 'x-forwarded-host': 'example.com, proxy.local',
124
+ 'x-custom': 'abc123',
125
+ },
126
+ body: 'payload',
127
+ });
128
+
129
+ expect(response.status).toBe(201);
130
+ expect(response.headers.get('x-response')).toBe('ok');
131
+ expect(await response.text()).toBe('created');
132
+ },
133
+ );
134
+
135
+ expect(captured).toEqual({
136
+ method: 'POST',
137
+ url: 'https://example.com/users?id=1',
138
+ body: 'payload',
139
+ custom_header: 'abc123',
140
+ });
141
+ });
142
+
143
+ it('runs middleware before handler when middleware calls next()', async () => {
144
+ /** @type {string[]} */
145
+ const calls = [];
146
+
147
+ await with_server(
148
+ () => {
149
+ calls.push('handler');
150
+ return new Response('ok');
151
+ },
152
+ async (base_url) => {
153
+ const response = await fetch(base_url);
154
+ expect(response.status).toBe(200);
155
+ expect(await response.text()).toBe('ok');
156
+ },
157
+ {
158
+ middleware(req, res, next) {
159
+ void req;
160
+ void res;
161
+ calls.push('middleware');
162
+ next();
163
+ },
164
+ },
165
+ );
166
+
167
+ expect(calls).toEqual(['middleware', 'handler']);
168
+ });
169
+
170
+ it('short-circuits the handler when middleware already handled the response', async () => {
171
+ const fetch_handler = vi.fn(() => new Response('from-handler'));
172
+
173
+ await with_server(
174
+ fetch_handler,
175
+ async (base_url) => {
176
+ const response = await fetch(base_url);
177
+ expect(response.status).toBe(204);
178
+ expect(await response.text()).toBe('');
179
+ },
180
+ {
181
+ middleware(req, res, next) {
182
+ void req;
183
+ res.statusCode = 204;
184
+ res.end();
185
+ next();
186
+ },
187
+ },
188
+ );
189
+
190
+ expect(fetch_handler).not.toHaveBeenCalled();
191
+ });
192
+
193
+ it('forwards multiple set-cookie headers', async () => {
194
+ await with_server(
195
+ () => {
196
+ const headers = new Headers();
197
+ headers.append('set-cookie', 'session=abc; Path=/');
198
+ headers.append('set-cookie', 'theme=dark; Path=/');
199
+ return new Response('ok', { headers });
200
+ },
201
+ async (base_url) => {
202
+ const response = await send_node_request(base_url);
203
+ const set_cookie = response.headers['set-cookie'];
204
+ const normalized_set_cookie = Array.isArray(set_cookie)
205
+ ? set_cookie.join('\n')
206
+ : String(set_cookie ?? '');
207
+
208
+ expect(response.status_code).toBe(200);
209
+ expect(normalized_set_cookie).toContain('session=abc');
210
+ expect(normalized_set_cookie).toContain('theme=dark');
211
+ },
212
+ );
213
+ });
214
+
215
+ it('returns internal server error when handler throws', async () => {
216
+ await with_server(
217
+ () => {
218
+ throw new Error('boom');
219
+ },
220
+ async (base_url) => {
221
+ const response = await send_node_request(base_url);
222
+
223
+ expect(response.status_code).toBe(500);
224
+ expect(response.body).toBe('Internal Server Error');
225
+ expect(response.headers['content-type']).toBe('text/plain; charset=utf-8');
226
+ },
227
+ );
228
+ });
229
+
230
+ it('does not write response body for HEAD requests', async () => {
231
+ /** @type {string | undefined} */
232
+ let request_method;
233
+
234
+ await with_server(
235
+ (request) => {
236
+ request_method = request.method;
237
+ return new Response('body-should-not-be-sent');
238
+ },
239
+ async (base_url) => {
240
+ const response = await fetch(base_url, { method: 'HEAD' });
241
+
242
+ expect(response.status).toBe(200);
243
+ expect(await response.text()).toBe('');
244
+ },
245
+ );
246
+
247
+ expect(request_method).toBe('HEAD');
248
+ });
249
+
250
+ it('serveStatic middleware serves files and bypasses fetch handler', async () => {
251
+ const temp_dir = mkdtempSync(join(tmpdir(), 'adapter-node-static-'));
252
+ try {
253
+ writeFileSync(join(temp_dir, 'hello.txt'), 'hello static');
254
+
255
+ const fetch_handler = vi.fn(() => new Response('fallback', { status: 404 }));
256
+ await with_server(
257
+ fetch_handler,
258
+ async (base_url) => {
259
+ const response = await fetch(`${base_url}/assets/hello.txt`);
260
+ expect(response.status).toBe(200);
261
+ expect(response.headers.get('content-type')).toBe('text/plain; charset=utf-8');
262
+ expect(await response.text()).toBe('hello static');
263
+ },
264
+ {
265
+ middleware: serveStatic(temp_dir, { prefix: '/assets' }),
266
+ },
267
+ );
268
+
269
+ expect(fetch_handler).not.toHaveBeenCalled();
270
+ } finally {
271
+ rmSync(temp_dir, { recursive: true, force: true });
272
+ }
273
+ });
274
+
275
+ it('serveStatic middleware falls through when file is missing', async () => {
276
+ const temp_dir = mkdtempSync(join(tmpdir(), 'adapter-node-static-fallthrough-'));
277
+ try {
278
+ const fetch_handler = vi.fn(() => new Response('fallback', { status: 404 }));
279
+
280
+ await with_server(
281
+ fetch_handler,
282
+ async (base_url) => {
283
+ const response = await fetch(`${base_url}/assets/missing.txt`);
284
+ expect(response.status).toBe(404);
285
+ expect(await response.text()).toBe('fallback');
286
+ },
287
+ {
288
+ middleware: serveStatic(temp_dir, { prefix: '/assets' }),
289
+ },
290
+ );
291
+
292
+ expect(fetch_handler).toHaveBeenCalledTimes(1);
293
+ } finally {
294
+ rmSync(temp_dir, { recursive: true, force: true });
295
+ }
296
+ });
297
+
298
+ it('serves files from ./public by default', async () => {
299
+ const temp_dir = mkdtempSync(join(tmpdir(), 'adapter-node-default-static-'));
300
+ try {
301
+ const public_dir = join(temp_dir, 'public');
302
+ mkdirSync(public_dir);
303
+ writeFileSync(join(public_dir, 'llms.txt'), 'hello llms');
304
+
305
+ const fetch_handler = vi.fn(() => new Response('fallback', { status: 404 }));
306
+
307
+ await with_cwd(temp_dir, async () => {
308
+ await with_server(fetch_handler, async (base_url) => {
309
+ const response = await fetch(`${base_url}/llms.txt`);
310
+ expect(response.status).toBe(200);
311
+ expect(await response.text()).toBe('hello llms');
312
+ });
313
+ });
314
+
315
+ expect(fetch_handler).not.toHaveBeenCalled();
316
+ } finally {
317
+ rmSync(temp_dir, { recursive: true, force: true });
318
+ }
319
+ });
320
+
321
+ it('can disable default static serving via options.static = false', async () => {
322
+ const temp_dir = mkdtempSync(join(tmpdir(), 'adapter-node-default-static-disabled-'));
323
+ try {
324
+ const public_dir = join(temp_dir, 'public');
325
+ mkdirSync(public_dir);
326
+ writeFileSync(join(public_dir, 'llms.txt'), 'hello llms');
327
+
328
+ const fetch_handler = vi.fn(() => new Response('fallback', { status: 404 }));
329
+
330
+ await with_cwd(temp_dir, async () => {
331
+ await with_server(
332
+ fetch_handler,
333
+ async (base_url) => {
334
+ const response = await fetch(`${base_url}/llms.txt`);
335
+ expect(response.status).toBe(404);
336
+ expect(await response.text()).toBe('fallback');
337
+ },
338
+ { static: false },
339
+ );
340
+ });
341
+
342
+ expect(fetch_handler).toHaveBeenCalledTimes(1);
343
+ } finally {
344
+ rmSync(temp_dir, { recursive: true, force: true });
345
+ }
346
+ });
347
+
348
+ it('serves static files via options.static with custom prefix and cache settings', async () => {
349
+ const temp_dir = mkdtempSync(join(tmpdir(), 'adapter-node-static-options-'));
350
+ try {
351
+ writeFileSync(join(temp_dir, 'asset.txt'), 'asset-data');
352
+
353
+ const fetch_handler = vi.fn(() => new Response('fallback', { status: 404 }));
354
+ await with_server(
355
+ fetch_handler,
356
+ async (base_url) => {
357
+ const response = await fetch(`${base_url}/public/asset.txt`);
358
+ expect(response.status).toBe(200);
359
+ expect(response.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
360
+ expect(await response.text()).toBe('asset-data');
361
+ },
362
+ {
363
+ static: {
364
+ dir: temp_dir,
365
+ prefix: '/public',
366
+ maxAge: 60,
367
+ immutable: true,
368
+ },
369
+ },
370
+ );
371
+
372
+ expect(fetch_handler).not.toHaveBeenCalled();
373
+ } finally {
374
+ rmSync(temp_dir, { recursive: true, force: true });
375
+ }
376
+ });
377
+ });
@@ -0,0 +1,18 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ const types_source = readFileSync(new URL('../types/index.d.ts', import.meta.url), 'utf8');
5
+
6
+ describe('@ripple-ts/adapter-node types', () => {
7
+ it('uses shared ServeStaticDirectoryOptions alias from @ripple-ts/adapter', () => {
8
+ expect(types_source).toContain(
9
+ 'ServeStaticDirectoryOptions as BaseServeStaticDirectoryOptions',
10
+ );
11
+ expect(types_source).toContain('static?: BaseServeStaticDirectoryOptions | false;');
12
+ expect(types_source).toContain('export type ServeStaticOptions = BaseServeStaticOptions;');
13
+ });
14
+
15
+ it('does not inline static dir type shape locally', () => {
16
+ expect(types_source).not.toMatch(/BaseServeStaticOptions\s*&\s*\{\s*dir\?: string;\s*\}/);
17
+ });
18
+ });
@@ -0,0 +1,39 @@
1
+ import type {
2
+ AdapterCoreOptions,
3
+ FetchHandler,
4
+ ServeResult,
5
+ ServeStaticOptions as BaseServeStaticOptions,
6
+ ServeStaticDirectoryOptions as BaseServeStaticDirectoryOptions,
7
+ } from '@ripple-ts/adapter';
8
+
9
+ export type ServeOptions = AdapterCoreOptions & {
10
+ middleware?:
11
+ | ((
12
+ req: import('node:http').IncomingMessage,
13
+ res: import('node:http').ServerResponse,
14
+ next: (error?: any) => void,
15
+ ) => void)
16
+ | null;
17
+ static?: BaseServeStaticDirectoryOptions | false;
18
+ };
19
+
20
+ export type ServeStaticOptions = BaseServeStaticOptions;
21
+
22
+ export type StaticMiddleware = (
23
+ req: import('node:http').IncomingMessage,
24
+ res: import('node:http').ServerResponse,
25
+ next: (error?: any) => void,
26
+ ) => void;
27
+
28
+ export function serve(
29
+ fetch_handler: FetchHandler<{
30
+ node_request: import('node:http').IncomingMessage;
31
+ node_response: import('node:http').ServerResponse;
32
+ }>,
33
+ options?: ServeOptions,
34
+ ): ServeResult<import('node:http').Server>;
35
+
36
+ /**
37
+ * Create a middleware that serves static files from a directory
38
+ */
39
+ export function serveStatic(dir: string, options?: ServeStaticOptions): StaticMiddleware;