webpack-dev-middleware
Version:
A development middleware for webpack
683 lines (636 loc) • 23.4 kB
JavaScript
"use strict";
const path = require("node:path");
const mime = require("mime-types");
const onFinishedStream = require("on-finished");
const {
createReadStreamOrReadFileSync,
finish,
getHeadersSent,
getOutgoing,
getRequestHeader,
getRequestMethod,
getRequestURL,
getResponseHeader,
getResponseHeaders,
getStatusCode,
initState,
pipe,
removeResponseHeader,
send,
setResponseHeader,
setState,
setStatusCode
} = require("./utils/compatibleAPI");
const getFilenameFromUrl = require("./utils/getFilenameFromUrl");
const memorize = require("./utils/memorize");
const ready = require("./utils/ready");
/** @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("fs").ReadStream} ReadStream */
const BYTES_RANGE_REGEXP = /^ *bytes/i;
/**
* @param {"bytes"} type type
* @param {number} size size
* @param {import("range-parser").Range=} range range
* @returns {string} value of content range header
*/
function getValueContentRangeHeader(type, size, range) {
return `${type} ${range ? `${range.start}-${range.end}` : "*"}/${size}`;
}
/**
* Parse an HTTP Date into a number.
* @param {string} date date
* @returns {number} timestamp
*/
function parseHttpDate(date) {
const timestamp = date && Date.parse(date);
// istanbul ignore next: guard against date.js Date.parse patching
return typeof timestamp === "number" ? timestamp : Number.NaN;
}
const CACHE_CONTROL_NO_CACHE_REGEXP = /(?:^|,)\s*?no-cache\s*?(?:,|$)/;
/**
* @param {import("fs").ReadStream} stream stream
* @param {boolean} suppress do need suppress?
* @returns {void}
*/
function destroyStream(stream, suppress) {
if (typeof stream.destroy === "function") {
stream.destroy();
}
if (typeof stream.close === "function") {
// Node.js core bug workaround
stream.on("open",
/**
* @this {import("fs").ReadStream}
*/
function onOpenClose() {
// @ts-expect-error
if (typeof this.fd === "number") {
// actually close down the fd
this.close();
}
});
}
if (typeof stream.addListener === "function" && suppress) {
stream.removeAllListeners("error");
stream.addListener("error", () => {});
}
}
/** @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 getETag = memorize(() => require("./utils/etag"));
const getEscapeHtml = memorize(() => require("./utils/escapeHtml"));
const getParseTokenList = memorize(() => require("./utils/parseTokenList"));
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
* @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"];
// TODO do we need an option here?
const forwardError = false;
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 (forwardError) {
const error = /** @type {Error & { statusCode: number }} */
new Error(message);
error.statusCode = status;
await goNext(error);
}
const escapeHtml = getEscapeHtml();
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
* @returns {Promise<void>}
*/
async function errorHandler(error) {
switch (error.code) {
case "ENAMETOOLONG":
case "ENOENT":
case "ENOTDIR":
await sendError(error.message, 404, {
modifyResponseData: context.options.modifyResponseData
});
break;
default:
await sendError(error.message, 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 !== "*" && getParseTokenList()(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 = getParseTokenList()(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 {import("./utils/getFilenameFromUrl").Extra} */
const extra = {};
const filename = getFilenameFromUrl(context, /** @type {string} */getRequestURL(req), extra);
if (extra.errorCode) {
if (extra.errorCode === 403) {
context.logger.error(`Malicious path "${filename}".`);
}
await sendError(extra.errorCode === 400 ? "Bad Request" : "Forbidden", extra.errorCode, {
modifyResponseData: context.options.modifyResponseData
});
return;
}
if (!filename) {
await goNext();
return;
}
if (getHeadersSent(res)) {
await goNext();
return;
}
const {
size
} = /** @type {import("fs").Stats} */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")) {
// TODO enable the `cacheImmutable` by default for the next major release
const cacheControl = context.options.cacheImmutable && extra.immutable ? {
immutable: true
} : context.options.cacheControl;
if (cacheControl) {
let cacheControlValue;
if (typeof cacheControl === "boolean") {
cacheControlValue = "public, max-age=31536000";
} else if (typeof cacheControl === "number") {
const maxAge = Math.floor(Math.min(Math.max(0, cacheControl), MAX_MAX_AGE) / 1000);
cacheControlValue = `public, max-age=${maxAge}`;
} else if (typeof cacheControl === "string") {
cacheControlValue = cacheControl;
} else {
const maxAge = cacheControl.maxAge ? Math.floor(Math.min(Math.max(0, cacheControl.maxAge), MAX_MAX_AGE) / 1000) : MAX_MAX_AGE / 1000;
cacheControlValue = `public, max-age=${maxAge}`;
if (cacheControl.immutable) {
cacheControlValue += ", immutable";
}
}
setResponseHeader(res, "Cache-Control", cacheControlValue);
}
}
if (context.options.lastModified && !getResponseHeader(res, "Last-Modified")) {
const modified = /** @type {import("fs").Stats} */
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 = createReadStreamOrReadFileSync(filename, context.outputFileSystem, start, end);
({
bufferOrStream,
byteLength
} = result);
} catch (error) {
await errorHandler(/** @type {NodeJS.ErrnoException} */error);
return;
}
}
const result = await getETag()(isStrongETag ? (/** @type {Buffer | ReadStream} */bufferOrStream) : (/** @type {import("fs").Stats} */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
} = createReadStreamOrReadFileSync(filename, context.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;
}
// Cleanup
const cleanup = () => {
destroyStream(/** @type {import("fs").ReadStream} */bufferOrStream, true);
};
// Error handling
/** @type {import("fs").ReadStream} */
bufferOrStream.on("error", error => {
// clean up stream early
cleanup();
errorHandler(error);
});
pipe(res, /** @type {ReadStream} */bufferOrStream);
const outgoing = getOutgoing(res);
if (outgoing) {
// Response finished, cleanup
onFinishedStream(outgoing, cleanup);
}
}
ready(context, processRequest, req);
};
}
module.exports = wrapper;