web: Packagify live reload plugin. (#14134)
* web: Packagify live reload plugin. * web: Use shared formatter. * web: Format. * web: Use project mode typecheck. * web: Fix type errors.
This commit is contained in:
		
							
								
								
									
										243
									
								
								web/packages/esbuild-plugin-live-reload/plugin/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										243
									
								
								web/packages/esbuild-plugin-live-reload/plugin/index.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,243 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @file Live reload plugin for ESBuild.
 | 
			
		||||
 *
 | 
			
		||||
 * @import { ListenOptions } from "node:net";
 | 
			
		||||
 * @import {Server as HTTPServer} from "node:http";
 | 
			
		||||
 * @import {Server as HTTPSServer} from "node:https";
 | 
			
		||||
 */
 | 
			
		||||
import { findFreePorts } from "find-free-ports";
 | 
			
		||||
import * as http from "node:http";
 | 
			
		||||
import * as path from "node:path";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Serializes a custom event to a text stream.
 | 
			
		||||
 * @param {Event} event
 | 
			
		||||
 * @returns {string}
 | 
			
		||||
 */
 | 
			
		||||
export function serializeCustomEventToStream(event) {
 | 
			
		||||
    // @ts-expect-error - TS doesn't know about the detail property
 | 
			
		||||
    const data = event.detail ?? {};
 | 
			
		||||
 | 
			
		||||
    const eventContent = [`event: ${event.type}`, `data: ${JSON.stringify(data)}`];
 | 
			
		||||
 | 
			
		||||
    return eventContent.join("\n") + "\n\n";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const MIN_PORT = 1025;
 | 
			
		||||
const MAX_PORT = 65535;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Find a random port that is not in use, sufficiently far from the default port.
 | 
			
		||||
 * @returns {Promise<number>}
 | 
			
		||||
 */
 | 
			
		||||
async function findDisparatePort() {
 | 
			
		||||
    const startPort = Math.floor(Math.random() * (MAX_PORT - MIN_PORT + 1)) + MIN_PORT;
 | 
			
		||||
 | 
			
		||||
    const wathcherPorts = await findFreePorts(1, {
 | 
			
		||||
        startPort,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const [port] = wathcherPorts;
 | 
			
		||||
 | 
			
		||||
    if (!port) {
 | 
			
		||||
        throw new Error("No free ports available");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return port;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Event server initialization options.
 | 
			
		||||
 *
 | 
			
		||||
 * @typedef {Object} EventServerInit
 | 
			
		||||
 *
 | 
			
		||||
 * @property {string} pathname
 | 
			
		||||
 * @property {EventTarget} dispatcher
 | 
			
		||||
 * @property {string} [logPrefix]
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @typedef {(req: http.IncomingMessage, res: http.ServerResponse) => void} RequestHandler
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Create an event request handler.
 | 
			
		||||
 * @param {EventServerInit} options
 | 
			
		||||
 * @returns {RequestHandler}
 | 
			
		||||
 * @category ESBuild
 | 
			
		||||
 */
 | 
			
		||||
export function createRequestHandler({ pathname, dispatcher, logPrefix = "Build Observer" }) {
 | 
			
		||||
    // eslint-disable-next-line no-console
 | 
			
		||||
    const log = console.log.bind(console, `[${logPrefix}]`);
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @type {RequestHandler}
 | 
			
		||||
     */
 | 
			
		||||
    const requestHandler = (req, res) => {
 | 
			
		||||
        res.setHeader("Access-Control-Allow-Origin", "*");
 | 
			
		||||
        res.setHeader("Access-Control-Allow-Methods", "GET");
 | 
			
		||||
        res.setHeader("Access-Control-Allow-Headers", "Content-Type");
 | 
			
		||||
 | 
			
		||||
        if (req.url !== pathname) {
 | 
			
		||||
            log(`🚫 Invalid request to ${req.url}`);
 | 
			
		||||
            res.writeHead(404);
 | 
			
		||||
            res.end();
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        log("🔌 Client connected");
 | 
			
		||||
 | 
			
		||||
        res.writeHead(200, {
 | 
			
		||||
            "Content-Type": "text/event-stream",
 | 
			
		||||
            "Cache-Control": "no-cache",
 | 
			
		||||
            "Connection": "keep-alive",
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        /**
 | 
			
		||||
         * @param {Event} event
 | 
			
		||||
         */
 | 
			
		||||
        const listener = (event) => {
 | 
			
		||||
            const body = serializeCustomEventToStream(event);
 | 
			
		||||
 | 
			
		||||
            res.write(body);
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        dispatcher.addEventListener("esbuild:start", listener);
 | 
			
		||||
        dispatcher.addEventListener("esbuild:error", listener);
 | 
			
		||||
        dispatcher.addEventListener("esbuild:end", listener);
 | 
			
		||||
 | 
			
		||||
        req.on("close", () => {
 | 
			
		||||
            log("🔌 Client disconnected");
 | 
			
		||||
 | 
			
		||||
            clearInterval(keepAliveInterval);
 | 
			
		||||
 | 
			
		||||
            dispatcher.removeEventListener("esbuild:start", listener);
 | 
			
		||||
            dispatcher.removeEventListener("esbuild:error", listener);
 | 
			
		||||
            dispatcher.removeEventListener("esbuild:end", listener);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const keepAliveInterval = setInterval(() => {
 | 
			
		||||
            console.timeStamp("🏓 Keep-alive");
 | 
			
		||||
 | 
			
		||||
            res.write("event: keep-alive\n\n");
 | 
			
		||||
            res.write(serializeCustomEventToStream(new CustomEvent("esbuild:keep-alive")));
 | 
			
		||||
        }, 15_000);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return requestHandler;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Options for the build observer plugin.
 | 
			
		||||
 *
 | 
			
		||||
 * @typedef {object} BuildObserverOptions
 | 
			
		||||
 *
 | 
			
		||||
 * @property {HTTPServer | HTTPSServer} [server]
 | 
			
		||||
 * @property {ListenOptions} [listenOptions]
 | 
			
		||||
 * @property {string | URL} [publicURL]
 | 
			
		||||
 * @property {string} [logPrefix]
 | 
			
		||||
 * @property {string} [relativeRoot]
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Creates a plugin that listens for build events and sends them to a server-sent event stream.
 | 
			
		||||
 *
 | 
			
		||||
 * @param {BuildObserverOptions} [options]
 | 
			
		||||
 * @returns {import('esbuild').Plugin}
 | 
			
		||||
 */
 | 
			
		||||
export function liveReloadPlugin(options = {}) {
 | 
			
		||||
    return {
 | 
			
		||||
        name: "build-watcher",
 | 
			
		||||
        setup: async (build) => {
 | 
			
		||||
            const logPrefix = options.logPrefix || "Build Observer";
 | 
			
		||||
 | 
			
		||||
            const timerLabel = `[${logPrefix}] 🏁`;
 | 
			
		||||
            const relativeRoot = options.relativeRoot || process.cwd();
 | 
			
		||||
 | 
			
		||||
            const dispatcher = new EventTarget();
 | 
			
		||||
 | 
			
		||||
            /**
 | 
			
		||||
             * @type {URL}
 | 
			
		||||
             */
 | 
			
		||||
            let publicURL;
 | 
			
		||||
 | 
			
		||||
            if (!options.publicURL) {
 | 
			
		||||
                const port = await findDisparatePort();
 | 
			
		||||
 | 
			
		||||
                publicURL = new URL(`http://localhost:${port}/events`);
 | 
			
		||||
            } else {
 | 
			
		||||
                publicURL =
 | 
			
		||||
                    typeof options.publicURL === "string"
 | 
			
		||||
                        ? new URL(options.publicURL)
 | 
			
		||||
                        : options.publicURL;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            build.initialOptions.define = {
 | 
			
		||||
                ...build.initialOptions.define,
 | 
			
		||||
                "import.meta.env.ESBUILD_WATCHER_URL": JSON.stringify(publicURL.href),
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            const requestHandler = createRequestHandler({
 | 
			
		||||
                pathname: publicURL.pathname,
 | 
			
		||||
                dispatcher,
 | 
			
		||||
                logPrefix,
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            const server = options.server || http.createServer(requestHandler);
 | 
			
		||||
 | 
			
		||||
            const listenOptions = options.listenOptions || {
 | 
			
		||||
                port: parseInt(publicURL.port, 10),
 | 
			
		||||
                host: publicURL.hostname,
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            server.listen(listenOptions, () => {
 | 
			
		||||
                // eslint-disable-next-line no-console
 | 
			
		||||
                console.log(`[${logPrefix}] Listening`);
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            build.onDispose(() => {
 | 
			
		||||
                server?.close();
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            build.onStart(() => {
 | 
			
		||||
                console.time(timerLabel);
 | 
			
		||||
 | 
			
		||||
                dispatcher.dispatchEvent(
 | 
			
		||||
                    new CustomEvent("esbuild:start", {
 | 
			
		||||
                        detail: new Date().toISOString(),
 | 
			
		||||
                    }),
 | 
			
		||||
                );
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            build.onEnd((buildResult) => {
 | 
			
		||||
                console.timeEnd(timerLabel);
 | 
			
		||||
 | 
			
		||||
                if (!buildResult.errors.length) {
 | 
			
		||||
                    dispatcher.dispatchEvent(
 | 
			
		||||
                        new CustomEvent("esbuild:end", {
 | 
			
		||||
                            detail: new Date().toISOString(),
 | 
			
		||||
                        }),
 | 
			
		||||
                    );
 | 
			
		||||
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                console.warn(`Build ended with ${buildResult.errors.length} errors`);
 | 
			
		||||
 | 
			
		||||
                dispatcher.dispatchEvent(
 | 
			
		||||
                    new CustomEvent("esbuild:error", {
 | 
			
		||||
                        detail: buildResult.errors.map((error) => ({
 | 
			
		||||
                            ...error,
 | 
			
		||||
                            location: error.location
 | 
			
		||||
                                ? {
 | 
			
		||||
                                      ...error.location,
 | 
			
		||||
                                      file: path.resolve(relativeRoot, error.location.file),
 | 
			
		||||
                                  }
 | 
			
		||||
                                : null,
 | 
			
		||||
                        })),
 | 
			
		||||
                    }),
 | 
			
		||||
                );
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user