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 +1 -1
- package/TODO.md +3 -1
- package/package.json +1 -1
- package/src/Api.js +91 -112
- package/src/ApiConstants.js +6 -2
- package/src/Commander.js +10 -1
- package/src/Dashboard.css +16 -12
- package/src/Dashboard.js +45 -14
- package/src/MockBroker.js +2 -3
- package/src/MockDispatcher.js +3 -3
- package/src/Mockaton.js +35 -36
- package/src/Mockaton.test.js +1 -1
- package/src/ProxyRelay.js +3 -3
- package/src/StaticDispatcher.js +9 -14
- package/src/Watcher.js +48 -0
- package/src/mockBrokersCollection.js +9 -5
- package/src/utils/http-request.js +1 -1
- package/src/utils/http-response.js +0 -6
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
|
-
##
|
|
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
|
-
-
|
|
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
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 {
|
|
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.
|
|
34
|
-
[API.
|
|
35
|
-
[API.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
|
package/src/ApiConstants.js
CHANGED
|
@@ -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:
|
|
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:
|
|
120
|
+
margin-right: 20px;
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
.Field {
|
|
120
|
-
width:
|
|
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
|
|
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.
|
|
179
|
+
opacity: 0.8;
|
|
176
180
|
}
|
|
177
181
|
}
|
|
178
182
|
}
|
|
179
183
|
}
|
|
180
184
|
|
|
181
185
|
.BulkSelector {
|
|
182
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
80
|
-
|
|
81
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
}
|
package/src/MockDispatcher.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
sendNoContent(response)
|
|
50
|
+
const { url, method } = req
|
|
62
51
|
|
|
63
|
-
|
|
64
|
-
|
|
52
|
+
if (isPreflight(req))
|
|
53
|
+
sendNoContent(response)
|
|
65
54
|
|
|
66
|
-
|
|
67
|
-
|
|
55
|
+
else if (method === 'PATCH' && apiPatchRequests.has(url))
|
|
56
|
+
await apiPatchRequests.get(url)(req, response)
|
|
68
57
|
|
|
69
|
-
|
|
70
|
-
|
|
58
|
+
else if (method === 'GET' && apiGetRequests.has(url))
|
|
59
|
+
apiGetRequests.get(url)(req, response)
|
|
71
60
|
|
|
72
|
-
|
|
73
|
-
|
|
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
|
}
|
package/src/Mockaton.test.js
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
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 (
|
|
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
|
}
|
package/src/StaticDispatcher.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
|
-
import fs, { readFileSync
|
|
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 =
|
|
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 =
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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) {
|
|
@@ -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()
|