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 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
  ![NPM Version](https://img.shields.io/npm/v/mockaton)
3
5
  [![Test](https://github.com/ericfortis/mockaton/actions/workflows/test.yml/badge.svg)](https://github.com/ericfortis/mockaton/actions/workflows/test.yml)
4
6
  [![codecov](https://codecov.io/github/ericfortis/mockaton/graph/badge.svg?token=90NYLMMG1J)](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 filenames can have dynamic
41
- parameters.
42
- Also, filenames can have comments, which are anything within parentheses, this way each route can have different mock
43
- file variants.
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 **comment**, they are ignored when routing |
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
- - How to **configure** Mockaton? See [CLI and mockaton.config.js](https://mockaton.com/config) docs.
57
- - How to **control** Mockaton? Besides the dashboard, there's a [Programmatic API](https://mockaton.com/api), in which
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
- ## How to create mocks?
74
-
74
+ ## Installation ([more options ↗](https://mockaton.com/installation))
75
75
  ```sh
76
76
  npm install mockaton
77
77
  ```
78
78
 
79
- Write to your mocks directory, `.ts` files are served as JSON by default.
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
- cat > my-mocks-dir/api/user.GET.200.ts << EOF
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 JavaScript), and export an Object, Array, or String.
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.7",
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
- "./openapi.json": "./www/src/assets/openapi.json",
13
- "./SKILLS.md": "./www/src/.well-known/agent-skills/mockaton/SKILLS.md"
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
- "www/src/assets/openapi.json",
21
- "www/src/.well-known/agent-skills/mockaton/SKILLS.md"
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
- based router, so filenames can have dynamic parameters and comments.
13
- Also, each route can have different mock file variants.
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 **comment**, they are ignored when routing |
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
- - How to **configure** Mockaton? See [CLI and mockaton.config.js](https://mockaton.com/config) docs.
27
- - How to **control** Mockaton? Besides the dashboard, there's a [Programmatic API](https://mockaton.com/api), in which
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
- ## How to create mocks?
34
-
33
+ ## Installation ([more options ↗](https://mockaton.com/installation))
35
34
  ```sh
36
35
  npm install mockaton
37
36
  ```
38
37
 
39
- Write to your mocks directory, `.ts` files are served as JSON by default.
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
- cat << EOF >> my-mocks-dir/api/user.GET.200.ts
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 JavaScript), and export an Object, Array, or String.
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
  ```
@@ -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
@@ -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(#f2f2f3, #141414);
7
- --colorBgHeaderField: light-dark(#fff, #222);
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.374px;
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(--colorHover);
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: 22px;
190
- height: 22px;
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: 16px;
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 { pathname } = new URL(req.url, 'http://_')
65
+ const url = removeQueryStringAndFragment(req.url)
65
66
  const handler = (
66
- req.method === 'GET' && getReqs.get(pathname) ||
67
- req.method === 'PATCH' && patchReqs.get(pathname))
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
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_MOCK_COMMENT } from '../client/ApiConstants.js'
2
- import { parseFilename, includesComment, extractComments, removeQueryStringAndFragment } from '../client/Filename.js'
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'
@@ -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(error)
91
+ response.internalServerError()
92
92
  }
93
93
  }
94
94
  }
@@ -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 { readBody, BodyReaderError } from './utils/HttpIncomingMessage.js'
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 readBody(req)
22
+ : await req.body()
23
23
  })
24
24
  }
25
25
  catch (error) { // TESTME
@@ -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, removeQueryStringAndFragment } from '../client/Filename.js'
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, '../../www/src/.well-known/agent-skills/mockaton/SKILL.md')
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 SKILLS.md file path
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 readBody(this, JSON.parse)
20
+ return this.body(JSON.parse)
17
21
  }
18
- }
19
-
20
- export const parseJSON = req => readBody(req, JSON.parse)
21
22
 
22
- export function readBody(req, parser = a => a) {
23
- return new Promise((resolve, reject) => {
23
+ async body(parser = a => a) {
24
24
  const MAX_BODY_SIZE = 200 * 1024
25
- const expectedLength = req.headers['content-length'] | 0
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
- function onData(chunk) {
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
- onEnd()
36
- else
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
- function onEnd() {
41
- req.removeListener('data', onData)
42
- req.removeListener('end', onEnd)
43
- req.removeListener('error', onEnd)
44
- if (lengthSoFar !== expectedLength)
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
- this.setHeader('Content-Type', mimeFor(file))
31
- await pipeline(fs.createReadStream(file), this)
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
- noContent() {
35
- this.statusCode = 204
36
- this.end()
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 http, { createServer } from 'node:http'
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.partialContent (real HTTP)', () => {
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
- const tmpDir = mkdtempSync(join(tmpdir(), 'response-'))
16
+ tmpDir = mkdtempSync(join(tmpdir(), 'response-'))
19
17
  tmpFile = join(tmpDir, 'test.txt')
20
18
  writeFileSync(tmpFile, FILE)
21
- server = createServer({ ServerResponse }, async (_, response) => {
22
- await response.partialContent(tmpFile)
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
- const { port } = server.address()
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(dirname(tmpFile), { recursive: true, force: true })
36
+ await rm(tmpDir, { recursive: true, force: true })
34
37
  })
35
38
 
36
- function request(range) {
37
- return new Promise((resolve, reject) => {
38
- const req = http.get(baseUrl, { headers: { range } }, response => {
39
- let data = ''
40
- response.setEncoding('utf8')
41
- response.on('data', chunk => data += chunk)
42
- response.on('end', () => resolve({
43
- statusCode: response.statusCode,
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
- test('206 - normal range', async () => {
61
- const { statusCode, headers, data } = await request('bytes=0-4')
62
- strictEqual(statusCode, 206)
63
- strictEqual(headers['content-range'], `bytes 0-4/${FILE_SIZE}`)
64
- strictEqual(headers['content-length'], '5')
65
- strictEqual(headers['content-type'], 'text/plain')
66
- strictEqual(data, '01234')
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
- test('206 - suffix range', async () => {
70
- const { statusCode, headers, data } = await request('bytes=-3')
71
- strictEqual(statusCode, 206)
72
- strictEqual(headers['content-range'], `bytes 7-9/${FILE_SIZE}`)
73
- strictEqual(headers['content-length'], '3')
74
- strictEqual(data, '789')
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
- test('206 - open ended range', async () => {
78
- const { statusCode, headers, data } = await request('bytes=5-')
79
- strictEqual(statusCode, 206)
80
- strictEqual(headers['content-range'], `bytes 5-9/${FILE_SIZE}`)
81
- strictEqual(headers['content-length'], '5')
82
- strictEqual(data, '56789')
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
  })
@@ -39,7 +39,7 @@ export async function resolveIn(baseDir, file) {
39
39
  ? child
40
40
  : null
41
41
  }
42
- catch (e) {
42
+ catch {
43
43
  return null
44
44
  }
45
45
  }
@@ -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 }))