packaton 0.0.26 → 0.0.28

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.28",
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,23 +1,32 @@
1
- import { execFileSync } from 'node:child_process'
1
+ import { spawnSync } from 'node:child_process'
2
2
 
3
3
 
4
4
  export const openInBrowser = (async () => {
5
5
  try {
6
6
  return (await import('open')).default
7
7
  }
8
- catch (error) {
8
+ catch {
9
9
  return _openInBrowser
10
10
  }
11
11
  })()
12
12
 
13
13
  function _openInBrowser(address) {
14
+ let opener
14
15
  switch (process.platform) {
15
16
  case 'darwin':
16
- execFileSync('open', [address])
17
+ opener = 'open'
17
18
  break
18
19
  case 'win32':
19
- execFileSync('start', [address])
20
+ opener = 'start'
20
21
  break
22
+ default:
23
+ opener = ['xdg-open', 'gnome-open', 'kde-open'].find(hasCommand)
21
24
  }
25
+ if (opener)
26
+ spawnSync(opener, [address])
22
27
  }
23
28
 
29
+ function hasCommand(cmd) {
30
+ const { status } = spawnSync('command', ['-v', cmd], { stdio: 'ignore' })
31
+ return status === 0
32
+ }
@@ -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,96 +1,91 @@
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
- export function router({ srcPath, ignore, mode }) {
24
+ export function router({ srcPath, ignore, hotReload }) {
25
+ const WORKSPACE_ID = randomUUID()
22
26
  docs.init(srcPath, ignore)
23
- const isDev = mode === 'development'
27
+
24
28
  return async function (req, response) {
25
- let url = new URL(req.url, 'http://_').pathname
29
+ response.setHeader('Server', `Packaton ${pkgJSON.version}`)
30
+
31
+ let url = req.url || ''
32
+ if (url.length > 2048) {
33
+ response.uriTooLong()
34
+ return
35
+ }
36
+ if (hasControlChars(url)) {
37
+ response.badRequest()
38
+ return
39
+ }
40
+ if (req.method !== 'GET') {
41
+ response.notFound()
42
+ return
43
+ }
44
+
45
+ url = removeQueryStringAndFragment(url)
26
46
  try {
27
- if (url === '/.well-known/appspecific/com.chrome.devtools.json')
28
- sendJSON(response, {
47
+ if (url === WATCHER_DEV)
48
+ response.file(rel(WATCHER_DEV))
49
+
50
+ else if (url === API.watchHotReload)
51
+ sseDevHotReload(req, response)
52
+
53
+ else if (url === API.devToolsJson)
54
+ response.json({
29
55
  workspace: {
30
56
  root: srcPath,
31
- uuid: devtoolsWorkspaceId
57
+ uuid: WORKSPACE_ID
32
58
  }
33
59
  })
34
60
 
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
61
  else if (docs.hasRoute(url))
42
- await serveDocument(response, docs.fileFor(url), url, isDev)
43
-
62
+ await serveDocument(response, docs.fileFor(url), url, hotReload)
44
63
  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))
64
+ await serveDocument(response, docs.fileFor(join(url, 'index')), '', hotReload)
65
+
66
+ else {
67
+ const f = await resolveIn(srcPath, url)
68
+ if (!f)
69
+ response.forbidden('Filename path resolves outside config.srcPath')
70
+ else if (req.headers.range)
71
+ await response.partialContent(f)
72
+ else
73
+ await response.file(f)
74
+ }
52
75
  }
53
- catch (error) {
54
- sendError(response, error)
76
+ catch (err) {
77
+ console.error(err)
78
+ response.internalServerError()
55
79
  }
56
80
  }
57
81
  }
58
82
 
59
- async function serveDocument(response, file, url, isDev) {
83
+ async function serveDocument(response, file, url, hotReload) {
60
84
  let html = file.endsWith('.html')
61
85
  ? await readFile(file, 'utf8')
62
- : (await import(file + '?' + Date.now())).default(url)
63
- if (isDev)
64
- html += `<script type="module" src="${WATCHER_DEV}"></script>`
65
- response.setHeader('Content-Type', mimeFor('html'))
86
+ : (await import(pathToFileURL(file))).default(url)
87
+ if (hotReload)
88
+ html += `<script type="module" src="${WATCHER_DEV}?url=${API.watchHotReload}"></script>`
89
+ response.setHeader('Content-Type', mimeFor('.html'))
66
90
  response.end(html)
67
91
  }
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,96 @@
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
+ || err.code === 'ERR_STREAM_PREMATURE_CLOSE'
24
+ || err.code === 'ERR_STREAM_UNABLE_TO_PIPE')
25
+ this.destroy()
26
+ else if (err.code === 'ENOENT')
27
+ this.notFound()
28
+ else
29
+ throw err
30
+ }
31
+ }
32
+
33
+ async partialContent(file) {
34
+ try {
35
+ const { size } = await fs.promises.lstat(file)
36
+ let [start, end] = this.req.headers.range.replace(/bytes=/, '').split('-').map(n => parseInt(n, 10))
37
+
38
+ if (isNaN(start)) {
39
+ start = size - end
40
+ end = size - 1
41
+ }
42
+ else if (isNaN(end))
43
+ end = size - 1
44
+
45
+ if (start < 0 || end >= size || start > end) {
46
+ this.statusCode = 416 // Range Not Satisfiable
47
+ this.setHeader('Content-Range', `bytes */${size}`)
48
+ this.end()
49
+ return
50
+ }
51
+
52
+ this.statusCode = 206 // Partial Content
53
+ this.setHeader('Accept-Ranges', 'bytes')
54
+ this.setHeader('Content-Range', `bytes ${start}-${end}/${size}`)
55
+ this.setHeader('Content-Length', (end - start) + 1)
56
+ this.setHeader('Content-Type', mimeFor(file))
57
+
58
+ await pipeline(fs.createReadStream(file, { start, end }), this)
59
+ }
60
+ catch (err) {
61
+ if (this.headersSent
62
+ || err.code === 'ERR_STREAM_PREMATURE_CLOSE'
63
+ || err.code === 'ERR_STREAM_UNABLE_TO_PIPE')
64
+ this.destroy()
65
+ else if (err.code === 'ENOENT')
66
+ this.notFound()
67
+ else
68
+ throw err
69
+ }
70
+ }
71
+
72
+ badRequest() {
73
+ this.statusCode = 400
74
+ this.end()
75
+ }
76
+
77
+ forbidden(msg) {
78
+ this.statusCode = 403
79
+ this.end(msg)
80
+ }
81
+
82
+ notFound() {
83
+ this.statusCode = 404
84
+ this.end()
85
+ }
86
+
87
+ uriTooLong() {
88
+ this.statusCode = 414
89
+ this.end()
90
+ }
91
+
92
+ internalServerError() {
93
+ this.statusCode = 500
94
+ this.end()
95
+ }
96
+ }
@@ -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
- }