mockaton 10.4.2 → 10.5.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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "mockaton",
3
3
  "description": "HTTP Mock Server",
4
4
  "type": "module",
5
- "version": "10.4.2",
5
+ "version": "10.5.1",
6
6
  "main": "index.js",
7
7
  "types": "index.d.ts",
8
8
  "license": "MIT",
package/src/Api.js CHANGED
@@ -4,26 +4,29 @@
4
4
  */
5
5
 
6
6
  import { join } from 'node:path'
7
+ import { randomBytes } from 'node:crypto'
8
+
7
9
  import { cookie } from './cookie.js'
10
+ import DashboardHtml from './DashboardHtml.js'
8
11
  import { parseJSON } from './utils/http-request.js'
9
12
  import { uiSyncVersion } from './Watcher.js'
10
13
  import * as staticCollection from './staticCollection.js'
11
14
  import * as mockBrokersCollection from './mockBrokersCollection.js'
12
15
  import { config, ConfigValidator } from './config.js'
13
16
  import { DF, API, LONG_POLL_SERVER_TIMEOUT } from './ApiConstants.js'
14
- import { sendOK, sendJSON, sendUnprocessableContent, sendFile } from './utils/http-response.js'
17
+ import { sendOK, sendJSON, sendUnprocessableContent, sendFile, sendHTML } from './utils/http-response.js'
15
18
 
16
19
 
