mockaton 8.8.2 → 8.9.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.
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.0",
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,27 @@ 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)
76
- }
77
- catch (error) {
78
- sendBadRequest(response, error)
79
- }
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
+ function onAddOrRemoveMock() {
82
+ unsubscribeAR_EventListener(onAddOrRemoveMock)
83
+ sendJSON(response, countAR_Events())
84
+ }
85
+ response.setTimeout(LONG_POLL_SERVER_TIMEOUT, onAddOrRemoveMock)
86
+ req.on('error', () => {
87
+ unsubscribeAR_EventListener(onAddOrRemoveMock)
88
+ response.destroy()
89
+ })
90
+ subscribeAR_EventListener(onAddOrRemoveMock)
80
91
  }
81
92
 
82
93
 
94
+
83
95
  /* === PATCH === */
84
96
 
85
97
  function reinitialize(_, response) {
@@ -88,124 +100,84 @@ function reinitialize(_, response) {
88
100
  }
89
101
 
90
102
  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
- }
103
+ const error = cookie.setCurrent(await parseJSON(req))
104
+ if (error)
105
+ sendUnprocessableContent(response, error)
106
+ else
107
+ sendOK(response)
101
108
  }
102
109
 
103
110
  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)
111
+ const file = await parseJSON(req)
112
+ const broker = mockBrokersCollection.getBrokerByFilename(file)
113
+ if (!broker || !broker.hasMock(file))
114
+ sendUnprocessableContent(response, `Missing Mock: ${file}`)
115
+ else {
116
+ broker.updateFile(file)
117
+ sendOK(response)
116
118
  }
117
119
  }
118
120
 
119
121
  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)
122
+ const body = await parseJSON(req)
123
+ const delayed = body[DF.delayed]
124
+ const broker = mockBrokersCollection.getBrokerForUrl(
125
+ body[DF.routeMethod],
126
+ body[DF.routeUrlMask])
127
+
128
+ if (!broker) // TESTME
129
+ sendUnprocessableContent(response, `Route does not exist: ${body[DF.routeUrlMask]} ${body[DF.routeUrlMask]}`)
130
+ else if (typeof delayed !== 'boolean')
131
+ sendUnprocessableContent(response, `Expected a boolean for "delayed"`) // TESTME
132
+ else {
133
+ broker.updateDelay(body[DF.delayed])
134
+ sendOK(response)
138
135
  }
139
136
  }
140
137
 
141
138
  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)
139
+ const body = await parseJSON(req)
140
+ const proxied = body[DF.proxied]
141
+ const broker = mockBrokersCollection.getBrokerForUrl(
142
+ body[DF.routeMethod],
143
+ body[DF.routeUrlMask])
144
+
145
+ if (!broker)
146
+ sendUnprocessableContent(response, `Route does not exist: ${body[DF.routeUrlMask]} ${body[DF.routeUrlMask]}`)
147
+ else if (typeof proxied !== 'boolean')
148
+ sendUnprocessableContent(response, `Expected a boolean for "proxied"`)
149
+ else if (proxied && !config.proxyFallback)
150
+ sendUnprocessableContent(response, `There’s no proxy fallback`)
151
+ else {
152
+ broker.updateProxied(proxied)
153
+ sendOK(response)
162
154
  }
163
155
  }
164
156
 
165
157
  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
- }
158
+ const fallback = await parseJSON(req)
159
+ if (fallback && !URL.canParse(fallback)) {
160
+ sendUnprocessableContent(response)
161
+ return
162
+ }
163
+ if (!fallback) // TESTME
164
+ mockBrokersCollection.ensureAllRoutesHaveSelectedMock()
165
+ config.proxyFallback = fallback
166
+ sendOK(response)
180
167
  }
181
168
 
182
169
  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
- }
170
+ config.collectProxied = await parseJSON(req)
171
+ sendOK(response)
190
172
  }
191
173
 
192
174
  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
- }
175
+ mockBrokersCollection.setMocksMatchingComment(await parseJSON(req))
176
+ sendOK(response)
200
177
  }
201
178
 
202
179
  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
- }
180
+ config.corsAllowed = await parseJSON(req)
181
+ sendOK(response)
210
182
  }
211
183
 
@@ -12,7 +12,8 @@ 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)
@@ -25,3 +26,5 @@ export const DF = { // Dashboard Fields (XHR)
25
26
  export const DEFAULT_500_COMMENT = '(Mockaton 500)'
26
27
  export const DEFAULT_MOCK_COMMENT = '(default)'
27
28
  export const EXT_FOR_UNKNOWN_MIME = 'unknown'
29
+
30
+ 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,10 @@ export class Commander {
84
84
  reset() {
85
85
  return this.#patch(API.reset)
86
86
  }
87
+
88
+ getAR_EventsCount() {
89
+ return fetch(API.arEvents, {
90
+ signal: AbortSignal.timeout(LONG_POLL_SERVER_TIMEOUT + 1000)
91
+ })
92
+ }
87
93
  }
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()
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
 
@@ -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()