mockaton 8.13.0 → 8.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/package.json +1 -1
- package/src/Api.js +42 -12
- package/src/ApiConstants.js +3 -0
- package/src/Commander.js +14 -0
- package/src/Dashboard.css +36 -42
- package/src/Dashboard.js +150 -68
- package/src/Mockaton.js +2 -1
- package/src/StaticDispatcher.js +68 -44
- package/src/utils/fs.js +9 -4
- package/src/utils/http-response.js +28 -1
package/README.md
CHANGED
|
@@ -75,9 +75,7 @@ They will be saved in your `config.mocksDir` following the filename convention.
|
|
|
75
75
|
<br/>
|
|
76
76
|
|
|
77
77
|
|
|
78
|
-
## Basic Usage
|
|
79
|
-
Mockaton is a Node.js program.
|
|
80
|
-
|
|
78
|
+
## Basic Usage (See below for Node < 23.6)
|
|
81
79
|
```sh
|
|
82
80
|
npm install mockaton --save-dev
|
|
83
81
|
```
|
|
@@ -98,6 +96,14 @@ Mockaton({
|
|
|
98
96
|
node my-mockaton.js
|
|
99
97
|
```
|
|
100
98
|
|
|
99
|
+
### Node < 23.6 + TypeScript
|
|
100
|
+
If you want to write mocks in TypeScript in a version older than Node 23.6:
|
|
101
|
+
```shell
|
|
102
|
+
npm install tsx
|
|
103
|
+
node --import=tsx my-mockaton.js
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
|
|
101
107
|
<br/>
|
|
102
108
|
|
|
103
109
|
## Demo App (Vite)
|
package/package.json
CHANGED
package/src/Api.js
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
import { join } from 'node:path'
|
|
7
7
|
import { cookie } from './cookie.js'
|
|
8
|
-
import { uiSyncVersion } from './Watcher.js'
|
|
9
8
|
import { parseJSON } from './utils/http-request.js'
|
|
10
|
-
import {
|
|
9
|
+
import { uiSyncVersion } from './Watcher.js'
|
|
11
10
|
import * as mockBrokersCollection from './mockBrokersCollection.js'
|
|
12
|
-
import { config,
|
|
11
|
+
import { config, ConfigValidator } from './config.js'
|
|
12
|
+
import { getStaticFilesCollection, findStaticBrokerByRoute } from './StaticDispatcher.js'
|
|
13
13
|
import { DF, API, LONG_POLL_SERVER_TIMEOUT } from './ApiConstants.js'
|
|
14
14
|
import { sendOK, sendJSON, sendUnprocessableContent, sendFile } from './utils/http-response.js'
|
|
15
15
|
|
|
@@ -47,7 +47,9 @@ export const apiPatchRequests = new Map([
|
|
|
47
47
|
[API.fallback, updateProxyFallback],
|
|
48
48
|
[API.bulkSelect, bulkUpdateBrokersByCommentTag],
|
|
49
49
|
[API.globalDelay, setGlobalDelay],
|
|
50
|
-
[API.collectProxied, setCollectProxied]
|
|
50
|
+
[API.collectProxied, setCollectProxied],
|
|
51
|
+
[API.delayStatic, setStaticRouteIsDelayed],
|
|
52
|
+
[API.notFoundStatic, setStaticRouteIsNotFound]
|
|
51
53
|
])
|
|
52
54
|
|
|
53
55
|
|
|
@@ -64,18 +66,13 @@ function serveDashboardAsset(f) {
|
|
|
64
66
|
|
|
65
67
|
function listCookies(_, response) { sendJSON(response, cookie.list()) }
|
|
66
68
|
function listComments(_, response) { sendJSON(response, mockBrokersCollection.extractAllComments()) }
|
|
69
|
+
function listStaticFiles(req, response) { sendJSON(response, getStaticFilesCollection()) }
|
|
67
70
|
function getGlobalDelay(_, response) { sendJSON(response, config.delay) }
|
|
68
71
|
function listMockBrokers(_, response) { sendJSON(response, mockBrokersCollection.getAll()) }
|
|
69
72
|
function getProxyFallback(_, response) { sendJSON(response, config.proxyFallback) }
|
|
70
73
|
function getIsCorsAllowed(_, response) { sendJSON(response, config.corsAllowed) }
|
|
71
74
|
function getCollectProxied(_, response) { sendJSON(response, config.collectProxied) }
|
|
72
75
|
|
|
73
|
-
function listStaticFiles(req, response) {
|
|
74
|
-
sendJSON(response, config.staticDir
|
|
75
|
-
? listFilesRecursively(config.staticDir).filter(isFileAllowed)
|
|
76
|
-
: [])
|
|
77
|
-
}
|
|
78
|
-
|
|
79
76
|
function longPollClientSyncVersion(req, response) {
|
|
80
77
|
if (uiSyncVersion.version !== Number(req.headers[DF.syncVersion])) {
|
|
81
78
|
// e.g., tab was hidden while new mocks were added or removed
|
|
@@ -134,7 +131,7 @@ async function setRouteIsDelayed(req, response) {
|
|
|
134
131
|
body[DF.routeUrlMask])
|
|
135
132
|
|
|
136
133
|
if (!broker) // TESTME
|
|
137
|
-
sendUnprocessableContent(response, `Route does not exist: ${body[DF.
|
|
134
|
+
sendUnprocessableContent(response, `Route does not exist: ${body[DF.routeMethod]} ${body[DF.routeUrlMask]}`)
|
|
138
135
|
else if (typeof delayed !== 'boolean')
|
|
139
136
|
sendUnprocessableContent(response, `Expected a boolean for "delayed"`) // TESTME
|
|
140
137
|
else {
|
|
@@ -151,7 +148,7 @@ async function setRouteIsProxied(req, response) { // TESTME
|
|
|
151
148
|
body[DF.routeUrlMask])
|
|
152
149
|
|
|
153
150
|
if (!broker)
|
|
154
|
-
sendUnprocessableContent(response, `Route does not exist: ${body[DF.
|
|
151
|
+
sendUnprocessableContent(response, `Route does not exist: ${body[DF.routeMethod]} ${body[DF.routeUrlMask]}`)
|
|
155
152
|
else if (typeof proxied !== 'boolean')
|
|
156
153
|
sendUnprocessableContent(response, `Expected a boolean for "proxied"`)
|
|
157
154
|
else if (proxied && !config.proxyFallback)
|
|
@@ -198,3 +195,36 @@ async function setGlobalDelay(req, response) { // TESTME
|
|
|
198
195
|
config.delay = parseInt(await parseJSON(req), 10)
|
|
199
196
|
sendOK(response)
|
|
200
197
|
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async function setStaticRouteIsNotFound(req, response) {
|
|
201
|
+
const body = await parseJSON(req)
|
|
202
|
+
const shouldBeNotFound = body[DF.shouldBeNotFound]
|
|
203
|
+
const broker = findStaticBrokerByRoute(body[DF.routeUrlMask])
|
|
204
|
+
|
|
205
|
+
if (!broker) // TESTME
|
|
206
|
+
sendUnprocessableContent(response, `Route does not exist: ${body[DF.routeUrlMask]}`)
|
|
207
|
+
else if (typeof shouldBeNotFound !== 'boolean')
|
|
208
|
+
sendUnprocessableContent(response, `Expected a boolean for "not found"`) // TESTME
|
|
209
|
+
else {
|
|
210
|
+
broker.updateNotFound(body[DF.shouldBeNotFound])
|
|
211
|
+
sendOK(response)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async function setStaticRouteIsDelayed(req, response) {
|
|
217
|
+
const body = await parseJSON(req)
|
|
218
|
+
const shouldBeNotFound = body[DF.delayed]
|
|
219
|
+
const broker = findStaticBrokerByRoute(body[DF.routeUrlMask])
|
|
220
|
+
|
|
221
|
+
if (!broker) // TESTME
|
|
222
|
+
sendUnprocessableContent(response, `Route does not exist: ${body[DF.routeUrlMask]}`)
|
|
223
|
+
else if (typeof shouldBeNotFound !== 'boolean')
|
|
224
|
+
sendUnprocessableContent(response, `Expected a boolean for "delayed"`) // TESTME
|
|
225
|
+
else {
|
|
226
|
+
broker.updateDelayed(body[DF.delayed])
|
|
227
|
+
sendOK(response)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
package/src/ApiConstants.js
CHANGED
|
@@ -7,9 +7,11 @@ export const API = {
|
|
|
7
7
|
cookies: MOUNT + '/cookies',
|
|
8
8
|
cors: MOUNT + '/cors',
|
|
9
9
|
delay: MOUNT + '/delay',
|
|
10
|
+
delayStatic: MOUNT + '/delay-static',
|
|
10
11
|
fallback: MOUNT + '/fallback',
|
|
11
12
|
globalDelay: MOUNT + '/global-delay',
|
|
12
13
|
mocks: MOUNT + '/mocks',
|
|
14
|
+
notFoundStatic: MOUNT + '/not-found-static',
|
|
13
15
|
proxied: MOUNT + '/proxied',
|
|
14
16
|
reset: MOUNT + '/reset',
|
|
15
17
|
select: MOUNT + '/select',
|
|
@@ -22,6 +24,7 @@ export const DF = { // Dashboard Fields (XHR)
|
|
|
22
24
|
routeUrlMask: 'route_url_mask',
|
|
23
25
|
delayed: 'delayed',
|
|
24
26
|
proxied: 'proxied',
|
|
27
|
+
shouldBeNotFound: 'should_be_not_found',
|
|
25
28
|
syncVersion: 'last_received_sync_version'
|
|
26
29
|
}
|
|
27
30
|
|
package/src/Commander.js
CHANGED
|
@@ -37,6 +37,20 @@ export class Commander {
|
|
|
37
37
|
})
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
setStaticRouteIsDelayed(routeUrlMask, delayed) {
|
|
41
|
+
return this.#patch(API.delayStatic, {
|
|
42
|
+
[DF.routeUrlMask]: routeUrlMask,
|
|
43
|
+
[DF.delayed]: delayed
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setStaticRouteIs404(routeUrlMask, shouldBeNotFound) {
|
|
48
|
+
return this.#patch(API.notFoundStatic, {
|
|
49
|
+
[DF.routeUrlMask]: routeUrlMask,
|
|
50
|
+
[DF.shouldBeNotFound]: shouldBeNotFound
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
40
54
|
setRouteIsProxied(routeMethod, routeUrlMask, proxied) {
|
|
41
55
|
return this.#patch(API.proxied, {
|
|
42
56
|
[DF.routeMethod]: routeMethod,
|
package/src/Dashboard.css
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
:root {
|
|
2
2
|
--boxShadow1: 0 2px 1px -1px rgba(0, 0, 0, 0.15), 0 1px 1px 0 rgba(0, 0, 0, 0.15), 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
|
3
|
-
--radius:
|
|
3
|
+
--radius: 4px;
|
|
4
4
|
--radiusSmall: 4px;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
@media (prefers-color-scheme: light) {
|
|
8
8
|
:root {
|
|
9
9
|
--color4xxBackground: #ffedd1;
|
|
10
|
-
--colorAccent: #
|
|
10
|
+
--colorAccent: #0170cc;
|
|
11
11
|
--colorAccentAlt: #068185;
|
|
12
12
|
--colorBackground: #fff;
|
|
13
13
|
--colorComboBoxHeaderBackground: #fff;
|
|
14
|
-
--colorComboBoxBackground: #
|
|
14
|
+
--colorComboBoxBackground: #eee;
|
|
15
15
|
--colorHeaderBackground: #eee;
|
|
16
|
-
--colorSecondaryButtonBackground: #
|
|
16
|
+
--colorSecondaryButtonBackground: #eee;
|
|
17
17
|
--colorSecondaryAction: #555;
|
|
18
18
|
--colorDisabledMockSelector: #444;
|
|
19
19
|
--colorHover: #dfefff;
|
|
@@ -27,11 +27,10 @@
|
|
|
27
27
|
:root {
|
|
28
28
|
--color4xxBackground: #403630;
|
|
29
29
|
--colorAccent: #2495ff;
|
|
30
|
-
--colorAccentAlt: #00bf64;
|
|
31
30
|
--colorBackground: #161616;
|
|
32
31
|
--colorHeaderBackground: #090909;
|
|
33
|
-
--colorComboBoxBackground: #
|
|
34
|
-
--colorSecondaryButtonBackground: #
|
|
32
|
+
--colorComboBoxBackground: #2a2a2a;
|
|
33
|
+
--colorSecondaryButtonBackground: #2a2a2a;
|
|
35
34
|
--colorSecondaryAction: #999;
|
|
36
35
|
--colorComboBoxHeaderBackground: #222;
|
|
37
36
|
--colorDisabledMockSelector: #b9b9b9;
|
|
@@ -227,24 +226,29 @@ select {
|
|
|
227
226
|
}
|
|
228
227
|
|
|
229
228
|
|
|
230
|
-
.
|
|
229
|
+
.Main {
|
|
231
230
|
display: flex;
|
|
232
|
-
|
|
233
|
-
margin-top: 64px;
|
|
231
|
+
}
|
|
234
232
|
|
|
235
|
-
|
|
236
|
-
|
|
233
|
+
table {
|
|
234
|
+
border-collapse: collapse;
|
|
237
235
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
236
|
+
th {
|
|
237
|
+
padding-top: 20px;
|
|
238
|
+
padding-bottom: 2px;
|
|
239
|
+
padding-left: 99px;
|
|
240
|
+
text-align: left;
|
|
241
|
+
}
|
|
243
242
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
243
|
+
tr {
|
|
244
|
+
border-top: 2px solid transparent;
|
|
247
245
|
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.MockList {
|
|
249
|
+
display: flex;
|
|
250
|
+
align-items: flex-start;
|
|
251
|
+
margin-top: 64px;
|
|
248
252
|
|
|
249
253
|
&.empty {
|
|
250
254
|
margin-top: 80px;
|
|
@@ -254,6 +258,7 @@ select {
|
|
|
254
258
|
|
|
255
259
|
.PayloadViewer {
|
|
256
260
|
position: sticky;
|
|
261
|
+
margin-top: 62px;
|
|
257
262
|
top: 62px;
|
|
258
263
|
width: 50%;
|
|
259
264
|
margin-left: 20px;
|
|
@@ -292,7 +297,6 @@ select {
|
|
|
292
297
|
|
|
293
298
|
span {
|
|
294
299
|
opacity: 0.5;
|
|
295
|
-
filter: saturate(0.5);
|
|
296
300
|
}
|
|
297
301
|
|
|
298
302
|
&:hover {
|
|
@@ -336,7 +340,6 @@ select {
|
|
|
336
340
|
.DelayToggler,
|
|
337
341
|
.ProxyToggler {
|
|
338
342
|
display: flex;
|
|
339
|
-
margin-left: 8px;
|
|
340
343
|
|
|
341
344
|
> input {
|
|
342
345
|
appearance: none;
|
|
@@ -357,9 +360,9 @@ select {
|
|
|
357
360
|
}
|
|
358
361
|
|
|
359
362
|
.DelayToggler {
|
|
363
|
+
margin-left: 8px;
|
|
360
364
|
> input {
|
|
361
365
|
&:checked ~ svg {
|
|
362
|
-
border: 1px solid var(--colorBackground);
|
|
363
366
|
fill: var(--colorAccent);
|
|
364
367
|
background: var(--colorAccent);
|
|
365
368
|
stroke: var(--colorBackground);
|
|
@@ -373,11 +376,12 @@ select {
|
|
|
373
376
|
}
|
|
374
377
|
|
|
375
378
|
> svg {
|
|
376
|
-
width:
|
|
377
|
-
height:
|
|
379
|
+
width: 19px;
|
|
380
|
+
height: 19px;
|
|
378
381
|
stroke-width: 2.5px;
|
|
379
382
|
border-radius: 50%;
|
|
380
383
|
background: var(--colorSecondaryButtonBackground);
|
|
384
|
+
box-shadow: var(--boxShadow1);
|
|
381
385
|
}
|
|
382
386
|
}
|
|
383
387
|
|
|
@@ -385,10 +389,12 @@ select {
|
|
|
385
389
|
padding: 1px 3px;
|
|
386
390
|
background: var(--colorSecondaryButtonBackground);
|
|
387
391
|
border-radius: var(--radiusSmall);
|
|
392
|
+
box-shadow: var(--boxShadow1);
|
|
388
393
|
|
|
389
394
|
&:has(input:checked),
|
|
390
395
|
&:has(input:disabled) {
|
|
391
396
|
background: transparent;
|
|
397
|
+
box-shadow: none;
|
|
392
398
|
}
|
|
393
399
|
|
|
394
400
|
> input {
|
|
@@ -431,6 +437,7 @@ select {
|
|
|
431
437
|
.InternalServerErrorToggler {
|
|
432
438
|
display: flex;
|
|
433
439
|
margin-left: 8px;
|
|
440
|
+
margin-right: 12px;
|
|
434
441
|
cursor: pointer;
|
|
435
442
|
|
|
436
443
|
> input {
|
|
@@ -456,6 +463,7 @@ select {
|
|
|
456
463
|
color: var(--colorSecondaryAction);
|
|
457
464
|
border-radius: var(--radiusSmall);
|
|
458
465
|
background: var(--colorSecondaryButtonBackground);
|
|
466
|
+
box-shadow: var(--boxShadow1);
|
|
459
467
|
|
|
460
468
|
&:hover {
|
|
461
469
|
background: var(--colorLightRed);
|
|
@@ -491,26 +499,13 @@ select {
|
|
|
491
499
|
}
|
|
492
500
|
|
|
493
501
|
.StaticFilesList {
|
|
494
|
-
margin-top:
|
|
495
|
-
|
|
496
|
-
h2 {
|
|
497
|
-
margin-bottom: 8px;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
ul {
|
|
501
|
-
position: relative;
|
|
502
|
-
left: -6px;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
li {
|
|
506
|
-
list-style: none;
|
|
507
|
-
}
|
|
502
|
+
margin-top: 8px;
|
|
508
503
|
|
|
509
504
|
a {
|
|
510
505
|
display: inline-block;
|
|
511
|
-
padding: 6px;
|
|
506
|
+
padding: 6px 0;
|
|
512
507
|
border-radius: var(--radius);
|
|
513
|
-
color: var(--
|
|
508
|
+
color: var(--colorAccent);
|
|
514
509
|
text-decoration: none;
|
|
515
510
|
|
|
516
511
|
&:hover {
|
|
@@ -519,7 +514,6 @@ select {
|
|
|
519
514
|
|
|
520
515
|
span {
|
|
521
516
|
opacity: 0.5;
|
|
522
|
-
filter: saturate(0.5);
|
|
523
517
|
}
|
|
524
518
|
}
|
|
525
519
|
}
|
package/src/Dashboard.js
CHANGED
|
@@ -26,6 +26,7 @@ const Strings = {
|
|
|
26
26
|
internal_server_error: 'Internal Server Error',
|
|
27
27
|
mock: 'Mock',
|
|
28
28
|
no_mocks_found: 'No mocks found',
|
|
29
|
+
not_found: 'Not Found',
|
|
29
30
|
pick_comment: 'Pick Comment…',
|
|
30
31
|
proxied: 'Proxied',
|
|
31
32
|
proxy_toggler: 'Proxy Toggler',
|
|
@@ -51,6 +52,7 @@ const CSS = {
|
|
|
51
52
|
GlobalDelayField: 'GlobalDelayField',
|
|
52
53
|
SaveProxiedCheckbox: 'SaveProxiedCheckbox',
|
|
53
54
|
StaticFilesList: 'StaticFilesList',
|
|
55
|
+
Main: 'Main',
|
|
54
56
|
|
|
55
57
|
red: 'red',
|
|
56
58
|
empty: 'empty',
|
|
@@ -89,8 +91,11 @@ function App([brokersByMethod, cookies, comments, delay, collectProxied, fallbac
|
|
|
89
91
|
return (
|
|
90
92
|
r('div', null,
|
|
91
93
|
r(Header, { cookies, comments, delay, fallbackAddress, collectProxied }),
|
|
92
|
-
r(
|
|
93
|
-
|
|
94
|
+
r('main', { className: CSS.Main },
|
|
95
|
+
r('div', null,
|
|
96
|
+
r(MockList, { brokersByMethod, canProxy: Boolean(fallbackAddress) }),
|
|
97
|
+
r(StaticFilesList, { staticFiles })),
|
|
98
|
+
r(PayloadViewer))))
|
|
94
99
|
}
|
|
95
100
|
|
|
96
101
|
|
|
@@ -243,13 +248,12 @@ function MockList({ brokersByMethod, canProxy }) {
|
|
|
243
248
|
const hasMocks = Object.keys(brokersByMethod).length
|
|
244
249
|
if (!hasMocks)
|
|
245
250
|
return (
|
|
246
|
-
r('
|
|
251
|
+
r('div', { className: cssClass(CSS.MockList, CSS.empty) },
|
|
247
252
|
Strings.no_mocks_found))
|
|
248
253
|
return (
|
|
249
|
-
r('
|
|
254
|
+
r('div', { className: CSS.MockList },
|
|
250
255
|
r('table', null, Object.entries(brokersByMethod).map(([method, brokers]) =>
|
|
251
|
-
r(SectionByMethod, { method, brokers, canProxy })))
|
|
252
|
-
r(PayloadViewer)))
|
|
256
|
+
r(SectionByMethod, { method, brokers, canProxy })))))
|
|
253
257
|
}
|
|
254
258
|
|
|
255
259
|
function SectionByMethod({ method, brokers, canProxy }) {
|
|
@@ -258,20 +262,21 @@ function SectionByMethod({ method, brokers, canProxy }) {
|
|
|
258
262
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
259
263
|
|
|
260
264
|
const urlMasks = brokersSorted.map(([urlMask]) => urlMask)
|
|
261
|
-
const
|
|
265
|
+
const urlMasksDittoed = dittoSplitPaths(urlMasks)
|
|
262
266
|
return (
|
|
263
267
|
r('tbody', null,
|
|
264
|
-
r('th',
|
|
268
|
+
r('th', { colspan: 4 }, method),
|
|
265
269
|
brokersSorted.map(([urlMask, broker], i) =>
|
|
266
270
|
r('tr', { 'data-method': method, 'data-urlMask': urlMask },
|
|
267
|
-
r('td', null, r(
|
|
268
|
-
r('td', null, r(MockSelector, { broker })),
|
|
269
|
-
r('td', null, r(InternalServerErrorToggler, { broker })),
|
|
271
|
+
r('td', null, r(ProxyToggler, { broker, disabled: !canProxy })),
|
|
270
272
|
r('td', null, r(DelayRouteToggler, { broker })),
|
|
271
|
-
r('td', null, r(
|
|
273
|
+
r('td', null, r(InternalServerErrorToggler, { broker })),
|
|
274
|
+
r('td', null, r(PreviewLink, { method, urlMask, urlMaskDittoed: urlMasksDittoed[i] })),
|
|
275
|
+
r('td', null, r(MockSelector, { broker }))
|
|
276
|
+
))))
|
|
272
277
|
}
|
|
273
278
|
|
|
274
|
-
function PreviewLink({ method, urlMask,
|
|
279
|
+
function PreviewLink({ method, urlMask, urlMaskDittoed }) {
|
|
275
280
|
async function onClick(event) {
|
|
276
281
|
event.preventDefault()
|
|
277
282
|
try {
|
|
@@ -283,13 +288,15 @@ function PreviewLink({ method, urlMask, urlMaskHighlighted }) {
|
|
|
283
288
|
onError(error)
|
|
284
289
|
}
|
|
285
290
|
}
|
|
291
|
+
const [ditto, tail] = urlMaskDittoed
|
|
286
292
|
return (
|
|
287
293
|
r('a', {
|
|
288
294
|
className: CSS.PreviewLink,
|
|
289
295
|
href: urlMask,
|
|
290
|
-
onClick
|
|
291
|
-
|
|
292
|
-
|
|
296
|
+
onClick
|
|
297
|
+
}, ditto
|
|
298
|
+
? [r('span', null, ditto), tail]
|
|
299
|
+
: tail))
|
|
293
300
|
}
|
|
294
301
|
|
|
295
302
|
function MockSelector({ broker }) {
|
|
@@ -394,6 +401,84 @@ function ProxyToggler({ broker, disabled }) {
|
|
|
394
401
|
}
|
|
395
402
|
|
|
396
403
|
|
|
404
|
+
|
|
405
|
+
/** # StaticFilesList */
|
|
406
|
+
|
|
407
|
+
function StaticFilesList({ staticFiles }) {
|
|
408
|
+
if (!Object.keys(staticFiles).length)
|
|
409
|
+
return null
|
|
410
|
+
const paths = dittoSplitPaths(Object.keys(staticFiles)).map(([ditto, tail]) => ditto
|
|
411
|
+
? [r('span', null, ditto), tail]
|
|
412
|
+
: tail)
|
|
413
|
+
return (
|
|
414
|
+
r('table', { className: CSS.StaticFilesList },
|
|
415
|
+
r('tbody', null,
|
|
416
|
+
r('th', { colspan: 4 }, Strings.static_get),
|
|
417
|
+
Object.values(staticFiles).map((broker, i) =>
|
|
418
|
+
r('tr', null,
|
|
419
|
+
r('td', null, r(ProxyStaticToggler, {})),
|
|
420
|
+
r('td', null, r(DelayStaticRouteToggler, { broker })),
|
|
421
|
+
r('td', null, r(NotFoundToggler, { broker })),
|
|
422
|
+
r('td', null, r('a', { href: broker.file, target: '_blank' }, paths[i]))
|
|
423
|
+
)))))
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
function DelayStaticRouteToggler({ broker }) {
|
|
428
|
+
function onChange() {
|
|
429
|
+
mockaton.setStaticRouteIsDelayed(broker.file, this.checked)
|
|
430
|
+
.catch(onError)
|
|
431
|
+
}
|
|
432
|
+
return (
|
|
433
|
+
r('label', {
|
|
434
|
+
className: CSS.DelayToggler,
|
|
435
|
+
title: Strings.delay
|
|
436
|
+
},
|
|
437
|
+
r('input', {
|
|
438
|
+
type: 'checkbox',
|
|
439
|
+
checked: broker.delayed,
|
|
440
|
+
onChange
|
|
441
|
+
}),
|
|
442
|
+
TimerIcon()))
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function NotFoundToggler({ broker }) {
|
|
446
|
+
function onChange() {
|
|
447
|
+
mockaton.setStaticRouteIs404(broker.file, this.checked)
|
|
448
|
+
.catch(onError)
|
|
449
|
+
}
|
|
450
|
+
return (
|
|
451
|
+
r('label', {
|
|
452
|
+
className: CSS.InternalServerErrorToggler, // TODO rename
|
|
453
|
+
title: Strings.not_found
|
|
454
|
+
},
|
|
455
|
+
r('input', {
|
|
456
|
+
type: 'checkbox',
|
|
457
|
+
checked: broker.should404,
|
|
458
|
+
onChange
|
|
459
|
+
}),
|
|
460
|
+
r('span', null, '404')))
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
// TODO
|
|
465
|
+
function ProxyStaticToggler({}) {
|
|
466
|
+
function onChange() {
|
|
467
|
+
}
|
|
468
|
+
return (
|
|
469
|
+
r('label', {
|
|
470
|
+
style: { visibility: 'hidden' },
|
|
471
|
+
className: CSS.ProxyToggler,
|
|
472
|
+
title: Strings.proxy_toggler
|
|
473
|
+
},
|
|
474
|
+
r('input', {
|
|
475
|
+
type: 'checkbox',
|
|
476
|
+
disabled: true,
|
|
477
|
+
onChange
|
|
478
|
+
}),
|
|
479
|
+
r(CloudIcon)))
|
|
480
|
+
}
|
|
481
|
+
|
|
397
482
|
/** # Payload Preview */
|
|
398
483
|
|
|
399
484
|
const payloadViewerTitleRef = useRef()
|
|
@@ -491,28 +576,6 @@ function mockSelectorFor(method, urlMask) {
|
|
|
491
576
|
}
|
|
492
577
|
|
|
493
578
|
|
|
494
|
-
/** # StaticFilesList */
|
|
495
|
-
|
|
496
|
-
function StaticFilesList({ staticFiles }) {
|
|
497
|
-
if (!staticFiles.length)
|
|
498
|
-
return null
|
|
499
|
-
const highlighted = highlightCommonPathPrefixes(staticFiles)
|
|
500
|
-
return (
|
|
501
|
-
r('section', {
|
|
502
|
-
open: true,
|
|
503
|
-
className: CSS.StaticFilesList
|
|
504
|
-
},
|
|
505
|
-
r('h2', null, Strings.static_get),
|
|
506
|
-
r('ul', null, staticFiles.map((f, i) =>
|
|
507
|
-
r('li', null,
|
|
508
|
-
r('a', {
|
|
509
|
-
href: f,
|
|
510
|
-
target: '_blank',
|
|
511
|
-
innerHTML: highlighted[i]
|
|
512
|
-
}))))))
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
|
|
516
579
|
/** # Misc */
|
|
517
580
|
|
|
518
581
|
function onError(error) {
|
|
@@ -619,37 +682,56 @@ function useRef() {
|
|
|
619
682
|
|
|
620
683
|
|
|
621
684
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
if (
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
result.push(`<span>${prefix}</span>${suffix}`)
|
|
685
|
+
/**
|
|
686
|
+
* Think of this as a way of printing a directory tree in which
|
|
687
|
+
* the repeated folder paths are kept but styled differently.
|
|
688
|
+
* @param {string[]} paths - sorted
|
|
689
|
+
*/
|
|
690
|
+
function dittoSplitPaths(paths) {
|
|
691
|
+
const result = [['', paths[0]]]
|
|
692
|
+
const pathsInParts = paths.map(p => p.split('/').filter(Boolean))
|
|
693
|
+
|
|
694
|
+
for (let i = 1; i < paths.length; i++) {
|
|
695
|
+
const prevParts = pathsInParts[i - 1]
|
|
696
|
+
const currParts = pathsInParts[i]
|
|
697
|
+
|
|
698
|
+
let j = 0
|
|
699
|
+
while (
|
|
700
|
+
j < currParts.length &&
|
|
701
|
+
j < prevParts.length &&
|
|
702
|
+
currParts[j] === prevParts[j])
|
|
703
|
+
j++
|
|
704
|
+
|
|
705
|
+
if (!j) // no common dirs
|
|
706
|
+
result.push(['', paths[i]])
|
|
707
|
+
else {
|
|
708
|
+
const ditto = '/' + currParts.slice(0, j).join('/') + '/'
|
|
709
|
+
result.push([ditto, paths[i].slice(ditto.length)])
|
|
648
710
|
}
|
|
649
|
-
else
|
|
650
|
-
result.push(path)
|
|
651
|
-
|
|
652
|
-
seen.push(path)
|
|
653
711
|
}
|
|
654
712
|
return result
|
|
655
713
|
}
|
|
714
|
+
|
|
715
|
+
(function testDittoSplitPaths() {
|
|
716
|
+
const input = [
|
|
717
|
+
'/api/user',
|
|
718
|
+
'/api/user/avatar',
|
|
719
|
+
'/api/user/friends',
|
|
720
|
+
'/api/vid',
|
|
721
|
+
'/api/video/id',
|
|
722
|
+
'/api/video/stats',
|
|
723
|
+
'/v2/foo',
|
|
724
|
+
'/v2/foo/bar'
|
|
725
|
+
]
|
|
726
|
+
const expected = [
|
|
727
|
+
['', '/api/user'],
|
|
728
|
+
['/api/user/', 'avatar'],
|
|
729
|
+
['/api/user/', 'friends'],
|
|
730
|
+
['/api/', 'vid'],
|
|
731
|
+
['/api/', 'video/id'],
|
|
732
|
+
['/api/video/', 'stats'],
|
|
733
|
+
['', '/v2/foo'],
|
|
734
|
+
['/v2/foo/', 'bar']
|
|
735
|
+
]
|
|
736
|
+
console.assert(JSON.stringify(dittoSplitPaths(input)) === JSON.stringify(expected))
|
|
737
|
+
}())
|
package/src/Mockaton.js
CHANGED
|
@@ -6,9 +6,9 @@ import { dispatchMock } from './MockDispatcher.js'
|
|
|
6
6
|
import { watchMocksDir } from './Watcher.js'
|
|
7
7
|
import { BodyReaderError } from './utils/http-request.js'
|
|
8
8
|
import * as mockBrokerCollection from './mockBrokersCollection.js'
|
|
9
|
-
import { dispatchStatic, isStatic } from './StaticDispatcher.js'
|
|
10
9
|
import { setCorsHeaders, isPreflight } from './utils/http-cors.js'
|
|
11
10
|
import { apiPatchRequests, apiGetRequests } from './Api.js'
|
|
11
|
+
import { dispatchStatic, isStatic, initStaticCollection } from './StaticDispatcher.js'
|
|
12
12
|
import { sendNoContent, sendInternalServerError, sendUnprocessableContent } from './utils/http-response.js'
|
|
13
13
|
|
|
14
14
|
|
|
@@ -18,6 +18,7 @@ export function Mockaton(options) {
|
|
|
18
18
|
setup(options)
|
|
19
19
|
mockBrokerCollection.init()
|
|
20
20
|
watchMocksDir()
|
|
21
|
+
initStaticCollection()
|
|
21
22
|
|
|
22
23
|
return createServer(onRequest).listen(config.port, config.host, function (error) {
|
|
23
24
|
if (error) {
|
package/src/StaticDispatcher.js
CHANGED
|
@@ -1,59 +1,83 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
|
-
import
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
3
|
|
|
4
4
|
import { mimeFor } from './utils/mime.js'
|
|
5
|
-
import { isDirectory, isFile } from './utils/fs.js'
|
|
6
5
|
import { config, isFileAllowed } from './config.js'
|
|
7
|
-
import {
|
|
6
|
+
import { sendPartialContent, sendNotFound } from './utils/http-response.js'
|
|
7
|
+
import { isDirectory, isFile, listFilesRecursively } from './utils/fs.js'
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
class StaticBroker {
|
|
11
|
+
constructor(file) {
|
|
12
|
+
this.file = file
|
|
13
|
+
this.delayed = false
|
|
14
|
+
this.should404 = false
|
|
15
|
+
this.resolvedPath = this.#staticFilePath()
|
|
16
|
+
}
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
#staticFilePath() { // url is absolute e.g. /home/../.. => /
|
|
19
|
+
let candidate = join(config.staticDir, this.file)
|
|
20
|
+
if (isDirectory(candidate))
|
|
21
|
+
candidate = join(candidate, 'index.html')
|
|
22
|
+
if (isFile(candidate))
|
|
23
|
+
return candidate
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
updateDelayed(value) {
|
|
27
|
+
this.delayed = value
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
updateNotFound(value) {
|
|
31
|
+
this.should404 = value
|
|
24
32
|
}
|
|
25
33
|
}
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
let collection = {}
|
|
36
|
+
|
|
37
|
+
export function initStaticCollection() {
|
|
38
|
+
collection = {}
|
|
39
|
+
listFilesRecursively(config.staticDir)
|
|
40
|
+
.filter(isFileAllowed)
|
|
41
|
+
.sort()
|
|
42
|
+
.forEach(f => registerStatic(f))
|
|
33
43
|
}
|
|
34
44
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (isNaN(start)) start = size - end
|
|
45
|
+
function registerStatic(file) {
|
|
46
|
+
file = '/' + file
|
|
47
|
+
collection[file] = new StaticBroker(file)
|
|
48
|
+
}
|
|
40
49
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
50
|
+
export function findStaticBrokerByRoute(route) {
|
|
51
|
+
return collection[route] || collection[join(route, 'index.html')]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getStaticFilesCollection() {
|
|
55
|
+
return collection
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isStatic(req) {
|
|
59
|
+
return req.url in collection || join(req.url, 'index.html') in collection
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// TODO improve
|
|
63
|
+
export async function dispatchStatic(req, response) {
|
|
64
|
+
let broker = collection[join(req.url, 'index.html')]
|
|
65
|
+
if (!broker && req.url in collection)
|
|
66
|
+
broker = collection[req.url]
|
|
67
|
+
|
|
68
|
+
if (broker?.should404) { // TESTME
|
|
69
|
+
sendNotFound(response)
|
|
70
|
+
return
|
|
58
71
|
}
|
|
72
|
+
|
|
73
|
+
const file = broker.resolvedPath
|
|
74
|
+
setTimeout(async () => {
|
|
75
|
+
if (req.headers.range)
|
|
76
|
+
await sendPartialContent(response, req.headers.range, file)
|
|
77
|
+
else {
|
|
78
|
+
response.setHeader('Content-Type', mimeFor(file))
|
|
79
|
+
response.end(readFileSync(file))
|
|
80
|
+
}
|
|
81
|
+
}, broker.delayed * config.delay)
|
|
59
82
|
}
|
|
83
|
+
|
package/src/utils/fs.js
CHANGED
|
@@ -7,10 +7,15 @@ export const isDirectory = path => lstatSync(path, { throwIfNoEntry: false })?.i
|
|
|
7
7
|
|
|
8
8
|
/** @returns {Array<string>} paths relative to `dir` */
|
|
9
9
|
export const listFilesRecursively = dir => {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
try {
|
|
11
|
+
const files = readdirSync(dir, { recursive: true }).filter(f => isFile(join(dir, f)))
|
|
12
|
+
return process.platform === 'win32'
|
|
13
|
+
? files.map(f => f.replaceAll(sep, posix.sep))
|
|
14
|
+
: files
|
|
15
|
+
}
|
|
16
|
+
catch (err) { // e.g. ENOENT
|
|
17
|
+
return []
|
|
18
|
+
}
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
export const write = (path, body) => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs'
|
|
1
|
+
import fs, { readFileSync } from 'node:fs'
|
|
2
2
|
import { mimeFor } from './mime.js'
|
|
3
3
|
import { HEADER_FOR_502 } from '../ApiConstants.js'
|
|
4
4
|
|
|
@@ -45,3 +45,30 @@ export function sendBadGateway(response, error) {
|
|
|
45
45
|
response.setHeader(HEADER_FOR_502, 1)
|
|
46
46
|
response.end()
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
export async function sendPartialContent(response, range, file) {
|
|
51
|
+
const { size } = await fs.promises.lstat(file)
|
|
52
|
+
let [start, end] = range.replace(/bytes=/, '').split('-').map(n => parseInt(n, 10))
|
|
53
|
+
if (isNaN(end)) end = size - 1
|
|
54
|
+
if (isNaN(start)) start = size - end
|
|
55
|
+
|
|
56
|
+
if (start < 0 || start > end || start >= size || end >= size) {
|
|
57
|
+
response.statusCode = 416 // Range Not Satisfiable
|
|
58
|
+
response.setHeader('Content-Range', `bytes */${size}`)
|
|
59
|
+
response.end()
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
response.statusCode = 206 // Partial Content
|
|
63
|
+
response.setHeader('Accept-Ranges', 'bytes')
|
|
64
|
+
response.setHeader('Content-Range', `bytes ${start}-${end}/${size}`)
|
|
65
|
+
response.setHeader('Content-Type', mimeFor(file))
|
|
66
|
+
const reader = fs.createReadStream(file, { start, end })
|
|
67
|
+
reader.on('open', function () {
|
|
68
|
+
this.pipe(response)
|
|
69
|
+
})
|
|
70
|
+
reader.on('error', function (error) {
|
|
71
|
+
sendInternalServerError(response, error)
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|