mockaton 13.11.3 → 13.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +46 -30
  2. package/index.d.ts +30 -29
  3. package/package.json +1 -1
  4. package/skills/mockaton/SKILL.md +24 -21
  5. package/src/client/ApiCommander.js +20 -19
  6. package/src/client/{app.js → App.js} +6 -6
  7. package/src/client/Filename.js +7 -8
  8. package/src/client/{app-header.css → Header.css} +1 -1
  9. package/src/client/{app-header.js → Header.js} +4 -4
  10. package/src/client/IndexHtml.js +1 -1
  11. package/src/client/{app-mock-list.js → MockList.js} +7 -8
  12. package/src/client/{app-payload-viewer.js → PayloadViewer.js} +3 -3
  13. package/src/client/{app-store.test.js → store.test.js} +1 -1
  14. package/src/server/Api.js +25 -18
  15. package/src/server/MockBroker.js +2 -8
  16. package/src/server/Mockaton.js +13 -12
  17. package/src/server/Mockaton.test.js +23 -35
  18. package/src/server/ProxyRelay.js +2 -2
  19. package/src/server/{ResolverBypassImportCache.js → resolveBypassImportCache.js} +3 -3
  20. package/src/server/{ResolverResolveExtensionless.js → resolveExtensionless.js} +3 -3
  21. package/src/server/stores/brokers.js +1 -4
  22. package/src/server/stores/config.js +2 -2
  23. package/src/server/utils/HttpIncomingMessage.js +17 -16
  24. package/src/server/utils/logger.js +7 -1
  25. package/src/server/utils/mime.js +2 -0
  26. /package/src/client/{app.css → App.css} +0 -0
  27. /package/src/client/{app-mock-list.css → MockList.css} +0 -0
  28. /package/src/client/{app-payload-viewer.css → PayloadViewer.css} +0 -0
  29. /package/src/client/{graphics.js → icons.js} +0 -0
  30. /package/src/client/{app-store.js → store.js} +0 -0
package/README.md CHANGED
@@ -1,14 +1,27 @@
1
1
  <!-- SKILLS_IGNORE_BEGIN -->
2
- <img src="logo.svg" alt="Mockaton Logo" width="180" style="margin-top: 30px"/>
3
2
 
