mockaton 13.9.6 → 13.9.8
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/server/Api.js +14 -3
- package/src/server/MockBroker.js +2 -1
- package/src/server/MockDispatcherPlugins.js +2 -1
- package/src/server/Mockaton.js +7 -16
- package/src/server/ProxyRelay.js +2 -2
- package/src/server/UrlParsers.js +2 -2
- package/src/server/cli.js +4 -3
- 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.8",
|
|
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/server/Api.js
CHANGED
|
@@ -18,11 +18,12 @@ 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')
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
const getReqs = new Map([
|
|
26
27
|
[API.dashboard, serveDashboard],
|
|
27
28
|
|
|
28
29
|
...listFilesRecursively(CLIENT_ASSETS).map(f => [
|
|
@@ -38,8 +39,7 @@ export const apiGetReqs = new Map([
|
|
|
38
39
|
[API.throws, () => { throw new Error('Test500') }]
|
|
39
40
|
])
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
export const apiPatchReqs = new Map([
|
|
42
|
+
const patchReqs = new Map([
|
|
43
43
|
[API.cors, setCorsAllowed],
|
|
44
44
|
[API.reset, reset],
|
|
45
45
|
[API.cookies, selectCookie],
|
|
@@ -61,6 +61,17 @@ export const apiPatchReqs = new Map([
|
|
|
61
61
|
[API.watchMocks, setWatchMocks]
|
|
62
62
|
])
|
|
63
63
|
|
|
64
|
+
export async function handleApiRequest(req, response) {
|
|
65
|
+
const url = removeQueryStringAndFragment(req.url)
|
|
66
|
+
const handler = (
|
|
67
|
+
req.method === 'GET' && getReqs.get(url) ||
|
|
68
|
+
req.method === 'PATCH' && patchReqs.get(url))
|
|
69
|
+
if (handler) {
|
|
70
|
+
await handler(req, response)
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
64
75
|
|
|
65
76
|
/** # GET */
|
|
66
77
|
|
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
|
@@ -11,7 +11,7 @@ import { IncomingMessage, BodyReaderError, hasControlChars } from './utils/HttpI
|
|
|
11
11
|
import { API } from '../client/ApiConstants.js'
|
|
12
12
|
import { cookie } from './cookie.js'
|
|
13
13
|
import { config, setup } from './config.js'
|
|
14
|
-
import {
|
|
14
|
+
import { CLIENT_ASSETS, handleApiRequest } from './Api.js'
|
|
15
15
|
|
|
16
16
|
import { dispatchMock } from './MockDispatcher.js'
|
|
17
17
|
import * as mockBrokerCollection from './mockBrokersCollection.js'
|
|
@@ -50,6 +50,7 @@ export function Mockaton(options) {
|
|
|
50
50
|
})
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
|
|
53
54
|
async function onRequest(req, response) {
|
|
54
55
|
response.setHeader('Server', `Mockaton ${pkgJSON.version}`)
|
|
55
56
|
response.on('error', logger.warn)
|
|
@@ -71,23 +72,13 @@ async function onRequest(req, response) {
|
|
|
71
72
|
return
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
setCorsHeaders(req, response, config)
|
|
77
|
-
|
|
78
|
-
const { method } = req
|
|
79
|
-
const { pathname } = new URL(url, 'http://_')
|
|
75
|
+
if (config.corsAllowed)
|
|
76
|
+
setCorsHeaders(req, response, config)
|
|
80
77
|
|
|
78
|
+
try {
|
|
81
79
|
if (isPreflight(req))
|
|
82
80
|
response.noContent()
|
|
83
|
-
|
|
84
|
-
else if (method === 'PATCH' && apiPatchReqs.has(pathname))
|
|
85
|
-
await apiPatchReqs.get(pathname)(req, response)
|
|
86
|
-
|
|
87
|
-
else if (method === 'GET' && apiGetReqs.has(pathname))
|
|
88
|
-
apiGetReqs.get(pathname)(req, response)
|
|
89
|
-
|
|
90
|
-
else {
|
|
81
|
+
else if (!(await handleApiRequest(req, response))) {
|
|
91
82
|
handledByMockDispatcher = true
|
|
92
83
|
await dispatchMock(req, response)
|
|
93
84
|
}
|
|
@@ -97,7 +88,7 @@ async function onRequest(req, response) {
|
|
|
97
88
|
response.unprocessable(`${error.name}: ${error.message}`)
|
|
98
89
|
else {
|
|
99
90
|
logger.error(500, req.url, error?.message || error, error?.stack || '')
|
|
100
|
-
response.internalServerError(
|
|
91
|
+
response.internalServerError()
|
|
101
92
|
}
|
|
102
93
|
}
|
|
103
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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
3
4
|
import { resolve, join } from 'node:path'
|
|
4
5
|
import { parseArgs } from 'node:util'
|
|
5
6
|
|
|
@@ -11,7 +12,7 @@ import pkgJSON from '../../package.json' with { type: 'json' }
|
|
|
11
12
|
process.on('unhandledRejection', error => { throw error })
|
|
12
13
|
|
|
13
14
|
const DEFAULT_CONFIG_FILE = 'mockaton.config.js'
|
|
14
|
-
const SKILLS_PATH = join(import.meta.dirname, '../../
|
|
15
|
+
const SKILLS_PATH = join(import.meta.dirname, '../../skills/mockaton/SKILL.md')
|
|
15
16
|
|
|
16
17
|
let args, positionals
|
|
17
18
|
try {
|
|
@@ -63,7 +64,7 @@ Options:
|
|
|
63
64
|
--no-open Don't open dashboard in a browser
|
|
64
65
|
--no-read-only Allow writing and deleting mocks via API
|
|
65
66
|
|
|
66
|
-
--skills Show AI agent
|
|
67
|
+
--skills Show AI agent SKILL.md file path
|
|
67
68
|
-h, --help
|
|
68
69
|
-v, --version
|
|
69
70
|
|
|
@@ -79,7 +80,7 @@ else if (args.config && !isFile(args.config)) {
|
|
|
79
80
|
else {
|
|
80
81
|
const userConf = resolve(args.config ?? DEFAULT_CONFIG_FILE)
|
|
81
82
|
const opts = isFile(userConf)
|
|
82
|
-
? (await import(userConf)).default ?? {}
|
|
83
|
+
? (await import(pathToFileURL(userConf))).default ?? {}
|
|
83
84
|
: {}
|
|
84
85
|
|
|
85
86
|
if (args.host) opts.host = args.host
|
|
@@ -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 }))
|