mockaton 13.9.7 → 13.9.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -24
- package/package.json +5 -5
- package/{www/src/.well-known/agent-skills/mockaton/SKILLS.md → skills/mockaton/SKILL.md} +18 -23
- package/src/client/Filename.js +0 -4
- package/src/client/app.css +36 -12
- package/src/server/Api.js +4 -3
- package/src/server/MockBroker.js +2 -1
- package/src/server/MockDispatcherPlugins.js +2 -1
- package/src/server/Mockaton.js +1 -1
- package/src/server/ProxyRelay.js +2 -2
- package/src/server/UrlParsers.js +2 -2
- package/src/server/cli.js +2 -2
- package/src/server/utils/HttpIncomingMessage.js +26 -31
- package/src/server/utils/HttpServerResponse.js +55 -37
- package/src/server/utils/HttpServerResponse.test.js +72 -55
- package/src/server/utils/fs.js +1 -1
- package/src/server/utils/fs.test.js +2 -2
package/README.md
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<!-- SKILLS_IGNORE_BEGIN -->
|
|
2
|
+
<img src="logo.svg" alt="Mockaton Logo" width="180" style="margin-top: 30px"/>
|
|
3
|
+
|
|
2
4
|

|
|
3
5
|
[](https://github.com/ericfortis/mockaton/actions/workflows/test.yml)
|
|
4
6
|
[](https://codecov.io/github/ericfortis/mockaton)
|
|
@@ -37,26 +39,25 @@ Dashboard: [localhost:2020/mockaton](http://localhost:2020/mockaton)
|
|
|
37
39
|
npx mockaton --port 2020 my-mocks-dir/
|
|
38
40
|
```
|
|
39
41
|
|
|
40
|
-
Mockaton will serve the files on the given directory. It's a file-system based router, so
|
|
41
|
-
parameters.
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
Mockaton will serve the files on the given directory. It's a file-system based router, so
|
|
43
|
+
filenames can have dynamic parameters. Also, filenames can have comments, which are
|
|
44
|
+
anything within parentheses, this way each route can have different mock file variants.
|
|
45
|
+
Similarly, each route can have different response status code variants.
|
|
44
46
|
|
|
45
47
|
|
|
46
48
|
| Route | Filename | Description |
|
|
47
49
|
| -----| -----| ---|
|
|
48
50
|
| /api/company/123 | api/company/[id].GET.200.ts | `[id]` is a dynamic parameter. `.ts`, and `.js` are sent as JSON by default |
|
|
49
51
|
| /media/avatar.png | media/avatar.png | Statics assets don't need the above extension |
|
|
50
|
-
| /api/login | api/login(invalid attempt).POST.401.ts | Anything within parenthesis is a
|
|
52
|
+
| /api/login | api/login(invalid attempt).POST.401.ts | **Anything within parenthesis is a comment**, they are ignored when routing |
|
|
51
53
|
| /api/login | api/login(default).GET.200.ts | `(default)` is a special comment; otherwise, the first mock variant in alphabetical order wins |
|
|
52
54
|
| /api/login | api/login(locked out user).POST.423.json | `.json` is allowed too |
|
|
53
55
|
|
|
54
56
|
|
|
55
57
|
## Docs
|
|
56
|
-
-
|
|
57
|
-
-
|
|
58
|
+
- [Configuration: CLI and mockaton.config.js](https://mockaton.com/config).
|
|
59
|
+
- [Programmatic API](https://mockaton.com/api), in which
|
|
58
60
|
you can delay a route, select a different mock file, such as a 500 error, among other options.
|
|
59
|
-
- How to **add plugins**? You can write [Plugins](https://mockaton.com/plugins) for customizing responses.
|
|
60
61
|
|
|
61
62
|
<!-- SKILLS_IGNORE_BEGIN -->
|
|
62
63
|
## How to scrape your backend APIs?
|
|
@@ -70,37 +71,33 @@ npx skills add ericfortis/mockaton
|
|
|
70
71
|
```
|
|
71
72
|
<!-- SKILLS_IGNORE_END -->
|
|
72
73
|
|
|
73
|
-
##
|
|
74
|
-
|
|
74
|
+
## Installation ([more options ↗](https://mockaton.com/installation))
|
|
75
75
|
```sh
|
|
76
76
|
npm install mockaton
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
## How to create mocks?
|
|
80
|
+
Write it to your mocks directory. `.ts` files are served as JSON by default.
|
|
80
81
|
```sh
|
|
81
82
|
mkdir -p my-mocks-dir/api
|
|
82
|
-
|
|
83
|
-
interface User {
|
|
84
|
-
name: string
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export default {
|
|
88
|
-
"name": "John"
|
|
89
|
-
} satisfies User
|
|
90
|
-
EOF
|
|
83
|
+
echo "export default { name: 'John' }" > my-mocks-dir/api/user.GET.200.ts
|
|
91
84
|
```
|
|
92
85
|
|
|
93
86
|
### Example A: JSON
|
|
94
|
-
For JSON responses, use TypeScript (or
|
|
87
|
+
For JSON responses, use TypeScript (or JS), and `export default` an Object, Array, or
|
|
88
|
+
String.
|
|
95
89
|
|
|
96
90
|
- **Route:** /api/company/123
|
|
97
91
|
- **Filename:** api/company/[id].GET.200.ts
|
|
98
92
|
|
|
99
93
|
```ts
|
|
94
|
+
interface Company {
|
|
95
|
+
name: string
|
|
96
|
+
}
|
|
97
|
+
|
|
100
98
|
export default {
|
|
101
|
-
id: 123,
|
|
102
99
|
name: 'Acme, Inc.'
|
|
103
|
-
}
|
|
100
|
+
} satisfies Company
|
|
104
101
|
```
|
|
105
102
|
|
|
106
103
|
### Example B: Non-JSON
|
|
@@ -109,7 +106,6 @@ export default {
|
|
|
109
106
|
|
|
110
107
|
```xml
|
|
111
108
|
<company>
|
|
112
|
-
<id>123</id>
|
|
113
109
|
<name>Acme, Inc.</name>
|
|
114
110
|
</company>
|
|
115
111
|
```
|
package/package.json
CHANGED
|
@@ -2,23 +2,23 @@
|
|
|
2
2
|
"name": "mockaton",
|
|
3
3
|
"description": "HTTP Mock Server",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "13.9.
|
|
5
|
+
"version": "13.9.9",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
8
8
|
"import": "./index.js",
|
|
9
9
|
"types": "./index.d.ts"
|
|
10
10
|
},
|
|
11
11
|
"./vite": "./vite-plugin/index.js",
|
|
12
|
-
"./
|
|
13
|
-
"./
|
|
12
|
+
"./SKILL.md": "./skills/mockaton/SKILL.md",
|
|
13
|
+
"./openapi.json": "./www/src/assets/openapi.json"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"src",
|
|
17
17
|
"index.js",
|
|
18
18
|
"index.d.ts",
|
|
19
19
|
"vite-plugin",
|
|
20
|
-
"
|
|
21
|
-
"www/src
|
|
20
|
+
"skills/mockaton/SKILL.md",
|
|
21
|
+
"www/src/assets/openapi.json"
|
|
22
22
|
],
|
|
23
23
|
"license": "MIT",
|
|
24
24
|
"homepage": "https://mockaton.com",
|
|
@@ -8,59 +8,55 @@ user-invocable: false
|
|
|
8
8
|
npx mockaton --port 2020 my-mocks-dir/
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Mockaton will serve the files on the given directory. It's a file-system
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
Mockaton will serve the files on the given directory. It's a file-system based router, so
|
|
12
|
+
filenames can have dynamic parameters. Also, filenames can have comments, which are
|
|
13
|
+
anything within parentheses, this way each route can have different mock file variants.
|
|
14
|
+
Similarly, each route can have different response status code variants.
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
| Route | Filename | Description |
|
|
17
18
|
| -----| -----| ---|
|
|
18
19
|
| /api/company/123 | api/company/[id].GET.200.ts | `[id]` is a dynamic parameter. `.ts`, and `.js` are sent as JSON by default |
|
|
19
20
|
| /media/avatar.png | media/avatar.png | Statics assets don't need the above extension |
|
|
20
|
-
| /api/login | api/login(invalid attempt).POST.401.ts | Anything within parenthesis is a
|
|
21
|
+
| /api/login | api/login(invalid attempt).POST.401.ts | **Anything within parenthesis is a comment**, they are ignored when routing |
|
|
21
22
|
| /api/login | api/login(default).GET.200.ts | `(default)` is a special comment; otherwise, the first mock variant in alphabetical order wins |
|
|
22
23
|
| /api/login | api/login(locked out user).POST.423.json | `.json` is allowed too |
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
## Docs
|
|
26
|
-
-
|
|
27
|
-
-
|
|
27
|
+
- [Configuration: CLI and mockaton.config.js](https://mockaton.com/config).
|
|
28
|
+
- [Programmatic API](https://mockaton.com/api), in which
|
|
28
29
|
you can delay a route, select a different mock file, such as a 500 error, among other options.
|
|
29
|
-
- How to **add plugins**? You can write [Plugins](https://mockaton.com/plugins) for customizing responses.
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
##
|
|
34
|
-
|
|
33
|
+
## Installation ([more options ↗](https://mockaton.com/installation))
|
|
35
34
|
```sh
|
|
36
35
|
npm install mockaton
|
|
37
36
|
```
|
|
38
37
|
|
|
39
|
-
|
|
38
|
+
## How to create mocks?
|
|
39
|
+
Write it to your mocks directory. `.ts` files are served as JSON by default.
|
|
40
40
|
```sh
|
|
41
41
|
mkdir -p my-mocks-dir/api
|
|
42
|
-
|
|
43
|
-
interface User {
|
|
44
|
-
name: string
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export default {
|
|
48
|
-
"name": "John"
|
|
49
|
-
} satisfies User
|
|
50
|
-
EOF
|
|
42
|
+
echo "export default { name: 'John' }" > my-mocks-dir/api/user.GET.200.ts
|
|
51
43
|
```
|
|
52
44
|
|
|
53
45
|
### Example A: JSON
|
|
54
|
-
For JSON responses, use TypeScript (or
|
|
46
|
+
For JSON responses, use TypeScript (or JS), and `export default` an Object, Array, or
|
|
47
|
+
String.
|
|
55
48
|
|
|
56
49
|
- **Route:** /api/company/123
|
|
57
50
|
- **Filename:** api/company/[id].GET.200.ts
|
|
58
51
|
|
|
59
52
|
```ts
|
|
53
|
+
interface Company {
|
|
54
|
+
name: string
|
|
55
|
+
}
|
|
56
|
+
|
|
60
57
|
export default {
|
|
61
|
-
id: 123,
|
|
62
58
|
name: 'Acme, Inc.'
|
|
63
|
-
}
|
|
59
|
+
} satisfies Company
|
|
64
60
|
```
|
|
65
61
|
|
|
66
62
|
### Example B: Non-JSON
|
|
@@ -69,7 +65,6 @@ export default {
|
|
|
69
65
|
|
|
70
66
|
```xml
|
|
71
67
|
<company>
|
|
72
|
-
<id>123</id>
|
|
73
68
|
<name>Acme, Inc.</name>
|
|
74
69
|
</company>
|
|
75
70
|
```
|
package/src/client/Filename.js
CHANGED
|
@@ -50,10 +50,6 @@ export function removeTrailingSlash(url = '') {
|
|
|
50
50
|
.replace('/#', '#')
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export function removeQueryStringAndFragment(url = '') {
|
|
54
|
-
return new URL(url, 'http://_').pathname
|
|
55
|
-
}
|
|
56
|
-
|
|
57
53
|
function responseStatusIsValid(status) {
|
|
58
54
|
return Number.isInteger(status)
|
|
59
55
|
&& status >= 100
|
package/src/client/app.css
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
color-scheme: light dark;
|
|
3
3
|
--colorBg: light-dark(#fff, #181818);
|
|
4
4
|
--colorBgField: light-dark(#fcfcfc, #2c2c2c);
|
|
5
|
+
--colorBgFieldHover: light-dark(#fff, #171717);
|
|
5
6
|
|
|
6
|
-
--colorBgHeader: light-dark(#
|
|
7
|
-
--colorBgHeaderField: light-dark(#
|
|
7
|
+
--colorBgHeader: light-dark(#f0f0f2, #141414);
|
|
8
|
+
--colorBgHeaderField: light-dark(#fafafa, #222);
|
|
8
9
|
|
|
9
10
|
--colorBorder: light-dark(#e0e0e0, #2c2c2c);
|
|
10
11
|
--colorBorderActive: light-dark(#c8c8c8, #3c3c3c);
|
|
@@ -24,6 +25,10 @@
|
|
|
24
25
|
accent-color: var(--colorAccent);
|
|
25
26
|
--radius: 16px;
|
|
26
27
|
--subtoolbarHeight: 42px;
|
|
28
|
+
|
|
29
|
+
--shadowRim: light-dark(#fff, #333);
|
|
30
|
+
--shadowDrop: light-dark(rgba(0, 0, 0, 0.2), #000);
|
|
31
|
+
--boxShadowRimDrop: inset 0 0 1px 1px var(--shadowRim), 0 1px 2px var(--shadowDrop);
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
html,
|
|
@@ -45,7 +50,7 @@ body {
|
|
|
45
50
|
padding: 0;
|
|
46
51
|
border: 0;
|
|
47
52
|
margin: 0;
|
|
48
|
-
letter-spacing: -0.
|
|
53
|
+
letter-spacing: -0.2px;
|
|
49
54
|
line-height: 14px;
|
|
50
55
|
font-family: inherit;
|
|
51
56
|
font-size: 100%;
|
|
@@ -86,7 +91,6 @@ a {
|
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
select {
|
|
89
|
-
border: 1px solid transparent;
|
|
90
94
|
color: var(--colorText);
|
|
91
95
|
border-radius: var(--radius);
|
|
92
96
|
appearance: none;
|
|
@@ -101,7 +105,7 @@ select {
|
|
|
101
105
|
|
|
102
106
|
&:hover {
|
|
103
107
|
border-color: var(--colorHover);
|
|
104
|
-
background-color: var(--
|
|
108
|
+
background-color: var(--colorBgFieldHover);
|
|
105
109
|
}
|
|
106
110
|
}
|
|
107
111
|
|
|
@@ -174,6 +178,7 @@ header {
|
|
|
174
178
|
background: transparent;
|
|
175
179
|
border-radius: 50px;
|
|
176
180
|
color: var(--colorRed);
|
|
181
|
+
box-shadow: var(--boxShadowRimDrop);
|
|
177
182
|
|
|
178
183
|
@media (prefers-color-scheme: dark) {
|
|
179
184
|
color: var(--colorText);
|
|
@@ -186,12 +191,13 @@ header {
|
|
|
186
191
|
}
|
|
187
192
|
|
|
188
193
|
.HelpLink {
|
|
189
|
-
width:
|
|
190
|
-
height:
|
|
194
|
+
width: 24px;
|
|
195
|
+
height: 24px;
|
|
191
196
|
flex-shrink: 0;
|
|
192
197
|
align-self: end;
|
|
193
198
|
margin-bottom: 3px;
|
|
194
199
|
margin-left: auto;
|
|
200
|
+
box-shadow: var(--boxShadowRimDrop);
|
|
195
201
|
opacity: 0.8;
|
|
196
202
|
border-radius: 50%;
|
|
197
203
|
fill: var(--colorBgHeader);
|
|
@@ -237,12 +243,17 @@ header {
|
|
|
237
243
|
width: 100%;
|
|
238
244
|
height: 28px;
|
|
239
245
|
padding: 4px 8px;
|
|
240
|
-
border: 1px solid var(--colorBorder);
|
|
241
246
|
margin-top: 2px;
|
|
242
247
|
color: var(--colorText);
|
|
243
248
|
font-size: 11px;
|
|
244
249
|
background-color: var(--colorBgHeaderField);
|
|
245
250
|
border-radius: var(--radius);
|
|
251
|
+
box-shadow: var(--boxShadowRimDrop);
|
|
252
|
+
transition: background-color ease-in-out 120ms;
|
|
253
|
+
|
|
254
|
+
&:hover {
|
|
255
|
+
background-color: var(--colorBgFieldHover);
|
|
256
|
+
}
|
|
246
257
|
}
|
|
247
258
|
|
|
248
259
|
&.GlobalDelay {
|
|
@@ -279,7 +290,6 @@ header {
|
|
|
279
290
|
margin-left: 4px;
|
|
280
291
|
}
|
|
281
292
|
input {
|
|
282
|
-
border-left: 2px solid transparent;
|
|
283
293
|
border-bottom-left-radius: 0;
|
|
284
294
|
border-top-left-radius: 0;
|
|
285
295
|
}
|
|
@@ -412,7 +422,6 @@ main {
|
|
|
412
422
|
select {
|
|
413
423
|
width: 110px;
|
|
414
424
|
padding: 6px 8px;
|
|
415
|
-
border: 1px solid var(--colorBorder);
|
|
416
425
|
margin-left: 4px;
|
|
417
426
|
background-image: none;
|
|
418
427
|
text-align-last: center;
|
|
@@ -420,6 +429,12 @@ main {
|
|
|
420
429
|
font-size: 11px;
|
|
421
430
|
background-color: var(--colorBgHeaderField);
|
|
422
431
|
border-radius: var(--radius);
|
|
432
|
+
box-shadow: var(--boxShadowRimDrop);
|
|
433
|
+
transition: background-color ease-in-out 120ms;
|
|
434
|
+
|
|
435
|
+
&:hover {
|
|
436
|
+
background-color: var(--colorBgFieldHover);
|
|
437
|
+
}
|
|
423
438
|
}
|
|
424
439
|
}
|
|
425
440
|
|
|
@@ -602,6 +617,13 @@ main {
|
|
|
602
617
|
text-overflow: ellipsis;
|
|
603
618
|
text-align: right;
|
|
604
619
|
|
|
620
|
+
&:enabled {
|
|
621
|
+
box-shadow: var(--boxShadowRimDrop);
|
|
622
|
+
&:hover {
|
|
623
|
+
background-color: var(--colorBgFieldHover);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
605
627
|
&.nonDefault {
|
|
606
628
|
font-weight: bold;
|
|
607
629
|
font-size: 11px;
|
|
@@ -665,11 +687,12 @@ main {
|
|
|
665
687
|
height: 22px;
|
|
666
688
|
align-items: center;
|
|
667
689
|
justify-content: center;
|
|
668
|
-
border: 1px solid var(--colorBorder);
|
|
669
690
|
fill: none;
|
|
670
691
|
stroke: var(--colorLabel);
|
|
671
692
|
stroke-width: 2.5px;
|
|
672
693
|
border-radius: 50%;
|
|
694
|
+
box-shadow: var(--boxShadowRimDrop);
|
|
695
|
+
background-color: var(--colorBgHeaderField);
|
|
673
696
|
}
|
|
674
697
|
|
|
675
698
|
&.canProxy {
|
|
@@ -685,7 +708,7 @@ main {
|
|
|
685
708
|
|
|
686
709
|
.checkboxBody {
|
|
687
710
|
svg {
|
|
688
|
-
width:
|
|
711
|
+
width: 15px;
|
|
689
712
|
}
|
|
690
713
|
}
|
|
691
714
|
}
|
|
@@ -736,6 +759,7 @@ main {
|
|
|
736
759
|
}
|
|
737
760
|
|
|
738
761
|
> code {
|
|
762
|
+
line-height: 1.3;
|
|
739
763
|
white-space: pre;
|
|
740
764
|
tab-size: 2;
|
|
741
765
|
color: var(--colorLabel);
|
package/src/server/Api.js
CHANGED
|
@@ -18,6 +18,7 @@ import { IndexHtml, CSP } from '../client/IndexHtml.js'
|
|
|
18
18
|
import { cookie } from './cookie.js'
|
|
19
19
|
import { config, ConfigValidator } from './config.js'
|
|
20
20
|
import * as mockBrokersCollection from './mockBrokersCollection.js'
|
|
21
|
+
import { removeQueryStringAndFragment } from './utils/HttpIncomingMessage.js'
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
export const CLIENT_ASSETS = join(import.meta.dirname, '../client')
|
|
@@ -61,10 +62,10 @@ const patchReqs = new Map([
|
|
|
61
62
|
])
|
|
62
63
|
|
|
63
64
|
export async function handleApiRequest(req, response) {
|
|
64
|
-
const
|
|
65
|
+
const url = removeQueryStringAndFragment(req.url)
|
|
65
66
|
const handler = (
|
|
66
|
-
req.method === 'GET' && getReqs.get(
|
|
67
|
-
req.method === 'PATCH' && patchReqs.get(
|
|
67
|
+
req.method === 'GET' && getReqs.get(url) ||
|
|
68
|
+
req.method === 'PATCH' && patchReqs.get(url))
|
|
68
69
|
if (handler) {
|
|
69
70
|
await handler(req, response)
|
|
70
71
|
return true
|
package/src/server/MockBroker.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_MOCK_COMMENT } from '../client/ApiConstants.js'
|
|
2
|
-
import { parseFilename, includesComment, extractComments
|
|
2
|
+
import { parseFilename, includesComment, extractComments } from '../client/Filename.js'
|
|
3
|
+
import { removeQueryStringAndFragment } from './utils/HttpIncomingMessage.js'
|
|
3
4
|
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { pathToFileURL } from 'node:url'
|
|
3
|
-
|
|
4
3
|
import { mimeFor } from './utils/mime.js'
|
|
5
4
|
|
|
5
|
+
|
|
6
6
|
export function echoFilePlugin(filePath) {
|
|
7
7
|
return {
|
|
8
8
|
mime: mimeFor(filePath),
|
|
@@ -10,6 +10,7 @@ export function echoFilePlugin(filePath) {
|
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
|
|
13
14
|
export async function jsToJsonPlugin(filePath, req, response) {
|
|
14
15
|
const jsExport = (await import(pathToFileURL(filePath))).default
|
|
15
16
|
const body = typeof jsExport === 'function'
|
package/src/server/Mockaton.js
CHANGED
|
@@ -88,7 +88,7 @@ async function onRequest(req, response) {
|
|
|
88
88
|
response.unprocessable(`${error.name}: ${error.message}`)
|
|
89
89
|
else {
|
|
90
90
|
logger.error(500, req.url, error?.message || error, error?.stack || '')
|
|
91
|
-
response.internalServerError(
|
|
91
|
+
response.internalServerError()
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
}
|
package/src/server/ProxyRelay.js
CHANGED
|
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
3
3
|
|
|
4
4
|
import { extFor } from './utils/mime.js'
|
|
5
5
|
import { write, isFile, resolveIn } from './utils/fs.js'
|
|
6
|
-
import {
|
|
6
|
+
import { BodyReaderError } from './utils/HttpIncomingMessage.js'
|
|
7
7
|
|
|
8
8
|
import { config } from './config.js'
|
|
9
9
|
import { logger } from './utils/logger.js'
|
|
@@ -19,7 +19,7 @@ export async function proxy(req, response, delay) {
|
|
|
19
19
|
headers: req.headers,
|
|
20
20
|
body: req.method === 'GET' || req.method === 'HEAD'
|
|
21
21
|
? undefined
|
|
22
|
-
: await
|
|
22
|
+
: await req.body()
|
|
23
23
|
})
|
|
24
24
|
}
|
|
25
25
|
catch (error) { // TESTME
|
package/src/server/UrlParsers.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { relative } from 'node:path'
|
|
2
2
|
import { config } from './config.js'
|
|
3
|
-
import { decode } from './utils/HttpIncomingMessage.js'
|
|
4
|
-
import { parseFilename, removeTrailingSlash
|
|
3
|
+
import { decode, removeQueryStringAndFragment } from './utils/HttpIncomingMessage.js'
|
|
4
|
+
import { parseFilename, removeTrailingSlash } from '../client/Filename.js'
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
export function parseQueryParams(url) {
|
package/src/server/cli.js
CHANGED
|
@@ -12,7 +12,7 @@ import pkgJSON from '../../package.json' with { type: 'json' }
|
|
|
12
12
|
process.on('unhandledRejection', error => { throw error })
|
|
13
13
|
|
|
14
14
|
const DEFAULT_CONFIG_FILE = 'mockaton.config.js'
|
|
15
|
-
const SKILLS_PATH = join(import.meta.dirname, '../../
|
|
15
|
+
const SKILLS_PATH = join(import.meta.dirname, '../../skills/mockaton/SKILL.md')
|
|
16
16
|
|
|
17
17
|
let args, positionals
|
|
18
18
|
try {
|
|
@@ -64,7 +64,7 @@ Options:
|
|
|
64
64
|
--no-open Don't open dashboard in a browser
|
|
65
65
|
--no-read-only Allow writing and deleting mocks via API
|
|
66
66
|
|
|
67
|
-
--skills Show AI agent
|
|
67
|
+
--skills Show AI agent SKILL.md file path
|
|
68
68
|
-h, --help
|
|
69
69
|
-v, --version
|
|
70
70
|
|
|
@@ -3,6 +3,10 @@ import http, { METHODS } from 'node:http'
|
|
|
3
3
|
|
|
4
4
|
export const methodIsSupported = method => METHODS.includes(method)
|
|
5
5
|
|
|
6
|
+
export function removeQueryStringAndFragment(url = '') {
|
|
7
|
+
return new URL(url, 'http://_').pathname
|
|
8
|
+
}
|
|
9
|
+
|
|
6
10
|
export class BodyReaderError extends Error {
|
|
7
11
|
name = 'BodyReaderError'
|
|
8
12
|
constructor(msg) {
|
|
@@ -13,47 +17,39 @@ export class BodyReaderError extends Error {
|
|
|
13
17
|
|
|
14
18
|
export class IncomingMessage extends http.IncomingMessage {
|
|
15
19
|
json() {
|
|
16
|
-
return
|
|
20
|
+
return this.body(JSON.parse)
|
|
17
21
|
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export const parseJSON = req => readBody(req, JSON.parse)
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
return new Promise((resolve, reject) => {
|
|
23
|
+
async body(parser = a => a) {
|
|
24
24
|
const MAX_BODY_SIZE = 200 * 1024
|
|
25
|
-
const expectedLength =
|
|
26
|
-
let lengthSoFar = 0
|
|
27
|
-
const body = []
|
|
28
|
-
req.on('data', onData)
|
|
29
|
-
req.on('end', onEnd)
|
|
30
|
-
req.on('error', onEnd)
|
|
25
|
+
const expectedLength = this.headers['content-length'] | 0
|
|
31
26
|
|
|
32
|
-
|
|
27
|
+
const chunks = []
|
|
28
|
+
let lengthSoFar = 0
|
|
29
|
+
for await (const chunk of this) {
|
|
33
30
|
lengthSoFar += chunk.length
|
|
34
31
|
if (lengthSoFar > MAX_BODY_SIZE)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
body.push(chunk)
|
|
32
|
+
throw new BodyReaderError(`Body too large. Max is ${MAX_BODY_SIZE} bytes`)
|
|
33
|
+
chunks.push(chunk)
|
|
38
34
|
}
|
|
39
35
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
reject(new BodyReaderError('Length mismatch'))
|
|
46
|
-
else
|
|
47
|
-
try {
|
|
48
|
-
resolve(parser(Buffer.concat(body).toString()))
|
|
49
|
-
}
|
|
50
|
-
catch (_) {
|
|
51
|
-
reject(new BodyReaderError('Could not parse'))
|
|
52
|
-
}
|
|
36
|
+
if (lengthSoFar !== expectedLength)
|
|
37
|
+
throw new BodyReaderError('Length mismatch')
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
return parser(Buffer.concat(chunks).toString())
|
|
53
41
|
}
|
|
54
|
-
|
|
42
|
+
catch (_) {
|
|
43
|
+
throw new BodyReaderError('Could not parse')
|
|
44
|
+
}
|
|
45
|
+
}
|
|
55
46
|
}
|
|
56
47
|
|
|
48
|
+
export const parseJSON = req =>
|
|
49
|
+
IncomingMessage.prototype.body.call(req, JSON.parse)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
57
53
|
export const reControlAndDelChars = /[\x00-\x1f\x7f]/
|
|
58
54
|
|
|
59
55
|
export function hasControlChars(url) {
|
|
@@ -72,4 +68,3 @@ export function decode(url) {
|
|
|
72
68
|
? candidate
|
|
73
69
|
: '' // reject multiple encodings
|
|
74
70
|
}
|
|
75
|
-
|
|
@@ -15,6 +15,11 @@ export class ServerResponse extends http.ServerResponse {
|
|
|
15
15
|
this.end()
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
noContent() {
|
|
19
|
+
this.statusCode = 204
|
|
20
|
+
this.end()
|
|
21
|
+
}
|
|
22
|
+
|
|
18
23
|
html(html, csp) {
|
|
19
24
|
this.setHeader('Content-Type', mimeFor('.html'))
|
|
20
25
|
this.setHeader('Content-Security-Policy', csp)
|
|
@@ -27,15 +32,59 @@ export class ServerResponse extends http.ServerResponse {
|
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
async file(file) {
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
try {
|
|
36
|
+
const { size } = await fs.promises.stat(file)
|
|
37
|
+
this.setHeader('Content-Length', size)
|
|
38
|
+
this.setHeader('Content-Type', mimeFor(file))
|
|
39
|
+
await pipeline(fs.createReadStream(file), this)
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
if (this.headersSent)
|
|
43
|
+
this.destroy()
|
|
44
|
+
else if (err.code === 'ENOENT')
|
|
45
|
+
this.notFound()
|
|
46
|
+
else
|
|
47
|
+
throw err
|
|
48
|
+
}
|
|
32
49
|
}
|
|
33
50
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
51
|
+
async partialContent(file) {
|
|
52
|
+
try {
|
|
53
|
+
const { size } = await fs.promises.lstat(file)
|
|
54
|
+
let [start, end] = this.req.headers.range.replace(/bytes=/, '').split('-').map(n => parseInt(n, 10))
|
|
55
|
+
|
|
56
|
+
if (isNaN(start)) {
|
|
57
|
+
start = size - end
|
|
58
|
+
end = size - 1
|
|
59
|
+
}
|
|
60
|
+
else if (isNaN(end))
|
|
61
|
+
end = size - 1
|
|
62
|
+
|
|
63
|
+
if (start < 0 || end >= size || start > end) {
|
|
64
|
+
this.statusCode = 416 // Range Not Satisfiable
|
|
65
|
+
this.setHeader('Content-Range', `bytes */${size}`)
|
|
66
|
+
this.end()
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.statusCode = 206 // Partial Content
|
|
71
|
+
this.setHeader('Accept-Ranges', 'bytes')
|
|
72
|
+
this.setHeader('Content-Range', `bytes ${start}-${end}/${size}`)
|
|
73
|
+
this.setHeader('Content-Length', (end - start) + 1)
|
|
74
|
+
this.setHeader('Content-Type', mimeFor(file))
|
|
75
|
+
|
|
76
|
+
await pipeline(fs.createReadStream(file, { start, end }), this)
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (this.headersSent)
|
|
80
|
+
this.destroy()
|
|
81
|
+
else if (err.code === 'ENOENT')
|
|
82
|
+
this.notFound()
|
|
83
|
+
else
|
|
84
|
+
throw err
|
|
85
|
+
}
|
|
37
86
|
}
|
|
38
|
-
|
|
87
|
+
|
|
39
88
|
|
|
40
89
|
badRequest() {
|
|
41
90
|
this.statusCode = 400
|
|
@@ -62,7 +111,6 @@ export class ServerResponse extends http.ServerResponse {
|
|
|
62
111
|
this.end(error)
|
|
63
112
|
}
|
|
64
113
|
|
|
65
|
-
|
|
66
114
|
internalServerError() {
|
|
67
115
|
this.statusCode = 500
|
|
68
116
|
this.end()
|
|
@@ -72,34 +120,4 @@ export class ServerResponse extends http.ServerResponse {
|
|
|
72
120
|
this.statusCode = 502
|
|
73
121
|
this.end()
|
|
74
122
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
async partialContent(file) {
|
|
78
|
-
const { size } = await fs.promises.lstat(file)
|
|
79
|
-
let [start, end] = this.req.headers.range.replace(/bytes=/, '').split('-').map(n => parseInt(n, 10))
|
|
80
|
-
|
|
81
|
-
if (isNaN(start)) {
|
|
82
|
-
start = size - end
|
|
83
|
-
end = size - 1
|
|
84
|
-
}
|
|
85
|
-
else if (isNaN(end))
|
|
86
|
-
end = size - 1
|
|
87
|
-
|
|
88
|
-
if (start < 0 || end >= size || start > end) {
|
|
89
|
-
this.statusCode = 416 // Range Not Satisfiable
|
|
90
|
-
this.setHeader('Content-Range', `bytes */${size}`)
|
|
91
|
-
this.end()
|
|
92
|
-
return
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
this.statusCode = 206 // Partial Content
|
|
96
|
-
this.setHeader('Accept-Ranges', 'bytes')
|
|
97
|
-
this.setHeader('Content-Range', `bytes ${start}-${end}/${size}`)
|
|
98
|
-
this.setHeader('Content-Length', (end - start) + 1)
|
|
99
|
-
this.setHeader('Content-Type', mimeFor(file))
|
|
100
|
-
|
|
101
|
-
const stream = fs.createReadStream(file, { start, end })
|
|
102
|
-
this.on('close', () => stream.destroy())
|
|
103
|
-
stream.pipe(this)
|
|
104
|
-
}
|
|
105
123
|
}
|
|
@@ -1,84 +1,101 @@
|
|
|
1
1
|
import { describe, test, before, after } from 'node:test'
|
|
2
2
|
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
3
|
-
import
|
|
4
|
-
import { join, dirname } from 'node:path'
|
|
5
|
-
import { strictEqual } from 'node:assert'
|
|
3
|
+
import { createServer } from 'node:http'
|
|
6
4
|
import { tmpdir } from 'node:os'
|
|
5
|
+
import { equal } from 'node:assert/strict'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
7
|
import { rm } from 'node:fs/promises'
|
|
8
8
|
|
|
9
9
|
import { ServerResponse } from './HttpServerResponse.js'
|
|
10
10
|
|
|
11
|
-
describe('ServerResponse
|
|
11
|
+
describe('ServerResponse', () => {
|
|
12
12
|
const FILE = '0123456789'
|
|
13
|
-
const FILE_SIZE = FILE.length
|
|
14
|
-
|
|
15
|
-
let tmpFile, server, baseUrl
|
|
16
13
|
|
|
14
|
+
let tmpDir, tmpFile, server, addr
|
|
17
15
|
before(async () => {
|
|
18
|
-
|
|
16
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'response-'))
|
|
19
17
|
tmpFile = join(tmpDir, 'test.txt')
|
|
20
18
|
writeFileSync(tmpFile, FILE)
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
|
|
20
|
+
server = createServer({ ServerResponse }, (req, response) => {
|
|
21
|
+
const file = join(tmpDir, req.url)
|
|
22
|
+
if (req.headers.range)
|
|
23
|
+
response.partialContent(file)
|
|
24
|
+
else
|
|
25
|
+
response.file(file)
|
|
23
26
|
})
|
|
27
|
+
|
|
24
28
|
await new Promise(resolve => server.listen(0, () => {
|
|
25
|
-
|
|
26
|
-
baseUrl = `http://127.0.0.1:${port}`
|
|
29
|
+
addr = `http://127.0.0.1:${(server.address().port)}`
|
|
27
30
|
resolve()
|
|
28
31
|
}))
|
|
29
32
|
})
|
|
30
33
|
|
|
31
34
|
after(async () => {
|
|
32
35
|
server?.close()
|
|
33
|
-
await rm(
|
|
36
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
34
37
|
})
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
headers: response.headers,
|
|
45
|
-
data
|
|
46
|
-
}))
|
|
47
|
-
})
|
|
48
|
-
req.on('error', reject)
|
|
39
|
+
|
|
40
|
+
describe('partialContent', () => {
|
|
41
|
+
const GET = (path, range) => fetch(addr + path, { headers: { range } })
|
|
42
|
+
|
|
43
|
+
test('404', async () => {
|
|
44
|
+
const r = await GET('/not-found', 'bytes=0-')
|
|
45
|
+
equal(r.status, 404)
|
|
46
|
+
equal(r.headers.get('content-length'), '0')
|
|
49
47
|
})
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
test('416 - out of bounds', async () => {
|
|
53
|
-
for (const range of ['bytes=10-12', 'bytes=5-2', 'bytes=12-', 'bytes=-15']) {
|
|
54
|
-
const { statusCode, headers } = await request(range)
|
|
55
|
-
strictEqual(statusCode, 416)
|
|
56
|
-
strictEqual(headers['content-range'], `bytes */${FILE_SIZE}`)
|
|
57
|
-
}
|
|
58
|
-
})
|
|
59
48
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
49
|
+
test('416 - out of bounds', async () => {
|
|
50
|
+
for (const range of ['bytes=10-12', 'bytes=5-2', 'bytes=12-', 'bytes=-15']) {
|
|
51
|
+
const r = await GET('/test.txt', range)
|
|
52
|
+
equal(r.status, 416)
|
|
53
|
+
equal(r.headers.get('content-range'), `bytes */${FILE.length}`)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('206 - normal range', async () => {
|
|
58
|
+
const r = await GET('/test.txt', 'bytes=0-4')
|
|
59
|
+
equal(r.status, 206)
|
|
60
|
+
equal(r.headers.get('content-range'), `bytes 0-4/${FILE.length}`)
|
|
61
|
+
equal(r.headers.get('content-length'), '5')
|
|
62
|
+
equal(r.headers.get('content-type'), 'text/plain')
|
|
63
|
+
equal(await r.text(), '01234')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('206 - suffix range', async () => {
|
|
67
|
+
const r = await GET('/test.txt', 'bytes=-3')
|
|
68
|
+
equal(r.status, 206)
|
|
69
|
+
equal(r.headers.get('content-range'), `bytes 7-9/${FILE.length}`)
|
|
70
|
+
equal(r.headers.get('content-length'), '3')
|
|
71
|
+
equal(await r.text(), '789')
|
|
72
|
+
})
|
|
68
73
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
test('206 - open ended range', async () => {
|
|
75
|
+
const r = await GET('/test.txt', 'bytes=5-')
|
|
76
|
+
equal(r.status, 206)
|
|
77
|
+
equal(r.headers.get('content-range'), `bytes 5-9/${FILE.length}`)
|
|
78
|
+
equal(r.headers.get('content-length'), '5')
|
|
79
|
+
equal(await r.text(), '56789')
|
|
80
|
+
})
|
|
75
81
|
})
|
|
76
82
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
|
|
84
|
+
describe('file', () => {
|
|
85
|
+
const GET = path => fetch(addr + path)
|
|
86
|
+
|
|
87
|
+
test('404', async () => {
|
|
88
|
+
const r = await GET('/not-found')
|
|
89
|
+
equal(r.status, 404)
|
|
90
|
+
equal(r.headers.get('content-length'), '0')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('200', async () => {
|
|
94
|
+
const r = await GET('/test.txt')
|
|
95
|
+
equal(r.status, 200)
|
|
96
|
+
equal(r.headers.get('content-type'), 'text/plain')
|
|
97
|
+
equal(r.headers.get('content-length'), String(FILE.length))
|
|
98
|
+
equal(await r.text(), FILE)
|
|
99
|
+
})
|
|
83
100
|
})
|
|
84
101
|
})
|
package/src/server/utils/fs.js
CHANGED
|
@@ -6,9 +6,9 @@ import { mkdtempSync, rmSync, realpathSync } from 'node:fs'
|
|
|
6
6
|
|
|
7
7
|
import { resolveIn } from './fs.js'
|
|
8
8
|
|
|
9
|
-
const isNull = v => equal(v, null)
|
|
10
|
-
|
|
11
9
|
describe('resolveIn', () => {
|
|
10
|
+
const isNull = v => equal(v, null)
|
|
11
|
+
|
|
12
12
|
const baseDir = mkdtempSync(join(tmpdir(), '_resolveIn'))
|
|
13
13
|
const baseParentDir = join(baseDir, '..')
|
|
14
14
|
after(() => rmSync(baseDir, { recursive: true, force: true }))
|