packaton 0.0.26 → 0.0.27

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "packaton",
3
- "version": "0.0.26",
3
+ "version": "0.0.27",
4
4
  "type": "module",
5
5
  "author": "Eric Fortis",
6
6
  "license": "MIT",
@@ -18,6 +18,6 @@
18
18
  "terser": "^5"
19
19
  },
20
20
  "devDependencies": {
21
- "terser": "5.46.1"
21
+ "terser": "5.46.2"
22
22
  }
23
23
  }
package/src/app-dev.js CHANGED
@@ -3,6 +3,7 @@ import { createServer } from 'node:http'
3
3
 
4
4
  import { router } from './router.js'
5
5
  import { watchDev } from './plugins-dev/WatcherDevClient.js'
6
+ import { ServerResponse } from './utils/HttpServerResponse.js'
6
7
 
7
8
 
8
9
  /**
@@ -16,7 +17,7 @@ export function devStaticPages(config) {
16
17
  if (config.hotReload)
17
18
  watchDev(config.srcPath, config.watchIgnore)
18
19
 
19
- const server = createServer(router(config))
20
+ const server = createServer({ ServerResponse }, router(config))
20
21
  server.on('error', reject)
21
22
  server.listen(config.port, config.host, () => {
22
23
  const addr = `http://${server.address().address}:${server.address().port}`
package/src/app-prod.js CHANGED
@@ -6,8 +6,9 @@ import { docs } from './app.js'
6
6
  import { router } from './router.js'
7
7
  import { HtmlCompiler } from './plugins-prod/HtmlCompiler.js'
8
8
  import { sitemapPlugin } from './plugins-prod/sitemapPlugin.js'
9
+ import { ServerResponse } from './utils/HttpServerResponse.js'
9
10
  import { reportSizesPlugin } from './plugins-prod/reportSizesPlugin.js'
10
- import { write, removeDir, } from './utils/fs-utils.js'
11
+ import { write, removeDir, } from './utils/fs.js'
11
12
  import { cspNginxMapPlugin } from './plugins-prod/cspNginxMapPlugin.js'
12
13
  import { renameMediaWithHashes } from './plugins-prod/media-remaper.js'
13
14
  import { netiflyAndCloudflareHeadersPlugin } from './plugins-prod/netiflyAndCloudflareHeadersPlugin.js'
@@ -25,7 +26,7 @@ export async function buildStaticPages(config) {
25
26
  const pDist = config.outputDir
26
27
  const pDistAssets = join(pDist, config.assetsDir)
27
28
 
28
- const server = createServer(router(config))
29
+ const server = createServer({ ServerResponse }, router(config))
29
30
  server.on('error', reject)
30
31
  server.listen(0, '127.0.0.1', async () => {
31
32
  docs.init(config.srcPath, config.ignore)
@@ -79,9 +80,10 @@ export async function buildStaticPages(config) {
79
80
 
80
81
  const r = route === '/index' ? '/' : route
81
82
  headers[r] ??= []
82
- headers[r].push(['Content-Security-Policy', doc.csp()])
83
83
  for (const h of config.routeHeaders)
84
84
  headers[r].push(h)
85
+ headers[r].push(['Content-Security-Policy', doc.csp()])
86
+ cspByRoute.push([route, doc.csp()])
85
87
  }
86
88
 
87
89
  sitemapPlugin(config, docs.routes)
package/src/app.js CHANGED
@@ -2,7 +2,7 @@ import { readdirSync } from 'node:fs'
2
2
  import { basename, join, dirname } from 'node:path'
3
3
 
4
4
  import { setup } from './config.js'
5
- import { isFile } from './utils/fs-utils.js'
5
+ import { isFile } from './utils/fs.js'
6
6
  import { devStaticPages } from './app-dev.js'
7
7
  import { buildStaticPages } from './app-prod.js'
8
8
 
package/src/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { resolve } from 'node:path'
2
2
 
3
- import { isDirectory } from './utils/fs-utils.js'
3
+ import { isDirectory } from './utils/fs.js'
4
4
  import { openInBrowser } from './plugins-dev/openInBrowser.js'
5
5
 
6
6
  import { minifyJS } from './plugins-prod/minifyJS.js'
@@ -18,3 +18,29 @@ export function watchDev(rootPath, watchIgnore) {
18
18
  devClientWatcher.emit(file)
19
19
  })
20
20
  }
21
+
22
+ export function sseDevHotReload(req, response) {
23
+ response.writeHead(200, {
24
+ 'Content-Type': 'text/event-stream',
25
+ 'Cache-Control': 'no-cache',
26
+ 'Connection': 'keep-alive',
27
+ })
28
+ response.flushHeaders()
29
+
30
+ function onDevChange(file = '') {
31
+ response.write(`data: ${file}\n\n`)
32
+ }
33
+
34
+ devClientWatcher.subscribe(onDevChange)
35
+
36
+ const keepAlive = setInterval(() => {
37
+ response.write(': ping\n\n')
38
+ }, 10_000)
39
+
40
+ req.on('close', cleanup)
41
+ req.on('error', cleanup)
42
+ function cleanup() {
43
+ clearInterval(keepAlive)
44
+ devClientWatcher.unsubscribe(onDevChange)
45
+ }
46
+ }
@@ -1,47 +1,54 @@
1
- const WATCH_API = '/packaton/watch-dev'
2
-
3
- let conn = null
4
- let timer = null
5
-
6
- window.addEventListener('beforeunload', teardown)
7
- connect()
8
- function connect() {
9
- if (conn)
10
- return
11
-
12
- clearTimeout(timer)
13
- conn = new EventSource(WATCH_API)
14
-
15
- conn.onmessage = function (event) {
16
- const file = event.data
17
- if (file.endsWith('.css'))
18
- hotReloadCSS(file)
19
- else if (file)
20
- location.reload()
1
+ const url = new URL(import.meta.url).searchParams.get('url')
2
+
3
+ if (!url)
4
+ console.warn('Missing ?url=')
5
+ else
6
+ init()
7
+
8
+ function init() {
9
+ let conn = null
10
+ let timer = null
11
+
12
+ connect()
13
+ window.addEventListener('beforeunload', teardown)
14
+
15
+ function connect() {
16
+ if (conn) return
17
+
18
+ clearTimeout(timer)
19
+ conn = new EventSource(url)
20
+
21
+ conn.onmessage = function (event) {
22
+ const file = event.data
23
+ if (file.endsWith('.css'))
24
+ hotReloadCSS(file)
25
+ else if (file)
26
+ location.reload()
27
+ }
28
+
29
+ conn.onerror = function () {
30
+ console.error('hot reload')
31
+ teardown()
32
+ timer = setTimeout(connect, 3000)
33
+ }
21
34
  }
22
35
 
23
- conn.onerror = function () {
24
- console.error('hot reload')
25
- teardown()
26
- timer = setTimeout(connect, 3000)
36
+ function teardown() {
37
+ clearTimeout(timer)
38
+ conn?.close()
39
+ conn = null
27
40
  }
28
- }
29
41
 
30
- function teardown() {
31
- clearTimeout(timer)
32
- conn?.close()
33
- conn = null
34
- }
35
42
 
36
- async function hotReloadCSS(file) {
37
- let link = document.querySelector(`link[href^="/${file}"]`)
38
- if (link) {
39
- const [url] = link.getAttribute('href').split('?')
40
- link.href = url + '?' + Date.now()
41
- }
42
- else {
43
- const mod = await import(`/${file}?${Date.now()}`, { with: { type: 'css' } })
44
- document.adoptedStyleSheets = [mod.default]
43
+ async function hotReloadCSS(file) {
44
+ let link = document.querySelector(`link[href^="/${file}"]`)
45
+ if (link) {
46
+ const [url] = link.getAttribute('href').split('?')
47
+ link.href = url + '?' + Date.now()
48
+ }
49
+ else {
50
+ const mod = await import(`/${file}?${Date.now()}`, { with: { type: 'css' } })
51
+ document.adoptedStyleSheets = [mod.default]
52
+ }
45
53
  }
46
54
  }
47
-
@@ -1,7 +1,7 @@
1
1
  import { join } from 'node:path'
2
2
  import { createHash } from 'node:crypto'
3
3
 
4
- import { read } from '../utils/fs-utils.js'
4
+ import { read } from '../utils/fs.js'
5
5
  import { remapMediaInHTML } from './media-remaper.js'
6
6
 
7
7
 
@@ -1,4 +1,4 @@
1
- import { write } from '../utils/fs-utils.js'
1
+ import { write } from '../utils/fs.js'
2
2
  import { join } from 'node:path'
3
3
 
4
4
 
@@ -1,6 +1,6 @@
1
1
  import { renameSync } from 'node:fs'
2
2
  import { join, parse, relative } from 'node:path'
3
- import { sha1, listFiles } from '../utils/fs-utils.js'
3
+ import { sha1, listFiles } from '../utils/fs.js'
4
4
 
5
5
 
6
6
  /**
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path'
2
- import { read, sizeOf, sha1, saveAsJSON, isFile } from '../utils/fs-utils.js'
2
+ import { read, sizeOf, sha1, saveAsJSON, isFile } from '../utils/fs.js'
3
3
 
4
4
 
5
5
  export function reportSizesPlugin(config, routes) {
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path'
2
- import { write, isFile } from '../utils/fs-utils.js'
2
+ import { write, isFile } from '../utils/fs.js'
3
3
 
4
4
 
5
5
  export function sitemapPlugin(config, routes) {
package/src/router.js CHANGED
@@ -1,57 +1,82 @@
1
1
  import { join } from 'node:path'
2
2
  import { readFile } from 'node:fs/promises'
3
3
  import { randomUUID } from 'node:crypto'
4
+ import { pathToFileURL } from 'node:url'
4
5
 
5
6
  import { docs } from './app.js'
6
- import { mimeFor } from './utils/mimes.js'
7
- import { devClientWatcher } from './plugins-dev/WatcherDevClient.js'
8
- import { sendError, sendJSON, servePartialContent, serveAsset } from './utils/http-response.js'
7
+ import { mimeFor } from './utils/mime.js'
8
+ import { sseDevHotReload } from './plugins-dev/WatcherDevClient.js'
9
9
 
10
+ import pkgJSON from '../package.json' with { type: 'json' }
11
+ import { resolveIn } from './utils/fs.js'
12
+ import { hasControlChars, removeQueryStringAndFragment } from './utils/HttpIncomingMessage.js'
10
13
 
11
- const devtoolsWorkspaceId = randomUUID()
12
14
 
13
- const WATCHER_DEV = '/plugins-dev/watcherDev.js'
15
+ const rel = f => join(import.meta.dirname, f)
14
16
 
15
17
  const API = {
16
- watchDev: '/packaton/watch-dev'
18
+ watchHotReload: '/packaton/watch-hot-reload',
19
+ devToolsJson: '/.well-known/appspecific/com.chrome.devtools.json'
17
20
  }
18
-
21
+ const WATCHER_DEV = `/plugins-dev/watcherDev.js`
19
22
 
20
23
  /** @param {Config} config */
