mockaton 11.2.6 → 11.3.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.
@@ -3,8 +3,7 @@ import { randomUUID } from 'node:crypto'
3
3
 
4
4
  import { extFor } from './utils/mime.js'
5
5
  import { write, isFile } from './utils/fs.js'
6
- import { readBody, BodyReaderError } from './utils/http-request.js'
7
- import { sendUnprocessable, sendBadGateway } from './utils/http-response.js'
6
+ import { readBody, BodyReaderError } from './utils/HttpIncomingMessage.js'
8
7
 
9
8
  import { config } from './config.js'
10
9
  import { makeMockFilename } from './Filename.js'
@@ -23,9 +22,9 @@ export async function proxy(req, response, delay) {
23
22
  }
24
23
  catch (error) { // TESTME
25
24
  if (error instanceof BodyReaderError)
26
- sendUnprocessable(response, error.name)
25
+ response.unprocessable(error.name)
27
26
  else
28
- sendBadGateway(response, error)
27
+ response.badGateway(error)
29
28
  return
30
29
  }
31
30
 
@@ -4,7 +4,6 @@ import { readFileSync } from 'node:fs'
4
4
  import { isFile } from './utils/fs.js'
5
5
  import { logger } from './utils/logger.js'
6
6
  import { mimeFor } from './utils/mime.js'
7
- import { sendMockNotFound, sendPartialContent } from './utils/http-response.js'
8
7
 
9
8
  import { brokerByRoute } from './staticCollection.js'
10
9
  import { config, calcDelay } from './config.js'
