kempo-server 1.10.7 → 2.0.1
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 +296 -0
- package/CONFIG.md +11 -0
- package/README.md +25 -40
- package/dist/defaultConfig.js +1 -1
- package/dist/requestWrapper.js +1 -1
- package/dist/router.js +1 -1
- package/dist/serveFile.js +1 -1
- package/docs/configuration.html +5 -0
- package/docs/request-response.html +21 -6
- package/package.json +1 -1
- package/src/defaultConfig.js +1 -0
- package/src/requestWrapper.js +35 -39
- package/src/router.js +41 -2
- package/src/serveFile.js +6 -1
- package/tests/requestWrapper.node-test.js +57 -11
- package/tests/serveFile.node-test.js +6 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `kempo-server` are documented in this file.
|
|
4
|
+
|
|
5
|
+
## [2.0.0] - 2026-04-04
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- **`request.body` is now a pre-parsed value instead of a function.** Previously, `request.body()` was an async function that returned the raw body string. Now `request.body` is a property that contains the parsed body (JSON object, form data object, or raw string depending on `Content-Type`).
|
|
10
|
+
|
|
11
|
+
**Migration:**
|
|
12
|
+
```javascript
|
|
13
|
+
// Before (1.x)
|
|
14
|
+
const raw = await request.body();
|
|
15
|
+
const data = JSON.parse(raw);
|
|
16
|
+
|
|
17
|
+
// After (2.0)
|
|
18
|
+
const data = request.body; // already parsed based on Content-Type
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
If you need the raw body string, use `await request.text()` or access `request._rawBody`.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- `maxBodySize` config option (default: 1MB). Requests exceeding this limit receive a `413 Payload Too Large` response.
|
|
26
|
+
- Request body is now buffered once at the start of the request lifecycle, making it available to both middleware and route handlers without double-consumption issues.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- Rescan double-wrap bug where the rescan path was incorrectly wrapping requests.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## [1.10.7] - 2026-03-21
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- `llm.txt` file for LLM-friendly project documentation.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## [1.10.6] - 2026-03-12
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- SPA (Single Page Application) example and documentation.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- Updated CI workflows.
|
|
51
|
+
- Renamed `AGENTS.md` and updated testing framework.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## [1.10.3] - 2026-01-15
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- Missing `cookies` property in the request wrapper. Cookie parsing now works correctly on all requests.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## [1.10.2] - 2026-01-14
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- Middleware path resolution now correctly resolves relative middleware paths.
|
|
68
|
+
- Request and response wrappers are now properly passed through the middleware pipeline and into route handlers.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## [1.10.0] - 2026-01-08
|
|
73
|
+
|
|
74
|
+
### Breaking Changes
|
|
75
|
+
|
|
76
|
+
- **The `--rescan` CLI flag has been removed.** Rescanning is now controlled entirely by the `maxRescanAttempts` config option.
|
|
77
|
+
|
|
78
|
+
**Migration:**
|
|
79
|
+
```bash
|
|
80
|
+
# Before (1.9.x)
|
|
81
|
+
kempo-server --rescan
|
|
82
|
+
|
|
83
|
+
# After (1.10.0)
|
|
84
|
+
# Set in your config file:
|
|
85
|
+
# { "maxRescanAttempts": 3 }
|
|
86
|
+
# No CLI flag needed — rescanning is automatic based on config.
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Fixed
|
|
90
|
+
|
|
91
|
+
- `noRescanPaths` now correctly excludes well-known paths.
|
|
92
|
+
- `maxRescanAttempts` config now applies correctly.
|
|
93
|
+
- Various workflow and build fixes.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## [1.9.4] - 2026-01-07
|
|
98
|
+
|
|
99
|
+
### Security
|
|
100
|
+
|
|
101
|
+
- Default config now blocks `package.json` from being served, preventing exposure of dependency and project metadata.
|
|
102
|
+
|
|
103
|
+
**Action:** If you need to serve `package.json`, explicitly add it to your allowed paths.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## [1.9.2] - 2025-12-06
|
|
108
|
+
|
|
109
|
+
### Changed
|
|
110
|
+
|
|
111
|
+
- Removed the word "password" from the default banned regex pattern. This was causing false positives on legitimate routes/files containing the word "password" (e.g., password reset pages).
|
|
112
|
+
|
|
113
|
+
**Action:** If you relied on the default regex to block paths containing "password", add a custom rule to your config.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## [1.9.0] - 2025-10-25
|
|
118
|
+
|
|
119
|
+
### Added
|
|
120
|
+
|
|
121
|
+
- CLI utilities now support equals-separated values (e.g., `--port=3000`) and automatic boolean conversion.
|
|
122
|
+
- HTML documentation for CLI and file system utilities.
|
|
123
|
+
|
|
124
|
+
### Fixed
|
|
125
|
+
|
|
126
|
+
- Documentation markup fixes.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## [1.8.3] - 2025-10-24
|
|
131
|
+
|
|
132
|
+
### Added
|
|
133
|
+
|
|
134
|
+
- `encoding` response header is now automatically set on served files.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## [1.8.1] - 2025-10-24
|
|
139
|
+
|
|
140
|
+
### Added
|
|
141
|
+
|
|
142
|
+
- Config fallback system: user configs now merge with defaults so missing properties don't cause errors.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## [1.8.0] - 2025-10-24
|
|
147
|
+
|
|
148
|
+
### Added
|
|
149
|
+
|
|
150
|
+
- `encoding` config option to control the character encoding of served files (default: `utf-8`).
|
|
151
|
+
|
|
152
|
+
**Action:** If you were manually setting encoding headers in middleware, you can now use the config option instead.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## [1.7.13] - 2025-10-14
|
|
157
|
+
|
|
158
|
+
### Fixed
|
|
159
|
+
|
|
160
|
+
- Paths ending in `/` now correctly resolve to `index.html` (or the configured directory index).
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## [1.7.8] - 2025-09-19
|
|
165
|
+
|
|
166
|
+
### Fixed
|
|
167
|
+
|
|
168
|
+
- Malformed URL parameters no longer crash the server. Invalid query strings are now handled gracefully.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## [1.7.5] - 2025-09-02
|
|
173
|
+
|
|
174
|
+
### Changed
|
|
175
|
+
|
|
176
|
+
- Internal: refactored unit tests to use a static `test-server-root` directory instead of temporary files.
|
|
177
|
+
- Cleaned up documentation and examples.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## [1.7.3] - 2025-08-28
|
|
182
|
+
|
|
183
|
+
### Fixed
|
|
184
|
+
|
|
185
|
+
- Wildcard bug in `customRoutes` matching where `**` patterns were not resolving correctly.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## [1.7.2] - 2025-08-28
|
|
190
|
+
|
|
191
|
+
### Added
|
|
192
|
+
|
|
193
|
+
- Config file path validation: relative paths in the config are now validated to stay within the server root directory. Absolute paths are still allowed.
|
|
194
|
+
|
|
195
|
+
### Fixed
|
|
196
|
+
|
|
197
|
+
- Custom route path resolution improved to handle edge cases.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## [1.7.1] - 2025-08-28
|
|
202
|
+
|
|
203
|
+
### Fixed
|
|
204
|
+
|
|
205
|
+
- Static files no longer take precedence over `customRoutes` config entries. Custom routes now correctly override static file matches.
|
|
206
|
+
|
|
207
|
+
**Action:** If you relied on static files shadowing custom routes, be aware that custom routes now take priority.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## [1.7.0] - 2025-08-28
|
|
212
|
+
|
|
213
|
+
### Added
|
|
214
|
+
|
|
215
|
+
- **Module caching** for the file router. Dynamically imported route modules are now cached, significantly improving performance for repeated requests.
|
|
216
|
+
- Cache can be configured via the `cache` config section (`enabled`, `maxSize`).
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## [1.6.0] - 2025-08-28
|
|
221
|
+
|
|
222
|
+
### Added
|
|
223
|
+
|
|
224
|
+
- **`**` (double asterisk) wildcard support** in custom routes. Matches any number of path segments.
|
|
225
|
+
|
|
226
|
+
```json
|
|
227
|
+
{
|
|
228
|
+
"customRoutes": {
|
|
229
|
+
"/docs/**": "./docs-handler.js"
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
**Action:** If you have custom routes with literal `**` in the path, they will now be interpreted as wildcards.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## [1.5.1] - 2025-08-28
|
|
239
|
+
|
|
240
|
+
### Changed
|
|
241
|
+
|
|
242
|
+
- Restructured repository to use `src/` and `dist/` directories.
|
|
243
|
+
- Docs now use `kempo.min.css` instead of `essential.css`.
|
|
244
|
+
|
|
245
|
+
### Added
|
|
246
|
+
|
|
247
|
+
- Node.js utility modules (`cli.js`, `fs-utils.js`).
|
|
248
|
+
|
|
249
|
+
**Action:** If you were importing internal modules directly, paths have changed from root to `dist/`.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## [1.4.7] - 2025-08-26
|
|
254
|
+
|
|
255
|
+
### Added
|
|
256
|
+
|
|
257
|
+
- **`--config` CLI flag** to specify a custom config file path.
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
kempo-server --config ./my-config.json
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## [1.4.6] - 2025-08-22
|
|
266
|
+
|
|
267
|
+
### Added
|
|
268
|
+
|
|
269
|
+
- GitHub Actions workflow for automated publishing to NPM.
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## [1.4.5] - 2025-08-19
|
|
274
|
+
|
|
275
|
+
### Added
|
|
276
|
+
|
|
277
|
+
- Comprehensive unit test suite.
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## [1.0.0] - 2025-07-09
|
|
282
|
+
|
|
283
|
+
### Initial Release
|
|
284
|
+
|
|
285
|
+
- File-based routing server with zero dependencies.
|
|
286
|
+
- Dynamic route parameters via `[param]` directory/file naming.
|
|
287
|
+
- HTTP method-based route handlers (`GET.js`, `POST.js`, etc.).
|
|
288
|
+
- Request wrapper with Express-like API (`request.query`, `request.params`, `request.body()`, `request.json()`).
|
|
289
|
+
- Response wrapper with convenience methods (`response.json()`, `response.send()`, `response.status()`).
|
|
290
|
+
- Wildcard (`*`) support in custom routes.
|
|
291
|
+
- MIME type detection and configurable overrides.
|
|
292
|
+
- Security defaults: blocked dotfiles, `node_modules`, and sensitive path patterns.
|
|
293
|
+
- Static file serving.
|
|
294
|
+
- Configurable via `.config.json`.
|
|
295
|
+
- Middleware support.
|
|
296
|
+
- CLI interface with `--root`, `--port`, `--verbose` flags.
|
package/CONFIG.md
CHANGED
|
@@ -35,6 +35,7 @@ This json file can have any of the following properties, any property not define
|
|
|
35
35
|
- [routeFiles](#routefiles)
|
|
36
36
|
- [noRescanPaths](#norescanpaths)
|
|
37
37
|
- [maxRescanAttempts](#maxrescanattempts)
|
|
38
|
+
- [maxBodySize](#maxbodysize)
|
|
38
39
|
- [cache](#cache)
|
|
39
40
|
- [middleware](#middleware)
|
|
40
41
|
|
|
@@ -391,6 +392,16 @@ The maximum number of times to attempt rescanning the file system when a file is
|
|
|
391
392
|
}
|
|
392
393
|
```
|
|
393
394
|
|
|
395
|
+
### maxBodySize
|
|
396
|
+
|
|
397
|
+
Maximum allowed request body size in bytes. If a request body exceeds this limit, the server responds with `413 Payload Too Large` before the route handler runs. Defaults to `1048576` (1 MB).
|
|
398
|
+
|
|
399
|
+
```json
|
|
400
|
+
{
|
|
401
|
+
"maxBodySize": 1048576
|
|
402
|
+
}
|
|
403
|
+
```
|
|
404
|
+
|
|
394
405
|
## Configuration Examples
|
|
395
406
|
|
|
396
407
|
### Development Environment
|
package/README.md
CHANGED
|
@@ -114,11 +114,12 @@ Kempo Server provides a request object that makes working with HTTP requests eas
|
|
|
114
114
|
- `request.headers` - Request headers object
|
|
115
115
|
- `request.url` - Full request URL
|
|
116
116
|
|
|
117
|
+
- `request.body` - Pre-parsed request body (JSON → object, form-urlencoded → object, other → raw string, no body → `null`)
|
|
118
|
+
|
|
117
119
|
### Methods
|
|
118
|
-
- `await request.json()` -
|
|
119
|
-
- `await request.text()` - Get
|
|
120
|
-
- `await request.
|
|
121
|
-
- `await request.buffer()` - Get request body as Buffer
|
|
120
|
+
- `await request.json()` - Get cached body parsed as JSON
|
|
121
|
+
- `await request.text()` - Get cached body as text
|
|
122
|
+
- `await request.buffer()` - Get cached body as Buffer
|
|
122
123
|
- `request.get(headerName)` - Get specific header value
|
|
123
124
|
- `request.is(type)` - Check if content-type contains specified type
|
|
124
125
|
|
|
@@ -149,17 +150,12 @@ export default async function(request, response) {
|
|
|
149
150
|
// api/user/[id]/POST.js
|
|
150
151
|
export default async function(request, response) {
|
|
151
152
|
const { id } = request.params;
|
|
153
|
+
const { name, email } = request.body;
|
|
152
154
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const updatedUser = await updateUser(id, updateData);
|
|
158
|
-
|
|
159
|
-
response.json(updatedUser);
|
|
160
|
-
} catch (error) {
|
|
161
|
-
response.status(400).json({ error: 'Invalid JSON' });
|
|
162
|
-
}
|
|
155
|
+
// request.body is already parsed from JSON
|
|
156
|
+
const updatedUser = await updateUser(id, { name, email });
|
|
157
|
+
|
|
158
|
+
response.json(updatedUser);
|
|
163
159
|
}
|
|
164
160
|
```
|
|
165
161
|
|
|
@@ -291,20 +287,14 @@ export default async function(request, response) {
|
|
|
291
287
|
export default async function(request, response) {
|
|
292
288
|
const { id } = request.params;
|
|
293
289
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
};
|
|
303
|
-
|
|
304
|
-
response.json(updatedUser);
|
|
305
|
-
} catch (error) {
|
|
306
|
-
response.status(400).json({ error: 'Invalid JSON' });
|
|
307
|
-
}
|
|
290
|
+
// request.body is pre-parsed based on Content-Type
|
|
291
|
+
const updatedUser = {
|
|
292
|
+
id: id,
|
|
293
|
+
...request.body,
|
|
294
|
+
updatedAt: new Date().toISOString()
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
response.json(updatedUser);
|
|
308
298
|
}
|
|
309
299
|
```
|
|
310
300
|
|
|
@@ -312,19 +302,14 @@ export default async function(request, response) {
|
|
|
312
302
|
|
|
313
303
|
```javascript
|
|
314
304
|
// contact/POST.js
|
|
305
|
+
// With Content-Type: application/x-www-form-urlencoded,
|
|
306
|
+
// request.body is automatically parsed into an object
|
|
315
307
|
export default async function(request, response) {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
// Process form data...
|
|
323
|
-
|
|
324
|
-
response.html('<h1>Thank you for your message!</h1>');
|
|
325
|
-
} catch (error) {
|
|
326
|
-
response.status(400).html('<h1>Error processing form</h1>');
|
|
327
|
-
}
|
|
308
|
+
const { name, email } = request.body;
|
|
309
|
+
|
|
310
|
+
// Process form data...
|
|
311
|
+
|
|
312
|
+
response.html('<h1>Thank you for your message!</h1>');
|
|
328
313
|
}
|
|
329
314
|
```
|
|
330
315
|
|
package/dist/defaultConfig.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export default{allowedMimes:{html:{mime:"text/html",encoding:"utf8"},htm:{mime:"text/html",encoding:"utf8"},shtml:{mime:"text/html",encoding:"utf8"},css:{mime:"text/css",encoding:"utf8"},xml:{mime:"text/xml",encoding:"utf8"},gif:{mime:"image/gif",encoding:"binary"},jpeg:{mime:"image/jpeg",encoding:"binary"},jpg:{mime:"image/jpeg",encoding:"binary"},js:{mime:"application/javascript",encoding:"utf8"},mjs:{mime:"application/javascript",encoding:"utf8"},json:{mime:"application/json",encoding:"utf8"},webp:{mime:"image/webp",encoding:"binary"},png:{mime:"image/png",encoding:"binary"},svg:{mime:"image/svg+xml",encoding:"utf8"},svgz:{mime:"image/svg+xml",encoding:"utf8"},ico:{mime:"image/x-icon",encoding:"binary"},webm:{mime:"video/webm",encoding:"binary"},mp4:{mime:"video/mp4",encoding:"binary"},m4v:{mime:"video/mp4",encoding:"binary"},ogv:{mime:"video/ogg",encoding:"binary"},mp3:{mime:"audio/mpeg",encoding:"binary"},ogg:{mime:"audio/ogg",encoding:"binary"},wav:{mime:"audio/wav",encoding:"binary"},woff:{mime:"font/woff",encoding:"binary"},woff2:{mime:"font/woff2",encoding:"binary"},ttf:{mime:"font/ttf",encoding:"binary"},otf:{mime:"font/otf",encoding:"binary"},eot:{mime:"application/vnd.ms-fontobject",encoding:"binary"},pdf:{mime:"application/pdf",encoding:"binary"},txt:{mime:"text/plain",encoding:"utf8"},webmanifest:{mime:"application/manifest+json",encoding:"utf8"},md:{mime:"text/markdown",encoding:"utf8"},csv:{mime:"text/csv",encoding:"utf8"},doc:{mime:"application/msword",encoding:"binary"},docx:{mime:"application/vnd.openxmlformats-officedocument.wordprocessingml.document",encoding:"binary"},xls:{mime:"application/vnd.ms-excel",encoding:"binary"},xlsx:{mime:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",encoding:"binary"},ppt:{mime:"application/vnd.ms-powerpoint",encoding:"binary"},pptx:{mime:"application/vnd.openxmlformats-officedocument.presentationml.presentation",encoding:"binary"},avif:{mime:"image/avif",encoding:"binary"},wasm:{mime:"application/wasm",encoding:"binary"}},disallowedRegex:["^/\\..*","\\.config$","\\.env$","\\.git/","\\.htaccess$","\\.htpasswd$","^/node_modules/","^/vendor/","\\.log$","\\.bak$","\\.sql$","\\.ini$","config\\.php$","wp-config\\.php$","\\.DS_Store$","package\\.json$","package-lock\\.json$"],routeFiles:["GET.js","POST.js","PUT.js","DELETE.js","HEAD.js","OPTIONS.js","PATCH.js","CONNECT.js","TRACE.js","index.js"],noRescanPaths:["^/\\.well-known/","/favicon\\.ico$","/robots\\.txt$","/sitemap\\.xml$","/apple-touch-icon","/android-chrome-","/browserconfig\\.xml$","/manifest\\.json$","\\.map$","/__webpack_hmr$","/hot-update\\.","/sockjs-node/"],maxRescanAttempts:3,customRoutes:{},middleware:{cors:{enabled:!1,origin:"*",methods:["GET","POST","PUT","DELETE","OPTIONS"],headers:["Content-Type","Authorization"]},compression:{enabled:!1,threshold:1024},rateLimit:{enabled:!1,maxRequests:100,windowMs:6e4,message:"Too many requests"},security:{enabled:!0,headers:{"X-Content-Type-Options":"nosniff","X-Frame-Options":"DENY","X-XSS-Protection":"1; mode=block"}},logging:{enabled:!0,includeUserAgent:!1,includeResponseTime:!0},custom:[]},cache:{enabled:!1,maxSize:100,maxMemoryMB:50,ttlMs:3e5,maxHeapUsagePercent:70,memoryCheckInterval:3e4,watchFiles:!0,enableMemoryMonitoring:!0}};
|
|
1
|
+
export default{allowedMimes:{html:{mime:"text/html",encoding:"utf8"},htm:{mime:"text/html",encoding:"utf8"},shtml:{mime:"text/html",encoding:"utf8"},css:{mime:"text/css",encoding:"utf8"},xml:{mime:"text/xml",encoding:"utf8"},gif:{mime:"image/gif",encoding:"binary"},jpeg:{mime:"image/jpeg",encoding:"binary"},jpg:{mime:"image/jpeg",encoding:"binary"},js:{mime:"application/javascript",encoding:"utf8"},mjs:{mime:"application/javascript",encoding:"utf8"},json:{mime:"application/json",encoding:"utf8"},webp:{mime:"image/webp",encoding:"binary"},png:{mime:"image/png",encoding:"binary"},svg:{mime:"image/svg+xml",encoding:"utf8"},svgz:{mime:"image/svg+xml",encoding:"utf8"},ico:{mime:"image/x-icon",encoding:"binary"},webm:{mime:"video/webm",encoding:"binary"},mp4:{mime:"video/mp4",encoding:"binary"},m4v:{mime:"video/mp4",encoding:"binary"},ogv:{mime:"video/ogg",encoding:"binary"},mp3:{mime:"audio/mpeg",encoding:"binary"},ogg:{mime:"audio/ogg",encoding:"binary"},wav:{mime:"audio/wav",encoding:"binary"},woff:{mime:"font/woff",encoding:"binary"},woff2:{mime:"font/woff2",encoding:"binary"},ttf:{mime:"font/ttf",encoding:"binary"},otf:{mime:"font/otf",encoding:"binary"},eot:{mime:"application/vnd.ms-fontobject",encoding:"binary"},pdf:{mime:"application/pdf",encoding:"binary"},txt:{mime:"text/plain",encoding:"utf8"},webmanifest:{mime:"application/manifest+json",encoding:"utf8"},md:{mime:"text/markdown",encoding:"utf8"},csv:{mime:"text/csv",encoding:"utf8"},doc:{mime:"application/msword",encoding:"binary"},docx:{mime:"application/vnd.openxmlformats-officedocument.wordprocessingml.document",encoding:"binary"},xls:{mime:"application/vnd.ms-excel",encoding:"binary"},xlsx:{mime:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",encoding:"binary"},ppt:{mime:"application/vnd.ms-powerpoint",encoding:"binary"},pptx:{mime:"application/vnd.openxmlformats-officedocument.presentationml.presentation",encoding:"binary"},avif:{mime:"image/avif",encoding:"binary"},wasm:{mime:"application/wasm",encoding:"binary"}},disallowedRegex:["^/\\..*","\\.config$","\\.env$","\\.git/","\\.htaccess$","\\.htpasswd$","^/node_modules/","^/vendor/","\\.log$","\\.bak$","\\.sql$","\\.ini$","config\\.php$","wp-config\\.php$","\\.DS_Store$","package\\.json$","package-lock\\.json$"],routeFiles:["GET.js","POST.js","PUT.js","DELETE.js","HEAD.js","OPTIONS.js","PATCH.js","CONNECT.js","TRACE.js","index.js"],noRescanPaths:["^/\\.well-known/","/favicon\\.ico$","/robots\\.txt$","/sitemap\\.xml$","/apple-touch-icon","/android-chrome-","/browserconfig\\.xml$","/manifest\\.json$","\\.map$","/__webpack_hmr$","/hot-update\\.","/sockjs-node/"],maxRescanAttempts:3,customRoutes:{},middleware:{cors:{enabled:!1,origin:"*",methods:["GET","POST","PUT","DELETE","OPTIONS"],headers:["Content-Type","Authorization"]},compression:{enabled:!1,threshold:1024},rateLimit:{enabled:!1,maxRequests:100,windowMs:6e4,message:"Too many requests"},security:{enabled:!0,headers:{"X-Content-Type-Options":"nosniff","X-Frame-Options":"DENY","X-XSS-Protection":"1; mode=block"}},logging:{enabled:!0,includeUserAgent:!1,includeResponseTime:!0},custom:[]},maxBodySize:1048576,cache:{enabled:!1,maxSize:100,maxMemoryMB:50,ttlMs:3e5,maxHeapUsagePercent:70,memoryCheckInterval:3e4,watchFiles:!0,enableMemoryMonitoring:!0}};
|
package/dist/requestWrapper.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{URL}from"url";export function createRequestWrapper(request,params={}){const url=new URL(request.url,`http://${request.headers.host||"localhost"}`),query=Object.fromEntries(url.searchParams);return{...request,_originalRequest:request,method:request.method,url:request.url,headers:request.headers,params:params,query:query,path:url.pathname,cookies:(()=>{const cookieHeader=request.headers.cookie||request.headers.Cookie;return cookieHeader?cookieHeader.split(";").reduce((cookies,cookie)=>{const[name,...rest]=cookie.trim().split("=");return name&&(cookies[name]=rest.join("=")),cookies},{}):{}})(),body:
|
|
1
|
+
import{URL}from"url";export const readRawBody=req=>void 0!==req._bufferedBody?Promise.resolve(req._bufferedBody):new Promise((resolve,reject)=>{let body="";req.on("data",chunk=>{body+=chunk.toString()}),req.on("end",()=>{resolve(body)}),req.on("error",reject)});export const parseBody=(rawBody,contentType)=>{if(!rawBody)return null;const ct=(contentType||"").toLowerCase();if(ct.includes("application/json"))try{return JSON.parse(rawBody)}catch{return null}return ct.includes("application/x-www-form-urlencoded")?Object.fromEntries(new URLSearchParams(rawBody)):rawBody};export function createRequestWrapper(request,params={}){const url=new URL(request.url,`http://${request.headers.host||"localhost"}`),query=Object.fromEntries(url.searchParams);return{...request,_originalRequest:request,method:request.method,url:request.url,headers:request.headers,params:params,query:query,path:url.pathname,cookies:(()=>{const cookieHeader=request.headers.cookie||request.headers.Cookie;return cookieHeader?cookieHeader.split(";").reduce((cookies,cookie)=>{const[name,...rest]=cookie.trim().split("=");return name&&(cookies[name]=rest.join("=")),cookies},{}):{}})(),body:null,_rawBody:"",async json(){return JSON.parse(this._rawBody)},async text(){return this._rawBody},async buffer(){return Buffer.from(this._rawBody)},get:headerName=>request.headers[headerName.toLowerCase()],is(type){return(this.get("content-type")||"").includes(type)}}}export default createRequestWrapper;
|
package/dist/router.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import path from"path";import{readFile}from"fs/promises";import{pathToFileURL}from"url";import defaultConfig from"./defaultConfig.js";import getFiles from"./getFiles.js";import findFile from"./findFile.js";import serveFile from"./serveFile.js";import MiddlewareRunner from"./middlewareRunner.js";import ModuleCache from"./moduleCache.js";import createRequestWrapper from"./requestWrapper.js";import createResponseWrapper from"./responseWrapper.js";import{corsMiddleware,compressionMiddleware,rateLimitMiddleware,securityMiddleware,loggingMiddleware}from"./builtinMiddleware.js";export default async(flags,log)=>{log("Initializing router",3);const rootPath=path.isAbsolute(flags.root)?flags.root:path.join(process.cwd(),flags.root);log(`Root path: ${rootPath}`,3);let config=defaultConfig;try{const configFileName=flags.config||".config.json",configPath=path.isAbsolute(configFileName)?configFileName:path.join(rootPath,configFileName);if(log(`Config file name: ${configFileName}`,3),log(`Config path: ${configPath}`,3),path.isAbsolute(configFileName))log("Config file name is absolute, skipping validation",4);else{const relativeConfigPath=path.relative(rootPath,configPath);if(log(`Relative config path: ${relativeConfigPath}`,4),log(`Starts with '..': ${relativeConfigPath.startsWith("..")}`,4),relativeConfigPath.startsWith("..")||path.isAbsolute(relativeConfigPath))throw log("Validation failed - throwing error",4),new Error(`Config file must be within the server root directory. Config path: ${configPath}, Root path: ${rootPath}`);log("Validation passed",4)}log(`Loading config from: ${configPath}`,3);const configContent=await readFile(configPath,"utf8"),userConfig=JSON.parse(configContent);config={...defaultConfig,...userConfig,allowedMimes:{...defaultConfig.allowedMimes,...userConfig.allowedMimes||{}},middleware:{...defaultConfig.middleware,...userConfig.middleware||{}},customRoutes:{...defaultConfig.customRoutes,...userConfig.customRoutes||{}},cache:{...defaultConfig.cache,...userConfig.cache||{}}},log("User config loaded and merged with defaults",3)}catch(e){if(e.message.includes("Config file must be within the server root directory"))throw e;log("Using default config (no config file found)",3)}const dis=new Set(config.disallowedRegex);dis.add("^/\\..*"),dis.add("\\.config$"),dis.add("\\.git/"),config.disallowedRegex=[...dis],log(`Config loaded with ${config.disallowedRegex.length} disallowed patterns`,3);let files=await getFiles(rootPath,config,log);log(`Initial scan found ${files.length} files`,2);const middlewareRunner=new MiddlewareRunner;if(config.middleware?.cors?.enabled&&(middlewareRunner.use(corsMiddleware(config.middleware.cors)),log("CORS middleware enabled",3)),config.middleware?.compression?.enabled&&(middlewareRunner.use(compressionMiddleware(config.middleware.compression)),log("Compression middleware enabled",3)),config.middleware?.rateLimit?.enabled&&(middlewareRunner.use(rateLimitMiddleware(config.middleware.rateLimit)),log("Rate limit middleware enabled",3)),config.middleware?.security?.enabled&&(middlewareRunner.use(securityMiddleware(config.middleware.security)),log("Security middleware enabled",3)),config.middleware?.logging?.enabled&&(middlewareRunner.use(loggingMiddleware(config.middleware.logging,log)),log("Logging middleware enabled",3)),config.middleware?.custom&&config.middleware.custom.length>0){log(`Loading ${config.middleware.custom.length} custom middleware files`,3);for(const middlewarePath of config.middleware.custom)try{const resolvedPath=path.isAbsolute(middlewarePath)?middlewarePath:path.resolve(rootPath,middlewarePath),middlewareUrl=pathToFileURL(resolvedPath).href+`?t=${Date.now()}`,customMiddleware=(await import(middlewareUrl)).default;"function"==typeof customMiddleware?(middlewareRunner.use(customMiddleware(config.middleware)),log(`Custom middleware loaded: ${middlewarePath}`,3)):log(`Custom middleware error: ${middlewarePath} does not export a default function`,1)}catch(error){log(`Custom middleware error for ${middlewarePath}: ${error.message}`,1)}}let moduleCache=null;config.cache?.enabled&&(moduleCache=new ModuleCache(config.cache),log(`Module cache initialized: ${config.cache.maxSize} max modules, ${config.cache.maxMemoryMB}MB limit, ${config.cache.ttlMs}ms TTL`,2));const customRoutes=new Map,wildcardRoutes=new Map;if(config.customRoutes&&Object.keys(config.customRoutes).length>0){log(`Processing ${Object.keys(config.customRoutes).length} custom routes`,3);for(const[urlPath,filePath]of Object.entries(config.customRoutes))if(urlPath.includes("*")){const resolvedPath=path.isAbsolute(filePath)?filePath:path.resolve(rootPath,filePath);wildcardRoutes.set(urlPath,resolvedPath),log(`Wildcard route mapped: ${urlPath} -> ${resolvedPath}`,3)}else{const resolvedPath=path.isAbsolute(filePath)?filePath:path.resolve(rootPath,filePath);customRoutes.set(urlPath,resolvedPath),log(`Custom route mapped: ${urlPath} -> ${resolvedPath}`,3)}}const matchWildcardRoute=(requestPath,pattern)=>{const regexPattern=pattern.replace(/\*\*/g,"(.+)").replace(/\*/g,"([^/]+)");return new RegExp(`^${regexPattern}$`).exec(requestPath)},rescanAttempts=new Map,dynamicNoRescanPaths=new Set,shouldSkipRescan=requestPath=>config.noRescanPaths.some(pattern=>new RegExp(pattern).test(requestPath))?(log(`Skipping rescan for configured pattern: ${requestPath}`,3),!0):!!dynamicNoRescanPaths.has(requestPath)&&(log(`Skipping rescan for dynamically blacklisted path: ${requestPath}`,3),!0),handler=async(req,res)=>{const enhancedRequest=createRequestWrapper(req,{}),enhancedResponse=createResponseWrapper(res);await middlewareRunner.run(enhancedRequest,enhancedResponse,async()=>{const requestPath=enhancedRequest.url.split("?")[0];log(`${enhancedRequest.method} ${requestPath}`,4),log(`customRoutes keys: ${Array.from(customRoutes.keys()).join(", ")}`,4);const normalizePath=p=>{try{let np=decodeURIComponent(p);return np.startsWith("/")||(np="/"+np),np.length>1&&np.endsWith("/")&&(np=np.slice(0,-1)),np}catch(e){log(`Warning: Failed to decode URI component "${p}": ${e.message}`,1);let np=p;return np.startsWith("/")||(np="/"+np),np.length>1&&np.endsWith("/")&&(np=np.slice(0,-1)),np}},normalizedRequestPath=normalizePath(requestPath);log(`Normalized requestPath: ${normalizedRequestPath}`,4);let matchedKey=null;for(const key of customRoutes.keys())if(normalizePath(key)===normalizedRequestPath){matchedKey=key;break}if(matchedKey){const customFilePath=customRoutes.get(matchedKey);log(`Serving custom route: ${normalizedRequestPath} -> ${customFilePath}`,3);try{const{stat:stat}=await import("fs/promises");try{await stat(customFilePath),log(`Custom route file exists: ${customFilePath}`,4)}catch(e){return log(`Custom route file does NOT exist: ${customFilePath}`,1),res.writeHead(404,{"Content-Type":"text/plain"}),void res.end("Custom route file not found")}const fileExtension=path.extname(customFilePath).toLowerCase().slice(1),mimeConfig=config.allowedMimes[fileExtension];let mimeType,encoding;"string"==typeof mimeConfig?(mimeType=mimeConfig,encoding=mimeType.startsWith("text/")?"utf8":void 0):(mimeType=mimeConfig?.mime||"application/octet-stream",encoding="utf8"===mimeConfig?.encoding?"utf8":void 0);const fileContent=await readFile(customFilePath,encoding);log(`Serving custom file as ${mimeType} (${fileContent.length} bytes)`,2);const contentType="utf8"===encoding&&mimeType.startsWith("text/")?`${mimeType}; charset=utf-8`:mimeType;return res.writeHead(200,{"Content-Type":contentType}),void res.end(fileContent)}catch(error){return log(`Error serving custom route ${normalizedRequestPath}: ${error.message}`,1),res.writeHead(500,{"Content-Type":"text/plain"}),void res.end("Internal Server Error")}}const wildcardMatch=(requestPath=>{for(const[pattern,filePath]of wildcardRoutes){const matches=matchWildcardRoute(requestPath,pattern);if(matches)return{filePath:filePath,matches:matches}}return null})(requestPath);if(wildcardMatch){const resolvedFilePath=((filePath,matches)=>{let resolvedPath=filePath,matchIndex=1;for(;resolvedPath.includes("**")&&matchIndex<matches.length;)resolvedPath=resolvedPath.replace("**",matches[matchIndex]),matchIndex++;for(;resolvedPath.includes("*")&&matchIndex<matches.length;)resolvedPath=resolvedPath.replace("*",matches[matchIndex]),matchIndex++;return path.isAbsolute(resolvedPath)?resolvedPath:path.resolve(rootPath,resolvedPath)})(wildcardMatch.filePath,wildcardMatch.matches);log(`Serving wildcard route: ${requestPath} -> ${resolvedFilePath}`,3);try{const fileExtension=path.extname(resolvedFilePath).toLowerCase().slice(1),mimeConfig=config.allowedMimes[fileExtension];let mimeType,encoding;"string"==typeof mimeConfig?(mimeType=mimeConfig,encoding=mimeType.startsWith("text/")?"utf8":void 0):(mimeType=mimeConfig?.mime||"application/octet-stream",encoding="utf8"===mimeConfig?.encoding?"utf8":void 0);const fileContent=await readFile(resolvedFilePath,encoding);log(`Serving wildcard file as ${mimeType} (${fileContent.length} bytes)`,4);const contentType="utf8"===encoding&&mimeType.startsWith("text/")?`${mimeType}; charset=utf-8`:mimeType;return res.writeHead(200,{"Content-Type":contentType}),void res.end(fileContent)}catch(error){if("ENOENT"!==error.code)return log(`Error serving wildcard route ${requestPath}: ${error.message}`,1),enhancedResponse.writeHead(500,{"Content-Type":"text/plain"}),void enhancedResponse.end("Internal Server Error");log(`Wildcard route file not found: ${requestPath}`,2)}}const served=await serveFile(files,rootPath,requestPath,req.method,config,req,res,log,moduleCache);if(!served&&config.maxRescanAttempts>0&&!shouldSkipRescan(requestPath)){log("File not found, rescanning directory...",1),files=await getFiles(rootPath,config,log),log(`Rescan found ${files.length} files`,2);await serveFile(files,rootPath,requestPath,enhancedRequest.method,config,enhancedRequest,enhancedResponse,log,moduleCache)?rescanAttempts.delete(requestPath):((requestPath=>{const newAttempts=(rescanAttempts.get(requestPath)||0)+1;rescanAttempts.set(requestPath,newAttempts),newAttempts>config.maxRescanAttempts&&(dynamicNoRescanPaths.add(requestPath),log(`Path ${requestPath} added to dynamic blacklist after ${newAttempts} failed attempts`,1)),log(`Rescan attempt ${newAttempts}/${config.maxRescanAttempts} for: ${requestPath}`,3)})(requestPath),log(`404 - File not found after rescan: ${requestPath}`,1),enhancedResponse.writeHead(404,{"Content-Type":"text/plain"}),enhancedResponse.end("Not Found"))}else served||(shouldSkipRescan(requestPath)?log(`404 - Skipped rescan for: ${requestPath}`,2):log(`404 - File not found: ${requestPath}`,1),enhancedResponse.writeHead(404,{"Content-Type":"text/plain"}),enhancedResponse.end("Not Found"))})};return handler.moduleCache=moduleCache,handler.getStats=()=>moduleCache?.getStats()||null,handler.logCacheStats=()=>moduleCache?.logStats(log),handler.clearCache=()=>moduleCache?.clear(),handler};
|
|
1
|
+
import path from"path";import{readFile}from"fs/promises";import{pathToFileURL}from"url";import defaultConfig from"./defaultConfig.js";import getFiles from"./getFiles.js";import findFile from"./findFile.js";import serveFile from"./serveFile.js";import MiddlewareRunner from"./middlewareRunner.js";import ModuleCache from"./moduleCache.js";import createRequestWrapper,{readRawBody,parseBody}from"./requestWrapper.js";import createResponseWrapper from"./responseWrapper.js";import{corsMiddleware,compressionMiddleware,rateLimitMiddleware,securityMiddleware,loggingMiddleware}from"./builtinMiddleware.js";export default async(flags,log)=>{log("Initializing router",3);const rootPath=path.isAbsolute(flags.root)?flags.root:path.join(process.cwd(),flags.root);log(`Root path: ${rootPath}`,3);let config=defaultConfig;try{const configFileName=flags.config||".config.json",configPath=path.isAbsolute(configFileName)?configFileName:path.join(rootPath,configFileName);if(log(`Config file name: ${configFileName}`,3),log(`Config path: ${configPath}`,3),path.isAbsolute(configFileName))log("Config file name is absolute, skipping validation",4);else{const relativeConfigPath=path.relative(rootPath,configPath);if(log(`Relative config path: ${relativeConfigPath}`,4),log(`Starts with '..': ${relativeConfigPath.startsWith("..")}`,4),relativeConfigPath.startsWith("..")||path.isAbsolute(relativeConfigPath))throw log("Validation failed - throwing error",4),new Error(`Config file must be within the server root directory. Config path: ${configPath}, Root path: ${rootPath}`);log("Validation passed",4)}log(`Loading config from: ${configPath}`,3);const configContent=await readFile(configPath,"utf8"),userConfig=JSON.parse(configContent);config={...defaultConfig,...userConfig,allowedMimes:{...defaultConfig.allowedMimes,...userConfig.allowedMimes||{}},middleware:{...defaultConfig.middleware,...userConfig.middleware||{}},customRoutes:{...defaultConfig.customRoutes,...userConfig.customRoutes||{}},cache:{...defaultConfig.cache,...userConfig.cache||{}}},log("User config loaded and merged with defaults",3)}catch(e){if(e.message.includes("Config file must be within the server root directory"))throw e;log("Using default config (no config file found)",3)}const dis=new Set(config.disallowedRegex);dis.add("^/\\..*"),dis.add("\\.config$"),dis.add("\\.git/"),config.disallowedRegex=[...dis],log(`Config loaded with ${config.disallowedRegex.length} disallowed patterns`,3);let files=await getFiles(rootPath,config,log);log(`Initial scan found ${files.length} files`,2);const middlewareRunner=new MiddlewareRunner;if(config.middleware?.cors?.enabled&&(middlewareRunner.use(corsMiddleware(config.middleware.cors)),log("CORS middleware enabled",3)),config.middleware?.compression?.enabled&&(middlewareRunner.use(compressionMiddleware(config.middleware.compression)),log("Compression middleware enabled",3)),config.middleware?.rateLimit?.enabled&&(middlewareRunner.use(rateLimitMiddleware(config.middleware.rateLimit)),log("Rate limit middleware enabled",3)),config.middleware?.security?.enabled&&(middlewareRunner.use(securityMiddleware(config.middleware.security)),log("Security middleware enabled",3)),config.middleware?.logging?.enabled&&(middlewareRunner.use(loggingMiddleware(config.middleware.logging,log)),log("Logging middleware enabled",3)),config.middleware?.custom&&config.middleware.custom.length>0){log(`Loading ${config.middleware.custom.length} custom middleware files`,3);for(const middlewarePath of config.middleware.custom)try{const resolvedPath=path.isAbsolute(middlewarePath)?middlewarePath:path.resolve(rootPath,middlewarePath),middlewareUrl=pathToFileURL(resolvedPath).href+`?t=${Date.now()}`,customMiddleware=(await import(middlewareUrl)).default;"function"==typeof customMiddleware?(middlewareRunner.use(customMiddleware(config.middleware)),log(`Custom middleware loaded: ${middlewarePath}`,3)):log(`Custom middleware error: ${middlewarePath} does not export a default function`,1)}catch(error){log(`Custom middleware error for ${middlewarePath}: ${error.message}`,1)}}let moduleCache=null;config.cache?.enabled&&(moduleCache=new ModuleCache(config.cache),log(`Module cache initialized: ${config.cache.maxSize} max modules, ${config.cache.maxMemoryMB}MB limit, ${config.cache.ttlMs}ms TTL`,2));const customRoutes=new Map,wildcardRoutes=new Map;if(config.customRoutes&&Object.keys(config.customRoutes).length>0){log(`Processing ${Object.keys(config.customRoutes).length} custom routes`,3);for(const[urlPath,filePath]of Object.entries(config.customRoutes))if(urlPath.includes("*")){const resolvedPath=path.isAbsolute(filePath)?filePath:path.resolve(rootPath,filePath);wildcardRoutes.set(urlPath,resolvedPath),log(`Wildcard route mapped: ${urlPath} -> ${resolvedPath}`,3)}else{const resolvedPath=path.isAbsolute(filePath)?filePath:path.resolve(rootPath,filePath);customRoutes.set(urlPath,resolvedPath),log(`Custom route mapped: ${urlPath} -> ${resolvedPath}`,3)}}const matchWildcardRoute=(requestPath,pattern)=>{const regexPattern=pattern.replace(/\*\*/g,"(.+)").replace(/\*/g,"([^/]+)");return new RegExp(`^${regexPattern}$`).exec(requestPath)},rescanAttempts=new Map,dynamicNoRescanPaths=new Set,shouldSkipRescan=requestPath=>config.noRescanPaths.some(pattern=>new RegExp(pattern).test(requestPath))?(log(`Skipping rescan for configured pattern: ${requestPath}`,3),!0):!!dynamicNoRescanPaths.has(requestPath)&&(log(`Skipping rescan for dynamically blacklisted path: ${requestPath}`,3),!0),handler=async(req,res)=>{if(parseInt(req.headers["content-length"]||"0",10)>config.maxBodySize)return res.writeHead(413,{"Content-Type":"text/plain"}),void res.end("Payload Too Large");const rawBody=await new Promise((resolve,reject)=>{if(["GET","HEAD"].includes(req.method)&&!req.headers["content-length"])return resolve("");let body="",size=0;req.on("data",chunk=>{if(size+=chunk.length,size>config.maxBodySize)return req.destroy(),void reject(new Error("Payload Too Large"));body+=chunk.toString()}),req.on("end",()=>resolve(body)),req.on("error",reject)}).catch(err=>{if("Payload Too Large"===err.message)return res.writeHead(413,{"Content-Type":"text/plain"}),res.end("Payload Too Large"),null;throw err});if(null===rawBody)return;req._bufferedBody=rawBody;const enhancedRequest=createRequestWrapper(req,{}),enhancedResponse=createResponseWrapper(res);enhancedRequest._rawBody=rawBody,enhancedRequest.body=parseBody(rawBody,req.headers["content-type"]),await middlewareRunner.run(enhancedRequest,enhancedResponse,async()=>{const requestPath=enhancedRequest.url.split("?")[0];log(`${enhancedRequest.method} ${requestPath}`,4),log(`customRoutes keys: ${Array.from(customRoutes.keys()).join(", ")}`,4);const normalizePath=p=>{try{let np=decodeURIComponent(p);return np.startsWith("/")||(np="/"+np),np.length>1&&np.endsWith("/")&&(np=np.slice(0,-1)),np}catch(e){log(`Warning: Failed to decode URI component "${p}": ${e.message}`,1);let np=p;return np.startsWith("/")||(np="/"+np),np.length>1&&np.endsWith("/")&&(np=np.slice(0,-1)),np}},normalizedRequestPath=normalizePath(requestPath);log(`Normalized requestPath: ${normalizedRequestPath}`,4);let matchedKey=null;for(const key of customRoutes.keys())if(normalizePath(key)===normalizedRequestPath){matchedKey=key;break}if(matchedKey){const customFilePath=customRoutes.get(matchedKey);log(`Serving custom route: ${normalizedRequestPath} -> ${customFilePath}`,3);try{const{stat:stat}=await import("fs/promises");try{await stat(customFilePath),log(`Custom route file exists: ${customFilePath}`,4)}catch(e){return log(`Custom route file does NOT exist: ${customFilePath}`,1),res.writeHead(404,{"Content-Type":"text/plain"}),void res.end("Custom route file not found")}const fileExtension=path.extname(customFilePath).toLowerCase().slice(1),mimeConfig=config.allowedMimes[fileExtension];let mimeType,encoding;"string"==typeof mimeConfig?(mimeType=mimeConfig,encoding=mimeType.startsWith("text/")?"utf8":void 0):(mimeType=mimeConfig?.mime||"application/octet-stream",encoding="utf8"===mimeConfig?.encoding?"utf8":void 0);const fileContent=await readFile(customFilePath,encoding);log(`Serving custom file as ${mimeType} (${fileContent.length} bytes)`,2);const contentType="utf8"===encoding&&mimeType.startsWith("text/")?`${mimeType}; charset=utf-8`:mimeType;return res.writeHead(200,{"Content-Type":contentType}),void res.end(fileContent)}catch(error){return log(`Error serving custom route ${normalizedRequestPath}: ${error.message}`,1),res.writeHead(500,{"Content-Type":"text/plain"}),void res.end("Internal Server Error")}}const wildcardMatch=(requestPath=>{for(const[pattern,filePath]of wildcardRoutes){const matches=matchWildcardRoute(requestPath,pattern);if(matches)return{filePath:filePath,matches:matches}}return null})(requestPath);if(wildcardMatch){const resolvedFilePath=((filePath,matches)=>{let resolvedPath=filePath,matchIndex=1;for(;resolvedPath.includes("**")&&matchIndex<matches.length;)resolvedPath=resolvedPath.replace("**",matches[matchIndex]),matchIndex++;for(;resolvedPath.includes("*")&&matchIndex<matches.length;)resolvedPath=resolvedPath.replace("*",matches[matchIndex]),matchIndex++;return path.isAbsolute(resolvedPath)?resolvedPath:path.resolve(rootPath,resolvedPath)})(wildcardMatch.filePath,wildcardMatch.matches);log(`Serving wildcard route: ${requestPath} -> ${resolvedFilePath}`,3);try{const fileExtension=path.extname(resolvedFilePath).toLowerCase().slice(1),mimeConfig=config.allowedMimes[fileExtension];let mimeType,encoding;"string"==typeof mimeConfig?(mimeType=mimeConfig,encoding=mimeType.startsWith("text/")?"utf8":void 0):(mimeType=mimeConfig?.mime||"application/octet-stream",encoding="utf8"===mimeConfig?.encoding?"utf8":void 0);const fileContent=await readFile(resolvedFilePath,encoding);log(`Serving wildcard file as ${mimeType} (${fileContent.length} bytes)`,4);const contentType="utf8"===encoding&&mimeType.startsWith("text/")?`${mimeType}; charset=utf-8`:mimeType;return res.writeHead(200,{"Content-Type":contentType}),void res.end(fileContent)}catch(error){if("ENOENT"!==error.code)return log(`Error serving wildcard route ${requestPath}: ${error.message}`,1),enhancedResponse.writeHead(500,{"Content-Type":"text/plain"}),void enhancedResponse.end("Internal Server Error");log(`Wildcard route file not found: ${requestPath}`,2)}}const served=await serveFile(files,rootPath,requestPath,req.method,config,req,res,log,moduleCache);if(!served&&config.maxRescanAttempts>0&&!shouldSkipRescan(requestPath)){log("File not found, rescanning directory...",1),files=await getFiles(rootPath,config,log),log(`Rescan found ${files.length} files`,2);await serveFile(files,rootPath,requestPath,req.method,config,req,res,log,moduleCache)?rescanAttempts.delete(requestPath):((requestPath=>{const newAttempts=(rescanAttempts.get(requestPath)||0)+1;rescanAttempts.set(requestPath,newAttempts),newAttempts>config.maxRescanAttempts&&(dynamicNoRescanPaths.add(requestPath),log(`Path ${requestPath} added to dynamic blacklist after ${newAttempts} failed attempts`,1)),log(`Rescan attempt ${newAttempts}/${config.maxRescanAttempts} for: ${requestPath}`,3)})(requestPath),log(`404 - File not found after rescan: ${requestPath}`,1),enhancedResponse.writeHead(404,{"Content-Type":"text/plain"}),enhancedResponse.end("Not Found"))}else served||(shouldSkipRescan(requestPath)?log(`404 - Skipped rescan for: ${requestPath}`,2):log(`404 - File not found: ${requestPath}`,1),enhancedResponse.writeHead(404,{"Content-Type":"text/plain"}),enhancedResponse.end("Not Found"))})};return handler.moduleCache=moduleCache,handler.getStats=()=>moduleCache?.getStats()||null,handler.logCacheStats=()=>moduleCache?.logStats(log),handler.clearCache=()=>moduleCache?.clear(),handler};
|
package/dist/serveFile.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import path from"path";import{readFile,stat}from"fs/promises";import{pathToFileURL}from"url";import findFile from"./findFile.js";import createRequestWrapper
|
|
1
|
+
import path from"path";import{readFile,stat}from"fs/promises";import{pathToFileURL}from"url";import findFile from"./findFile.js";import createRequestWrapper,{readRawBody,parseBody}from"./requestWrapper.js";import createResponseWrapper from"./responseWrapper.js";export default async(files,rootPath,requestPath,method,config,req,res,log,moduleCache=null)=>{log(`Attempting to serve: ${requestPath}`,3);const[file,params]=await findFile(files,rootPath,requestPath,method,log);if(!file)return log(`No file found for: ${requestPath}`,3),!1;const fileName=path.basename(file);if(log(`Found file: ${file}`,2),config.routeFiles.includes(fileName)){log(`Executing route file: ${fileName}`,2);try{let module;if(moduleCache&&config.cache?.enabled){const fileStats=await stat(file);if(module=moduleCache.get(file,fileStats),module)log(`Using cached module: ${fileName}`,3);else{const fileUrl=pathToFileURL(file).href+`?t=${Date.now()}`;log(`Loading module from: ${fileUrl}`,3),module=await import(fileUrl);const estimatedSizeKB=fileStats.size/1024;moduleCache.set(file,module,fileStats,estimatedSizeKB),log(`Cached module: ${fileName} (${estimatedSizeKB.toFixed(1)}KB)`,3)}}else{const fileUrl=pathToFileURL(file).href+`?t=${Date.now()}`;log(`Loading module from: ${fileUrl}`,3),module=await import(fileUrl)}if("function"==typeof module.default){log(`Executing route function with params: ${JSON.stringify(params)}`,3);const enhancedRequest=createRequestWrapper(req,params),enhancedResponse=createResponseWrapper(res),rawBody=await readRawBody(req);return enhancedRequest._rawBody=rawBody,enhancedRequest.body=parseBody(rawBody,req.headers["content-type"]),moduleCache&&(enhancedRequest._kempoCache=moduleCache),await module.default(enhancedRequest,enhancedResponse),log(`Route executed successfully: ${fileName}`,2),!0}return log(`Route file does not export a function: ${fileName}`,0),res.writeHead(500,{"Content-Type":"text/plain"}),res.end("Route file does not export a function"),!0}catch(error){return log(`Error loading route file ${fileName}: ${error.message}`,0),res.writeHead(500,{"Content-Type":"text/plain"}),res.end("Internal Server Error"),!0}}else{log(`Serving static file: ${fileName}`,2);try{const fileExtension=path.extname(file).toLowerCase().slice(1),mimeConfig=config.allowedMimes[fileExtension];let mimeType,encoding;"string"==typeof mimeConfig?(mimeType=mimeConfig,encoding=mimeType.startsWith("text/")?"utf8":void 0):(mimeType=mimeConfig?.mime||"application/octet-stream",encoding="utf8"===mimeConfig?.encoding?"utf8":void 0);const fileContent=await readFile(file,encoding);log(`Serving ${file} as ${mimeType} (${fileContent.length} bytes)`,2);const contentType="utf8"===encoding&&mimeType.startsWith("text/")?`${mimeType}; charset=utf-8`:mimeType;return res.writeHead(200,{"Content-Type":contentType}),res.end(fileContent),!0}catch(error){return log(`Error reading file ${file}: ${error.message}`,0),res.writeHead(500,{"Content-Type":"text/plain"}),res.end("Internal Server Error"),!0}}};
|
package/docs/configuration.html
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
<li><a href="#routeFiles">routeFiles</a> - Files that should be treated as route handlers</li>
|
|
31
31
|
<li><a href="#noRescanPaths">noRescanPaths</a> - Paths that should not trigger file system rescans</li>
|
|
32
32
|
<li><a href="#maxRescanAttempts">maxRescanAttempts</a> - Maximum number of rescan attempts</li>
|
|
33
|
+
<li><a href="#maxBodySize">maxBodySize</a> - Maximum request body size in bytes</li>
|
|
33
34
|
<li><a href="#cache">cache</a> - Module caching configuration</li>
|
|
34
35
|
<li><a href="#middleware">middleware</a> - Middleware configuration</li>
|
|
35
36
|
</ul>
|
|
@@ -121,6 +122,10 @@
|
|
|
121
122
|
<p>The maximum number of times to attempt rescanning the file system when a file is not found. Defaults to 3.</p>
|
|
122
123
|
<pre><code class="hljs json">{<br /> <span class="hljs-attr">"maxRescanAttempts"</span>: <span class="hljs-number">3</span><br />}</code></pre>
|
|
123
124
|
|
|
125
|
+
<h3 id="maxBodySize">maxBodySize</h3>
|
|
126
|
+
<p>Maximum allowed request body size in bytes. If a request body exceeds this limit, the server responds with <code>413 Payload Too Large</code> before the route handler runs. Defaults to <code>1048576</code> (1 MB).</p>
|
|
127
|
+
<pre><code class="hljs json">{<br /> <span class="hljs-attr">"maxBodySize"</span>: <span class="hljs-number">1048576</span><br />}</code></pre>
|
|
128
|
+
|
|
124
129
|
<h3 id="middleware">middleware</h3>
|
|
125
130
|
<p>Configuration for built-in and custom middleware. Middleware runs before your route handlers and can modify requests, responses, or handle requests entirely.</p>
|
|
126
131
|
<pre><code class="hljs json">{<br /> <span class="hljs-attr">"middleware"</span>: {<br /> <span class="hljs-attr">"cors"</span>: {<br /> <span class="hljs-attr">"enabled"</span>: <span class="hljs-literal">true</span>,<br /> <span class="hljs-attr">"origin"</span>: <span class="hljs-string">"*"</span><br /> },<br /> <span class="hljs-attr">"compression"</span>: {<br /> <span class="hljs-attr">"enabled"</span>: <span class="hljs-literal">true</span><br /> },<br /> <span class="hljs-attr">"custom"</span>: [<span class="hljs-string">"./middleware/auth.js"</span>]<br /> }<br />}</code></pre>
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
<ul>
|
|
22
22
|
<li><code>request.params</code> - Route parameters from dynamic routes (e.g., <code>{ id: "123", info: "info" }</code>)</li>
|
|
23
23
|
<li><code>request.query</code> - Query string parameters as an object (e.g., <code>{ page: "1", limit: "10" }</code>)</li>
|
|
24
|
+
<li><code>request.body</code> - Pre-parsed request body (see <a href="#body-parsing">Body Parsing</a> below)</li>
|
|
24
25
|
<li><code>request.path</code> - The pathname of the request URL</li>
|
|
25
26
|
<li><code>request.method</code> - HTTP method (GET, POST, etc.)</li>
|
|
26
27
|
<li><code>request.headers</code> - Request headers object</li>
|
|
@@ -29,14 +30,28 @@
|
|
|
29
30
|
|
|
30
31
|
<h3>Methods</h3>
|
|
31
32
|
<ul>
|
|
32
|
-
<li><code>await request.json()</code> - Parse
|
|
33
|
-
<li><code>await request.text()</code> - Get
|
|
34
|
-
<li><code>await request.
|
|
35
|
-
<li><code>await request.buffer()</code> - Get request body as Buffer</li>
|
|
33
|
+
<li><code>await request.json()</code> - Parse cached body as JSON</li>
|
|
34
|
+
<li><code>await request.text()</code> - Get cached body as text</li>
|
|
35
|
+
<li><code>await request.buffer()</code> - Get cached body as Buffer</li>
|
|
36
36
|
<li><code>request.get(headerName)</code> - Get specific header value</li>
|
|
37
37
|
<li><code>request.is(type)</code> - Check if content-type contains specified type</li>
|
|
38
38
|
</ul>
|
|
39
39
|
|
|
40
|
+
<h3 id="body-parsing">Body Parsing</h3>
|
|
41
|
+
<p><code>request.body</code> is eagerly parsed before your route handler runs. The parsing strategy depends on the <code>Content-Type</code> header:</p>
|
|
42
|
+
<table>
|
|
43
|
+
<thead>
|
|
44
|
+
<tr><th>Content-Type</th><th><code>request.body</code> value</th></tr>
|
|
45
|
+
</thead>
|
|
46
|
+
<tbody>
|
|
47
|
+
<tr><td><code>application/json</code></td><td>Parsed object via <code>JSON.parse()</code>. If parsing fails, <code>null</code>.</td></tr>
|
|
48
|
+
<tr><td><code>application/x-www-form-urlencoded</code></td><td>Parsed object via <code>URLSearchParams</code></td></tr>
|
|
49
|
+
<tr><td>No body (GET, HEAD, Content-Length: 0)</td><td><code>null</code></td></tr>
|
|
50
|
+
<tr><td>Any other Content-Type</td><td>Raw string</td></tr>
|
|
51
|
+
</tbody>
|
|
52
|
+
</table>
|
|
53
|
+
<p>The convenience methods <code>request.json()</code>, <code>request.text()</code>, and <code>request.buffer()</code> still exist and return promises for backward compatibility, but they resolve immediately from the cached body.</p>
|
|
54
|
+
|
|
40
55
|
<h2>Response Object</h2>
|
|
41
56
|
<p>Kempo Server also provides a response object that makes sending responses easier:</p>
|
|
42
57
|
|
|
@@ -60,10 +75,10 @@
|
|
|
60
75
|
<pre><code class="hljs javascript"><span class="hljs-comment">// api/user/[id]/GET.js</span><br /><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">request, response</span>) </span>{<br /> <span class="hljs-keyword">const</span> { id } = request.params;<br /> <span class="hljs-keyword">const</span> { includeDetails } = request.query;<br /> <br /> <span class="hljs-comment">// Fetch user data from database</span><br /> <span class="hljs-keyword">const</span> userData = <span class="hljs-keyword">await</span> getUserById(id);<br /> <br /> <span class="hljs-keyword">if</span> (!userData) {<br /> <span class="hljs-keyword">return</span> response.status(<span class="hljs-number">404</span>).json({ error: <span class="hljs-string">'User not found'</span> });<br /> }<br /> <br /> response.json(userData);<br />}</code></pre>
|
|
61
76
|
|
|
62
77
|
<h3>Handling JSON Data</h3>
|
|
63
|
-
<pre><code class="hljs javascript"><span class="hljs-comment">// api/user/[id]/POST.js</span><br /><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">request, response</span>) </span>{<br /> <span class="hljs-keyword">const</span> { id } = request.params;<br /> <
|
|
78
|
+
<pre><code class="hljs javascript"><span class="hljs-comment">// api/user/[id]/POST.js</span><br /><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">request, response</span>) </span>{<br /> <span class="hljs-keyword">const</span> { id } = request.params;<br /> <span class="hljs-keyword">const</span> { name, email } = request.body;<br /> <br /> <span class="hljs-comment">// request.body is already parsed from JSON</span><br /> <span class="hljs-keyword">const</span> updatedUser = <span class="hljs-keyword">await</span> updateUser(id, { name, email });<br /> <br /> response.json(updatedUser);<br />}</code></pre>
|
|
64
79
|
|
|
65
80
|
<h3>Working with Form Data</h3>
|
|
66
|
-
<pre><code class="hljs javascript"><span class="hljs-comment">// contact/POST.js</span><br /><span class="hljs-
|
|
81
|
+
<pre><code class="hljs javascript"><span class="hljs-comment">// contact/POST.js</span><br /><span class="hljs-comment">// With Content-Type: application/x-www-form-urlencoded,</span><br /><span class="hljs-comment">// request.body is automatically parsed into an object</span><br /><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">request, response</span>) </span>{<br /> <span class="hljs-keyword">const</span> { name, email, message } = request.body;<br /> <br /> <span class="hljs-keyword">await</span> sendContactEmail({ name, email, message });<br /> <br /> response.html(<span class="hljs-string">'<h1>Thank you for your message!</h1>'</span>);<br />}</code></pre>
|
|
67
82
|
|
|
68
83
|
<h3>Checking Headers</h3>
|
|
69
84
|
<pre><code class="hljs javascript"><span class="hljs-comment">// api/upload/POST.js</span><br /><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">request, response</span>) </span>{<br /> <span class="hljs-comment">// Check if request contains JSON data</span><br /> <span class="hljs-keyword">if</span> (request.is(<span class="hljs-string">'application/json'</span>)) {<br /> <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> request.json();<br /> <span class="hljs-comment">// Handle JSON data</span><br /> }<br /> <br /> <span class="hljs-comment">// Check specific headers</span><br /> <span class="hljs-keyword">const</span> authHeader = request.get(<span class="hljs-string">'authorization'</span>);<br /> <span class="hljs-keyword">const</span> userAgent = request.get(<span class="hljs-string">'user-agent'</span>);<br /> <br /> <span class="hljs-comment">// Check request method</span><br /> <span class="hljs-keyword">if</span> (request.method === <span class="hljs-string">'POST'</span>) {<br /> <span class="hljs-comment">// Handle POST request</span><br /> }<br /> <br /> response.json({ success: <span class="hljs-literal">true</span> });<br />}</code></pre>
|
package/package.json
CHANGED
package/src/defaultConfig.js
CHANGED
|
@@ -128,6 +128,7 @@ export default {
|
|
|
128
128
|
// Example: "./middleware/auth.js"
|
|
129
129
|
]
|
|
130
130
|
},
|
|
131
|
+
maxBodySize: 1048576, // 1MB default max body size
|
|
131
132
|
cache: {
|
|
132
133
|
enabled: false, // Disabled by default - opt-in for performance
|
|
133
134
|
maxSize: 100, // Maximum number of cached modules
|
package/src/requestWrapper.js
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
import { URL } from 'url';
|
|
2
2
|
|
|
3
|
+
export const readRawBody = req => {
|
|
4
|
+
if(req._bufferedBody !== undefined) return Promise.resolve(req._bufferedBody);
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
let body = '';
|
|
7
|
+
req.on('data', chunk => { body += chunk.toString(); });
|
|
8
|
+
req.on('end', () => { resolve(body); });
|
|
9
|
+
req.on('error', reject);
|
|
10
|
+
});
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const parseBody = (rawBody, contentType) => {
|
|
14
|
+
if(!rawBody) return null;
|
|
15
|
+
const ct = (contentType || '').toLowerCase();
|
|
16
|
+
if(ct.includes('application/json')) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(rawBody);
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if(ct.includes('application/x-www-form-urlencoded')) {
|
|
24
|
+
return Object.fromEntries(new URLSearchParams(rawBody));
|
|
25
|
+
}
|
|
26
|
+
return rawBody;
|
|
27
|
+
};
|
|
28
|
+
|
|
3
29
|
/**
|
|
4
30
|
* Creates an enhanced request object with Express-like functionality
|
|
5
31
|
* @param {IncomingMessage} request - The original Node.js request object
|
|
@@ -37,50 +63,20 @@ export function createRequestWrapper(request, params = {}) {
|
|
|
37
63
|
path: url.pathname,
|
|
38
64
|
cookies: parseCookies(),
|
|
39
65
|
|
|
40
|
-
// Body
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
request.on('data', chunk => {
|
|
46
|
-
body += chunk.toString();
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
request.on('end', () => {
|
|
50
|
-
resolve(body);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
request.on('error', reject);
|
|
54
|
-
});
|
|
55
|
-
},
|
|
56
|
-
|
|
66
|
+
// Body — set to null initially; populated by router/serveFile before handler
|
|
67
|
+
body: null,
|
|
68
|
+
_rawBody: '',
|
|
69
|
+
|
|
57
70
|
async json() {
|
|
58
|
-
|
|
59
|
-
const body = await this.body();
|
|
60
|
-
return JSON.parse(body);
|
|
61
|
-
} catch (error) {
|
|
62
|
-
throw new Error('Invalid JSON in request body');
|
|
63
|
-
}
|
|
71
|
+
return JSON.parse(this._rawBody);
|
|
64
72
|
},
|
|
65
|
-
|
|
73
|
+
|
|
66
74
|
async text() {
|
|
67
|
-
return this.
|
|
75
|
+
return this._rawBody;
|
|
68
76
|
},
|
|
69
|
-
|
|
77
|
+
|
|
70
78
|
async buffer() {
|
|
71
|
-
return
|
|
72
|
-
const chunks = [];
|
|
73
|
-
|
|
74
|
-
request.on('data', chunk => {
|
|
75
|
-
chunks.push(chunk);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
request.on('end', () => {
|
|
79
|
-
resolve(Buffer.concat(chunks));
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
request.on('error', reject);
|
|
83
|
-
});
|
|
79
|
+
return Buffer.from(this._rawBody);
|
|
84
80
|
},
|
|
85
81
|
|
|
86
82
|
// Utility methods
|
package/src/router.js
CHANGED
|
@@ -7,7 +7,7 @@ import findFile from './findFile.js';
|
|
|
7
7
|
import serveFile from './serveFile.js';
|
|
8
8
|
import MiddlewareRunner from './middlewareRunner.js';
|
|
9
9
|
import ModuleCache from './moduleCache.js';
|
|
10
|
-
import createRequestWrapper from './requestWrapper.js';
|
|
10
|
+
import createRequestWrapper, { readRawBody, parseBody } from './requestWrapper.js';
|
|
11
11
|
import createResponseWrapper from './responseWrapper.js';
|
|
12
12
|
import {
|
|
13
13
|
corsMiddleware,
|
|
@@ -267,9 +267,48 @@ export default async (flags, log) => {
|
|
|
267
267
|
};
|
|
268
268
|
|
|
269
269
|
const requestHandler = async (req, res) => {
|
|
270
|
+
// Buffer body once early so both middleware and route handlers can access it
|
|
271
|
+
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
|
|
272
|
+
if(contentLength > config.maxBodySize) {
|
|
273
|
+
res.writeHead(413, { 'Content-Type': 'text/plain' });
|
|
274
|
+
res.end('Payload Too Large');
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const rawBody = await new Promise((resolve, reject) => {
|
|
279
|
+
const noBody = ['GET', 'HEAD'].includes(req.method) && !req.headers['content-length'];
|
|
280
|
+
if(noBody) return resolve('');
|
|
281
|
+
let body = '';
|
|
282
|
+
let size = 0;
|
|
283
|
+
req.on('data', chunk => {
|
|
284
|
+
size += chunk.length;
|
|
285
|
+
if(size > config.maxBodySize) {
|
|
286
|
+
req.destroy();
|
|
287
|
+
reject(new Error('Payload Too Large'));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
body += chunk.toString();
|
|
291
|
+
});
|
|
292
|
+
req.on('end', () => resolve(body));
|
|
293
|
+
req.on('error', reject);
|
|
294
|
+
}).catch(err => {
|
|
295
|
+
if(err.message === 'Payload Too Large') {
|
|
296
|
+
res.writeHead(413, { 'Content-Type': 'text/plain' });
|
|
297
|
+
res.end('Payload Too Large');
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
throw err;
|
|
301
|
+
});
|
|
302
|
+
if(rawBody === null) return;
|
|
303
|
+
req._bufferedBody = rawBody;
|
|
304
|
+
|
|
270
305
|
// Create enhanced request and response wrappers before middleware
|
|
271
306
|
const enhancedRequest = createRequestWrapper(req, {});
|
|
272
307
|
const enhancedResponse = createResponseWrapper(res);
|
|
308
|
+
|
|
309
|
+
// Populate body from buffered data
|
|
310
|
+
enhancedRequest._rawBody = rawBody;
|
|
311
|
+
enhancedRequest.body = parseBody(rawBody, req.headers['content-type']);
|
|
273
312
|
|
|
274
313
|
await middlewareRunner.run(enhancedRequest, enhancedResponse, async () => {
|
|
275
314
|
const requestPath = enhancedRequest.url.split('?')[0];
|
|
@@ -395,7 +434,7 @@ export default async (flags, log) => {
|
|
|
395
434
|
log(`Rescan found ${files.length} files`, 2);
|
|
396
435
|
|
|
397
436
|
// Try to serve again after rescan
|
|
398
|
-
const reserved = await serveFile(files, rootPath, requestPath,
|
|
437
|
+
const reserved = await serveFile(files, rootPath, requestPath, req.method, config, req, res, log, moduleCache);
|
|
399
438
|
|
|
400
439
|
if (!reserved) {
|
|
401
440
|
trackRescanAttempt(requestPath);
|
package/src/serveFile.js
CHANGED
|
@@ -2,7 +2,7 @@ import path from 'path';
|
|
|
2
2
|
import { readFile, stat } from 'fs/promises';
|
|
3
3
|
import { pathToFileURL } from 'url';
|
|
4
4
|
import findFile from './findFile.js';
|
|
5
|
-
import createRequestWrapper from './requestWrapper.js';
|
|
5
|
+
import createRequestWrapper, { readRawBody, parseBody } from './requestWrapper.js';
|
|
6
6
|
import createResponseWrapper from './responseWrapper.js';
|
|
7
7
|
|
|
8
8
|
export default async (files, rootPath, requestPath, method, config, req, res, log, moduleCache = null) => {
|
|
@@ -57,6 +57,11 @@ export default async (files, rootPath, requestPath, method, config, req, res, lo
|
|
|
57
57
|
// Create enhanced request and response wrappers
|
|
58
58
|
const enhancedRequest = createRequestWrapper(req, params);
|
|
59
59
|
const enhancedResponse = createResponseWrapper(res);
|
|
60
|
+
|
|
61
|
+
// Populate body from buffered data
|
|
62
|
+
const rawBody = await readRawBody(req);
|
|
63
|
+
enhancedRequest._rawBody = rawBody;
|
|
64
|
+
enhancedRequest.body = parseBody(rawBody, req.headers['content-type']);
|
|
60
65
|
|
|
61
66
|
// Make module cache accessible for admin endpoints
|
|
62
67
|
if (moduleCache) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {createMockReq} from './utils/mock-req.js';
|
|
2
|
-
import createRequestWrapper from '../src/requestWrapper.js';
|
|
2
|
+
import createRequestWrapper, {readRawBody, parseBody} from '../src/requestWrapper.js';
|
|
3
3
|
|
|
4
4
|
export default {
|
|
5
5
|
'parses query and path and provides params': async ({pass, fail}) => {
|
|
@@ -12,27 +12,35 @@ export default {
|
|
|
12
12
|
|
|
13
13
|
pass('parsed url');
|
|
14
14
|
},
|
|
15
|
-
'body/json/text/buffer helpers work': async ({pass, fail}) => {
|
|
15
|
+
'body/json/text/buffer helpers work with _rawBody': async ({pass, fail}) => {
|
|
16
16
|
const payload = {a: 1};
|
|
17
|
-
|
|
18
|
-
const reqText = createMockReq({method: 'POST', url: '/', headers: {host: 'x', 'content-type': 'application/json'}, body: JSON.stringify(payload)});
|
|
19
|
-
const text = await createRequestWrapper(reqText).text();
|
|
20
|
-
if(text !== JSON.stringify(payload)) return fail('text');
|
|
17
|
+
const raw = JSON.stringify(payload);
|
|
21
18
|
|
|
22
|
-
const
|
|
23
|
-
const
|
|
19
|
+
const reqText = createMockReq({method: 'POST', url: '/', headers: {host: 'x', 'content-type': 'application/json'}, body: raw});
|
|
20
|
+
const wText = createRequestWrapper(reqText);
|
|
21
|
+
wText._rawBody = await readRawBody(reqText);
|
|
22
|
+
if((await wText.text()) !== raw) return fail('text');
|
|
23
|
+
|
|
24
|
+
const reqJson = createMockReq({method: 'POST', url: '/', headers: {host: 'x', 'content-type': 'application/json'}, body: raw});
|
|
25
|
+
const wJson = createRequestWrapper(reqJson);
|
|
26
|
+
wJson._rawBody = await readRawBody(reqJson);
|
|
27
|
+
const obj = await wJson.json();
|
|
24
28
|
if(obj.a !== 1) return fail('json');
|
|
25
29
|
|
|
26
30
|
const reqBuf = createMockReq({url: '/', headers: {host: 'x'}, body: 'abc'});
|
|
27
|
-
const
|
|
31
|
+
const wBuf = createRequestWrapper(reqBuf);
|
|
32
|
+
wBuf._rawBody = await readRawBody(reqBuf);
|
|
33
|
+
const buf = await wBuf.buffer();
|
|
28
34
|
if(!(Buffer.isBuffer(buf) && buf.toString() === 'abc')) return fail('buffer');
|
|
29
|
-
|
|
35
|
+
|
|
30
36
|
pass('helpers');
|
|
31
37
|
},
|
|
32
38
|
'invalid json throws': async ({pass, fail}) => {
|
|
33
39
|
const req = createMockReq({url: '/', headers: {host: 'x'}, body: 'not json'});
|
|
40
|
+
const w = createRequestWrapper(req);
|
|
41
|
+
w._rawBody = await readRawBody(req);
|
|
34
42
|
try {
|
|
35
|
-
await
|
|
43
|
+
await w.json();
|
|
36
44
|
fail('should throw');
|
|
37
45
|
} catch(e){
|
|
38
46
|
pass('threw');
|
|
@@ -46,5 +54,43 @@ export default {
|
|
|
46
54
|
if(w.is('text/plain') !== true) return fail('is');
|
|
47
55
|
|
|
48
56
|
pass('header helpers');
|
|
57
|
+
},
|
|
58
|
+
'body property is null by default': async ({pass, fail}) => {
|
|
59
|
+
const req = createMockReq({url: '/', headers: {host: 'x'}});
|
|
60
|
+
const w = createRequestWrapper(req);
|
|
61
|
+
if(w.body !== null) return fail('expected null');
|
|
62
|
+
pass('body null');
|
|
63
|
+
},
|
|
64
|
+
'parseBody returns parsed JSON for application/json': async ({pass, fail}) => {
|
|
65
|
+
const result = parseBody('{"a":1}', 'application/json');
|
|
66
|
+
if(result?.a !== 1) return fail('json parse');
|
|
67
|
+
pass('json');
|
|
68
|
+
},
|
|
69
|
+
'parseBody returns null for invalid JSON': async ({pass, fail}) => {
|
|
70
|
+
const result = parseBody('not json', 'application/json');
|
|
71
|
+
if(result !== null) return fail('expected null');
|
|
72
|
+
pass('invalid json');
|
|
73
|
+
},
|
|
74
|
+
'parseBody returns object for urlencoded': async ({pass, fail}) => {
|
|
75
|
+
const result = parseBody('a=1&b=2', 'application/x-www-form-urlencoded');
|
|
76
|
+
if(result?.a !== '1' || result?.b !== '2') return fail('urlencoded');
|
|
77
|
+
pass('urlencoded');
|
|
78
|
+
},
|
|
79
|
+
'parseBody returns raw string for unknown content-type': async ({pass, fail}) => {
|
|
80
|
+
const result = parseBody('hello', 'text/plain');
|
|
81
|
+
if(result !== 'hello') return fail('expected raw string');
|
|
82
|
+
pass('raw string');
|
|
83
|
+
},
|
|
84
|
+
'parseBody returns null for empty body': async ({pass, fail}) => {
|
|
85
|
+
const result = parseBody('', 'application/json');
|
|
86
|
+
if(result !== null) return fail('expected null');
|
|
87
|
+
pass('empty body');
|
|
88
|
+
},
|
|
89
|
+
'readRawBody uses _bufferedBody when present': async ({pass, fail}) => {
|
|
90
|
+
const req = createMockReq({url: '/', headers: {host: 'x'}, body: 'stream data'});
|
|
91
|
+
req._bufferedBody = 'cached data';
|
|
92
|
+
const result = await readRawBody(req);
|
|
93
|
+
if(result !== 'cached data') return fail('expected cached data');
|
|
94
|
+
pass('buffered');
|
|
49
95
|
}
|
|
50
96
|
};
|
|
@@ -29,7 +29,9 @@ export default {
|
|
|
29
29
|
const cfg = JSON.parse(JSON.stringify(defaultConfig));
|
|
30
30
|
const files = [path.join(dir, 'api/GET.js')];
|
|
31
31
|
const res = createMockRes();
|
|
32
|
-
const
|
|
32
|
+
const req = createMockReq();
|
|
33
|
+
req._bufferedBody = '';
|
|
34
|
+
const ok = await serveFile(files, dir, '/api', 'GET', cfg, req, res, log);
|
|
33
35
|
if(ok !== true) return fail('served route');
|
|
34
36
|
if(res.statusCode !== 201) return fail('route status');
|
|
35
37
|
if(!res.getBody().toString().includes('ok')) return fail('body contains ok');
|
|
@@ -43,7 +45,9 @@ export default {
|
|
|
43
45
|
const cfg = JSON.parse(JSON.stringify(defaultConfig));
|
|
44
46
|
const files = [path.join(dir, 'api/no-default.js')];
|
|
45
47
|
const res = createMockRes();
|
|
46
|
-
const
|
|
48
|
+
const req = createMockReq();
|
|
49
|
+
req._bufferedBody = '';
|
|
50
|
+
const ok = await serveFile(files, dir, '/api', 'GET', cfg, req, res, log);
|
|
47
51
|
if(ok !== true) return fail('handled');
|
|
48
52
|
if(res.statusCode !== 500) return fail('500');
|
|
49
53
|
});
|