// Adapted from https://raw.githubusercontent.com/vercel/serve-handler/main/src/index.js // Native import path from 'path'; import { createHash } from 'crypto'; import { createReadStream, ReadStream, PathLike, EncodingOption, BufferEncodingOption, StatOptions, Stats, BigIntStats, ObjectEncodingOptions, Dirent, promises } from 'fs'; import { lstat, realpath, readlink, readdir } from 'fs/promises'; // Packages import uri from 'fast-uri'; import slasher from './glob-slash.mjs'; import minimatch from 'minimatch'; import pathToRegexp from 'path-to-regexp'; import mime from 'mime-types'; import bytes from 'bytes'; import contentDisposition from 'content-disposition'; import isPathInside from 'path-is-inside'; import parseRange from 'range-parser'; import { ServerResponse, OutgoingHttpHeader, OutgoingHttpHeaders } from 'http'; import { Request } from 'express'; import ejs from 'ejs'; import { config } from '../config.mjs'; import helpers from '../helpers/functions.mjs'; import { Breadcrumb } from '../helpers/breadcrumbs.mjs'; export interface Dictionary { [Key: string]: T; } export interface StreamOptions { flags?: string | undefined; encoding?: BufferEncoding | undefined; fd?: number | promises.FileHandle | undefined; mode?: number | undefined; autoClose?: boolean | undefined; emitClose?: boolean | undefined; start?: number | undefined; signal?: AbortSignal | null | undefined; highWaterMark?: number | undefined; } export interface FSImplementation { open?: (...args: any[]) => any; close?: (...args: any[]) => any; } export interface CreateReadStreamFSImplementation extends FSImplementation { read: (...args: any[]) => any; } export interface ReadStreamOptions extends StreamOptions { fs?: CreateReadStreamFSImplementation | null | undefined; end?: number | undefined; } export type RegExpKey = pathToRegexp.Key; export interface ServeRewrite { source: string; destination: string; } export interface ServeRedirect extends ServeRewrite { type?: number; } export interface ServeHeader { key: string; value: string; } export interface ServeHeaderOverride { source: string; headers: ServeHeader[]; } export interface ServeHandlerOptions { /** * By default, the current working directory will be served. If you only want to serve a specific path, * you can use this options to pass an absolute path or a custom directory to be served relative to the * current working directory. * * For example, if serving a Jekyll app, it would look like this: * { * "public": "_site" * } * * Using absolute path: * * { * "public": "/path/to/your/_site" * } * * NOTE: The path cannot contain globs or regular expressions. */ public?: string; /** * By default, all .html files can be accessed without their extension. * * If one of these extensions is used at the end of a filename, it will automatically perform a redirect * with status code 301 to the same path, but with the extension dropped. * * You can disable the feature like follows: * { * "cleanUrls": false * } * * However, you can also restrict it to certain paths: * { * "cleanUrls": [ * "/app/**", * "/!components/**" * ] * } * * NOTE: The paths can only contain globs that are matched using minimatch. */ cleanUrls?: boolean|string[]; /** * If you want your visitors to receive a response under a certain path, but actually serve a completely * different one behind the curtains, this option is what you need. * * It's perfect for single page applications (SPAs), for example: * { * "rewrites": [ * { "source": "app/**", "destination": "/index.html" }, * { "source": "projects/edit", "destination": "/edit-project.html" } * ] * } * * You can also use so-called "routing segments" as follows: * { * "rewrites": [ * { "source": "/projects/:id/edit", "destination": "/edit-project-:id.html" }, * ] * } * * Now, if a visitor accesses /projects/123/edit, it will respond with the file /edit-project-123.html. * * NOTE: The paths can contain globs (matched using minimatch) or regular expressions (match using path-to-regexp). */ rewrites?: ServeRewrite[]; redirects?: ServeRedirect[]; headers?: ServeHeaderOverride[]; directoryListing?: boolean|string[]; unlisted?: string[]; trailingSlash?: boolean; renderSingle?: boolean; symlinks?: boolean; etag?: boolean; } export interface Path { name: string; url: string; } export interface ServeDirectoryTemplateParameters { files: PathDetails[]; directory: string; paths: Path[]; }; export interface ServeErrorTemplateParameters { statusCode: number; code: string; message: string; err?: any; } export const directoryTemplate = (vals: ServeDirectoryTemplateParameters) => { let breadcrumbs: Breadcrumb[] = []; if (vals.paths.length == 1 && helpers.getDirectoryName(vals.paths[0].name)) { breadcrumbs.push({ title: config.siteWelcomeMessage, url: '/' }); } else { vals.paths.forEach((path, index, paths) => { if (index == 0) { breadcrumbs.push({ title: config.siteName, url: '/' }); } else { breadcrumbs.push({ title: helpers.getDirectoryName(path.name).replaceAll('_', ' '), url: `/${path.url}` }); } }); } return new Promise((resolve, reject) => { ejs.renderFile(path.join(config.viewsPath, 'directory.ejs'), { breadcrumbs, h: helpers, ...vals }, (err, str) => { if (err) { reject(err); } else { resolve(str); } }); }); }; export const errorTemplate = (vals: ServeErrorTemplateParameters) => { return new Promise((resolve, reject) => { ejs.renderFile(path.join(config.viewsPath, 'error.ejs'), { h: helpers, ...vals }, (err, str) => { if (err) { reject(err); } else { resolve(str); } }); }); }; export const isDirectoryOrDirectorySymbolicLink: (path: string, max_recursion_depth: number) => Promise = async (path: string, max_recursion_depth = 10) => { try { let sym_stats = await lstat(path); if (sym_stats.isSymbolicLink() && max_recursion_depth > 0) { path = await readlink(path); return isDirectoryOrDirectorySymbolicLink(path, max_recursion_depth-1); } return sym_stats.isDirectory(); } catch (err) { throw err; } }; export const etags = new Map(); export const calculateSha = (handlers: ServeIOHandlers, absolutePath: string) => new Promise((resolve, reject) => { const hash = createHash('sha1'); hash.update(path.extname(absolutePath)); hash.update('-'); const rs = handlers.createReadStream(absolutePath); rs.on('error', reject); rs.on('data', buf => hash.update(buf)); rs.on('end', () => { const sha = hash.digest('hex'); resolve(sha); }); }); export const sourceMatches = (source: string, requestPath: string, allowSegments: boolean = false) => { const keys: RegExpKey[] = []; const slashed = slasher(source); const resolvedPath = path.posix.resolve(requestPath); let results = null; if (allowSegments) { const normalized = slashed.replace('*', '(.*)'); const expression = pathToRegexp(normalized, keys); results = expression.exec(resolvedPath); if (!results) { // clear keys so that they are not used // later with empty results. this may // happen if minimatch returns true keys.length = 0; } } if (results || minimatch(resolvedPath, slashed)) { return { keys, results }; } return null; }; const toTarget = (source: string, destination: string, previousPath: string) => { const matches = sourceMatches(source, previousPath, true); if (!matches) { return null; } const {keys, results} = matches; const props: Dictionary = {}; const {scheme} = uri.parse(destination); const normalizedDest = scheme ? destination : slasher(destination); const toPath = pathToRegexp.compile(normalizedDest); for (let index = 0; index < keys.length; index++) { const {name} = keys[index]; props[name] = results && results.length ? results[index + 1] : undefined; } return toPath(props); }; export const applyRewrites: (requestPath: string, rewrites?: ServeRewrite[], repetitive?: boolean) => string|null = (requestPath, rewrites = [], repetitive = false) => { // We need to copy the array, since we're going to modify it. const rewritesCopy = rewrites.slice(); // If the method was called again, the path was already rewritten // so we need to make sure to return it. const fallback = repetitive ? requestPath : null; if (rewritesCopy.length === 0) { return fallback; } for (let index = 0; index < rewritesCopy.length; index++) { const {source, destination} = rewrites[index]; const target = toTarget(source, destination, requestPath); if (target) { // Remove rules that were already applied rewritesCopy.splice(index, 1); // Check if there are remaining ones to be applied return applyRewrites(slasher(target), rewritesCopy, true); } } return fallback; }; export const ensureSlashStart = (target: string) => (target.startsWith('/') ? target : `/${target}`); export const shouldRedirect = (decodedPath: string, { redirects, trailingSlash }: ServeHandlerOptions, cleanUrl: boolean) => { const slashing = typeof trailingSlash === 'boolean'; const defaultType = 301; const matchHTML = /(\.html|\/index)$/g; if (redirects && redirects.length === 0 && !slashing && !cleanUrl) { return null; } // By stripping the HTML parts from the decoded // path *before* handling the trailing slash, we make // sure that only *one* redirect occurs if both // config options are used. if (cleanUrl && matchHTML.test(decodedPath)) { decodedPath = decodedPath.replace(matchHTML, ''); if (decodedPath.indexOf('//') > -1) { decodedPath = decodedPath.replace(/\/+/g, '/'); } return { target: ensureSlashStart(decodedPath), statusCode: defaultType }; } if (slashing) { const {ext, name} = path.parse(decodedPath); const isTrailed = decodedPath.endsWith('/'); const isDotfile = name.startsWith('.'); let target = null; if (!trailingSlash && isTrailed) { target = decodedPath.slice(0, -1); } else if (trailingSlash && !isTrailed && !ext && !isDotfile) { target = `${decodedPath}/`; } if (decodedPath.indexOf('//') > -1) { target = decodedPath.replace(/\/+/g, '/'); } if (target) { return { target: ensureSlashStart(target), statusCode: defaultType }; } } // This is currently the fastest way to // iterate over an array if (redirects) { for (let index = 0; index < redirects.length; index++) { const {source, destination, type} = redirects[index]; const target = toTarget(source, destination, decodedPath); if (target) { return { target, statusCode: type || defaultType }; } } } return null; }; export interface SourceHeader { key: string; value: string; } export const appendHeaders = (target: OutgoingHttpHeaders, source: SourceHeader[]) => { for (let index = 0; index < source.length; index++) { const {key, value} = source[index]; target[key] = value; } }; export const getHeaders = async (handlers: ServeIOHandlers, config: ServeHandlerOptions, current: string, absolutePath: string, stats: Stats|BigIntStats|null) => { const {headers: customHeaders = [], etag = false} = config; const related = {}; const {base} = path.parse(absolutePath); const relativePath = path.relative(current, absolutePath); if (customHeaders.length > 0) { // By iterating over all headers and never stopping, developers // can specify multiple header sources in the config that // might match a single path. for (let index = 0; index < customHeaders.length; index++) { const {source, headers} = customHeaders[index]; if (sourceMatches(source, slasher(relativePath))) { appendHeaders(related, headers); } } } let defaultHeaders: Dictionary = {}; if (stats) { defaultHeaders = { 'Content-Length': new String(stats.size) as string, // Default to "inline", which always tries to render in the browser, // if that's not working, it will save the file. But to be clear: This // only happens if it cannot find a appropiate value. 'Content-Disposition': contentDisposition(base, { type: 'inline' }), 'Accept-Ranges': 'bytes' }; if (etag) { let [mtime, sha] = etags.get(absolutePath) || []; if (Number(mtime) !== Number(stats.mtime)) { sha = await calculateSha(handlers, absolutePath); etags.set(absolutePath, [stats.mtime, sha]); } defaultHeaders['ETag'] = `"${sha}"`; } else { defaultHeaders['Last-Modified'] = stats.mtime.toUTCString(); } const contentType = mime.contentType(base); if (contentType) { defaultHeaders['Content-Type'] = contentType; } } const headers: OutgoingHttpHeaders = Object.assign(defaultHeaders, related); for (const key in headers) { if (headers.hasOwnProperty(key) && headers[key] === null) { delete headers[key]; } } return headers; }; export const applicable = (decodedPath: string, configEntry?: boolean|string[]) => { if (typeof configEntry === 'boolean') { return configEntry; } if (Array.isArray(configEntry)) { for (let index = 0; index < configEntry.length; index++) { const source = configEntry[index]; if (sourceMatches(source, decodedPath)) { return true; } } return false; } return true; }; export const getPossiblePaths = (relativePath: string, extension: string) => [ path.join(relativePath, `index${extension}`), relativePath.endsWith('/') ? relativePath.replace(/\/$/g, extension) : (relativePath + extension) ].filter(item => path.basename(item) !== extension); export const findRelated = async (current: string, relativePath: string, rewrittenPath: string|null, originalStat: lstat_signature) => { const possible = rewrittenPath ? [rewrittenPath] : getPossiblePaths(relativePath, '.html'); let stats = null; for (let index = 0; index < possible.length; index++) { const related = possible[index]; const absolutePath = path.join(current, related); try { stats = await originalStat(absolutePath); } catch (err) { if (err && (err as {code: string}).code) { if ((err as {code: string}).code !== 'ENOENT' && (err as {code: string}).code !== 'ENOTDIR') { throw err; } } } if (stats) { return { stats, absolutePath }; } } return null; }; export const canBeListed = (excluded: string[], file: string) => { const slashed = slasher(file); let whether = true; for (let mark = 0; mark < excluded.length; mark++) { const source = excluded[mark]; if (sourceMatches(source, slashed)) { whether = false; break; } } return whether; }; export interface PathDetails extends path.ParsedPath { relative?: string; type?: string; size?: string; title?: string; } export interface RenderDirectoryPathConfig { relativePath: string; absolutePath: string; } export const renderDirectory = async (current: string, acceptsJSON: boolean|null, handlers: ServeIOHandlers, methods: ServeIOHandlers|Dictionary, config: ServeHandlerOptions, paths: RenderDirectoryPathConfig) => { const {directoryListing, trailingSlash, unlisted = [], renderSingle} = config; const slashSuffix = typeof trailingSlash === 'boolean' ? (trailingSlash ? '/' : '') : '/'; const {relativePath, absolutePath} = paths; const excluded = [ '.DS_Store', '.git', ...unlisted ]; if (!applicable(relativePath, directoryListing) && !renderSingle) { return {}; } const filesAsStrings: string[]|Buffer[]|Dirent[] = await handlers.readdir(absolutePath); let filesAsPathDetails: PathDetails[] = [] const canRenderSingle = renderSingle && (filesAsStrings.length === 1); for (let index = 0; index < filesAsStrings.length; index++) { const file = filesAsStrings[index] as string; const filePath = path.resolve(absolutePath, file); const details: PathDetails = path.parse(filePath); // It's important to indicate that the `stat` call was // spawned by the directory listing, as Now is // simulating those calls and needs to special-case this. let stats = null; if (methods.lstat !== undefined) { stats = await handlers.lstat(filePath, { bigint: true }); } else { stats = await handlers.lstat(filePath, { bigint: false }); } details.relative = path.join(relativePath, details.base); //if (stats.isDirectory()) { if (await handlers.isDirectoryOrDirectorySymbolicLink(filePath, 10)) { details.base += slashSuffix; details.relative += slashSuffix; details.type = 'directory'; } else { if (canRenderSingle) { return { singleFile: true, absolutePath: filePath, stats }; } details.ext = details.ext.split('.')[1] || 'txt'; details.type = 'file'; //TODO: this might not work with stats.size as bigint details.size = bytes(stats.size as number, { unitSeparator: ' ', decimalPlaces: 0 }); } details.title = details.base; if (canBeListed(excluded, file)) { filesAsPathDetails[index] = details; } else { delete filesAsStrings[index]; } } const toRoot = path.relative(current, absolutePath); const directory = path.join(path.basename(current), toRoot, slashSuffix); const pathParts = directory.split(path.sep).filter(Boolean); // Sort to list directories first, then sort alphabetically filesAsPathDetails = filesAsPathDetails.sort((a, b) => { const aIsDir = a.type === 'directory'; const bIsDir = b.type === 'directory'; /* istanbul ignore next */ if (aIsDir && !bIsDir) { return -1; } if ((bIsDir && !aIsDir) || (a.base > b.base)) { return 1; } /* istanbul ignore next */ if (a.base < b.base) { return -1; } /* istanbul ignore next */ return 0; }).filter(Boolean); // Add parent directory to the head of the sorted files array if (toRoot.length > 0) { const directoryPath = [...pathParts].slice(1); const relative = path.join('/', ...directoryPath, '..', slashSuffix); let parentDirPathDetails = path.parse(path.join('/', ...directoryPath, '..', slashSuffix)); filesAsPathDetails.unshift({ ...parentDirPathDetails, type: 'directory', base: '..', relative, title: '..', ext: '', root: parentDirPathDetails.root, dir: parentDirPathDetails.dir, name: parentDirPathDetails.name, }); } const subPaths: Path[] = []; for (let index = 0; index < pathParts.length; index++) { const parents = []; const isLast = index === (pathParts.length - 1); let before = 0; while (before <= index) { parents.push(pathParts[before]); before++; } parents.shift(); subPaths.push({ name: pathParts[index] + (isLast ? slashSuffix : '/'), url: index === 0 ? '' : parents.join('/') + slashSuffix }); } const spec = { files: filesAsPathDetails, directory, paths: subPaths }; const output = acceptsJSON ? JSON.stringify(spec) : await directoryTemplate(spec); return {directory: output}; }; export const sendError = async (absolutePath: string, response: ServerResponse, acceptsJSON: boolean|null, current: string, handlers: ServeIOHandlers, config: ServeHandlerOptions, spec: ServeErrorTemplateParameters) => { const {err: original, message, code, statusCode} = spec; /* istanbul ignore next */ if (original && process.env.NODE_ENV !== 'test') { console.error(original); } response.statusCode = statusCode; if (acceptsJSON) { response.setHeader('Content-Type', 'application/json; charset=utf-8'); response.end(JSON.stringify({ error: spec })); return; } let stats = null; const errorPage = path.join(current, `${statusCode}.html`); try { stats = await handlers.lstat(errorPage); } catch (err) { if (err && (err as {code: string}).code) { if ((err as {code: string}).code !== 'ENOENT') { console.error(err); } } } if (stats) { let stream = null; try { stream = handlers.createReadStream(errorPage); const headers = await getHeaders(handlers, config, current, errorPage, stats); response.writeHead(statusCode, headers); stream.pipe(response); return; } catch (err) { console.error(err); } } const headers = await getHeaders(handlers, config, current, absolutePath, null); headers['Content-Type'] = 'text/html; charset=utf-8'; response.writeHead(statusCode, headers); response.end(await errorTemplate(spec)); }; export const internalError = async (absolutePath: string, response: ServerResponse, acceptsJSON: boolean|null, current: string, handlers: ServeIOHandlers, config: ServeHandlerOptions, spec: any) => { let internalError: ServeErrorTemplateParameters = { statusCode: 500, code: 'internal_server_error', message: 'A server error has occurred', err: spec, }; return sendError(absolutePath, response, acceptsJSON, current, handlers, config, internalError); }; export type lstat_signature = ((path: PathLike, opts?: StatOptions) => Promise); export type realpath_signature = ((path: PathLike, options?: EncodingOption) => Promise) | ((path: PathLike, options?: BufferEncodingOption) => Promise) | ((path: PathLike, options?: EncodingOption) => Promise); export type createReadStream_signature = ((path: PathLike, options?: BufferEncoding | ReadStreamOptions | undefined) => ReadStream); export type readdir_signature = ((path: PathLike, options?: | (ObjectEncodingOptions & { withFileTypes?: false | undefined; recursive?: boolean | undefined; }) | BufferEncoding | null,) => Promise) | ((path: PathLike, options?: | { encoding: "buffer"; withFileTypes?: false | undefined; recursive?: boolean | undefined; } | "buffer",) => Promise) | ((path: PathLike, options?: | (ObjectEncodingOptions & { withFileTypes?: false | undefined; recursive?: boolean | undefined; }) | BufferEncoding | null,) => Promise) | ((path: PathLike, options?: ObjectEncodingOptions & { withFileTypes: true; recursive?: boolean | undefined;},) => Promise); export type sendError_signature = ((absolutePath: string, response: ServerResponse, acceptsJSON: boolean|null, current: string, handlers: ServeIOHandlers, config: ServeHandlerOptions, spec: ServeErrorTemplateParameters) => void); export type isDirectoryOrDirectorySymbolicLink_signature = ((path: string, max_recursion_depth: number) => Promise); export interface ServeIOHandlers { lstat: lstat_signature, realpath: realpath_signature, createReadStream: createReadStream_signature, readdir: readdir_signature, sendError: sendError_signature, isDirectoryOrDirectorySymbolicLink: isDirectoryOrDirectorySymbolicLink_signature, } lstat export const getHandlers: (methods: ServeIOHandlers|Dictionary) => ServeIOHandlers = (methods) => Object.assign({ lstat, realpath, createReadStream, readdir, sendError, isDirectoryOrDirectorySymbolicLink, }, methods); export default async (request: Request, response: ServerResponse, serveConfig: ServeHandlerOptions = {}, methods: ServeIOHandlers|Dictionary = {}) => { const cwd = process.cwd(); const current = serveConfig.public ? path.resolve(cwd, serveConfig.public) : cwd; const handlers = getHandlers(methods); let relativePath = null; let acceptsJSON = null; if (request.headers!.accept) { acceptsJSON = request.headers!.accept.includes('application/json'); } try { relativePath = decodeURIComponent(uri.parse(request.url).path as string); } catch (err) { return sendError('/', response, acceptsJSON, current, handlers, serveConfig, { statusCode: 400, code: 'bad_request', message: 'Bad Request' }); } let absolutePath = path.join(current, relativePath); // Prevent path traversal vulnerabilities. We could do this // by ourselves, but using the package covers all the edge cases. if (!isPathInside(absolutePath, current)) { return sendError(absolutePath, response, acceptsJSON, current, handlers, serveConfig, { statusCode: 400, code: 'bad_request', message: 'Bad Request' }); } const cleanUrl = applicable(relativePath, serveConfig.cleanUrls); const redirect = shouldRedirect(relativePath, serveConfig, cleanUrl); if (redirect) { response.writeHead(redirect.statusCode, { Location: encodeURI(redirect.target) }); response.end(); return; } let stats = null; // It's extremely important that we're doing multiple stat calls. This one // right here could technically be removed, but then the program // would be slower. Because for directories, we always want to see if a related file // exists and then (after that), fetch the directory itself if no // related file was found. However (for files, of which most have extensions), we should // always stat right away. // // When simulating a file system without directory indexes, calculating whether a // directory exists requires loading all the file paths and then checking if // one of them includes the path of the directory. As that's a very // performance-expensive thing to do, we need to ensure it's not happening if not really necessary. if (path.extname(relativePath) !== '') { try { stats = await handlers.lstat(absolutePath); } catch (err) { if (err && (err as {code: string}).code) { if ((err as {code: string}).code !== 'ENOENT' && (err as {code: string}).code !== 'ENOTDIR') { return internalError(absolutePath, response, acceptsJSON, current, handlers, serveConfig, err); } } } } const rewrittenPath = applyRewrites(relativePath, serveConfig.rewrites); if (!stats && (cleanUrl || rewrittenPath)) { try { const related = await findRelated(current, relativePath, rewrittenPath, handlers.lstat); if (related) { ({stats, absolutePath} = related); } } catch (err) { if (err && (err as {code: string}).code) { if ((err as {code: string}).code !== 'ENOENT' && (err as {code: string}).code !== 'ENOTDIR') { return internalError(absolutePath, response, acceptsJSON, current, handlers, serveConfig, err); } } } } if (!stats) { try { stats = await handlers.lstat(absolutePath); } catch (err) { console.error(err); if (err && (err as {code: string}).code) { if ((err as {code: string}).code !== 'ENOENT' && (err as {code: string}).code !== 'ENOTDIR') { return internalError(absolutePath, response, acceptsJSON, current, handlers, serveConfig, err); } } } } //if (stats && stats.isDirectory()) { if (stats && await handlers.isDirectoryOrDirectorySymbolicLink(absolutePath, 10)) { let directory = null; let singleFile = null; try { const related = await renderDirectory(current, acceptsJSON, handlers, methods, serveConfig, { relativePath, absolutePath }); if (related.singleFile) { ({stats, absolutePath, singleFile} = related); } else { ({directory} = related); } } catch (err) { console.log('error trap', err); if (err && (err as {code: string}).code) { if ((err as {code: string}).code !== 'ENOENT') { return internalError(absolutePath, response, acceptsJSON, current, handlers, serveConfig, err); } } } if (directory) { const contentType = acceptsJSON ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8'; response.statusCode = 200; response.setHeader('Content-Type', contentType); response.end(directory); return; } if (!singleFile) { // The directory listing is disabled, so we want to // render a 404 error. stats = null; } } const isSymLink = stats && stats.isSymbolicLink(); // There are two scenarios in which we want to reply with // a 404 error: Either the path does not exist, or it is a // symlink while the `symlinks` option is disabled (which it is by default). if (!stats || (!serveConfig.symlinks && isSymLink)) { // allow for custom 404 handling return handlers.sendError(absolutePath, response, acceptsJSON, current, handlers, serveConfig, { statusCode: 404, code: 'not_found', message: 'The requested path could not be found' }); } // If we figured out that the target is a symlink, we need to // resolve the symlink and run a new `stat` call just for the // target of that symlink. if (isSymLink) { absolutePath = await handlers.realpath(absolutePath) as string; stats = await handlers.lstat(absolutePath); } const streamOpts: ReadStreamOptions = {}; // TODO ? if-range if (request.headers?.range && stats.size) { //TODO might not work with stats.size as bigint const range = parseRange(stats.size as number, request.headers.range); if (typeof range === 'object' && range.type === 'bytes') { const {start, end} = range[0]; streamOpts.start = start; streamOpts.end = end; response.statusCode = 206; } else { response.statusCode = 416; response.setHeader('Content-Range', `bytes */${stats.size}`); } } // TODO ? multiple ranges let stream = null; try { stream = handlers.createReadStream(absolutePath, streamOpts); } catch (err) { return internalError(absolutePath, response, acceptsJSON, current, handlers, serveConfig, err); } const headers: OutgoingHttpHeaders = await getHeaders(handlers, serveConfig, current, absolutePath, stats); // eslint-disable-next-line no-undefined if (streamOpts.start !== undefined && streamOpts.end !== undefined) { headers['Content-Range'] = `bytes ${streamOpts.start}-${streamOpts.end}/${stats.size}`; headers['Content-Length'] = streamOpts.end - streamOpts.start + 1; } // We need to check for `headers.ETag` being truthy first, otherwise it will // match `undefined` being equal to `undefined`, which is true. // // Checking for `undefined` and `null` is also important, because `Range` can be `0`. // // eslint-disable-next-line no-eq-null if (request.headers?.range == null && headers.ETag && headers.ETag === request.headers?.['if-none-match']) { response.statusCode = 304; response.end(); return; } response.writeHead(response.statusCode || 200, headers); stream.pipe(response); };