kempo-server 2.0.0 → 2.1.0
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 +310 -0
- package/CONFIG.md +12 -0
- package/dist/router.js +1 -1
- package/docs/configuration.html +11 -0
- package/docs/routing.html +10 -0
- package/llm.txt +1 -1
- package/package.json +1 -1
- package/src/router.js +119 -64
- package/tests/router-custom-route-dirs.node-test.js +319 -0
- package/tests/utils/http.js +21 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `kempo-server` are documented in this file.
|
|
4
|
+
|
|
5
|
+
## [2.1.0] - 2026-04-05
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Custom routes (`customRoutes`) now support file-based routing when the resolved path is a directory. The server looks for route files (`GET.js`, `POST.js`, etc.) and index files (`index.html`) inside directories, matching the same behavior as normal file-based routing.
|
|
10
|
+
- `httpRequest` test utility for making HTTP requests with arbitrary methods.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Wildcard routes resolving to a directory no longer return `500 EISDIR`. The server now correctly resolves route files or index files within the directory.
|
|
15
|
+
- Exact custom routes pointing to a directory now resolve `index.html` correctly (e.g. `/admin` mapping to a directory containing `index.html`).
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## [2.0.0] - 2026-04-04
|
|
20
|
+
|
|
21
|
+
### Breaking Changes
|
|
22
|
+
|
|
23
|
+
- **`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`).
|
|
24
|
+
|
|
25
|
+
**Migration:**
|
|
26
|
+
```javascript
|
|
27
|
+
// Before (1.x)
|
|
28
|
+
const raw = await request.body();
|
|
29
|
+
const data = JSON.parse(raw);
|
|
30
|
+
|
|
31
|
+
// After (2.0)
|
|
32
|
+
const data = request.body; // already parsed based on Content-Type
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
If you need the raw body string, use `await request.text()` or access `request._rawBody`.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- `maxBodySize` config option (default: 1MB). Requests exceeding this limit receive a `413 Payload Too Large` response.
|
|
40
|
+
- 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.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- Rescan double-wrap bug where the rescan path was incorrectly wrapping requests.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## [1.10.7] - 2026-03-21
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
- `llm.txt` file for LLM-friendly project documentation.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## [1.10.6] - 2026-03-12
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
|
|
60
|
+
- SPA (Single Page Application) example and documentation.
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- Updated CI workflows.
|
|
65
|
+
- Renamed `AGENTS.md` and updated testing framework.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## [1.10.3] - 2026-01-15
|
|
70
|
+
|
|
71
|
+
### Fixed
|
|
72
|
+
|
|
73
|
+
- Missing `cookies` property in the request wrapper. Cookie parsing now works correctly on all requests.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## [1.10.2] - 2026-01-14
|
|
78
|
+
|
|
79
|
+
### Fixed
|
|
80
|
+
|
|
81
|
+
- Middleware path resolution now correctly resolves relative middleware paths.
|
|
82
|
+
- Request and response wrappers are now properly passed through the middleware pipeline and into route handlers.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## [1.10.0] - 2026-01-08
|
|
87
|
+
|
|
88
|
+
### Breaking Changes
|
|
89
|
+
|
|
90
|
+
- **The `--rescan` CLI flag has been removed.** Rescanning is now controlled entirely by the `maxRescanAttempts` config option.
|
|
91
|
+
|
|
92
|
+
**Migration:**
|
|
93
|
+
```bash
|
|
94
|
+
# Before (1.9.x)
|
|
95
|
+
kempo-server --rescan
|
|
96
|
+
|
|
97
|
+
# After (1.10.0)
|
|
98
|
+
# Set in your config file:
|
|
99
|
+
# { "maxRescanAttempts": 3 }
|
|
100
|
+
# No CLI flag needed — rescanning is automatic based on config.
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Fixed
|
|
104
|
+
|
|
105
|
+
- `noRescanPaths` now correctly excludes well-known paths.
|
|
106
|
+
- `maxRescanAttempts` config now applies correctly.
|
|
107
|
+
- Various workflow and build fixes.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## [1.9.4] - 2026-01-07
|
|
112
|
+
|
|
113
|
+
### Security
|
|
114
|
+
|
|
115
|
+
- Default config now blocks `package.json` from being served, preventing exposure of dependency and project metadata.
|
|
116
|
+
|
|
117
|
+
**Action:** If you need to serve `package.json`, explicitly add it to your allowed paths.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## [1.9.2] - 2025-12-06
|
|
122
|
+
|
|
123
|
+
### Changed
|
|
124
|
+
|
|
125
|
+
- 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).
|
|
126
|
+
|
|
127
|
+
**Action:** If you relied on the default regex to block paths containing "password", add a custom rule to your config.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## [1.9.0] - 2025-10-25
|
|
132
|
+
|
|
133
|
+
### Added
|
|
134
|
+
|
|
135
|
+
- CLI utilities now support equals-separated values (e.g., `--port=3000`) and automatic boolean conversion.
|
|
136
|
+
- HTML documentation for CLI and file system utilities.
|
|
137
|
+
|
|
138
|
+
### Fixed
|
|
139
|
+
|
|
140
|
+
- Documentation markup fixes.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## [1.8.3] - 2025-10-24
|
|
145
|
+
|
|
146
|
+
### Added
|
|
147
|
+
|
|
148
|
+
- `encoding` response header is now automatically set on served files.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## [1.8.1] - 2025-10-24
|
|
153
|
+
|
|
154
|
+
### Added
|
|
155
|
+
|
|
156
|
+
- Config fallback system: user configs now merge with defaults so missing properties don't cause errors.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## [1.8.0] - 2025-10-24
|
|
161
|
+
|
|
162
|
+
### Added
|
|
163
|
+
|
|
164
|
+
- `encoding` config option to control the character encoding of served files (default: `utf-8`).
|
|
165
|
+
|
|
166
|
+
**Action:** If you were manually setting encoding headers in middleware, you can now use the config option instead.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## [1.7.13] - 2025-10-14
|
|
171
|
+
|
|
172
|
+
### Fixed
|
|
173
|
+
|
|
174
|
+
- Paths ending in `/` now correctly resolve to `index.html` (or the configured directory index).
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## [1.7.8] - 2025-09-19
|
|
179
|
+
|
|
180
|
+
### Fixed
|
|
181
|
+
|
|
182
|
+
- Malformed URL parameters no longer crash the server. Invalid query strings are now handled gracefully.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## [1.7.5] - 2025-09-02
|
|
187
|
+
|
|
188
|
+
### Changed
|
|
189
|
+
|
|
190
|
+
- Internal: refactored unit tests to use a static `test-server-root` directory instead of temporary files.
|
|
191
|
+
- Cleaned up documentation and examples.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## [1.7.3] - 2025-08-28
|
|
196
|
+
|
|
197
|
+
### Fixed
|
|
198
|
+
|
|
199
|
+
- Wildcard bug in `customRoutes` matching where `**` patterns were not resolving correctly.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## [1.7.2] - 2025-08-28
|
|
204
|
+
|
|
205
|
+
### Added
|
|
206
|
+
|
|
207
|
+
- Config file path validation: relative paths in the config are now validated to stay within the server root directory. Absolute paths are still allowed.
|
|
208
|
+
|
|
209
|
+
### Fixed
|
|
210
|
+
|
|
211
|
+
- Custom route path resolution improved to handle edge cases.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## [1.7.1] - 2025-08-28
|
|
216
|
+
|
|
217
|
+
### Fixed
|
|
218
|
+
|
|
219
|
+
- Static files no longer take precedence over `customRoutes` config entries. Custom routes now correctly override static file matches.
|
|
220
|
+
|
|
221
|
+
**Action:** If you relied on static files shadowing custom routes, be aware that custom routes now take priority.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## [1.7.0] - 2025-08-28
|
|
226
|
+
|
|
227
|
+
### Added
|
|
228
|
+
|
|
229
|
+
- **Module caching** for the file router. Dynamically imported route modules are now cached, significantly improving performance for repeated requests.
|
|
230
|
+
- Cache can be configured via the `cache` config section (`enabled`, `maxSize`).
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## [1.6.0] - 2025-08-28
|
|
235
|
+
|
|
236
|
+
### Added
|
|
237
|
+
|
|
238
|
+
- **`**` (double asterisk) wildcard support** in custom routes. Matches any number of path segments.
|
|
239
|
+
|
|
240
|
+
```json
|
|
241
|
+
{
|
|
242
|
+
"customRoutes": {
|
|
243
|
+
"/docs/**": "./docs-handler.js"
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**Action:** If you have custom routes with literal `**` in the path, they will now be interpreted as wildcards.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## [1.5.1] - 2025-08-28
|
|
253
|
+
|
|
254
|
+
### Changed
|
|
255
|
+
|
|
256
|
+
- Restructured repository to use `src/` and `dist/` directories.
|
|
257
|
+
- Docs now use `kempo.min.css` instead of `essential.css`.
|
|
258
|
+
|
|
259
|
+
### Added
|
|
260
|
+
|
|
261
|
+
- Node.js utility modules (`cli.js`, `fs-utils.js`).
|
|
262
|
+
|
|
263
|
+
**Action:** If you were importing internal modules directly, paths have changed from root to `dist/`.
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## [1.4.7] - 2025-08-26
|
|
268
|
+
|
|
269
|
+
### Added
|
|
270
|
+
|
|
271
|
+
- **`--config` CLI flag** to specify a custom config file path.
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
kempo-server --config ./my-config.json
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## [1.4.6] - 2025-08-22
|
|
280
|
+
|
|
281
|
+
### Added
|
|
282
|
+
|
|
283
|
+
- GitHub Actions workflow for automated publishing to NPM.
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## [1.4.5] - 2025-08-19
|
|
288
|
+
|
|
289
|
+
### Added
|
|
290
|
+
|
|
291
|
+
- Comprehensive unit test suite.
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## [1.0.0] - 2025-07-09
|
|
296
|
+
|
|
297
|
+
### Initial Release
|
|
298
|
+
|
|
299
|
+
- File-based routing server with zero dependencies.
|
|
300
|
+
- Dynamic route parameters via `[param]` directory/file naming.
|
|
301
|
+
- HTTP method-based route handlers (`GET.js`, `POST.js`, etc.).
|
|
302
|
+
- Request wrapper with Express-like API (`request.query`, `request.params`, `request.body()`, `request.json()`).
|
|
303
|
+
- Response wrapper with convenience methods (`response.json()`, `response.send()`, `response.status()`).
|
|
304
|
+
- Wildcard (`*`) support in custom routes.
|
|
305
|
+
- MIME type detection and configurable overrides.
|
|
306
|
+
- Security defaults: blocked dotfiles, `node_modules`, and sensitive path patterns.
|
|
307
|
+
- Static file serving.
|
|
308
|
+
- Configurable via `.config.json`.
|
|
309
|
+
- Middleware support.
|
|
310
|
+
- CLI interface with `--root`, `--port`, `--verbose` flags.
|
package/CONFIG.md
CHANGED
|
@@ -382,6 +382,18 @@ The `*` wildcard matches any single path segment (anything between `/` character
|
|
|
382
382
|
The `**` wildcard matches any number of path segments, allowing you to map entire directory trees.
|
|
383
383
|
Multiple wildcards can be used in a single route pattern.
|
|
384
384
|
|
|
385
|
+
**Directory Resolution:**
|
|
386
|
+
When a custom route (basic or wildcard) resolves to a directory, the server looks for files inside it using the same priority as normal file-based routing:
|
|
387
|
+
|
|
388
|
+
1. `METHOD.js` (e.g. `GET.js`, `POST.js`) — executed as a route module
|
|
389
|
+
2. `METHOD.html` (e.g. `GET.html`) — served as a static file
|
|
390
|
+
3. `index.js` — executed as a route module
|
|
391
|
+
4. `index.html` / `index.htm` — served as a static file
|
|
392
|
+
|
|
393
|
+
This means wildcard routes like `"/api/**": "../api/**"` fully support API directories containing route files (`GET.js`, `POST.js`, etc.), not just static files.
|
|
394
|
+
|
|
395
|
+
If the resolved path is a file whose name matches `routeFiles` (e.g. `GET.js`), it will be executed as a route module rather than served as static content.
|
|
396
|
+
|
|
385
397
|
### maxRescanAttempts
|
|
386
398
|
|
|
387
399
|
The maximum number of times to attempt rescanning the file system when a file is not found. Defaults to 3.
|
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,{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};
|
|
1
|
+
import path from"path";import{readFile,stat}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)},serveStaticCustomFile=async(filePath,res)=>{const fileExtension=path.extname(filePath).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(filePath,encoding);log(`Serving custom file as ${mimeType} (${fileContent.length} bytes)`,2);const contentType="utf8"===encoding&&mimeType.startsWith("text/")?`${mimeType}; charset=utf-8`:mimeType;res.writeHead(200,{"Content-Type":contentType}),res.end(fileContent)},executeRouteModule=async(filePath,req,res)=>{let module;if(moduleCache&&config.cache?.enabled){const fileStats=await stat(filePath);if(module=moduleCache.get(filePath,fileStats),!module){const fileUrl=pathToFileURL(filePath).href+`?t=${Date.now()}`;module=await import(fileUrl);const estimatedSizeKB=fileStats.size/1024;moduleCache.set(filePath,module,fileStats,estimatedSizeKB)}}else{const fileUrl=pathToFileURL(filePath).href+`?t=${Date.now()}`;module=await import(fileUrl)}if("function"!=typeof module.default)return log(`Route file does not export a function: ${filePath}`,0),res.writeHead(500,{"Content-Type":"text/plain"}),void res.end("Route file does not export a function");const enhancedReq=createRequestWrapper(req,{}),enhancedRes=createResponseWrapper(res),rawBody=await readRawBody(req);enhancedReq._rawBody=rawBody,enhancedReq.body=parseBody(rawBody,req.headers["content-type"]),moduleCache&&(enhancedReq._kempoCache=moduleCache),await module.default(enhancedReq,enhancedRes)},serveCustomRoutePath=async(resolvedFilePath,req,res)=>{let fileStat;try{fileStat=await stat(resolvedFilePath)}catch(e){if("ENOENT"===e.code)return null;throw e}if(fileStat.isDirectory()){const methodUpper=req.method.toUpperCase(),candidates=[`${methodUpper}.js`,`${methodUpper}.html`,"index.js","index.html","index.htm"];for(const candidate of candidates){const candidatePath=path.join(resolvedFilePath,candidate);try{await stat(candidatePath)}catch{continue}return config.routeFiles.includes(candidate)?(log(`Executing route file: ${candidatePath}`,2),await executeRouteModule(candidatePath,req,res),!0):(log(`Serving index file: ${candidatePath}`,2),await serveStaticCustomFile(candidatePath,res),!0)}return null}const fileName=path.basename(resolvedFilePath);return config.routeFiles.includes(fileName)?(log(`Executing route file: ${resolvedFilePath}`,2),await executeRouteModule(resolvedFilePath,req,res),!0):(await serveStaticCustomFile(resolvedFilePath,res),!0)},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{if(await serveCustomRoutePath(customFilePath,req,res))return;return log(`Custom route path not found: ${customFilePath}`,1),res.writeHead(404,{"Content-Type":"text/plain"}),void res.end("Custom route file not found")}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{if(await serveCustomRoutePath(resolvedFilePath,req,res))return;log(`Wildcard route path not found: ${requestPath}`,2)}catch(error){return log(`Error serving wildcard route ${requestPath}: ${error.message}`,1),enhancedResponse.writeHead(500,{"Content-Type":"text/plain"}),void enhancedResponse.end("Internal Server Error")}}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/docs/configuration.html
CHANGED
|
@@ -118,6 +118,17 @@
|
|
|
118
118
|
}</code></pre>
|
|
119
119
|
<p>Now requests to <code>/dist/app.js</code> will serve <code>../src/app.js</code> (the unminified source), while production uses the actual <code>dist/</code> files.</p>
|
|
120
120
|
|
|
121
|
+
<h4>Directory Resolution</h4>
|
|
122
|
+
<p>When a custom route (basic or wildcard) resolves to a directory, the server looks for files inside it using the same priority as normal file-based routing:</p>
|
|
123
|
+
<ol>
|
|
124
|
+
<li><code>METHOD.js</code> (e.g. <code>GET.js</code>, <code>POST.js</code>) — executed as a route module</li>
|
|
125
|
+
<li><code>METHOD.html</code> (e.g. <code>GET.html</code>) — served as a static file</li>
|
|
126
|
+
<li><code>index.js</code> — executed as a route module</li>
|
|
127
|
+
<li><code>index.html</code> / <code>index.htm</code> — served as a static file</li>
|
|
128
|
+
</ol>
|
|
129
|
+
<p>This means wildcard routes like <code>"/api/**": "../api/**"</code> fully support API directories containing route files (<code>GET.js</code>, <code>POST.js</code>, etc.), not just static files.</p>
|
|
130
|
+
<p>If the resolved path is a file whose name matches <code>routeFiles</code> (e.g. <code>GET.js</code>), it will be executed as a route module rather than served as static content.</p>
|
|
131
|
+
|
|
121
132
|
<h3 id="maxRescanAttempts">maxRescanAttempts</h3>
|
|
122
133
|
<p>The maximum number of times to attempt rescanning the file system when a file is not found. Defaults to 3.</p>
|
|
123
134
|
<pre><code class="hljs json">{<br /> <span class="hljs-attr">"maxRescanAttempts"</span>: <span class="hljs-number">3</span><br />}</code></pre>
|
package/docs/routing.html
CHANGED
|
@@ -71,6 +71,16 @@
|
|
|
71
71
|
|
|
72
72
|
<p>Example static file structure:</p>
|
|
73
73
|
<pre><code class="hljs markdown">public/<br />├─ index.html # Served at /<br />├─ styles.css # Served at /styles.css<br />├─ script.js # Served at /script.js<br />├─ images/<br />│ ├─ logo.png # Served at /images/logo.png<br />├─ api/ # Routes directory<br />│ ├─ hello/GET.js # Route handler<br /></code></pre>
|
|
74
|
+
|
|
75
|
+
<h2>Custom Route Directory Resolution</h2>
|
|
76
|
+
<p>When using <code>customRoutes</code> (see <a href="configuration.html#customRoutes">Configuration</a>), routes that resolve to a directory support full file-based routing. The server checks for route files and index files inside the directory using the same priority order as normal routing:</p>
|
|
77
|
+
<ol>
|
|
78
|
+
<li><code>METHOD.js</code> (e.g. <code>GET.js</code>, <code>POST.js</code>) — executed as a route module</li>
|
|
79
|
+
<li><code>METHOD.html</code> (e.g. <code>GET.html</code>) — served as static</li>
|
|
80
|
+
<li><code>index.js</code> — executed as a route module</li>
|
|
81
|
+
<li><code>index.html</code> / <code>index.htm</code> — served as static</li>
|
|
82
|
+
</ol>
|
|
83
|
+
<p>This applies to both exact and wildcard custom routes. For example, with a wildcard route <code>"/api/**": "../api/**"</code>, a request to <code>/api/auth/session</code> resolves to the <code>../api/auth/session/</code> directory and executes the appropriate route file (e.g. <code>GET.js</code> for GET requests).</p>
|
|
74
84
|
</main>
|
|
75
85
|
<div style="height:25vh"></div>
|
|
76
86
|
</body>
|
package/llm.txt
CHANGED
|
@@ -96,7 +96,7 @@ export default async (req, res) => {
|
|
|
96
96
|
}
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
-
`customRoutes` values: exact path string
|
|
99
|
+
`customRoutes` values: exact path string or wildcard pattern mapped to a file or directory path. `*` matches one path segment; `**` matches multiple. When a route resolves to a directory, the server looks for route files inside it using the same priority as normal routing: `METHOD.js`, `METHOD.html`, `index.js`, `index.html`.
|
|
100
100
|
|
|
101
101
|
## Custom Middleware
|
|
102
102
|
|
package/package.json
CHANGED
package/src/router.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
|
-
import { readFile } from 'fs/promises';
|
|
2
|
+
import { readFile, stat } from 'fs/promises';
|
|
3
3
|
import { pathToFileURL } from 'url';
|
|
4
4
|
import defaultConfig from './defaultConfig.js';
|
|
5
5
|
import getFiles from './getFiles.js';
|
|
@@ -225,6 +225,108 @@ export default async (flags, log) => {
|
|
|
225
225
|
return null;
|
|
226
226
|
};
|
|
227
227
|
|
|
228
|
+
/*
|
|
229
|
+
Custom Route Helpers
|
|
230
|
+
*/
|
|
231
|
+
const serveStaticCustomFile = async (filePath, res) => {
|
|
232
|
+
const fileExtension = path.extname(filePath).toLowerCase().slice(1);
|
|
233
|
+
const mimeConfig = config.allowedMimes[fileExtension];
|
|
234
|
+
let mimeType, encoding;
|
|
235
|
+
if(typeof mimeConfig === 'string') {
|
|
236
|
+
mimeType = mimeConfig;
|
|
237
|
+
encoding = mimeType.startsWith('text/') ? 'utf8' : undefined;
|
|
238
|
+
} else {
|
|
239
|
+
mimeType = mimeConfig?.mime || 'application/octet-stream';
|
|
240
|
+
encoding = mimeConfig?.encoding === 'utf8' ? 'utf8' : undefined;
|
|
241
|
+
}
|
|
242
|
+
const fileContent = await readFile(filePath, encoding);
|
|
243
|
+
log(`Serving custom file as ${mimeType} (${fileContent.length} bytes)`, 2);
|
|
244
|
+
const contentType = encoding === 'utf8' && mimeType.startsWith('text/')
|
|
245
|
+
? `${mimeType}; charset=utf-8`
|
|
246
|
+
: mimeType;
|
|
247
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
248
|
+
res.end(fileContent);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const executeRouteModule = async (filePath, req, res) => {
|
|
252
|
+
let module;
|
|
253
|
+
if(moduleCache && config.cache?.enabled) {
|
|
254
|
+
const fileStats = await stat(filePath);
|
|
255
|
+
module = moduleCache.get(filePath, fileStats);
|
|
256
|
+
if(!module) {
|
|
257
|
+
const fileUrl = pathToFileURL(filePath).href + `?t=${Date.now()}`;
|
|
258
|
+
module = await import(fileUrl);
|
|
259
|
+
const estimatedSizeKB = fileStats.size / 1024;
|
|
260
|
+
moduleCache.set(filePath, module, fileStats, estimatedSizeKB);
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
const fileUrl = pathToFileURL(filePath).href + `?t=${Date.now()}`;
|
|
264
|
+
module = await import(fileUrl);
|
|
265
|
+
}
|
|
266
|
+
if(typeof module.default !== 'function') {
|
|
267
|
+
log(`Route file does not export a function: ${filePath}`, 0);
|
|
268
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
269
|
+
res.end('Route file does not export a function');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const enhancedReq = createRequestWrapper(req, {});
|
|
273
|
+
const enhancedRes = createResponseWrapper(res);
|
|
274
|
+
const rawBody = await readRawBody(req);
|
|
275
|
+
enhancedReq._rawBody = rawBody;
|
|
276
|
+
enhancedReq.body = parseBody(rawBody, req.headers['content-type']);
|
|
277
|
+
if(moduleCache) enhancedReq._kempoCache = moduleCache;
|
|
278
|
+
await module.default(enhancedReq, enhancedRes);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// Resolves a custom route path that may be a file or directory.
|
|
282
|
+
// Returns true if handled, null if path not found.
|
|
283
|
+
const serveCustomRoutePath = async (resolvedFilePath, req, res) => {
|
|
284
|
+
let fileStat;
|
|
285
|
+
try {
|
|
286
|
+
fileStat = await stat(resolvedFilePath);
|
|
287
|
+
} catch(e) {
|
|
288
|
+
if(e.code === 'ENOENT') return null;
|
|
289
|
+
throw e;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if(fileStat.isDirectory()) {
|
|
293
|
+
const methodUpper = req.method.toUpperCase();
|
|
294
|
+
const candidates = [
|
|
295
|
+
`${methodUpper}.js`,
|
|
296
|
+
`${methodUpper}.html`,
|
|
297
|
+
'index.js',
|
|
298
|
+
'index.html',
|
|
299
|
+
'index.htm'
|
|
300
|
+
];
|
|
301
|
+
for(const candidate of candidates) {
|
|
302
|
+
const candidatePath = path.join(resolvedFilePath, candidate);
|
|
303
|
+
try {
|
|
304
|
+
await stat(candidatePath);
|
|
305
|
+
} catch {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if(config.routeFiles.includes(candidate)) {
|
|
309
|
+
log(`Executing route file: ${candidatePath}`, 2);
|
|
310
|
+
await executeRouteModule(candidatePath, req, res);
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
log(`Serving index file: ${candidatePath}`, 2);
|
|
314
|
+
await serveStaticCustomFile(candidatePath, res);
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const fileName = path.basename(resolvedFilePath);
|
|
321
|
+
if(config.routeFiles.includes(fileName)) {
|
|
322
|
+
log(`Executing route file: ${resolvedFilePath}`, 2);
|
|
323
|
+
await executeRouteModule(resolvedFilePath, req, res);
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
await serveStaticCustomFile(resolvedFilePath, res);
|
|
327
|
+
return true;
|
|
328
|
+
};
|
|
329
|
+
|
|
228
330
|
// Track 404 attempts to avoid unnecessary rescans
|
|
229
331
|
const rescanAttempts = new Map(); // path -> attempt count
|
|
230
332
|
const dynamicNoRescanPaths = new Set(); // paths that have exceeded max attempts
|
|
@@ -346,37 +448,13 @@ export default async (flags, log) => {
|
|
|
346
448
|
const customFilePath = customRoutes.get(matchedKey);
|
|
347
449
|
log(`Serving custom route: ${normalizedRequestPath} -> ${customFilePath}`, 3);
|
|
348
450
|
try {
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
res.end('Custom route file not found');
|
|
357
|
-
return;
|
|
358
|
-
}
|
|
359
|
-
const fileExtension = path.extname(customFilePath).toLowerCase().slice(1);
|
|
360
|
-
const mimeConfig = config.allowedMimes[fileExtension];
|
|
361
|
-
let mimeType, encoding;
|
|
362
|
-
if (typeof mimeConfig === 'string') {
|
|
363
|
-
mimeType = mimeConfig;
|
|
364
|
-
// Default to UTF-8 for text MIME types
|
|
365
|
-
encoding = mimeType.startsWith('text/') ? 'utf8' : undefined;
|
|
366
|
-
} else {
|
|
367
|
-
mimeType = mimeConfig?.mime || 'application/octet-stream';
|
|
368
|
-
encoding = mimeConfig?.encoding === 'utf8' ? 'utf8' : undefined;
|
|
369
|
-
}
|
|
370
|
-
const fileContent = await readFile(customFilePath, encoding);
|
|
371
|
-
log(`Serving custom file as ${mimeType} (${fileContent.length} bytes)`, 2);
|
|
372
|
-
// Add charset=utf-8 for text MIME types when using UTF-8 encoding
|
|
373
|
-
const contentType = encoding === 'utf8' && mimeType.startsWith('text/')
|
|
374
|
-
? `${mimeType}; charset=utf-8`
|
|
375
|
-
: mimeType;
|
|
376
|
-
res.writeHead(200, { 'Content-Type': contentType });
|
|
377
|
-
res.end(fileContent);
|
|
378
|
-
return; // Successfully served custom route
|
|
379
|
-
} catch (error) {
|
|
451
|
+
const result = await serveCustomRoutePath(customFilePath, req, res);
|
|
452
|
+
if(result) return;
|
|
453
|
+
log(`Custom route path not found: ${customFilePath}`, 1);
|
|
454
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
455
|
+
res.end('Custom route file not found');
|
|
456
|
+
return;
|
|
457
|
+
} catch(error) {
|
|
380
458
|
log(`Error serving custom route ${normalizedRequestPath}: ${error.message}`, 1);
|
|
381
459
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
382
460
|
res.end('Internal Server Error');
|
|
@@ -386,41 +464,18 @@ export default async (flags, log) => {
|
|
|
386
464
|
|
|
387
465
|
// Check wildcard routes (allow outside rootPath)
|
|
388
466
|
const wildcardMatch = findWildcardRoute(requestPath);
|
|
389
|
-
if
|
|
467
|
+
if(wildcardMatch) {
|
|
390
468
|
const resolvedFilePath = resolveWildcardPath(wildcardMatch.filePath, wildcardMatch.matches);
|
|
391
469
|
log(`Serving wildcard route: ${requestPath} -> ${resolvedFilePath}`, 3);
|
|
392
470
|
try {
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
mimeType = mimeConfig?.mime || 'application/octet-stream';
|
|
402
|
-
encoding = mimeConfig?.encoding === 'utf8' ? 'utf8' : undefined;
|
|
403
|
-
}
|
|
404
|
-
const fileContent = await readFile(resolvedFilePath, encoding);
|
|
405
|
-
log(`Serving wildcard file as ${mimeType} (${fileContent.length} bytes)`, 4);
|
|
406
|
-
// Add charset=utf-8 for text MIME types when using UTF-8 encoding
|
|
407
|
-
const contentType = encoding === 'utf8' && mimeType.startsWith('text/')
|
|
408
|
-
? `${mimeType}; charset=utf-8`
|
|
409
|
-
: mimeType;
|
|
410
|
-
res.writeHead(200, { 'Content-Type': contentType });
|
|
411
|
-
res.end(fileContent);
|
|
412
|
-
return; // Successfully served wildcard route
|
|
413
|
-
} catch (error) {
|
|
414
|
-
// Check if it's a file not found error
|
|
415
|
-
if (error.code === 'ENOENT') {
|
|
416
|
-
log(`Wildcard route file not found: ${requestPath}`, 2);
|
|
417
|
-
// Let it fall through to normal 404 handling
|
|
418
|
-
} else {
|
|
419
|
-
log(`Error serving wildcard route ${requestPath}: ${error.message}`, 1);
|
|
420
|
-
enhancedResponse.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
421
|
-
enhancedResponse.end('Internal Server Error');
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
471
|
+
const result = await serveCustomRoutePath(resolvedFilePath, req, res);
|
|
472
|
+
if(result) return;
|
|
473
|
+
log(`Wildcard route path not found: ${requestPath}`, 2);
|
|
474
|
+
} catch(error) {
|
|
475
|
+
log(`Error serving wildcard route ${requestPath}: ${error.message}`, 1);
|
|
476
|
+
enhancedResponse.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
477
|
+
enhancedResponse.end('Internal Server Error');
|
|
478
|
+
return;
|
|
424
479
|
}
|
|
425
480
|
}
|
|
426
481
|
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import {withTempDir} from './utils/temp-dir.js';
|
|
3
|
+
import {write} from './utils/file-writer.js';
|
|
4
|
+
import {randomPort} from './utils/port.js';
|
|
5
|
+
import {httpGet, httpRequest} from './utils/http.js';
|
|
6
|
+
import router from '../src/router.js';
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
'wildcard route resolves GET.js in directory': async ({pass, fail, log}) => {
|
|
10
|
+
try {
|
|
11
|
+
await withTempDir(async (dir) => {
|
|
12
|
+
await write(dir, 'api/auth/session/GET.js',
|
|
13
|
+
`export default (req, res) => { res.writeHead(200, {'Content-Type':'application/json'}); res.end('{"user":"test"}'); };`
|
|
14
|
+
);
|
|
15
|
+
await write(dir, 'docs/index.html', '<html></html>');
|
|
16
|
+
await write(dir, 'docs/.config.json', JSON.stringify({
|
|
17
|
+
customRoutes: { '/api/**': '../api/**' }
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const prev = process.cwd();
|
|
21
|
+
process.chdir(dir);
|
|
22
|
+
const handler = await router({root: 'docs', logging: 0}, () => {});
|
|
23
|
+
const server = http.createServer(handler);
|
|
24
|
+
const port = randomPort();
|
|
25
|
+
await new Promise(r => server.listen(port, r));
|
|
26
|
+
await new Promise(r => setTimeout(r, 50));
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const r1 = await httpGet(`http://localhost:${port}/api/auth/session`);
|
|
30
|
+
log('status: ' + r1.res.statusCode);
|
|
31
|
+
if(r1.res.statusCode !== 200) throw new Error('expected 200, got ' + r1.res.statusCode);
|
|
32
|
+
const body = JSON.parse(r1.body.toString());
|
|
33
|
+
if(body.user !== 'test') throw new Error('unexpected body: ' + r1.body.toString());
|
|
34
|
+
} finally {
|
|
35
|
+
server.close();
|
|
36
|
+
process.chdir(prev);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
pass('wildcard route resolves GET.js in directory');
|
|
40
|
+
} catch(e) {
|
|
41
|
+
fail(e.message);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
'wildcard route resolves POST.js in directory': async ({pass, fail, log}) => {
|
|
46
|
+
try {
|
|
47
|
+
await withTempDir(async (dir) => {
|
|
48
|
+
await write(dir, 'api/users/POST.js',
|
|
49
|
+
`export default (req, res) => { res.writeHead(201, {'Content-Type':'application/json'}); res.end('{"created":true}'); };`
|
|
50
|
+
);
|
|
51
|
+
await write(dir, 'docs/index.html', '<html></html>');
|
|
52
|
+
await write(dir, 'docs/.config.json', JSON.stringify({
|
|
53
|
+
customRoutes: { '/api/**': '../api/**' }
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
const prev = process.cwd();
|
|
57
|
+
process.chdir(dir);
|
|
58
|
+
const handler = await router({root: 'docs', logging: 0}, () => {});
|
|
59
|
+
const server = http.createServer(handler);
|
|
60
|
+
const port = randomPort();
|
|
61
|
+
await new Promise(r => server.listen(port, r));
|
|
62
|
+
await new Promise(r => setTimeout(r, 50));
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const r1 = await httpRequest(`http://localhost:${port}/api/users`, 'POST', '{"name":"alice"}');
|
|
66
|
+
log('status: ' + r1.res.statusCode);
|
|
67
|
+
if(r1.res.statusCode !== 201) throw new Error('expected 201, got ' + r1.res.statusCode);
|
|
68
|
+
const body = JSON.parse(r1.body.toString());
|
|
69
|
+
if(!body.created) throw new Error('unexpected body: ' + r1.body.toString());
|
|
70
|
+
} finally {
|
|
71
|
+
server.close();
|
|
72
|
+
process.chdir(prev);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
pass('wildcard route resolves POST.js in directory');
|
|
76
|
+
} catch(e) {
|
|
77
|
+
fail(e.message);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
'wildcard route resolves index.html in directory': async ({pass, fail, log}) => {
|
|
82
|
+
try {
|
|
83
|
+
await withTempDir(async (dir) => {
|
|
84
|
+
await write(dir, 'app/admin/index.html', '<h1>Admin Panel</h1>');
|
|
85
|
+
await write(dir, 'docs/index.html', '<html></html>');
|
|
86
|
+
await write(dir, 'docs/.config.json', JSON.stringify({
|
|
87
|
+
customRoutes: { '/app/**': '../app/**' }
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
const prev = process.cwd();
|
|
91
|
+
process.chdir(dir);
|
|
92
|
+
const handler = await router({root: 'docs', logging: 0}, () => {});
|
|
93
|
+
const server = http.createServer(handler);
|
|
94
|
+
const port = randomPort();
|
|
95
|
+
await new Promise(r => server.listen(port, r));
|
|
96
|
+
await new Promise(r => setTimeout(r, 50));
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const r1 = await httpGet(`http://localhost:${port}/app/admin`);
|
|
100
|
+
log('status: ' + r1.res.statusCode);
|
|
101
|
+
if(r1.res.statusCode !== 200) throw new Error('expected 200, got ' + r1.res.statusCode);
|
|
102
|
+
if(!r1.body.toString().includes('Admin Panel')) throw new Error('missing content');
|
|
103
|
+
} finally {
|
|
104
|
+
server.close();
|
|
105
|
+
process.chdir(prev);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
pass('wildcard route resolves index.html in directory');
|
|
109
|
+
} catch(e) {
|
|
110
|
+
fail(e.message);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
'exact custom route resolves GET.js in directory': async ({pass, fail, log}) => {
|
|
115
|
+
try {
|
|
116
|
+
await withTempDir(async (dir) => {
|
|
117
|
+
await write(dir, 'api/status/GET.js',
|
|
118
|
+
`export default (req, res) => { res.writeHead(200, {'Content-Type':'application/json'}); res.end('{"ok":true}'); };`
|
|
119
|
+
);
|
|
120
|
+
await write(dir, 'docs/index.html', '<html></html>');
|
|
121
|
+
await write(dir, 'docs/.config.json', JSON.stringify({
|
|
122
|
+
customRoutes: { '/status': '../api/status' }
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
const prev = process.cwd();
|
|
126
|
+
process.chdir(dir);
|
|
127
|
+
const handler = await router({root: 'docs', logging: 0}, () => {});
|
|
128
|
+
const server = http.createServer(handler);
|
|
129
|
+
const port = randomPort();
|
|
130
|
+
await new Promise(r => server.listen(port, r));
|
|
131
|
+
await new Promise(r => setTimeout(r, 50));
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const r1 = await httpGet(`http://localhost:${port}/status`);
|
|
135
|
+
log('status: ' + r1.res.statusCode);
|
|
136
|
+
if(r1.res.statusCode !== 200) throw new Error('expected 200, got ' + r1.res.statusCode);
|
|
137
|
+
const body = JSON.parse(r1.body.toString());
|
|
138
|
+
if(!body.ok) throw new Error('unexpected body');
|
|
139
|
+
} finally {
|
|
140
|
+
server.close();
|
|
141
|
+
process.chdir(prev);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
pass('exact custom route resolves GET.js in directory');
|
|
145
|
+
} catch(e) {
|
|
146
|
+
fail(e.message);
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
'exact custom route resolves index.html in directory': async ({pass, fail, log}) => {
|
|
151
|
+
try {
|
|
152
|
+
await withTempDir(async (dir) => {
|
|
153
|
+
await write(dir, 'site/dashboard/index.html', '<h1>Dashboard</h1>');
|
|
154
|
+
await write(dir, 'docs/index.html', '<html></html>');
|
|
155
|
+
await write(dir, 'docs/.config.json', JSON.stringify({
|
|
156
|
+
customRoutes: { '/dashboard': '../site/dashboard' }
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
const prev = process.cwd();
|
|
160
|
+
process.chdir(dir);
|
|
161
|
+
const handler = await router({root: 'docs', logging: 0}, () => {});
|
|
162
|
+
const server = http.createServer(handler);
|
|
163
|
+
const port = randomPort();
|
|
164
|
+
await new Promise(r => server.listen(port, r));
|
|
165
|
+
await new Promise(r => setTimeout(r, 50));
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const r1 = await httpGet(`http://localhost:${port}/dashboard`);
|
|
169
|
+
log('status: ' + r1.res.statusCode);
|
|
170
|
+
if(r1.res.statusCode !== 200) throw new Error('expected 200, got ' + r1.res.statusCode);
|
|
171
|
+
if(!r1.body.toString().includes('Dashboard')) throw new Error('missing content');
|
|
172
|
+
} finally {
|
|
173
|
+
server.close();
|
|
174
|
+
process.chdir(prev);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
pass('exact custom route resolves index.html in directory');
|
|
178
|
+
} catch(e) {
|
|
179
|
+
fail(e.message);
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
'wildcard route still serves static files': async ({pass, fail, log}) => {
|
|
184
|
+
try {
|
|
185
|
+
await withTempDir(async (dir) => {
|
|
186
|
+
await write(dir, 'assets/style.css', 'body { color: red; }');
|
|
187
|
+
await write(dir, 'docs/index.html', '<html></html>');
|
|
188
|
+
await write(dir, 'docs/.config.json', JSON.stringify({
|
|
189
|
+
customRoutes: { '/assets/**': '../assets/**' }
|
|
190
|
+
}));
|
|
191
|
+
|
|
192
|
+
const prev = process.cwd();
|
|
193
|
+
process.chdir(dir);
|
|
194
|
+
const handler = await router({root: 'docs', logging: 0}, () => {});
|
|
195
|
+
const server = http.createServer(handler);
|
|
196
|
+
const port = randomPort();
|
|
197
|
+
await new Promise(r => server.listen(port, r));
|
|
198
|
+
await new Promise(r => setTimeout(r, 50));
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const r1 = await httpGet(`http://localhost:${port}/assets/style.css`);
|
|
202
|
+
log('status: ' + r1.res.statusCode);
|
|
203
|
+
if(r1.res.statusCode !== 200) throw new Error('expected 200');
|
|
204
|
+
if(!r1.body.toString().includes('color: red')) throw new Error('wrong content');
|
|
205
|
+
} finally {
|
|
206
|
+
server.close();
|
|
207
|
+
process.chdir(prev);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
pass('wildcard route still serves static files');
|
|
211
|
+
} catch(e) {
|
|
212
|
+
fail(e.message);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
'wildcard directory with no matching files returns 404': async ({pass, fail, log}) => {
|
|
217
|
+
try {
|
|
218
|
+
await withTempDir(async (dir) => {
|
|
219
|
+
await write(dir, 'api/empty/.gitkeep', '');
|
|
220
|
+
await write(dir, 'docs/index.html', '<html></html>');
|
|
221
|
+
await write(dir, 'docs/.config.json', JSON.stringify({
|
|
222
|
+
customRoutes: { '/api/**': '../api/**' }
|
|
223
|
+
}));
|
|
224
|
+
|
|
225
|
+
const prev = process.cwd();
|
|
226
|
+
process.chdir(dir);
|
|
227
|
+
const handler = await router({root: 'docs', logging: 0}, () => {});
|
|
228
|
+
const server = http.createServer(handler);
|
|
229
|
+
const port = randomPort();
|
|
230
|
+
await new Promise(r => server.listen(port, r));
|
|
231
|
+
await new Promise(r => setTimeout(r, 50));
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const r1 = await httpGet(`http://localhost:${port}/api/empty`);
|
|
235
|
+
log('status: ' + r1.res.statusCode);
|
|
236
|
+
if(r1.res.statusCode !== 404) throw new Error('expected 404, got ' + r1.res.statusCode);
|
|
237
|
+
} finally {
|
|
238
|
+
server.close();
|
|
239
|
+
process.chdir(prev);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
pass('wildcard directory with no matching files returns 404');
|
|
243
|
+
} catch(e) {
|
|
244
|
+
fail(e.message);
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
'route file GET.js takes priority over index.html in directory': async ({pass, fail, log}) => {
|
|
249
|
+
try {
|
|
250
|
+
await withTempDir(async (dir) => {
|
|
251
|
+
await write(dir, 'api/data/GET.js',
|
|
252
|
+
`export default (req, res) => { res.writeHead(200, {'Content-Type':'application/json'}); res.end('{"from":"route"}'); };`
|
|
253
|
+
);
|
|
254
|
+
await write(dir, 'api/data/index.html', '<h1>Index</h1>');
|
|
255
|
+
await write(dir, 'docs/index.html', '<html></html>');
|
|
256
|
+
await write(dir, 'docs/.config.json', JSON.stringify({
|
|
257
|
+
customRoutes: { '/api/**': '../api/**' }
|
|
258
|
+
}));
|
|
259
|
+
|
|
260
|
+
const prev = process.cwd();
|
|
261
|
+
process.chdir(dir);
|
|
262
|
+
const handler = await router({root: 'docs', logging: 0}, () => {});
|
|
263
|
+
const server = http.createServer(handler);
|
|
264
|
+
const port = randomPort();
|
|
265
|
+
await new Promise(r => server.listen(port, r));
|
|
266
|
+
await new Promise(r => setTimeout(r, 50));
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const r1 = await httpGet(`http://localhost:${port}/api/data`);
|
|
270
|
+
log('status: ' + r1.res.statusCode);
|
|
271
|
+
if(r1.res.statusCode !== 200) throw new Error('expected 200');
|
|
272
|
+
const body = JSON.parse(r1.body.toString());
|
|
273
|
+
if(body.from !== 'route') throw new Error('GET.js should take priority over index.html');
|
|
274
|
+
} finally {
|
|
275
|
+
server.close();
|
|
276
|
+
process.chdir(prev);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
pass('route file GET.js takes priority over index.html');
|
|
280
|
+
} catch(e) {
|
|
281
|
+
fail(e.message);
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
'wildcard route executes route file directly mapped to a file': async ({pass, fail, log}) => {
|
|
286
|
+
try {
|
|
287
|
+
await withTempDir(async (dir) => {
|
|
288
|
+
await write(dir, 'handlers/GET.js',
|
|
289
|
+
`export default (req, res) => { res.writeHead(200, {'Content-Type':'text/plain'}); res.end('handled'); };`
|
|
290
|
+
);
|
|
291
|
+
await write(dir, 'docs/index.html', '<html></html>');
|
|
292
|
+
await write(dir, 'docs/.config.json', JSON.stringify({
|
|
293
|
+
customRoutes: { '/handler': '../handlers/GET.js' }
|
|
294
|
+
}));
|
|
295
|
+
|
|
296
|
+
const prev = process.cwd();
|
|
297
|
+
process.chdir(dir);
|
|
298
|
+
const handler = await router({root: 'docs', logging: 0}, () => {});
|
|
299
|
+
const server = http.createServer(handler);
|
|
300
|
+
const port = randomPort();
|
|
301
|
+
await new Promise(r => server.listen(port, r));
|
|
302
|
+
await new Promise(r => setTimeout(r, 50));
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const r1 = await httpGet(`http://localhost:${port}/handler`);
|
|
306
|
+
log('status: ' + r1.res.statusCode);
|
|
307
|
+
if(r1.res.statusCode !== 200) throw new Error('expected 200');
|
|
308
|
+
if(r1.body.toString() !== 'handled') throw new Error('expected route execution');
|
|
309
|
+
} finally {
|
|
310
|
+
server.close();
|
|
311
|
+
process.chdir(prev);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
pass('exact custom route executes route file directly');
|
|
315
|
+
} catch(e) {
|
|
316
|
+
fail(e.message);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
};
|
package/tests/utils/http.js
CHANGED
|
@@ -6,4 +6,25 @@ export const httpGet = (url) => new Promise((resolve, reject) => {
|
|
|
6
6
|
res.on('end', () => resolve({res, body: Buffer.concat(chunks)}));
|
|
7
7
|
}).on('error', reject);
|
|
8
8
|
}).catch(reject);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const httpRequest = (url, method, body) => new Promise((resolve, reject) => {
|
|
12
|
+
import('http').then(({request}) => {
|
|
13
|
+
const parsed = new URL(url);
|
|
14
|
+
const opts = {
|
|
15
|
+
hostname: parsed.hostname,
|
|
16
|
+
port: parsed.port,
|
|
17
|
+
path: parsed.pathname + parsed.search,
|
|
18
|
+
method
|
|
19
|
+
};
|
|
20
|
+
if(body) opts.headers = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) };
|
|
21
|
+
const req = request(opts, res => {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
res.on('data', c => chunks.push(c));
|
|
24
|
+
res.on('end', () => resolve({res, body: Buffer.concat(chunks)}));
|
|
25
|
+
});
|
|
26
|
+
req.on('error', reject);
|
|
27
|
+
if(body) req.write(body);
|
|
28
|
+
req.end();
|
|
29
|
+
}).catch(reject);
|
|
9
30
|
});
|