packaton 0.0.25 → 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/index.d.ts +1 -0
- package/package.json +2 -2
- package/src/app-dev.js +2 -1
- package/src/app-prod.js +15 -3
- package/src/app.js +1 -1
- package/src/config.js +2 -1
- package/src/plugins-dev/WatcherDevClient.js +26 -0
- package/src/plugins-dev/watcherDev.js +47 -40
- package/src/plugins-prod/HtmlCompiler.js +1 -1
- package/src/plugins-prod/cspNginxMapPlugin.js +1 -1
- package/src/plugins-prod/media-remaper.js +1 -1
- package/src/plugins-prod/netiflyAndCloudflareHeadersPlugin.js +8 -26
- package/src/plugins-prod/reportSizesPlugin.js +1 -1
- package/src/plugins-prod/sitemapPlugin.js +1 -1
- package/src/router.js +57 -61
- package/src/utils/HttpIncomingMessage.js +23 -0
- package/src/utils/HttpServerResponse.js +92 -0
- package/src/utils/HttpServerResponse.test.js +101 -0
- package/src/utils/{fs-utils.js → fs.js} +18 -3
- package/src/utils/fs.test.js +49 -0
- package/src/utils/{mimes.js → mime.js} +69 -15
- package/src/utils/mime.test.js +21 -0
- package/src/utils/fs-utils.test.js +0 -21
- package/src/utils/http-response.js +0 -46
package/index.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "packaton",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
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
|
|
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)
|
|
@@ -55,6 +56,11 @@ export async function buildStaticPages(config) {
|
|
|
55
56
|
const pages = await crawlRoutes(server.address(), docs.routes)
|
|
56
57
|
const mediaHashes = await renameMediaWithHashes(pDist, MEDIA_REL_URL)
|
|
57
58
|
|
|
59
|
+
const headers = {
|
|
60
|
+
['/' + MEDIA_REL_URL + '/*']: [
|
|
61
|
+
['Cache-Control', 'public,max-age=31536000,immutable']
|
|
62
|
+
]
|
|
63
|
+
}
|
|
58
64
|
const cspByRoute = []
|
|
59
65
|
for (const [route, rawHtml] of pages) {
|
|
60
66
|
const doc = new HtmlCompiler(rawHtml, pSource, {
|
|
@@ -71,13 +77,19 @@ export async function buildStaticPages(config) {
|
|
|
71
77
|
await doc.inlineMinifiedCSS()
|
|
72
78
|
await doc.inlineMinifiedJS()
|
|
73
79
|
write(join(pDist, route + config.outputExtension), doc.html)
|
|
80
|
+
|
|
81
|
+
const r = route === '/index' ? '/' : route
|
|
82
|
+
headers[r] ??= []
|
|
83
|
+
for (const h of config.routeHeaders)
|
|
84
|
+
headers[r].push(h)
|
|
85
|
+
headers[r].push(['Content-Security-Policy', doc.csp()])
|
|
74
86
|
cspByRoute.push([route, doc.csp()])
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
sitemapPlugin(config, docs.routes)
|
|
78
90
|
reportSizesPlugin(config, docs.routes)
|
|
79
91
|
cspNginxMapPlugin(config, cspByRoute)
|
|
80
|
-
|
|
92
|
+
write(join(config.outputDir, '_headers'), netiflyAndCloudflareHeadersPlugin(headers))
|
|
81
93
|
}
|
|
82
94
|
catch (error) {
|
|
83
95
|
reject(error)
|
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
|
|
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
|
|
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'
|
|
@@ -35,6 +35,7 @@ const schema = {
|
|
|
35
35
|
minifyHTML: [minifyHTML, optional(Function)],
|
|
36
36
|
sitemapDomain: ['', optional(String)],
|
|
37
37
|
cspMapEnabled: [true, optional(Boolean)],
|
|
38
|
+
routeHeaders: [[], Array.isArray]
|
|
38
39
|
}
|
|
39
40
|
// TODO watch New Routes?
|
|
40
41
|
|
|
@@ -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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
function
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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,28 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
*/
|
|
10
|
-
export function netiflyAndCloudflareHeadersPlugin(config, cspByRoute, relMediaURL) {
|
|
11
|
-
const out = join(join(config.outputDir), '_headers')
|
|
12
|
-
|
|
13
|
-
const cspHeaders = cspByRoute.map(([route, csp]) => {
|
|
14
|
-
const r = route === '/index'
|
|
15
|
-
? '/'
|
|
16
|
-
: route
|
|
17
|
-
return [
|
|
18
|
-
r,
|
|
19
|
-
` Content-Security-Policy: ${csp}`,
|
|
20
|
-
` Cache-Control: public,max-age=60`
|
|
21
|
-
].join('\n')
|
|
22
|
-
})
|
|
23
|
-
cspHeaders.push(`/${relMediaURL}/*`)
|
|
24
|
-
cspHeaders.push(' Cache-Control: public,max-age=31536000,immutable')
|
|
25
|
-
|
|
26
|
-
write(out, cspHeaders.join('\n'))
|
|
1
|
+
export function netiflyAndCloudflareHeadersPlugin(opts) {
|
|
2
|
+
let result = []
|
|
3
|
+
for (const [route, headers] of Object.entries(opts)) {
|
|
4
|
+
result.push(route)
|
|
5
|
+
for (const [h, v] of headers)
|
|
6
|
+
result.push(` ${h}: ${v}`)
|
|
7
|
+
}
|
|
8
|
+
return result.join('\n')
|
|
27
9
|
}
|
|
28
10
|
|
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/
|
|
7
|
-
import {
|
|
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
|
|
15
|
+
const rel = f => join(import.meta.dirname, f)
|
|
14
16
|
|
|
15
17
|
const API = {
|
|
16
|
-
|
|
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
|
-
|
|
28
|
+
|
|
24
29
|
return async function (req, response) {
|
|
25
|
-
|
|
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 ===
|
|
28
|
-
|
|
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:
|
|
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,
|
|
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')), '',
|
|
46
|
-
|
|
47
|
-
else
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 (
|
|
54
|
-
|
|
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
|
|
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 {
|
|
4
|
-
import {
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
}
|