veloze
Version:
A modern and fast express-like webserver for the web
290 lines (261 loc) • 7.37 kB
JavaScript
import * as fs from 'node:fs'
import * as fsp from 'node:fs/promises'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'
import { HttpError } from '../HttpError.js'
import { redirect } from '../response/index.js'
import { logger } from '../utils/logger.js'
import {
bytes,
mimeTypes as mimeTypesDef,
rangeParser,
compressStream
} from '../utils/index.js'
import {
CONTENT_LENGTH,
CONTENT_RANGE,
CONTENT_TYPE,
REQ_METHOD_HEAD
} from '../constants.js'
/** @typedef {import('../types.js').Request} Request */
/** @typedef {import('../types.js').Response} Response */
/** @typedef {import('../utils/compressStream.js').CompressOptions} CompressOptions */
const log = logger(':serve')
const ALLOWED_METHODS = ['GET', 'HEAD']
const ALLOW = ALLOWED_METHODS.join(', ')
const RE_TRAVERSE = /(?:^|[\\/])\.\.(?:[\\/]|$)/
/**
* @typedef {object} ServeOptions
* @property {boolean} [etag=true] generates weak ETag
* @property {boolean} [fallthrough] continue processing if document could not be found
* @property {string} [index='index.html'] index document being served in case that directory was found
* @property {string} [strip] strip
* @property {boolean} [compress=true] compresses all text files with file-size greater than compressThreshold
* @property {number|string} [threshold=1024] compress threshold in bytes
* @property {(req: Request, res: Response) => boolean} [filter] filter to decide if response shall be compressible. If `true` then response is potentially compressible
* @property {CompressOptions} [compressOptions] zlib.Options
* @property {Record<string,string>} [mimeTypes] Dictionary of MIME-types by file extension e.g. `{'.txt':'text/plain'}`
*/
/**
* @param {string|URL} root directory
* @param {ServeOptions} [options]
* @returns
*/
export function serve(root, options) {
const {
etag = true,
fallthrough = false,
index = 'index.html',
strip,
compress = true,
compressOptions,
filter,
mimeTypes = mimeTypesDef
} = options || {}
const threshold = bytes(options?.threshold) || 1024
const _root = toPathname(root)
if (!_root) {
throw new TypeError('need root path')
}
log.debug('root path is %s', _root)
if (typeof index !== 'string') {
throw new TypeError('index must be a string')
}
return async function serveMw(req, res, next) {
let err
try {
const { method, url, originalUrl } = req
if (!ALLOWED_METHODS.includes(method)) {
res.setHeader('allow', ALLOW)
throw new HttpError(405)
}
let [pathnameFromUrl] = (originalUrl || url).split('?', 1)
if (strip && pathnameFromUrl.startsWith(strip)) {
pathnameFromUrl = pathnameFromUrl.slice(strip.length)
}
const pathname = path.resolve('/', pathnameFromUrl)
/* c8 ignore next 3 */
if (RE_TRAVERSE.test(pathname)) {
throw new HttpError(403)
}
const filename = path.join(_root, pathname)
const basename = path.basename(filename)
// do not deliver hidden files
if (basename[0] === '.') {
throw new HttpError(404)
}
const stats = await fsp.stat(filename)
log.debug(
'url=%s filename=%s isFile=%s',
pathnameFromUrl,
filename,
stats?.isFile()
)
if (stats.isDirectory() && !/[\\/]$/.test(pathnameFromUrl)) {
redirect(res, pathname + '/')
return
}
const [start, end] = rangeParser(req.headers.range, stats.size)
if (start === -1) {
res.setHeader(CONTENT_RANGE, `bytes */${stats.size}`)
throw new HttpError(416)
}
if (stats.isFile()) {
streamFile({
filename,
req,
res,
etag,
stats,
start,
end,
compress,
threshold,
compressOptions,
filter,
mimeTypes
})
return
}
{
const _filename = path.join(filename, index)
const stats = await fsp.stat(_filename).catch(() => {})
log.debug(
'url=%s filename=%s isFile=%s',
pathnameFromUrl,
filename,
stats?.isFile()
)
if (stats?.isFile()) {
streamFile({
filename: _filename,
req,
res,
etag,
stats,
start,
end,
compress,
threshold,
compressOptions,
filter,
mimeTypes
})
return
}
}
} catch (/** @type {Error|any} */ e) {
err = e
if (['ENAMETOOLONG', 'ENOENT', 'ENOTDIR'].includes(e.code)) {
err = new HttpError(404)
}
log.error(err)
}
next(fallthrough ? null : err)
}
}
/**
* @param {{
* filename: string
* req: object
* res: object
* etag?: boolean
* stats: fs.Stats
* start?: number
* end?: number
* compress?: boolean
* threshold: number
* compressOptions?: CompressOptions
* filter?: (req: Request, res: Response) => boolean
* mimeTypes: Record<string,string>
* }} param0
*/
const streamFile = (param0) => {
const {
filename,
req,
res,
etag,
stats,
start = 0,
end,
compress,
threshold,
compressOptions,
filter,
mimeTypes
} = param0
const eTag = setEtag(res, stats)
if (etag && req.headers['if-none-match'] === eTag) {
res.statusCode = 304
res.removeHeader('content-encoding')
res.removeHeader('content-language')
res.removeHeader(CONTENT_LENGTH)
res.removeHeader(CONTENT_RANGE)
res.removeHeader(CONTENT_TYPE)
res.removeHeader('transfer-encoding')
res.end()
return
}
if (end) {
res.setHeader(CONTENT_RANGE, `bytes ${start}-${end}/${stats.size}`)
res.statusCode = 206
}
res.setHeader(CONTENT_LENGTH, end ? end - start : stats.size)
if (res[REQ_METHOD_HEAD] || req.method === 'HEAD') {
res.end()
return
}
setMimeType(res, filename, mimeTypes)
const stream = fs.createReadStream(filename, { start, end })
stream.on('error', (err) => {
/* c8 ignore next 3 */
log.error(err)
stream.destroy()
})
const compressibleStream = compress
? compressStream(req, res, { compressOptions, threshold, filter })
: undefined
if (compressibleStream) {
stream.pipe(compressibleStream).pipe(res)
} else {
stream.pipe(res)
}
res.writablePipe = true
}
/**
* @param {object} res The response object.
* @param {fs.Stats} stats file stats
*/
const setEtag = (res, stats) => {
const { mtime, size } = stats
const eTag = `W/"${mtime.getTime()}-${size}"`
res.setHeader('etag', eTag)
return eTag
}
/**
* @param {object} res The response object.
* @param {string} filename The name of the file.
* @returns {string} mimeType
*/
const setMimeType = (res, filename, mimeTypes) => {
const fileExtension = path.extname(filename)
const mimeType = mimeTypes[fileExtension] || 'application/octet-stream'
res.setHeader(CONTENT_TYPE, mimeType)
return mimeType
}
/**
* @param {string|URL} pathOrUrl
* @returns {string|undefined}
*/
const toPathname = (pathOrUrl) => {
if (!pathOrUrl) {
return
}
if (typeof pathOrUrl === 'string') {
return pathOrUrl
}
if (pathOrUrl instanceof URL) {
return fileURLToPath(pathOrUrl)
}
}