mockaton 8.8.2 → 8.9.1

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
@@ -30,7 +30,7 @@ which is handy for setting up tests (see **Commander API** below).
30
30
 
31
31
 
32
32
 
33
- ## No Need to Mock Everything
33
+ ## Fallback to your Backend
34
34
  Mockaton can fallback to your real backend on routes you don’t have mocks
35
35
  for. For that, type your backend address in the **Fallback Backend** field.
36
36
 
package/TODO.md CHANGED
@@ -1,4 +1,6 @@
1
1
  # TODO
2
2
 
3
3
  - Refactor tests
4
- - Dashboard refresh (long polling?)
4
+ - Preserve focus when refreshing dashboard `init()`
5
+ - More real-time updates. Currently, it's only for add/remove mock but not for
6
+ static files and changes from another client (Browser, or Commander).
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "mockaton",
3
3
  "description": "A deterministic server-side for developing and testing frontend clients",
4
4
  "type": "module",
5
- "version": "8.8.2",
5
+ "version": "8.9.1",
6
6
  "main": "index.js",
7
7
  "types": "index.d.ts",
8
8
  "license": "MIT",
package/src/Api.js CHANGED
@@ -6,11 +6,12 @@
6
6
  import { join } from 'node:path'
7
7
  import { cookie } from './cookie.js'
8
8
  import { config } from './config.js'
9
- import { DF, API } from './ApiConstants.js'
10
9
  import { parseJSON } from './utils/http-request.js'
11
10
  import { listFilesRecursively } from './utils/fs.js'
12
11
  import * as mockBrokersCollection from './mockBrokersCollection.js'
13
- import { sendOK, sendBadRequest, sendJSON, sendUnprocessableContent, sendDashboardFile, sendForbidden } from './utils/http-response.js'
12
+ import { DF, API, LONG_POLL_SERVER_TIMEOUT } from './ApiConstants.js'
13
+ import { countAR_Events, subscribeAR_EventListener, unsubscribeAR_EventListener } from './Watcher.js'
14
+ import { sendOK, sendJSON, sendUnprocessableContent, sendDashboardFile, sendForbidden } from './utils/http-response.js'
14
15
 
15
16
 
