UNPKG

8.37 kBJavaScriptView Raw
1/*
2 Copyright © 2018 Andrew Powell
3
4 This Source Code Form is subject to the terms of the Mozilla Public
5 License, v. 2.0. If a copy of the MPL was not distributed with this
6 file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
8 The above copyright notice and this permission notice shall be
9 included in all copies or substantial portions of this Source Code Form.
10*/
11const EventEmitter = require('events');
12const { existsSync } = require('fs');
13const { join } = require('path');
14
15const chalk = require('chalk');
16const globby = require('globby');
17const Koa = require('koa');
18const { customAlphabet } = require('nanoid');
19const { DefinePlugin, ProgressPlugin } = require('webpack');
20
21const { init: initHmrPlugin } = require('./plugins/hmr');
22const { init: initRamdiskPlugin } = require('./plugins/ramdisk');
23const { forceError, getLogger } = require('./log');
24const { start } = require('./server');
25const { validate } = require('./validate');
26
27const defaults = {
28 // leave `client` undefined
29 // client: null,
30 compress: null,
31 headers: null,
32 historyFallback: false,
33 hmr: true,
34 host: null,
35 liveReload: false,
36 log: { level: 'info' },
37 middleware: () => {},
38 open: false,
39 port: 55555,
40 progress: true,
41 publicPath: null,
42 ramdisk: false,
43 secure: false,
44 static: null,
45 status: true
46};
47
48const key = 'webpack-plugin-serve';
49const newline = () => console.log(); // eslint-disable-line no-console
50const nanoid = customAlphabet('1234567890abcdef', 7);
51
52let instance = null;
53
54// TODO: test this on a multicompiler setup
55class WebpackPluginServe extends EventEmitter {
56 constructor(opts = {}) {
57 super();
58
59 const valid = validate(opts);
60
61 if (valid.error) {
62 forceError('An option was passed to WebpackPluginServe that is not valid');
63 throw valid.error;
64 }
65
66 // NOTE: undocumented option. this is used primarily in testing to allow for multiple instances
67 // of the plugin to be tested within the same context. If you find this, use this at your own
68 // peril.
69 /* istanbul ignore if */
70 if (!opts.allowMany && instance) {
71 instance.log.error(
72 'Duplicate instances created. Only the first instance of this plugin will be active.'
73 );
74 return;
75 }
76
77 instance = this;
78
79 const options = Object.assign({}, defaults, opts);
80
81 if (options.compress === true) {
82 options.compress = {};
83 }
84
85 if (options.historyFallback === true) {
86 options.historyFallback = {};
87 }
88
89 // if the user has set this to a string, rewire it as a function
90 // host and port are setup like this to allow passing a function for each to the options, which
91 // returns a promise
92 if (typeof options.host === 'string') {
93 const { host } = options;
94 options.host = {
95 then(r) {
96 r(host);
97 }
98 };
99 }
100
101 if (Number.isInteger(options.port)) {
102 const { port } = options;
103 options.port = {
104 then(r) {
105 r(port);
106 }
107 };
108 }
109
110 if (!options.static) {
111 options.static = [];
112 } else if (options.static.glob) {
113 const { glob, options: globOptions = {} } = options.static;
114 options.static = globby.sync(glob, globOptions);
115 }
116
117 this.app = new Koa();
118 this.log = getLogger(options.log || {});
119 this.options = options;
120 this.compilers = [];
121 this.state = {};
122 }
123
124 apply(compiler) {
125 this.compiler = compiler;
126
127 // only allow once instance of the plugin to run for a build
128 /* istanbul ignore if */
129 if (instance !== this) {
130 return;
131 }
132
133 this.hook(compiler);
134 }
135
136 // eslint-disable-next-line class-methods-use-this
137 attach() {
138 const self = this;
139 const result = {
140 apply(compiler) {
141 return self.hook(compiler);
142 }
143 };
144 return result;
145 }
146
147 // #138. handle emitted events that don't have a listener registered so they can be sent via WebSocket
148 emit(eventName, ...args) {
149 const listeners = this.eventNames();
150
151 if (listeners.includes(eventName)) {
152 super.emit(eventName, ...args);
153 } else {
154 // #144. don't send the watchClose event to the client
155 if (eventName === 'close') {
156 return;
157 }
158 const [data] = args;
159 super.emit('unhandled', { eventName, data });
160 }
161 }
162
163 hook(compiler) {
164 const { done, invalid, watchClose, watchRun } = compiler.hooks;
165
166 if (!compiler.wpsId) {
167 // eslint-disable-next-line no-param-reassign
168 compiler.wpsId = nanoid();
169 }
170
171 if (!compiler.name && !compiler.options.name) {
172 // eslint-disable-next-line no-param-reassign
173 compiler.options.name = this.compilers.length.toString();
174 this.compilers.push(compiler);
175 }
176
177 if (this.options.hmr) {
178 initHmrPlugin(compiler, this.log);
179 }
180
181 if (this.options.ramdisk) {
182 initRamdiskPlugin.call(this, compiler, this.options.ramdisk);
183 }
184
185 if (!this.options.static.length) {
186 this.options.static.push(compiler.context);
187 }
188
189 // check static paths for publicPath. #100
190 const publicPath =
191 this.options.publicPath === null
192 ? compiler.options.output.publicPath
193 : this.options.publicPath;
194 if (publicPath) {
195 let foundPath = false;
196 for (const path of this.options.static) {
197 const joined = join(path, publicPath);
198 if (existsSync(joined)) {
199 foundPath = true;
200 break;
201 }
202 }
203
204 /* istanbul ignore next */
205 if (!foundPath) {
206 this.log.warn(
207 chalk`{bold {yellow Warning}} The value of {yellow \`publicPath\`} was not found on the filesystem in any static paths specified\n`
208 );
209 }
210 }
211
212 // we do this emit because webpack caches and optimizes the hooks, so there's no way to detach
213 // a listener/hook.
214 done.tap(key, (stats) => this.emit('done', stats, compiler));
215 invalid.tap(key, (filePath) => this.emit('invalid', filePath, compiler));
216 watchClose.tap(key, () => this.emit('close', compiler));
217
218 if (this.options.waitForBuild) {
219 // track the first build of the bundle
220 this.state.compiling = new Promise((resolve) => {
221 this.once('done', () => resolve());
222 });
223
224 // track subsequent builds from watching
225 this.on('invalid', () => {
226 /* istanbul ignore next */
227 this.state.compiling = new Promise((resolve) => {
228 this.once('done', () => resolve());
229 });
230 });
231 }
232
233 compiler.hooks.compilation.tap(key, (compilation) => {
234 compilation.hooks.afterHash.tap(key, () => {
235 // webpack still has a 4 year old bug whereby in watch mode, file timestamps aren't properly
236 // accounted for, which will trigger multiple builds of the same hash.
237 // see: https://github.com/egoist/time-fix-plugin
238 /* istanbul ignore if */
239 if (this.lastHash === compilation.hash) {
240 return;
241 }
242 this.lastHash = compilation.hash;
243 this.emit('build', compiler.name, compiler);
244 });
245 });
246
247 watchRun.tapPromise(key, async () => {
248 if (!this.state.starting) {
249 // ensure we're only trying to start the server once
250 this.state.starting = start.bind(this)();
251 this.state.starting.then(() => newline());
252 }
253
254 // wait for the server to startup so we can get our client connection info from it
255 await this.state.starting;
256
257 const compilerData = {
258 // only set the compiler name if we're dealing with more than one compiler. otherwise, the
259 // user doesn't need the additional feedback in the console
260 compilerName: this.compilers.length > 1 ? compiler.options.name : null,
261 wpsId: compiler.wpsId
262 };
263
264 const defineObject = Object.assign({}, this.options, compilerData);
265 const defineData = { ʎɐɹɔosǝʌɹǝs: JSON.stringify(defineObject) };
266 const definePlugin = new DefinePlugin(defineData);
267
268 definePlugin.apply(compiler);
269
270 if (this.options.progress) {
271 const progressPlugin = new ProgressPlugin((percent, message, misc) => {
272 // pass the data onto the client raw. connected sockets may want to interpret the data
273 // differently
274 this.emit('progress', { percent, message, misc }, compiler);
275 });
276
277 progressPlugin.apply(compiler);
278 }
279 });
280 }
281}
282
283module.exports = { defaults, WebpackPluginServe };