UNPKG

webpack-dev-middleware

Version:
913 lines (849 loc) 30.3 kB
"use strict"; const path = require("node:path"); const querystring = require("node:querystring"); const mime = require("mime-types"); const onFinishedStream = require("on-finished"); const { createReadStreamOrReadFile, destroyStream, escapeHtml, etag, finish, getHeadersSent, getOutgoing, getRequestHeader, getRequestMethod, getRequestURL, getResponseHeader, getResponseHeaders, getStatusCode, getValueContentRangeHeader, initState, memorize, parseHttpDate, parseTokenList, pipe, removeResponseHeader, send, setResponseHeader, setState, setStatusCode } = require("./utils"); /** @typedef {import("fs").ReadStream} ReadStream */ /** @typedef {import("webpack").Compiler} Compiler */ /** @typedef {import("webpack").Stats} Stats */ /** @typedef {import("webpack").MultiStats} MultiStats */ /** @typedef {import("webpack").Asset} Asset */ /** @typedef {import("./index.js").NextFunction} NextFunction */ /** @typedef {import("./index.js").IncomingMessage} IncomingMessage */ /** @typedef {import("./index.js").ServerResponse} ServerResponse */ /** @typedef {import("./index.js").NormalizedHeaders} NormalizedHeaders */ /** @typedef {import("./index.js").OutputFileSystem} OutputFileSystem */ const BYTES_RANGE_REGEXP = /^ *bytes/i; /** * @param {string} input input * @returns {string} unescape input */ function decode(input) { return querystring.unescape(input); } const memoizedParse = memorize(url => { const urlObject = new URL(url, "http://localhost"); // We can't change pathname in URL object directly because don't decode correctly return { ...urlObject, pathname: decode(urlObject.pathname) }; }, undefined); const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/; /** @typedef {import("fs").Stats} FSStats */ /** * @typedef {object} Extra * @property {FSStats} stats stats * @property {boolean=} immutable true when immutable, otherwise false * @property {OutputFileSystem} outputFileSystem outputFileSystem */ /** * decodeURIComponent. * * Allows V8 to only deoptimize this fn instead of all of send(). * @param {string} input * @returns {string} */ class FilenameError extends Error { /** * @param {string} message message * @param {number=} code error code */ constructor(message, code) { super(message); this.name = "FilenameError"; this.statusCode = code; } } /** @typedef {{ filename: string, extra: Extra }} FilenameWithExtra */ /** * @param {unknown} error error * @returns {boolean} true when error is like not found, otherwise false */ function isNotFoundError(error) { switch (/** @type {NodeJS.ErrnoException} */error.code) { case "ENAMETOOLONG": case "ENOENT": case "ENOTDIR": return true; default: return false; } } /** * @template {IncomingMessage} Request * @template {ServerResponse} Response * @param {import("./index.js").FilledContext<Request, Response>} context context * @param {string} url url * @returns {Promise<FilenameWithExtra | undefined>} result of get filename from url */ async function getFilenameFromUrl(context, url) { /** @type {URL} */ let urlObject; try { // The `url` property of the `request` is contains only `pathname`, `search` and `hash` urlObject = memoizedParse(url); } catch { return; } const { options, stats } = context; /** @type {Stats[]} */ const allStats = /** @type {MultiStats} */ stats.stats || [(/** @type {Stats} */stats)]; const index = options.index === false ? (/** @type {string[]} */[]) : typeof options.index === "undefined" || options.index === true ? ["index.html"] : [options.index]; for (const { compilation } of allStats) { if (compilation.options.devServer === false) { continue; } /** @type {URL} */ let publicPathObject; const publicPath = options.publicPath || compilation.options.output.publicPath || ""; try { publicPathObject = memoizedParse(publicPath === "auto" ? "/" : compilation.getPath(publicPath)); } catch { continue; } const { pathname } = urlObject; const { pathname: publicPathPathname } = publicPathObject; /** @type {string | undefined} */ let filename; if (pathname && publicPathPathname && pathname.startsWith(publicPathPathname)) { // Null byte(s) if (pathname.includes("\0")) { throw new FilenameError("Bad Request", 400); } // ".." is malicious if (UP_PATH_REGEXP.test(path.normalize(`./${pathname}`))) { throw new FilenameError("Forbidden", 403); } // send file logic // The `output.path` is always present and always absolute const outputPath = compilation.getPath(compilation.outputOptions.path || ""); // Strip the `pathname` property from the `publicPath` option from the start of requested url // `/complex/foo.js` => `foo.js` // and add outputPath // `foo.js` => `/home/user/my-project/dist/foo.js` filename = path.join(outputPath, pathname.slice(publicPathPathname.length)); const { assetsInfo } = compilation; const { outputFileSystem } = /** @type {Compiler & { outputFileSystem: OutputFileSystem }} */ compilation.compiler; /** * @param {string} filename filename * @returns {Promise<FilenameWithExtra | undefined>} filename when found, otherwise undefined */ const resolveIndex = async filename => { if (index.length === 0) { return; } filename = path.join(filename, index[0]); let stats; try { stats = await new Promise((resolve, reject) => { outputFileSystem.stat(filename, (err, res) => { if (err) { reject(err); return; } resolve(res); }); }); } catch (err) { if (isNotFoundError(err)) return; throw err; } if (/** @type {FSStats} */stats.isDirectory()) { return resolveIndex(filename); } const extra = { immutable: assetsInfo ? assetsInfo.get(pathname.slice(publicPathPathname.length))?.immutable : false, outputFileSystem, stats: (/** @type {FSStats} */stats) }; return { filename, extra }; }; /** * @param {string} filename filename * @returns {Promise<FilenameWithExtra | undefined>} filename when found, otherwise undefined */ const resolveFile = async filename => { let stats; try { stats = await new Promise((resolve, reject) => { outputFileSystem.stat(filename, (err, res) => { if (err) { reject(err); return; } resolve(res); }); }); } catch (err) { if (isNotFoundError(err)) return; throw err; } if (/** @type {FSStats} */stats.isDirectory()) { // Different between `send` and our logic is here, `send` makes a redirect, we just return a file. return resolveIndex(filename); } if (filename.endsWith(path.sep)) { return; } /** @type {Extra} */ const extra = { immutable: assetsInfo ? assetsInfo.get(pathname.slice(publicPathPathname.length))?.immutable : false, outputFileSystem, stats: (/** @type {FSStats} */stats) }; return { filename, extra }; }; // send index logic if (index.length > 0 && pathname.endsWith("/")) { const result = await resolveIndex(filename); if (!result) { continue; } return result; } // send file logic const result = await resolveFile(filename); if (!result) { continue; } return result; } } } const CACHE_CONTROL_NO_CACHE_REGEXP = /(?:^|,)\s*?no-cache\s*?(?:,|$)/; /** @type {Record<number, string>} */ const statuses = { 400: "Bad Request", 403: "Forbidden", 404: "Not Found", 416: "Range Not Satisfiable", 500: "Internal Server Error" }; const parseRangeHeaders = memorize( /** * @param {string} value value * @returns {import("range-parser").Result | import("range-parser").Ranges} ranges */ value => { const [len, rangeHeader] = value.split("|"); return require("range-parser")(Number(len), rangeHeader, { combine: true }); }); const MAX_MAX_AGE = 31536000000; /** * @template {IncomingMessage} Request * @template {ServerResponse} Response * @typedef {object} SendErrorOptions send error options * @property {Record<string, number | string | string[] | undefined>=} headers headers * @property {import("./index").ModifyResponseData<Request, Response>=} modifyResponseData modify response data callback */ /** * @template {IncomingMessage} Request * @template {ServerResponse} Response * @param {import("./index.js").FilledContext<Request, Response>} context context * @param {import("./index.js").Callback} callback callback * @param {Request=} req req * @returns {void} */ function ready(context, callback, req) { if (context.state) { callback(context.stats); return; } const name = req && req.url || callback.name; context.logger.info(`wait until bundle finished${name ? `: ${name}` : ""}`); context.callbacks.push(callback); } /** * @template {IncomingMessage} Request * @template {ServerResponse} Response * @param {import("./index.js").FilledContext<Request, Response>} context context * @returns {import("./index.js").Middleware<Request, Response>} wrapper */ function wrapper(context) { return async function middleware(req, res, next) { /** * @param {NodeJS.ErrnoException=} err an error * @returns {Promise<void>} */ async function goNext(err) { if (!context.options.serverSideRender) { return next(err); } return new Promise(resolve => { ready(context, () => { setState(res, "webpack", { devMiddleware: context }); resolve(next(err)); }, req); }); } const acceptedMethods = context.options.methods || ["GET", "HEAD"]; initState(res); const method = getRequestMethod(req); if (method && !acceptedMethods.includes(method)) { await goNext(); return; } /** * @param {string} message an error message * @param {number} status status * @param {Partial<SendErrorOptions<Request, Response>>=} options options * @returns {Promise<void>} */ async function sendError(message, status, options) { if (context.options.forwardError) { if (!getHeadersSent(res)) { const headers = getResponseHeaders(res); for (let i = 0; i < headers.length; i++) { removeResponseHeader(res, headers[i]); } } const error = /** @type {Error & { statusCode: number }} */ new Error(message); error.statusCode = status; await goNext(error); // need the return for prevent to execute the code below and override the status and body set by user in the next middleware return; } const content = statuses[status] || String(status); let document = Buffer.from(`<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>Error</title> </head> <body> <pre>${escapeHtml(content)}</pre> </body> </html>`, "utf8"); // Clear existing headers const headers = getResponseHeaders(res); for (let i = 0; i < headers.length; i++) { removeResponseHeader(res, headers[i]); } if (options && options.headers) { const keys = Object.keys(options.headers); for (let i = 0; i < keys.length; i++) { const key = keys[i]; const value = options.headers[key]; if (typeof value !== "undefined") { setResponseHeader(res, key, value); } } } // Send basic response setStatusCode(res, status); setResponseHeader(res, "Content-Type", "text/html; charset=utf-8"); setResponseHeader(res, "Content-Security-Policy", "default-src 'none'"); setResponseHeader(res, "X-Content-Type-Options", "nosniff"); let byteLength = Buffer.byteLength(document); if (options && options.modifyResponseData) { ({ data: document, byteLength } = /** @type {{ data: Buffer<ArrayBuffer>, byteLength: number }} */ options.modifyResponseData(req, res, document, byteLength)); } setResponseHeader(res, "Content-Length", byteLength); finish(res, document); } /** * @param {NodeJS.ErrnoException} error error * @param {string=} message override message * @param {number=} code override code * @returns {Promise<void>} */ async function errorHandler(error, message, code) { switch (error.code) { case "ENAMETOOLONG": case "ENOENT": case "ENOTDIR": await sendError(error.message, 404, { modifyResponseData: context.options.modifyResponseData }); break; default: await sendError(message || error.message, code || 500, { modifyResponseData: context.options.modifyResponseData }); break; } } /** * @returns {string | string[] | undefined} something when conditional get exist */ function isConditionalGET() { return getRequestHeader(req, "if-match") || getRequestHeader(req, "if-unmodified-since") || getRequestHeader(req, "if-none-match") || getRequestHeader(req, "if-modified-since"); } /** * @returns {boolean} true when precondition failure, otherwise false */ function isPreconditionFailure() { // if-match const ifMatch = /** @type {string} */getRequestHeader(req, "if-match"); // A recipient MUST ignore If-Unmodified-Since if the request contains // an If-Match header field; the condition in If-Match is considered to // be a more accurate replacement for the condition in // If-Unmodified-Since, and the two are only combined for the sake of // interoperating with older intermediaries that might not implement If-Match. if (ifMatch) { const etag = getResponseHeader(res, "ETag"); return !etag || ifMatch !== "*" && parseTokenList(ifMatch).every(match => match !== etag && match !== `W/${etag}` && `W/${match}` !== etag); } // if-unmodified-since const ifUnmodifiedSince = /** @type {string} */ getRequestHeader(req, "if-unmodified-since"); if (ifUnmodifiedSince) { const unmodifiedSince = parseHttpDate(ifUnmodifiedSince); // A recipient MUST ignore the If-Unmodified-Since header field if the // received field-value is not a valid HTTP-date. if (!Number.isNaN(unmodifiedSince)) { const lastModified = parseHttpDate(/** @type {string} */getResponseHeader(res, "Last-Modified")); return Number.isNaN(lastModified) || lastModified > unmodifiedSince; } } return false; } /** * @returns {boolean} is cachable */ function isCachable() { const statusCode = getStatusCode(res); return statusCode >= 200 && statusCode < 300 || statusCode === 304 || // For Koa and Hono, because by default status code is 404, but we already found a file statusCode === 404; } /** * @param {import("http").OutgoingHttpHeaders} resHeaders res header * @returns {boolean} true when fresh, otherwise false */ function isFresh(resHeaders) { // Always return stale when Cache-Control: no-cache to support end-to-end reload requests // https://tools.ietf.org/html/rfc2616#section-14.9.4 const cacheControl = /** @type {string} */ getRequestHeader(req, "cache-control"); if (cacheControl && CACHE_CONTROL_NO_CACHE_REGEXP.test(cacheControl)) { return false; } // fields const noneMatch = /** @type {string} */ getRequestHeader(req, "if-none-match"); const modifiedSince = /** @type {string} */ getRequestHeader(req, "if-modified-since"); // unconditional request if (!noneMatch && !modifiedSince) { return false; } // if-none-match if (noneMatch && noneMatch !== "*") { if (!resHeaders.etag) { return false; } const matches = parseTokenList(noneMatch); let etagStale = true; for (let i = 0; i < matches.length; i++) { const match = matches[i]; if (match === resHeaders.etag || match === `W/${resHeaders.etag}` || `W/${match}` === resHeaders.etag) { etagStale = false; break; } } if (etagStale) { return false; } } // A recipient MUST ignore If-Modified-Since if the request contains an If-None-Match header field; // the condition in If-None-Match is considered to be a more accurate replacement for the condition in If-Modified-Since, // and the two are only combined for the sake of interoperating with older intermediaries that might not implement If-None-Match. if (noneMatch) { return true; } // if-modified-since if (modifiedSince) { const lastModified = resHeaders["last-modified"]; // A recipient MUST ignore the If-Modified-Since header field if the // received field-value is not a valid HTTP-date, or if the request // method is neither GET nor HEAD. const modifiedStale = !lastModified || !(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince)); if (modifiedStale) { return false; } } return true; } /** * @returns {boolean} true when range is fresh, otherwise false */ function isRangeFresh() { const ifRange = /** @type {string | undefined} */ getRequestHeader(req, "if-range"); if (!ifRange) { return true; } // if-range as etag if (ifRange.includes('"')) { const etag = /** @type {string | undefined} */ getResponseHeader(res, "ETag"); if (!etag) { return true; } return Boolean(etag && ifRange.includes(etag)); } // if-range as modified date const lastModified = /** @type {string | undefined} */ getResponseHeader(res, "Last-Modified"); if (!lastModified) { return true; } return parseHttpDate(lastModified) <= parseHttpDate(ifRange); } /** * @returns {string | undefined} range header */ function getRangeHeader() { const range = /** @type {string} */getRequestHeader(req, "range"); if (range && BYTES_RANGE_REGEXP.test(range)) { return range; } return undefined; } /** * @param {import("range-parser").Range} range range * @returns {[number, number]} offset and length */ function getOffsetAndLenFromRange(range) { const offset = range.start; const len = range.end - range.start + 1; return [offset, len]; } /** * @param {number} offset offset * @param {number} len len * @returns {[number, number]} start and end */ function calcStartAndEnd(offset, len) { const start = offset; const end = Math.max(offset, offset + len - 1); return [start, end]; } /** * @returns {Promise<void>} */ async function processRequest() { // Pipe and SendFile /** @type {FilenameWithExtra | undefined} */ let resolved; const requestUrl = /** @type {string} */getRequestURL(req); try { resolved = await getFilenameFromUrl(context, requestUrl); } catch (err) { // Fallback to 403 for unknown errors const errorCode = typeof err === "object" && err !== null && typeof (/** @type {FilenameError} */err.statusCode) !== "undefined" ? /** @type {FilenameError} */err.statusCode : undefined; if (errorCode === 403) { context.logger.error(`Malicious path "${requestUrl}".`); } await errorHandler(/** @type {NodeJS.ErrnoException} */err, errorCode === 400 ? "Bad Request" : errorCode === 403 ? "Forbidden" : undefined, errorCode); return; } if (!resolved) { await goNext(); return; } if (getHeadersSent(res)) { await goNext(); return; } const { extra, filename } = resolved; const { size } = extra.stats; let len = size; let offset = 0; // Send logic if (context.options.headers) { let { headers } = context.options; if (typeof headers === "function") { headers = /** @type {NormalizedHeaders} */ headers(req, res, context); } /** * @type {{ key: string, value: string | number }[]} */ const allHeaders = []; if (typeof headers !== "undefined") { if (!Array.isArray(headers)) { for (const name in headers) { allHeaders.push({ key: name, value: headers[name] }); } headers = allHeaders; } for (const { key, value } of headers) { setResponseHeader(res, key, value); } } } if (!getResponseHeader(res, "Accept-Ranges")) { setResponseHeader(res, "Accept-Ranges", "bytes"); } if (!getResponseHeader(res, "Cache-Control")) { const { cacheControl, cacheImmutable } = context.options; let cacheControlValue; if ((cacheImmutable === undefined || cacheImmutable) && extra.immutable) { cacheControlValue = `public, max-age=${Math.floor(MAX_MAX_AGE / 1000)}, immutable`; } else if (typeof cacheControl === "boolean") { cacheControlValue = `public, max-age=${Math.floor(MAX_MAX_AGE / 1000)}`; } else if (typeof cacheControl === "number") { const maxAge = Math.min(Math.max(0, cacheControl), MAX_MAX_AGE); cacheControlValue = `public, max-age=${Math.floor(maxAge / 1000)}`; } else if (typeof cacheControl === "string") { cacheControlValue = cacheControl; } else if (cacheControl) { const maxAge = cacheControl.maxAge !== undefined ? Math.min(Math.max(0, cacheControl.maxAge), MAX_MAX_AGE) : MAX_MAX_AGE; cacheControlValue = `public, max-age=${Math.floor(maxAge / 1000)}`; if (cacheControl.immutable) { cacheControlValue += ", immutable"; } } if (cacheControlValue) { setResponseHeader(res, "Cache-Control", cacheControlValue); } } if (context.options.lastModified && !getResponseHeader(res, "Last-Modified")) { const modified = extra.stats.mtime.toUTCString(); setResponseHeader(res, "Last-Modified", modified); } /** @type {number} */ let start; /** @type {number} */ let end; /** @type {undefined | Buffer | ReadStream} */ let bufferOrStream; /** @type {number | undefined} */ let byteLength; const rangeHeader = getRangeHeader(); if (context.options.etag && !getResponseHeader(res, "ETag")) { const isStrongETag = context.options.etag === "strong"; // TODO cache strong etag generation? if (isStrongETag) { if (rangeHeader) { const parsedRanges = /** @type {import("range-parser").Ranges | import("range-parser").Result} */ parseRangeHeaders(`${size}|${rangeHeader}`); if (parsedRanges !== -2 && parsedRanges !== -1 && parsedRanges.length === 1) { [offset, len] = getOffsetAndLenFromRange(parsedRanges[0]); } } [start, end] = calcStartAndEnd(offset, len); try { const result = createReadStreamOrReadFile(filename, extra.outputFileSystem, start, end); ({ bufferOrStream, byteLength } = result); } catch (error) { await errorHandler(/** @type {NodeJS.ErrnoException} */error); return; } } const result = await etag(isStrongETag ? (/** @type {Buffer | ReadStream} */bufferOrStream) : extra.stats); // Because we already read stream, we can cache buffer to avoid extra read from fs if (result.buffer) { bufferOrStream = result.buffer; } setResponseHeader(res, "ETag", result.hash); } if (!getResponseHeader(res, "Content-Type") || getStatusCode(res) === 404) { removeResponseHeader(res, "Content-Type"); // content-type name (like application/javascript; charset=utf-8) or false const contentType = mime.contentType(path.extname(filename)); // Only set content-type header if media type is known // https://tools.ietf.org/html/rfc7231#section-3.1.1.5 if (contentType) { setResponseHeader(res, "Content-Type", contentType); } else if (context.options.mimeTypeDefault) { setResponseHeader(res, "Content-Type", context.options.mimeTypeDefault); } } // Conditional GET support if (isConditionalGET()) { if (isPreconditionFailure()) { await sendError("Precondition Failed", 412, { modifyResponseData: context.options.modifyResponseData }); return; } if (isCachable() && isFresh({ etag: (/** @type {string | undefined} */ getResponseHeader(res, "ETag")), "last-modified": (/** @type {string | undefined} */ getResponseHeader(res, "Last-Modified")) })) { setStatusCode(res, 304); // Remove content header fields removeResponseHeader(res, "Content-Encoding"); removeResponseHeader(res, "Content-Language"); removeResponseHeader(res, "Content-Length"); removeResponseHeader(res, "Content-Range"); removeResponseHeader(res, "Content-Type"); finish(res); return; } } let isPartialContent = false; if (rangeHeader) { let parsedRanges = /** @type {import("range-parser").Ranges | import("range-parser").Result | []} */ parseRangeHeaders(`${size}|${rangeHeader}`); // If-Range support if (!isRangeFresh()) { parsedRanges = []; } if (parsedRanges === -1) { context.logger.error("Unsatisfiable range for 'Range' header."); setResponseHeader(res, "Content-Range", getValueContentRangeHeader("bytes", size)); await sendError("Range Not Satisfiable", 416, { headers: { "Content-Range": getResponseHeader(res, "Content-Range") }, modifyResponseData: context.options.modifyResponseData }); return; } else if (parsedRanges === -2) { context.logger.error("A malformed 'Range' header was provided. A regular response will be sent for this request."); } else if (parsedRanges.length > 1) { context.logger.error("A 'Range' header with multiple ranges was provided. Multiple ranges are not supported, so a regular response will be sent for this request."); } if (parsedRanges !== -2 && parsedRanges.length === 1) { // Content-Range setStatusCode(res, 206); setResponseHeader(res, "Content-Range", getValueContentRangeHeader("bytes", size, /** @type {import("range-parser").Ranges} */parsedRanges[0])); isPartialContent = true; [offset, len] = getOffsetAndLenFromRange(parsedRanges[0]); } } // When strong Etag generation is enabled we already read file, so we can skip extra fs call if (!bufferOrStream) { [start, end] = calcStartAndEnd(offset, len); try { ({ bufferOrStream, byteLength } = createReadStreamOrReadFile(filename, extra.outputFileSystem, start, end)); } catch (error) { await errorHandler(/** @type {NodeJS.ErrnoException} */error); return; } } if (context.options.modifyResponseData) { ({ data: bufferOrStream, byteLength } = context.options.modifyResponseData(req, res, bufferOrStream, /** @type {number} */ byteLength)); } setResponseHeader(res, "Content-Length", /** @type {number} */ byteLength); if (method === "HEAD") { if (!isPartialContent) { setStatusCode(res, 200); } finish(res); return; } if (!isPartialContent) { setStatusCode(res, 200); } const isPipeSupports = typeof (/** @type {import("fs").ReadStream} */bufferOrStream.pipe) === "function"; if (!isPipeSupports) { send(res, /** @type {Buffer} */bufferOrStream); return; } // Error handling /** @type {import("fs").ReadStream} */ bufferOrStream.on("error", error => { context.logger.error("Stream error:", error); // clean up stream early destroyStream(/** @type {import("fs").ReadStream} */bufferOrStream, true); errorHandler(error); }); pipe(res, /** @type {ReadStream} */bufferOrStream); const outgoing = getOutgoing(res); if (outgoing) { // Response finished, cleanup onFinishedStream(outgoing, err => { if (err) { context.logger.error("Stream error:", err); } destroyStream(/** @type {import("fs").ReadStream} */bufferOrStream, true); }); } } ready(context, processRequest, req); }; } module.exports = wrapper; module.exports.getFilenameFromUrl = getFilenameFromUrl; module.exports.ready = ready;