17
20
  export const apiGetRequests = new Map([
18
- [API.dashboard, serveDashboardAsset('Dashboard.html')],
21
+ [API.dashboard, serveHtml],
19
22
  ...[
20
- '/ApiConstants.js',
21
- '/ApiCommander.js',
22
23
  '/Dashboard.css',
23
24
  '/Dashboard.js',
25
+ '/ApiConstants.js',
26
+ '/ApiCommander.js',
24
27
  '/Filename.js',
25
28
  '/Logo.svg'
26
- ].map(f => [API.dashboard + f, serveDashboardAsset(f)]),
29
+ ].map(f => [API.dashboard + f, serveStatic(f)]),
27
30
 
28
31
  [API.state, getState],
29
32
  [API.syncVersion, longPollClientSyncVersion],
@@ -49,12 +52,18 @@ export const apiPatchRequests = new Map([
49
52
 
50
53
  /** # GET */
51
54
 
52
- function serveDashboardAsset(f) {
53
- return (_, response) => {
54
- if (f.endsWith('.html'))
55
- response.setHeader('Content-Security-Policy', `default-src 'self'; img-src data: blob: 'self'`)
56
- sendFile(response, join(import.meta.dirname, f))
57
- }
55
+ function serveHtml(_, response) {
56
+ const nonce = randomBytes(12).toString('base64url')
57
+ response.setHeader('Content-Security-Policy', [
58
+ `default-src 'self'`,
59
+ `img-src data: blob: 'self'`,
60
+ `script-src 'nonce-${nonce}' 'self'`
61
+ ].join(';'))
62
+ sendHTML(response, DashboardHtml(nonce))
63
+ }
64
+
65
+ function serveStatic(f) {
66
+ return (_, response) => sendFile(response, join(import.meta.dirname, f))
58
67
  }
59
68
 
60
69
  function getState(_, response) {
@@ -17,7 +17,13 @@ export class Commander {
17
17
 
18
18
  /** @returns {JsonPromise<State>} */
19
19
  getState() {
20
- return fetch(this.#addr + API.state)
20
+ const url = this.#addr + API.state
21
+ if (globalThis._aotFetch?.[url]) { // github.com/ericfortis/aot-fetch-demo
22
+ const promise = globalThis._aotFetch[url]
23
+ delete globalThis._aotFetch[url]
24
+ return promise
25
+ }
26
+ return fetch(url)
21
27
  }
22
28
 
23
29
  /** @returns {JsonPromise<number>} */
package/src/Dashboard.css CHANGED
@@ -149,7 +149,6 @@ header {
149
149
  background: var(--colorHeaderBackground);
150
150
 
151
151
  > img {
152
- width: 120px;
153
152
  align-self: end;
154
153
  margin-right: 22px;
155
154
  margin-bottom: 5px;
package/src/Dashboard.js CHANGED
@@ -3,40 +3,6 @@ import { parseFilename, extractComments } from './Filename.js'
3
3
  import { Commander } from './ApiCommander.js'
4
4
 
5
5
 
6
- const Strings = {
7
- auto500: 'Auto500',
8
- bulk_select: 'Bulk Select',
9
- bulk_select_disabled_title: 'No mock files have comments, which are anything within parentheses on the filename.',
10
- click_link_to_preview: 'Click a link to preview it',
11
- cookie: 'Cookie',
12
- cookie_disabled_title: 'No cookies specified in config.cookies',
13
- delay: 'Delay',
14
- delay_ms: 'Delay (ms)',
15
- empty_response_body: '/* Empty Response Body */',
16
- error_server_down: 'Looks like the Mockaton server is not running',
17
- error_unexpected_error: 'Unexpected Error',
18
- fallback_server: 'Fallback',
19
- fallback_server_error: '⛔ Fallback Backend Error',
20
- fallback_server_placeholder: 'Type backend address',
21
- fetching: 'Fetching…',
22
- got: 'Got',
23
- group_by_method: 'Group by Method',
24
- internal_server_error: 'Internal Server Error',
25
- mock_selector: 'Mock Selector',
26
- no_mocks_found: 'No mocks found',
27
- none: 'None',
28
- not_found: 'Not Found',
29
- pick_comment: 'Pick Comment…',
30
- preview: 'Preview',
31
- proxied: 'Proxied',
32
- proxy_toggler: 'Proxy Toggler',
33
- reset: 'Reset',
34
- save_proxied: 'Save Mocks',
35
- settings: 'Settings',
36
- static_get: 'Static GET',
37
- title: 'Mockaton'
38
- }
39
-
40
6
  const CSS = {
41
7
  BulkSelector: null,
42
8
  CookieSelector: null,
@@ -72,26 +38,19 @@ const CSS = {
72
38
  status4xx: null,
73
39
 
74
40
  json: null,
75
- syntaxKey: null,
76
- syntaxStr: null,
77
- syntaxVal: null,
78
-
79
41
  syntaxAttr: null,
80
42
  syntaxAttrVal: null,
43
+ syntaxKey: null,
44
+ syntaxPunc: null,
45
+ syntaxStr: null,
81
46
  syntaxTag: null,
82
- syntaxPunc: null
47
+ syntaxVal: null
83
48
  }
84
49
  for (const k of Object.keys(CSS))
85
50
  CSS[k] = k
86
51
 
87
52
 
88
- /** @type {State & {
89
- * canProxy: boolean
90
- * groupByMethod: boolean
91
- * toggleGroupByMethod: () => void
92
- * leftSideWidth?: number
93
- * }} */
94
- const state = {
53
+ const state = /** @type {State} */ {
95
54
  brokersByMethod: {},
96
55
  staticBrokers: {},
97
56
  cookies: [],
@@ -101,21 +60,23 @@ const state = {
101
60
  collectProxied: false,
102
61
  proxyFallback: '',
103
62
  get canProxy() {
104
- return Boolean(this.proxyFallback)
63
+ return Boolean(state.proxyFallback)
105
64
  },
106
65
 
107
- groupByMethod: localStorage.getItem('groupByMethod') !== 'false',
66
+ groupByMethod: initPreference('groupByMethod'),
108
67
  toggleGroupByMethod() {
109
- this.groupByMethod = !this.groupByMethod
110
- localStorage.setItem('groupByMethod', String(this.groupByMethod))
68
+ state.groupByMethod = !state.groupByMethod
69
+ togglePreference('groupByMethod', state.groupByMethod)
70
+ updateState()
111
71
  },
112
72
 
113
73
  leftSideWidth: undefined
114
74
  }
115
75
 
76
+
116
77
  const mockaton = new Commander(location.origin)
117
78
  updateState()
118
- initLongPoll()
79
+ deferred(initLongPoll)
119
80
 
120
81
  async function updateState() {
121
82
  try {
@@ -132,6 +93,7 @@ async function updateState() {
132
93
 
133
94
  const r = createElement
134
95
  const s = createSvgElement
96
+ const t = translation => translation[0]
135
97
 
136
98
  const leftSideRef = useRef()
137
99
 
@@ -159,9 +121,10 @@ function Header() {
159
121
  return (
160
122
  r('header', null,
161
123
  r('img', {
162
- alt: Strings.title,
124
+ alt: t`Mockaton`,
163
125
  src: 'mockaton/Logo.svg',
164
- width: 160
126
+ width: 120,
127
+ height: 22
165
128
  }),
166
129
  r('div', null,
167
130
  GlobalDelayField(),
@@ -173,47 +136,38 @@ function Header() {
173
136
  }
174
137
 
175
138
  function SettingsMenu() {
176
- const id = '_SettingsMenu_'
139
+ const { groupByMethod, toggleGroupByMethod } = state
140
+
141
+ const id = '_settings_menu_'
142
+ return (
143
+ r('button', {
144
+ title: t`Settings`,
145
+ popovertarget: id,
146
+ className: CSS.MenuTrigger
147
+ },
148
+ SettingsIcon(),
177
149
 
178
- function MenuContent() {
179
- return (
180
150
  r('menu', {
181
151
  id,
152
+ deferred: true,
182
153
  popover: '',
183
- className: CSS.SettingsMenu,
184
- onToggle(event) {
185
- if (event.newState === 'closed')
186
- this.parentNode.removeChild(this)
187
- }
154
+ className: CSS.SettingsMenu
188
155
  },
156
+
189
157
  r('label', className(CSS.GroupByMethod),
190
158
  r('input', {
191
159
  type: 'checkbox',
192
- checked: state.groupByMethod,
193
- onChange() {
194
- state.toggleGroupByMethod()
195
- updateState()
196
- }
160
+ checked: groupByMethod,
161
+ autofocus: true,
162
+ onChange: toggleGroupByMethod
197
163
  }),
198
- r('span', null, Strings.group_by_method)),
164
+ r('span', null, t`Group by Method`)),
199
165
 
200
166
  r('a', {
201
167
  href: 'https://github.com/ericfortis/mockaton',
202
168
  target: '_blank',
203
169
  rel: 'noopener noreferrer'
204
- }, 'Documentation')))
205
- }
206
-
207
- return (
208
- r('button', {
209
- title: Strings.settings,
210
- onClick() {
211
- if (!this.querySelector('menu'))
212
- this.appendChild(MenuContent())
213
- },
214
- className: CSS.MenuTrigger,
215
- popovertarget: id
216
- }, SettingsIcon()))
170
+ }, t`Documentation`))))
217
171
  }
218
172
 
219
173
  function CookieSelector() {
@@ -224,14 +178,14 @@ function CookieSelector() {
224
178
  .catch(onError)
225
179
  }
226
180
  const disabled = cookies.length <= 1
227
- const list = cookies.length ? cookies : [[Strings.none, true]]
181
+ const list = cookies.length ? cookies : [[t`None`, true]]
228
182
  return (
229
183
  r('label', className(CSS.Field, CSS.CookieSelector),
230
- r('span', null, Strings.cookie),
184
+ r('span', null, t`Cookie`),
231
185
  r('select', {
232
186
  autocomplete: 'off',
233
187
  disabled,
234
- title: disabled ? Strings.cookie_disabled_title : '',
188
+ title: disabled ? t`No cookies specified in config.cookies` : '',
235
189
  onChange
236
190
  }, list.map(([value, selected]) =>
237
191
  r('option', { value, selected }, value)))))
@@ -241,7 +195,7 @@ function BulkSelector() {
241
195
  const { comments } = state
242
196
  // UX wise this should be a menu instead of this `select`.
243
197
  // But this way is easier to implement, with a few hacks.
244
- const firstOption = Strings.pick_comment
198
+ const firstOption = t`Pick Comment…`
245
199
  function onChange() {
246
200
  const value = this.value
247
201
  this.value = firstOption // Hack
@@ -254,12 +208,12 @@ function BulkSelector() {
254
208
  const disabled = !comments.length
255
209
  return (
256
210
  r('label', className(CSS.Field),
257
- r('span', null, Strings.bulk_select),
211
+ r('span', null, t`Bulk Select`),
258
212
  r('select', {
259
213
  className: CSS.BulkSelector,
260
214
  autocomplete: 'off',
261
215
  disabled,
262
- title: disabled ? Strings.bulk_select_disabled_title : '',
216
+ title: disabled ? t`No mock files have comments which are anything within parentheses on the filename.` : '',
263
217
  onChange
264
218
  },
265
219
  r('option', { value: firstOption }, firstOption),
@@ -267,7 +221,7 @@ function BulkSelector() {
267
221
  comments.map(value =>
268
222
  r('option', { value }, value)),
269
223
  r('hr'),
270
- r('option', { value: AUTOGENERATED_500_COMMENT }, Strings.auto500)
224
+ r('option', { value: AUTOGENERATED_500_COMMENT }, t`Auto500`)
271
225
  )))
272
226
  }
273
227
 
@@ -287,7 +241,7 @@ function GlobalDelayField() {
287
241
  }
288
242
  return (
289
243
  r('label', className(CSS.Field, CSS.GlobalDelayField),
290
- r('span', null, Strings.delay_ms),
244
+ r('span', null, t`Delay (ms)`),
291
245
  r('input', {
292
246
  type: 'number',
293
247
  min: 0,
@@ -317,11 +271,11 @@ function ProxyFallbackField() {
317
271
  return (
318
272
  r('div', className(CSS.Field, CSS.FallbackBackend),
319
273
  r('label', null,
320
- r('span', null, Strings.fallback_server),
274
+ r('span', null, t`Fallback`),
321
275
  r('input', {
322
276
  type: 'url',
323
277
  autocomplete: 'none',
324
- placeholder: Strings.fallback_server_placeholder,
278
+ placeholder: t`Type backend address`,
325
279
  value: proxyFallback,
326
280
  onChange
327
281
  })),
@@ -343,7 +297,7 @@ function SaveProxiedCheckbox() {
343
297
  checked: collectProxied,
344
298
  onChange
345
299
  }),
346
- r('span', null, Strings.save_proxied)))
300
+ r('span', null, t`Save Mocks`)))
347
301
  }
348
302
 
349
303
  function ResetButton() {
@@ -358,7 +312,7 @@ function ResetButton() {
358
312
  r('button', {
359
313
  className: CSS.ResetButton,
360
314
  onClick
361
- }, Strings.reset))
315
+ }, t`Reset`))
362
316
  }
363
317
 
364
318
 
@@ -371,7 +325,7 @@ function MockList() {
371
325
  if (!Object.keys(brokersByMethod).length)
372
326
  return (
373
327
  r('div', className(CSS.empty),
374
- Strings.no_mocks_found))
328
+ t`No mocks found`))
375
329
 
376
330
  if (groupByMethod)
377
331
  return Object.keys(brokersByMethod).map((method) => Fragment(
@@ -439,6 +393,8 @@ function PreviewLink(method, urlMask, urlMaskDittoed) {
439
393
  : tail))
440
394
  }
441
395
 
396
+ const STR_PROXIED = t`Proxied`
397
+
442
398
  /** @param {ClientMockBroker} broker */
443
399
  function MockSelector(broker) {
444
400
  function onChange() {
@@ -456,20 +412,20 @@ function MockSelector(broker) {
456
412
  status === 500 ||
457
413
  !item.includes(AUTOGENERATED_500_COMMENT))
458
414
  if (!selected) {
459
- selected = Strings.proxied
415
+ selected = STR_PROXIED
460
416
  files.push(selected)
461
417
  }
462
418
 
463
419
  function nameFor(file) {
464
- if (file === Strings.proxied)
465
- return Strings.proxied
420
+ if (file === STR_PROXIED)
421
+ return STR_PROXIED
466
422
  const { status, ext } = parseFilename(file)
467
423
  const comments = extractComments(file)
468
424
  const isAutogen500 = comments.includes(AUTOGENERATED_500_COMMENT)
469
425
  return [
470
426
  isAutogen500 ? '' : status,
471
427
  ext === 'empty' || ext === 'unknown' ? '' : ext,
472
- isAutogen500 ? Strings.auto500 : comments.join(' ')
428
+ isAutogen500 ? t`Auto500` : comments.join(' ')
473
429
  ].filter(Boolean).join(' ')
474
430
  }
475
431
 
@@ -477,7 +433,7 @@ function MockSelector(broker) {
477
433
  r('select', {
478
434
  onChange,
479
435
  autocomplete: 'off',
480
- 'aria-label': Strings.mock_selector,
436
+ 'aria-label': t`Mock Selector`,
481
437
  disabled: files.length <= 1,
482
438
  ...className(
483
439
  CSS.MockSelector,
@@ -518,7 +474,7 @@ function InternalServerErrorToggler(broker) {
518
474
  return (
519
475
  r('label', {
520
476
  className: CSS.InternalServerErrorToggler,
521
- title: Strings.internal_server_error
477
+ title: t`Internal Server Error`
522
478
  },
523
479
  r('input', {
524
480
  type: 'checkbox',
@@ -526,7 +482,7 @@ function InternalServerErrorToggler(broker) {
526
482
  checked: parseFilename(broker.currentMock.file).status === 500,
527
483
  onChange
528
484
  }),
529
- r('span', null, '500')))
485
+ r('span', null, t`500`)))
530
486
  }
531
487
 
532
488
  /** @param {ClientMockBroker} broker */
@@ -542,7 +498,7 @@ function ProxyToggler(broker) {
542
498
  return (
543
499
  r('label', {
544
500
  className: CSS.ProxyToggler,
545
- title: Strings.proxy_toggler
501
+ title: t`Proxy Toggler`
546
502
  },
547
503
  r('input', {
548
504
  type: 'checkbox',
@@ -567,10 +523,10 @@ function StaticFilesList() {
567
523
  Fragment(
568
524
  r('tr', null,
569
525
  r('th', { colspan: (2 + Number(!groupByMethod)) + Number(canProxy) }),
570
- r('th', null, Strings.static_get)),
526
+ r('th', null, t`Static GET`)),
571
527
  Object.values(staticBrokers).map((broker, i) =>
572
528
  r('tr', null,
573
- canProxy && r('td', null, ProxyStaticToggler()),
529
+ canProxy && r('td'),
574
530
  r('td', null, DelayStaticRouteToggler(broker)),
575
531
  r('td', null, NotFoundToggler(broker)),
576
532
  !groupByMethod && r('td', className(CSS.Method), 'GET'),
@@ -605,31 +561,14 @@ function NotFoundToggler(broker) {
605
561
  return (
606
562
  r('label', {
607
563
  className: CSS.NotFoundToggler,
608
- title: Strings.not_found
564
+ title: t`Not Found`
609
565
  },
610
566
  r('input', {
611
567
  type: 'checkbox',
612
568
  checked: broker.status === 404,
613
569
  onChange
614
570
  }),
615
- r('span', null, '404')))
616
- }
617
-
618
- function ProxyStaticToggler() { // TODO
619
- function onChange() {
620
- }
621
- return (
622
- r('label', {
623
- style: { visibility: 'hidden' },
624
- className: CSS.ProxyToggler,
625
- title: Strings.proxy_toggler
626
- },
627
- r('input', {
628
- type: 'checkbox',
629
- disabled: true,
630
- onChange
631
- }),
632
- CloudIcon()))
571
+ r('span', null, t`404`)))
633
572
  }
634
573
 
635
574
 
@@ -652,7 +591,7 @@ function ClickDragToggler({ checked, commit }) {
652
591
  return (
653
592
  r('label', {
654
593
  className: CSS.DelayToggler,
655
- title: Strings.delay
594
+ title: t`Delay`
656
595
  },
657
596
  r('input', {
658
597
  type: 'checkbox',
@@ -706,19 +645,20 @@ Resizer.onUp = function () {
706
645
 
707
646
  const payloadViewerTitleRef = useRef()
708
647
  const payloadViewerRef = useRef()
648
+ const SPINNER_DELAY = 80
709
649
 
710
650
  function PayloadViewer() {
711
651
  return (
712
652
  r('div', className(CSS.PayloadViewer),
713
- r('h2', { ref: payloadViewerTitleRef }, Strings.preview),
653
+ r('h2', { ref: payloadViewerTitleRef }, t`Preview`),
714
654
  r('pre', null,
715
- r('code', { ref: payloadViewerRef }, Strings.click_link_to_preview))))
655
+ r('code', { ref: payloadViewerRef }, t`Click a link to preview it`))))
716
656
  }
717
657
 
718
658
  function PayloadViewerProgressBar() {
719
659
  return (
720
660
  r('div', className(CSS.ProgressBar),
721
- r('div', { style: { animationDuration: state.delay + 'ms' } })))
661
+ r('div', { style: { animationDuration: state.delay - SPINNER_DELAY + 'ms' } })))
722
662
  }
723
663
 
724
664
  function PayloadViewerTitle({ file, statusText }) {
@@ -737,8 +677,8 @@ function PayloadViewerTitleWhenProxied({ mime, status, statusText, gatewayIsBad
737
677
  return (
738
678
  r('span', null,
739
679
  gatewayIsBad
740
- ? r('span', className(CSS.red), Strings.fallback_server_error + ' ')
741
- : r('span', null, Strings.got + ' '),
680
+ ? r('span', className(CSS.red), t`⛔ Fallback Backend Error` + ' ')
681
+ : r('span', null, t`Got` + ' '),
742
682
  r('abbr', { title: statusText }, status),
743
683
  ' ' + mime))
744
684
  }
@@ -748,9 +688,9 @@ async function previewMock(method, urlMask, href) {
748
688
  previewMock.controller = new AbortController
749
689
 
750
690
  const spinnerTimer = setTimeout(() => {
751
- payloadViewerTitleRef.current.replaceChildren(Strings.fetching)
691
+ payloadViewerTitleRef.current.replaceChildren(t`Fetching…`)
752
692
  payloadViewerRef.current.replaceChildren(PayloadViewerProgressBar())
753
- }, 80)
693
+ }, SPINNER_DELAY)
754
694
 
755
695
  try {
756
696
  const response = await fetch(href, {
@@ -766,11 +706,12 @@ async function previewMock(method, urlMask, href) {
766
706
  }
767
707
  }
768
708
 
709
+
769
710
  async function updatePayloadViewer(method, urlMask, response) {
770
711
  const mime = response.headers.get('content-type') || ''
771
712
 
772
713
  const file = mockSelectorFor(method, urlMask).value
773
- if (file === Strings.proxied)
714
+ if (file === STR_PROXIED)
774
715
  payloadViewerTitleRef.current.replaceChildren(PayloadViewerTitleWhenProxied({
775
716
  status: response.status,
776
717
  statusText: response.statusText,
@@ -790,7 +731,7 @@ async function updatePayloadViewer(method, urlMask, response) {
790
731
  }))
791
732
  }
792
733
  else {
793
- const body = await response.text() || Strings.empty_response_body
734
+ const body = await response.text() || t`/* Empty Response Body */`
794
735
  if (mime === 'application/json')
795
736
  payloadViewerRef.current.replaceChildren(r('span', className(CSS.json), syntaxJSON(body)))
796
737
  else if (isXML(mime))
@@ -834,9 +775,9 @@ function onError(error) {
834
775
  if (error?.name === 'AbortError')
835
776
  return
836
777
  if (error?.message === 'Failed to fetch')
837
- showErrorToast(Strings.error_server_down)
778
+ showErrorToast(t`Looks like the Mockaton server is not running`)
838
779
  else
839
- showErrorToast(error || Strings.error_unexpected_error)
780
+ showErrorToast(error || t`Unexpected Error`)
840
781
  console.error(error)
841
782
  }
842
783
 
@@ -921,6 +862,14 @@ function className(...args) {
921
862
 
922
863
 
923
864
  function createElement(tag, props, ...children) {
865
+ if (props?.deferred) {
866
+ delete props.deferred
867
+ const placeholder = document.createComment('')
868
+ deferred(() =>
869
+ placeholder.replaceWith(createElement(tag, props, ...children)))
870
+ return placeholder
871
+ }
872
+
924
873
  const node = document.createElement(tag)
925
874
  for (const [k, v] of Object.entries(props || {}))
926
875
  if (k === 'ref') v.current = node
@@ -954,6 +903,43 @@ function Fragment(...args) {
954
903
  return frag
955
904
  }
956
905
 
906
+ function deferred(cb) {
907
+ return window.requestIdleCallback
908
+ ? requestIdleCallback(cb)
909
+ : setTimeout(cb, 100) // Safari
910
+ }
911
+
912
+
913
+ // When false, the URL will be updated with param=false
914
+ function initPreference(param) {
915
+ const qs = new URLSearchParams(location.search)
916
+ if (!qs.has(param)) {
917
+ const group = localStorage.getItem(param) !== 'false'
918
+ if (!group) {
919
+ const url = new URL(location.href)
920
+ url.searchParams.set(param, false)
921
+ history.replaceState(null, '', url)
922
+ }
923
+ return group
924
+ }
925
+ return qs.get(param) !== 'false'
926
+ }
927
+
928
+ // When false, the URL and localStorage will have param=false
929
+ function togglePreference(param, nextVal) {
930
+ if (nextVal)
931
+ localStorage.removeItem(param)
932
+ else
933
+ localStorage.setItem(param, nextVal)
934
+
935
+ const url = new URL(location.href)
936
+ if (nextVal)
937
+ url.searchParams.delete(param)
938
+ else
939
+ url.searchParams.set(param, false)
940
+ history.replaceState(null, '', url)
941
+ }
942
+
957
943
 
958
944
  /**
959
945
  * Think of this as a way of printing a directory tree in which
@@ -985,7 +971,7 @@ function dittoSplitPaths(paths) {
985
971
  return result
986
972
  }
987
973
 
988
- (function testDittoSplitPaths() {
974
+ dittoSplitPaths.test = function () {
989
975
  const input = [
990
976
  '/api/user',
991
977
  '/api/user/avatar',
@@ -1007,7 +993,7 @@ function dittoSplitPaths(paths) {
1007
993
  ['/v2/foo/', 'bar']
1008
994
  ]
1009
995
  console.assert(JSON.stringify(dittoSplitPaths(input)) === JSON.stringify(expected))
1010
- }())
996
+ }
1011
997
 
1012
998
 
1013
999
 
@@ -1,14 +1,26 @@
1
+ export default (nonce) => `
1
2
  <!DOCTYPE html>
2
3
  <html lang="en-US">
3
4
  <head>
4
5
  <meta charset="UTF-8">
5
- <link rel="stylesheet" href="./mockaton/Dashboard.css">
6
+
7
+ <script nonce="${nonce}">
8
+ // Fetches critical API data ahead-of-time.
9
+ // It’s before loading the stylesheet because CSS blocks.
10
+ // https://github.com/ericfortis/aot-fetch-demo
11
+ const initialState = location.origin + '/mockaton/state'
12
+ window._aotFetch = { [initialState]: fetch(initialState) }
13
+ </script>
14
+
15
+ <link rel="stylesheet" href="./mockaton/Dashboard.css" />
16
+
6
17
  <link rel="icon" href="data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m235 33.7v202c0 9.19-5.81 14-17.4 14-11.6 0-17.4-4.83-17.4-14v-151c-0.115-4.49-6.72-5.88-8.46-0.87l-48.3 155c-2.22 7.01-7.72 10.1-16 9.9-3.63-0.191-7.01-1.14-9.66-2.89-2.89-1.72-4.83-4.34-5.57-7.72-11.1-37-22.6-74.3-34.1-111-4.34-14-8.95-31.4-14-48.3-1.82-4.83-8.16-5.32-8.46 1.16v156c0 9.19-5.81 14-17.4 14-11.6 0-17.4-4.83-17.4-14v-207c0-5.74 2.62-13.2 9.39-16.3 7.5-3.14 15-4.05 21.8-3.8 3.14 0 6.03 0.686 8.95 1.46 3.14 0.797 6.03 1.98 8.7 3.63 2.65 1.38 5.32 3.14 7.5 5.57 2.22 2.22 3.87 4.83 5.07 7.72l45.8 157c4.63-15.9 32.4-117 33.3-121 4.12-13.8 7.72-26.5 10.9-38.7 1.16-2.65 2.89-5.32 5.07-7.5 2.15-2.15 4.58-4.12 7.5-5.32 2.65-1.57 5.57-2.89 8.46-3.63 3.14-0.797 9.44-0.988 12.1-0.988 11.6 1.07 29.4 9.14 29.4 27z' fill='%23808080'/%3E%3C/svg%3E">
7
18
  <meta name="viewport" content="width=device-width, initial-scale=1">
8
19
  <meta name="description" content="HTTP Mock Server">
9
20
  <title>Mockaton</title>
10
21
  </head>
11
22
  <body>
12
- <script src="./mockaton/Dashboard.js" type="module"></script>
23
+ <script type="module" src="./mockaton/Dashboard.js"></script>
13
24
  </body>
14
25
  </html>
26
+ `
package/src/Mockaton.js CHANGED
@@ -52,8 +52,9 @@ async function onRequest(req, response) {
52
52
  sendBadRequest(response)
53
53
  return
54
54
  }
55
-
55
+
56
56
  try {
57
+ const route = new URL(url, 'http://_').pathname
57
58
  const { method } = req
58
59
 
59
60
  if (config.corsAllowed)
@@ -61,11 +62,11 @@ async function onRequest(req, response) {
61
62
 
62
63
  if (isPreflight(req))
63
64
  sendNoContent(response)
64
- else if (method === 'PATCH' && apiPatchRequests.has(url))
65
- await apiPatchRequests.get(url)(req, response)
66
- else if (method === 'GET' && apiGetRequests.has(url))
67
- apiGetRequests.get(url)(req, response)
68
- else if (method === 'GET' && staticCollection.brokerByRoute(url))
65
+ else if (method === 'PATCH' && apiPatchRequests.has(route))
66
+ await apiPatchRequests.get(route)(req, response)
67
+ else if (method === 'GET' && apiGetRequests.has(route))
68
+ apiGetRequests.get(route)(req, response)
69
+ else if (method === 'GET' && staticCollection.brokerByRoute(route))
69
70
  await dispatchStatic(req, response)
70
71
  else
71
72
  await dispatchMock(req, response)
@@ -9,6 +9,12 @@ export function sendOK(response) {
9
9
  response.end()
10
10
  }
11
11
 
12
+ export function sendHTML(response, html) {
13
+ logger.access(response)
14
+ response.setHeader('Content-Type', mimeFor('html'))
15
+ response.end(html)
16
+ }
17
+
12
18
  export function sendJSON(response, payload) {
13
19
  logger.access(response)
14
20
  response.setHeader('Content-Type', 'application/json')