@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 +106 -0
- package/package.json +32 -0
- package/src/index.js +290 -0
- package/tests/serve.test.js +377 -0
- package/tests/types.test.js +18 -0
- package/types/index.d.ts +39 -0
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
|
+
});
|
package/types/index.d.ts
ADDED
|
@@ -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;
|