lazyCompilationBackend.js 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. /*
  2. MIT License http://www.opensource.org/licenses/mit-license.php
  3. Author Tobias Koppers @sokra
  4. */
  5. "use strict";
  6. /** @typedef {import("http").ServerOptions} HttpServerOptions */
  7. /** @typedef {import("https").ServerOptions} HttpsServerOptions */
  8. /** @import { RequestListener, Server as HttpServer } from "http" */
  9. /** @import { Server as HttpsServer } from "https" */
  10. /** @import { AddressInfo } from "net" */
  11. /** @import { BackendHandler } from "./LazyCompilationPlugin" */
  12. /**
  13. * @import {
  14. * LazyCompilationDefaultBackendOptions
  15. * } from "../../declarations/WebpackOptions"
  16. */
  17. /** @typedef {HttpServer | HttpsServer} Server */
  18. /** @typedef {(server: Server) => void} Listen */
  19. /** @typedef {() => Server} CreateServerFunction */
  20. /**
  21. * Returns backend.
  22. * @param {Omit<LazyCompilationDefaultBackendOptions, "client"> & { client: NonNullable<LazyCompilationDefaultBackendOptions["client"]> }} options additional options for the backend
  23. * @returns {BackendHandler} backend
  24. */
  25. module.exports = (options) => (compiler, callback) => {
  26. const logger = compiler.getInfrastructureLogger("LazyCompilationBackend");
  27. /** @type {Map<string, number>} */
  28. const activeModules = new Map();
  29. /** @type {Set<NodeJS.Timeout>} */
  30. const idleTimers = new Set();
  31. let isClosing = false;
  32. const prefix = "/lazy-compilation-using-";
  33. const isHttps =
  34. options.protocol === "https" ||
  35. (typeof options.server === "object" &&
  36. ("key" in options.server || "pfx" in options.server));
  37. /** @type {CreateServerFunction} */
  38. const createServer =
  39. typeof options.server === "function"
  40. ? options.server
  41. : (() => {
  42. const http = isHttps ? require("https") : require("http");
  43. return /** @type {(this: import("http") | import("https"), options: HttpServerOptions | HttpsServerOptions) => Server} */ (
  44. http.createServer
  45. ).bind(
  46. http,
  47. /** @type {HttpServerOptions | HttpsServerOptions} */
  48. (options.server)
  49. );
  50. })();
  51. /** @type {Listen} */
  52. const listen =
  53. typeof options.listen === "function"
  54. ? options.listen
  55. : (server) => {
  56. let listen = options.listen;
  57. if (typeof listen === "object" && !("port" in listen)) {
  58. listen = { ...listen, port: undefined };
  59. }
  60. server.listen(listen);
  61. };
  62. const protocol = options.protocol || (isHttps ? "https" : "http");
  63. /** @type {RequestListener} */
  64. const requestListener = (req, res) => {
  65. if (req.url === undefined) return;
  66. const keys = req.url.slice(prefix.length).split("@");
  67. req.socket.on("close", () => {
  68. // Shutting down: skip the idle-decrement timer entirely.
  69. if (isClosing) return;
  70. const timer = setTimeout(() => {
  71. idleTimers.delete(timer);
  72. for (const key of keys) {
  73. const oldValue = activeModules.get(key) || 0;
  74. // Drop the entry at zero so the map doesn't retain idle modules
  75. // and the count can't drift negative.
  76. if (oldValue <= 1) {
  77. activeModules.delete(key);
  78. if (oldValue === 1) {
  79. logger.log(
  80. `${key} is no longer in use. Next compilation will skip this module.`
  81. );
  82. }
  83. } else {
  84. activeModules.set(key, oldValue - 1);
  85. }
  86. }
  87. }, 120000);
  88. // Don't keep the process alive just to decrement idle counters.
  89. if (timer.unref) timer.unref();
  90. idleTimers.add(timer);
  91. });
  92. // Not all runtimes expose `setNoDelay` on the request socket (e.g. Deno's
  93. // HTTP server); it's only a latency optimization, so skip it when absent.
  94. if (req.socket.setNoDelay) req.socket.setNoDelay(true);
  95. res.writeHead(200, {
  96. "content-type": "text/event-stream",
  97. "Access-Control-Allow-Origin": "*",
  98. "Access-Control-Allow-Methods": "*",
  99. "Access-Control-Allow-Headers": "*"
  100. });
  101. res.write("\n");
  102. let moduleActivated = false;
  103. for (const key of keys) {
  104. const oldValue = activeModules.get(key) || 0;
  105. activeModules.set(key, oldValue + 1);
  106. if (oldValue === 0) {
  107. logger.log(`${key} is now in use and will be compiled.`);
  108. moduleActivated = true;
  109. }
  110. }
  111. if (moduleActivated && compiler.watching) compiler.watching.invalidate();
  112. };
  113. const server = createServer();
  114. server.on("request", requestListener);
  115. /** @type {Set<import("net").Socket>} */
  116. const sockets = new Set();
  117. server.on("connection", (socket) => {
  118. sockets.add(socket);
  119. socket.on("close", () => {
  120. sockets.delete(socket);
  121. });
  122. if (isClosing) socket.destroy();
  123. });
  124. server.on("clientError", (e) => {
  125. // A closing browser tab or a test tearing down aborts the request and
  126. // resets the socket; that surfaces here as ECONNRESET and is benign.
  127. if (
  128. e.message === "Server is disposing" ||
  129. /** @type {NodeJS.ErrnoException} */ (e).code === "ECONNRESET"
  130. ) {
  131. return;
  132. }
  133. logger.warn(e);
  134. });
  135. server.on(
  136. "listening",
  137. /**
  138. * Handles the callback logic for this hook.
  139. * @param {Error} err error
  140. * @returns {void}
  141. */
  142. (err) => {
  143. if (err) return callback(err);
  144. const _addr = server.address();
  145. if (typeof _addr === "string") {
  146. throw new Error("addr must not be a string");
  147. }
  148. const addr = /** @type {AddressInfo} */ (_addr);
  149. const urlBase =
  150. addr.address === "::" || addr.address === "0.0.0.0"
  151. ? `${protocol}://localhost:${addr.port}`
  152. : addr.family === "IPv6"
  153. ? `${protocol}://[${addr.address}]:${addr.port}`
  154. : `${protocol}://${addr.address}:${addr.port}`;
  155. logger.log(
  156. `Server-Sent-Events server for lazy compilation open at ${urlBase}.`
  157. );
  158. callback(null, {
  159. dispose(callback) {
  160. isClosing = true;
  161. for (const timer of idleTimers) clearTimeout(timer);
  162. idleTimers.clear();
  163. // Removing the listener is a workaround for a memory leak in node.js
  164. server.off("request", requestListener);
  165. for (const socket of sockets) {
  166. socket.destroy(new Error("Server is disposing"));
  167. }
  168. // Some runtimes (e.g. Deno) don't emit "connection", so `sockets`
  169. // misses the open SSE connections and `server.close` would hang;
  170. // force-close everything before waiting for it.
  171. if (server.closeAllConnections) server.closeAllConnections();
  172. server.close((err) => {
  173. // `closeAllConnections()` already stops the server on some runtimes
  174. // (e.g. Bun), so `close` then reports ERR_SERVER_NOT_RUNNING; the
  175. // server is closed either way, so that's not a dispose failure.
  176. callback(
  177. err &&
  178. /** @type {NodeJS.ErrnoException} */ (err).code !==
  179. "ERR_SERVER_NOT_RUNNING"
  180. ? err
  181. : null
  182. );
  183. });
  184. },
  185. module(originalModule) {
  186. const key = `${encodeURIComponent(
  187. originalModule.identifier().replace(/\\/g, "/").replace(/@/g, "_")
  188. ).replace(/%(2F|3A|24|26|2B|2C|3B|3D)/g, decodeURIComponent)}`;
  189. const active = /** @type {number} */ (activeModules.get(key)) > 0;
  190. return {
  191. client: `${options.client}?${encodeURIComponent(urlBase + prefix)}`,
  192. data: key,
  193. active
  194. };
  195. }
  196. });
  197. }
  198. );
  199. listen(server);
  200. };