@@ -16,19 +15,19 @@ export async function dispatchStatic(req, response) {
16
15
 
17
16
  setTimeout(async () => {
18
17
  if (!broker || broker.status === 404) {
19
- sendMockNotFound(response)
18
+ response.mockNotFound()
20
19
  return
21
20
  }
22
21
 
23
22
  const file = join(config.staticDir, broker.route)
24
23
  if (!isFile(file)) {
25
- sendMockNotFound(response)
24
+ response.mockNotFound()
26
25
  return
27
26
  }
28
27
 
29
28
  logger.accessMock(req.url, 'static200')
30
29
  if (req.headers.range)
31
- await sendPartialContent(response, req.headers.range, file)
30
+ await response.partialContent(req.headers.range, file)
32
31
  else {
33
32
  response.setHeader('Content-Type', mimeFor(file))
34
33
  response.end(readFileSync(file))
@@ -8,7 +8,6 @@ import {
8
8
  } from './ApiConstants.js'
9
9
 
10
10
  import { config } from './config.js'
11
- import { sendJSON } from './utils/http-response.js'
12
11
  import { isFile, isDirectory } from './utils/fs.js'
13
12
 
14
13
  import * as staticCollection from './staticCollection.js'
@@ -22,7 +21,6 @@ import * as mockBrokerCollection from './mockBrokersCollection.js'
22
21
  * and also renames, which are two events (delete + add).
23
22
  */
24
23
  const uiSyncVersion = new class extends EventEmitter {
25
- delay = Number(process.env.MOCKATON_WATCHER_DEBOUNCE_MS ?? 80)
26
24
  version = 0
27
25
 
28
26
  increment = /** @type {function} */ this.#debounce(() => {
@@ -41,7 +39,7 @@ const uiSyncVersion = new class extends EventEmitter {
41
39
  let timer
42
40
  return () => {
43
41
  clearTimeout(timer)
44
- timer = setTimeout(fn, this.delay)
42
+ timer = setTimeout(fn, config.watcherDebounceMs)
45
43
  }
46
44
  }
47
45
  }
@@ -101,12 +99,12 @@ export function longPollClientSyncVersion(req, response) {
101
99
  const clientVersion = req.headers[HEADER_SYNC_VERSION]
102
100
  if (clientVersion !== undefined && uiSyncVersion.version !== Number(clientVersion)) {
103
101
  // e.g., tab was hidden while new mocks were added or removed
104
- sendJSON(response, uiSyncVersion.version)
102
+ response.json(uiSyncVersion.version)
105
103
  return
106
104
  }
107
105
  function onARR() {
108
106
  uiSyncVersion.unsubscribe(onARR)
109
- sendJSON(response, uiSyncVersion.version)
107
+ response.json(uiSyncVersion.version)
110
108
  }
111
109
  response.setTimeout(LONG_POLL_SERVER_TIMEOUT, onARR)
112
110
  req.on('error', () => {
@@ -1,12 +1,10 @@
1
1
  import { join } from 'node:path'
2
2
  import { EventEmitter } from 'node:events'
3
3
  import { watch, readdirSync } from 'node:fs'
4
- import { sendJSON, sendNotFound } from './utils/http-response.js'
4
+ import { config } from './config.js'
5
5
  import { LONG_POLL_SERVER_TIMEOUT } from './ApiConstants.js'
6
6
 
7
7
 
8
- const DEV = process.env.NODE_ENV === 'development'
9
-
10
8
  export const CLIENT_DIR = join(import.meta.dirname, '../client')
11
9
  export const DASHBOARD_ASSETS = readdirSync(CLIENT_DIR)
12
10
 
@@ -29,18 +27,18 @@ export function watchDevSPA() {
29
27
 
30
28
  /** Realtime notify Dev UI changes */
31
29
  export function longPollDevClientHotReload(req, response) {
32
- if (!DEV) {
33
- sendNotFound(response)
30
+ if (!config.hotReload) {
31
+ response.notFound()
34
32
  return
35
33
  }
36
34
 
37
35
  function onDevChange(file) {
38
36
  devClientWatcher.unsubscribe(onDevChange)
39
- sendJSON(response, file)
37
+ response.json(file)
40
38
  }
41
39
  response.setTimeout(LONG_POLL_SERVER_TIMEOUT, () => {
42
40
  devClientWatcher.unsubscribe(onDevChange)
43
- sendJSON(response, '')
41
+ response.json('')
44
42
  })
45
43
  req.on('error', () => {
46
44
  devClientWatcher.unsubscribe(onDevChange)
@@ -4,7 +4,7 @@ import { logger } from './utils/logger.js'
4
4
  import { isDirectory } from './utils/fs.js'
5
5
  import { openInBrowser } from './utils/openInBrowser.js'
6
6
  import { optional, is, validate } from './utils/validate.js'
7
- import { SUPPORTED_METHODS } from './utils/http-request.js'
7
+ import { SUPPORTED_METHODS } from './utils/HttpIncomingMessage.js'
8
8
  import { validateCorsAllowedMethods, validateCorsAllowedOrigins } from './utils/http-cors.js'
9
9
 
10
10
  import { jsToJsonPlugin } from './MockDispatcher.js'
@@ -21,6 +21,7 @@ const schema = {
21
21
  staticDir: [resolve('mockaton-static-mocks'), optional(isDirectory)],
22
22
  ignore: [/(\.DS_Store|~)$/, is(RegExp)],
23
23
  watcherEnabled: [true, is(Boolean)],
24
+ watcherDebounceMs: [80, ms => Number.isInteger(ms) && ms >= 0],
24
25
 
25
26
  host: ['127.0.0.1', is(String)],
26
27
  port: [0, port => Number.isInteger(port) && port >= 0 && port < 2 ** 16], // 0 means auto-assigned
@@ -73,9 +74,6 @@ export const ConfigValidator = Object.freeze(validators)
73
74
 
74
75
  /** @param {Partial<Config>} opts */
75
76
  export function setup(opts) {
76
- if (process.env.NODE_ENV !== 'development')
77
- opts.hotReload = false
78
-
79
77
  if (opts.mocksDir)
80
78
  opts.mocksDir = resolve(opts.mocksDir)
81
79
 
@@ -1,4 +1,4 @@
1
- import { METHODS } from 'node:http'
1
+ import http, { METHODS } from 'node:http'
2
2
 
3
3
 
4
4
  export const SUPPORTED_METHODS = METHODS
@@ -12,6 +12,12 @@ export class BodyReaderError extends Error {
12
12
  }
13
13
  }
14
14
 
15
+ export class IncomingMessage extends http.IncomingMessage {
16
+ json() {
17
+ return readBody(this, JSON.parse)
18
+ }
19
+ }
20
+
15
21
  export const parseJSON = req => readBody(req, JSON.parse)
16
22
 
17
23
  export function readBody(req, parser = a => a) {
@@ -0,0 +1,117 @@
1
+ import http from 'node:http'
2
+ import fs, { readFileSync } from 'node:fs'
3
+
4
+ import { logger } from './logger.js'
5
+ import { mimeFor } from './mime.js'
6
+ import { HEADER_502 } from '../ApiConstants.js'
7
+
8
+
9
+ export class ServerResponse extends http.ServerResponse {
10
+ setHeaderList(headers) {
11
+ for (let i = 0; i < headers.length; i += 2)
12
+ this.setHeader(headers[i], headers[i + 1])
13
+ }
14
+
15
+ ok() {
16
+ logger.access(this)
17
+ this.end()
18
+ }
19
+
20
+ html(html, csp) {
21
+ logger.access(this)
22
+ this.setHeader('Content-Type', mimeFor('html'))
23
+ this.setHeader('Content-Security-Policy', csp)
24
+ this.end(html)
25
+ }
26
+
27
+ json(payload) {
28
+ logger.access(this)
29
+ this.setHeader('Content-Type', 'application/json')
30
+ this.end(JSON.stringify(payload))
31
+ }
32
+
33
+ file(file) {
34
+ logger.access(this)
35
+ this.setHeader('Content-Type', mimeFor(file))
36
+ this.end(readFileSync(file, 'utf8'))
37
+ }
38
+
39
+ noContent() {
40
+ this.statusCode = 204
41
+ logger.access(this)
42
+ this.end()
43
+ }
44
+
45
+
46
+ badRequest() {
47
+ this.statusCode = 400
48
+ logger.access(this)
49
+ this.end()
50
+ }
51
+
52
+ notFound() {
53
+ this.statusCode = 404
54
+ logger.access(this)
55
+ this.end()
56
+ }
57
+
58
+ mockNotFound() {
59
+ this.statusCode = 404
60
+ logger.accessMock(this.req.url, '404')
61
+ this.end()
62
+ }
63
+
64
+ uriTooLong() {
65
+ this.statusCode = 414
66
+ logger.access(this)
67
+ this.end()
68
+ }
69
+
70
+ unprocessable(error) {
71
+ logger.access(this, error)
72
+ this.statusCode = 422
73
+ this.end(error)
74
+ }
75
+
76
+
77
+ internalServerError(error) {
78
+ logger.error(500, this.req.url, error?.message || error, error?.stack || '')
79
+ this.statusCode = 500
80
+ this.end()
81
+ }
82
+
83
+ badGateway(error) {
84
+ logger.warn('Fallback Proxy Error:', error.cause.message)
85
+ this.statusCode = 502
86
+ this.setHeader(HEADER_502, 1)
87
+ this.end()
88
+ }
89
+
90
+
91
+ async partialContent(range, file) {
92
+ const { size } = await fs.promises.lstat(file)
93
+ let [start, end] = range.replace(/bytes=/, '').split('-').map(n => parseInt(n, 10))
94
+ if (isNaN(end)) end = size - 1
95
+ if (isNaN(start)) start = size - end
96
+
97
+ if (start < 0 || start > end || start >= size || end >= size) {
98
+ this.statusCode = 416 // Range Not Satisfiable
99
+ this.setHeader('Content-Range', `bytes */${size}`)
100
+ this.end()
101
+ }
102
+ else {
103
+ this.statusCode = 206 // Partial Content
104
+ this.setHeader('Accept-Ranges', 'bytes')
105
+ this.setHeader('Content-Range', `bytes ${start}-${end}/${size}`)
106
+ this.setHeader('Content-Type', mimeFor(file))
107
+ const reader = fs.createReadStream(file, { start, end })
108
+ const response = this
109
+ reader.on('open', function () {
110
+ this.pipe(response)
111
+ })
112
+ reader.on('error', error => {
113
+ this.internalServerError(error)
114
+ })
115
+ }
116
+ }
117
+ }
@@ -1,4 +1,4 @@
1
- import { methodIsSupported } from './http-request.js'
1
+ import { methodIsSupported } from './HttpIncomingMessage.js'
2
2
 
3
3
  /* https://www.w3.org/TR/2020/SPSD-cors-20200602/#resource-processing-model */
4
4
 
@@ -0,0 +1,225 @@
1
+ import { equal } from 'node:assert/strict'
2
+ import { promisify } from 'node:util'
3
+ import { createServer } from 'node:http'
4
+ import { describe, test, after } from 'node:test'
5
+ import { isPreflight, setCorsHeaders, CorsHeader as CH } from './http-cors.js'
6
+
7
+
8
+ function headerIs(response, header, value) {
9
+ equal(response.headers.get(header), value)
10
+ }
11
+
12
+ const FooDotTest = 'https://foo.test'
13
+ const AllowedDotTest = 'https://allowed.test'
14
+ const NotAllowedDotTest = 'https://not-allowed.test'
15
+
16
+ await describe('CORS', async () => {
17
+ let corsConfig = {}
18
+
19
+ const server = createServer((req, response) => {
20
+ setCorsHeaders(req, response, corsConfig)
21
+ if (isPreflight(req)) {
22
+ response.statusCode = 204
23
+ response.end()
24
+ }
25
+ else
26
+ response.end('NON_PREFLIGHT')
27
+ })
28
+ await promisify(server.listen).bind(server, 0, '127.0.0.1')()
29
+ after(() => {
30
+ server.close()
31
+ })
32
+ function preflight(headers, method = 'OPTIONS') {
33
+ const { address, port } = server.address()
34
+ return fetch(`http://${address}:${port}/`, { method, headers })
35
+ }
36
+ function request(headers, method) {
37
+ const { address, port } = server.address()
38
+ return fetch(`http://${address}:${port}/`, { method, headers })
39
+ }
40
+
41
+ await describe('Identifies Preflight Requests', async () => {
42
+ const requiredRequestHeaders = {
43
+ [CH.Origin]: 'http://localhost:9999',
44
+ [CH.AcRequestMethod]: 'POST'
45
+ }
46
+
47
+ await test('Ignores non-OPTIONS requests', async () => {
48
+ const res = await request(requiredRequestHeaders, 'POST')
49
+ equal(await res.text(), 'NON_PREFLIGHT')
50
+ })
51
+
52
+ await test(`Ignores non-parseable req ${CH.Origin} header`, async () => {
53
+ const headers = {
54
+ ...requiredRequestHeaders,
55
+ [CH.Origin]: 'non-url'
56
+ }
57
+ const res = await preflight(headers)
58
+ equal(await res.text(), 'NON_PREFLIGHT')
59
+ })
60
+
61
+ await test(`Ignores missing method in ${CH.AcRequestMethod} header`, async () => {
62
+ const headers = { ...requiredRequestHeaders }
63
+ delete headers[CH.AcRequestMethod]
64
+ const res = await preflight(headers)
65
+ equal(await res.text(), 'NON_PREFLIGHT')
66
+ })
67
+
68
+ await test(`Ignores non-standard method in ${CH.AcRequestMethod} header`, async () => {
69
+ const headers = {
70
+ ...requiredRequestHeaders,
71
+ [CH.AcRequestMethod]: 'NON_STANDARD'
72
+ }
73
+ const res = await preflight(headers)
74
+ equal(await res.text(), 'NON_PREFLIGHT')
75
+ })
76
+
77
+ await test('204 valid preflights', async () => {
78
+ const res = await preflight(requiredRequestHeaders)
79
+ equal(res.status, 204)
80
+ })
81
+ })
82
+
83
+ await describe('Preflight Response Headers', async () => {
84
+ await test('no origins allowed', async () => {
85
+ corsConfig = {
86
+ corsOrigins: [],
87
+ corsMethods: ['GET']
88
+ }
89
+ const p = await preflight({
90
+ [CH.Origin]: FooDotTest,
91
+ [CH.AcRequestMethod]: 'GET'
92
+ })
93
+ headerIs(p, CH.AcAllowOrigin, null)
94
+ headerIs(p, CH.AcAllowMethods, null)
95
+ headerIs(p, CH.AcAllowCredentials, null)
96
+ headerIs(p, CH.AcAllowHeaders, null)
97
+ headerIs(p, CH.AcMaxAge, null)
98
+ })
99
+
100
+ await test('not in allowed origins', async () => {
101
+ corsConfig = {
102
+ corsOrigins: [AllowedDotTest],
103
+ corsMethods: ['GET']
104
+ }
105
+ const p = await preflight({
106
+ [CH.Origin]: NotAllowedDotTest,
107
+ [CH.AcRequestMethod]: 'GET'
108
+ })
109
+ headerIs(p, CH.AcAllowOrigin, null)
110
+ headerIs(p, CH.AcAllowMethods, null)
111
+ headerIs(p, CH.AcAllowCredentials, null)
112
+ headerIs(p, CH.AcAllowHeaders, null)
113
+ })
114
+
115
+ await test('origin and method match', async () => {
116
+ corsConfig = {
117
+ corsOrigins: [AllowedDotTest],
118
+ corsMethods: ['GET']
119
+ }
120
+ const p = await preflight({
121
+ [CH.Origin]: AllowedDotTest,
122
+ [CH.AcRequestMethod]: 'GET'
123
+ })
124
+ headerIs(p, CH.AcAllowOrigin, AllowedDotTest)
125
+ headerIs(p, CH.AcAllowMethods, 'GET')
126
+ headerIs(p, CH.AcAllowCredentials, null)
127
+ headerIs(p, CH.AcAllowHeaders, null)
128
+ })
129
+
130
+ await test('origin matches from multiple', async () => {
131
+ corsConfig = {
132
+ corsOrigins: [AllowedDotTest, FooDotTest],
133
+ corsMethods: ['GET']
134
+ }
135
+ const p = await preflight({
136
+ [CH.Origin]: AllowedDotTest,
137
+ [CH.AcRequestMethod]: 'GET'
138
+ })
139
+ headerIs(p, CH.AcAllowOrigin, AllowedDotTest)
140
+ headerIs(p, CH.AcAllowMethods, 'GET')
141
+ headerIs(p, CH.AcAllowCredentials, null)
142
+ headerIs(p, CH.AcAllowHeaders, null)
143
+ })
144
+
145
+ await test('wildcard origin', async () => {
146
+ corsConfig = {
147
+ corsOrigins: ['*'],
148
+ corsMethods: ['GET']
149
+ }
150
+ const p = await preflight({
151
+ [CH.Origin]: FooDotTest,
152
+ [CH.AcRequestMethod]: 'GET'
153
+ })
154
+ headerIs(p, CH.AcAllowOrigin, FooDotTest)
155
+ headerIs(p, CH.AcAllowMethods, 'GET')
156
+ headerIs(p, CH.AcAllowCredentials, null)
157
+ headerIs(p, CH.AcAllowHeaders, null)
158
+ })
159
+
160
+ await test(`wildcard and credentials`, async () => {
161
+ corsConfig = {
162
+ corsOrigins: ['*'],
163
+ corsMethods: ['GET'],
164
+ corsCredentials: true
165
+ }
166
+ const p = await preflight({
167
+ [CH.Origin]: FooDotTest,
168
+ [CH.AcRequestMethod]: 'GET'
169
+ })
170
+ headerIs(p, CH.AcAllowOrigin, FooDotTest)
171
+ headerIs(p, CH.AcAllowMethods, 'GET')
172
+ headerIs(p, CH.AcAllowCredentials, 'true')
173
+ headerIs(p, CH.AcAllowHeaders, null)
174
+ })
175
+
176
+ await test(`wildcard, credentials, and headers`, async () => {
177
+ corsConfig = {
178
+ corsOrigins: ['*'],
179
+ corsMethods: ['GET'],
180
+ corsCredentials: true,
181
+ corsHeaders: ['content-type', 'my-header']
182
+ }
183
+ const p = await preflight({
184
+ [CH.Origin]: FooDotTest,
185
+ [CH.AcRequestMethod]: 'GET'
186
+ })
187
+ headerIs(p, CH.AcAllowOrigin, FooDotTest)
188
+ headerIs(p, CH.AcAllowMethods, 'GET')
189
+ headerIs(p, CH.AcAllowCredentials, 'true')
190
+ headerIs(p, CH.AcAllowHeaders, 'content-type,my-header')
191
+ })
192
+ })
193
+
194
+ await describe('Non-Preflight (Actual Response) Headers', async () => {
195
+ await test('no origins allowed', async () => {
196
+ corsConfig = {
197
+ corsOrigins: [],
198
+ corsMethods: ['GET']
199
+ }
200
+ const p = await request({
201
+ [CH.Origin]: NotAllowedDotTest
202
+ })
203
+ equal(p.status, 200)
204
+ headerIs(p, CH.AcAllowOrigin, null)
205
+ headerIs(p, CH.AcAllowCredentials, null)
206
+ headerIs(p, CH.AcExposeHeaders, null)
207
+ })
208
+
209
+ await test('origin allowed', async () => {
210
+ corsConfig = {
211
+ corsOrigins: [AllowedDotTest],
212
+ corsMethods: ['GET'],
213
+ corsCredentials: true,
214
+ corsExposedHeaders: ['x-h1', 'x-h2']
215
+ }
216
+ const p = await request({
217
+ [CH.Origin]: AllowedDotTest
218
+ })
219
+ equal(p.status, 200)
220
+ headerIs(p, CH.AcAllowOrigin, AllowedDotTest)
221
+ headerIs(p, CH.AcAllowCredentials, 'true')
222
+ headerIs(p, CH.AcExposeHeaders, 'x-h1,x-h2')
223
+ })
224
+ })
225
+ })
@@ -9,13 +9,8 @@ export function jwtCookie(cookieName, payload, path = '/') {
9
9
  function jwt(payload) {
10
10
  return [
11
11
  'Header_Not_In_Use',
12
- toBase64Url(payload),
12
+ Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'),
13
13
  'Signature_Not_In_Use'
14
14
  ].join('.')
15
15
  }
16
16
 
17
- function toBase64Url(obj) {
18
- return btoa(JSON.stringify(obj))
19
- .replace('+', '-')
20
- .replace('/', '_')
21
- }
@@ -1,4 +1,4 @@
1
- import { decode, reControlAndDelChars } from './http-request.js'
1
+ import { decode, reControlAndDelChars } from './HttpIncomingMessage.js'
2
2
 
3
3
 
4
4
  export const logger = new class {
@@ -11,9 +11,13 @@ import { UNKNOWN_MIME_EXT } from '../ApiConstants.js'
11
11
  const extToMime = {
12
12
  '3g2': 'video/3gpp2',
13
13
  '3gp': 'video/3gpp',
14
+ '3mf': 'model/3mf',
14
15
  '7z': 'application/x-7z-compressed',
15
16
  aac: 'audio/aac',
16
17
  abw: 'application/x-abiword',
18
+ aif: 'audio/aiff',
19
+ aifc: 'audio/aiff',
20
+ aiff: 'audio/aiff',
17
21
  apng: 'image/apng',
18
22
  arc: 'application/x-freearc',
19
23
  avi: 'video/x-msvideo',
@@ -28,12 +32,22 @@ const extToMime = {
28
32
  csh: 'application/x-csh',
29
33
  css: 'text/css',
30
34
  csv: 'text/csv',
35
+ dae: 'model/vnd.collada+xml',
31
36
  doc: 'application/msword',
32
37
  docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
38
+ drc: 'model/vnd.draco',
39
+ eml: 'message/rfc822',
33
40
  eot: 'application/vnd.ms-fontobject',
34
41
  epub: 'application/epub+zip',
42
+ exe: 'application/vnd.microsoft.portable-executable',
43
+ fbx: 'application/octet-stream',
44
+ flac: 'audio/flac',
35
45
  gif: 'image/gif',
46
+ glb: 'model/gltf-binary',
47
+ gltf: 'model/gltf+json',
36
48
  gz: 'application/gzip',
49
+ heic: 'image/heic',
50
+ heif: 'image/heif',
37
51
  htm: 'text/html',
38
52
  html: 'text/html',
39
53
  ico: 'image/vnd.microsoft.icon',
@@ -44,13 +58,21 @@ const extToMime = {
44
58
  js: 'application/javascript',
45
59
  json: 'application/json',
46
60
  jsonld: 'application/ld+json',
61
+ lz: 'application/x-lzip',
62
+ m4a: 'audio/mp4',
63
+ map: 'application/json',
64
+ md: 'text/markdown',
47
65
  mid: 'audio/midi',
48
66
  midi: 'audio/midi',
49
67
  mjs: 'text/javascript',
68
+ mkv: 'video/x-matroska',
69
+ mov: 'video/quicktime',
50
70
  mp3: 'audio/mpeg',
51
71
  mp4: 'video/mp4',
52
72
  mpeg: 'video/mpeg',
53
73
  mpkg: 'application/vnd.apple.installer+xml',
74
+ mtl: 'text/plain',
75
+ obj: 'text/plain',
54
76
  odp: 'application/vnd.oasis.opendocument.presentation',
55
77
  ods: 'application/vnd.oasis.opendocument.spreadsheet',
56
78
  odt: 'application/vnd.oasis.opendocument.text',
@@ -61,19 +83,24 @@ const extToMime = {
61
83
  otf: 'font/otf',
62
84
  pdf: 'application/pdf',
63
85
  php: 'application/x-httpd-php',
86
+ ply: 'application/octet-stream',
64
87
  png: 'image/png',
65
88
  ppt: 'application/vnd.ms-powerpoint',
66
89
  pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
67
90
  rar: 'application/vnd.rar',
68
91
  rtf: 'application/rtf',
69
92
  sh: 'application/x-sh',
93
+ stl: 'model/stl',
70
94
  svg: 'image/svg+xml',
71
95
  tar: 'application/x-tar',
72
96
  tif: 'image/tiff',
73
97
  ts: 'video/mp2t',
74
98
  ttf: 'font/ttf',
75
99
  txt: 'text/plain',
100
+ usd: 'model/vnd.usd',
101
+ usdz: 'model/vnd.usdz+zip',
76
102
  vsd: 'application/vnd.visio',
103
+ wasm: 'application/wasm',
77
104
  wav: 'audio/wav',
78
105
  weba: 'audio/webm',
79
106
  webm: 'video/webm',
@@ -85,9 +112,11 @@ const extToMime = {
85
112
  xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
86
113
  xml: 'application/xml',
87
114
  xul: 'application/vnd.mozilla.xul+xml',
115
+ xz: 'application/x-xz',
88
116
  yaml: 'application/yaml',
89
117
  yml: 'application/yaml',
90
- zip: 'application/zip'
118
+ zip: 'application/zip',
119
+ zst: 'application/zstd'
91
120
  }
92
121
 
93
122
  const mimeToExt = mapMimeToExt(extToMime)
@@ -105,8 +134,8 @@ export function mimeFor(filename) {
105
134
  }
106
135
  function extname(filename) {
107
136
  const i = filename.lastIndexOf('.')
108
- return i === -1
109
- ? ''
137
+ return i === -1
138
+ ? ''
110
139
  : filename.slice(i + 1)
111
140
  }
112
141
 
@@ -0,0 +1,24 @@
1
+ import { test } from 'node:test'
2
+ import { equal } from 'node:assert/strict'
3
+ import { parseMime, extFor, mimeFor } from './mime.js'
4
+
5
+
6
+ test('parseMime', () => [
7
+ 'text/html',
8
+ 'TEXT/html',
9
+ 'text/html; charset=utf-8'
10
+ ].map(input =>
11
+ equal(parseMime(input), 'text/html')))
12
+
13
+ test('extFor', () => [
14
+ 'text/html',
15
+ 'Text/html',
16
+ 'text/Html; charset=UTF-16'
17
+ ].map(input =>
18
+ equal(extFor(input), 'html')))
19
+
20
+ test('mimeFor', () => [
21
+ 'file.html',
22
+ 'file.HTmL'
23
+ ].map(input =>
24
+ equal(mimeFor(input), 'text/html')))