@hippy/debug-server-next
Version:
Debug server for hippy.
1,637 lines (1,361 loc) • 55.4 kB
JavaScript
/**
* copy form webpack-dev-server, modify to support Hippy HMR
*/
'use strict';
const os = require('os');
const path = require('path');
const url = require('url');
const util = require('util');
const fs = require('graceful-fs');
const ipaddr = require('ipaddr.js');
const express = require('express');
const { validate } = require('schema-utils');
const WebSocket = require('ws');
const { get } = require('lodash');
const colors = require('colors/safe');
const { HMREvent, GatewayFamily } = require('@debug-server-next/@types/enum');
const { encodeHMRData } = require('@debug-server-next/utils/buffer');
const { getWSProtocolByHttpProtocol, makeUrl } = require('@debug-server-next/utils/url');
const { saveDevPort, injectEntry } = require('@debug-server-next/utils/webpack');
const { internalIP, internalIPSync } = require('@debug-server-next/utils/ip');
const { startAdbProxy } = require('@debug-server-next/child-process/adb');
const schema = require('./options.json');
if (!process.env.WEBPACK_SERVE) {
process.env.WEBPACK_SERVE = true;
}
class Server {
constructor(options = {}, compiler, cb) {
// TODO: remove this after plugin support is published
if (options.hooks) {
util.deprecate(
() => {},
"Using 'compiler' as the first argument is deprecated. Please use 'options' as the first argument and 'compiler' as the second argument.",
'DEP_WEBPACK_DEV_SERVER_CONSTRUCTOR',
)();
[options = {}, compiler] = [compiler, options];
}
validate(schema, options, 'webpack Dev Server');
this.options = options;
this.staticWatchers = [];
this.listeners = [];
// Keep track of websocket proxies for external websocket upgrade.
this.webSocketProxies = [];
this.sockets = [];
this.compiler = compiler;
this.currentHash = null;
this.cb = cb || (() => {});
this.msgQueue = [];
this.hadSyncBundleResource = false;
}
static get DEFAULT_STATS() {
return {
all: false,
hash: true,
warnings: true,
errors: true,
errorDetails: false,
};
}
static isAbsoluteURL(URL) {
// Don't match Windows paths `c:\`
if (/^[a-zA-Z]:\\/.test(URL)) {
return false;
}
// Scheme: https://tools.ietf.org/html/rfc3986#section-3.1
// Absolute URL: https://tools.ietf.org/html/rfc3986#section-4.3
return /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(URL);
}
static async getHostname(hostname) {
if (hostname === 'local-ip') {
return (await internalIP(GatewayFamily.V4)) || (await internalIP(GatewayFamily.V6)) || '0.0.0.0';
}
if (hostname === 'local-ipv4') {
return (await internalIP(GatewayFamily.V4)) || '0.0.0.0';
}
if (hostname === 'local-ipv6') {
return (await internalIP(GatewayFamily.V6)) || '::';
}
return hostname || 'localhost';
}
static async getFreePort(port) {
if (typeof port !== 'undefined' && port !== null && port !== 'auto') {
return port;
}
const pRetry = require('p-retry');
const portfinder = require('portfinder');
portfinder.basePort = process.env.WEBPACK_DEV_SERVER_BASE_PORT || 39000;
// Try to find unused port and listen on it for 3 times,
// if port is not specified in options.
const defaultPortRetry = parseInt(process.env.WEBPACK_DEV_SERVER_PORT_RETRY, 10) || 3;
return pRetry(() => portfinder.getPortPromise(), {
retries: defaultPortRetry,
});
}
static findCacheDir() {
const cwd = process.cwd();
let dir = cwd;
for (;;) {
try {
if (fs.statSync(path.join(dir, 'package.json')).isFile()) break;
// eslint-disable-next-line no-empty
} catch (e) {}
const parent = path.dirname(dir);
if (dir === parent) {
// eslint-disable-next-line no-undefined
dir = undefined;
break;
}
dir = parent;
}
if (!dir) {
return path.resolve(cwd, '.cache/webpack-dev-server');
}
if (process.versions.pnp === '1') {
return path.resolve(dir, '.pnp/.cache/webpack-dev-server');
}
if (process.versions.pnp === '3') {
return path.resolve(dir, '.yarn/.cache/webpack-dev-server');
}
return path.resolve(dir, 'node_modules/.cache/webpack-dev-server');
}
addAdditionalEntries(compiler) {
if (!this.options.remote) return;
const { appendEntries: hmrAppendEntries, prependEntries: hmrPrependEntries } = this.addHMREntries(compiler);
const { appendEntries: vueAppendEntries, prependEntries: vuePrependEntries } = this.addVueDevtoolsEntries();
const { appendEntries: reactAppendEntries, prependEntries: reactPrependEntries } = this.addReactDevtoolsEntries();
const { appendEntries: jsAppendEntries, prependEntries: jsPrependEntries } = this.addVanillaJSDevtoolsEntries();
// must ensure correct inject sequence, because the append entries depend on the prepend and original entries.
injectEntry(
compiler,
undefined,
[...hmrPrependEntries, ...vuePrependEntries, ...reactPrependEntries, ...jsPrependEntries],
[...hmrAppendEntries, ...vueAppendEntries, ...reactAppendEntries, ...jsAppendEntries],
);
}
addHMREntries(compiler) {
const appendEntries = [];
const prependEntries = [];
if(!this.options.hot && !this.options.liveReload) return { appendEntries, prependEntries };
const isWebTarget = compiler.options.externalsPresets
? compiler.options.externalsPresets.web
: [
'web',
'webworker',
'electron-preload',
'electron-renderer',
'node-webkit',
// eslint-disable-next-line no-undefined
undefined,
null,
].includes(compiler.options.target);
// TODO maybe empty empty client
if (this.options.client && isWebTarget) {
let webSocketURL = '';
const { host, port, protocol } = this.options.remote;
const searchParams = new URLSearchParams();
searchParams.set('protocol', `${getWSProtocolByHttpProtocol(protocol)}:`);
searchParams.set('hostname', host);
searchParams.set('port', String(port));
searchParams.set('pathname', '/debugger-proxy');
searchParams.set('role', 'hmr_client');
searchParams.set('hash', this.options.id);
if (typeof this.options.client.logging !== 'undefined') {
searchParams.set('logging', this.options.client.logging);
}
searchParams.set('hot', Boolean(this.options.hot));
searchParams.set('liveReload', Boolean(this.options.liveReload));
searchParams.set('progress', Boolean(this.options.client.progress));
searchParams.set('overlay', false);
searchParams.set('reconnect', this.options.client.reconnect || 10);
webSocketURL = searchParams.toString();
appendEntries.push(`${require.resolve('../client/index.js')}?${webSocketURL}`);
}
if (this.options.hot) {
let hotEntry;
if (this.options.hot === 'only') {
hotEntry = require.resolve('../client/hot/only-dev-server');
} else if (this.options.hot) {
hotEntry = require.resolve('../client/hot/dev-server');
}
appendEntries.push(hotEntry);
}
return { appendEntries, prependEntries };
}
addVueDevtoolsEntries() {
if (!this.options.vueDevtools) return { appendEntries: [], prependEntries: [] };
const { host, port, protocol } = this.options.remote;
const vueBackend = makeUrl(require.resolve('@hippy/hippy-vue-devtools-plugin/lib/backend'), {
host,
port,
protocol,
});
const vueHook = require.resolve('@hippy/hippy-vue-devtools-plugin/lib/hook');
return {
appendEntries: [vueBackend],
prependEntries: [vueHook],
};
}
addReactDevtoolsEntries() {
if (!this.options.reactDevtools) return { appendEntries: [], prependEntries: [] };
const { host, port, protocol } = this.options.remote;
const reactBackend = makeUrl(require.resolve('@hippy/hippy-react-devtools-plugin/lib/backend'), {
host,
port,
protocol,
});
return {
appendEntries: [],
prependEntries: [reactBackend],
};
}
addVanillaJSDevtoolsEntries() {
if (!this.options.injectJSDevtools) return { appendEntries: [], prependEntries: [] };
const { domains } = this.options.injectJSDevtools;
const { host, port, protocol } = this.options.remote;
const vanillaJSBackend = makeUrl(require.resolve('@hippy/vanilla-js-devtools/dist/index.js'), {
domains: JSON.stringify(domains),
host,
port,
protocol,
});
return {
appendEntries: [],
prependEntries: [vanillaJSBackend],
};
}
getCompilerOptions() {
if (typeof this.compiler.compilers !== 'undefined') {
if (this.compiler.compilers.length === 1) {
return this.compiler.compilers[0].options;
}
// Configuration with the `devServer` options
const compilerWithDevServer = this.compiler.compilers.find((config) => config.options.devServer);
if (compilerWithDevServer) {
return compilerWithDevServer.options;
}
// Configuration with `web` preset
const isTarget = (config) =>
[
'web',
'webworker',
'electron-preload',
'electron-renderer',
'node-webkit',
// eslint-disable-next-line no-undefined
undefined,
null,
].includes(config.options.target);
const compilerWithWebPreset = this.compiler.compilers.find(
(config) => (config.options.externalsPresets && config.options.externalsPresets.web) || isTarget(config),
);
if (compilerWithWebPreset) {
return compilerWithWebPreset.options;
}
// Fallback
return this.compiler.compilers[0].options;
}
return this.compiler.options;
}
async normalizeOptions() {
const { options } = this;
if (!this.logger) {
this.logger = this.compiler.getInfrastructureLogger('webpack-dev-server');
}
const compilerOptions = this.getCompilerOptions();
// TODO remove `{}` after drop webpack v4 support
const compilerWatchOptions = compilerOptions.watchOptions || {};
const getWatchOptions = (watchOptions = {}) => {
const getPolling = () => {
if (typeof watchOptions.usePolling !== 'undefined') {
return watchOptions.usePolling;
}
if (typeof watchOptions.poll !== 'undefined') {
return Boolean(watchOptions.poll);
}
if (typeof compilerWatchOptions.poll !== 'undefined') {
return Boolean(compilerWatchOptions.poll);
}
return false;
};
const getInterval = () => {
if (typeof watchOptions.interval !== 'undefined') {
return watchOptions.interval;
}
if (typeof watchOptions.poll === 'number') {
return watchOptions.poll;
}
if (typeof compilerWatchOptions.poll === 'number') {
return compilerWatchOptions.poll;
}
};
const usePolling = getPolling();
const interval = getInterval();
const { poll, ...rest } = watchOptions;
return {
ignoreInitial: true,
persistent: true,
followSymlinks: false,
atomic: false,
alwaysStat: true,
ignorePermissionErrors: true,
// Respect options from compiler watchOptions
usePolling,
interval,
ignored: watchOptions.ignored,
// TODO: we respect these options for all watch options and allow
// developers to pass them to chokidar, but chokidar doesn't have
// these options maybe we need revisit that in future
...rest,
};
};
const getStaticItem = (optionsForStatic) => {
const getDefaultStaticOptions = () => ({
directory: path.join(process.cwd(), 'public'),
staticOptions: {},
publicPath: ['/'],
serveIndex: { icons: true },
watch: getWatchOptions(),
});
let item;
if (typeof optionsForStatic === 'undefined') {
item = getDefaultStaticOptions();
} else if (typeof optionsForStatic === 'string') {
item = {
...getDefaultStaticOptions(),
directory: optionsForStatic,
};
} else {
const def = getDefaultStaticOptions();
item = {
directory: typeof optionsForStatic.directory !== 'undefined' ? optionsForStatic.directory : def.directory,
// TODO: do merge in the next major release
staticOptions:
typeof optionsForStatic.staticOptions !== 'undefined' ? optionsForStatic.staticOptions : def.staticOptions,
publicPath: typeof optionsForStatic.publicPath !== 'undefined' ? optionsForStatic.publicPath : def.publicPath,
// TODO: do merge in the next major release
serveIndex:
// eslint-disable-next-line no-nested-ternary
typeof optionsForStatic.serveIndex !== 'undefined'
? typeof optionsForStatic.serveIndex === 'boolean' && optionsForStatic.serveIndex
? def.serveIndex
: optionsForStatic.serveIndex
: def.serveIndex,
watch:
// eslint-disable-next-line no-nested-ternary
typeof optionsForStatic.watch !== 'undefined'
? typeof optionsForStatic.watch === 'boolean'
? optionsForStatic.watch
? def.watch
: false
: getWatchOptions(optionsForStatic.watch)
: def.watch,
};
}
if (Server.isAbsoluteURL(item.directory)) {
throw new Error('Using a URL as static.directory is not supported');
}
// ensure that publicPath is an array
if (typeof item.publicPath === 'string') {
item.publicPath = [item.publicPath];
}
return item;
};
if (typeof options.allowedHosts === 'undefined') {
// AllowedHosts allows some default hosts picked from `options.host` or `webSocketURL.hostname` and `localhost`
options.allowedHosts = 'auto';
} else if (
typeof options.allowedHosts === 'string' &&
options.allowedHosts !== 'auto' &&
options.allowedHosts !== 'all'
) {
// We store allowedHosts as array when supplied as string
options.allowedHosts = [options.allowedHosts];
} else if (Array.isArray(options.allowedHosts) && options.allowedHosts.includes('all')) {
// CLI pass options as array, we should normalize them
options.allowedHosts = 'all';
}
if (typeof options.bonjour === 'undefined') {
options.bonjour = false;
} else if (typeof options.bonjour === 'boolean') {
options.bonjour = options.bonjour ? {} : false;
}
if (typeof options.client === 'undefined' || (typeof options.client === 'object' && options.client !== null)) {
if (!options.client) {
options.client = {};
}
// Disable client overlay by default
if (typeof options.client.overlay === 'undefined') {
options.client.overlay = false;
} else if (typeof options.client.overlay !== 'boolean') {
options.client.overlay = {
errors: true,
warnings: true,
...options.client.overlay,
};
}
if (typeof options.client.reconnect === 'undefined') {
options.client.reconnect = 10;
} else if (options.client.reconnect === true) {
options.client.reconnect = Infinity;
} else if (options.client.reconnect === false) {
options.client.reconnect = 0;
}
// Respect infrastructureLogging.level
if (typeof options.client.logging === 'undefined') {
options.client.logging = compilerOptions.infrastructureLogging
? compilerOptions.infrastructureLogging.level
: 'info';
}
}
if (typeof options.compress === 'undefined') {
options.compress = true;
}
if (typeof options.devMiddleware === 'undefined') {
options.devMiddleware = {};
}
// No need to normalize `headers`
if (typeof options.historyApiFallback === 'undefined') {
options.historyApiFallback = false;
} else if (typeof options.historyApiFallback === 'boolean' && options.historyApiFallback) {
options.historyApiFallback = {};
}
// No need to normalize `host`
options.hot = typeof options.hot === 'boolean' || options.hot === 'only' ? options.hot : true;
const isHTTPs = Boolean(options.https);
const isSPDY = Boolean(options.http2);
if (isHTTPs || isSPDY) {
// TODO: remove in the next major release
util.deprecate(
() => {},
`'${isHTTPs ? 'https' : 'http2'}' option is deprecated. Please use the 'server' option.`,
`DEP_WEBPACK_DEV_SERVER_${isHTTPs ? 'HTTPS' : 'HTTP2'}`,
)();
}
options.server = {
type:
// eslint-disable-next-line no-nested-ternary
typeof options.server === 'string'
? options.server
: typeof (options.server || {}).type === 'string'
? options.server.type
: isSPDY
? 'spdy'
: isHTTPs
? 'https'
: 'http',
options: {
...options.https,
...(options.server || {}).options,
},
};
if (options.server.type === 'spdy' && typeof options.server.options.spdy === 'undefined') {
options.server.options.spdy = {
protocols: ['h2', 'http/1.1'],
};
}
if (options.server.type === 'https' || options.server.type === 'spdy') {
if (typeof options.server.options.requestCert === 'undefined') {
options.server.options.requestCert = false;
}
// TODO remove the `cacert` option in favor `ca` in the next major release
for (const property of ['cacert', 'ca', 'cert', 'crl', 'key', 'pfx']) {
if (typeof options.server.options[property] === 'undefined') {
// eslint-disable-next-line no-continue
continue;
}
const value = options.server.options[property];
const readFile = (item) => {
if (Buffer.isBuffer(item) || (typeof item === 'object' && item !== null && !Array.isArray(item))) {
return item;
}
if (item) {
let stats = null;
try {
stats = fs.lstatSync(fs.realpathSync(item)).isFile();
} catch (error) {
// Ignore error
}
// It is file
return stats ? fs.readFileSync(item) : item;
}
};
options.server.options[property] = Array.isArray(value) ? value.map((item) => readFile(item)) : readFile(value);
}
let fakeCert;
if (!options.server.options.key || !options.server.options.cert) {
const certificateDir = Server.findCacheDir();
const certificatePath = path.join(certificateDir, 'server.pem');
let certificateExists;
try {
const certificate = await fs.promises.stat(certificatePath);
certificateExists = certificate.isFile();
} catch {
certificateExists = false;
}
if (certificateExists) {
const certificateTtl = 1000 * 60 * 60 * 24;
const certificateStat = await fs.promises.stat(certificatePath);
const now = new Date();
// cert is more than 30 days old, kill it with fire
if ((now - certificateStat.ctime) / certificateTtl > 30) {
const del = require('del');
this.logger.info('SSL certificate is more than 30 days old. Removing...');
await del([certificatePath], { force: true });
certificateExists = false;
}
}
if (!certificateExists) {
this.logger.info('Generating SSL certificate...');
const selfsigned = require('selfsigned');
const attributes = [{ name: 'commonName', value: 'localhost' }];
const pems = selfsigned.generate(attributes, {
algorithm: 'sha256',
days: 30,
keySize: 2048,
extensions: [
{
name: 'basicConstraints',
cA: true,
},
{
name: 'keyUsage',
keyCertSign: true,
digitalSignature: true,
nonRepudiation: true,
keyEncipherment: true,
dataEncipherment: true,
},
{
name: 'extKeyUsage',
serverAuth: true,
clientAuth: true,
codeSigning: true,
timeStamping: true,
},
{
name: 'subjectAltName',
altNames: [
{
// type 2 is DNS
type: 2,
value: 'localhost',
},
{
type: 2,
value: 'localhost.localdomain',
},
{
type: 2,
value: 'lvh.me',
},
{
type: 2,
value: '*.lvh.me',
},
{
type: 2,
value: '[::1]',
},
{
// type 7 is IP
type: 7,
ip: '127.0.0.1',
},
{
type: 7,
ip: 'fe80::1',
},
],
},
],
});
await fs.promises.mkdir(certificateDir, { recursive: true });
await fs.promises.writeFile(certificatePath, pems.private + pems.cert, {
encoding: 'utf8',
});
}
fakeCert = await fs.promises.readFile(certificatePath);
this.logger.info(`SSL certificate: ${certificatePath}`);
}
if (options.server.options.cacert) {
if (options.server.options.ca) {
this.logger.warn("Do not specify 'ca' and 'cacert' options together, the 'ca' option will be used.");
} else {
options.server.options.ca = options.server.options.cacert;
}
delete options.server.options.cacert;
}
options.server.options.key = options.server.options.key || fakeCert;
options.server.options.cert = options.server.options.cert || fakeCert;
}
options.liveReload = typeof options.liveReload !== 'undefined' ? options.liveReload : true;
// https://github.com/webpack/webpack-dev-server/issues/1990
const defaultOpenOptions = { wait: false };
const getOpenItemsFromObject = ({ target, ...rest }) => {
const normalizedOptions = { ...defaultOpenOptions, ...rest };
if (typeof normalizedOptions.app === 'string') {
normalizedOptions.app = {
name: normalizedOptions.app,
};
}
const normalizedTarget = typeof target === 'undefined' ? '<url>' : target;
if (Array.isArray(normalizedTarget)) {
return normalizedTarget.map((singleTarget) => ({ target: singleTarget, options: normalizedOptions }));
}
return [{ target: normalizedTarget, options: normalizedOptions }];
};
if (typeof options.open === 'undefined') {
options.open = [];
} else if (typeof options.open === 'boolean') {
options.open = options.open ? [{ target: '<url>', options: defaultOpenOptions }] : [];
} else if (typeof options.open === 'string') {
options.open = [{ target: options.open, options: defaultOpenOptions }];
} else if (Array.isArray(options.open)) {
const result = [];
options.open.forEach((item) => {
if (typeof item === 'string') {
result.push({ target: item, options: defaultOpenOptions });
return;
}
result.push(...getOpenItemsFromObject(item));
});
options.open = result;
} else {
options.open = [...getOpenItemsFromObject(options.open)];
}
if (typeof options.port === 'string' && options.port !== 'auto') {
options.port = Number(options.port);
}
if (typeof options.setupExitSignals === 'undefined') {
options.setupExitSignals = true;
}
if (typeof options.static === 'undefined') {
options.static = [getStaticItem()];
} else if (typeof options.static === 'boolean') {
options.static = options.static ? [getStaticItem()] : false;
} else if (typeof options.static === 'string') {
options.static = [getStaticItem(options.static)];
} else if (Array.isArray(options.static)) {
options.static = options.static.map((item) => {
if (typeof item === 'string') {
return getStaticItem(item);
}
return getStaticItem(item);
});
} else {
options.static = [getStaticItem(options.static)];
}
if (typeof options.watchFiles === 'string') {
options.watchFiles = [{ paths: options.watchFiles, options: getWatchOptions() }];
} else if (
typeof options.watchFiles === 'object' &&
options.watchFiles !== null &&
!Array.isArray(options.watchFiles)
) {
options.watchFiles = [
{
paths: options.watchFiles.paths,
options: getWatchOptions(options.watchFiles.options || {}),
},
];
} else if (Array.isArray(options.watchFiles)) {
options.watchFiles = options.watchFiles.map((item) => {
if (typeof item === 'string') {
return { paths: item, options: getWatchOptions() };
}
return {
paths: item.paths,
options: getWatchOptions(item.options || {}),
};
});
} else {
options.watchFiles = [];
}
options.host = await Server.getHostname(options.host);
options.port = await Server.getFreePort(options.port);
}
setupProgressPlugin() {
const { ProgressPlugin } = this.compiler.webpack || require('webpack');
new ProgressPlugin((percent, msg, addInfo, pluginName) => {
percent = Math.floor(percent * 100);
if (percent === 100) {
msg = 'Compilation completed';
}
if (addInfo) {
msg = `${msg} (${addInfo})`;
}
if (this.webSocketClient) {
this.sendMessage({
messages: [
{
type: HMREvent.ProgressUpdate,
data: {
percent,
msg,
pluginName,
},
},
],
});
}
if (this.server) {
this.server.emit(HMREvent.ProgressUpdate, { percent, msg, pluginName });
}
}).apply(this.compiler);
}
async initialize() {
const compilers = this.compiler.compilers || [this.compiler];
compilers.forEach((compiler) => {
this.addAdditionalEntries(compiler);
const webpack = compiler.webpack || require('webpack');
// TODO remove after drop webpack v4 support
compiler.options.plugins = compiler.options.plugins || [];
if (this.options.hot) {
const HMRPluginExists = compiler.options.plugins.find(
(p) => p.constructor === webpack.HotModuleReplacementPlugin,
);
if (HMRPluginExists) {
this.logger.warn(
'"hot: true" automatically applies HMR plugin, you don\'t have to add it manually to your webpack configuration.',
);
} else {
// Apply the HMR plugin
const plugin = new webpack.HotModuleReplacementPlugin();
plugin.apply(compiler);
}
}
});
if (this.options.client && this.options.client.progress) {
this.setupProgressPlugin();
}
this.setupAdbReverse();
this.setupHooks();
this.setupApp();
this.setupHostHeaderCheck();
this.setupDevMiddleware();
// Should be after `webpack-dev-middleware`, otherwise other middlewares might rewrite response
this.setupBuiltInRoutes();
this.setupWatchFiles();
this.setupFeatures();
this.createServer();
if (this.options.setupExitSignals) {
const signals = ['SIGINT', 'SIGTERM'];
let needForceShutdown = false;
signals.forEach((signal) => {
const listener = () => {
if (needForceShutdown) {
// eslint-disable-next-line no-process-exit
process.exit();
}
this.logger.info('Gracefully shutting down. To force exit, press ^C again. Please wait...');
needForceShutdown = true;
this.stopCallback(() => {
if (typeof this.compiler.close === 'function') {
this.compiler.close(() => {
// eslint-disable-next-line no-process-exit
process.exit();
});
} else {
// eslint-disable-next-line no-process-exit
process.exit();
}
});
};
this.listeners.push({ name: signal, listener });
process.on(signal, listener);
});
}
}
setupApp() {
// Init express server
// eslint-disable-next-line new-cap
this.app = new express();
}
getWebpackStats(statsObj) {
const stats = Server.DEFAULT_STATS;
const compilerOptions = this.getCompilerOptions();
if (compilerOptions.stats && compilerOptions.stats.warningsFilter) {
stats.warningsFilter = compilerOptions.stats.warningsFilter;
}
return statsObj.toJson(stats);
}
async setupAdbReverse() {
await saveDevPort(this.options.port);
await startAdbProxy();
}
setupHooks() {
this.compiler.hooks.failed.tap('webpack-dev-server', (error) => {
this.cb(error);
});
this.compiler.hooks.invalid.tap('webpack-dev-server', () => {
this.sendMessage({
messages: [
{
type: HMREvent.Invalid,
},
],
});
});
this.compiler.hooks.done.tap('webpack-dev-server', async (stats) => {
if (!this.webSocketClient) {
await this.createWebSocketClient();
}
this.stats = stats;
this.cb(null, stats);
this.sendStatsWithOption(this.getWebpackStats(stats));
});
this.setupEmitHooks();
}
setupEmitHooks() {
const { compiler } = this;
compiler.hooks.emit.tap('webpack-dev-server', (compilation) => {
this.emitMap = new Map();
compiler.hooks.assetEmitted.tap('webpack-dev-server', (file, info) => {
let content = null;
let targetName = null;
if (info.compilation) {
({ targetPath: targetName, content } = info);
} else {
let targetFile = file;
const queryStringIdx = targetFile.indexOf('?');
if (queryStringIdx >= 0) {
targetFile = targetFile.substr(0, queryStringIdx);
}
targetName = targetFile;
content = info;
}
/**
* targetName may be absolute or relative path
* target file name must be relative to outputPath
*/
const name = path.relative(this.compiler.outputPath, path.resolve(this.compiler.outputPath, targetName))
this.emitMap.set(targetName, {
name,
content: content,
isHMRResource: (/hot-update\.js(on)?(\.map)?$/.test(file)),
});
});
});
}
setupHostHeaderCheck() {
this.app.all('*', (req, res, next) => {
if (this.checkHeader(req.headers, 'host')) {
return next();
}
res.send('Invalid Host header');
});
}
setupDevMiddleware() {
const webpackDevMiddleware = require('webpack-dev-middleware');
// middleware for serving webpack bundle
this.middleware = webpackDevMiddleware(this.compiler, this.options.devMiddleware);
}
setupBuiltInRoutes() {
const { app, middleware } = this;
app.get('/__webpack_dev_server__/sockjs.bundle.js', (req, res) => {
res.setHeader('Content-Type', 'application/javascript');
const { createReadStream } = fs;
const clientPath = path.join(__dirname, '..', 'client');
createReadStream(path.join(clientPath, 'modules/sockjs-client/index.js')).pipe(res);
});
app.get('/webpack-dev-server/invalidate', (_req, res) => {
this.invalidate();
res.end();
});
app.get('/webpack-dev-server', (req, res) => {
middleware.waitUntilValid((stats) => {
res.setHeader('Content-Type', 'text/html');
res.write('<!DOCTYPE html><html><head><meta charset="utf-8"/></head><body>');
const statsForPrint = typeof stats.stats !== 'undefined' ? stats.toJson().children : [stats.toJson()];
res.write('<h1>Assets Report:</h1>');
statsForPrint.forEach((item, index) => {
res.write('<div>');
const name = item.name || (stats.stats ? `unnamed[${index}]` : 'unnamed');
res.write(`<h2>Compilation: ${name}</h2>`);
res.write('<ul>');
const publicPath = item.publicPath === 'auto' ? '' : item.publicPath;
for (const asset of item.assets) {
const assetName = asset.name;
const assetURL = `${publicPath}${assetName}`;
res.write(`<li>
<strong><a href="${assetURL}" target="_blank">${assetName}</a></strong>
</li>`);
}
res.write('</ul>');
res.write('</div>');
});
res.end('</body></html>');
});
});
}
setupCompressFeature() {
const compress = require('compression');
this.app.use(compress());
}
setupHistoryApiFallbackFeature() {
const { historyApiFallback } = this.options;
if (typeof historyApiFallback.logger === 'undefined' && !historyApiFallback.verbose) {
historyApiFallback.logger = this.logger.log.bind(this.logger, '[connect-history-api-fallback]');
}
// Fall back to /index.html if nothing else matches.
this.app.use(require('connect-history-api-fallback')(historyApiFallback));
}
setupStaticFeature() {
this.options.static.forEach((staticOption) => {
staticOption.publicPath.forEach((publicPath) => {
this.app.use(publicPath, express.static(staticOption.directory, staticOption.staticOptions));
});
});
}
setupStaticServeIndexFeature() {
const serveIndex = require('serve-index');
this.options.static.forEach((staticOption) => {
staticOption.publicPath.forEach((publicPath) => {
if (staticOption.serveIndex) {
this.app.use(publicPath, (req, res, next) => {
// serve-index doesn't fallthrough non-get/head request to next middleware
if (req.method !== 'GET' && req.method !== 'HEAD') {
return next();
}
serveIndex(staticOption.directory, staticOption.serveIndex)(req, res, next);
});
}
});
});
}
setupStaticWatchFeature() {
this.options.static.forEach((staticOption) => {
if (staticOption.watch) {
this.watchFiles(staticOption.directory, staticOption.watch);
}
});
}
setupOnBeforeSetupMiddlewareFeature() {
this.options.onBeforeSetupMiddleware(this);
}
setupWatchFiles() {
const { watchFiles } = this.options;
if (watchFiles.length > 0) {
watchFiles.forEach((item) => {
this.watchFiles(item.paths, item.options);
});
}
}
setupMiddleware() {
this.app.use(this.middleware);
}
setupOnAfterSetupMiddlewareFeature() {
this.options.onAfterSetupMiddleware(this);
}
setupHeadersFeature() {
this.app.all('*', this.setHeaders.bind(this));
}
setupFeatures() {
const features = {
compress: () => {
if (this.options.compress) {
this.setupCompressFeature();
}
},
historyApiFallback: () => {
if (this.options.historyApiFallback) {
this.setupHistoryApiFallbackFeature();
}
},
static: () => {
this.setupStaticFeature();
},
staticServeIndex: () => {
this.setupStaticServeIndexFeature();
},
staticWatch: () => {
this.setupStaticWatchFeature();
},
onBeforeSetupMiddleware: () => {
if (typeof this.options.onBeforeSetupMiddleware === 'function') {
this.setupOnBeforeSetupMiddlewareFeature();
}
},
onAfterSetupMiddleware: () => {
if (typeof this.options.onAfterSetupMiddleware === 'function') {
this.setupOnAfterSetupMiddlewareFeature();
}
},
middleware: () => {
// include our middleware to ensure
// it is able to handle '/index.html' request after redirect
this.setupMiddleware();
},
headers: () => {
this.setupHeadersFeature();
},
};
const runnableFeatures = [];
// compress is placed last and uses unshift so that it will be the first middleware used
if (this.options.compress) {
runnableFeatures.push('compress');
}
if (this.options.onBeforeSetupMiddleware) {
runnableFeatures.push('onBeforeSetupMiddleware');
}
runnableFeatures.push('headers', 'middleware');
if (this.options.static) {
runnableFeatures.push('static');
}
if (this.options.historyApiFallback) {
runnableFeatures.push('historyApiFallback', 'middleware');
if (this.options.static) {
runnableFeatures.push('static');
}
}
if (this.options.static) {
runnableFeatures.push('staticServeIndex', 'staticWatch');
}
if (this.options.onAfterSetupMiddleware) {
runnableFeatures.push('onAfterSetupMiddleware');
}
runnableFeatures.forEach((feature) => {
features[feature]();
});
}
createServer() {
// eslint-disable-next-line import/no-dynamic-require
this.server = require(this.options.server.type).createServer(this.options.server.options, this.app);
this.server.on('connection', (socket) => {
// Add socket to list
this.sockets.push(socket);
socket.once('close', () => {
// Remove socket from list
this.sockets.splice(this.sockets.indexOf(socket), 1);
});
});
this.server.on('error', (error) => {
this.logger.error('start hippy dev server error: ', error);
throw error;
});
}
createWebSocketClient() {
if (!this.options.remote) return;
return new Promise((resolve, reject) => {
const { host, port, protocol, proxy } = this.options.remote;
const webSocketURL = `${getWSProtocolByHttpProtocol(
protocol,
)}://${host}:${port}/debugger-proxy?role=hmr_server&hash=${this.options.id}`;
this.webSocketClient = new WebSocket(webSocketURL);
this.webSocketClient.on('open', () => {
this.logger.info('HMR websocket client is connected.');
this.msgQueue.map((hmrData) => this.sendMessage(hmrData));
this.msgQueue = [];
resolve();
});
this.webSocketClient.on('ping', () => {
this.webSocketClient.pong();
});
this.webSocketClient.on('close', (code, reason) => {
this.logger.warn(
`HMR websocket is closed(${code}), will try to reconnect when you modify source code. ${reason}`,
);
});
this.webSocketClient.on('error', (e) => {
this.logger.warn('HMR websocket error: ', e);
if (host === '127.0.0.1' || host === 'localhost') {
this.logger.warn(
'Hippy use @hippy/debug-server-next to transit HMR message, connect to debug server failed, recommend to run `npm run hippy:debug` first!',
);
}
if (e.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
this.logger.info(
`if you are behind a proxy server(such as whistle, charles), you should run 'export NODE_EXTRA_CA_CERTS=<path_to_whistle_rootCA>' first to resolve full key chain!`,
);
}
if (this.webSocketClient.readyState === WebSocket.CLOSING) reject();
});
})
.catch(e => {});
}
async sendStatsWithOption() {
if (!this.stats) {
return;
}
const stats = this.getWebpackStats(this.stats);
const messages = [
{
type: HMREvent.Hash,
data: stats.hash,
},
];
const { hmrResources, otherResources } = this.getEmitList();
const allResource = [...otherResources, ...hmrResources];
if (allResource.length === 0) return;
const syncQueue = [];
/**
* hot reload will always sync all file
* hmr will sync all file at first time, in other case will sync patch files in priority
*/
if (this.hadSyncBundleResource && this.options.hot) {
syncQueue.push(hmrResources, otherResources);
} else {
syncQueue.push(allResource);
}
const hmrData = {
emitList: syncQueue.shift(),
messages,
};
if (this.options.hot === true || this.options.hot === 'only') {
this.logger.info('enable HMR');
}
if (this.options.liveReload) {
this.logger.info('enable live reload');
}
const shouldEmit =
stats &&
(!stats.errors || stats.errors.length === 0) &&
(!stats.warnings || stats.warnings.length === 0) &&
this.currentHash === stats.hash;
if (shouldEmit) {
messages.push({
type: HMREvent.StillOk,
});
delete hmrData.emitList;
} else {
this.currentHash = stats.hash;
if (stats.errors.length > 0 || stats.warnings.length > 0) {
const hasErrors = stats.errors.length > 0;
if (stats.warnings.length > 0) {
let params;
if (hasErrors) {
params = { preventReloading: true };
}
messages.push({
type: HMREvent.Warnings,
data: stats.warnings,
params,
});
}
if (stats.errors.length > 0) {
messages.push({
type: HMREvent.Errors,
data: stats.errors,
});
}
} else {
messages.push({
type: HMREvent.Ok,
});
}
}
this.sendMessage({
...hmrData,
hadSyncBundleResource: this.hadSyncBundleResource,
});
if (syncQueue.length) {
this.sendMessage({
emitList: syncQueue.pop(),
hadSyncBundleResource: true,
});
}
this.hadSyncBundleResource = true;
}
openBrowser(defaultOpenTarget) {
const open = require('open');
Promise.all(
this.options.open.map((item) => {
let openTarget;
if (item.target === '<url>') {
openTarget = defaultOpenTarget;
} else {
openTarget = Server.isAbsoluteURL(item.target)
? item.target
: new URL(item.target, defaultOpenTarget).toString();
}
return open(openTarget, item.options).catch(() => {
this.logger.warn(
`Unable to open "${openTarget}" page${
// eslint-disable-next-line no-nested-ternary
item.options.app
? ` in "${item.options.app.name}" app${
item.options.app.arguments ? ` with "${item.options.app.arguments.join(' ')}" arguments` : ''
}`
: ''
}. If you are running in a headless environment, please do not use the "open" option or related flags like "--open", "--open-target", and "--open-app".`,
);
});
}),
);
}
stopBonjour(callback = () => {}) {
this.bonjour.unpublishAll(() => {
this.bonjour.destroy();
if (callback) {
callback();
}
});
}
runBonjour() {
this.bonjour = require('bonjour')();
this.bonjour.publish({
name: `Webpack Dev Server ${os.hostname()}:${this.options.port}`,
port: this.options.port,
type: this.options.server.type === 'http' ? 'http' : 'https',
subtypes: ['webpack'],
...this.options.bonjour,
});
}
logStatus() {
const protocol = this.options.server.type === 'http' ? 'http' : 'https';
const { address, port } = this.server.address();
const prettyPrintURL = (newHostname) => url.format({ protocol, hostname: newHostname, port, pathname: '/' });
let server;
let localhost;
let loopbackIPv4;
let loopbackIPv6;
let networkUrlIPv4;
let networkUrlIPv6;
if (this.options.host) {
if (this.options.host === 'localhost') {
localhost = prettyPrintURL('localhost');
} else {
let isIP;
try {
isIP = ipaddr.parse(this.options.host);
} catch (error) {
// Ignore
}
if (!isIP) {
server = prettyPrintURL(this.options.host);
}
}
}
const parsedIP = ipaddr.parse(address);
if (parsedIP.range() === 'unspecified') {
localhost = prettyPrintURL('localhost');
const networkIPv4 = internalIPSync(GatewayFamily.V4);
if (networkIPv4) {
networkUrlIPv4 = prettyPrintURL(networkIPv4);
}
const networkIPv6 = internalIPSync(GatewayFamily.V6);
if (networkIPv6) {
networkUrlIPv6 = prettyPrintURL(networkIPv6);
}
} else if (parsedIP.range() === 'loopback') {
if (parsedIP.kind() === 'ipv4') {
loopbackIPv4 = prettyPrintURL(parsedIP.toString());
} else if (parsedIP.kind() === 'ipv6') {
loopbackIPv6 = prettyPrintURL(parsedIP.toString());
}
} else {
networkUrlIPv4 =
parsedIP.kind() === 'ipv6' && parsedIP.isIPv4MappedAddress()
? prettyPrintURL(parsedIP.toIPv4Address().toString())
: prettyPrintURL(address);
if (parsedIP.kind() === 'ipv6') {
networkUrlIPv6 = prettyPrintURL(address);
}
}
this.logger.info('Project is running at:');
if (server) {
this.logger.info(`Server: ${colors.cyan(server)}`);
}
if (localhost || loopbackIPv4 || loopbackIPv6) {
const loopbacks = []
.concat(localhost ? [colors.cyan(localhost)] : [])
.concat(loopbackIPv4 ? [colors.cyan(loopbackIPv4)] : [])
.concat(loopbackIPv6 ? [colors.cyan(loopbackIPv6)] : []);
this.logger.info(`Loopback: ${loopbacks.join(', ')}`);
}
if (networkUrlIPv4) {
this.logger.info(`On Your Network (IPv4): ${colors.cyan(networkUrlIPv4)}`);
}
if (networkUrlIPv6) {
this.logger.info(`On Your Network (IPv6): ${colors.cyan(networkUrlIPv6)}`);
}
if (this.options.open.length > 0) {
const openTarget = prettyPrintURL(this.options.host || 'localhost');
this.openBrowser(openTarget);
}
if (this.options.static && this.options.static.length > 0) {
this.logger.info(
`Content not from webpack is served from '${colors.cyan(
this.options.static.map((staticOption) => staticOption.directory).join(', '),
)}' directory`,
);
}
if (this.options.historyApiFallback) {
this.logger.info(
`404s will fallback to '${colors.cyan(this.options.historyApiFallback.index || '/index.html')}'`,
);
}
if (this.options.bonjour) {
const bonjourProtocol = this.options.bonjour.type || this.options.server.type === 'http' ? 'http' : 'https';
this.logger.info(`Broadcasting "${bonjourProtocol}" with subtype of "webpack" via ZeroConf DNS (Bonjour)`);
}
}
setHeaders(req, res, next) {
let { headers } = this.options;
if (headers) {
if (typeof headers === 'function') {
headers = headers(req, res, this.middleware.context);
}
const allHeaders = [];
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;
}
headers.forEach((header) => {
res.setHeader(header.key, header.value);
});
}
next();
}
checkHeader(headers, headerToCheck) {
// allow user to opt out of this security check, at their own risk
// by explicitly enabling allowedHosts
if (this.options.allowedHosts === 'all') {
return true;
}
// get the Host header and extract hostname
// we don't care about port not matching
const hostHeader = headers[headerToCheck];
if (!hostHeader) {
return false;
}
if (/^(file|.+-extension):/i.test(hostHeader)) {
return true;
}
// use the node url-parser to retrieve the hostname from the host-header.
const { hostname } = url.parse(
// if hostHeader doesn't have scheme, add // for parsing.
/^(.+:)?\/\//.test(hostHeader) ? hostHeader : `//${hostHeader}`,
false,
true,
);
// always allow requests with explicit IPv4 or IPv6-address.
// A note on IPv6 addresses:
// hostHeader will always contain the brackets denoting
// an IPv6-address in URLs,
// these are removed from the hostname in url.parse(),
// so we have the pure IPv6-address in hostname.
// always allow localhost host, for convenience (hostname === 'localhost')
// allow hostname of listening address (hostname === this.options.host)
const isValidHostname =
ipaddr.IPv4.isValid(hostname) ||
ipaddr.IPv6.isValid(hostname) ||
hostname === 'localhost' ||
hostname === this.options.host;
if (isValidHostname) {
return true;
}
const { allowedHosts } = this.options;
// always allow localhost host, for convenience
// allow if hostname is in allowedHosts
if (Array.isArray(allowedHosts) && allowedHosts.length > 0) {
for (let hostIdx = 0; hostIdx < allowedHosts.length; hostIdx++) {
const allowedHost = allowedHosts[hostIdx];
if (allowedHost === hostname) {
return true;
}
// support "." as a subdomain wildcard
// e.g. ".example.com" will allow "example.com", "www.example.com", "subdomain.example.com", etc