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 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
@@ -2,7 +2,7 @@
2
2
  "name": "mockaton",
3
3
  "description": "HTTP Mock Server",
4
4
  "type": "module",
5
- "version": "8.13.0",
5
+ "version": "8.14.0",
6
6
  "main": "index.js",
7
7
  "types": "index.d.ts",
8
8
  "license": "MIT",
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 { listFilesRecursively } from './utils/fs.js'
9
+ import { uiSyncVersion } from './Watcher.js'
11
10
  import * as mockBrokersCollection from './mockBrokersCollection.js'
12
- import { config, isFileAllowed, ConfigValidator } from './config.js'
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.routeUrlMask]} ${body[DF.routeUrlMask]}`)
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.routeUrlMask]} ${body[DF.routeUrlMask]}`)
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
+
@@ -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: 6px;
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: #0075db;
10
+ --colorAccent: #0170cc;
11
11
  --colorAccentAlt: #068185;
12
12
  --colorBackground: #fff;
13
13
  --colorComboBoxHeaderBackground: #fff;
14
- --colorComboBoxBackground: #f7f7f7;
14
+ --colorComboBoxBackground: #eee;
15
15
  --colorHeaderBackground: #eee;
16
- --colorSecondaryButtonBackground: #f3f3f3;
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: #252525;
34
- --colorSecondaryButtonBackground: #282828;
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
- .MockList {
229
+ .Main {
231
230
  display: flex;
232
- align-items: flex-start;
233
- margin-top: 64px;
231
+ }
234
232
 
235
- > table {
236
- border-collapse: collapse;
233
+ table {
234
+ border-collapse: collapse;
237
235
 
238
- th {
239
- padding-top: 20px;
240
- padding-bottom: 2px;
241
- text-align: left;
242
- }
236
+ th {
237
+ padding-top: 20px;
238
+ padding-bottom: 2px;
239
+ padding-left: 99px;
240
+ text-align: left;
241
+ }
243
242
 
244
- tr {
245
- border-top: 2px solid transparent;
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: 18px;
377
- height: 18px;
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: 20px;
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(--colorAccentAlt);
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(MockList, { brokersByMethod, canProxy: Boolean(fallbackAddress) }),
93
- r(StaticFilesList, { staticFiles })))
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('main', { className: cssClass(CSS.MockList, CSS.empty) },
251
+ r('div', { className: cssClass(CSS.MockList, CSS.empty) },
247
252
  Strings.no_mocks_found))
248
253
  return (
249
- r('main', { className: CSS.MockList },
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 urlMasksHighlighted = highlightCommonPathPrefixes(urlMasks)
265
+ const urlMasksDittoed = dittoSplitPaths(urlMasks)
262
266
  return (
263
267
  r('tbody', null,
264
- r('th', null, method),
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(PreviewLink, { method, urlMask, urlMaskHighlighted: urlMasksHighlighted[i] })),
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(ProxyToggler, { broker, disabled: !canProxy }))))))
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, urlMaskHighlighted }) {
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
- innerHTML: urlMaskHighlighted
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
- /** This is for styling the repeated paths with a faint style */
624
- function highlightCommonPathPrefixes(paths) {
625
- const seen = []
626
- const result = []
627
- for (const path of paths) {
628
- let longestPrefixSegments = []
629
-
630
- for (const prev of seen) {
631
- const currSegs = path.split('/')
632
- const prevSegs = prev.split('/')
633
-
634
- let i = 0
635
- while (i < currSegs.length && i < prevSegs.length && currSegs[i] === prevSegs[i])
636
- i++
637
-
638
- if (i > longestPrefixSegments.length)
639
- longestPrefixSegments = currSegs.slice(0, i)
640
- }
641
-
642
- if (longestPrefixSegments.length > 0) {
643
- let prefix = longestPrefixSegments.join('/')
644
- if (!prefix.endsWith('/'))
645
- prefix += '/' // always end with slash for dirs
646
- const suffix = path.slice(prefix.length)
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) {
@@ -1,59 +1,83 @@
1
1
  import { join } from 'node:path'
2
- import fs, { readFileSync } from 'node:fs'
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 { sendInternalServerError } from './utils/http-response.js'
6
+ import { sendPartialContent, sendNotFound } from './utils/http-response.js'
7
+ import { isDirectory, isFile, listFilesRecursively } from './utils/fs.js'
8
8
 
9
9
 
10
- export function isStatic(req) {
11
- if (!config.staticDir)
12
- return false
13
- const f = resolvePath(req.url)
14
- return f && isFileAllowed(f)
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
- export async function dispatchStatic(req, response) {
18
- const file = resolvePath(req.url)
19
- if (req.headers.range)
20
- await sendPartialContent(response, req.headers.range, file)
21
- else {
22
- response.setHeader('Content-Type', mimeFor(file))
23
- response.end(readFileSync(file))
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
- 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
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
- async function sendPartialContent(response, range, file) {
36
- const { size } = await fs.promises.lstat(file)
37
- let [start, end] = range.replace(/bytes=/, '').split('-').map(n => parseInt(n, 10))
38
- if (isNaN(end)) end = size - 1
39
- if (isNaN(start)) start = size - end
45
+ function registerStatic(file) {
46
+ file = '/' + file
47
+ collection[file] = new StaticBroker(file)
48
+ }
40
49
 
41
- if (start < 0 || start > end || start >= size || end >= size) {
42
- response.statusCode = 416 // Range Not Satisfiable
43
- response.setHeader('Content-Range', `bytes */${size}`)
44
- response.end()
45
- }
46
- else {
47
- response.statusCode = 206 // Partial Content
48
- response.setHeader('Accept-Ranges', 'bytes')
49
- response.setHeader('Content-Range', `bytes ${start}-${end}/${size}`)
50
- response.setHeader('Content-Type', mimeFor(file))
51
- const reader = fs.createReadStream(file, { start, end })
52
- reader.on('open', function () {
53
- this.pipe(response)
54
- })
55
- reader.on('error', function (error) {
56
- sendInternalServerError(response, error)
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
- const files = readdirSync(dir, { recursive: true }).filter(f => isFile(join(dir, f)))
11
- return process.platform === 'win32'
12
- ? files.map(f => f.replaceAll(sep, posix.sep))
13
- : files
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
+ }