UNPKG

webpack-plugin-serve

Version:
284 lines (238 loc) 8.37 kB
/* Copyright © 2018 Andrew Powell This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. The above copyright notice and this permission notice shall be included in all copies or substantial portions of this Source Code Form. */ const EventEmitter = require('events'); const { existsSync } = require('fs'); const { join } = require('path'); const chalk = require('chalk'); const globby = require('globby'); const Koa = require('koa'); const { customAlphabet } = require('nanoid'); const { DefinePlugin, ProgressPlugin } = require('webpack'); const { init: initHmrPlugin } = require('./plugins/hmr'); const { init: initRamdiskPlugin } = require('./plugins/ramdisk'); const { forceError, getLogger } = require('./log'); const { start } = require('./server'); const { validate } = require('./validate'); const defaults = { // leave `client` undefined // client: null, compress: null, headers: null, historyFallback: false, hmr: true, host: null, liveReload: false, log: { level: 'info' }, middleware: () => {}, open: false, port: 55555, progress: true, publicPath: null, ramdisk: false, secure: false, static: null, status: true }; const key = 'webpack-plugin-serve'; const newline = () => console.log(); // eslint-disable-line no-console const nanoid = customAlphabet('1234567890abcdef', 7); let instance = null; // TODO: test this on a multicompiler setup class WebpackPluginServe extends EventEmitter { constructor(opts = {}) { super(); const valid = validate(opts); if (valid.error) { forceError('An option was passed to WebpackPluginServe that is not valid'); throw valid.error; } // NOTE: undocumented option. this is used primarily in testing to allow for multiple instances // of the plugin to be tested within the same context. If you find this, use this at your own // peril. /* istanbul ignore if */ if (!opts.allowMany && instance) { instance.log.error( 'Duplicate instances created. Only the first instance of this plugin will be active.' ); return; } instance = this; const options = Object.assign({}, defaults, opts); if (options.compress === true) { options.compress = {}; } if (options.historyFallback === true) { options.historyFallback = {}; } // if the user has set this to a string, rewire it as a function // host and port are setup like this to allow passing a function for each to the options, which // returns a promise if (typeof options.host === 'string') { const { host } = options; options.host = { then(r) { r(host); } }; } if (Number.isInteger(options.port)) { const { port } = options; options.port = { then(r) { r(port); } }; } if (!options.static) { options.static = []; } else if (options.static.glob) { const { glob, options: globOptions = {} } = options.static; options.static = globby.sync(glob, globOptions); } this.app = new Koa(); this.log = getLogger(options.log || {}); this.options = options; this.compilers = []; this.state = {}; } apply(compiler) { this.compiler = compiler; // only allow once instance of the plugin to run for a build /* istanbul ignore if */ if (instance !== this) { return; } this.hook(compiler); } // eslint-disable-next-line class-methods-use-this attach() { const self = this; const result = { apply(compiler) { return self.hook(compiler); } }; return result; } // #138. handle emitted events that don't have a listener registered so they can be sent via WebSocket emit(eventName, ...args) { const listeners = this.eventNames(); if (listeners.includes(eventName)) { super.emit(eventName, ...args); } else { // #144. don't send the watchClose event to the client if (eventName === 'close') { return; } const [data] = args; super.emit('unhandled', { eventName, data }); } } hook(compiler) { const { done, invalid, watchClose, watchRun } = compiler.hooks; if (!compiler.wpsId) { // eslint-disable-next-line no-param-reassign compiler.wpsId = nanoid(); } if (!compiler.name && !compiler.options.name) { // eslint-disable-next-line no-param-reassign compiler.options.name = this.compilers.length.toString(); this.compilers.push(compiler); } if (this.options.hmr) { initHmrPlugin(compiler, this.log); } if (this.options.ramdisk) { initRamdiskPlugin.call(this, compiler, this.options.ramdisk); } if (!this.options.static.length) { this.options.static.push(compiler.context); } // check static paths for publicPath. #100 const publicPath = this.options.publicPath === null ? compiler.options.output.publicPath : this.options.publicPath; if (publicPath) { let foundPath = false; for (const path of this.options.static) { const joined = join(path, publicPath); if (existsSync(joined)) { foundPath = true; break; } } /* istanbul ignore next */ if (!foundPath) { this.log.warn( chalk`{bold {yellow Warning}} The value of {yellow \`publicPath\`} was not found on the filesystem in any static paths specified\n` ); } } // we do this emit because webpack caches and optimizes the hooks, so there's no way to detach // a listener/hook. done.tap(key, (stats) => this.emit('done', stats, compiler)); invalid.tap(key, (filePath) => this.emit('invalid', filePath, compiler)); watchClose.tap(key, () => this.emit('close', compiler)); if (this.options.waitForBuild) { // track the first build of the bundle this.state.compiling = new Promise((resolve) => { this.once('done', () => resolve()); }); // track subsequent builds from watching this.on('invalid', () => { /* istanbul ignore next */ this.state.compiling = new Promise((resolve) => { this.once('done', () => resolve()); }); }); } compiler.hooks.compilation.tap(key, (compilation) => { compilation.hooks.afterHash.tap(key, () => { // webpack still has a 4 year old bug whereby in watch mode, file timestamps aren't properly // accounted for, which will trigger multiple builds of the same hash. // see: https://github.com/egoist/time-fix-plugin /* istanbul ignore if */ if (this.lastHash === compilation.hash) { return; } this.lastHash = compilation.hash; this.emit('build', compiler.name, compiler); }); }); watchRun.tapPromise(key, async () => { if (!this.state.starting) { // ensure we're only trying to start the server once this.state.starting = start.bind(this)(); this.state.starting.then(() => newline()); } // wait for the server to startup so we can get our client connection info from it await this.state.starting; const compilerData = { // only set the compiler name if we're dealing with more than one compiler. otherwise, the // user doesn't need the additional feedback in the console compilerName: this.compilers.length > 1 ? compiler.options.name : null, wpsId: compiler.wpsId }; const defineObject = Object.assign({}, this.options, compilerData); const defineData = { ʎɐɹɔosǝʌɹǝs: JSON.stringify(defineObject) }; const definePlugin = new DefinePlugin(defineData); definePlugin.apply(compiler); if (this.options.progress) { const progressPlugin = new ProgressPlugin((percent, message, misc) => { // pass the data onto the client raw. connected sockets may want to interpret the data // differently this.emit('progress', { percent, message, misc }, compiler); }); progressPlugin.apply(compiler); } }); } } module.exports = { defaults, WebpackPluginServe };