mockaton 8.13.1 → 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/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.1",
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
@@ -230,26 +230,26 @@ select {
230
230
  display: flex;
231
231
  }
232
232
 
233
+ table {
234
+ border-collapse: collapse;
235
+
236
+ th {
237
+ padding-top: 20px;
238
+ padding-bottom: 2px;
239
+ padding-left: 99px;
240
+ text-align: left;
241
+ }
242
+
243
+ tr {
244
+ border-top: 2px solid transparent;
245
+ }
246
+ }
233
247
 
234
248
  .MockList {
235
249
  display: flex;
236
250
  align-items: flex-start;
237
251
  margin-top: 64px;
238
252
 
239
- > table {
240
- border-collapse: collapse;
241
-
242
- th {
243
- padding-top: 20px;
244
- padding-bottom: 2px;
245
- text-align: left;
246
- }
247
-
248
- tr {
249
- border-top: 2px solid transparent;
250
- }
251
- }
252
-
253
253
  &.empty {
254
254
  margin-top: 80px;
255
255
  }
@@ -340,7 +340,6 @@ select {
340
340
  .DelayToggler,
341
341
  .ProxyToggler {
342
342
  display: flex;
343
- margin-left: 8px;
344
343
 
345
344
  > input {
346
345
  appearance: none;
@@ -361,6 +360,7 @@ select {
361
360
  }
362
361
 
363
362
  .DelayToggler {
363
+ margin-left: 8px;
364
364
  > input {
365
365
  &:checked ~ svg {
366
366
  fill: var(--colorAccent);
@@ -437,6 +437,7 @@ select {
437
437
  .InternalServerErrorToggler {
438
438
  display: flex;
439
439
  margin-left: 8px;
440
+ margin-right: 12px;
440
441
  cursor: pointer;
441
442
 
442
443
  > input {
@@ -498,24 +499,11 @@ select {
498
499
  }
499
500
 
500
501
  .StaticFilesList {
501
- margin-top: 20px;
502
-
503
- h2 {
504
- margin-bottom: 8px;
505
- }
506
-
507
- ul {
508
- position: relative;
509
- left: -6px;
510
- }
511
-
512
- li {
513
- list-style: none;
514
- }
502
+ margin-top: 8px;
515
503
 
516
504
  a {
517
505
  display: inline-block;
518
- padding: 6px;
506
+ padding: 6px 0;
519
507
  border-radius: var(--radius);
520
508
  color: var(--colorAccent);
521
509
  text-decoration: none;
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',
@@ -264,14 +265,15 @@ function SectionByMethod({ method, brokers, canProxy }) {
264
265
  const urlMasksDittoed = dittoSplitPaths(urlMasks)
265
266
  return (
266
267
  r('tbody', null,
267
- r('th', null, method),
268
+ r('th', { colspan: 4 }, method),
268
269
  brokersSorted.map(([urlMask, broker], i) =>
269
270
  r('tr', { 'data-method': method, 'data-urlMask': urlMask },
270
- r('td', null, r(PreviewLink, { method, urlMask, urlMaskDittoed: urlMasksDittoed[i] })),
271
- r('td', null, r(MockSelector, { broker })),
272
- r('td', null, r(InternalServerErrorToggler, { broker })),
271
+ r('td', null, r(ProxyToggler, { broker, disabled: !canProxy })),
273
272
  r('td', null, r(DelayRouteToggler, { broker })),
274
- 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
+ ))))
275
277
  }
276
278
 
277
279
  function PreviewLink({ method, urlMask, urlMaskDittoed }) {
@@ -399,6 +401,84 @@ function ProxyToggler({ broker, disabled }) {
399
401
  }
400
402
 
401
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
+
402
482
  /** # Payload Preview */
403
483
 
404
484
  const payloadViewerTitleRef = useRef()
@@ -496,29 +576,6 @@ function mockSelectorFor(method, urlMask) {
496
576
  }
497
577
 
498
578
 
499
- /** # StaticFilesList */
500
-
501
- function StaticFilesList({ staticFiles }) {
502
- if (!staticFiles.length)
503
- return null
504
- const paths = dittoSplitPaths(staticFiles).map(([ditto, tail]) => ditto
505
- ? [r('span', null, ditto), tail]
506
- : tail)
507
- return (
508
- r('section', {
509
- open: true,
510
- className: CSS.StaticFilesList
511
- },
512
- r('h2', null, Strings.static_get),
513
- r('ul', null, staticFiles.map((f, i) =>
514
- r('li', null,
515
- r('a', {
516
- href: f,
517
- target: '_blank'
518
- }, paths[i]))))))
519
- }
520
-
521
-
522
579
  /** # Misc */
523
580
 
524
581
  function onError(error) {
@@ -626,39 +683,32 @@ function useRef() {
626
683
 
627
684
 
628
685
  /**
629
- * This is for styling the repeated paths with a faint style.
630
- * It splits each path into [dittoPrefix, tail], where dittoPrefix is
631
- * the longest previously-seen common directory prefix.
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
632
689
  */
633
690
  function dittoSplitPaths(paths) {
634
- const result = []
635
- for (let i = 0; i < paths.length; i++) {
636
- const path = paths[i]
637
- const currParts = path.split('/')
638
-
639
- let dittoParts = []
640
- for (let j = 0; j < i; j++) {
641
- const prevParts = paths[j].split('/')
642
-
643
- let k = 0
644
- while (
645
- k < currParts.length &&
646
- k < prevParts.length &&
647
- currParts[k] === prevParts[k])
648
- k++
649
-
650
- if (k > dittoParts.length)
651
- dittoParts = currParts.slice(0, k)
652
- }
653
-
654
- if (!dittoParts.length)
655
- result.push(['', path])
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]])
656
707
  else {
657
- const ditto = dittoParts.join('/') + '/'
658
- result.push([ditto, path.slice(ditto.length)])
708
+ const ditto = '/' + currParts.slice(0, j).join('/') + '/'
709
+ result.push([ditto, paths[i].slice(ditto.length)])
659
710
  }
660
711
  }
661
-
662
712
  return result
663
713
  }
664
714
 
@@ -669,7 +719,9 @@ function dittoSplitPaths(paths) {
669
719
  '/api/user/friends',
670
720
  '/api/vid',
671
721
  '/api/video/id',
672
- '/api/video/stats'
722
+ '/api/video/stats',
723
+ '/v2/foo',
724
+ '/v2/foo/bar'
673
725
  ]
674
726
  const expected = [
675
727
  ['', '/api/user'],
@@ -677,7 +729,9 @@ function dittoSplitPaths(paths) {
677
729
  ['/api/user/', 'friends'],
678
730
  ['/api/', 'vid'],
679
731
  ['/api/', 'video/id'],
680
- ['/api/video/', 'stats']
732
+ ['/api/video/', 'stats'],
733
+ ['', '/v2/foo'],
734
+ ['/v2/foo/', 'bar']
681
735
  ]
682
736
  console.assert(JSON.stringify(dittoSplitPaths(input)) === JSON.stringify(expected))
683
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
+ }