webpack-dashboard
Version:
a CLI dashboard for webpack dev server
337 lines (297 loc) • 8.91 kB
JavaScript
/* eslint-disable max-params, max-statements */
;
const webpack = require("webpack");
const io = require("socket.io-client");
const inspectpack = require("inspectpack");
const serializer = require("../utils/error-serialization");
const DEFAULT_PORT = 9838;
const DEFAULT_HOST = "127.0.0.1";
const ONE_SECOND = 1000;
const INSPECTPACK_PROBLEM_ACTIONS = ["duplicates", "versions"];
const INSPECTPACK_PROBLEM_TYPE = "problems";
const CLEANUP_MAX_NUM_TRIES = 3; // Try 3 times to close before giving up.
const CLEANUP_RETRY_DELAY_MS = 100; // Delay before a retry.
function noop() {}
function getTimeMessage(timer) {
let time = Date.now() - timer;
if (time >= ONE_SECOND) {
time /= ONE_SECOND;
time = Math.round(time);
time += "s";
} else {
time += "ms";
}
return ` (${time})`;
}
// Naive camel-casing.
const camel = str => str.replace(/-([a-z])/, group => group[1].toUpperCase());
// Normalize webpack3 vs. 4 API differences.
function _webpackHook(hookType, compiler, event, callback) {
if (compiler.hooks) {
hookType = hookType || "tap";
compiler.hooks[camel(event)][hookType]("webpack-dashboard", callback);
} else {
compiler.plugin(event, callback);
}
}
const webpackHook = _webpackHook.bind(null, "tap");
const webpackAsyncHook = _webpackHook.bind(null, "tapAsync");
class DashboardPlugin {
constructor(options) {
if (typeof options === "function") {
this._handler = options;
} else {
options = options || {};
this.host = options.host || DEFAULT_HOST;
this.port = options.port || DEFAULT_PORT;
this.includeAssets = options.includeAssets || [];
this._handler = options.handler || null;
}
this.watching = false;
this.openMessages = 0;
}
handler(...args) {
if (this._handler) {
this._handler(...args);
}
}
cleanup(numTried = 0) {
if (!this._cleanedUp && !this.watching && this.socket) {
// Clear handler so we don't emit any more messages.
this._handler = null;
// Check if we have unhandled dashboard messages.
if (this.openMessages > 0 && numTried < CLEANUP_MAX_NUM_TRIES) {
// Wait a small interval and try again, up to a maximum.
setTimeout(() => this.cleanup(numTried++), CLEANUP_RETRY_DELAY_MS);
return;
}
// Close!
this._cleanedUp = true;
this.socket.close();
}
}
apply(compiler) {
// Reached compile "done" state.
let reachedDone = false;
// Compile has finished in "done", "error", "failed" states.
let finished = false;
let timer;
if (!this._handler) {
this._handler = noop;
const port = this.port;
const host = this.host;
this.socket = io(`http://${host}:${port}`);
this.socket.on("connect", () => {
// Manually track messages we send to the dashboard and decrement later.
const socketMsg = this.socket.emit.bind(this.socket, "message");
const ack = () => {
this.openMessages--;
};
this._handler = (...args) => {
this.openMessages++;
socketMsg(...args, ack);
};
});
this.socket.once("options", args => {
this.minimal = args.minimal;
this.includeAssets = this.includeAssets.concat(args.includeAssets || []);
});
this.socket.on("error", err => {
// eslint-disable-next-line no-console
console.log(err);
});
this.socket.on("disconnect", () => {
if (!reachedDone) {
// eslint-disable-next-line no-console
console.log("Socket.io disconnected before completing build lifecycle.");
}
});
}
new webpack.ProgressPlugin((percent, msg) => {
// Skip reporting once finished.
if (finished) {
return;
}
this.handler([
{
type: "status",
value: "Compiling"
},
{
type: "progress",
value: percent
},
{
type: "operations",
value: msg + getTimeMessage(timer)
}
]);
}).apply(compiler);
webpackAsyncHook(compiler, "watch-run", (c, done) => {
this.watching = true;
done();
});
webpackAsyncHook(compiler, "run", (c, done) => {
this.watching = false;
done();
});
webpackHook(compiler, "compile", () => {
timer = Date.now();
finished = false;
this.handler([
{
type: "status",
value: "Compiling"
}
]);
});
webpackHook(compiler, "invalid", () => {
finished = true;
this.handler([
{
type: "status",
value: "Invalidated"
},
{
type: "progress",
value: 0
},
{
type: "operations",
value: "idle"
},
{
type: "clear"
}
]);
});
webpackHook(compiler, "failed", () => {
finished = true;
this.handler([
{
type: "status",
value: "Failed"
},
{
type: "operations",
value: `idle${getTimeMessage(timer)}`
}
]);
});
webpackAsyncHook(compiler, "done", (stats, done) => {
const { errors, options } = stats.compilation;
const statsOptions = (options.devServer && options.devServer.stats) ||
options.stats || { colors: true };
const status = errors.length ? "Error" : "Success";
// We only need errors/warnings for stats information for finishing up.
// This allows us to avoid sending a full stats object to the CLI which
// can cause socket.io client disconnects for large objects.
// See: https://github.com/FormidableLabs/webpack-dashboard/issues/279
const statsJsonOptions = {
all: false,
errors: true,
warnings: true
};
reachedDone = true;
finished = true;
this.handler([
{
type: "status",
value: status
},
{
type: "progress",
value: 1
},
{
type: "operations",
value: `idle${getTimeMessage(timer)}`
},
{
type: "stats",
value: {
errors: stats.hasErrors(),
warnings: stats.hasWarnings(),
data: stats.toJson(statsJsonOptions)
}
},
{
type: "log",
value: stats.toString(statsOptions)
}
]);
// Skip metrics in minimal mode.
const getMetrics = () => (this.minimal ? Promise.resolve() : this.getMetrics(stats));
// eslint-disable-next-line promise/catch-or-return
getMetrics()
.then(datas => this.handler(datas))
.catch(err => {
console.log("Error from inspectpack:", err); // eslint-disable-line no-console
})
// eslint-disable-next-line promise/always-return
.then(() => {
this.cleanup();
done(); // eslint-disable-line promise/no-callback-in-promise
});
});
}
getMetrics(statsObj) {
// Get the **full** stats object here for `inspectpack` analysis.
const stats = statsObj.toJson({
source: true // Needed for webpack5+
});
// Truncate off non-included assets.
const { includeAssets } = this;
if (includeAssets.length) {
stats.assets = stats.assets.filter(({ name }) =>
includeAssets.some(pattern => {
if (typeof pattern === "string") {
return name.startsWith(pattern);
} else if (pattern instanceof RegExp) {
return pattern.test(name);
}
// Pass through bad options..
return false;
})
);
}
// Late destructure so that we can stub.
const { actions } = inspectpack;
const { serializeError } = serializer;
const getSizes = () =>
actions("sizes", { stats })
.then(instance => instance.getData())
.then(data => ({
type: "sizes",
value: data
}))
.catch(err => ({
type: "sizes",
error: true,
value: serializeError(err)
}));
const getProblems = () =>
Promise.all(
INSPECTPACK_PROBLEM_ACTIONS.map(action =>
actions(action, { stats }).then(instance => instance.getData())
)
)
.then(datas => ({
type: INSPECTPACK_PROBLEM_TYPE,
value: INSPECTPACK_PROBLEM_ACTIONS.reduce(
(memo, action, i) =>
Object.assign({}, memo, {
[action]: datas[i]
}),
{}
)
}))
.catch(err => ({
type: INSPECTPACK_PROBLEM_TYPE,
error: true,
value: serializeError(err)
}));
return Promise.all([getSizes(), getProblems()]);
}
}
module.exports = DashboardPlugin;