16
17
  const dashboardAssets = [
@@ -26,25 +27,26 @@ export const apiGetRequests = new Map([
26
27
  [API.dashboard, serveDashboard],
27
28
  ...dashboardAssets.map(f =>
28
29
  [API.dashboard + f, serveDashboardAsset]),
30
+ [API.cors, getIsCorsAllowed],
31
+ [API.static, listStaticFiles],
29
32
  [API.mocks, listMockBrokers],
30
33
  [API.cookies, listCookies],
31
- [API.comments, listComments],
32
34
  [API.fallback, getProxyFallback],
33
- [API.cors, getIsCorsAllowed],
34
- [API.collectProxied, getCollectProxied],
35
- [API.static, listStaticFiles]
35
+ [API.arEvents, longPollAR_Events],
36
+ [API.comments, listComments],
37
+ [API.collectProxied, getCollectProxied]
36
38
  ])
37
39
 
38
40
  export const apiPatchRequests = new Map([
39
- [API.select, selectMock],
41
+ [API.cors, setCorsAllowed],
40
42
  [API.delay, setRouteIsDelayed],
41
- [API.proxied, setRouteIsProxied],
42
43
  [API.reset, reinitialize],
44
+ [API.select, selectMock],
45
+ [API.proxied, setRouteIsProxied],
43
46
  [API.cookies, selectCookie],
44
47
  [API.fallback, updateProxyFallback],
45
- [API.collectProxied, setCollectProxied],
46
48
  [API.bulkSelect, bulkUpdateBrokersByCommentTag],
47
- [API.cors, setCorsAllowed]
49
+ [API.collectProxied, setCollectProxied]
48
50
  ])
49
51
 
50
52
  /* === GET === */
@@ -52,6 +54,7 @@ export const apiPatchRequests = new Map([
52
54
  function serveDashboard(_, response) {
53
55
  sendDashboardFile(response, join(import.meta.dirname, 'Dashboard.html'))
54
56
  }
57
+
55
58
  function serveDashboardAsset(req, response) {
56
59
  const f = req.url.replace(API.dashboard, '')
57
60
  if (dashboardAssets.includes(f))
@@ -68,18 +71,34 @@ function getIsCorsAllowed(_, response) { sendJSON(response, config.corsAllowed)
68
71
  function getCollectProxied(_, response) { sendJSON(response, config.collectProxied) }
69
72
 
70
73
  function listStaticFiles(req, response) {
71
- try {
72
- const files = config.staticDir
73
- ? listFilesRecursively(config.staticDir).filter(f => !config.ignore.test(f))
74
- : []
75
- sendJSON(response, files)
74
+ const files = config.staticDir
75
+ ? listFilesRecursively(config.staticDir).filter(f => !config.ignore.test(f))
76
+ : []
77
+ sendJSON(response, files)
78
+ }
79
+
80
+ function longPollAR_Events(req, response) {
81
+ // e.g. tab was hidden while new mocks were added or removed
82
+ const clientIsOutOfSync = parseInt(req.headers[DF.lastReceived_nAR], 10) !== countAR_Events()
83
+ if (clientIsOutOfSync) {
84
+ sendJSON(response, countAR_Events())
85
+ return
76
86
  }
77
- catch (error) {
78
- sendBadRequest(response, error)
87
+
88
+ function onAddOrRemoveMock() {
89
+ unsubscribeAR_EventListener(onAddOrRemoveMock)
90
+ sendJSON(response, countAR_Events())
79
91
  }
92
+ response.setTimeout(LONG_POLL_SERVER_TIMEOUT, onAddOrRemoveMock)
93
+ req.on('error', () => {
94
+ unsubscribeAR_EventListener(onAddOrRemoveMock)
95
+ response.destroy()
96
+ })
97
+ subscribeAR_EventListener(onAddOrRemoveMock)
80
98
  }
81
99
 
82
100
 
101
+
83
102
  /* === PATCH === */
84
103
 
85
104
  function reinitialize(_, response) {
@@ -88,124 +107,84 @@ function reinitialize(_, response) {
88
107
  }
89
108
 
90
109
  async function selectCookie(req, response) {
91
- try {
92
- const error = cookie.setCurrent(await parseJSON(req))
93
- if (error)
94
- sendUnprocessableContent(response, error)
95
- else
96
- sendOK(response)
97
- }
98
- catch (error) {
99
- sendBadRequest(response, error)
100
- }
110
+ const error = cookie.setCurrent(await parseJSON(req))
111
+ if (error)
112
+ sendUnprocessableContent(response, error)
113
+ else
114
+ sendOK(response)
101
115
  }
102
116
 
103
117
  async function selectMock(req, response) {
104
- try {
105
- const file = await parseJSON(req)
106
- const broker = mockBrokersCollection.getBrokerByFilename(file)
107
- if (!broker || !broker.hasMock(file))
108
- sendUnprocessableContent(response, `Missing Mock: ${file}`)
109
- else {
110
- broker.updateFile(file)
111
- sendOK(response)
112
- }
113
- }
114
- catch (error) {
115
- sendBadRequest(response, error)
118
+ const file = await parseJSON(req)
119
+ const broker = mockBrokersCollection.getBrokerByFilename(file)
120
+ if (!broker || !broker.hasMock(file))
121
+ sendUnprocessableContent(response, `Missing Mock: ${file}`)
122
+ else {
123
+ broker.updateFile(file)
124
+ sendOK(response)
116
125
  }
117
126
  }
118
127
 
119
128
  async function setRouteIsDelayed(req, response) {
120
- try {
121
- const body = await parseJSON(req)
122
- const delayed = body[DF.delayed]
123
- const broker = mockBrokersCollection.getBrokerForUrl(
124
- body[DF.routeMethod],
125
- body[DF.routeUrlMask])
126
-
127
- if (!broker) // TESTME
128
- sendUnprocessableContent(response, `Route does not exist: ${body[DF.routeUrlMask]} ${body[DF.routeUrlMask]}`)
129
- else if (typeof delayed !== 'boolean')
130
- sendUnprocessableContent(response, `Expected a boolean for "delayed"`) // TESTME
131
- else {
132
- broker.updateDelay(body[DF.delayed])
133
- sendOK(response)
134
- }
135
- }
136
- catch (error) {
137
- sendBadRequest(response, error)
129
+ const body = await parseJSON(req)
130
+ const delayed = body[DF.delayed]
131
+ const broker = mockBrokersCollection.getBrokerForUrl(
132
+ body[DF.routeMethod],
133
+ body[DF.routeUrlMask])
134
+
135
+ if (!broker) // TESTME
136
+ sendUnprocessableContent(response, `Route does not exist: ${body[DF.routeUrlMask]} ${body[DF.routeUrlMask]}`)
137
+ else if (typeof delayed !== 'boolean')
138
+ sendUnprocessableContent(response, `Expected a boolean for "delayed"`) // TESTME
139
+ else {
140
+ broker.updateDelay(body[DF.delayed])
141
+ sendOK(response)
138
142
  }
139
143
  }
140
144
 
141
145
  async function setRouteIsProxied(req, response) { // TESTME
142
- try {
143
- const body = await parseJSON(req)
144
- const proxied = body[DF.proxied]
145
- const broker = mockBrokersCollection.getBrokerForUrl(
146
- body[DF.routeMethod],
147
- body[DF.routeUrlMask])
148
-
149
- if (!broker)
150
- sendUnprocessableContent(response, `Route does not exist: ${body[DF.routeUrlMask]} ${body[DF.routeUrlMask]}`)
151
- else if (typeof proxied !== 'boolean')
152
- sendUnprocessableContent(response, `Expected a boolean for "proxied"`)
153
- else if (proxied && !config.proxyFallback)
154
- sendUnprocessableContent(response, `There’s no proxy fallback`)
155
- else {
156
- broker.updateProxied(proxied)
157
- sendOK(response)
158
- }
159
- }
160
- catch (error) {
161
- sendBadRequest(response, error)
146
+ const body = await parseJSON(req)
147
+ const proxied = body[DF.proxied]
148
+ const broker = mockBrokersCollection.getBrokerForUrl(
149
+ body[DF.routeMethod],
150
+ body[DF.routeUrlMask])
151
+
152
+ if (!broker)
153
+ sendUnprocessableContent(response, `Route does not exist: ${body[DF.routeUrlMask]} ${body[DF.routeUrlMask]}`)
154
+ else if (typeof proxied !== 'boolean')
155
+ sendUnprocessableContent(response, `Expected a boolean for "proxied"`)
156
+ else if (proxied && !config.proxyFallback)
157
+ sendUnprocessableContent(response, `There’s no proxy fallback`)
158
+ else {
159
+ broker.updateProxied(proxied)
160
+ sendOK(response)
162
161
  }
163
162
  }
164
163
 
165
164
  async function updateProxyFallback(req, response) {
166
- try {
167
- const fallback = await parseJSON(req)
168
- if (fallback && !URL.canParse(fallback)) {
169
- sendUnprocessableContent(response)
170
- return
171
- }
172
- if (!fallback) // TESTME
173
- mockBrokersCollection.ensureAllRoutesHaveSelectedMock()
174
- config.proxyFallback = fallback
175
- sendOK(response)
176
- }
177
- catch (error) {
178
- sendBadRequest(response, error)
179
- }
165
+ const fallback = await parseJSON(req)
166
+ if (fallback && !URL.canParse(fallback)) {
167
+ sendUnprocessableContent(response)
168
+ return
169
+ }
170
+ if (!fallback) // TESTME
171
+ mockBrokersCollection.ensureAllRoutesHaveSelectedMock()
172
+ config.proxyFallback = fallback
173
+ sendOK(response)
180
174
  }
181
175
 
182
176
  async function setCollectProxied(req, response) {
183
- try {
184
- config.collectProxied = await parseJSON(req)
185
- sendOK(response)
186
- }
187
- catch (error) {
188
- sendBadRequest(response, error)
189
- }
177
+ config.collectProxied = await parseJSON(req)
178
+ sendOK(response)
190
179
  }
191
180
 
192
181
  async function bulkUpdateBrokersByCommentTag(req, response) {
193
- try {
194
- mockBrokersCollection.setMocksMatchingComment(await parseJSON(req))
195
- sendOK(response)
196
- }
197
- catch (error) {
198
- sendBadRequest(response, error)
199
- }
182
+ mockBrokersCollection.setMocksMatchingComment(await parseJSON(req))
183
+ sendOK(response)
200
184
  }
201
185
 
202
186
  async function setCorsAllowed(req, response) {
203
- try {
204
- config.corsAllowed = await parseJSON(req)
205
- sendOK(response)
206
- }
207
- catch (error) {
208
- sendBadRequest(response, error)
209
- }
187
+ config.corsAllowed = await parseJSON(req)
188
+ sendOK(response)
210
189
  }
211
190
 
@@ -12,16 +12,20 @@ export const API = {
12
12
  collectProxied: MOUNT + '/collect-proxied',
13
13
  proxied: MOUNT + '/proxied',
14
14
  cors: MOUNT + '/cors',
15
- static: MOUNT + '/static'
15
+ static: MOUNT + '/static',
16
+ arEvents: MOUNT + '/ar_events'
16
17
  }
17
18
 
18
19
  export const DF = { // Dashboard Fields (XHR)
19
20
  routeMethod: 'route_method',
20
21
  routeUrlMask: 'route_url_mask',
21
22
  delayed: 'delayed',
22
- proxied: 'proxied'
23
+ proxied: 'proxied',
24
+ lastReceived_nAR: 'last_received_n_ar'
23
25
  }
24
26
 
25
27
  export const DEFAULT_500_COMMENT = '(Mockaton 500)'
26
28
  export const DEFAULT_MOCK_COMMENT = '(default)'
27
29
  export const EXT_FOR_UNKNOWN_MIME = 'unknown'
30
+
31
+ export const LONG_POLL_SERVER_TIMEOUT = 8_000
package/src/Commander.js CHANGED
@@ -1,4 +1,4 @@
1
- import { API, DF } from './ApiConstants.js'
1
+ import { API, DF, LONG_POLL_SERVER_TIMEOUT } from './ApiConstants.js'
2
2
 
3
3
 
4
4
  // Client for controlling Mockaton via its HTTP API
@@ -84,4 +84,13 @@ export class Commander {
84
84
  reset() {
85
85
  return this.#patch(API.reset)
86
86
  }
87
+
88
+ getAR_EventsCount(nAR_EventReceived) {
89
+ return fetch(API.arEvents, {
90
+ signal: AbortSignal.timeout(LONG_POLL_SERVER_TIMEOUT + 1000),
91
+ headers: {
92
+ [DF.lastReceived_nAR]: nAR_EventReceived
93
+ }
94
+ })
95
+ }
87
96
  }
package/src/Dashboard.css CHANGED
@@ -13,7 +13,7 @@
13
13
  --colorComboBoxHeaderBackground: #fff;
14
14
  --colorComboBoxBackground: #f7f7f7;
15
15
  --colorHeaderBackground: #f3f3f3;
16
- --colorSecondaryButtonBackground: transparent;
16
+ --colorSecondaryButtonBackground: #f3f3f3;
17
17
  --colorSecondaryAction: #555;
18
18
  --colorDisabledMockSelector: #444;
19
19
  --colorHover: #dfefff;
@@ -78,18 +78,22 @@ a, button, input[type=checkbox] {
78
78
 
79
79
  select {
80
80
  font-size: 100%;
81
- background: var(--colorComboBoxBackground);
82
81
  color: var(--colorText);
83
82
  cursor: pointer;
84
83
  outline: 0;
85
84
  border-radius: var(--radius);
85
+ appearance: none;
86
+ background: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23888888'><path d='M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z'/></svg>") no-repeat;
87
+ background-color: var(--colorComboBoxBackground);
88
+ background-size: 16px;
89
+ background-position: 100% center;
86
90
 
87
91
  &:enabled {
88
92
  box-shadow: var(--boxShadow1);
89
93
  }
90
94
  &:enabled:hover {
91
95
  cursor: pointer;
92
- background: var(--colorHover);
96
+ background-color: var(--colorHover);
93
97
  }
94
98
  &:disabled {
95
99
  cursor: not-allowed;
@@ -113,11 +117,11 @@ select {
113
117
  img {
114
118
  width: 130px;
115
119
  align-self: center;
116
- margin-right: 18px;
120
+ margin-right: 20px;
117
121
  }
118
122
 
119
123
  .Field {
120
- width: 120px;
124
+ width: 132px;
121
125
 
122
126
  span {
123
127
  display: flex;
@@ -131,11 +135,11 @@ select {
131
135
  select {
132
136
  width: 100%;
133
137
  height: 28px;
134
- padding: 4px 2px;
138
+ padding: 4px 8px;
135
139
  border-right: 3px solid transparent;
136
140
  margin-top: 4px;
137
141
  font-size: 11px;
138
- background: var(--colorComboBoxHeaderBackground);
142
+ background-color: var(--colorComboBoxHeaderBackground);
139
143
  border-radius: var(--radius);
140
144
  }
141
145
 
@@ -172,15 +176,15 @@ select {
172
176
  gap: 4px;
173
177
 
174
178
  input:disabled + span {
175
- opacity: 0.7;
179
+ opacity: 0.8;
176
180
  }
177
181
  }
178
182
  }
179
183
  }
180
184
 
181
185
  .BulkSelector {
182
- appearance: none;
183
- text-align: center;
186
+ background-image: none;
187
+ text-align-last: center;
184
188
  }
185
189
 
186
190
  .ResetButton {
@@ -276,11 +280,12 @@ select {
276
280
  width: 260px;
277
281
  height: 30px;
278
282
  border: 0;
279
- border-left: 3px solid transparent;
283
+ padding-right: 5px;
280
284
  text-align: right;
281
285
  direction: rtl;
282
286
  text-overflow: ellipsis;
283
287
  font-size: 12px;
288
+ background-position: 2px center;
284
289
 
285
290
  &.nonDefault {
286
291
  font-weight: bold;
@@ -337,7 +342,6 @@ select {
337
342
  > svg {
338
343
  width: 18px;
339
344
  height: 18px;
340
- border: 1px solid var(--colorSecondaryAction);
341
345
  vertical-align: bottom;
342
346
  fill: var(--colorSecondaryAction);
343
347
  border-radius: 50%;
package/src/Dashboard.js CHANGED
@@ -1,6 +1,6 @@
1
+ import { DEFAULT_500_COMMENT } from './ApiConstants.js'
1
2
  import { parseFilename } from './Filename.js'
2
3
  import { Commander } from './Commander.js'
3
- import { DEFAULT_500_COMMENT } from './ApiConstants.js'
4
4
 
5
5
 
6
6
  function syntaxHighlightJson(textBody) {
@@ -10,7 +10,6 @@ function syntaxHighlightJson(textBody) {
10
10
  : false
11
11
  }
12
12
 
13
-
14
13
  const Strings = {
15
14
  bulk_select: 'Bulk Select',
16
15
  bulk_select_disabled_title: 'No mock files have comments, which are anything within parentheses on the filename.',
@@ -57,9 +56,15 @@ const CSS = {
57
56
  }
58
57
 
59
58
  const r = createElement
60
-
61
59
  const mockaton = new Commander(window.location.origin)
62
60
 
61
+ init()
62
+ pollAR_Events() // Add or Remove Mocks from File System
63
+ document.addEventListener('visibilitychange', () => {
64
+ if (!document.hidden)
65
+ pollAR_Events()
66
+ })
67
+
63
68
  function init() {
64
69
  return Promise.all([
65
70
  mockaton.listMocks(),
@@ -69,20 +74,18 @@ function init() {
69
74
  mockaton.getProxyFallback(),
70
75
  mockaton.listStaticFiles()
71
76
  ].map(api => api.then(response => response.ok && response.json())))
72
- .then(data => document.body.replaceChildren(...App(data)))
77
+ .then(data => document.body.replaceChildren(App(data)))
73
78
  .catch(onError)
74
79
  }
75
- init()
76
80
 
77
81
  function App([brokersByMethod, cookies, comments, collectProxied, fallbackAddress, staticFiles]) {
78
- return [
79
- r(Header, { cookies, comments, fallbackAddress, collectProxied }),
80
- r(MockList, { brokersByMethod, canProxy: Boolean(fallbackAddress) }),
81
- r(StaticFilesList, { staticFiles })
82
- ]
82
+ return (
83
+ r('div', null,
84
+ r(Header, { cookies, comments, fallbackAddress, collectProxied }),
85
+ r(MockList, { brokersByMethod, canProxy: Boolean(fallbackAddress) }),
86
+ r(StaticFilesList, { staticFiles })))
83
87
  }
84
88
 
85
-
86
89
  // Header ===============
87
90
 
88
91
  function Header({ cookies, comments, fallbackAddress, collectProxied }) {
@@ -500,11 +503,39 @@ function CloudIcon() {
500
503
  }
501
504
 
502
505
 
506
+ // AR Events (Add or Remove mock) ============
507
+
508
+ pollAR_Events.isPolling = false
509
+ pollAR_Events.oldAR_EventsCount = 0
510
+ async function pollAR_Events() {
511
+ if (pollAR_Events.isPolling || document.hidden)
512
+ return
513
+ try {
514
+ pollAR_Events.isPolling = true
515
+ const response = await mockaton.getAR_EventsCount(pollAR_Events.oldAR_EventsCount)
516
+ if (response.ok) {
517
+ const nAR_Events = await response.json()
518
+ if (pollAR_Events.oldAR_EventsCount !== nAR_Events) { // because it could be < or >
519
+ pollAR_Events.oldAR_EventsCount = nAR_Events
520
+ await init()
521
+ }
522
+ pollAR_Events.isPolling = false
523
+ pollAR_Events()
524
+ }
525
+ else
526
+ throw response.status
527
+ }
528
+ catch (_) {
529
+ pollAR_Events.isPolling = false
530
+ setTimeout(pollAR_Events, 5000)
531
+ }
532
+ }
533
+
503
534
 
504
535
  // Utils ============
505
536
 
506
537
  function cssClass(...args) {
507
- return args.filter(a => a).join(' ')
538
+ return args.filter(Boolean).join(' ')
508
539
  }
509
540
 
510
541
 
@@ -531,7 +562,7 @@ function createElement(elem, props = null, ...children) {
531
562
  node[key] = value
532
563
  else
533
564
  node.setAttribute(key, value)
534
- node.append(...children.flat().filter(a => a))
565
+ node.append(...children.flat().filter(Boolean))
535
566
  return node
536
567
  }
537
568
 
@@ -539,7 +570,7 @@ function createSvgElement(tagName, props, ...children) {
539
570
  const elem = document.createElementNS('http://www.w3.org/2000/svg', tagName)
540
571
  for (const [key, value] of Object.entries(props))
541
572
  elem.setAttribute(key, value)
542
- elem.append(...children.flat().filter(a => a))
573
+ elem.append(...children.flat().filter(Boolean))
543
574
  return elem
544
575
  }
545
576
 
package/src/MockBroker.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { config } from './config.js'
2
- import { DEFAULT_500_COMMENT, DEFAULT_MOCK_COMMENT } from './ApiConstants.js'
3
2
  import { includesComment, extractComments, parseFilename } from './Filename.js'
3
+ import { DEFAULT_500_COMMENT, DEFAULT_MOCK_COMMENT } from './ApiConstants.js'
4
4
 
5
5
 
6
6
  // MockBroker is a state for a particular route. It knows the available mock files
@@ -70,10 +70,9 @@ export class MockBroker {
70
70
  this.mocks.sort()
71
71
  const defaults = this.mocks.filter(file => includesComment(file, DEFAULT_MOCK_COMMENT))
72
72
  const temp500 = this.mocks.filter(file => includesComment(file, DEFAULT_500_COMMENT))
73
- this.mocks = this.mocks.filter(file => !defaults.includes(file) && !temp500.includes(file))
74
73
  this.mocks = [
75
74
  ...defaults,
76
- ...this.mocks,
75
+ ...this.mocks.filter(file => !defaults.includes(file) && !temp500.includes(file)),
77
76
  ...temp500
78
77
  ]
79
78
  }
@@ -4,9 +4,9 @@ import { proxy } from './ProxyRelay.js'
4
4
  import { cookie } from './cookie.js'
5
5
  import { config } from './config.js'
6
6
  import { applyPlugins } from './MockDispatcherPlugins.js'
7
- import * as mockBrokerCollection from './mockBrokersCollection.js'
8
7
  import { BodyReaderError } from './utils/http-request.js'
9
- import { sendInternalServerError, sendNotFound, sendBadRequest } from './utils/http-response.js'
8
+ import * as mockBrokerCollection from './mockBrokersCollection.js'
9
+ import { sendInternalServerError, sendNotFound, sendUnprocessableContent } from './utils/http-response.js'
10
10
 
11
11
 
12
12
  export async function dispatchMock(req, response) {
@@ -38,7 +38,7 @@ export async function dispatchMock(req, response) {
38
38
  }
39
39
  catch (error) {
40
40
  if (error instanceof BodyReaderError)
41
- sendBadRequest(response, error)
41
+ sendUnprocessableContent(response, error.name)
42
42
  else if (error.code === 'ENOENT') // mock-file has been deleted
43
43
  sendNotFound(response)
44
44
  else if (error.code === 'ERR_UNKNOWN_FILE_EXTENSION') {
package/src/Mockaton.js CHANGED
@@ -1,15 +1,15 @@
1
- import { join } from 'node:path'
2
1
  import { createServer } from 'node:http'
3
- import { watch, existsSync } from 'node:fs'
4
2
 
5
3
  import { API } from './ApiConstants.js'
6
- import { dispatchMock } from './MockDispatcher.js'
7
4
  import { config, setup } from './config.js'
8
- import { sendNoContent } from './utils/http-response.js'
5
+ import { dispatchMock } from './MockDispatcher.js'
6
+ import { watchMocksDir } from './Watcher.js'
7
+ import { BodyReaderError } from './utils/http-request.js'
9
8
  import * as mockBrokerCollection from './mockBrokersCollection.js'
10
9
  import { dispatchStatic, isStatic } from './StaticDispatcher.js'
11
10
  import { setCorsHeaders, isPreflight } from './utils/http-cors.js'
12
11
  import { apiPatchRequests, apiGetRequests } from './Api.js'
12
+ import { sendNoContent, sendInternalServerError, sendUnprocessableContent } from './utils/http-response.js'
13
13
 
14
14
 
15
15
  process.on('unhandledRejection', error => { throw error })
@@ -17,16 +17,7 @@ process.on('unhandledRejection', error => { throw error })
17
17
  export function Mockaton(options) {
18
18
  setup(options)
19
19
  mockBrokerCollection.init()
20
-
21
- watch(config.mocksDir, { recursive: true, persistent: false },
22
- function handleAddedOrDeletedMocks(_, file) {
23
- if (!file)
24
- return
25
- if (existsSync(join(config.mocksDir, file)))
26
- mockBrokerCollection.registerMock(file, 'isFromWatcher')
27
- else
28
- mockBrokerCollection.unregisterMock(file)
29
- })
20
+ watchMocksDir()
30
21
 
31
22
  return createServer(onRequest).listen(config.port, config.host, function (error) {
32
23
  const { address, port } = this.address()
@@ -41,34 +32,42 @@ export function Mockaton(options) {
41
32
  }
42
33
 
43
34
  async function onRequest(req, response) {
44
- req.on('error', console.error)
45
35
  response.on('error', console.error)
46
- response.setHeader('Server', 'Mockaton')
47
36
 
48
- if (config.corsAllowed)
49
- setCorsHeaders(req, response, {
50
- origins: config.corsOrigins,
51
- headers: config.corsHeaders,
52
- methods: config.corsMethods,
53
- maxAge: config.corsMaxAge,
54
- credentials: config.corsCredentials,
55
- exposedHeaders: config.corsExposedHeaders
56
- })
37
+ try {
38
+ response.setHeader('Server', 'Mockaton')
57
39
 
58
- const { url, method } = req
40
+ if (config.corsAllowed)
41
+ setCorsHeaders(req, response, {
42
+ origins: config.corsOrigins,
43
+ headers: config.corsHeaders,
44
+ methods: config.corsMethods,
45
+ maxAge: config.corsMaxAge,
46
+ credentials: config.corsCredentials,
47
+ exposedHeaders: config.corsExposedHeaders
48
+ })
59
49
 
60
- if (isPreflight(req))
61
- sendNoContent(response)
50
+ const { url, method } = req
62
51
 
63
- else if (method === 'PATCH' && apiPatchRequests.has(url))
64
- await apiPatchRequests.get(url)(req, response)
52
+ if (isPreflight(req))
53
+ sendNoContent(response)
65
54
 
66
- else if (method === 'GET' && apiGetRequests.has(url))
67
- apiGetRequests.get(url)(req, response)
55
+ else if (method === 'PATCH' && apiPatchRequests.has(url))
56
+ await apiPatchRequests.get(url)(req, response)
68
57
 
69
- else if (method === 'GET' && isStatic(req))
70
- await dispatchStatic(req, response)
58
+ else if (method === 'GET' && apiGetRequests.has(url))
59
+ apiGetRequests.get(url)(req, response)
71
60
 
72
- else
73
- await dispatchMock(req, response)
61
+ else if (method === 'GET' && isStatic(req))
62
+ await dispatchStatic(req, response)
63
+
64
+ else
65
+ await dispatchMock(req, response)
66
+ }
67
+ catch (error) {
68
+ if (error instanceof BodyReaderError)
69
+ sendUnprocessableContent(response, error.name)
70
+ else
71
+ sendInternalServerError(response, error)
72
+ }
74
73
  }
@@ -501,7 +501,7 @@ export default function (req, response) {
501
501
  async function testStaticFileServing() {
502
502
  await describe('Static File Serving', () => {
503
503
  it('404 path traversal', async () => {
504
- const res = await request('/../../../etc/passwd')
504
+ const res = await request('/../../../../../../../../../../../%2E%2E/etc/passwd')
505
505
  equal(res.status, 404)
506
506
  })
507
507
 
package/src/ProxyRelay.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { join } from 'node:path'
2
- import { existsSync } from 'node:fs'
3
2
  import { randomUUID } from 'node:crypto'
4
- import { write } from './utils/fs.js'
3
+
5
4
  import { config } from './config.js'
6
5
  import { extFor } from './utils/mime.js'
7
6
  import { readBody } from './utils/http-request.js'
7
+ import { write, isFile } from './utils/fs.js'
8
8
  import { makeMockFilename } from './Filename.js'
9
9
 
10
10
 
@@ -26,7 +26,7 @@ export async function proxy(req, response) {
26
26
  if (config.collectProxied) {
27
27
  const ext = extFor(proxyResponse.headers.get('content-type'))
28
28
  let filename = makeMockFilename(req.url, req.method, proxyResponse.status, ext)
29
- if (existsSync(join(config.mocksDir, filename))) // TESTME
29
+ if (isFile(join(config.mocksDir, filename))) // TESTME
30
30
  filename = makeMockFilename(req.url + `(${randomUUID()})`, req.method, proxyResponse.status, ext)
31
31
  write(join(config.mocksDir, filename), body)
32
32
  }
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path'
2
- import fs, { readFileSync, realpathSync } from 'node:fs'
2
+ import fs, { readFileSync } from 'node:fs'
3
3
 
4
4
  import { config } from './config.js'
5
5
  import { mimeFor } from './utils/mime.js'
@@ -10,12 +10,12 @@ import { sendInternalServerError } from './utils/http-response.js'
10
10
  export function isStatic(req) {
11
11
  if (!config.staticDir)
12
12
  return false
13
- const f = resolvedAllowedPath(req.url)
13
+ const f = resolvePath(req.url)
14
14
  return f && !config.ignore.test(f)
15
15
  }
16
16
 
17
17
  export async function dispatchStatic(req, response) {
18
- const file = resolvedAllowedPath(req.url)
18
+ const file = resolvePath(req.url)
19
19
  if (req.headers.range)
20
20
  await sendPartialContent(response, req.headers.range, file)
21
21
  else {
@@ -24,17 +24,12 @@ export async function dispatchStatic(req, response) {
24
24
  }
25
25
  }
26
26
 
27
- function resolvedAllowedPath(url) {
28
- try {
29
- let candidate = realpathSync(join(config.staticDir, url))
30
- if (!candidate.startsWith(config.staticDir))
31
- return false
32
- if (isDirectory(candidate))
33
- candidate = join(candidate, 'index.html')
34
- if (isFile(candidate))
35
- return candidate
36
- }
37
- catch {}
27
+ function resolvePath(url) { // url is absolute e.g. /home/../.. => /
28
+ let candidate = join(config.staticDir, url)
29
+ if (isDirectory(candidate))
30
+ candidate = join(candidate, 'index.html')
31
+ if (isFile(candidate))
32
+ return candidate
38
33
  }
39
34
 
40
35
  async function sendPartialContent(response, range, file) {
package/src/Watcher.js ADDED
@@ -0,0 +1,48 @@
1
+ import { join } from 'node:path'
2
+ import { watch } from 'node:fs'
3
+ import { EventEmitter } from 'node:events'
4
+
5
+ import { config } from './config.js'
6
+ import { isFile } from './utils/fs.js'
7
+ import * as mockBrokerCollection from './mockBrokersCollection.js'
8
+
9
+
10
+ let nAR_Events = 0 // AR = Add or Remove Mock
11
+
12
+ export function countAR_Events() {
13
+ return nAR_Events
14
+ }
15
+
16
+
17
+ const emitter = new EventEmitter()
18
+
19
+ export function subscribeAR_EventListener(callback) {
20
+ emitter.on('AR', callback)
21
+ }
22
+ export function unsubscribeAR_EventListener(callback) {
23
+ emitter.removeListener('AR', callback)
24
+ }
25
+
26
+ function emitAddOrRemoveMock() {
27
+ nAR_Events++
28
+ emitter.emit('AR')
29
+ }
30
+
31
+ export function watchMocksDir() {
32
+ const dir = config.mocksDir
33
+ watch(dir, { recursive: true, persistent: false }, (_, file) => {
34
+ if (!file)
35
+ return
36
+ if (isFile(join(dir, file))) {
37
+ if (mockBrokerCollection.registerMock(file, 'isFromWatcher'))
38
+ emitAddOrRemoveMock()
39
+ }
40
+ else {
41
+ mockBrokerCollection.unregisterMock(file)
42
+ emitAddOrRemoveMock()
43
+ }
44
+ })
45
+ }
46
+
47
+ // TODO staticDir, config changes
48
+ // TODO think about throttling e.g. bulk deletes/remove files
@@ -35,24 +35,28 @@ export function init() {
35
35
  })
36
36
  }
37
37
 
38
+ /** @returns {boolean} registered */
38
39
  export function registerMock(file, isFromWatcher) {
39
40
  if (getBrokerByFilename(file)?.hasMock(file)
40
41
  || config.ignore.test(file)
41
42
  || !filenameIsValid(file))
42
- return
43
+ return false
43
44
 
44
45
  const { method, urlMask } = parseFilename(file)
45
46
  collection[method] ??= {}
47
+
46
48
  if (!collection[method][urlMask])
47
49
  collection[method][urlMask] = new MockBroker(file)
48
50
  else
49
51
  collection[method][urlMask].register(file)
50
52
 
51
- if (isFromWatcher) {
52
- if (!this.file)
53
- collection[method][urlMask].selectDefaultFile()
53
+ if (isFromWatcher && !this.file)
54
+ collection[method][urlMask].selectDefaultFile()
55
+
56
+ if (isFromWatcher)
54
57
  collection[method][urlMask].ensureItHas500()
55
- }
58
+
59
+ return true
56
60
  }
57
61
 
58
62
  export function unregisterMock(file) {
@@ -3,7 +3,7 @@ export const StandardMethods = [
3
3
  'HEAD', 'OPTIONS', 'TRACE', 'CONNECT'
4
4
  ]
5
5
 
6
- export class BodyReaderError extends Error {}
6
+ export class BodyReaderError extends Error {name = 'BodyReaderError'}
7
7
 
8
8
  export const parseJSON = req => readBody(req, JSON.parse)
9
9
 
@@ -26,12 +26,6 @@ export function sendDashboardFile(response, file) {
26
26
  response.end(readFileSync(file, 'utf8'))
27
27
  }
28
28
 
29
- export function sendBadRequest(response, error) {
30
- console.error(error)
31
- response.statusCode = 400
32
- response.end()
33
- }
34
-
35
29
  export function sendNotFound(response) {
36
30
  response.statusCode = 404
37
31
  response.end()