kempo-server 1.3.0 → 1.4.3
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/.github/copilot-instructions.md +96 -0
- package/README.md +212 -4
- package/docs/configuration.html +119 -0
- package/docs/examples.html +201 -0
- package/docs/getting-started.html +72 -0
- package/docs/index.html +53 -330
- package/docs/manifest.json +87 -0
- package/docs/media/hexagon.svg +22 -0
- package/docs/media/icon-maskable.png +0 -0
- package/docs/media/icon.svg +44 -0
- package/docs/media/icon128.png +0 -0
- package/docs/media/icon144.png +0 -0
- package/docs/media/icon152.png +0 -0
- package/docs/media/icon16-48.svg +21 -0
- package/docs/media/icon16.png +0 -0
- package/docs/media/icon192.png +0 -0
- package/docs/media/icon256.png +0 -0
- package/docs/media/icon32.png +0 -0
- package/docs/media/icon384.png +0 -0
- package/docs/media/icon48.png +0 -0
- package/docs/media/icon512.png +0 -0
- package/docs/media/icon64.png +0 -0
- package/docs/media/icon72.png +0 -0
- package/docs/media/icon96.png +0 -0
- package/docs/media/kempo-fist.svg +21 -0
- package/docs/middleware.html +147 -0
- package/docs/request-response.html +95 -0
- package/docs/routing.html +77 -0
- package/package.json +8 -3
- package/tests/builtinMiddleware-cors.node-test.js +17 -0
- package/tests/builtinMiddleware.node-test.js +74 -0
- package/tests/defaultConfig.node-test.js +13 -0
- package/tests/example-middleware.node-test.js +31 -0
- package/tests/findFile.node-test.js +46 -0
- package/tests/getFiles.node-test.js +25 -0
- package/tests/getFlags.node-test.js +30 -0
- package/tests/index.node-test.js +23 -0
- package/tests/middlewareRunner.node-test.js +18 -0
- package/tests/requestWrapper.node-test.js +51 -0
- package/tests/responseWrapper.node-test.js +74 -0
- package/tests/router-middleware.node-test.js +46 -0
- package/tests/router.node-test.js +88 -0
- package/tests/serveFile.node-test.js +52 -0
- package/tests/test-utils.js +106 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<html lang="en" theme="auto">
|
|
2
|
+
<head>
|
|
3
|
+
<meta charset='utf-8'>
|
|
4
|
+
<meta http-equiv='X-UA-Compatible' content='IE=edge'>
|
|
5
|
+
<title>Routes & Routing - Kempo Server</title>
|
|
6
|
+
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
|
7
|
+
<link rel="icon" type="image/png" sizes="48x48" href="media/icon48.png">
|
|
8
|
+
<link rel="manifest" href="manifest.json">
|
|
9
|
+
<link rel="stylesheet" href="essential.css" />
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<main>
|
|
13
|
+
<a href="./" class="btn">Home</a>
|
|
14
|
+
<h1>Routes & Routing</h1>
|
|
15
|
+
<p>Learn how Kempo Server's file-based routing system works.</p>
|
|
16
|
+
|
|
17
|
+
<h2>How Routes Work</h2>
|
|
18
|
+
<p>A route is a request to a directory that will be handled by a file. To define routes, create the directory structure to the route and create a file with the name of the method that this file will handle. For example <code>GET.js</code> will handle the <code>GET</code> requests, <code>POST.js</code> will handle the <code>POST</code> requests and so on. Use <code>index.js</code> to handle all request types.</p>
|
|
19
|
+
|
|
20
|
+
<p>The Javascript file must have a <b>default</b> export that is the function that will handle the request. It will be passed a <code>request</code> object and a <code>response</code> object.</p>
|
|
21
|
+
|
|
22
|
+
<p>For example this directory structure:</p>
|
|
23
|
+
<pre><code class="hljs markdown">my/<br />├─ route/<br />│ ├─ GET.js<br />│ ├─ POST.js<br />├─ other/<br />│ ├─ route/<br />│ │ ├─ GET.js<br /></code></pre>
|
|
24
|
+
<p>Would be used to handle <code>GET my/route/</code>, <code>POST my/route/</code> and <code>GET my/other/route/</code></p>
|
|
25
|
+
|
|
26
|
+
<h2 id="htmlRoutes">HTML Routes</h2>
|
|
27
|
+
<p>Just like JS files, HTML files can be used to define a route. Use <code>GET.html</code>, <code>POST.html</code>, etc... to define files that will be served when that route is requested.</p>
|
|
28
|
+
<pre><code class="hljs markdown">my/<br />├─ route/<br />│ ├─ GET.js<br />│ ├─ POST.js<br />├─ other/<br />│ ├─ route/<br />│ │ ├─ GET.js<br />│ ├─ POST.html<br />│ ├─ GET.html<br /></code></pre>
|
|
29
|
+
|
|
30
|
+
<h3><code>index</code> fallbacks</h3>
|
|
31
|
+
<p><code>index.js</code> or <code>index.html</code> will be used as a fallback for all routes if a <i>method</i> file is not defined. In the above examples we do not have any routes defined for <code>DELETE</code>, <code>PUT</code> <code>PATCH</code>, etc... so lets use an <code>index.js</code> and <code>index.html</code> to be a "catch-all" for all the methods we have not created handlers for.</p>
|
|
32
|
+
<pre><code class="hljs markdown">my/<br />├─ route/<br />│ ├─ GET.js<br />│ ├─ POST.js<br />│ ├─ index.js<br />├─ other/<br />│ ├─ route/<br />│ │ ├─ GET.js<br />│ │ ├─ index.js<br />│ ├─ POST.html<br />│ ├─ GET.html<br />│ ├─ index.html<br />├─ index.html<br /></code></pre>
|
|
33
|
+
|
|
34
|
+
<h2>Dynamic Routes</h2>
|
|
35
|
+
<p>A dynamic route is a route with a "param" in its path. To define the dynamic parts of the route just wrap the directory name in square brackets. For example if you wanted to get a users profile, or perform CRUD operations on a user you might create the following directory structure.</p>
|
|
36
|
+
<pre><code class="hljs markdown">api/<br />├─ user/<br />│ ├─ [id]/<br />│ │ ├─ [info]/<br />│ │ │ ├─ GET.js<br />│ │ │ ├─ DELETE.js<br />│ │ │ ├─ PUT.js<br />│ │ │ ├─ POST.js<br />│ │ ├─ GET.js<br /></code></pre>
|
|
37
|
+
<p>When a request is made to <code>/api/user/123/info</code>, the route file <code>api/user/[id]/[info]/GET.js</code> would be executed and receive a request object with <code>request.params</code> containing <code>{ id: "123", info: "info" }</code>.</p>
|
|
38
|
+
|
|
39
|
+
<h2>Route Examples</h2>
|
|
40
|
+
|
|
41
|
+
<h3>Simple API Route</h3>
|
|
42
|
+
<pre><code class="hljs javascript"><span class="hljs-comment">// api/hello/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> { name } = request.query;<br /> <span class="hljs-keyword">const</span> message = name ? <span class="hljs-string">`Hello ${name}!`</span> : <span class="hljs-string">'Hello World!'</span>;<br /> <br /> response.json({ message });<br />}</code></pre>
|
|
43
|
+
|
|
44
|
+
<h3>Dynamic User Profile Route</h3>
|
|
45
|
+
<pre><code class="hljs javascript"><span class="hljs-comment">// api/users/[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> { includeProfile } = request.query;<br /> <br /> <span class="hljs-comment">// Simulate database lookup</span><br /> <span class="hljs-keyword">const</span> user = {<br /> id: id,<br /> name: <span class="hljs-string">`User ${id}`</span>,<br /> email: <span class="hljs-string">`user${id}@example.com`</span><br /> };<br /> <br /> <span class="hljs-keyword">if</span> (includeProfile === <span class="hljs-string">'true'</span>) {<br /> user.profile = {<br /> bio: <span class="hljs-string">`Bio for user ${id}`</span>,<br /> joinDate: <span class="hljs-string">'2024-01-01'</span><br /> };<br /> }<br /> <br /> response.json(user);<br />}</code></pre>
|
|
46
|
+
|
|
47
|
+
<h3>Nested Dynamic Routes</h3>
|
|
48
|
+
<pre><code class="hljs javascript"><span class="hljs-comment">// api/users/[id]/posts/[postId]/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, postId } = request.params;<br /> <br /> <span class="hljs-keyword">const</span> post = {<br /> id: postId,<br /> userId: id,<br /> title: <span class="hljs-string">`Post ${postId} by User ${id}`</span>,<br /> content: <span class="hljs-string">'This is the post content...'</span>,<br /> createdAt: <span class="hljs-string">'2024-01-01T00:00:00.000Z'</span><br /> };<br /> <br /> response.json(post);<br />}</code></pre>
|
|
49
|
+
|
|
50
|
+
<h3>HTML Route Example</h3>
|
|
51
|
+
<pre><code class="hljs html"><span class="hljs-comment"><!-- pages/about/GET.html --></span><br /><span class="hljs-meta"><!DOCTYPE html></span><br /><span class="hljs-tag"><<span class="hljs-name">html</span>></span><br /><span class="hljs-tag"><<span class="hljs-name">head</span>></span><br /> <span class="hljs-tag"><<span class="hljs-name">title</span>></span>About Us<span class="hljs-tag"></<span class="hljs-name">title</span>></span><br /><span class="hljs-tag"></<span class="hljs-name">head</span>></span><br /><span class="hljs-tag"><<span class="hljs-name">body</span>></span><br /> <span class="hljs-tag"><<span class="hljs-name">h1</span>></span>About Our Company<span class="hljs-tag"></<span class="hljs-name">h1</span>></span><br /> <span class="hljs-tag"><<span class="hljs-name">p</span>></span>We are a company that does amazing things.<span class="hljs-tag"></<span class="hljs-name">p</span>></span><br /><span class="hljs-tag"></<span class="hljs-name">body</span>></span><br /><span class="hljs-tag"></<span class="hljs-name">html</span>></span></code></pre>
|
|
52
|
+
|
|
53
|
+
<h2>Route File Structure Best Practices</h2>
|
|
54
|
+
|
|
55
|
+
<h3>Organize by Feature</h3>
|
|
56
|
+
<pre><code class="hljs markdown">api/<br />├─ auth/<br />│ ├─ login/<br />│ │ ├─ POST.js<br />│ ├─ logout/<br />│ │ ├─ POST.js<br />│ ├─ register/<br />│ │ ├─ POST.js<br />├─ users/<br />│ ├─ [id]/<br />│ │ ├─ GET.js<br />│ │ ├─ PUT.js<br />│ │ ├─ DELETE.js<br />│ ├─ GET.js<br />│ ├─ POST.js<br />├─ posts/<br />│ ├─ [id]/<br />│ │ ├─ GET.js<br />│ │ ├─ PUT.js<br />│ │ ├─ DELETE.js<br />│ ├─ GET.js<br />│ ├─ POST.js<br /></code></pre>
|
|
57
|
+
|
|
58
|
+
<h3>Use Index Files for Fallbacks</h3>
|
|
59
|
+
<p>Use <code>index.js</code> to handle methods not explicitly defined:</p>
|
|
60
|
+
<pre><code class="hljs javascript"><span class="hljs-comment">// api/users/index.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">// Handle any method not explicitly defined</span><br /> response.status(<span class="hljs-number">405</span>).json({<br /> error: <span class="hljs-string">'Method not allowed'</span>,<br /> allowed: [<span class="hljs-string">'GET'</span>, <span class="hljs-string">'POST'</span>]<br /> });<br />}</code></pre>
|
|
61
|
+
|
|
62
|
+
<h2>Static File Serving</h2>
|
|
63
|
+
<p>Any file that doesn't match a route pattern will be served as a static file. This includes:</p>
|
|
64
|
+
<ul>
|
|
65
|
+
<li>HTML files (except route files)</li>
|
|
66
|
+
<li>CSS files</li>
|
|
67
|
+
<li>JavaScript files (except route files)</li>
|
|
68
|
+
<li>Images</li>
|
|
69
|
+
<li>Any other static assets</li>
|
|
70
|
+
</ul>
|
|
71
|
+
|
|
72
|
+
<p>Example static file structure:</p>
|
|
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
|
+
</main>
|
|
75
|
+
<div style="height:25vh"></div>
|
|
76
|
+
</body>
|
|
77
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kempo-server",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.3
|
|
4
|
+
"version": "1.4.3",
|
|
5
5
|
"description": "A lightweight, zero-dependency, file based routing server.",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
8
8
|
"kempo-server": "./index.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
|
|
11
|
+
"start": "node index.js -r ./docs",
|
|
12
|
+
"tests": "npx kempo-test",
|
|
13
|
+
"tests:gui": "npx kempo-test --gui",
|
|
14
|
+
"tests:browser": "npx kempo-test -b",
|
|
15
|
+
"tests:node": "npx kempo-test -n"
|
|
12
16
|
},
|
|
13
17
|
"author": "",
|
|
14
18
|
"license": "ISC",
|
|
15
19
|
"devDependencies": {
|
|
16
|
-
"essentialcss": "^2.0.1"
|
|
20
|
+
"essentialcss": "^2.0.1",
|
|
21
|
+
"kempo-testing-framework": "^1.2.3"
|
|
17
22
|
}
|
|
18
23
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {corsMiddleware} from '../builtinMiddleware.js';
|
|
2
|
+
import {createMockReq, createMockRes, expect} from './test-utils.js';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
'cors origin array and non-OPTIONS continues to next': async ({pass, fail}) => {
|
|
6
|
+
try {
|
|
7
|
+
const res = createMockRes();
|
|
8
|
+
const mw = corsMiddleware({origin: ['http://a','http://b'], methods: ['GET'], headers: ['X']});
|
|
9
|
+
const req = createMockReq({method: 'GET', headers: {origin: 'http://b'}});
|
|
10
|
+
let called = false;
|
|
11
|
+
await mw(req, res, async () => { called = true; });
|
|
12
|
+
expect(called, 'next not called');
|
|
13
|
+
expect(res.getHeader('Access-Control-Allow-Origin') === 'http://b', 'allowed origin');
|
|
14
|
+
pass('cors array');
|
|
15
|
+
} catch(e){ fail(e.message); }
|
|
16
|
+
}
|
|
17
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import {createMockReq, createMockRes, expect, bigString, gzipSize, setEnv} from './test-utils.js';
|
|
3
|
+
import {corsMiddleware, compressionMiddleware, rateLimitMiddleware, securityMiddleware, loggingMiddleware} from '../builtinMiddleware.js';
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
'cors middleware sets headers and handles OPTIONS': async ({pass, fail}) => {
|
|
7
|
+
try {
|
|
8
|
+
const res = createMockRes();
|
|
9
|
+
const mw = corsMiddleware({origin: '*', methods: ['GET'], headers: ['X']});
|
|
10
|
+
const req = createMockReq({method: 'OPTIONS', headers: {origin: 'http://x'}});
|
|
11
|
+
await mw(req, res, async () => {});
|
|
12
|
+
expect(res.isEnded(), 'preflight should end');
|
|
13
|
+
expect(res.getHeader('Access-Control-Allow-Origin') === 'http://x' || res.getHeader('Access-Control-Allow-Origin') === '*', 'origin header');
|
|
14
|
+
pass('cors');
|
|
15
|
+
} catch(e){ fail(e.message); }
|
|
16
|
+
},
|
|
17
|
+
'compression middleware gzips when threshold met and client accepts': async ({pass, fail}) => {
|
|
18
|
+
try {
|
|
19
|
+
const res = createMockRes();
|
|
20
|
+
const mw = compressionMiddleware({threshold: 1024});
|
|
21
|
+
const req = createMockReq({headers: {'accept-encoding': 'gzip'}});
|
|
22
|
+
// simulate next writing large body
|
|
23
|
+
await mw(req, res, async () => {
|
|
24
|
+
res.write(bigString(5000));
|
|
25
|
+
res.end();
|
|
26
|
+
});
|
|
27
|
+
await new Promise(r => setTimeout(r, 10));
|
|
28
|
+
const body = res.getBody();
|
|
29
|
+
const original = Buffer.from(bigString(5000));
|
|
30
|
+
const gzLen = await gzipSize(original);
|
|
31
|
+
// If gzipped is smaller, we expect gzip header. Otherwise, implementation may send uncompressed.
|
|
32
|
+
if(gzLen < original.length){
|
|
33
|
+
expect(res.getHeader('Content-Encoding') === 'gzip', 'should gzip when beneficial');
|
|
34
|
+
}
|
|
35
|
+
expect(body.length > 0, 'has body');
|
|
36
|
+
pass('compression');
|
|
37
|
+
} catch(e){ fail(e.message); }
|
|
38
|
+
},
|
|
39
|
+
'rate limit returns 429 after maxRequests': async ({pass, fail}) => {
|
|
40
|
+
try {
|
|
41
|
+
const cfg = {maxRequests: 2, windowMs: 1000, message: 'Too many'};
|
|
42
|
+
const mw = rateLimitMiddleware(cfg);
|
|
43
|
+
const req = createMockReq();
|
|
44
|
+
const res1 = createMockRes();
|
|
45
|
+
await mw(req, res1, async () => {});
|
|
46
|
+
const res2 = createMockRes();
|
|
47
|
+
await mw(req, res2, async () => {});
|
|
48
|
+
const res3 = createMockRes();
|
|
49
|
+
await mw(req, res3, async () => {});
|
|
50
|
+
expect(res3.statusCode === 429, 'should rate limit');
|
|
51
|
+
pass('rateLimit');
|
|
52
|
+
} catch(e){ fail(e.message); }
|
|
53
|
+
},
|
|
54
|
+
'security middleware sets headers': async ({pass, fail}) => {
|
|
55
|
+
try {
|
|
56
|
+
const res = createMockRes();
|
|
57
|
+
const mw = securityMiddleware({headers: {'X-Test': '1'}});
|
|
58
|
+
await mw(createMockReq(), res, async () => {});
|
|
59
|
+
expect(res.getHeader('X-Test') === '1', 'header set');
|
|
60
|
+
pass('security');
|
|
61
|
+
} catch(e){ fail(e.message); }
|
|
62
|
+
},
|
|
63
|
+
'logging middleware logs after response end': async ({pass, fail}) => {
|
|
64
|
+
try {
|
|
65
|
+
const logs = [];
|
|
66
|
+
const logger = (m) => logs.push(String(m));
|
|
67
|
+
const mw = loggingMiddleware({includeUserAgent: true, includeResponseTime: true}, logger);
|
|
68
|
+
const res = createMockRes();
|
|
69
|
+
await mw(createMockReq({headers: {'user-agent': 'UA'}}), res, async () => { res.end('x'); });
|
|
70
|
+
expect(logs.length === 1 && logs[0].includes('GET /') && logs[0].includes('UA'), 'logged');
|
|
71
|
+
pass('logging');
|
|
72
|
+
} catch(e){ fail(e.message); }
|
|
73
|
+
}
|
|
74
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import defaultConfig from '../defaultConfig.js';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
'defaultConfig contains required fields and types': async ({pass, fail}) => {
|
|
5
|
+
try {
|
|
6
|
+
if(typeof defaultConfig !== 'object') throw new Error('not object');
|
|
7
|
+
if(!defaultConfig.allowedMimes || !defaultConfig.disallowedRegex) throw new Error('missing keys');
|
|
8
|
+
if(!defaultConfig.routeFiles.includes('GET.js')) throw new Error('routeFiles missing GET.js');
|
|
9
|
+
if(!defaultConfig.middleware || typeof defaultConfig.middleware !== 'object') throw new Error('middleware missing');
|
|
10
|
+
pass('shape ok');
|
|
11
|
+
} catch(e){ fail(e.message); }
|
|
12
|
+
}
|
|
13
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import url from 'url';
|
|
3
|
+
import {createMockReq, createMockRes, expect, setEnv} from './test-utils.js';
|
|
4
|
+
|
|
5
|
+
// import the middleware module by file path to avoid executing index.js
|
|
6
|
+
const examplePath = path.join(process.cwd(), 'example-middleware.js');
|
|
7
|
+
const {default: authMiddleware} = await import(url.pathToFileURL(examplePath));
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
'blocks when API key missing and allows when present': async ({pass, fail}) => {
|
|
11
|
+
try {
|
|
12
|
+
await setEnv({API_KEY: 'abc'}, async () => {
|
|
13
|
+
const res1 = createMockRes();
|
|
14
|
+
await authMiddleware(createMockReq({url:'/private'}), res1, async ()=>{});
|
|
15
|
+
expect(res1.statusCode === 401, 'should 401 without key');
|
|
16
|
+
|
|
17
|
+
const res2 = createMockRes();
|
|
18
|
+
const req2 = createMockReq({headers: {'x-api-key': 'abc'}, url:'/private'});
|
|
19
|
+
let called = false;
|
|
20
|
+
await authMiddleware(req2, res2, async ()=>{ called = true; });
|
|
21
|
+
expect(called, 'should call next');
|
|
22
|
+
expect(req2.user && req2.user.authenticated, 'user attached');
|
|
23
|
+
|
|
24
|
+
const res3 = createMockRes();
|
|
25
|
+
await authMiddleware(createMockReq({url:'/public/file'}), res3, async ()=>{});
|
|
26
|
+
expect(res3.isEnded() === false, 'public should not end');
|
|
27
|
+
});
|
|
28
|
+
pass('auth middleware');
|
|
29
|
+
} catch(e){ fail(e.message); }
|
|
30
|
+
}
|
|
31
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import findFile from '../findFile.js';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const toAbs = (root, p) => path.join(root, p);
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
'exact match returns file': async ({pass, fail, log}) => {
|
|
8
|
+
try {
|
|
9
|
+
const root = path.join(process.cwd(), 'tmp-root');
|
|
10
|
+
const files = [toAbs(root, 'a/b/GET.js')];
|
|
11
|
+
const [file, params] = await findFile(files, root, '/a/b/GET.js', 'GET', log);
|
|
12
|
+
if(file !== files[0]) throw new Error('not exact');
|
|
13
|
+
if(Object.keys(params).length !== 0) throw new Error('params present');
|
|
14
|
+
pass('exact');
|
|
15
|
+
} catch(e){ fail(e.message); }
|
|
16
|
+
},
|
|
17
|
+
'directory index prioritization and method specific': async ({pass, fail, log}) => {
|
|
18
|
+
try {
|
|
19
|
+
const root = path.join(process.cwd(), 'tmp-root');
|
|
20
|
+
const files = ['a/index.html', 'a/GET.js', 'a/index.js'].map(p => toAbs(root, p));
|
|
21
|
+
const [file] = await findFile(files, root, '/a', 'GET', log);
|
|
22
|
+
if(!file || path.basename(file) !== 'GET.js') throw new Error('priority not respected');
|
|
23
|
+
pass('dir index');
|
|
24
|
+
} catch(e){ fail(e.message); }
|
|
25
|
+
},
|
|
26
|
+
'dynamic match with params and best priority': async ({pass, fail, log}) => {
|
|
27
|
+
try {
|
|
28
|
+
const root = path.join(process.cwd(), 'tmp-root');
|
|
29
|
+
const files = ['user/[id]/GET.js', 'user/[id]/index.html', 'user/[id]/index.js'].map(p => toAbs(root, p));
|
|
30
|
+
const [file, params] = await findFile(files, root, '/user/42', 'GET', log);
|
|
31
|
+
if(!file || path.basename(file) !== 'GET.js') throw new Error('did not pick GET.js');
|
|
32
|
+
if(params.id !== '42') throw new Error('param missing');
|
|
33
|
+
pass('dynamic');
|
|
34
|
+
} catch(e){ fail(e.message); }
|
|
35
|
+
},
|
|
36
|
+
'no match returns false and empty params': async ({pass, fail, log}) => {
|
|
37
|
+
try {
|
|
38
|
+
const root = path.join(process.cwd(), 'tmp-root');
|
|
39
|
+
const files = ['x/y/index.html'].map(p => toAbs(root, p));
|
|
40
|
+
const [file, params] = await findFile(files, root, '/nope', 'GET', log);
|
|
41
|
+
if(file !== false) throw new Error('should be false');
|
|
42
|
+
if(Object.keys(params).length !== 0) throw new Error('params not empty');
|
|
43
|
+
pass('no match');
|
|
44
|
+
} catch(e){ fail(e.message); }
|
|
45
|
+
}
|
|
46
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import getFiles from '../getFiles.js';
|
|
2
|
+
import defaultConfig from '../defaultConfig.js';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import {withTempDir, write, expect, log} from './test-utils.js';
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
'scans directories recursively and filters by mime and disallowed': async ({pass, fail}) => {
|
|
8
|
+
try {
|
|
9
|
+
await withTempDir(async (dir) => {
|
|
10
|
+
const cfg = JSON.parse(JSON.stringify(defaultConfig));
|
|
11
|
+
await write(dir, 'index.html', '<!doctype html>');
|
|
12
|
+
await write(dir, '.env', 'SECRET=1');
|
|
13
|
+
await write(dir, 'notes.xyz', 'unknown');
|
|
14
|
+
await write(dir, 'sub/app.js', 'console.log(1)');
|
|
15
|
+
const files = await getFiles(dir, cfg, log);
|
|
16
|
+
const rel = files.map(f => path.relative(dir, f).replace(/\\/g, '/'));
|
|
17
|
+
expect(rel.includes('index.html'), 'includes html');
|
|
18
|
+
expect(rel.includes('sub/app.js'), 'includes js');
|
|
19
|
+
expect(!rel.includes('.env'), 'excludes disallowed');
|
|
20
|
+
expect(!rel.includes('notes.xyz'), 'excludes unknown ext');
|
|
21
|
+
});
|
|
22
|
+
pass('scan and filter');
|
|
23
|
+
} catch(e){ fail(e.message); }
|
|
24
|
+
}
|
|
25
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import getFlags from '../getFlags.js';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
'parses long flags with values and booleans': async ({pass, fail}) => {
|
|
5
|
+
const args = ['--port', '8080', '--scan'];
|
|
6
|
+
const flags = getFlags(args, {port: 3000, scan: false});
|
|
7
|
+
try {
|
|
8
|
+
if(flags.port !== '8080') throw new Error('port not parsed');
|
|
9
|
+
if(flags.scan !== true) throw new Error('scan boolean not parsed');
|
|
10
|
+
pass('parsed long flags');
|
|
11
|
+
} catch(e){ fail(e.message); }
|
|
12
|
+
},
|
|
13
|
+
'parses short flags using map and preserves defaults': async ({pass, fail}) => {
|
|
14
|
+
const args = ['-p', '9090', '-s'];
|
|
15
|
+
const flags = getFlags(args, {port: 3000, scan: false}, {p: 'port', s: 'scan'});
|
|
16
|
+
try {
|
|
17
|
+
if(flags.port !== '9090') throw new Error('short mapped value failed');
|
|
18
|
+
if(flags.scan !== true) throw new Error('short mapped boolean failed');
|
|
19
|
+
pass('short flags parsed');
|
|
20
|
+
} catch(e){ fail(e.message); }
|
|
21
|
+
},
|
|
22
|
+
'treats next arg starting with dash as boolean flag': async ({pass, fail}) => {
|
|
23
|
+
const args = ['-l', '-5', 'file'];
|
|
24
|
+
const flags = getFlags(args, {l: 2});
|
|
25
|
+
try {
|
|
26
|
+
if(flags.l !== true) throw new Error('should be boolean true');
|
|
27
|
+
pass('dash after flag -> boolean');
|
|
28
|
+
} catch(e){ fail(e.message); }
|
|
29
|
+
}
|
|
30
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {startNode, expect, randomPort, httpGet, withTempDir, write} from './test-utils.js';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
'index.js CLI starts server and serves root': async ({pass, fail}) => {
|
|
6
|
+
try {
|
|
7
|
+
await withTempDir(async (dir) => {
|
|
8
|
+
await write(dir, 'index.html', 'home');
|
|
9
|
+
const port = randomPort();
|
|
10
|
+
const args = [path.join(process.cwd(), 'index.js'), '-r', '.', '-p', String(port), '-l', '0'];
|
|
11
|
+
const child = await startNode(args, {cwd: dir});
|
|
12
|
+
// wait briefly for server to start
|
|
13
|
+
await new Promise(r => setTimeout(r, 400));
|
|
14
|
+
const {res, body} = await httpGet(`http://localhost:${port}/index.html`);
|
|
15
|
+
expect(res.statusCode === 200, 'server running');
|
|
16
|
+
expect(body.toString() === 'home', 'served');
|
|
17
|
+
child.kill();
|
|
18
|
+
await new Promise(r => setTimeout(r, 50));
|
|
19
|
+
});
|
|
20
|
+
pass('cli');
|
|
21
|
+
} catch(e){ fail(e.message); }
|
|
22
|
+
}
|
|
23
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import MiddlewareRunner from '../middlewareRunner.js';
|
|
2
|
+
import {createMockReq, createMockRes, expect} from './test-utils.js';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
'runs middleware in order and calls finalHandler': async ({pass, fail}) => {
|
|
6
|
+
try {
|
|
7
|
+
const mr = new MiddlewareRunner();
|
|
8
|
+
const calls = [];
|
|
9
|
+
mr.use(async (_req, _res, next) => { calls.push('a'); await next(); calls.push('a:after'); });
|
|
10
|
+
mr.use(async (_req, _res, next) => { calls.push('b'); await next(); calls.push('b:after'); });
|
|
11
|
+
const req = createMockReq();
|
|
12
|
+
const res = createMockRes();
|
|
13
|
+
await mr.run(req, res, async () => { calls.push('final'); });
|
|
14
|
+
expect(calls.join(',') === 'a,b,final,b:after,a:after', 'order incorrect');
|
|
15
|
+
pass('middleware order');
|
|
16
|
+
} catch(e){ fail(e.message); }
|
|
17
|
+
}
|
|
18
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {createMockReq, expect} from './test-utils.js';
|
|
2
|
+
import createRequestWrapper from '../requestWrapper.js';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
'parses query and path and provides params': async ({pass, fail}) => {
|
|
6
|
+
try {
|
|
7
|
+
const req = createMockReq({url: '/user/123?x=1&y=2', headers: {host: 'localhost'}});
|
|
8
|
+
const wrapped = createRequestWrapper(req, {id: '123'});
|
|
9
|
+
expect(wrapped.path === '/user/123', 'path');
|
|
10
|
+
expect(wrapped.query.x === '1' && wrapped.query.y === '2', 'query');
|
|
11
|
+
expect(wrapped.params.id === '123', 'params');
|
|
12
|
+
pass('parsed url');
|
|
13
|
+
} catch(e){ fail(e.message); }
|
|
14
|
+
},
|
|
15
|
+
'body/json/text/buffer helpers work': async ({pass, fail}) => {
|
|
16
|
+
try {
|
|
17
|
+
const payload = {a: 1};
|
|
18
|
+
// Each body reader must have its own stream instance
|
|
19
|
+
const reqText = createMockReq({method: 'POST', url: '/', headers: {host: 'x', 'content-type': 'application/json'}, body: JSON.stringify(payload)});
|
|
20
|
+
const text = await createRequestWrapper(reqText).text();
|
|
21
|
+
expect(text === JSON.stringify(payload), 'text');
|
|
22
|
+
|
|
23
|
+
const reqJson = createMockReq({method: 'POST', url: '/', headers: {host: 'x', 'content-type': 'application/json'}, body: JSON.stringify(payload)});
|
|
24
|
+
const obj = await createRequestWrapper(reqJson).json();
|
|
25
|
+
expect(obj.a === 1, 'json');
|
|
26
|
+
|
|
27
|
+
const reqBuf = createMockReq({url: '/', headers: {host: 'x'}, body: 'abc'});
|
|
28
|
+
const buf = await createRequestWrapper(reqBuf).buffer();
|
|
29
|
+
expect(Buffer.isBuffer(buf) && buf.toString() === 'abc', 'buffer');
|
|
30
|
+
pass('helpers');
|
|
31
|
+
} catch(e){ fail(e.message); }
|
|
32
|
+
},
|
|
33
|
+
'invalid json throws': async ({pass, fail}) => {
|
|
34
|
+
const req = createMockReq({url: '/', headers: {host: 'x'}, body: 'not json'});
|
|
35
|
+
try {
|
|
36
|
+
await createRequestWrapper(req).json();
|
|
37
|
+
fail('should throw');
|
|
38
|
+
} catch(e){
|
|
39
|
+
pass('threw');
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
'get and is helpers': async ({pass, fail}) => {
|
|
43
|
+
try {
|
|
44
|
+
const req = createMockReq({url: '/', headers: {'content-type': 'text/plain', host: 'x'}});
|
|
45
|
+
const w = createRequestWrapper(req);
|
|
46
|
+
expect(w.get('content-type') === 'text/plain', 'get');
|
|
47
|
+
expect(w.is('text/plain') === true, 'is');
|
|
48
|
+
pass('header helpers');
|
|
49
|
+
} catch(e){ fail(e.message); }
|
|
50
|
+
}
|
|
51
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {createMockRes, expect, parseCookies} from './test-utils.js';
|
|
2
|
+
import createResponseWrapper from '../responseWrapper.js';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
'status and set/get headers and type': async ({pass, fail}) => {
|
|
6
|
+
try {
|
|
7
|
+
const res = createMockRes();
|
|
8
|
+
const w = createResponseWrapper(res);
|
|
9
|
+
w.status(201).set('X-Test', '1').type('json');
|
|
10
|
+
expect(res.statusCode === 201, 'status');
|
|
11
|
+
expect(res.getHeader('X-Test') === '1', 'set/get');
|
|
12
|
+
expect(res.getHeader('Content-Type') === 'application/json', 'type');
|
|
13
|
+
pass('status+headers+type');
|
|
14
|
+
} catch(e){ fail(e.message); }
|
|
15
|
+
},
|
|
16
|
+
'json sends and prevents further changes': async ({pass, fail}) => {
|
|
17
|
+
try {
|
|
18
|
+
const res = createMockRes();
|
|
19
|
+
const w = createResponseWrapper(res);
|
|
20
|
+
w.json({a: 1});
|
|
21
|
+
expect(res.isEnded(), 'ended');
|
|
22
|
+
try { w.set('X', 'y'); fail('should not set after send'); } catch(_){ /* ok */ }
|
|
23
|
+
pass('json');
|
|
24
|
+
} catch(e){ fail(e.message); }
|
|
25
|
+
},
|
|
26
|
+
'send handles string, object, buffer and null': async ({pass, fail}) => {
|
|
27
|
+
try {
|
|
28
|
+
const res1 = createMockRes();
|
|
29
|
+
createResponseWrapper(res1).send('hello');
|
|
30
|
+
// Content-Type defaults to text/html for string when not set
|
|
31
|
+
expect(res1.getHeader('Content-Type') === 'text/html', 'string content-type');
|
|
32
|
+
expect(res1.getBody().toString() === 'hello', 'string body');
|
|
33
|
+
|
|
34
|
+
const res2 = createMockRes();
|
|
35
|
+
createResponseWrapper(res2).send({a:1});
|
|
36
|
+
expect(res2.getHeader('Content-Type') === 'application/json', 'object content-type');
|
|
37
|
+
|
|
38
|
+
const res3 = createMockRes();
|
|
39
|
+
const buf = Buffer.from('abc');
|
|
40
|
+
createResponseWrapper(res3).send(buf);
|
|
41
|
+
const body3 = res3.getBody().toString();
|
|
42
|
+
expect(body3.includes('"data"'), 'buffer equal');
|
|
43
|
+
|
|
44
|
+
const res4 = createMockRes();
|
|
45
|
+
createResponseWrapper(res4).send(null);
|
|
46
|
+
expect(res4.isEnded(), 'null ended');
|
|
47
|
+
pass('send variants');
|
|
48
|
+
} catch(e){ fail(e.message); }
|
|
49
|
+
},
|
|
50
|
+
'html and text helpers': async ({pass, fail}) => {
|
|
51
|
+
try {
|
|
52
|
+
const r1 = createMockRes();
|
|
53
|
+
createResponseWrapper(r1).html('<h1>Ok</h1>');
|
|
54
|
+
expect(r1.getHeader('Content-Type') === 'text/html', 'html type');
|
|
55
|
+
|
|
56
|
+
const r2 = createMockRes();
|
|
57
|
+
createResponseWrapper(r2).text('plain');
|
|
58
|
+
expect(r2.getHeader('Content-Type') === 'text/plain', 'text type');
|
|
59
|
+
pass('helpers');
|
|
60
|
+
} catch(e){ fail(e.message); }
|
|
61
|
+
},
|
|
62
|
+
'redirect and cookies': async ({pass, fail}) => {
|
|
63
|
+
try {
|
|
64
|
+
const r = createMockRes();
|
|
65
|
+
const w = createResponseWrapper(r);
|
|
66
|
+
w.cookie('a', 'b', {httpOnly: true, path: '/'});
|
|
67
|
+
const cookies = parseCookies(r.getHeader('Set-Cookie'));
|
|
68
|
+
expect(cookies.length === 1 && cookies[0].includes('a=b'), 'cookie added');
|
|
69
|
+
w.redirect('/next', 301);
|
|
70
|
+
expect(r.statusCode === 301 && r.getHeader('Location') === '/next', 'redirect');
|
|
71
|
+
pass('redirect+cookie');
|
|
72
|
+
} catch(e){ fail(e.message); }
|
|
73
|
+
}
|
|
74
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import {withTempDir, write, expect, randomPort} from './test-utils.js';
|
|
3
|
+
import router from '../router.js';
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
'built-in middleware can be configured on router': async ({pass, fail}) => {
|
|
7
|
+
try {
|
|
8
|
+
await withTempDir(async (dir) => {
|
|
9
|
+
await write(dir, '.config.json', JSON.stringify({
|
|
10
|
+
middleware: {
|
|
11
|
+
cors: {enabled: true, origin: '*', methods: ['GET'], headers: ['X']},
|
|
12
|
+
compression: {enabled: true, threshold: 1},
|
|
13
|
+
rateLimit: {enabled: true, maxRequests: 1, windowMs: 1000, message: 'Too many'},
|
|
14
|
+
security: {enabled: true, headers: {'X-Test':'1'}},
|
|
15
|
+
logging: {enabled: true, includeUserAgent: false, includeResponseTime: true}
|
|
16
|
+
}
|
|
17
|
+
}));
|
|
18
|
+
await write(dir, 'index.html', 'hello world');
|
|
19
|
+
const prev = process.cwd();
|
|
20
|
+
process.chdir(dir);
|
|
21
|
+
const flags = {root: '.', logging: 0, scan: false};
|
|
22
|
+
const handler = await router(flags, () => {});
|
|
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
|
+
try {
|
|
28
|
+
const {get} = await import('http');
|
|
29
|
+
const one = await new Promise((res)=>{
|
|
30
|
+
get(`http://localhost:${port}/index.html`, {headers: {'accept-encoding': 'gzip'}}, r =>{
|
|
31
|
+
const chunks = []; r.on('data', c => chunks.push(c)); r.on('end', ()=> res({r, body: Buffer.concat(chunks)}));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
expect(one.r.statusCode === 200, 'first ok');
|
|
35
|
+
const two = await new Promise((res)=>{
|
|
36
|
+
get(`http://localhost:${port}/index.html`, r =>{
|
|
37
|
+
const chunks = []; r.on('data', c => chunks.push(c)); r.on('end', ()=> res({r, body: Buffer.concat(chunks)}));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
expect(two.r.statusCode === 429, 'rate limited');
|
|
41
|
+
} finally { server.close(); process.chdir(prev); }
|
|
42
|
+
});
|
|
43
|
+
pass('router middleware');
|
|
44
|
+
} catch(e){ fail(e.message); }
|
|
45
|
+
}
|
|
46
|
+
};
|