"use strict"; const mime = require("mime-types"); const { validate } = require("schema-utils"); const middleware = require("./middleware"); const schema = require("./options.json"); const getFilenameFromUrl = require("./utils/getFilenameFromUrl"); const ready = require("./utils/ready"); const setupHooks = require("./utils/setupHooks"); const setupOutputFileSystem = require("./utils/setupOutputFileSystem"); const setupWriteToDisk = require("./utils/setupWriteToDisk"); const noop = () => {}; /** @typedef {import("schema-utils/declarations/validate").Schema} Schema */ /** @typedef {import("webpack").Compiler} Compiler */ /** @typedef {import("webpack").MultiCompiler} MultiCompiler */ /** @typedef {import("webpack").Configuration} Configuration */ /** @typedef {import("webpack").Stats} Stats */ /** @typedef {import("webpack").MultiStats} MultiStats */ /** @typedef {import("fs").ReadStream} ReadStream */ /** * @typedef {object} ExtendedServerResponse * @property {{ webpack?: { devMiddleware?: Context } }=} locals locals */ /** @typedef {import("http").IncomingMessage} IncomingMessage */ /** @typedef {import("http").ServerResponse & ExtendedServerResponse} ServerResponse */ // eslint-disable-next-line jsdoc/no-restricted-syntax /** * @callback NextFunction * @param {any=} err error * @returns {void} */ /** * @typedef {NonNullable} WatchOptions */ /** * @typedef {Compiler["watching"]} Watching */ /** * @typedef {ReturnType} MultiWatching */ /** * @typedef {import("webpack").OutputFileSystem & { createReadStream?: import("fs").createReadStream, statSync: import("fs").statSync, readFileSync: import("fs").readFileSync }} OutputFileSystem */ /** @typedef {ReturnType} Logger */ /** * @callback Callback * @param {(Stats | MultiStats)=} stats */ /** * @typedef {object} ResponseData * @property {Buffer | ReadStream} data data * @property {number} byteLength byte length */ /** * @template {IncomingMessage} [RequestInternal=IncomingMessage] * @template {ServerResponse} [ResponseInternal=ServerResponse] * @callback ModifyResponseData * @param {RequestInternal} req req * @param {ResponseInternal} res res * @param {Buffer | ReadStream} data data * @param {number} byteLength byte length * @returns {ResponseData} */ /** * @template {IncomingMessage} [RequestInternal=IncomingMessage] * @template {ServerResponse} [ResponseInternal=ServerResponse] * @typedef {object} Context * @property {boolean} state state * @property {Stats | MultiStats | undefined} stats stats * @property {Callback[]} callbacks callbacks * @property {Options} options options * @property {Compiler | MultiCompiler} compiler compiler * @property {Watching | MultiWatching | undefined} watching watching * @property {Logger} logger logger * @property {OutputFileSystem} outputFileSystem output file system */ /** * @template {IncomingMessage} [RequestInternal=IncomingMessage] * @template {ServerResponse} [ResponseInternal=ServerResponse] * @typedef {WithoutUndefined, "watching">} FilledContext */ /** @typedef {Record | Array<{ key: string, value: number | string }>} NormalizedHeaders */ /** * @template {IncomingMessage} [RequestInternal=IncomingMessage] * @template {ServerResponse} [ResponseInternal=ServerResponse] * @typedef {NormalizedHeaders | ((req: RequestInternal, res: ResponseInternal, context: Context) => void | undefined | NormalizedHeaders) | undefined} Headers */ /** * @template {IncomingMessage} [RequestInternal = IncomingMessage] * @template {ServerResponse} [ResponseInternal = ServerResponse] * @typedef {object} Options * @property {{ [key: string]: string }=} mimeTypes mime types * @property {(string | undefined)=} mimeTypeDefault mime type default * @property {(boolean | ((targetPath: string) => boolean))=} writeToDisk write to disk * @property {string[]=} methods methods * @property {Headers=} headers headers * @property {NonNullable["publicPath"]=} publicPath public path * @property {Configuration["stats"]=} stats stats * @property {boolean=} serverSideRender is server side render * @property {OutputFileSystem=} outputFileSystem output file system * @property {(boolean | string)=} index index * @property {ModifyResponseData=} modifyResponseData modify response data * @property {"weak" | "strong"=} etag options to generate etag header * @property {boolean=} lastModified options to generate last modified header * @property {(boolean | number | string | { maxAge?: number, immutable?: boolean })=} cacheControl options to generate cache headers * @property {boolean=} cacheImmutable is cache immutable */ /** * @template {IncomingMessage} [RequestInternal=IncomingMessage] * @template {ServerResponse} [ResponseInternal=ServerResponse] * @callback Middleware * @param {RequestInternal} req * @param {ResponseInternal} res * @param {NextFunction} next * @returns {Promise} */ /** @typedef {import("./utils/getFilenameFromUrl").Extra} Extra */ /** * @callback GetFilenameFromUrl * @param {string} url * @param {Extra=} extra * @returns {string | undefined} */ /** * @callback WaitUntilValid * @param {Callback} callback */ /** * @callback Invalidate * @param {Callback} callback */ /** * @callback Close * @param {(err: Error | null | undefined) => void} callback */ /** * @template {IncomingMessage} RequestInternal * @template {ServerResponse} ResponseInternal * @typedef {object} AdditionalMethods * @property {GetFilenameFromUrl} getFilenameFromUrl get filename from url * @property {WaitUntilValid} waitUntilValid wait until valid * @property {Invalidate} invalidate invalidate * @property {Close} close close * @property {Context} context context */ /** * @template {IncomingMessage} [RequestInternal=IncomingMessage] * @template {ServerResponse} [ResponseInternal=ServerResponse] * @typedef {Middleware & AdditionalMethods} API */ /** * @template T * @template {keyof T} K * @typedef {Omit & Partial} WithOptional */ /** * @template T * @template {keyof T} K * @typedef {T & { [P in K]: NonNullable }} WithoutUndefined */ /** * @template {IncomingMessage} [RequestInternal=IncomingMessage] * @template {ServerResponse} [ResponseInternal=ServerResponse] * @param {Compiler | MultiCompiler} compiler compiler * @param {Options=} options options * @returns {API} webpack dev middleware */ function wdm(compiler, options = {}) { validate(/** @type {Schema} */schema, options, { name: "Dev Middleware", baseDataPath: "options" }); const { mimeTypes } = options; if (mimeTypes) { const { types } = mime; // mimeTypes from user provided options should take priority // over existing, known types // @ts-expect-error mime.types = { ...types, ...mimeTypes }; } /** * @type {WithOptional, "watching" | "outputFileSystem">} */ const context = { state: false, stats: undefined, callbacks: [], options, compiler, logger: compiler.getInfrastructureLogger("webpack-dev-middleware") }; setupHooks(context); if (options.writeToDisk) { setupWriteToDisk(context); } setupOutputFileSystem(context); // Start watching if (/** @type {Compiler} */context.compiler.watching) { context.watching = /** @type {Compiler} */context.compiler.watching; } else { /** * @param {Error | null | undefined} error error */ const errorHandler = error => { if (error) { // TODO: improve that in future // For example - `writeToDisk` can throw an error and right now it is ends watching. // We can improve that and keep watching active, but it is require API on webpack side. // Let's implement that in webpack@5 because it is rare case. context.logger.error(error); } }; if (Array.isArray(/** @type {MultiCompiler} */context.compiler.compilers)) { const compilers = /** @type {MultiCompiler} */context.compiler; const watchOptions = compilers.compilers.map(childCompiler => childCompiler.options.watchOptions || {}); context.watching = compiler.watch(watchOptions, errorHandler); } else { const oneCompiler = /** @type {Compiler} */context.compiler; const watchOptions = oneCompiler.options.watchOptions || {}; context.watching = compiler.watch(watchOptions, errorHandler); } } const filledContext = /** @type {FilledContext} */ context; const instance = /** @type {API} */ middleware(filledContext); // API instance.getFilenameFromUrl = (url, extra) => getFilenameFromUrl(filledContext, url, extra); instance.waitUntilValid = (callback = noop) => { ready(filledContext, callback); }; instance.invalidate = (callback = noop) => { ready(filledContext, callback); filledContext.watching.invalidate(); }; instance.close = (callback = noop) => { filledContext.watching.close(callback); }; instance.context = filledContext; return instance; } /** * @template S * @template O * @typedef {object} HapiPluginBase * @property {(server: S, options: O) => void | Promise} register register */ /** * @template S * @template O * @typedef {HapiPluginBase & { pkg: { name: string }, multiple: boolean }} HapiPlugin */ /** * @typedef {Options & { compiler: Compiler | MultiCompiler }} HapiOptions */ /** * @template HapiServer * @template {HapiOptions} HapiOptionsInternal * @returns {HapiPlugin} hapi wrapper */ function hapiWrapper() { return { pkg: { name: "webpack-dev-middleware" }, // Allow to have multiple middleware multiple: true, register(server, options) { const { compiler, ...rest } = options; if (!compiler) { throw new Error("The compiler options is required."); } const devMiddleware = wdm(compiler, rest); // @ts-expect-error if (!server.decorations.server.includes("webpackDevMiddleware")) { // @ts-expect-error server.decorate("server", "webpackDevMiddleware", devMiddleware); } // @ts-expect-error // eslint-disable-next-line id-length server.ext("onRequest", (request, h) => new Promise((resolve, reject) => { let isFinished = false; /** * @param {(string | Buffer)=} data */ request.raw.res.send = data => { isFinished = true; request.raw.res.end(data); }; /** * @param {(string | Buffer)=} data */ request.raw.res.finish = data => { isFinished = true; request.raw.res.end(data); }; devMiddleware(request.raw.req, request.raw.res, error => { if (error) { reject(error); return; } if (!isFinished) { resolve(request); } }); }).then(() => h.continue).catch(error => { throw error; })); } }; } wdm.hapiWrapper = hapiWrapper; // eslint-disable-next-line jsdoc/no-restricted-syntax /** * @template {IncomingMessage} [RequestInternal=IncomingMessage] * @template {ServerResponse} [ResponseInternal=ServerResponse] * @param {Compiler | MultiCompiler} compiler compiler * @param {Options=} options options * @returns {(ctx: any, next: Function) => Promise | void} kow wrapper */ function koaWrapper(compiler, options) { const devMiddleware = wdm(compiler, options); // eslint-disable-next-line jsdoc/no-restricted-syntax /** * @param {{req: RequestInternal, res: ResponseInternal & import("./utils/compatibleAPI").ExpectedServerResponse, status: number, body: string | Buffer | import("fs").ReadStream | {message: string}, state: object}} ctx context * @param {Function} next next * @returns {Promise} */ async function webpackDevMiddleware(ctx, next) { const { req, res } = ctx; res.locals = ctx.state; let { status } = ctx; /** * @returns {number} code */ res.getStatusCode = () => status; /** * @param {number} statusCode status code */ res.setStatusCode = statusCode => { status = statusCode; ctx.status = statusCode; }; let isFinished = false; let needNext = false; try { await new Promise( /** * @param {(value: void) => void} resolve resolve * @param {(reason?: Error) => void} reject reject */ (resolve, reject) => { /** * @param {import("fs").ReadStream} stream readable stream */ res.stream = stream => { ctx.body = stream; isFinished = true; resolve(); }; /** * @param {string | Buffer} data data */ res.send = data => { ctx.body = data; isFinished = true; resolve(); }; /** * @param {(string | Buffer)=} data data */ res.finish = data => { ctx.status = status; res.end(data); isFinished = true; resolve(); }; devMiddleware(req, res, err => { if (err) { reject(err); return; } needNext = true; if (!isFinished) { resolve(); } }); }); } catch (err) { ctx.status = /** @type {Error & { statusCode: number }} */err.statusCode || /** @type {Error & { status: number }} */err.status || 500; ctx.body = { message: /** @type {Error} */err.message }; } if (needNext) { await next(); } } webpackDevMiddleware.devMiddleware = devMiddleware; return webpackDevMiddleware; } wdm.koaWrapper = koaWrapper; // eslint-disable-next-line jsdoc/no-restricted-syntax /** * @template {IncomingMessage} [RequestInternal=IncomingMessage] * @template {ServerResponse} [ResponseInternal=ServerResponse] * @param {Compiler | MultiCompiler} compiler compiler * @param {Options=} options options * @returns {(ctx: any, next: Function) => Promise | void} hono wrapper */ function honoWrapper(compiler, options) { const devMiddleware = wdm(compiler, options); // eslint-disable-next-line jsdoc/no-restricted-syntax /** * @param {{ env: any, body: any, json: any, status: any, set: any, req: RequestInternal & import("./utils/compatibleAPI").ExpectedIncomingMessage & { header: (name: string) => string }, res: ResponseInternal & import("./utils/compatibleAPI").ExpectedServerResponse & { headers: any, status: any } }} context context * @param {Function} next next function * @returns {Promise} */ async function webpackDevMiddleware(context, next) { const { req, res } = context; context.set("webpack", { devMiddleware: devMiddleware.context }); /** * @returns {string | undefined} method */ req.getMethod = () => context.req.method; /** * @param {string} name name * @returns {string | string[] | undefined} header value */ req.getHeader = name => context.req.header(name); /** * @returns {string | undefined} URL */ req.getURL = () => context.req.url; let { status } = context.res; /** * @returns {number} code code */ res.getStatusCode = () => status; /** * @param {number} code code */ res.setStatusCode = code => { status = code; }; /** * @param {string} name header name * @returns {string | string[] | undefined} header */ res.getHeader = name => context.res.headers.get(name); // eslint-disable-next-line jsdoc/no-restricted-syntax /** * @param {string} name header name * @param {string | number | Readonly} value value * @returns {ResponseInternal & import("./utils/compatibleAPI").ExpectedServerResponse & { headers: any, status: any }} response */ res.setHeader = (name, value) => { context.res.headers.append(name, value); return context.res; }; /** * @param {string} name header name */ res.removeHeader = name => { context.res.headers.delete(name); }; /** * @returns {string[]} response headers */ res.getResponseHeaders = () => [...context.res.headers.keys()]; /** * @returns {ServerResponse} server response */ res.getOutgoing = () => context.env.outgoing; res.setState = () => { // Do nothing, because we set it before }; res.getHeadersSent = () => context.env.outgoing.headersSent; let body; let isFinished = false; try { await new Promise( /** * @param {(value: void) => void} resolve resolve * @param {(reason?: Error) => void} reject reject */ (resolve, reject) => { /** * @param {import("fs").ReadStream} stream readable stream */ res.stream = stream => { body = stream; isFinished = true; resolve(); }; /** * @param {string | Buffer} data data */ res.send = data => { // Hono sets `Content-Length` by default context.res.headers.delete("Content-Length"); body = data; isFinished = true; resolve(); }; /** * @param {(string | Buffer)=} data data */ res.finish = data => { const isDataExist = typeof data !== "undefined"; // Hono sets `Content-Length` by default if (isDataExist) { context.res.headers.delete("Content-Length"); } body = isDataExist ? data : null; isFinished = true; resolve(); }; devMiddleware(req, res, err => { if (err) { reject(err); return; } if (!isFinished) { resolve(); } }); }); } catch (err) { context.status(500); return context.json({ message: /** @type {Error} */err.message }); } if (typeof body !== "undefined") { return context.body(body, status); } await next(); } webpackDevMiddleware.devMiddleware = devMiddleware; return webpackDevMiddleware; } wdm.honoWrapper = honoWrapper; module.exports = wdm;