123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677 |
- "use strict";
- const path = require("path");
- const mime = require("mime-types");
- const onFinishedStream = require("on-finished");
- const getFilenameFromUrl = require("./utils/getFilenameFromUrl");
- const {
- setStatusCode,
- getStatusCode,
- getRequestHeader,
- getRequestMethod,
- getRequestURL,
- getResponseHeader,
- setResponseHeader,
- removeResponseHeader,
- getResponseHeaders,
- getHeadersSent,
- send,
- finish,
- pipe,
- createReadStreamOrReadFileSync,
- getOutgoing,
- initState,
- setState,
- getReadyReadableStreamState
- } = require("./utils/compatibleAPI");
- const ready = require("./utils/ready");
- const parseTokenList = require("./utils/parseTokenList");
- const memorize = require("./utils/memorize");
- /** @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 {string} type
- * @param {number} size
- * @param {import("range-parser").Range} [range]
- * @returns {string}
- */
- function getValueContentRangeHeader(type, size, range) {
- return `${type} ${range ? `${range.start}-${range.end}` : "*"}/${size}`;
- }
- /**
- * Parse an HTTP Date into a number.
- *
- * @param {string} date
- * @returns {number}
- */
- function parseHttpDate(date) {
- const timestamp = date && Date.parse(date);
- // istanbul ignore next: guard against date.js Date.parse patching
- return typeof timestamp === "number" ? timestamp : 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-ignore
- 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
- * @returns {import("range-parser").Result | import("range-parser").Ranges}
- */
- value => {
- const [len, rangeHeader] = value.split("|");
- // eslint-disable-next-line global-require
- 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
- * @return {import("./index.js").Middleware<Request, Response>}
- */
- function wrapper(context) {
- return async function middleware(req, res, next) {
- const acceptedMethods = context.options.methods || ["GET", "HEAD"];
- initState(res);
- async function goNext() {
- if (!context.options.serverSideRender) {
- return next();
- }
- return new Promise(resolve => {
- ready(context, () => {
- setState(res, "webpack", {
- devMiddleware: context
- });
- resolve(next());
- }, req);
- });
- }
- const method = getRequestMethod(req);
- if (method && !acceptedMethods.includes(method)) {
- await goNext();
- return;
- }
- /**
- * @param {number} status status
- * @param {Partial<SendErrorOptions<Request, Response>>=} options options
- * @returns {void}
- */
- function sendError(status, options) {
- // eslint-disable-next-line global-require
- const escapeHtml = require("./utils/escapeHtml");
- 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>`, "utf-8");
- // 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, byteLength: number }} */
- options.modifyResponseData(req, res, document, byteLength));
- }
- setResponseHeader(res, "Content-Length", byteLength);
- finish(res, document);
- }
- /**
- * @param {NodeJS.ErrnoException} error
- */
- function errorHandler(error) {
- switch (error.code) {
- case "ENAMETOOLONG":
- case "ENOENT":
- case "ENOTDIR":
- sendError(404, {
- modifyResponseData: context.options.modifyResponseData
- });
- break;
- default:
- sendError(500, {
- modifyResponseData: context.options.modifyResponseData
- });
- break;
- }
- }
- function isConditionalGET() {
- return getRequestHeader(req, "if-match") || getRequestHeader(req, "if-unmodified-since") || getRequestHeader(req, "if-none-match") || getRequestHeader(req, "if-modified-since");
- }
- 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 (!isNaN(unmodifiedSince)) {
- const lastModified = parseHttpDate( /** @type {string} */getResponseHeader(res, "Last-Modified"));
- return 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
- * @returns {boolean}
- */
- 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;
- }
- function isRangeFresh() {
- const ifRange = /** @type {string | undefined} */
- getRequestHeader(req, "if-range");
- if (!ifRange) {
- return true;
- }
- // if-range as etag
- if (ifRange.indexOf('"') !== -1) {
- const etag = /** @type {string | undefined} */
- getResponseHeader(res, "ETag");
- if (!etag) {
- return true;
- }
- return Boolean(etag && ifRange.indexOf(etag) !== -1);
- }
- // 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}
- */
- function getRangeHeader() {
- const range = /** @type {string} */getRequestHeader(req, "range");
- if (range && BYTES_RANGE_REGEXP.test(range)) {
- return range;
- }
- // eslint-disable-next-line no-undefined
- return undefined;
- }
- /**
- * @param {import("range-parser").Range} range
- * @returns {[number, number]}
- */
- function getOffsetAndLenFromRange(range) {
- const offset = range.start;
- const len = range.end - range.start + 1;
- return [offset, len];
- }
- /**
- * @param {number} offset
- * @param {number} len
- * @returns {[number, number]}
- */
- function calcStartAndEnd(offset, len) {
- const start = offset;
- const end = Math.max(offset, offset + len - 1);
- return [start, end];
- }
- 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}".`);
- }
- sendError(extra.errorCode, {
- modifyResponseData: context.options.modifyResponseData
- });
- await goNext();
- 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)) {
- // eslint-disable-next-line guard-for-in
- 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")) {
- /** @type {import("fs").Stats | Buffer | ReadStream | undefined} */
- let value;
- // TODO cache etag generation?
- if (context.options.etag === "weak") {
- value = /** @type {import("fs").Stats} */extra.stats;
- } else {
- 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);
- value = result.bufferOrStream;
- ({
- bufferOrStream,
- byteLength
- } = result);
- } catch (error) {
- errorHandler( /** @type {NodeJS.ErrnoException} */error);
- await goNext();
- return;
- }
- }
- if (value) {
- // eslint-disable-next-line global-require
- const result = await require("./utils/etag")(value);
- // 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()) {
- sendError(412, {
- modifyResponseData: context.options.modifyResponseData
- });
- await goNext();
- 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);
- await goNext();
- 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));
- sendError(416, {
- headers: {
- "Content-Range": getResponseHeader(res, "Content-Range")
- },
- modifyResponseData: context.options.modifyResponseData
- });
- await goNext();
- 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) {
- errorHandler( /** @type {NodeJS.ErrnoException} */error);
- await goNext();
- 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);
- await goNext();
- return;
- }
- if (!isPartialContent) {
- setStatusCode(res, 200);
- }
- const isPipeSupports = typeof ( /** @type {import("fs").ReadStream} */bufferOrStream.pipe) === "function";
- if (!isPipeSupports) {
- send(res, /** @type {Buffer} */bufferOrStream);
- await goNext();
- 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);
- goNext();
- }).on(getReadyReadableStreamState(res), () => {
- goNext();
- });
- pipe(res, /** @type {ReadStream} */bufferOrStream);
- const outgoing = getOutgoing(res);
- if (outgoing) {
- // Response finished, cleanup
- onFinishedStream(outgoing, cleanup);
- }
- }
- ready(context, processRequest, req);
- };
- }
- module.exports = wrapper;
|