4
3
  ![NPM Version](https://img.shields.io/npm/v/mockaton)
5
4
  [![Test](https://github.com/ericfortis/mockaton/actions/workflows/test.yml/badge.svg)](https://github.com/ericfortis/mockaton/actions/workflows/test.yml)
6
5
  [![codecov](https://codecov.io/github/ericfortis/mockaton/graph/badge.svg?token=90NYLMMG1J)](https://codecov.io/github/ericfortis/mockaton)
7
6
 
7
+ <img src="logo.svg" alt="Mockaton Logo" width="180" style="margin-top: 30px"/>
8
+
9
+ *No API state should be too difficult to test*
10
+
8
11
  ## [Docs ↗](https://mockaton.com) | [Changelog ↗](https://mockaton.com/changelog) | [Skills](skills/mockaton/SKILL.md)
9
12
 
10
- Mockaton is an HTTP mock server for simulating APIs. With it, you can
11
- test API errors, edge cases, and difficult to reproduce states in general.
13
+
14
+ Simulate API states that are normally ignored. For example, quickly trigger an error on
15
+ an endpoint by clicking the 500 button. Then, unclick it to test your retry logic.
16
+ Similarly, pick a mock variant from the dropdown, say to respond with a 423 (locked account).
17
+ As another example, click the clock button 🕓 to delay a response so you can test
18
+ spinners &mdash; you’ve seen them in production at the top left, or restart
19
+ their animation midway.
20
+
21
+
22
+ ## Dashboard
23
+ Besides the dashboard UI, there’s a [programmatic API](https://mockaton.com/api),
24
+ which is handy for setting up tests.
12
25
 
13
26
  <picture>
14
27
  <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/ericfortis/mockaton/refs/heads/main/pixaton-tests/tests/macos/pic-for-readme.vp762x762.light.gold.png">
@@ -35,59 +48,57 @@ curl localhost:2020/api/user
35
48
 
36
49
 
37
50
  # Installation ([more options ↗](https://mockaton.com/installation))
38
- Mockaton has no dependencies.
39
-
40
51
  ```sh
41
52
  npm install -g mockaton
42
53
  ```
54
+ <i>Mockaton is a Node.js app with no dependencies.</i>
55
+
56
+ <!-- SKILLS_IGNORE_BEGIN -->
57
+ ## Skills
58
+ ```sh
59
+ npx skills add ericfortis/mockaton
60
+ ```
61
+ <!-- SKILLS_IGNORE_END -->
62
+
63
+
43
64
 
44
65
  ## Basic Usage
45
66
  ```sh
46
- mockaton --port 2020 my-mocks-dir/
67
+ mockaton --port 2020 my-mocks-dir
47
68
  ```
48
69
 
49
- Mockaton will serve the files on the given directory. It's a file-system based router, so
50
- filenames can have dynamic parameters. Also, filenames can have comments, which are
51
- anything within parentheses, this way each route can have different mock file variants.
52
- Similarly, each route can have different response status code variants.
70
+ Mockaton will serve the files on the given directory. It's a file-system based router in which
71
+ filenames can have dynamic parameters and comments. For paraments use square brackets `[]`,
72
+ and for comments use parentheses `()`. Comments are handy because this way each route
73
+ can have different mock file variants. Similarly, each route can have different response status
74
+ code variants.
53
75
 
54
76
 
55
77
  | Route | Filename | Description |
56
78
  | -----| -----| ---|
57
- | /api/company/123 | api/company/[id].GET.200.ts | `[id]` is a dynamic parameter. `.ts`, and `.js` are sent as JSON by default |
58
- | /media/avatar.png | media/avatar.png | Statics assets don't need the above extension |
59
- | /api/login | api/login(invalid attempt).POST.401.ts | **Anything within parenthesis is a comment**, they are ignored when routing |
60
- | /api/login | api/login(default).GET.200.ts | `(default)` is a special comment; otherwise, the first mock variant in alphabetical order wins |
61
- | /api/login | api/login(locked out user).POST.423.json | `.json` is allowed too |
62
-
63
-
64
- ## Docs
65
- - [Configuration: CLI and mockaton.config.js](https://mockaton.com/config).
66
- - [Programmatic API](https://mockaton.com/api), in which
67
- you can delay a route, select a different mock file, such as a 500 error, among other options.
79
+ | /api/company/123 | api/company/[id].GET.200.ts | `[id]` is a dynamic parameter. `.ts`, and `.js` are sent as JSON by default. |
80
+ | /media/avatar.png | media/avatar.png | Statics assets don't need the above extension. |
81
+ | /api/login | api/login(invalid attempt).POST.401.ts | Anything within parenthesis is a comment. They are ignored when routing. You can add many comments, `foo(c0)(c1).png` |
82
+ | /api/login | api/login(default).GET.200.ts | `(default)` is a special comment. Otherwise, the first mock variant in alphabetical order wins. |
83
+ | /api/login | api/login(locked out user).POST.423.json | `.json` is allowed too. |
68
84
 
69
85
  <!-- SKILLS_IGNORE_BEGIN -->
70
86
  ## How to scrape your backend APIs?
71
- Mockaton has a [Browser Extension](https://mockaton.com/scraping) that lets
87
+ There’s a sister [Browser Extension](https://mockaton.com/scraping) that lets
72
88
  you download in bulk all your API responses following Mockaton's filename convention.
73
-
74
-
75
- ## Skills
76
- ```sh
77
- npx skills add ericfortis/mockaton
78
- ```
79
89
  <!-- SKILLS_IGNORE_END -->
80
90
 
81
91
 
82
92
  ## How to create mocks?
83
- Write it to your mocks directory. `.ts` files are served as JSON by default.
93
+ Write it to your mocks directory. TypeScript files are sent as JSON by default.
84
94
  ```sh
85
95
  mkdir -p my-mocks-dir/api
86
96
  echo "export default { name: 'John' }" > my-mocks-dir/api/user.GET.200.ts
87
97
  ```
98
+ Alternatively, there’s a [write-mock API](https://mockaton.com/api).
88
99
 
89
100
  ### Example A: JSON
90
- For JSON responses, use TypeScript (or JS), and `export default` an Object, Array, or
101
+ For JSON responses, you can use TypeScript (or JS) and `export default` an Object, Array, or
91
102
  String.
92
103
 
93
104
  - **Route:** /api/company/123
@@ -135,3 +146,8 @@ export default async function (req: IncomingMessage, response: OutgoingMessage)
135
146
  })
136
147
  }
137
148
  ```
149
+
150
+ ## Docs
151
+ - [Configuration](https://mockaton.com/config): CLI and mockaton.config.js
152
+ - [API](https://mockaton.com/api): Programatically, you can delay a route, select a different mock file, etc.
153
+
package/index.d.ts CHANGED
@@ -46,7 +46,7 @@ export declare interface Config {
46
46
  onReady?: (address: string) => void
47
47
 
48
48
  hotReload?: boolean // For UI dev purposes only
49
- bypassImportCache?: boolean
49
+ bypassImportCache?: boolean
50
50
  }
51
51
 
52
52
 
@@ -66,39 +66,40 @@ export declare function parseJSON(request: IncomingMessage): Promise<any>
66
66
  export declare function parseSegments(reqUrl: string, filename: string): Record<string, string>
67
67
  export declare function parseQueryParams(reqUrl: string): URLSearchParams
68
68
 
69
- export declare type JsonPromise<T> = Promise<Response & { json(): Promise<T> }>
70
-
71
-
72
- // API
73
-
74
- export declare type ClientMockBroker = {
75
- mocks: string[]
76
- file: string
77
- status: number
78
- isStatic: boolean
79
- autoStatus: number
80
- delayed: boolean
81
- proxied: boolean
82
- }
83
- export declare type ClientBrokersByMethod = {
84
- [method: string]: {
85
- [urlMask: string]: ClientMockBroker
69
+ declare global {
70
+ type JsonPromise<T> = Promise<Response & { json(): Promise<T> }>
71
+
72
+ type ClientMockBroker = {
73
+ mocks: string[]
74
+ file: string
75
+ status: number
76
+ isStatic: boolean
77
+ autoStatus: number
78
+ delayed: boolean
79
+ proxied: boolean
80
+ }
81
+ type ClientBrokersByMethod = {
82
+ [method: string]: {
83
+ [urlMask: string]: ClientMockBroker
84
+ }
86
85
  }
87
- }
88
86
 
89
- export declare interface State {
90
- brokersByMethod: ClientBrokersByMethod
87
+ interface State {
88
+ brokersByMethod: ClientBrokersByMethod
91
89
 
92
- cookies: [label: string, selected: boolean][]
93
- comments: string[]
90
+ cookies: [label: string, selected: boolean][]
91
+ comments: string[]
94
92
 
95
- delay: number
96
- delayJitter: number
93
+ delay: number
94
+ delayJitter: number
97
95
 
98
- collectProxied: boolean
99
- proxyFallback: string
96
+ collectProxied: boolean
97
+ proxyFallback: string
100
98
 
101
- readOnly: boolean
99
+ readOnly: boolean
102
100
 
103
- corsAllowed?: boolean
101
+ corsAllowed?: boolean
102
+ }
104
103
  }
104
+
105
+ export type { JsonPromise, ClientMockBroker, ClientBrokersByMethod, State }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "mockaton",
3
3
  "description": "HTTP Mock Server",
4
4
  "type": "module",
5
- "version": "13.11.3",
5
+ "version": "13.14.0",
6
6
  "exports": {
7
7
  ".": {
8
8
  "import": "./index.js",
@@ -4,49 +4,48 @@ description: Generates and serves mock HTTP APIs. Use when creating, editing, or
4
4
  user-invocable: false
5
5
  ---
6
6
  # Installation ([more options ↗](https://mockaton.com/installation))
7
- Mockaton has no dependencies.
8
-
9
7
  ```sh
10
8
  npm install -g mockaton
11
9
  ```
10
+ <i>Mockaton is a Node.js app with no dependencies.</i>
11
+
12
+
13
+
14
+
12
15
 
13
16
  ## Basic Usage
14
17
  ```sh
15
- mockaton --port 2020 my-mocks-dir/
18
+ mockaton --port 2020 my-mocks-dir
16
19
  ```
17
20
 
18
- Mockaton will serve the files on the given directory. It's a file-system based router, so
19
- filenames can have dynamic parameters. Also, filenames can have comments, which are
20
- anything within parentheses, this way each route can have different mock file variants.
21
- Similarly, each route can have different response status code variants.
21
+ Mockaton will serve the files on the given directory. It's a file-system based router in which
22
+ filenames can have dynamic parameters and comments. For paraments use square brackets `[]`,
23
+ and for comments use parentheses `()`. Comments are handy because this way each route
24
+ can have different mock file variants. Similarly, each route can have different response status
25
+ code variants.
22
26
 
23
27
 
24
28
  | Route | Filename | Description |
25
29
  | -----| -----| ---|
26
- | /api/company/123 | api/company/[id].GET.200.ts | `[id]` is a dynamic parameter. `.ts`, and `.js` are sent as JSON by default |
27
- | /media/avatar.png | media/avatar.png | Statics assets don't need the above extension |
28
- | /api/login | api/login(invalid attempt).POST.401.ts | **Anything within parenthesis is a comment**, they are ignored when routing |
29
- | /api/login | api/login(default).GET.200.ts | `(default)` is a special comment; otherwise, the first mock variant in alphabetical order wins |
30
- | /api/login | api/login(locked out user).POST.423.json | `.json` is allowed too |
31
-
32
-
33
- ## Docs
34
- - [Configuration: CLI and mockaton.config.js](https://mockaton.com/config).
35
- - [Programmatic API](https://mockaton.com/api), in which
36
- you can delay a route, select a different mock file, such as a 500 error, among other options.
30
+ | /api/company/123 | api/company/[id].GET.200.ts | `[id]` is a dynamic parameter. `.ts`, and `.js` are sent as JSON by default. |
31
+ | /media/avatar.png | media/avatar.png | Statics assets don't need the above extension. |
32
+ | /api/login | api/login(invalid attempt).POST.401.ts | Anything within parenthesis is a comment. They are ignored when routing. You can add many comments, `foo(c0)(c1).png` |
33
+ | /api/login | api/login(default).GET.200.ts | `(default)` is a special comment. Otherwise, the first mock variant in alphabetical order wins. |
34
+ | /api/login | api/login(locked out user).POST.423.json | `.json` is allowed too. |
37
35
 
38
36
 
39
37
 
40
38
 
41
39
  ## How to create mocks?
42
- Write it to your mocks directory. `.ts` files are served as JSON by default.
40
+ Write it to your mocks directory. TypeScript files are sent as JSON by default.
43
41
  ```sh
44
42
  mkdir -p my-mocks-dir/api
45
43
  echo "export default { name: 'John' }" > my-mocks-dir/api/user.GET.200.ts
46
44
  ```
45
+ Alternatively, there’s a [write-mock API](https://mockaton.com/api).
47
46
 
48
47
  ### Example A: JSON
49
- For JSON responses, use TypeScript (or JS), and `export default` an Object, Array, or
48
+ For JSON responses, you can use TypeScript (or JS) and `export default` an Object, Array, or
50
49
  String.
51
50
 
52
51
  - **Route:** /api/company/123
@@ -93,4 +92,8 @@ export default async function (req: IncomingMessage, response: OutgoingMessage)
93
92
  name: 'Acme, Inc.'
94
93
  })
95
94
  }
96
- ```
95
+ ```
96
+
97
+ ## Docs
98
+ - [Configuration](https://mockaton.com/config): CLI and mockaton.config.js
99
+ - [API](https://mockaton.com/api): Programatically, you can delay a route, select a different mock file, etc.
@@ -1,11 +1,15 @@
1
1
  import { API } from './ApiConstants.js'
2
2
 
3
3
 
4
- /** Client for controlling Mockaton via its HTTP API */
4
+ /**
5
+ * Client for controlling Mockaton via its HTTP API
6
+ * @see https://mockaton.com/api
7
+ * @example const mockaton = new Commander('http://localhost:2020')
8
+ */
5
9
  export class Commander {
6
- /** @param {string} addr */
7
- constructor(addr) {
8
- this.addr = addr
10
+ /** @param {string} mockatonServerAddr */
11
+ constructor(mockatonServerAddr) {
12
+ this.addr = mockatonServerAddr
9
13
  }
10
14
 
11
15
  /** @returns {Promise<Response>} */
@@ -16,38 +20,35 @@ export class Commander {
16
20
 
17
21
  reset = () => this.#patch(API.reset)
18
22
 
19
- setGlobalDelay = delay => this.#patch(API.globalDelay, delay)
20
- setGlobalDelayJitter = jitterPct => this.#patch(API.globalDelayJitter, jitterPct)
21
- setCorsAllowed = value => this.#patch(API.cors, value)
22
- setWatchMocks = enabled => this.#patch(API.watchMocks, enabled)
23
- setProxyFallback = proxyAddr => this.#patch(API.fallback, proxyAddr)
24
- setCollectProxied = shouldCollect => this.#patch(API.collectProxied, shouldCollect)
25
-
26
- /** @returns {JsonPromise<State.cookies>} */
27
- selectCookie = label => this.#patch(API.cookies, label)
28
-
29
-
30
- bulkSelectByComment = comment => this.#patch(API.bulkSelect, comment)
31
23
 
32
24
  /** @returns {JsonPromise<ClientMockBroker>} */
33
25
  select = file => this.#patch(API.select, file)
34
26
 
27
+ bulkSelectByComment = comment => this.#patch(API.bulkSelect, comment)
35
28
 
36
29
  /** @returns {JsonPromise<ClientMockBroker>} */
37
30
  toggleStatus = (method, urlMask, status) => this.#patch(API.toggleStatus, [method, urlMask, status])
38
31
 
32
+ /** @returns {JsonPromise<ClientMockBroker>} */
33
+ setRouteIsDelayed = (method, urlMask, delayed) => this.#patch(API.delay, [method, urlMask, delayed])
39
34
 
40
35
  /** @returns {JsonPromise<ClientMockBroker>} */
41
36
  setRouteIsProxied = (method, urlMask, proxied) => this.#patch(API.proxied, [method, urlMask, proxied])
42
37
 
43
- /** @returns {JsonPromise<ClientMockBroker>} */
44
- setRouteIsDelayed = (method, urlMask, delayed) => this.#patch(API.delay, [method, urlMask, delayed])
45
38
 
39
+ /** @returns {JsonPromise<State['cookies']>} */
40
+ selectCookie = label => this.#patch(API.cookies, label)
41
+
42
+ setGlobalDelay = delay => this.#patch(API.globalDelay, delay)
43
+ setGlobalDelayJitter = jitterPct => this.#patch(API.globalDelayJitter, jitterPct)
44
+ setCorsAllowed = value => this.#patch(API.cors, value)
45
+ setWatchMocks = enabled => this.#patch(API.watchMocks, enabled)
46
+ setProxyFallback = proxyAddr => this.#patch(API.fallback, proxyAddr)
47
+ setCollectProxied = shouldCollect => this.#patch(API.collectProxied, shouldCollect)
46
48
 
47
49
  writeMock = (file, content) => this.#patch(API.writeMock, [file, content])
48
50
  deleteMock = file => this.#patch(API.deleteMock, file)
49
51
 
50
-
51
52
  /** @returns {JsonPromise<State>} */
52
53
  getState = () => fetch(this.addr + API.state)
53
54
 
@@ -1,14 +1,14 @@
1
1
  import { createElement as r, t, restoreFocus, Fragment } from './utils/dom.js'
2
2
 
3
- import { store } from './app-store.js'
3
+ import { store } from './store.js'
4
4
  import { API } from './ApiConstants.js'
5
- import { Header } from './app-header.js'
5
+ import { Header } from './Header.js'
6
6
  import { adoptSheet } from './utils/css.js'
7
- import { PayloadViewer, previewMock } from './app-payload-viewer.js'
8
- import { MockList, initKeyboardNavigation, renderRow } from './app-mock-list.js'
7
+ import { PayloadViewer, previewMock } from './PayloadViewer.js'
8
+ import { MockList, initKeyboardNavigation, renderRow } from './MockList.js'
9
9
 
10
- import CSS from './app.css' with { type: 'css' }
11
- adoptSheet(CSS, './app.css')
10
+ import CSS from './App.css' with { type: 'css' }
11
+ adoptSheet(CSS, './App.css')
12
12
 
13
13
  store.onError = onError
14
14
  store.render = render
@@ -11,13 +11,12 @@ const METHODS = [ // @KeepSync node:http.METHODS
11
11
 
12
12
  const reComments = /\([^()]*\)/g // Anything within parentheses
13
13
 
14
- export function extractComments(file) {
15
- return Array.from(file.matchAll(reComments), ([c]) => c)
16
- }
14
+ export const extractComments = file =>
15
+ Array.from(file.matchAll(reComments), ([c]) => c)
16
+
17
+ export const includesComment = (file, search) =>
18
+ extractComments(file).some(c => c.includes(search))
17
19
 
18
- export function includesComment(file, search) {
19
- return extractComments(file).some(c => c.includes(search))
20
- }
21
20
 
22
21
  export function parseFilename(file) {
23
22
  const tokens = file.replace(reComments, '').split('.')
@@ -62,6 +61,6 @@ export function makeMockFilename(url, method, status, ext, comment = '') {
62
61
  }
63
62
 
64
63
  function replaceIds(filename) {
65
- return filename.replaceAll(replaceIds.reUuidV4, '[id]')
64
+ const reUuidV4 = /([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/gi
65
+ return filename.replaceAll(reUuidV4, '[id]')
66
66
  }
67
- replaceIds.reUuidV4 = /([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/gi
@@ -142,7 +142,7 @@
142
142
  &.GlobalDelay,
143
143
  &.GlobalDelayJitter {
144
144
  input {
145
- &&::selection {
145
+ &::selection {
146
146
  background-color: transparent;
147
147
  }
148
148
 
@@ -1,10 +1,10 @@
1
1
  import { createElement as r, t } from './utils/dom.js'
2
- import { Logo, HelpIcon } from './graphics.js'
2
+ import { Logo, HelpIcon } from './icons.js'
3
3
  import { adoptSheet } from './utils/css.js'
4
- import { store } from './app-store.js'
4
+ import { store } from './store.js'
5
5
 
6
- import CSS from './app-header.css' with { type: 'css' }
7
- adoptSheet(CSS, './app-header.css')
6
+ import CSS from './Header.css' with { type: 'css' }
7
+ adoptSheet(CSS, './Header.css')
8
8
 
9
9
 
10
10
  export function Header() {
@@ -13,7 +13,7 @@ export const IndexHtml = (hotReloadEnabled, version) => `
13
13
  <meta charset="UTF-8">
14
14
  <base href="${API.root}/">
15
15
 
16
- <script type="module" src="app.js"></script>
16
+ <script type="module" src="App.js"></script>
17
17
  <link rel="preload" href="${API.state}" as="fetch" crossorigin>
18
18
 
19
19
  <link rel="icon" href="data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m235 33.7v202c0 9.19-5.81 14-17.4 14-11.6 0-17.4-4.83-17.4-14v-151c-0.115-4.49-6.72-5.88-8.46-0.87l-48.3 155c-2.22 7.01-7.72 10.1-16 9.9-3.63-0.191-7.01-1.14-9.66-2.89-2.89-1.72-4.83-4.34-5.57-7.72-11.1-37-22.6-74.3-34.1-111-4.34-14-8.95-31.4-14-48.3-1.82-4.83-8.16-5.32-8.46 1.16v156c0 9.19-5.81 14-17.4 14-11.6 0-17.4-4.83-17.4-14v-207c0-5.74 2.62-13.2 9.39-16.3 7.5-3.14 15-4.05 21.8-3.8 3.14 0 6.03 0.686 8.95 1.46 3.14 0.797 6.03 1.98 8.7 3.63 2.65 1.38 5.32 3.14 7.5 5.57 2.22 2.22 3.87 4.83 5.07 7.72l45.8 157c4.63-15.9 32.4-117 33.3-121 4.12-13.8 7.72-26.5 10.9-38.7 1.16-2.65 2.89-5.32 5.07-7.5 2.15-2.15 4.58-4.12 7.5-5.32 2.65-1.57 5.57-2.89 8.46-3.63 3.14-0.797 9.44-0.988 12.1-0.988 11.6 1.07 29.4 9.14 29.4 27z' fill='%23808080'/%3E%3C/svg%3E">
@@ -1,12 +1,12 @@
1
1
  import { createElement as r, t, Fragment } from './utils/dom.js'
2
2
 
3
- import { store } from './app-store.js'
4
- import { previewMock } from './app-payload-viewer.js'
3
+ import { store } from './store.js'
4
+ import { previewMock } from './PayloadViewer.js'
5
5
  import { classNames, adoptSheet } from './utils/css.js'
6
- import { TimerIcon, CloudIcon, ChevronDownIcon } from './graphics.js'
6
+ import { TimerIcon, CloudIcon, ChevronDownIcon } from './icons.js'
7
7
 
8
- import CSS from './app-mock-list.css' with { type: 'css' }
9
- adoptSheet(CSS, './app-mock-list.css')
8
+ import CSS from './MockList.css' with { type: 'css' }
9
+ adoptSheet(CSS, './MockList.css')
10
10
 
11
11
 
12
12
  export function MockList() {
@@ -76,9 +76,8 @@ function TableContent() {
76
76
 
77
77
  if (store.groupByMethod)
78
78
  return Object.keys(store.brokersByMethod).map(method => Fragment(
79
- r('div', {
80
- className: classNames(CSS.TableHeading, store.canProxy && CSS.canProxy)
81
- }, method),
79
+ r('div', { className: classNames(CSS.TableHeading, store.canProxy && CSS.canProxy) },
80
+ method),
82
81
  FolderGroups(store.folderGroupsByMethod(method))))
83
82
 
84
83
  return FolderGroups(store.folderGroupsByMethod('*'))
@@ -1,10 +1,10 @@
1
1
  import { createElement as r, t } from './utils/dom.js'
2
2
  import { parseFilename } from './Filename.js'
3
- import { store } from './app-store.js'
3
+ import { store } from './store.js'
4
4
  import { adoptSheet } from './utils/css.js'
5
5
 
6
- import CSS from './app-payload-viewer.css' with { type: 'css' }
7
- adoptSheet(CSS, './app-payload-viewer.css')
6
+ import CSS from './PayloadViewer.css' with { type: 'css' }
7
+ adoptSheet(CSS, './PayloadViewer.css')
8
8
 
9
9
 
10
10
  const titleRef = {}
@@ -1,7 +1,7 @@
1
1
  import { test } from 'node:test'
2
2
  import { deepEqual } from 'node:assert/strict'
3
3
 
4
- import { BrokerRowModel, t } from './app-store.js'
4
+ import { BrokerRowModel, t } from './store.js'
5
5
 
6
6
 
7
7
  test('BrokerRowModel', () => {
package/src/server/Api.js CHANGED
@@ -18,7 +18,7 @@ import { IndexHtml, CSP } from '../client/IndexHtml.js'
18
18
  import { cookie } from './stores/cookies.js'
19
19
  import { config, ConfigValidator, reinitConfig } from './stores/config.js'
20
20
  import * as brokers from './stores/brokers.js'
21
- import * as Watcher from './stores/Watcher.js'
21
+ import * as Watcher from './stores/Watcher.js'
22
22
 
23
23
 
24
24
  export const CLIENT_ASSETS = join(import.meta.dirname, '../client')
@@ -269,19 +269,21 @@ async function setRouteIsProxied(req, response) {
269
269
 
270
270
 
271
271
  async function writeMock(req, response) {
272
- if (config.readOnly)
273
- return response.forbidden('Forbidden: Mockaton is in read-only mode. See config.readOnly, or --no-read-only (CLI)')
274
-
272
+ if (config.readOnly) {
273
+ response.forbidden('Forbidden: Mockaton is in read-only mode. See config.readOnly')
274
+ return
275
+ }
275
276
  const [file, content] = await req.json()
276
- if (typeof file !== 'string')
277
- return response.unprocessable('Invalid or missing filename. Expected: JSON [filename, content]')
278
-
277
+ if (typeof file !== 'string') {
278
+ response.unprocessable('Invalid or missing filename. Expected: JSON [filename, content]')
279
+ return
280
+ }
279
281
  const path = await resolveIn(config.mocksDir, file)
280
- if (!path)
281
- return response.forbidden('Filename path resolves outside config.mocksDir')
282
-
282
+ if (!path) {
283
+ response.forbidden('Filename path resolves outside config.mocksDir')
284
+ return
285
+ }
283
286
  await write(path, content)
284
-
285
287
  if (!config.watcherEnabled) {
286
288
  brokers.registerMock(file, true)
287
289
  Watcher.emitChange()
@@ -291,17 +293,22 @@ async function writeMock(req, response) {
291
293
 
292
294
 
293
295
  async function deleteMock(req, response) {
294
- if (config.readOnly)
295
- return response.forbidden('Forbidden: Mockaton is in read-only mode. See config.readOnly, or --no-read-only (CLI)')
296
-
296
+ if (config.readOnly) {
297
+ response.forbidden('Forbidden: Mockaton is in read-only mode. See config.readOnly')
298
+ return
299
+ }
297
300
  const file = await req.json()
298
301
  const path = await resolveIn(config.mocksDir, file)
299
302
 
300
- if (!path)
301
- return response.forbidden('Filename path resolves outside config.mocksDir')
303
+ if (!path) {
304
+ response.forbidden('Filename path resolves outside config.mocksDir')
305
+ return
306
+ }
302
307
 
303
- if (!isFile(path))
304
- return response.unprocessable(`Missing Mock: ${file}`)
308
+ if (!isFile(path)) {
309
+ response.unprocessable(`Missing Mock: ${file}`)
310
+ return
311
+ }
305
312
 
306
313
  await rm(path)
307
314
 
@@ -108,21 +108,15 @@ class UrlMatcher {
108
108
  this.#urlRegex = this.#buildUrlRegex(file)
109
109
  }
110
110
 
111
- #escapeRegex(str) {
112
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
113
- }
114
-
115
111
  #buildUrlRegex(file) {
116
112
  let { urlMask } = parseFilename(file)
117
113
  urlMask = removeQueryStringAndFragment(urlMask)
118
- urlMask = this.#escapeRegex(urlMask)
114
+ urlMask = RegExp.escape(urlMask)
119
115
  urlMask = this.#disregardVariables(urlMask)
120
116
  return new RegExp('^' + urlMask + '/*$')
121
117
  }
122
118
 
123
- #disregardVariables(str) {
124
- return str.replace(/\\\[.*?\\]/g, '[^/]+')
125
- }
119
+ #disregardVariables = str => str.replace(/\\\[.*?\\]/g, '[^/]+')
126
120
 
127
121
  // Appending a '/' so URLs ending with variables don't match
128
122
  // URLs that have a path after that variable. For example,
@@ -1,4 +1,4 @@
1
- import { register } from 'node:module'
1
+ import { registerHooks } from 'node:module'
2
2
  import { createServer } from 'node:http'
3
3
 
4
4
  import pkgJSON from '../../package.json' with { type: 'json' }
@@ -6,18 +6,21 @@ import pkgJSON from '../../package.json' with { type: 'json' }
6
6
  import { logger } from './utils/logger.js'
7
7
  import { ServerResponse } from './utils/HttpServerResponse.js'
8
8
  import { setCorsHeaders, isPreflight } from './utils/http-cors.js'
9
- import { IncomingMessage, BodyReaderError, hasControlChars } from './utils/HttpIncomingMessage.js'
9
+ import { IncomingMessage } from './utils/HttpIncomingMessage.js'
10
10
  import { watchDevSPA } from './utils/WatcherDevClient.js'
11
11
 
12
12
  import { API } from '../client/ApiConstants.js'
13
-
13
+ import { dispatchMock } from './MockDispatcher.js'
14
14
  import { CLIENT_ASSETS, handleApiRequest } from './Api.js'
15
+
16
+ import * as brokers from './stores/brokers.js'
15
17
  import { cookie } from './stores/cookies.js'
16
18
  import { config, initConfig } from './stores/config.js'
17
- import * as brokers from './stores/brokers.js'
18
- import { dispatchMock } from './MockDispatcher.js'
19
19
  import { watchMocksDir } from './stores/Watcher.js'
20
20
 
21
+ import { resolveExtensionless } from './resolveExtensionless.js'
22
+ import { resolveBypassImportCache } from './resolveBypassImportCache.js'
23
+
21
24
 
22
25
  export function Mockaton(options) {
23
26
  return new Promise((resolve, reject) => {
@@ -25,10 +28,10 @@ export function Mockaton(options) {
25
28
  cookie.init(config.cookies)
26
29
  brokers.init()
27
30
 
28
- register('./ResolverResolveExtensionless.js', import.meta.url)
31
+ registerHooks({ resolve: resolveExtensionless })
29
32
 
30
33
  if (config.bypassImportCache)
31
- register('./ResolverBypassImportCache.js', import.meta.url)
34
+ registerHooks({ resolve: resolveBypassImportCache })
32
35
 
33
36
  if (config.watcherEnabled)
34
37
  watchMocksDir()
@@ -60,13 +63,11 @@ async function onRequest(req, response) {
60
63
  logger.verbose('APP', response)
61
64
  })
62
65
 
63
- const url = req.url || ''
64
-
65
- if (url.length > 2048) {
66
+ if (req.url?.length > 2048) {
66
67
  response.uriTooLong()
67
68
  return
68
69
  }
69
- if (hasControlChars(url)) {
70
+ if (req.urlHasControlChars()) {
70
71
  response.badRequest()
71
72
  return
72
73
  }
@@ -83,7 +84,7 @@ async function onRequest(req, response) {
83
84
  }
84
85
  }
85
86
  catch (error) {
86
- if (error instanceof BodyReaderError)
87
+ if (error instanceof IncomingMessage.BodyReaderError)
87
88
  response.unprocessable(`${error.name}: ${error.message}`)
88
89
  else {
89
90
  logger.error(500, req.url, error?.message || error, error?.stack || '')
@@ -36,7 +36,7 @@ const proc = spawn(join(import.meta.dirname, 'cli.js'), [
36
36
  mocksDir.value,
37
37
  '--config', join(import.meta.dirname, 'Mockaton.test.config.js'),
38
38
  '--no-open'
39
- ])
39
+ ], { env: { ...process.env, FORCE_COLOR: 0, NO_COLOR: 1 } })
40
40
 
41
41
  const DEBUG = false
42
42
  proc.stdout.on('data', data => {
@@ -234,7 +234,7 @@ describe('Dashboard', () => {
234
234
  })
235
235
 
236
236
  test('serves assets', async () => {
237
- const r = await request(API.root + '/app.css')
237
+ const r = await request(API.root + '/App.css')
238
238
  match(await r.text(), new RegExp(':root {'))
239
239
  })
240
240
  })
@@ -1178,6 +1178,7 @@ describe('Registering Mocks', () => {
1178
1178
  before(async () => {
1179
1179
  await mocksDir.mkdir('reg0')
1180
1180
  await mocksDir.write(fx0.file, fx0.body)
1181
+ await sleep(config.watcherDebounceMs + 50)
1181
1182
  version = await resolveOnNextSyncVersion(-1)
1182
1183
  })
1183
1184
 
@@ -1217,41 +1218,28 @@ describe('Registering Mocks', () => {
1217
1218
  })
1218
1219
 
1219
1220
 
1220
- /** In Node, there's no EventSource, so we work around it like this.
1221
- * This is for listening to real-time updates. It responds when a new mock is added, deleted, or renamed. */
1222
- async function resolveOnNextSyncVersion(currSyncVer = undefined) {
1221
+ /** Uses the native `EventSource` to listen for real-time sync-version updates.
1222
+ * It resolves when a new version is pushed that differs from `currSyncVer`;
1223
+ * when `currSyncVer` is omitted, the first push after the initial connection is
1224
+ * used. */
1225
+ function resolveOnNextSyncVersion(currSyncVer = undefined) {
1223
1226
  let skipFirst = currSyncVer === undefined
1224
- const reader = (await api.getSyncVersion())
1225
- .body.pipeThrough(new TextDecoderStream())
1226
- .getReader()
1227
- let buffer = ''
1228
-
1229
- try {
1230
- while (true) {
1231
- try {
1232
- const { done, value } = await reader.read()
1233
- if (done) break
1234
- buffer += value
1227
+ return new Promise((resolve, reject) => {
1228
+ const es = new EventSource(api.addr + API.syncVersion)
1229
+ es.onmessage = event => {
1230
+ const v = Number(event.data)
1231
+ if (skipFirst || v === currSyncVer)
1232
+ skipFirst = false
1233
+ else {
1234
+ es.close()
1235
+ resolve(v)
1235
1236
  }
1236
- catch {
1237
- break
1237
+ }
1238
+ es.onerror = () => {
1239
+ if (es.readyState === EventSource.CLOSED) {
1240
+ es.close()
1241
+ reject(new Error('sync-version stream closed'))
1238
1242
  }
1239
- const parts = buffer.split('\n\n')
1240
- buffer = parts.pop() || ''
1241
-
1242
- for (const event of parts)
1243
- for (const line of event.split(/\r?\n/))
1244
- if (line.startsWith('data:')) {
1245
- const v = Number(line.slice(5).trim())
1246
- if (skipFirst || v === currSyncVer)
1247
- skipFirst = false
1248
- else
1249
- return v
1250
- }
1251
1243
  }
1252
- }
1253
- finally {
1254
- reader.cancel().catch(() => {})
1255
- }
1244
+ })
1256
1245
  }
1257
-
@@ -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 { BodyReaderError } from './utils/HttpIncomingMessage.js'
6
+ import { IncomingMessage } from './utils/HttpIncomingMessage.js'
7
7
  import { logger } from './utils/logger.js'
8
8
 
9
9
  import { makeMockFilename } from '../client/Filename.js'
@@ -24,7 +24,7 @@ export async function proxy(req, response, delay) {
24
24
  })
25
25
  }
26
26
  catch (error) { // TESTME
27
- if (error instanceof BodyReaderError)
27
+ if (error instanceof IncomingMessage.BodyReaderError)
28
28
  response.unprocessable(error.name)
29
29
  else {
30
30
  response.badGateway()
@@ -3,9 +3,9 @@ import { resolve as _resolve } from 'node:path'
3
3
  const mockatonSrcRoot = `file://${_resolve(import.meta.dirname, '..')}`
4
4
 
5
5
  // We register this hook at runtime so it doesn’t interfere with non-dynamic imports.
6
- // Cache bust by appending timestamp query param
7
- export async function resolve(specifier, context, nextResolve) {
8
- const result = await nextResolve(specifier, context)
6
+ // It cache-busts by appending timestamp query param
7
+ export function resolveBypassImportCache(specifier, context, nextResolve) {
8
+ const result = nextResolve(specifier, context)
9
9
  if (result.url?.startsWith('file://') && !result.url.startsWith(mockatonSrcRoot)) {
10
10
  const url = new URL(result.url)
11
11
  url.searchParams.set('t', performance.now())
@@ -3,9 +3,9 @@ import { join, dirname } from 'node:path'
3
3
  import { fileURLToPath, pathToFileURL } from 'node:url'
4
4
 
5
5
 
6
- export async function resolve(specifier, context, nextResolve) {
6
+ export function resolveExtensionless(specifier, context, nextResolve) {
7
7
  try {
8
- return await nextResolve(specifier, context)
8
+ return nextResolve(specifier, context)
9
9
  }
10
10
  catch (error) {
11
11
  // Attempt to resolve imports as .ts and .js
@@ -13,7 +13,7 @@ export async function resolve(specifier, context, nextResolve) {
13
13
  const absPath = join(dirname(fileURLToPath(context.parentURL)), specifier)
14
14
  for (const candidate of ['.ts', '.js'].map(ext => absPath + ext))
15
15
  if (existsSync(candidate))
16
- return resolve(pathToFileURL(candidate).href, context, nextResolve)
16
+ return resolveExtensionless(pathToFileURL(candidate).href, context, nextResolve)
17
17
  }
18
18
  throw error
19
19
  }
@@ -1,5 +1,3 @@
1
- import { basename } from 'node:path'
2
-
3
1
  import { config, isFileAllowed } from './config.js'
4
2
  import { MockBroker } from '../MockBroker.js'
5
3
  import { listFilesRecursively } from '../utils/fs.js'
@@ -35,8 +33,7 @@ export function init() {
35
33
 
36
34
  /** @returns {boolean} registered */
37
35
  export function registerMock(file, isFromWatcher = false) {
38
- if (brokerByFilename(file)?.hasMock(file) ||
39
- !isFileAllowed(basename(file)))
36
+ if (brokerByFilename(file)?.hasMock(file) || !isFileAllowed(file))
40
37
  return false
41
38
 
42
39
  const { method, urlMask } = parseFilename(file)
@@ -1,4 +1,4 @@
1
- import { resolve } from 'node:path'
1
+ import { resolve, basename } from 'node:path'
2
2
  import { lstatSync } from 'node:fs'
3
3
  import { METHODS } from 'node:http'
4
4
 
@@ -89,7 +89,7 @@ export function reinitConfig() {
89
89
  initConfig(originalOpts)
90
90
  }
91
91
 
92
- export const isFileAllowed = f => !config.ignore.test(f)
92
+ export const isFileAllowed = f => !config.ignore.test(basename(f))
93
93
 
94
94
  export const calcDelay = () => config.delayJitter
95
95
  ? config.delay * (1 + Math.random() * config.delayJitter)
@@ -1,6 +1,8 @@
1
1
  import http, { METHODS } from 'node:http'
2
2
 
3
3
 
4
+ export const reControlAndDelChars = /[\x00-\x1f\x7f]/
5
+
4
6
  export const methodIsSupported = method => METHODS.includes(method)
5
7
 
6
8
  export function removeQueryStringAndFragment(url = '') {
@@ -16,6 +18,8 @@ export class BodyReaderError extends Error {
16
18
  }
17
19
 
18
20
  export class IncomingMessage extends http.IncomingMessage {
21
+ static BodyReaderError = BodyReaderError
22
+
19
23
  json() {
20
24
  return this.body(JSON.parse)
21
25
  }
@@ -29,18 +33,28 @@ export class IncomingMessage extends http.IncomingMessage {
29
33
  for await (const chunk of this) {
30
34
  lengthSoFar += chunk.length
31
35
  if (lengthSoFar > MAX_BODY_SIZE)
32
- throw new BodyReaderError(`Body too large. Max is ${MAX_BODY_SIZE} bytes`)
36
+ throw new IncomingMessage.BodyReaderError(`Body too large. Max is ${MAX_BODY_SIZE} bytes`)
33
37
  chunks.push(chunk)
34
38
  }
35
39
 
36
40
  if (lengthSoFar !== expectedLength)
37
- throw new BodyReaderError('Length mismatch')
41
+ throw new IncomingMessage.BodyReaderError('Length mismatch')
38
42
 
39
43
  try {
40
44
  return parser(Buffer.concat(chunks).toString())
41
45
  }
42
46
  catch (_) {
43
- throw new BodyReaderError('Could not parse')
47
+ throw new IncomingMessage.BodyReaderError('Could not parse')
48
+ }
49
+ }
50
+
51
+ urlHasControlChars() {
52
+ try {
53
+ const decoded = decode(this.url || '')
54
+ return !decoded || reControlAndDelChars.test(decoded)
55
+ }
56
+ catch {
57
+ return true
44
58
  }
45
59
  }
46
60
  }
@@ -49,19 +63,6 @@ export const parseJSON = req =>
49
63
  IncomingMessage.prototype.body.call(req, JSON.parse)
50
64
 
51
65
 
52
-
53
- export const reControlAndDelChars = /[\x00-\x1f\x7f]/
54
-
55
- export function hasControlChars(url) {
56
- try {
57
- const decoded = decode(url)
58
- return !decoded || reControlAndDelChars.test(decoded)
59
- }
60
- catch {
61
- return true
62
- }
63
- }
64
-
65
66
  export function decode(url) {
66
67
  const candidate = decodeURIComponent(url)
67
68
  return candidate === decodeURIComponent(candidate)
@@ -1,3 +1,4 @@
1
+ import { styleText } from 'node:util'
1
2
  import { decode, reControlAndDelChars } from './HttpIncomingMessage.js'
2
3
 
3
4
 
@@ -39,7 +40,12 @@ export const logger = new class {
39
40
  #msg(...msg) {
40
41
  if (!msg.at(-1))
41
42
  msg.pop()
42
- return [new Date().toISOString(), ...msg.map(this.#sanitize)].join('::')
43
+ const parts = msg.map(this.#sanitize)
44
+ parts[1] = styleText(['bold', 'green'], parts[1])
45
+ return [
46
+ styleText('yellow', new Date().toISOString()),
47
+ ...parts
48
+ ].join(styleText('dim', '::'))
43
49
  }
44
50
 
45
51
  #sanitize(url) {
@@ -57,6 +57,7 @@ const extToMime = {
57
57
  js: 'application/javascript',
58
58
  json: 'application/json',
59
59
  jsonld: 'application/ld+json',
60
+ jxl: 'image/jxl',
60
61
  lz: 'application/x-lzip',
61
62
  m4a: 'audio/mp4',
62
63
  md: 'text/markdown',
@@ -91,6 +92,7 @@ const extToMime = {
91
92
  stl: 'model/stl',
92
93
  svg: 'image/svg+xml',
93
94
  tar: 'application/x-tar',
95
+ tga: 'image/x-targa',
94
96
  tif: 'image/tiff',
95
97
  ts: 'video/mp2t',
96
98
  ttf: 'font/ttf',
File without changes
File without changes
File without changes
File without changes