21
24
  export function router({ srcPath, ignore, mode }) {
25
+ const DEV = mode === 'development'
26
+ const WORKSPACE_ID = randomUUID()
22
27
  docs.init(srcPath, ignore)
23
- const isDev = mode === 'development'
28
+
24
29
  return async function (req, response) {
25
- let url = new URL(req.url, 'http://_').pathname
30
+ response.setHeader('Server', `Packaton ${pkgJSON.version}`)
31
+
32
+ let url = req.url || ''
33
+ if (url.length > 2048) {
34
+ response.uriTooLong()
35
+ return
36
+ }
37
+ if (hasControlChars(url)) {
38
+ response.badRequest()
39
+ return
40
+ }
41
+ if (req.method !== 'GET') {
42
+ response.notFound()
43
+ return
44
+ }
45
+
46
+ url = removeQueryStringAndFragment(url)
26
47
  try {
27
- if (url === '/.well-known/appspecific/com.chrome.devtools.json')
28
- sendJSON(response, {
48
+ if (url === WATCHER_DEV)
49
+ response.file(rel(WATCHER_DEV))
50
+
51
+ else if (url === API.watchHotReload)
52
+ sseDevHotReload(req, response)
53
+
54
+ else if (url === API.devToolsJson)
55
+ response.json({
29
56
  workspace: {
30
57
  root: srcPath,
31
- uuid: devtoolsWorkspaceId
58
+ uuid: WORKSPACE_ID
32
59
  }
33
60
  })
34
61
 
35
- else if (url === API.watchDev)
36
- sseDevHotReload(req, response)
37
-
38
- else if (url === WATCHER_DEV)
39
- serveAsset(response, join(import.meta.dirname, url))
40
-
41
62
  else if (docs.hasRoute(url))
42
- await serveDocument(response, docs.fileFor(url), url, isDev)
43
-
63
+ await serveDocument(response, docs.fileFor(url), url, DEV)
44
64
  else if (docs.hasRoute(join(url, 'index')))
45
- await serveDocument(response, docs.fileFor(join(url, 'index')), '', isDev)
46
-
47
- else if (req.headers.range)
48
- await servePartialContent(response, req.headers, join(srcPath, url))
49
-
50
- else
51
- serveAsset(response, join(srcPath, url))
65
+ await serveDocument(response, docs.fileFor(join(url, 'index')), '', DEV)
66
+
67
+ else {
68
+ const f = await resolveIn(srcPath, url)
69
+ if (!f)
70
+ response.forbidden('Filename path resolves outside config.srcPath')
71
+ else if (req.headers.range)
72
+ response.partialContent(f)
73
+ else
74
+ response.file(f)
75
+ }
52
76
  }
53
- catch (error) {
54
- sendError(response, error)
77
+ catch (err) {
78
+ console.error(err)
79
+ response.internalServerError()
55
80
  }
56
81
  }
57
82
  }
@@ -59,38 +84,9 @@ export function router({ srcPath, ignore, mode }) {
59
84
  async function serveDocument(response, file, url, isDev) {
60
85
  let html = file.endsWith('.html')
61
86
  ? await readFile(file, 'utf8')
62
- : (await import(file + '?' + Date.now())).default(url)
87
+ : (await import(pathToFileURL(file))).default(url)
63
88
  if (isDev)
64
- html += `<script type="module" src="${WATCHER_DEV}"></script>`
65
- response.setHeader('Content-Type', mimeFor('html'))
89
+ html += `<script type="module" src="${WATCHER_DEV}?url=${API.watchHotReload}"></script>`
90
+ response.setHeader('Content-Type', mimeFor('.html'))
66
91
  response.end(html)
67
92
  }
68
-
69
-
70
- function sseDevHotReload(req, response) {
71
- response.writeHead(200, {
72
- 'Content-Type': 'text/event-stream',
73
- 'Cache-Control': 'no-cache',
74
- 'Connection': 'keep-alive',
75
- })
76
- response.flushHeaders()
77
-
78
- function onDevChange(file = '') {
79
- response.write(`data: ${file}\n\n`)
80
- }
81
-
82
- devClientWatcher.subscribe(onDevChange)
83
-
84
- const keepAlive = setInterval(() => {
85
- response.write(': ping\n\n')
86
- }, 10_000)
87
-
88
- req.on('close', cleanup)
89
- req.on('error', cleanup)
90
- function cleanup() {
91
- clearInterval(keepAlive)
92
- devClientWatcher.unsubscribe(onDevChange)
93
- }
94
- }
95
-
96
-
@@ -0,0 +1,23 @@
1
+ export function removeQueryStringAndFragment(url = '') {
2
+ return new URL(url, 'http://_').pathname
3
+ }
4
+
5
+
6
+ const reControlAndDelChars = /[\x00-\x1f\x7f]/
7
+
8
+ export function hasControlChars(url) {
9
+ try {
10
+ const decoded = decode(url)
11
+ return !decoded || reControlAndDelChars.test(decoded)
12
+ }
13
+ catch {
14
+ return true
15
+ }
16
+ }
17
+
18
+ function decode(url) {
19
+ const candidate = decodeURIComponent(url)
20
+ return candidate === decodeURIComponent(candidate)
21
+ ? candidate
22
+ : '' // reject multiple encodings
23
+ }
@@ -0,0 +1,92 @@
1
+ import fs from 'node:fs'
2
+ import http from 'node:http'
3
+ import { pipeline } from 'node:stream/promises'
4
+
5
+ import { mimeFor } from './mime.js'
6
+
7
+
8
+ export class ServerResponse extends http.ServerResponse {
9
+ json(payload) {
10
+ this.setHeader('Content-Type', mimeFor('.json'))
11
+ this.end(JSON.stringify(payload))
12
+ }
13
+
14
+ async file(file) {
15
+ try {
16
+ const { size } = await fs.promises.stat(file)
17
+ this.setHeader('Content-Length', size)
18
+ this.setHeader('Content-Type', mimeFor(file))
19
+ await pipeline(fs.createReadStream(file), this)
20
+ }
21
+ catch (err) {
22
+ if (this.headersSent)
23
+ this.destroy()
24
+ else if (err.code === 'ENOENT')
25
+ this.notFound()
26
+ else
27
+ throw err
28
+ }
29
+ }
30
+
31
+ async partialContent(file) {
32
+ try {
33
+ const { size } = await fs.promises.lstat(file)
34
+ let [start, end] = this.req.headers.range.replace(/bytes=/, '').split('-').map(n => parseInt(n, 10))
35
+
36
+ if (isNaN(start)) {
37
+ start = size - end
38
+ end = size - 1
39
+ }
40
+ else if (isNaN(end))
41
+ end = size - 1
42
+
43
+ if (start < 0 || end >= size || start > end) {
44
+ this.statusCode = 416 // Range Not Satisfiable
45
+ this.setHeader('Content-Range', `bytes */${size}`)
46
+ this.end()
47
+ return
48
+ }
49
+
50
+ this.statusCode = 206 // Partial Content
51
+ this.setHeader('Accept-Ranges', 'bytes')
52
+ this.setHeader('Content-Range', `bytes ${start}-${end}/${size}`)
53
+ this.setHeader('Content-Length', (end - start) + 1)
54
+ this.setHeader('Content-Type', mimeFor(file))
55
+
56
+ await pipeline(fs.createReadStream(file, { start, end }), this)
57
+ }
58
+ catch (err) {
59
+ if (this.headersSent)
60
+ this.destroy()
61
+ else if (err.code === 'ENOENT')
62
+ this.notFound()
63
+ else
64
+ throw err
65
+ }
66
+ }
67
+
68
+ badRequest() {
69
+ this.statusCode = 400
70
+ this.end()
71
+ }
72
+
73
+ forbidden(msg) {
74
+ this.statusCode = 403
75
+ this.end(msg)
76
+ }
77
+
78
+ notFound() {
79
+ this.statusCode = 404
80
+ this.end()
81
+ }
82
+
83
+ uriTooLong() {
84
+ this.statusCode = 414
85
+ this.end()
86
+ }
87
+
88
+ internalServerError() {
89
+ this.statusCode = 500
90
+ this.end()
91
+ }
92
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, test, before, after } from 'node:test'
2
+ import { mkdtempSync, writeFileSync } from 'node:fs'
3
+ import { createServer } from 'node:http'
4
+ import { tmpdir } from 'node:os'
5
+ import { equal } from 'node:assert/strict'
6
+ import { join } from 'node:path'
7
+ import { rm } from 'node:fs/promises'
8
+
9
+ import { ServerResponse } from './HttpServerResponse.js'
10
+
11
+ describe('ServerResponse', () => {
12
+ const FILE = '0123456789'
13
+
14
+ let tmpDir, tmpFile, server, addr
15
+ before(async () => {
16
+ tmpDir = mkdtempSync(join(tmpdir(), 'response-'))
17
+ tmpFile = join(tmpDir, 'test.txt')
18
+ writeFileSync(tmpFile, FILE)
19
+
20
+ server = createServer({ ServerResponse }, (req, response) => {
21
+ const file = join(tmpDir, req.url)
22
+ if (req.headers.range)
23
+ response.partialContent(file)
24
+ else
25
+ response.file(file)
26
+ })
27
+
28
+ await new Promise(resolve => server.listen(0, () => {
29
+ addr = `http://127.0.0.1:${(server.address().port)}`
30
+ resolve()
31
+ }))
32
+ })
33
+
34
+ after(async () => {
35
+ server?.close()
36
+ await rm(tmpDir, { recursive: true, force: true })
37
+ })
38
+
39
+
40
+ describe('partialContent', () => {
41
+ const GET = (path, range) => fetch(addr + path, { headers: { range } })
42
+
43
+ test('404', async () => {
44
+ const r = await GET('/not-found', 'bytes=0-')
45
+ equal(r.status, 404)
46
+ equal(r.headers.get('content-length'), '0')
47
+ })
48
+
49
+ test('416 - out of bounds', async () => {
50
+ for (const range of ['bytes=10-12', 'bytes=5-2', 'bytes=12-', 'bytes=-15']) {
51
+ const r = await GET('/test.txt', range)
52
+ equal(r.status, 416)
53
+ equal(r.headers.get('content-range'), `bytes */${FILE.length}`)
54
+ }
55
+ })
56
+
57
+ test('206 - normal range', async () => {
58
+ const r = await GET('/test.txt', 'bytes=0-4')
59
+ equal(r.status, 206)
60
+ equal(r.headers.get('content-range'), `bytes 0-4/${FILE.length}`)
61
+ equal(r.headers.get('content-length'), '5')
62
+ equal(r.headers.get('content-type'), 'text/plain')
63
+ equal(await r.text(), '01234')
64
+ })
65
+
66
+ test('206 - suffix range', async () => {
67
+ const r = await GET('/test.txt', 'bytes=-3')
68
+ equal(r.status, 206)
69
+ equal(r.headers.get('content-range'), `bytes 7-9/${FILE.length}`)
70
+ equal(r.headers.get('content-length'), '3')
71
+ equal(await r.text(), '789')
72
+ })
73
+
74
+ test('206 - open ended range', async () => {
75
+ const r = await GET('/test.txt', 'bytes=5-')
76
+ equal(r.status, 206)
77
+ equal(r.headers.get('content-range'), `bytes 5-9/${FILE.length}`)
78
+ equal(r.headers.get('content-length'), '5')
79
+ equal(await r.text(), '56789')
80
+ })
81
+ })
82
+
83
+
84
+ describe('file', () => {
85
+ const GET = path => fetch(addr + path)
86
+
87
+ test('404', async () => {
88
+ const r = await GET('/not-found')
89
+ equal(r.status, 404)
90
+ equal(r.headers.get('content-length'), '0')
91
+ })
92
+
93
+ test('200', async () => {
94
+ const r = await GET('/test.txt')
95
+ equal(r.status, 200)
96
+ equal(r.headers.get('content-type'), 'text/plain')
97
+ equal(r.headers.get('content-length'), String(FILE.length))
98
+ equal(await r.text(), FILE)
99
+ })
100
+ })
101
+ })
@@ -1,7 +1,8 @@
1
- import { readdir } from 'node:fs/promises'
2
1
  import { createHash } from 'node:crypto'
3
- import { join, dirname } from 'node:path'
4
- import { rmSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { readdir, realpath } from 'node:fs/promises'
3
+ import { join, dirname, sep, resolve } from 'node:path'
4
+ import { rmSync, mkdirSync, readFileSync, writeFileSync, lstatSync } from 'node:fs'
5
+
5
6
 
6
7
 
7
8
  export const read = f => readFileSync(f, 'utf8')
@@ -47,3 +48,17 @@ export const replaceExt = (f, ext) => {
47
48
  parts.push(ext)
48
49
  return parts.join('.')
49
50
  }
51
+
52
+ /** @returns {string | null} absolute path if it’s within `baseDir` */
53
+ export async function resolveIn(baseDir, file) {
54
+ try {
55
+ const parent = await realpath(baseDir)
56
+ const child = resolve(join(parent, file))
57
+ return child.startsWith(join(parent, sep))
58
+ ? child
59
+ : null
60
+ }
61
+ catch {
62
+ return null
63
+ }
64
+ }
@@ -0,0 +1,49 @@
1
+ import { join } from 'node:path'
2
+ import { equal } from 'node:assert/strict'
3
+ import { tmpdir } from 'node:os'
4
+ import { after, describe, test } from 'node:test'
5
+ import { mkdtempSync, rmSync, realpathSync } from 'node:fs'
6
+
7
+ import { replaceExt, resolveIn } from './fs.js'
8
+
9
+
10
+ describe('replaceExt', () => {
11
+ test('replaces a simple extension', () =>
12
+ equal(replaceExt('file.txt', 'md'), 'file.md'))
13
+
14
+ test('replaces a multi-part extension', () =>
15
+ equal(replaceExt('archive.tar.gz', 'zip'), 'archive.tar.zip'))
16
+
17
+ test('adds extension when none exists', () =>
18
+ equal(replaceExt('README', 'md'), 'README.md'))
19
+
20
+ test('handles empty filename', () =>
21
+ equal(replaceExt('', 'ext'), '.ext'))
22
+
23
+ test('handles dot-files', () =>
24
+ equal(replaceExt('.env', 'txt'), '.env.txt'))
25
+ })
26
+
27
+
28
+ describe('resolveIn', () => {
29
+ const isNull = v => equal(v, null)
30
+ const baseDir = mkdtempSync(join(tmpdir(), '_resolveIn'))
31
+ const baseParentDir = join(baseDir, '..')
32
+ after(() => rmSync(baseDir, { recursive: true, force: true }))
33
+
34
+ test('null when baseDir does not exist', async () =>
35
+ isNull(await resolveIn(join(baseParentDir, 'missing'), 'file.json')))
36
+
37
+ test('null when relative path escapes baseDir', async () =>
38
+ isNull(await resolveIn(baseDir, '../outside.json')))
39
+
40
+
41
+ const realBaseDir = realpathSync(baseDir)
42
+ const onReal = f => join(realBaseDir, f)
43
+
44
+ test('resolves a relative file within baseDir', async () =>
45
+ equal(await resolveIn(baseDir, 'file.json'), onReal('file.json')))
46
+
47
+ test('resolves file starting with /', async () =>
48
+ equal(await resolveIn(baseDir, '/file.json'), onReal('file.json')))
49
+ })
@@ -1,22 +1,22 @@
1
+ import { MIMEType } from 'node:util'
2
+
3
+
1
4
  // Generated with:
2
5
  // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
3
6
  // m = {}
4
7
  // for (const row of tbody.children)
5
8
  // m[row.children[0].querySelector('code').innerText] = row.children[2].querySelector('code').innerText
6
9
 
7
- const mimes = {
8
- css: 'text/css; charset=utf8',
9
- html: 'text/html; charset=utf8',
10
- js: 'application/javascript; charset=utf8',
11
- json: 'application/json; charset=utf8',
12
- svg: 'image/svg+xml; charset=utf8',
13
- txt: 'text/plain; charset=utf8', // e.g., robots.txt when running lighthouse
14
-
10
+ const extToMime = {
15
11
  '3g2': 'video/3gpp2',
16
12
  '3gp': 'video/3gpp',
13
+ '3mf': 'model/3mf',
17
14
  '7z': 'application/x-7z-compressed',
18
15
  aac: 'audio/aac',
19
16
  abw: 'application/x-abiword',
17
+ aif: 'audio/aiff',
18
+ aifc: 'audio/aiff',
19
+ aiff: 'audio/aiff',
20
20
  apng: 'image/apng',
21
21
  arc: 'application/x-freearc',
22
22
  avi: 'video/x-msvideo',
@@ -29,27 +29,48 @@ const mimes = {
29
29
  cda: 'application/x-cdf',
30
30
  cjs: 'text/javascript',
31
31
  csh: 'application/x-csh',
32
+ css: 'text/css',
32
33
  csv: 'text/csv',
34
+ dae: 'model/vnd.collada+xml',
33
35
  doc: 'application/msword',
34
36
  docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
37
+ drc: 'model/vnd.draco',
38
+ eml: 'message/rfc822',
35
39
  eot: 'application/vnd.ms-fontobject',
36
40
  epub: 'application/epub+zip',
41
+ exe: 'application/vnd.microsoft.portable-executable',
42
+ fbx: 'application/octet-stream',
43
+ flac: 'audio/flac',
37
44
  gif: 'image/gif',
45
+ glb: 'model/gltf-binary',
46
+ gltf: 'model/gltf+json',
38
47
  gz: 'application/gzip',
48
+ heic: 'image/heic',
49
+ heif: 'image/heif',
39
50
  htm: 'text/html',
51
+ html: 'text/html',
40
52
  ico: 'image/vnd.microsoft.icon',
41
53
  ics: 'text/calendar',
42
54
  jar: 'application/java-archive',
43
55
  jpeg: 'image/jpeg',
44
56
  jpg: 'image/jpeg',
57
+ js: 'application/javascript',
58
+ json: 'application/json',
45
59
  jsonld: 'application/ld+json',
60
+ lz: 'application/x-lzip',
61
+ m4a: 'audio/mp4',
62
+ md: 'text/markdown',
46
63
  mid: 'audio/midi',
47
64
  midi: 'audio/midi',
48
65
  mjs: 'text/javascript',
66
+ mkv: 'video/x-matroska',
67
+ mov: 'video/quicktime',
49
68
  mp3: 'audio/mpeg',
50
69
  mp4: 'video/mp4',
51
70
  mpeg: 'video/mpeg',
52
71
  mpkg: 'application/vnd.apple.installer+xml',
72
+ mtl: 'text/plain',
73
+ obj: 'text/plain',
53
74
  odp: 'application/vnd.oasis.opendocument.presentation',
54
75
  ods: 'application/vnd.oasis.opendocument.spreadsheet',
55
76
  odt: 'application/vnd.oasis.opendocument.text',
@@ -60,17 +81,24 @@ const mimes = {
60
81
  otf: 'font/otf',
61
82
  pdf: 'application/pdf',
62
83
  php: 'application/x-httpd-php',
84
+ ply: 'application/octet-stream',
63
85
  png: 'image/png',
64
86
  ppt: 'application/vnd.ms-powerpoint',
65
87
  pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
66
88
  rar: 'application/vnd.rar',
67
89
  rtf: 'application/rtf',
68
90
  sh: 'application/x-sh',
91
+ stl: 'model/stl',
92
+ svg: 'image/svg+xml',
69
93
  tar: 'application/x-tar',
70
94
  tif: 'image/tiff',
71
95
  ts: 'video/mp2t',
72
96
  ttf: 'font/ttf',
97
+ txt: 'text/plain',
98
+ usd: 'model/vnd.usd',
99
+ usdz: 'model/vnd.usdz+zip',
73
100
  vsd: 'application/vnd.visio',
101
+ wasm: 'application/wasm',
74
102
  wav: 'audio/wav',
75
103
  weba: 'audio/webm',
76
104
  webm: 'video/webm',
@@ -82,18 +110,44 @@ const mimes = {
82
110
  xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
83
111
  xml: 'application/xml',
84
112
  xul: 'application/vnd.mozilla.xul+xml',
113
+ xz: 'application/x-xz',
85
114
  yaml: 'application/yaml',
86
115
  yml: 'application/yaml',
87
- zip: 'application/zip'
116
+ zip: 'application/zip',
117
+ zst: 'application/zstd'
88
118
  }
89
119
 
90
- export function mimeFor(filename) {
91
- return mimes[extname(filename)] || ''
120
+ const mimeToExt = {
121
+ data: mapMimeToExt(extToMime)
122
+ }
123
+
124
+ export function registerMimes(obj) {
125
+ Object.assign(extToMime, obj)
126
+ mimeToExt.data = mapMimeToExt(extToMime)
127
+ }
128
+
129
+ function mapMimeToExt(e2m) {
130
+ const m = {}
131
+ for (const [ext, mime] of Object.entries(e2m))
132
+ m[mime] = ext
133
+ return m
92
134
  }
93
135
 
136
+ export function mimeFor(filename) {
137
+ const ext = extname(filename).toLowerCase()
138
+ return extToMime[ext] || ''
139
+ }
94
140
  function extname(filename) {
95
- const ext = filename
96
- .split('.').at(-1)
97
- .split('?').at(0)
98
- return ext
141
+ const i = filename.lastIndexOf('.')
142
+ return i === -1
143
+ ? ''
144
+ : filename.slice(i + 1)
99
145
  }
146
+
147
+
148
+ export function extFor(mime) {
149
+ return mime
150
+ ? mimeToExt.data[new MIMEType(mime).essence]
151
+ : ''
152
+ }
153
+
@@ -0,0 +1,21 @@
1
+ import { test } from 'node:test'
2
+ import { equal } from 'node:assert/strict'
3
+ import { extFor, mimeFor } from './mime.js'
4
+
5
+
6
+ test('extFor', () => {
7
+ [
8
+ 'text/html',
9
+ 'Text/html',
10
+ 'text/Html; charset=UTF-16'
11
+ ].map(input =>
12
+ equal(extFor(input), 'html'))
13
+ })
14
+
15
+ test('mimeFor', () => {
16
+ [
17
+ 'file.html',
18
+ 'file.HTmL'
19
+ ].map(input =>
20
+ equal(mimeFor(input), 'text/html'))
21
+ })
@@ -1,21 +0,0 @@
1
- import test, { describe } from 'node:test'
2
- import { equal } from 'node:assert/strict'
3
- import { replaceExt } from './fs-utils.js'
4
-
5
-
6
- describe('replaceExt', () => {
7
- test('replaces a simple extension', () =>
8
- equal(replaceExt('file.txt', 'md'), 'file.md'))
9
-
10
- test('replaces a multi-part extension', () =>
11
- equal(replaceExt('archive.tar.gz', 'zip'), 'archive.tar.zip'))
12
-
13
- test('adds extension when none exists', () =>
14
- equal(replaceExt('README', 'md'), 'README.md'))
15
-
16
- test('handles empty filename', () =>
17
- equal(replaceExt('', 'ext'), '.ext'))
18
-
19
- test('handles dot-files', () =>
20
- equal(replaceExt('.env', 'txt'), '.env.txt'))
21
- })
@@ -1,46 +0,0 @@
1
- import fs from 'node:fs'
2
- import { mimeFor } from './mimes.js'
3
-
4
-
5
- export function sendError(response, error) {
6
- response.statusCode = error?.code === 'ENOENT'
7
- ? 404
8
- : 500
9
- console.error(error.message)
10
- response.end()
11
- }
12
-
13
- export function sendJSON(response, payload) {
14
- response.setHeader('Content-Type', 'application/json')
15
- response.end(JSON.stringify(payload))
16
- }
17
-
18
- export function serveAsset(response, file) {
19
- response.setHeader('Content-Type', mimeFor(file))
20
- const reader = fs.createReadStream(file)
21
- reader.on('open', function () { this.pipe(response) })
22
- reader.on('error', function (error) { sendError(response, error) })
23
- }
24
-
25
-
26
- export async function servePartialContent(response, headers, file) {
27
- const { size } = await fs.promises.lstat(file)
28
- let [start, end] = headers.range.replace(/bytes=/, '').split('-').map(n => parseInt(n, 10))
29
- if (isNaN(end)) end = size - 1
30
- if (isNaN(start)) start = size - end
31
-
32
- if (start < 0 || start > end || start >= size || end >= size) {
33
- response.statusCode = 416 // Range Not Satisfiable
34
- response.setHeader('Content-Range', `bytes */${size}`)
35
- response.end()
36
- }
37
- else {
38
- response.statusCode = 206 // Partial Content
39
- response.setHeader('Accept-Ranges', 'bytes')
40
- response.setHeader('Content-Range', `bytes ${start}-${end}/${size}`)
41
- response.setHeader('Content-Type', mimeFor(file))
42
- const reader = fs.createReadStream(file, { start, end })
43
- reader.on('open', function () { this.pipe(response) })
44
- reader.on('error', function (error) { sendError(response, error) })
45
- }
46
- }